Add async-safe polyfills, Jest custom config, and improved token validation
- Introduced Web Crypto API polyfills (`@peculiar/webcrypto`) for Node.js to enable SSR-safe cryptography utilities. - Added Jest setup file for global mocks (e.g., `localStorage`, `sessionStorage`, and `TextEncoder/Decoder`). - Enhanced token validation behavior in `storage` tests to reject incomplete tokens. - Replaced runtime configuration validation with clamping using `parseIntSafe` constraints for improved reliability. - Updated `package.json` and `package-lock.json` to include new dependencies (`@peculiar/webcrypto` and related libraries).
This commit is contained in:
36
frontend/jest.config.js
Normal file
36
frontend/jest.config.js
Normal file
@@ -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: ['<rootDir>/jest.setup.js'],
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
testMatch: [
|
||||
'<rootDir>/tests/**/*.test.ts',
|
||||
'<rootDir>/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)
|
||||
86
frontend/jest.setup.js
Normal file
86
frontend/jest.setup.js
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
92
frontend/package-lock.json
generated
92
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user