/** * Tests unitaires DocmostJwtVerifier (R2.3b). * * Strategie : pas de mock — on signe a la volee avec `jose.SignJWT` (le meme * code que celui qu'utilise NestJS `JwtService` cote Docmost). Le verifier est * le code sous test, pas son entourage. */ import { randomBytes } from 'node:crypto'; import { SignJWT, exportJWK, generateKeyPair } from 'jose'; import { describe, expect, it } from 'vitest'; import { logger } from '../../src/lib/logger.js'; import { DocmostJwtVerifier, decodeJwtAlg, extractDocmostPermissions, } from '../../src/middleware/docmost-jwt-verifier.js'; // Secret 64 chars — au-dessus du minimum 32. const DEFAULT_SECRET = randomBytes(48).toString('hex'); const DEFAULT_ISSUER = 'Docmost'; interface SignOpts { secret?: string; issuer?: string; audience?: string; alg?: 'HS256' | 'HS384' | 'HS512'; expiresIn?: string; claims?: Record; } async function signHsJwt(opts: SignOpts = {}): Promise { const secret = opts.secret ?? DEFAULT_SECRET; const claims = opts.claims ?? { sub: 'user-uuid-1', workspaceId: 'ws-uuid-1', type: 'access', }; const builder = new SignJWT(claims) .setProtectedHeader({ alg: opts.alg ?? 'HS256' }) .setIssuedAt() .setIssuer(opts.issuer ?? DEFAULT_ISSUER) .setExpirationTime(opts.expiresIn ?? '5m'); if (opts.audience) builder.setAudience(opts.audience); return builder.sign(Buffer.from(secret, 'utf-8')); } function makeVerifier(overrides?: { secret?: string; issuer?: string; audience?: string }) { return new DocmostJwtVerifier({ secret: overrides?.secret ?? DEFAULT_SECRET, issuer: overrides?.issuer ?? DEFAULT_ISSUER, audience: overrides?.audience, logger, }); } describe('DocmostJwtVerifier — constructor', () => { it('throw si secret < 32 bytes', () => { expect( () => new DocmostJwtVerifier({ secret: 'x'.repeat(31), issuer: 'Docmost', logger }), ).toThrow(/secret too short/); }); it('throw si issuer vide', () => { expect(() => new DocmostJwtVerifier({ secret: DEFAULT_SECRET, issuer: '', logger })).toThrow( /issuer required/, ); }); it('construit ok avec secret 32 bytes minimum', () => { expect( () => new DocmostJwtVerifier({ secret: 'a'.repeat(32), issuer: 'Docmost', logger }), ).not.toThrow(); }); }); describe('DocmostJwtVerifier — verify happy paths', () => { it('JWT HS256 valide + issuer match -> ok + payload', async () => { const v = makeVerifier(); const token = await signHsJwt({ claims: { sub: 'jane-uuid', email: 'jane@acadenice.fr', workspaceId: 'ws-1', type: 'access', sessionId: 'sess-1', }, }); const { payload } = await v.verify(token); expect(payload.sub).toBe('jane-uuid'); expect(payload.email).toBe('jane@acadenice.fr'); expect(payload.workspaceId).toBe('ws-1'); expect(payload.type).toBe('access'); expect(payload.sessionId).toBe('sess-1'); }); it('JWT HS384 valide -> ok (algo accepte)', async () => { const v = makeVerifier(); const token = await signHsJwt({ alg: 'HS384' }); const { payload } = await v.verify(token); expect(payload.sub).toBe('user-uuid-1'); }); it('JWT HS512 valide -> ok', async () => { const v = makeVerifier(); const token = await signHsJwt({ alg: 'HS512' }); const { payload } = await v.verify(token); expect(payload.sub).toBe('user-uuid-1'); }); it('JWT sans claim acadenice_permissions -> ok, scopes vides cote caller', async () => { const v = makeVerifier(); const token = await signHsJwt(); const { payload } = await v.verify(token); expect(extractDocmostPermissions(payload)).toEqual([]); }); it('JWT avec claim acadenice_permissions[] -> scopes alimentes', async () => { const v = makeVerifier(); const token = await signHsJwt({ claims: { sub: 'u', workspaceId: 'w', type: 'access', acadenice_permissions: ['read:tables', 'write:tables'], }, }); const { payload } = await v.verify(token); expect(extractDocmostPermissions(payload)).toEqual(['read:tables', 'write:tables']); }); it('JWT avec audience matching quand verifier configure -> ok', async () => { const v = makeVerifier({ audience: 'formation-hub-bridge' }); const token = await signHsJwt({ audience: 'formation-hub-bridge' }); const { payload } = await v.verify(token); expect(payload.sub).toBe('user-uuid-1'); }); it('JWT sans audience claim quand verifier ne configure pas d audience -> ok', async () => { const v = makeVerifier(); const token = await signHsJwt(); const { payload } = await v.verify(token); expect(payload).toBeDefined(); }); }); describe('DocmostJwtVerifier — verify rejections', () => { it('JWT HS256 expired -> throws AUTH_INVALID', async () => { const v = makeVerifier(); const token = await signHsJwt({ expiresIn: '-1s' }); await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID', status: 401 }); }); it('JWT HS256 wrong issuer -> throws', async () => { const v = makeVerifier({ issuer: 'Docmost' }); const token = await signHsJwt({ issuer: 'EvilCorp' }); await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' }); }); it('JWT HS256 wrong signature (different secret) -> throws', async () => { const v = makeVerifier(); const token = await signHsJwt({ secret: randomBytes(48).toString('hex') }); await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' }); }); it('JWT RS256 (mauvais algo) -> throws (algorithm mismatch)', async () => { const v = makeVerifier(); const { privateKey } = await generateKeyPair('RS256'); const rsaToken = await new SignJWT({ sub: 'u', workspaceId: 'w', type: 'access', }) .setProtectedHeader({ alg: 'RS256' }) .setIssuedAt() .setIssuer(DEFAULT_ISSUER) .setExpirationTime('5m') .sign(privateKey); await expect(v.verify(rsaToken)).rejects.toMatchObject({ code: 'AUTH_INVALID' }); }); it('JWT audience mismatch quand verifier exige audience -> throws', async () => { const v = makeVerifier({ audience: 'formation-hub-bridge' }); const token = await signHsJwt({ audience: 'other-app' }); await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' }); }); it('JWT sans audience quand verifier exige audience -> throws', async () => { const v = makeVerifier({ audience: 'formation-hub-bridge' }); const token = await signHsJwt(); await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' }); }); it('JWT sans claim sub -> throws (claim requis)', async () => { const v = makeVerifier(); const token = await signHsJwt({ claims: { workspaceId: 'w', type: 'access' }, }); await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' }); }); it('JWT sans claim workspaceId -> throws', async () => { const v = makeVerifier(); const token = await signHsJwt({ claims: { sub: 'u', type: 'access' }, }); await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' }); }); it('JWT sans claim type -> throws', async () => { const v = makeVerifier(); const token = await signHsJwt({ claims: { sub: 'u', workspaceId: 'w' }, }); await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' }); }); it('JWT malforme -> throws', async () => { const v = makeVerifier(); await expect(v.verify('not.a.jwt')).rejects.toMatchObject({ code: 'AUTH_INVALID' }); }); }); describe('decodeJwtAlg', () => { it('retourne alg pour un JWT HS256', async () => { const token = await signHsJwt(); expect(decodeJwtAlg(token)).toBe('HS256'); }); it('retourne alg pour un JWT HS384', async () => { const token = await signHsJwt({ alg: 'HS384' }); expect(decodeJwtAlg(token)).toBe('HS384'); }); it('retourne alg pour un JWT RS256', async () => { const { privateKey } = await generateKeyPair('RS256'); const token = await new SignJWT({}) .setProtectedHeader({ alg: 'RS256' }) .setIssuedAt() .sign(privateKey); expect(decodeJwtAlg(token)).toBe('RS256'); }); it('retourne null pour une string sans dot', () => { expect(decodeJwtAlg('garbage')).toBeNull(); }); it('retourne null pour un header non-base64', () => { expect(decodeJwtAlg('!!!!.payload.sig')).toBeNull(); }); it('retourne null si JSON sans alg', () => { const headerB64 = Buffer.from(JSON.stringify({ typ: 'JWT' }), 'utf-8').toString('base64url'); expect(decodeJwtAlg(`${headerB64}.x.y`)).toBeNull(); }); }); describe('extractDocmostPermissions', () => { it('retourne [] si claim absent', () => { expect(extractDocmostPermissions({ sub: 'u', workspaceId: 'w', type: 'access' })).toEqual([]); }); it('retourne [] si claim pas un array', () => { expect( extractDocmostPermissions({ sub: 'u', workspaceId: 'w', type: 'access', // biome-ignore lint/suspicious/noExplicitAny: test du runtime tolerance acadenice_permissions: 'foo' as any, }), ).toEqual([]); }); it('filtre les non-strings et vides', () => { expect( extractDocmostPermissions({ sub: 'u', workspaceId: 'w', type: 'access', // biome-ignore lint/suspicious/noExplicitAny: test du runtime tolerance acadenice_permissions: ['ok', 1, '', null, 'good'] as any, }), ).toEqual(['ok', 'good']); }); }); // Petite verif que `exportJWK` est utilise nulle part (pas de warning unused). void exportJWK;