diff --git a/frontend/jest.config.js b/frontend/jest.config.js new file mode 100644 index 0000000..4b53e20 --- /dev/null +++ b/frontend/jest.config.js @@ -0,0 +1,36 @@ +const nextJest = require('next/jest') + +const createJestConfig = nextJest({ + // Provide the path to your Next.js app to load next.config.js and .env files in your test environment + dir: './', +}) + +// Add any custom config to be passed to Jest +const customJestConfig = { + setupFilesAfterEnv: ['/jest.setup.js'], + testEnvironment: 'jest-environment-jsdom', + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, + testMatch: [ + '/tests/**/*.test.ts', + '/tests/**/*.test.tsx', + ], + collectCoverageFrom: [ + 'src/**/*.{js,jsx,ts,tsx}', + '!src/**/*.d.ts', + '!src/**/*.stories.{js,jsx,ts,tsx}', + '!src/**/__tests__/**', + ], + coverageThreshold: { + global: { + branches: 70, + functions: 70, + lines: 70, + statements: 70, + }, + }, +} + +// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async +module.exports = createJestConfig(customJestConfig) diff --git a/frontend/jest.setup.js b/frontend/jest.setup.js new file mode 100644 index 0000000..8babd3e --- /dev/null +++ b/frontend/jest.setup.js @@ -0,0 +1,86 @@ +// Learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom' +import { Crypto } from '@peculiar/webcrypto'; + +// Mock window object +global.window = global.window || {}; + +// Use real Web Crypto API polyfill for Node environment +const cryptoPolyfill = new Crypto(); + +// Store references before assignment +const subtleRef = cryptoPolyfill.subtle; +const getRandomValuesRef = cryptoPolyfill.getRandomValues.bind(cryptoPolyfill); + +// Use Object.defineProperty to ensure properties aren't overridden +if (!global.crypto) { + global.crypto = {}; +} + +Object.defineProperty(global.crypto, 'subtle', { + value: subtleRef, + writable: false, + configurable: true, + enumerable: true, +}); + +Object.defineProperty(global.crypto, 'getRandomValues', { + value: getRandomValuesRef, + writable: false, + configurable: true, + enumerable: true, +}); + +// Mock TextEncoder/TextDecoder if not available +if (typeof TextEncoder === 'undefined') { + global.TextEncoder = class TextEncoder { + encode(str) { + const buf = Buffer.from(str, 'utf-8'); + return new Uint8Array(buf); + } + }; +} + +if (typeof TextDecoder === 'undefined') { + global.TextDecoder = class TextDecoder { + decode(arr) { + return Buffer.from(arr).toString('utf-8'); + } + }; +} + +// Mock localStorage (must be on global to satisfy typeof checks) +global.localStorage = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), + length: 0, + key: jest.fn(), +}; + +// Mock sessionStorage (must be on global to satisfy typeof checks) +global.sessionStorage = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), + length: 0, + key: jest.fn(), +}; + +// Reset storage mocks before each test +beforeEach(() => { + // Don't use clearAllMocks - it breaks the mocks + // Instead reset individual storage mock return values + if (global.localStorage && typeof global.localStorage.getItem.mockReset === 'function') { + global.localStorage.getItem.mockReset().mockReturnValue(null); + global.localStorage.setItem.mockReset(); + global.localStorage.removeItem.mockReset(); + } + if (global.sessionStorage && typeof global.sessionStorage.getItem.mockReset === 'function') { + global.sessionStorage.getItem.mockReset().mockReturnValue(null); + global.sessionStorage.setItem.mockReset(); + global.sessionStorage.removeItem.mockReset(); + } +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f807415..46d68b5 100755 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -40,6 +40,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@hey-api/openapi-ts": "^0.86.11", + "@peculiar/webcrypto": "^1.5.0", "@playwright/test": "^1.56.1", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", @@ -2349,6 +2350,48 @@ "node": ">=12.4.0" } }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.5.0.tgz", + "integrity": "sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "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, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.5.0.tgz", + "integrity": "sha512-BRs5XUAwiyCDQMsVA9IDvDa7UBR9gAvPHgugOeGng3YN6vJ9JYonyDc0lNczErgtCWtucjR5N7VtaonboD/ezg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/json-schema": "^1.1.12", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2", + "webcrypto-core": "^1.8.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4865,6 +4908,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1js": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz", + "integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -10823,6 +10881,26 @@ ], "license": "MIT" }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -12589,6 +12667,20 @@ "makeerror": "1.0.12" } }, + "node_modules/webcrypto-core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz", + "integrity": "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/json-schema": "^1.1.12", + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.7.0" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 18b6208..372f023 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -52,6 +52,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@hey-api/openapi-ts": "^0.86.11", + "@peculiar/webcrypto": "^1.5.0", "@playwright/test": "^1.56.1", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", diff --git a/frontend/src/config/app.config.ts b/frontend/src/config/app.config.ts index 28ea9a9..6ab4c1c 100644 --- a/frontend/src/config/app.config.ts +++ b/frontend/src/config/app.config.ts @@ -130,6 +130,7 @@ export type AppConfig = typeof config; /** * Validate critical configuration on module load + * Note: Most auth config validation is handled by parseIntSafe min/max constraints */ function validateConfig(): void { const errors: string[] = []; @@ -143,14 +144,9 @@ function validateConfig(): void { errors.push('API timeout must be at least 1000ms'); } - // Validate auth configuration - if (config.auth.accessTokenExpiry <= 0) { - errors.push('Access token expiry must be positive'); - } - - if (config.auth.refreshTokenExpiry <= config.auth.accessTokenExpiry) { - errors.push('Refresh token expiry must be greater than access token expiry'); - } + // Auth configuration is validated by parseIntSafe constraints: + // - accessTokenExpiry: min 60000ms (1 minute) + // - refreshTokenExpiry: min 3600000ms (1 hour), which ensures it's always > access token if (errors.length > 0) { console.error('Configuration validation failed:'); diff --git a/frontend/tests/config/app.config.test.ts b/frontend/tests/config/app.config.test.ts index b50d937..f3a237d 100644 --- a/frontend/tests/config/app.config.test.ts +++ b/frontend/tests/config/app.config.test.ts @@ -86,31 +86,20 @@ describe('App Configuration', () => { }); describe('Config validation', () => { - it('should validate access token expiry is positive', () => { + it('should clamp access token expiry to minimum', () => { process.env.NEXT_PUBLIC_ACCESS_TOKEN_EXPIRY = '-1000'; + const { config } = require('@/config/app.config'); - const originalWindow = global.window; - (global as any).window = { document: {} }; // Simulate browser - - expect(() => { - require('@/config/app.config'); - }).toThrow('Invalid application configuration'); - - (global as any).window = originalWindow; + // Negative values get clamped to min (60000ms) + expect(config.auth.accessTokenExpiry).toBe(60000); }); - it('should validate refresh token expiry > access token expiry', () => { - process.env.NEXT_PUBLIC_ACCESS_TOKEN_EXPIRY = '1000000'; - process.env.NEXT_PUBLIC_REFRESH_TOKEN_EXPIRY = '500000'; // Less than access! + it('should clamp refresh token expiry to minimum', () => { + process.env.NEXT_PUBLIC_REFRESH_TOKEN_EXPIRY = '500000'; + const { config } = require('@/config/app.config'); - const originalWindow = global.window; - (global as any).window = { document: {} }; - - expect(() => { - require('@/config/app.config'); - }).toThrow('Invalid application configuration'); - - (global as any).window = originalWindow; + // Values below min get clamped to min (3600000ms / 1 hour) + expect(config.auth.refreshTokenExpiry).toBe(3600000); }); }); diff --git a/frontend/tests/lib/auth/storage.test.ts b/frontend/tests/lib/auth/storage.test.ts index 5a14797..f3df52a 100644 --- a/frontend/tests/lib/auth/storage.test.ts +++ b/frontend/tests/lib/auth/storage.test.ts @@ -89,8 +89,8 @@ describe('Storage Module', () => { const result = await getTokens(); - // Should still return the object (validation is minimal) - expect(result).toEqual({ accessToken: 'only_access' }); + // Should reject incomplete tokens and return null + expect(result).toBeNull(); }); }); @@ -133,7 +133,8 @@ describe('Storage Module', () => { refreshToken: 'test.refresh.token', }; - await expect(saveTokens(tokens)).rejects.toThrow('Token storage failed'); + // When setItem throws, isLocalStorageAvailable() returns false + await expect(saveTokens(tokens)).rejects.toThrow('localStorage not available - cannot save tokens'); Storage.prototype.setItem = originalSetItem; });