/** * Unit tests for src/utils/jwt.ts */ import crypto from 'crypto'; import { signToken, verifyToken, decodeToken, getTokenExpiresIn } from '../../../src/utils/jwt'; import { ITokenPayload } from '../../../src/types/index'; import { v4 as uuidv4 } from 'uuid'; // Generate a test RSA key pair for testing const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); describe('jwt utils', () => { const testPayload: Omit = { sub: uuidv4(), client_id: uuidv4(), scope: 'agents:read agents:write', jti: uuidv4(), }; describe('signToken()', () => { it('should return a non-empty JWT string', () => { const token = signToken(testPayload, privateKey); expect(typeof token).toBe('string'); expect(token.length).toBeGreaterThan(0); }); it('should return a JWT with three parts separated by dots', () => { const token = signToken(testPayload, privateKey); const parts = token.split('.'); expect(parts).toHaveLength(3); }); it('should include iat and exp in the payload', () => { const before = Math.floor(Date.now() / 1000); const token = signToken(testPayload, privateKey); const decoded = decodeToken(token); const after = Math.floor(Date.now() / 1000); expect(decoded).not.toBeNull(); if (decoded) { expect(decoded.iat).toBeGreaterThanOrEqual(before); expect(decoded.iat).toBeLessThanOrEqual(after); expect(decoded.exp).toBe(decoded.iat + 3600); } }); }); describe('verifyToken()', () => { it('should verify and return the payload for a valid token', () => { const token = signToken(testPayload, privateKey); const payload = verifyToken(token, publicKey); expect(payload.sub).toBe(testPayload.sub); expect(payload.client_id).toBe(testPayload.client_id); expect(payload.scope).toBe(testPayload.scope); expect(payload.jti).toBe(testPayload.jti); }); it('should throw for a token signed with a different private key', () => { const { privateKey: otherPrivateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); const token = signToken(testPayload, otherPrivateKey); expect(() => verifyToken(token, publicKey)).toThrow(); }); it('should throw for a tampered token', () => { const token = signToken(testPayload, privateKey); const parts = token.split('.'); // Tamper the payload const tamperedToken = `${parts[0]}.TAMPERED.${parts[2]}`; expect(() => verifyToken(tamperedToken, publicKey)).toThrow(); }); }); describe('decodeToken()', () => { it('should decode a valid token without verifying the signature', () => { const token = signToken(testPayload, privateKey); const decoded = decodeToken(token); expect(decoded).not.toBeNull(); expect(decoded?.sub).toBe(testPayload.sub); }); it('should return null for a malformed token', () => { const result = decodeToken('not.a.valid.token'); // jsonwebtoken.decode returns null for fully invalid tokens but // may parse some parts — we handle both cases expect(result === null || typeof result === 'object').toBe(true); }); it('should return null for an empty string', () => { const result = decodeToken(''); expect(result).toBeNull(); }); }); describe('getTokenExpiresIn()', () => { it('should return 3600', () => { expect(getTokenExpiresIn()).toBe(3600); }); }); });