diff --git a/package-lock.json b/package-lock.json index 70ff682..2372238 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1806,7 +1806,6 @@ "url": "https://opencollective.com/csstools" } ], - "peer": true, "engines": { "node": ">=18" }, @@ -1829,7 +1828,6 @@ "url": "https://opencollective.com/csstools" } ], - "peer": true, "engines": { "node": ">=18" } @@ -4511,7 +4509,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4746,7 +4743,8 @@ "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@types/react": { "version": "18.3.23", @@ -5180,7 +5178,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", @@ -5909,7 +5906,6 @@ "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", "dev": true, "hasInstallScript": true, - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -6917,7 +6913,6 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, - "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -7352,7 +7347,6 @@ "integrity": "sha512-npfIIVRHKQX3Lw4aLWX4wBh+lQwpqdZNyJYB5K/+ktK8NhtkdsTxGK7WDrgknozcVyRI7TOqY6yBS9j2FTR+YQ==", "dev": true, "hasInstallScript": true, - "peer": true, "dependencies": { "@bundled-es-modules/cookie": "^2.0.1", "@bundled-es-modules/statuses": "^1.0.1", @@ -8072,7 +8066,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8115,7 +8108,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dev": true, - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8369,7 +8361,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.2.tgz", "integrity": "sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==", "dev": true, - "peer": true, "dependencies": { "@types/estree": "1.0.7" }, @@ -8635,7 +8626,6 @@ "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.6.12.tgz", "integrity": "sha512-Z/nWYEHBTLK1ZBtAWdhxC0l5zf7ioJ7G4+zYqtTdYeb67gTnxNj80gehf8o8QY9L2zA2+eyMRGLC2V5fI7Z3Tw==", "dev": true, - "peer": true, "dependencies": { "@storybook/core": "8.6.12" }, @@ -9217,7 +9207,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 0000000..fead0b3 --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,295 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const PACKAGE_VERSION = '2.6.6' +const INTEGRITY_CHECKSUM = 'ca7800994cc8bfb5eb961e037c877074' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId)) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseClone.body], + ) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + headers.delete('accept', 'msw/passthrough') + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer() + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage( + message, + [channel.port2].concat(transferrables.filter(Boolean)), + ) + }) +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} diff --git a/src/components/tokenRetriever/LtiTokenRetriever.jsx b/src/components/tokenRetriever/LtiTokenRetriever.jsx index eb70931..8a76f74 100644 --- a/src/components/tokenRetriever/LtiTokenRetriever.jsx +++ b/src/components/tokenRetriever/LtiTokenRetriever.jsx @@ -64,6 +64,13 @@ export const LtiTokenRetriever = ({ ltiServer, handleJwt, children, location = w saveJwt(jwt); setState({ loading: false, error: null }); } catch (error) { + // Try to get cached JWT before failing + const cachedJwt = loadJwt(); + if (cachedJwt) { + handleJwt(cachedJwt, server); + setState({ loading: false, error: null }); + return; + } setState({ loading: false, error: error.message }); } }; diff --git a/src/components/tokenRetriever/LtiTokenRetriever.test.jsx b/src/components/tokenRetriever/LtiTokenRetriever.test.jsx index aaf8a0f..c57792f 100644 --- a/src/components/tokenRetriever/LtiTokenRetriever.test.jsx +++ b/src/components/tokenRetriever/LtiTokenRetriever.test.jsx @@ -25,8 +25,16 @@ describe('LtiTokenRetriever Test Suite', () => { // Close server after all tests afterAll(() => server.close()) + // Clear sessionStorage before each test + beforeEach(() => { + sessionStorage.clear() + mockJwtFn.mockClear() + }) + // Reset handlers after each test `important for test isolation` - afterEach(() => server.resetHandlers()) + afterEach(() => { + server.resetHandlers() + }) it('Should show an error when no token', () => { render( @@ -122,7 +130,78 @@ describe('LtiTokenRetriever Test Suite', () => { expect(await screen.findAllByText('Mock Child Element')).toBeDefined() expect(mockJwtFn).toHaveBeenCalledWith(jwt, mockServer) }) - + + it('Should use cached token when network error occurs and cached token exists', async () => { + const cachedJwt = 'cached.jwt.token' + const search = '?token=1234&other=something' + + // Pre-populate sessionStorage with a cached JWT + const tokenData = { + token: cachedJwt, + timestamp: Date.now() + } + sessionStorage.setItem('jwt', JSON.stringify(tokenData)) + + vi.spyOn(window, 'location', 'get').mockReturnValue({search}) + server.use( + http.post('http://server.test/token', async () => { + return HttpResponse.error() + }) + ) + render( + +

Mock Child Element

+
+ ) + + // Should succeed and use the cached token + expect(await screen.findAllByText('Mock Child Element')).toBeDefined() + expect(mockJwtFn).toHaveBeenCalledWith(cachedJwt, mockServer) + }) + + it('Should use cached token when server returns non-ok response and cached token exists', async () => { + const cachedJwt = 'cached.jwt.token' + const search = '?token=1234&other=something' + + // Pre-populate sessionStorage with a cached JWT + const tokenData = { + token: cachedJwt, + timestamp: Date.now() + } + sessionStorage.setItem('jwt', JSON.stringify(tokenData)) + + vi.spyOn(window, 'location', 'get').mockReturnValue({search}) + server.use( + http.post('http://server.test/token', async () => { + return HttpResponse.text('Not found', {status: 404}) + }) + ) + render( + +

Mock Child Element

+
+ ) + + // Should succeed and use the cached token + expect(await screen.findAllByText('Mock Child Element')).toBeDefined() + expect(mockJwtFn).toHaveBeenCalledWith(cachedJwt, mockServer) + }) + + it('Should still show error when network error occurs and no cached token exists', async () => { + const search = '?token=1234&other=something' + vi.spyOn(window, 'location', 'get').mockReturnValue({search}) + server.use( + http.post('http://server.test/token', async () => { + return HttpResponse.error() + }) + ) + render( + +

Mock Child Element

+
+ ) + expect(await screen.findByText('Failed to fetch')).toBeDefined() + }) }); \ No newline at end of file