From 61eba6831cb4134e7e9676703266d6411cf8851c Mon Sep 17 00:00:00 2001 From: Darko Kukovec Date: Tue, 20 Jun 2023 12:14:05 +0200 Subject: [PATCH] SW tests, tweaks (#8) * Fix PKCE tests * Fix the storage tests * Fix the login tests * Refactor tests setup, utils/operations * Fix postMessage tests * Fix register tests * Fix fetch and interceptor * Fix remaining tests, refactor state persistance * Add some missing tests * Streamline the idb layer * Add missing state tests * Add interceptor tests * Fix some code smells * Resolve deprecation warnings --- example/workers/service-worker.ts | 4 +- package-lock.json | 192 +++++++++++++++++++++++- package.json | 8 +- src/shared/db.mock.ts | 42 ++++++ src/shared/db.test.ts | 78 ++++++++++ src/shared/db.ts | 9 +- src/shared/pkce.test.ts | 34 ++--- src/shared/pkce.ts | 11 +- src/shared/storage.test.ts | 40 ++--- src/shared/utils.test.ts | 4 - src/shared/utils.ts | 18 ++- src/utils/login.test.ts | 17 +-- src/utils/operations.test.ts | 14 +- src/utils/operations.ts | 8 +- src/utils/postMessage.test.ts | 4 + src/utils/register.test.ts | 8 +- src/vendor/idb-keyval.ts | 21 --- src/worker/fetch.test.ts | 4 + src/worker/interceptor.test.ts | 192 +++++++++++++++++++++++- src/worker/operations.test.ts | 56 ++++--- src/worker/operations.ts | 1 - src/worker/postMessage.test.ts | 35 ++--- src/worker/service-worker.test.ts | 51 +++---- src/worker/service-worker.ts | 3 +- src/worker/state.test.ts | 240 +++++++++++++++++------------- src/worker/state.ts | 12 +- src/worker/utils.test.ts | 6 +- test/mock/localStorage.ts | 27 ---- test/setup.ts | 28 ++++ 29 files changed, 807 insertions(+), 360 deletions(-) create mode 100644 src/shared/db.mock.ts create mode 100644 src/shared/db.test.ts delete mode 100644 test/mock/localStorage.ts create mode 100644 test/setup.ts diff --git a/example/workers/service-worker.ts b/example/workers/service-worker.ts index c3c363e..dc543c1 100644 --- a/example/workers/service-worker.ts +++ b/example/workers/service-worker.ts @@ -16,6 +16,6 @@ addEventListener('activate', (event) => { initAuthServiceWorker( { google, facebook, twitter, reddit, auth0: auth0('dev-u8csbbr8zashh2k8.us.auth0.com') }, '/auth', - ['https://www.googleapis.com/oauth2/v3/userinfo'], - 'foobartest' + ['https://www.googleapis.com/oauth2/v3/userinfo'] + // 'foobartest' ); diff --git a/package-lock.json b/package-lock.json index 705ea8b..c362ce5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,23 @@ { "name": "auth-worker", - "version": "1.0.2", + "version": "2.0.0-beta.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "auth-worker", - "version": "1.0.2", + "version": "2.0.0-beta.5", "license": "MIT", "dependencies": { "jwt-decode": "^3.1.2" }, "devDependencies": { "@infinumjs/eslint-config-core-ts": "^3.3.1", + "@peculiar/webcrypto": "^1.4.3", "@types/jest": "^29.5.1", "@typescript-eslint/parser": "^5.59.2", "eslint": "^8.39.0", + "fake-indexeddb": "^4.0.1", "jest": "^29.5.0", "jest-environment-jsdom": "^29.5.0", "lint-staged": "^13.2.2", @@ -1580,6 +1582,45 @@ "node": ">= 8" } }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.6.tgz", + "integrity": "sha512-izNRxPoaeJeg/AyH8hER6s+H7p4itk+03QCa4sbxI3lNdseQYCuxzgsuNK8bTXChtLTjpJz6NmXKA73qLa3rCA==", + "dev": true, + "dependencies": { + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.4.3.tgz", + "integrity": "sha512-VtaY4spKTdN5LjJ04im/d/joXuvLbQdgy5Z4DXF4MFZhQ+MTrejbNMkfZBp1Bs3O5+bFqnJgyGdPuZQflvIa5A==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.6", + "@peculiar/json-schema": "^1.1.12", + "pvtsutils": "^1.3.2", + "tslib": "^2.5.0", + "webcrypto-core": "^1.7.7" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.25.24", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", @@ -2207,6 +2248,20 @@ "node": ">=8" } }, + "node_modules/asn1js": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", + "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "dev": true, + "dependencies": { + "pvtsutils": "^1.3.2", + "pvutils": "^1.1.3", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -2319,6 +2374,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64-arraybuffer-es6": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.7.0.tgz", + "integrity": "sha512-ESyU/U1CFZDJUdr+neHRhNozeCv72Y7Vm0m1DCbjX3KBjT6eYocvAJlSk6+8+HkVwXlT1FNxhGW6q3UKAlCvvw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -3384,6 +3448,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/fake-indexeddb": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-4.0.1.tgz", + "integrity": "sha512-hFRyPmvEZILYgdcLBxVdHLik4Tj3gDTu/g7s9ZDOiU3sTNiGx+vEu1ri/AMsFJUZ/1sdRbAVrEcKndh3sViBcA==", + "dev": true, + "dependencies": { + "realistic-structured-clone": "^3.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5069,6 +5142,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -5893,6 +5972,24 @@ } ] }, + "node_modules/pvtsutils": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.2.tgz", + "integrity": "sha512-+Ipe2iNUyrZz+8K/2IOo+kKikdtfhRKzNpQbruF2URmqPtoqAs8g3xS7TJvFF2GcPXjh7DkqMnpVveRFq4PgEQ==", + "dev": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -5937,6 +6034,26 @@ "node": ">=8.10.0" } }, + "node_modules/realistic-structured-clone": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/realistic-structured-clone/-/realistic-structured-clone-3.0.0.tgz", + "integrity": "sha512-rOjh4nuWkAqf9PWu6JVpOWD4ndI+JHfgiZeMmujYcPi+fvILUu7g6l26TC1K5aBIp34nV+jE1cDO75EKOfHC5Q==", + "dev": true, + "dependencies": { + "domexception": "^1.0.1", + "typeson": "^6.1.0", + "typeson-registry": "^1.0.0-alpha.20" + } + }, + "node_modules/realistic-structured-clone/node_modules/domexception": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", + "integrity": "sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==", + "dev": true, + "dependencies": { + "webidl-conversions": "^4.0.2" + } + }, "node_modules/regexpp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", @@ -6894,6 +7011,64 @@ "node": ">=12.20" } }, + "node_modules/typeson": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/typeson/-/typeson-6.1.0.tgz", + "integrity": "sha512-6FTtyGr8ldU0pfbvW/eOZrEtEkczHRUtduBnA90Jh9kMPCiFNnXIon3vF41N0S4tV1HHQt4Hk1j4srpESziCaA==", + "dev": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/typeson-registry": { + "version": "1.0.0-alpha.39", + "resolved": "https://registry.npmjs.org/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz", + "integrity": "sha512-NeGDEquhw+yfwNhguLPcZ9Oj0fzbADiX4R0WxvoY8nGhy98IbzQy1sezjoEFWOywOboj/DWehI+/aUlRVrJnnw==", + "dev": true, + "dependencies": { + "base64-arraybuffer-es6": "^0.7.0", + "typeson": "^6.0.0", + "whatwg-url": "^8.4.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/typeson-registry/node_modules/tr46": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", + "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", + "dev": true, + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/typeson-registry/node_modules/webidl-conversions": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", + "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", + "dev": true, + "engines": { + "node": ">=10.4" + } + }, + "node_modules/typeson-registry/node_modules/whatwg-url": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", + "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", + "dev": true, + "dependencies": { + "lodash": "^4.7.0", + "tr46": "^2.1.0", + "webidl-conversions": "^6.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -6999,6 +7174,19 @@ "makeerror": "1.0.12" } }, + "node_modules/webcrypto-core": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.7.7.tgz", + "integrity": "sha512-7FjigXNsBfopEj+5DV2nhNpfic2vumtjjgPmeDKk45z+MJwXKKfhPB7118Pfzrmh4jqOMST6Ch37iPAHoImg5g==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.6", + "@peculiar/json-schema": "^1.1.12", + "asn1js": "^3.0.1", + "pvtsutils": "^1.3.2", + "tslib": "^2.4.0" + } + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", diff --git a/package.json b/package.json index 274c89d..0825430 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "auth-worker", - "version": "2.0.0-beta.5", + "version": "2.0.0-beta.6", "description": "OAuth2 Service Worker handler", "main": "index.js", "module": "index.mjs", @@ -23,9 +23,11 @@ "homepage": "https://github.com/infinum/auth-worker#readme", "devDependencies": { "@infinumjs/eslint-config-core-ts": "^3.3.1", + "@peculiar/webcrypto": "^1.4.3", "@types/jest": "^29.5.1", "@typescript-eslint/parser": "^5.59.2", "eslint": "^8.39.0", + "fake-indexeddb": "^4.0.1", "jest": "^29.5.0", "jest-environment-jsdom": "^29.5.0", "lint-staged": "^13.2.2", @@ -49,6 +51,10 @@ "ts", "js" ], + "testEnvironment": "jest-environment-jsdom", + "setupFilesAfterEnv": [ + "./test/setup.ts" + ], "testRegex": "src/(.*).test.ts$", "preset": "ts-jest", "testMatch": null diff --git a/src/shared/db.mock.ts b/src/shared/db.mock.ts new file mode 100644 index 0000000..8c44e91 --- /dev/null +++ b/src/shared/db.mock.ts @@ -0,0 +1,42 @@ +let secret: string | null = null; +const data = new Map(); + +export const SECURE_KEY = 'auth-worker-state'; + +export function setSecret(secretPhrase: string | undefined) { + secret = secretPhrase ?? null; +} + +export function isPersistable(): boolean { + return Boolean(secret); +} + +export function setMockData(key: string, value: string): void; +export function setMockData(value: string): void; +export function setMockData(key: string, value?: string) { + if (value === undefined) { + data.set(SECURE_KEY, key); + } else { + data.set(key, value); + } +} + +export function clearMockData() { + data.clear(); +} + +export async function getData(key: string): Promise { + return data.get(key) ?? null; +} + +export async function saveData(key: string, value: string) { + data.set(key, value); +} + +export async function deleteData(keys: Array): Promise { + keys.forEach((key) => data.delete(key)); +} + +export async function getKeys(): Promise> { + return Array.from(data.keys()); +} diff --git a/src/shared/db.test.ts b/src/shared/db.test.ts new file mode 100644 index 0000000..2597a69 --- /dev/null +++ b/src/shared/db.test.ts @@ -0,0 +1,78 @@ +const { setSecret, isPersistable, getData, saveData, deleteData, getKeys, SECURE_KEY } = jest.requireActual('./db'); + +describe('shared/db', () => { + describe('setSecret', () => { + it('should set secret', () => { + const key = 'test'; + + setSecret(key); + + expect(isPersistable()).toBe(true); + }); + + it('should set secret to null', () => { + setSecret(undefined); + + expect(isPersistable()).toBe(false); + }); + }); + + describe('getData & saveData', () => { + it('should save and get data', async () => { + const key = 'test'; + const value = 'value'; + + await saveData(key, value); + + expect(await getData(key)).toBe(value); + }); + + it('should return null if no data', async () => { + const key = 'test'; + + expect(await getData(key)).toBe(null); + }); + + it('should not save the secret key if pass is not set', async () => { + const value = 'value'; + + setSecret(); + await saveData(SECURE_KEY, value); + + expect(await getData(SECURE_KEY)).toBe(null); + }); + + it('should save the secret key', async () => { + const key = 'test'; + const value = 'value'; + + setSecret(key); + await saveData(SECURE_KEY, value); + + expect(await getData(SECURE_KEY)).toBe(value); + }); + }); + + describe('deleteData', () => { + it('should delete data', async () => { + const key = 'test'; + const value = 'value'; + + await saveData(key, value); + await deleteData([key]); + + expect(await getData(key)).toBe(null); + }); + }); + + describe('getKeys', () => { + it('should return keys', async () => { + const key = 'test'; + const value = 'value'; + + await saveData(key, value); + + expect(await getKeys()).toEqual([key]); + }); + }); +}); diff --git a/src/shared/db.ts b/src/shared/db.ts index f04422e..8918958 100644 --- a/src/shared/db.ts +++ b/src/shared/db.ts @@ -1,4 +1,5 @@ import { get, set, keys, delMany } from '../vendor/idb-keyval'; +import { decode, encode } from './utils'; export const SECURE_KEY = 'auth-worker-state'; @@ -36,14 +37,6 @@ export function isPersistable(): boolean { return Boolean(secret); } -function encode(data: ArrayBuffer): string { - return btoa(String.fromCharCode(...new Uint8Array(data))); -} - -function decode(data: string): ArrayBuffer { - return Uint8Array.from(atob(data), (c) => c.charCodeAt(0)).buffer; -} - async function encrypt(data: string) { if (!secret) { return null; diff --git a/src/shared/pkce.test.ts b/src/shared/pkce.test.ts index 75cbd66..44406cf 100644 --- a/src/shared/pkce.test.ts +++ b/src/shared/pkce.test.ts @@ -1,36 +1,20 @@ -import { LocalStorageMock } from '../../test/mock/localStorage'; import { deletePkce, generateAsyncPKCE, getPkceVerifier } from './pkce'; describe('shared/pkce', () => { - beforeAll(() => { - global.localStorage = new LocalStorageMock(); - }); - - beforeEach(() => { - localStorage.clear(); - }); - describe('getPkceVerifier', () => { - it('should work with empty localStorage', () => { - const data = getPkceVerifier('test'); - const repeat = getPkceVerifier('test'); + it('should work with empty storage', async () => { + const data = await getPkceVerifier('test'); + const repeat = await getPkceVerifier('test'); expect(data).toEqual(repeat); expect(data).toHaveLength(128); }); - it('should work with filled localStorage', () => { - localStorage.setItem('auth-worker/pkce/test', 'test'); - const data = getPkceVerifier('test'); - - expect(data).toEqual('test'); - }); - - it('should work with multiple providers', () => { - const dataA = getPkceVerifier('testA'); - const repeatA = getPkceVerifier('testA'); - const dataB = getPkceVerifier('testB'); - const repeatB = getPkceVerifier('testB'); + it('should work with multiple providers', async () => { + const dataA = await getPkceVerifier('testA'); + const repeatA = await getPkceVerifier('testA'); + const dataB = await getPkceVerifier('testB'); + const repeatB = await getPkceVerifier('testB'); expect(dataA).toEqual(repeatA); expect(dataA).toHaveLength(128); @@ -63,7 +47,7 @@ describe('shared/pkce', () => { describe('deletePkce', () => { it('should regnerate the key after deletion', async () => { const pkce1 = await generateAsyncPKCE('test'); - deletePkce(); + await deletePkce(); const pkce2 = await generateAsyncPKCE('test'); expect(pkce1.codeVerifier).not.toEqual(pkce2.codeVerifier); diff --git a/src/shared/pkce.ts b/src/shared/pkce.ts index 01d00a5..36bb754 100644 --- a/src/shared/pkce.ts +++ b/src/shared/pkce.ts @@ -1,5 +1,5 @@ import { deleteData, getData, getKeys, saveData } from './db'; -import { getRandom } from './utils'; +import { encode, getRandom } from './utils'; const PKCE_PARAM_NAME = 'auth-worker/pkce'; @@ -10,14 +10,7 @@ interface IPKCE { } function arrayBufferToHex(arrayBuffer: ArrayBuffer): string { - return btoa( - Array.from(arrayBuffer as Uint8Array) - .map((x: number) => String.fromCharCode(x)) - .join('') - ) - .replace(/\//g, '_') - .replace(/\+/g, '-') - .replace(/=/g, ''); + return encode(arrayBuffer).replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, ''); } export async function getPkceVerifier(provider: string) { diff --git a/src/shared/storage.test.ts b/src/shared/storage.test.ts index e803823..21f0d77 100644 --- a/src/shared/storage.test.ts +++ b/src/shared/storage.test.ts @@ -1,36 +1,20 @@ -import { LocalStorageMock } from '../../test/mock/localStorage'; import { getState, deleteState } from './storage'; describe('utils/storage', () => { - beforeAll(() => { - global.localStorage = new LocalStorageMock(); - }); - - beforeEach(() => { - localStorage.clear(); - }); - describe('getState', () => { - it('should work with empty localStorage', () => { - const data = getState('test'); - const repeat = getState('test'); + it('should work with empty localStorage', async () => { + const data = await getState('test'); + const repeat = await getState('test'); expect(data).toEqual(repeat); expect(data).toHaveLength(32); }); - it('should work with filled localStorage', () => { - localStorage.setItem('auth-worker/state/test', 'test'); - const data = getState('test'); - - expect(data).toEqual('test'); - }); - - it('should work with multiple providers', () => { - const dataA = getState('testA'); - const repeatA = getState('testA'); - const dataB = getState('testB'); - const repeatB = getState('testB'); + it('should work with multiple providers', async () => { + const dataA = await getState('testA'); + const repeatA = await getState('testA'); + const dataB = await getState('testB'); + const repeatB = await getState('testB'); expect(dataA).toEqual(repeatA); expect(dataA).toHaveLength(32); @@ -43,10 +27,10 @@ describe('utils/storage', () => { }); describe('deleteStorage', () => { - it('should regnerate the key after deletion', () => { - const storage1 = getState('test'); - deleteState(); - const storage2 = getState('test'); + it('should regnerate the key after deletion', async () => { + const storage1 = await getState('test'); + await deleteState(); + const storage2 = await getState('test'); expect(storage1).not.toEqual(storage2); }); diff --git a/src/shared/utils.test.ts b/src/shared/utils.test.ts index 4c09830..2aea78c 100644 --- a/src/shared/utils.test.ts +++ b/src/shared/utils.test.ts @@ -2,10 +2,6 @@ import { getRandom } from './utils'; describe('shared/utils', () => { describe('getRandom', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - it('should return a random string', () => { const result = getRandom(); expect(result.length).toEqual(32); diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 10f3dc0..d57920a 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -1,15 +1,17 @@ export function getRandom(length = 32) { let str = ''; - if ('crypto' in globalThis && 'randomUUID' in globalThis.crypto) { - while (str.length < length) { - str += globalThis.crypto.randomUUID().replace(/-/g, ''); - } - } else { - while (str.length < length) { - str += Math.random().toString(36).slice(2); - } + while (str.length < length) { + str += globalThis.crypto.randomUUID().replace(/-/g, ''); } return str.slice(0, length); } + +export function encode(data: ArrayBuffer): string { + return window.btoa(String.fromCharCode(...new Uint8Array(data))); +} + +export function decode(data: string): ArrayBuffer { + return Uint8Array.from(window.atob(data), (c) => c.charCodeAt(0)).buffer; +} diff --git a/src/utils/login.test.ts b/src/utils/login.test.ts index a30e753..b7509d5 100644 --- a/src/utils/login.test.ts +++ b/src/utils/login.test.ts @@ -1,26 +1,10 @@ -/** - * @jest-environment jsdom - */ - import { getLoginUrl } from './login'; import { IFullConfig } from '../interfaces/IFullConfig'; import { auth0, google } from '../providers'; -import { LocalStorageMock } from '../../test/mock/localStorage'; import { GrantFlow } from '../shared/enums'; describe('utils/login', () => { describe('getLoginUrl', () => { - beforeAll(() => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - delete globalThis.localStorage; - globalThis.localStorage = new LocalStorageMock(); - }); - - beforeEach(() => { - localStorage.clear(); - }); - it('should generate login URL for token flow', async () => { const config: IFullConfig = { providers: { @@ -71,6 +55,7 @@ describe('utils/login', () => { it('should fail if ther loginUrl is not defined', () => { const config: IFullConfig = { providers: { + // @ts-expect-error Testing a bad config mockProvider: { grantType: GrantFlow.Token, }, diff --git a/src/utils/operations.test.ts b/src/utils/operations.test.ts index 8d51d48..d13bd74 100644 --- a/src/utils/operations.test.ts +++ b/src/utils/operations.test.ts @@ -10,7 +10,7 @@ import { deleteState, getState } from '../shared/storage'; import { deletePkce, getPkceVerifier } from '../shared/pkce'; jest.mock('./postMessage'); -jest.mock('./storage'); +jest.mock('../shared/storage'); jest.mock('../shared/pkce'); describe('utils/operations', () => { @@ -29,11 +29,7 @@ describe('utils/operations', () => { }); it('should call callWorker with the correct arguments', async () => { - globalThis.window = { - // @ts-ignore - location: new URL('http://example.com?query#hash'), - }; - await createSession('provider'); + await createSession('provider', new URL('http://example.com?query#hash')); expect(callWorker).toHaveBeenCalledWith('createSession', [ 'query', 'provider', @@ -46,11 +42,7 @@ describe('utils/operations', () => { }); it('should call callWorker with hash value if long enough', async () => { - globalThis.window = { - // @ts-ignore - location: new URL('http://example.com?query#hash123456789'), - }; - await createSession('provider'); + await createSession('provider', new URL('http://example.com?query#hash123456789')); expect(callWorker).toHaveBeenCalledWith('createSession', [ 'hash123456789', 'provider', diff --git a/src/utils/operations.ts b/src/utils/operations.ts index 7a46787..4e1c327 100644 --- a/src/utils/operations.ts +++ b/src/utils/operations.ts @@ -9,9 +9,9 @@ import type { import { deleteState, getState } from '../shared/storage'; import { deletePkce, getPkceVerifier } from '../shared/pkce'; -export async function createSession(provider: string) { - const hash = window.location.hash.substring(1); - const query = window.location.search.substring(1); +export async function createSession(provider: string, location: Location | URL = window.location) { + const hash = location.hash.substring(1); + const query = location.search.substring(1); const params = hash && hash.length > 10 ? hash : query; const localState = await getState(provider); const pkce = await getPkceVerifier(provider); @@ -19,7 +19,7 @@ export async function createSession(provider: string) { params, provider, localState, - window.location.origin, + location.origin, pkce, ]); deleteState(); diff --git a/src/utils/postMessage.test.ts b/src/utils/postMessage.test.ts index d3efb2c..89b02a7 100644 --- a/src/utils/postMessage.test.ts +++ b/src/utils/postMessage.test.ts @@ -1,3 +1,7 @@ +/** + * @jest-environment node + */ + import { callWorker, setWorker } from './postMessage'; type TListenerFn = (...args: Array) => void; diff --git a/src/utils/register.test.ts b/src/utils/register.test.ts index e327cc9..ff1afe2 100644 --- a/src/utils/register.test.ts +++ b/src/utils/register.test.ts @@ -1,7 +1,3 @@ -/** - * @jest-environment jsdom - */ - import { loadAuthServiceWorker } from './register'; describe('utils/register', () => { @@ -27,7 +23,7 @@ describe('utils/register', () => { { workerPath: './test-service-worker.js', scope: '/test', debug: true } ); expect(window.navigator.serviceWorker.register).toHaveBeenCalledWith( - './test-service-worker.js?config=%7B%22google%22%3A%7B%22clientId%22%3A%22example-client-id%22%2C%22redirectUrl%22%3A%22%2Ftest-redirect%22%2C%22scopes%22%3A%22https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile%22%7D%7D&v=1&debug=1', + './test-service-worker.js?config=%7B%22google%22%3A%7B%22clientId%22%3A%22example-client-id%22%2C%22scopes%22%3A%22https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile%22%7D%7D&v=1&debug=1', { scope: '/test', type: 'module', @@ -43,7 +39,7 @@ describe('utils/register', () => { }, }); expect(window.navigator.serviceWorker.register).toHaveBeenCalledWith( - './service-worker.js?config=%7B%22google%22%3A%7B%22clientId%22%3A%22example-client-id%22%2C%22redirectUrl%22%3A%22%2Ftest-redirect%22%2C%22scopes%22%3A%22https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile%22%7D%7D&v=1&debug=0', + './service-worker.js?config=%7B%22google%22%3A%7B%22clientId%22%3A%22example-client-id%22%2C%22scopes%22%3A%22https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile%22%7D%7D&v=1&debug=0', { scope: '/', type: 'module', diff --git a/src/vendor/idb-keyval.ts b/src/vendor/idb-keyval.ts index 2863551..a014b3c 100644 --- a/src/vendor/idb-keyval.ts +++ b/src/vendor/idb-keyval.ts @@ -40,28 +40,7 @@ export function get(key: IDBValidKey): Promise { return defaultStore()('readonly', (store) => promisifyRequest(store.get(key))); } -export function update(key: IDBValidKey, updater: (oldValue: T | undefined) => T): Promise { - return defaultStore()( - 'readwrite', - (store) => - new Promise((resolve, reject) => { - store.get(key).onsuccess = function () { - try { - store.put(updater(this.result), key); - resolve(promisifyRequest(store.transaction)); - } catch (err) { - reject(err); - } - }; - }) - ); -} - export async function set(key: IDBValidKey, value: string): Promise { - const keysList = await keys(); - if (keysList.includes(key)) { - return update(key, () => value); - } return defaultStore()('readwrite', (store) => { store.put(value, key); return promisifyRequest(store.transaction); diff --git a/src/worker/fetch.test.ts b/src/worker/fetch.test.ts index 54ee414..2c70d8e 100644 --- a/src/worker/fetch.test.ts +++ b/src/worker/fetch.test.ts @@ -1,3 +1,7 @@ +/** + * @jest-environment node + */ + import { AuthError } from '../shared/enums'; import { fetchWithCredentials, isAllowedUrl, refreshToken } from './fetch'; import { getProviderOptions, getProviderParams, getAuthState, saveAuthState } from './state'; diff --git a/src/worker/interceptor.test.ts b/src/worker/interceptor.test.ts index dd42b35..ad31d04 100644 --- a/src/worker/interceptor.test.ts +++ b/src/worker/interceptor.test.ts @@ -1,7 +1,14 @@ +/** + * @jest-environment node + */ + +import { GrantFlow } from '../shared/enums'; import { fetchListener } from './interceptor'; +import { createSession, deleteSession } from './operations'; import { getProviderOptions, getProviderParams, getAuthState } from './state'; jest.mock('./state'); +jest.mock('./operations'); describe('worker/interceptor', () => { afterEach(() => { @@ -17,10 +24,15 @@ describe('worker/interceptor', () => { describe('fetchListener', () => { it('should be ignored if the auth header is not set', async () => { + const respondWith = jest.fn(); const request = new Request('https://example.com'); - const response = await fetchListener({ request } as unknown as FetchEvent); + const response = await fetchListener({ request, respondWith } as unknown as FetchEvent); expect(response).toBeUndefined(); + (fetch as jest.Mock).mockResolvedValueOnce('mockResponse'); + + const mockResp = await respondWith.mock.calls[0][0]; + expect(mockResp).toEqual('mockResponse'); }); it('should pass on the GET request', async () => { @@ -62,4 +74,182 @@ describe('worker/interceptor', () => { expect(await response.text()).toEqual('mockResponse'); }); }); + + describe('intercept', () => { + it('should redirect to the login URL', async () => { + const respondWith = jest.fn(); + const state = await getAuthState(); + state.config = { + basePath: '/foobar', + config: { + foo: { + clientId: 'fooClientId', + }, + }, + debug: false, + providers: { + foo: { + grantType: GrantFlow.Token, + loginUrl: 'https://example.com/login', + }, + }, + }; + + await fetchListener({ + request: { + method: 'GET', + headers: new Headers({}), + url: 'https://example.com/foobar/login/foo', + }, + respondWith, + } as unknown as FetchEvent); + + const response = await respondWith.mock.calls[0][0]; + + const location = response.headers.get('location'); + expect(location.startsWith('https://example.com/login?client_id=fooClientId&response_type=token&state=')).toBe( + true + ); + expect(location.endsWith('&scope=&redirect_uri=https%3A%2F%2Fexample.com%2Ffoobar%2Fcallback%2Ffoo')).toBe(true); + expect(response.status).toBe(302); + }); + + it('should handle the callback URL', async () => { + const respondWith = jest.fn(); + const state = await getAuthState(); + state.config = { + basePath: '/foobar', + config: { + foo: { + clientId: 'fooClientId', + }, + }, + debug: false, + providers: { + foo: { + grantType: GrantFlow.Token, + loginUrl: 'https://example.com/login', + }, + }, + }; + + await fetchListener({ + request: { + method: 'GET', + headers: new Headers({}), + url: 'https://example.com/foobar/callback/foo#foobartest123', + }, + respondWith, + } as unknown as FetchEvent); + + const response = await respondWith.mock.calls[0][0]; + expect(response.headers.get('location')).toBe('https://example.com/#/'); + expect(response.status).toBe(302); + expect(createSession).toHaveBeenCalledWith( + 'foobartest123', + 'foo', + expect.stringMatching(/[a-z0-9]{16}/), + expect.stringMatching(/[a-z0-9]{128}/) + ); + }); + + it('should handle the logout URL', async () => { + const respondWith = jest.fn(); + const state = await getAuthState(); + state.config = { + basePath: '/foobar', + config: { + foo: { + clientId: 'fooClientId', + }, + }, + debug: false, + providers: { + foo: { + grantType: GrantFlow.Token, + loginUrl: 'https://example.com/login', + }, + }, + }; + + await fetchListener({ + request: { + method: 'GET', + headers: new Headers({}), + url: 'https://example.com/foobar/logout', + }, + respondWith, + } as unknown as FetchEvent); + + const response = await respondWith.mock.calls[0][0]; + expect(response.headers.get('location')).toBe('https://example.com/'); + expect(response.status).toBe(302); + expect(deleteSession).toHaveBeenCalled(); + }); + + it('should handle 404', async () => { + const respondWith = jest.fn(); + const state = await getAuthState(); + state.config = { + basePath: '/foobar', + config: { + foo: { + clientId: 'fooClientId', + }, + }, + debug: false, + providers: { + foo: { + grantType: GrantFlow.Token, + loginUrl: 'https://example.com/login', + }, + }, + }; + + await fetchListener({ + request: { + method: 'GET', + headers: new Headers({}), + url: 'https://example.com/foobar/test', + }, + respondWith, + } as unknown as FetchEvent); + + const response = await respondWith.mock.calls[0][0]; + expect(response.headers.get('location')).toBe('https://example.com/'); + expect(response.status).toBe(302); + }); + + it('should ignore other URLs', async () => { + const respondWith = jest.fn(); + const state = await getAuthState(); + state.config = { + basePath: '/foobar', + config: { + foo: { + clientId: 'fooClientId', + }, + }, + debug: false, + providers: { + foo: { + grantType: GrantFlow.Token, + loginUrl: 'https://example.com/login', + }, + }, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + global.fetch = jest.fn(() => Promise.resolve('mockResponse')); + + await fetchListener({ + request: new Request('https://example.com/baz/test'), + respondWith, + } as unknown as FetchEvent); + + const response = await respondWith.mock.calls[0][0]; + expect(response).toBe('mockResponse'); + }); + }); }); diff --git a/src/worker/operations.test.ts b/src/worker/operations.test.ts index 8d59049..cbfacfc 100644 --- a/src/worker/operations.test.ts +++ b/src/worker/operations.test.ts @@ -1,12 +1,14 @@ +/** + * @jest-environment node + */ + import jwtDecode from 'jwt-decode'; import { createSession, deleteSession, getUserData } from './operations'; import { getAuthState, saveAuthState } from './state'; import { GrantFlow } from '../shared/enums'; jest.mock('./state'); -jest.mock('jwt-decode', () => { - return jest.fn((data) => data); -}); +jest.mock('jwt-decode'); describe('worker/operations', () => { afterEach(() => { @@ -15,6 +17,7 @@ describe('worker/operations', () => { beforeEach(() => { jest.spyOn(globalThis, 'fetch'); + (jwtDecode as jest.Mock).mockImplementation((data) => data); }); describe('createSession', () => { @@ -23,7 +26,7 @@ describe('worker/operations', () => { (getAuthState as jest.Mock).mockReturnValue(state); - await expect(createSession('', 'mockProvider', '', 'example.com')).rejects.toThrow('No config found'); + await expect(createSession('', 'mockProvider', '', 'http://example.com')).rejects.toThrow('No config found'); }); it('should fail if there is no valid providers', async () => { @@ -31,7 +34,7 @@ describe('worker/operations', () => { (getAuthState as jest.Mock).mockReturnValue(state); - await expect(createSession('', 'mockProvider', '', 'example.com')).rejects.toThrow( + await expect(createSession('', 'mockProvider', '', 'http://example.com')).rejects.toThrow( 'No provider params found (createSession)' ); }); @@ -39,6 +42,7 @@ describe('worker/operations', () => { it('should fail on invalid state', async () => { const state = { config: { + basePath: '/foo', providers: { mockProvider: {}, }, @@ -50,15 +54,16 @@ describe('worker/operations', () => { (getAuthState as jest.Mock).mockReturnValue(state); - await expect(createSession('', 'mockProvider', '123', 'example.com')).rejects.toThrow('Invalid state'); + await expect(createSession('', 'mockProvider', '123', 'http://example.com')).rejects.toThrow('Invalid state'); }); - it('shuld work for token flow', async () => { + it('should work for token flow', async () => { const state = { session: { provider: 'mockProvider', }, config: { + basePath: '/foo', providers: { mockProvider: { stateParam: 'state_param', @@ -80,7 +85,7 @@ describe('worker/operations', () => { 'state_param=123&expiresIn=12&access=mockAccess&user=mockUserInfo&stuff=test', 'mockProvider', '123', - 'example.com' + 'http://example.com' ); expect(state.session).toEqual({ @@ -104,6 +109,7 @@ describe('worker/operations', () => { provider: 'mockProvider', }, config: { + basePath: '/foo', providers: { mockProvider: { stateParam: 'state_param', @@ -120,7 +126,12 @@ describe('worker/operations', () => { (getAuthState as jest.Mock).mockResolvedValue(state); await expect( - createSession('state_param=123&expiresIn=12&user=mockUserInfo&stuff=test', 'mockProvider', '123', 'example.com') + createSession( + 'state_param=123&expiresIn=12&user=mockUserInfo&stuff=test', + 'mockProvider', + '123', + 'http://example.com' + ) ).rejects.toThrow('No access token found'); }); @@ -130,6 +141,7 @@ describe('worker/operations', () => { provider: 'mockProvider', }, config: { + basePath: '/foo', providers: { mockProvider: { stateParam: 'state_param', @@ -156,7 +168,7 @@ describe('worker/operations', () => { 'state_param=123&authCode=abc&stuff=test', 'mockProvider', '123', - 'example.com' + 'http://example.com' ); expect(state.session).toEqual({ @@ -181,7 +193,7 @@ describe('worker/operations', () => { }); expect(params.get('grant_type')).toBe('authorization_code'); expect(params.get('code')).toBe('abc'); - expect(params.get('redirect_uri')).toBe('example.com/test'); + expect(params.get('redirect_uri')).toBe('http://example.com/foo/callback/mockProvider'); expect(params.get('client_id')).toBe('123'); }); @@ -191,6 +203,7 @@ describe('worker/operations', () => { provider: 'mockProvider', }, config: { + basePath: '/foo', providers: { mockProvider: { stateParam: 'state_param', @@ -211,7 +224,7 @@ describe('worker/operations', () => { (fetch as jest.Mock).mockResolvedValueOnce(new Response('{"user": "mockUserInfo"}', { status: 200 })); await expect( - createSession('state_param=123&authCode=abc&stuff=test', 'mockProvider', '123', 'example.com') + createSession('state_param=123&authCode=abc&stuff=test', 'mockProvider', '123', 'http://example.com') ).rejects.toThrow('No access token found'); }); @@ -221,6 +234,7 @@ describe('worker/operations', () => { provider: 'mockProvider', }, config: { + basePath: '/foo', providers: { mockProvider: { stateParam: 'state_param', @@ -240,7 +254,7 @@ describe('worker/operations', () => { (fetch as jest.Mock).mockResolvedValueOnce(new Response('someError', { status: 403 })); await expect( - createSession('state_param=123&authCode=abc&stuff=test', 'mockProvider', '123', 'example.com') + createSession('state_param=123&authCode=abc&stuff=test', 'mockProvider', '123', 'http://example.com') ).rejects.toThrow('Could not get token'); }); @@ -250,6 +264,7 @@ describe('worker/operations', () => { provider: 'mockProvider', }, config: { + basePath: '/foo', providers: { mockProvider: { stateParam: 'state_param', @@ -265,9 +280,9 @@ describe('worker/operations', () => { (getAuthState as jest.Mock).mockResolvedValue(state); - await expect(createSession('state_param=123&stuff=test', 'mockProvider', '123', 'example.com')).rejects.toThrow( - 'No access code found' - ); + await expect( + createSession('state_param=123&stuff=test', 'mockProvider', '123', 'http://example.com') + ).rejects.toThrow('No access code found'); }); it('should work for the pkce flow', async () => { @@ -276,6 +291,7 @@ describe('worker/operations', () => { provider: 'mockProvider', }, config: { + basePath: '/foo', providers: { mockProvider: { stateParam: 'state_param', @@ -308,7 +324,7 @@ describe('worker/operations', () => { 'state_param=123&authCode=abc&stuff=test', 'mockProvider', '123', - 'example.com', + 'http://example.com', 'mockCodeVerifier' ); @@ -334,7 +350,7 @@ describe('worker/operations', () => { }); expect(params.get('grant_type')).toBe('authorization_code'); expect(params.get('code')).toBe('abc'); - expect(params.get('redirect_uri')).toBe('example.com/test'); + expect(params.get('redirect_uri')).toBe('http://example.com/foo/callback/mockProvider'); expect(params.get('client_id')).toBe('123'); expect(params.get('code_verifier')).toBe('mockCodeVerifier'); }); @@ -354,6 +370,7 @@ describe('worker/operations', () => { userInfo: 'mockUserInfo', }, config: { + basePath: '/foo', providers: { mockProvider: { userInfoParser: jest.fn((data) => data), @@ -382,6 +399,7 @@ describe('worker/operations', () => { userInfo: 'mockUserInfo', }, config: { + basePath: '/foo', providers: { mockProvider: {}, }, @@ -407,6 +425,7 @@ describe('worker/operations', () => { expiresAt: Date.now() + 3600 * 1000, }, config: { + basePath: '/foo', providers: { mockProvider: { userInfoUrl: 'https://example.com/userInfo', @@ -437,6 +456,7 @@ describe('worker/operations', () => { expiresAt: Date.now() + 3600 * 1000, }, config: { + basePath: '/foo', providers: { mockProvider: { userInfoUrl: 'https://example.com/userInfo', diff --git a/src/worker/operations.ts b/src/worker/operations.ts index ee644d4..6641fba 100644 --- a/src/worker/operations.ts +++ b/src/worker/operations.ts @@ -8,7 +8,6 @@ import { fetchWithCredentials } from './fetch'; export async function createSession(params: string, provider: string, localState: string, host: string, pkce?: string) { const state = await getAuthState(); const parsedParams = new URLSearchParams(params); - console.log('state', state); if (!state.config) { throw new Error('No config found'); diff --git a/src/worker/postMessage.test.ts b/src/worker/postMessage.test.ts index 926bdae..ccf1e59 100644 --- a/src/worker/postMessage.test.ts +++ b/src/worker/postMessage.test.ts @@ -1,45 +1,36 @@ -/** - * @jest-environment jsdom - */ - import { MockResponse } from '../../test/mock/Response'; -import { createSession, fetch, deleteSession } from './operations'; +import { createSession, deleteSession, getUserData } from './operations'; import { messageListener, messageListenerWithOrigin } from './postMesage'; function sleep() { return new Promise((resolve) => setTimeout(resolve, 0)); } -jest.mock('./state', () => ({ - getState: jest.fn(() => Promise.resolve({})), - saveState: jest.fn(), -})); - -jest.mock('./operations', () => ({ - createSession: jest.fn().mockRejectedValue(new Error('createSession')), - fetch: jest.fn().mockResolvedValue(new MockResponse('foobar', { status: 200, statusText: 'OK', headers: {} })), - deleteSession: jest.fn(() => Promise.resolve('deleteSession')), -})); +jest.mock('./operations'); describe('worker/postMessage', () => { describe('messageListener', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - beforeEach(() => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore globalThis.Response = MockResponse; }); + afterEach(() => { + jest.resetAllMocks(); + }); + it('should work for the default case', async () => { const postMessage = jest.fn(); const options = [1, 2, 3]; + (getUserData as jest.Mock).mockResolvedValueOnce( + new MockResponse('foobar', { status: 200, statusText: 'OK', headers: {} }) + ); + messageListener({ data: { type: 'call', - fnName: 'fetch', + fnName: 'getUserData', options, caller: 'test', }, @@ -48,7 +39,7 @@ describe('worker/postMessage', () => { } as unknown as ExtendableMessageEvent); await sleep(); - expect(fetch).toHaveBeenCalledWith(...options); + expect(getUserData).toHaveBeenCalledWith(...options); expect(postMessage).toHaveBeenCalledWith( { key: 'test', @@ -66,6 +57,7 @@ describe('worker/postMessage', () => { it('should work for Response objects', async () => { const postMessage = jest.fn(); const options = [1, 2, 3]; + (deleteSession as jest.Mock).mockResolvedValueOnce('deleteSession'); messageListener({ data: { type: 'call', @@ -85,6 +77,7 @@ describe('worker/postMessage', () => { it('should handle errors', async () => { const postMessage = jest.fn(); const options = [1, 2, 3]; + (createSession as jest.Mock).mockRejectedValueOnce(new Error('createSession')); messageListenerWithOrigin({ data: { type: 'call', diff --git a/src/worker/service-worker.test.ts b/src/worker/service-worker.test.ts index 0b44e1f..0f57da9 100644 --- a/src/worker/service-worker.test.ts +++ b/src/worker/service-worker.test.ts @@ -1,15 +1,11 @@ -/** - * @jest-environment jsdom - */ - import { IProvider } from '../interfaces/IProvider'; import { GrantFlow } from '../shared/enums'; import { initAuthServiceWorker } from './service-worker'; import { getAuthState } from './state'; jest.mock('./state', () => ({ - getState: jest.fn(), - saveState: jest.fn(), + getAuthState: jest.fn(), + saveAuthState: jest.fn(), })); describe('worker/service-worker', () => { @@ -29,6 +25,7 @@ describe('worker/service-worker', () => { const providers: Record = { exampleProvider: { grantType: GrantFlow.Token, + loginUrl: 'https://example.com/login', }, }; @@ -49,6 +46,7 @@ describe('worker/service-worker', () => { const state = await getAuthState(); expect(state.config).toEqual({ + basePath: '/auth', config: { test: 1, }, @@ -61,6 +59,7 @@ describe('worker/service-worker', () => { const providers: Record = { exampleProvider: { grantType: GrantFlow.Token, + loginUrl: 'https://example.com/login', }, }; @@ -96,6 +95,7 @@ describe('worker/service-worker', () => { const providers: Record = { exampleProvider: { grantType: GrantFlow.Token, + loginUrl: 'https://example.com/login', }, }; @@ -129,47 +129,40 @@ describe('worker/service-worker', () => { const providers: Record = { exampleProvider: { grantType: GrantFlow.Token, + loginUrl: 'https://example.com/login', }, }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - delete globalThis.location; - globalThis.location = { search: '?config={"test": 2}' } as Location; - await initAuthServiceWorker(providers, '/auth', [], undefined, 'config={"test": 1}'); - const installFn = (globalThis.addEventListener as jest.Mock).mock.calls[0][1]; - const activateFn = (globalThis.addEventListener as jest.Mock).mock.calls[1][1]; + const listeners = (globalThis.addEventListener as jest.Mock).mock.calls; + const installFn = listeners.find(([type]) => type === 'install')[1]; + const activateFn = listeners.find(([type]) => type === 'activate')[1]; - const skipWaiting = jest.fn(); + const postMessage = jest.fn(); + const skipWaiting = jest.fn().mockResolvedValue(undefined); const claim = jest.fn(); - const matchAll = jest.fn(() => Promise.resolve([{ postMessage: jest.fn() }])); + const matchAll = jest.fn().mockResolvedValue([{ postMessage }, { postMessage }]); + const waitUntil = jest.fn(); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - globalThis.clients = { - claim, - matchAll, - } as unknown as Clients; + globalThis.clients = { claim, matchAll } as unknown as Clients; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore globalThis.skipWaiting = skipWaiting; - await installFn({ waitUntil: jest.fn() } as unknown as ExtendableEvent); - - expect(skipWaiting).toHaveBeenCalled(); + await installFn({ waitUntil } as unknown as ExtendableEvent); - await activateFn({ waitUntil: jest.fn() } as unknown as ExtendableEvent); + expect(skipWaiting).toHaveBeenCalledTimes(1); - expect(skipWaiting).toHaveBeenCalled(); - expect(claim).toHaveBeenCalled(); - expect(matchAll).toHaveBeenCalled(); + await activateFn({ waitUntil } as unknown as ExtendableEvent); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - expect(globalThis.clients.matchAll).toHaveBeenCalled(); + expect(skipWaiting).toHaveBeenCalledTimes(2); + expect(claim).toHaveBeenCalledTimes(1); + expect(matchAll).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledTimes(2); }); }); }); diff --git a/src/worker/service-worker.ts b/src/worker/service-worker.ts index eb03ec2..ecc6971 100644 --- a/src/worker/service-worker.ts +++ b/src/worker/service-worker.ts @@ -18,7 +18,6 @@ export async function initAuthServiceWorker( setSecret(secret); const { config, debug } = getConfig(urlConfig); getAuthState().then((state) => { - console.log('set config'); state.config = { config, providers, debug, basePath }; state.allowList = allowList; @@ -34,7 +33,7 @@ export async function initAuthServiceWorker( scope.addEventListener('activate', function (event) { log('Claiming control', event); - scope + return scope .skipWaiting() .then(() => scope.clients.claim()) .then(() => scope.clients.matchAll()) diff --git a/src/worker/state.test.ts b/src/worker/state.test.ts index b04f62a..7f1d05a 100644 --- a/src/worker/state.test.ts +++ b/src/worker/state.test.ts @@ -1,161 +1,189 @@ +import { IState, getAuthState, getProviderOptions, getProviderParams, saveAuthState } from './state'; +import { setSecret } from '../shared/db'; +import { setMockData } from '../shared/db.mock'; import { GrantFlow } from '../shared/enums'; -import { getAuthState, saveAuthState, getProviderParams, getProviderOptions, __setState, IState } from './state'; describe('worker/state', () => { - describe('getState', () => { - beforeEach(() => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - globalThis.caches = { - match: jest.fn(), - }; - jest.clearAllMocks(); - __setState(); - }); - - it('should return a default state object if no cached data exists', async () => { - jest.spyOn(globalThis.caches, 'match').mockResolvedValue(undefined); - - const result = await getAuthState(); + describe('getAuthState', () => { + it('should return the auth state', async () => { + setSecret('foo'); - expect(globalThis.caches.match).toHaveBeenCalledWith('state'); - expect(result).toEqual({ providers: {} }); - }); + const state = await getAuthState(); - it('should return the cached state object if it exists', async () => { - const cachedState = { - providers: { - exampleProvider: { - name: 'Example Provider', - clientId: 'exampleClientId', - authorizationEndpoint: 'https://example.com/authorize', - tokenEndpoint: 'https://example.com/token', + const initialState: IState = { + allowList: undefined, + config: { + basePath: '/auth', + config: {}, + debug: false, + providers: { + exampleProvider: { + grantType: GrantFlow.Token, + loginUrl: 'https://example.com/login', + }, }, }, + session: undefined, }; - jest.spyOn(globalThis.caches, 'match').mockResolvedValue(new Response(JSON.stringify(cachedState))); - const result = await getAuthState(); + state.allowList = undefined; + state.config = initialState.config; + state.session = undefined; - expect(globalThis.caches.match).toHaveBeenCalledWith('state'); - expect(result).toEqual(cachedState); - }); - }); + setMockData(JSON.stringify(initialState)); + + const newState = await getAuthState(); - describe('saveState', () => { - beforeEach(() => { - jest.clearAllMocks(); - __setState(); + expect(state).toEqual(newState); }); - it('should save the state to cache', async () => { - const cacheMock = { - put: jest.fn(), - }; - globalThis.caches.open = jest.fn().mockResolvedValue(cacheMock); + it('should work with non-primitive data and session data', async () => { + setSecret('foo'); + + const state = await getAuthState(); - const state: IState = { + const initialState: IState = { + allowList: undefined, config: { + basePath: '/auth', + config: {}, debug: false, providers: { exampleProvider: { grantType: GrantFlow.Token, - }, - }, - config: { - exampleProvider: { - clientId: 'exampleClientId', + loginUrl: 'https://example.com/login', + userInfoParser: (_data) => { + return { name: 'Foo Bar' }; + }, }, }, }, session: { + expiresAt: Date.now() + 1000, provider: 'exampleProvider', - accessToken: 'accessToken', - expiresAt: 1234567890, - refreshToken: 'refreshToken', + accessToken: 'mockAccessToken', tokenType: 'Bearer', - userInfo: '{"sub":"1234567890","name":"John Doe","email":"john.doe@example.com"}', }, + }; + + state.allowList = undefined; + state.config = initialState.config; + state.session = initialState.session; + + saveAuthState(state); + + const newState = await getAuthState(); + + expect(state).toEqual(newState); + }); + }); + + describe('getProviderParams', () => { + it('should return the provider params', async () => { + const state = await getAuthState(); + state.session = { + provider: 'foo', + accessToken: 'mockAccessToken', + tokenType: 'Bearer', + expiresAt: Date.now() + 1000, + }; + state.config = { + config: {}, providers: { - exampleProvider: { + foo: { grantType: GrantFlow.Token, + loginUrl: 'https://example.com/login', }, }, }; - __setState(state); await saveAuthState(state); - expect(global.caches.open).toHaveBeenCalledWith('v1'); - expect(cacheMock.put).toHaveBeenCalledTimes(1); - await cacheMock.put.mock.calls[0][1].text().then((text: string) => { - expect(text).toEqual(JSON.stringify(state)); + const params = await getProviderParams(); + + expect(params).toEqual({ + grantType: GrantFlow.Token, + loginUrl: 'https://example.com/login', }); }); - }); - describe('provider', () => { - let mockState: { - session?: unknown; - config: { - providers: Record; - config: Record; - }; - }; + it('should throw if user is not logged in', async () => { + expect(getProviderParams()).rejects.toThrow('No provider found'); + }); - beforeEach(() => { - mockState = { - session: { provider: 'google' }, - config: { - providers: { - google: { clientId: 'google-client-id' }, - }, - config: { - google: { scope: 'profile email' }, + it('should throw if there is no provider config', async () => { + const state = await getAuthState(); + state.session = { + provider: 'foo', + accessToken: 'mockAccessToken', + tokenType: 'Bearer', + expiresAt: Date.now() + 1000, + }; + state.config = { + config: {}, + providers: { + bar: { + grantType: GrantFlow.Token, + loginUrl: 'https://example.com/login', }, }, }; - jest.resetModules(); - __setState(mockState as unknown as IState); + await saveAuthState(state); + expect(getProviderParams()).rejects.toThrow('No provider params found (getProviderParams)'); }); + }); - afterEach(() => { - jest.restoreAllMocks(); - }); + describe('getProviderOptions', () => { + it('should return the provider options', async () => { + const state = await getAuthState(); + state.session = { + provider: 'foo', + accessToken: 'mockAccessToken', + tokenType: 'Bearer', + expiresAt: Date.now() + 1000, + }; + state.config = { + config: { + foo: { + clientId: 'mockClientId', + }, + }, + providers: {}, + }; - describe('getProviderParams', () => { - it('returns provider params if provider is found in state', async () => { - const providerParams = await getProviderParams(); - expect(providerParams).toEqual({ clientId: 'google-client-id' }); - }); + await saveAuthState(state); - it('throws error if provider is not found in state', async () => { - mockState.session = {}; - await expect(getProviderParams()).rejects.toThrow('No provider found'); - }); + const options = await getProviderOptions(); - it('throws error if provider params are not found in config', async () => { - delete mockState.config.providers.google; - await expect(getProviderParams()).rejects.toThrow('No provider params found'); + expect(options).toEqual({ + clientId: 'mockClientId', }); }); - describe('getProviderOptions', () => { - it('returns provider options if provider is found in state', async () => { - const providerOptions = await getProviderOptions(); - expect(providerOptions).toEqual({ scope: 'profile email' }); - }); + it('should throw if user is not logged in', async () => { + expect(getProviderOptions()).rejects.toThrow('No provider found'); + }); - it('throws error if provider is not found in state', async () => { - mockState.session = {}; - await expect(getProviderOptions()).rejects.toThrow('No provider found'); - }); + it('should throw if there is no provider config', async () => { + const state = await getAuthState(); + state.session = { + provider: 'foo', + accessToken: 'mockAccessToken', + tokenType: 'Bearer', + expiresAt: Date.now() + 1000, + }; + state.config = { + config: { + bar: { + clientId: 'mockClientId', + }, + }, + providers: {}, + }; - it('throws error if provider options are not found in config', async () => { - delete mockState.config.config.google; - await expect(getProviderOptions()).rejects.toThrow('No provider options found'); - }); + await saveAuthState(state); + expect(getProviderOptions()).rejects.toThrow('No provider options found'); }); }); }); diff --git a/src/worker/state.ts b/src/worker/state.ts index 2cbc954..8fc1980 100644 --- a/src/worker/state.ts +++ b/src/worker/state.ts @@ -23,16 +23,18 @@ export async function getAuthState(): Promise { if (!isPersistable()) { return cachedState; } - const storedState = await getData(SECURE_KEY); - if (!storedState) { - saveData(SECURE_KEY, JSON.stringify(cachedState)); + const sessionState = await getData(SECURE_KEY); + if (!sessionState) { + saveData(SECURE_KEY, JSON.stringify(cachedState?.session ?? null)); } - return storedState ? JSON.parse(storedState) : cachedState; + + cachedState.session = sessionState ? JSON.parse(sessionState) : null; + return cachedState; } export async function saveAuthState(newState: IState) { if (isPersistable()) { - return saveData(SECURE_KEY, JSON.stringify(newState)); + return saveData(SECURE_KEY, JSON.stringify(newState?.session ?? null)); } } diff --git a/src/worker/utils.test.ts b/src/worker/utils.test.ts index 3d4446b..cde2e72 100644 --- a/src/worker/utils.test.ts +++ b/src/worker/utils.test.ts @@ -1,12 +1,8 @@ -/** - * @jest-environment jsdom - */ - import { getAuthState } from './state'; import { getHashParams, log } from './utils'; jest.mock('./state', () => ({ - getState: jest.fn(), + getAuthState: jest.fn(), })); describe('worker/utils', () => { diff --git a/test/mock/localStorage.ts b/test/mock/localStorage.ts deleted file mode 100644 index 9df3f5a..0000000 --- a/test/mock/localStorage.ts +++ /dev/null @@ -1,27 +0,0 @@ -export class LocalStorageMock { - private store: Record = {}; - - public clear() { - this.store = {}; - } - - public getItem(key: string) { - return this.store[key] || null; - } - - public setItem(key: string, value: string) { - this.store[key] = String(value); - } - - public removeItem(key: string) { - delete this.store[key]; - } - - public get length() { - return Object.keys(this.store).length; - } - - public key(index: number) { - return Object.keys(this.store)[index]; - } -} diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..cd95ff2 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,28 @@ +/** + * @jest-environment jsdom + */ + +import 'fake-indexeddb/auto'; +import { IDBFactory } from 'fake-indexeddb'; +import { Crypto } from '@peculiar/webcrypto'; +import { TextEncoder, TextDecoder } from 'util'; + +global.TextEncoder = TextEncoder; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +global.TextDecoder = TextDecoder; +Object.defineProperty(global, 'crypto', { + value: new Crypto(), +}); + +import { clearMockData } from '../src/shared/db.mock'; +jest.mock('../src/shared/db', () => jest.requireActual('../src/shared/db.mock')); + +beforeEach(() => { + clearMockData(); + global.indexedDB = new IDBFactory(); +}); + +afterEach(() => { + jest.resetAllMocks(); +});