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:
Felipe Cardoso
2025-10-31 22:41:18 +01:00
parent 92a8699479
commit 092a82ee07
7 changed files with 232 additions and 31 deletions

36
frontend/jest.config.js Normal file
View 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
View 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();
}
});

View File

@@ -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",

View File

@@ -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",

View File

@@ -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:');

View File

@@ -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);
});
});

View File

@@ -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;
});