import { describe, it, expect, vi, beforeEach } from 'vitest'; import Fastify from 'fastify'; const { MockJOSEError } = vi.hoisted(() => ({ MockJOSEError: class JOSEError extends Error {}, })); // Mock jose before importing the plugin vi.mock('jose', () => ({ createRemoteJWKSet: vi.fn(() => 'mock-jwks'), jwtVerify: vi.fn(), errors: { JOSEError: MockJOSEError }, })); import authPlugin from './auth.plugin.js'; import * as jose from 'jose'; describe('auth.plugin', () => { function buildApp() { const app = Fastify({ logger: false }); return app; } beforeEach(() => { vi.clearAllMocks(); }); it('skips auth for routes marked as public', async () => { const app = buildApp(); await app.register(authPlugin); app.get('/public', { config: { public: true } as never }, async () => ({ ok: true })); await app.ready(); const res = await app.inject({ method: 'GET', url: '/public' }); expect(res.statusCode).toBe(200); }); it('throws 401 when no Authorization header', async () => { const app = buildApp(); await app.register(authPlugin); app.get('/protected', async () => ({ ok: true })); await app.ready(); const res = await app.inject({ method: 'GET', url: '/protected' }); expect(res.statusCode).toBe(401); expect(res.json().message).toContain('Missing or invalid Authorization'); }); it('throws 401 when Authorization header is not Bearer', async () => { const app = buildApp(); await app.register(authPlugin); app.get('/protected', async () => ({ ok: true })); await app.ready(); const res = await app.inject({ method: 'GET', url: '/protected', headers: { authorization: 'Basic abc123' }, }); expect(res.statusCode).toBe(401); }); it('throws 401 when token is invalid', async () => { vi.mocked(jose.jwtVerify).mockRejectedValue(new MockJOSEError('Invalid token')); const app = buildApp(); await app.register(authPlugin); app.get('/protected', async () => ({ ok: true })); await app.ready(); const res = await app.inject({ method: 'GET', url: '/protected', headers: { authorization: 'Bearer invalid-token' }, }); expect(res.statusCode).toBe(401); expect(res.json().message).toContain('Invalid or expired token'); }); it('sets request.user from valid JWT payload', async () => { vi.mocked(jose.jwtVerify).mockResolvedValue({ payload: { sub: 'kc-1', email: 'test@example.com', preferred_username: 'testuser', realm_access: { roles: ['member'] }, householdIds: ['hh1'], iss: 'http://localhost:8080/realms/meshitrack', aud: 'meshitrack-api', }, protectedHeader: { alg: 'RS256' }, key: {} as never, } as never); const app = buildApp(); await app.register(authPlugin); let capturedUser: unknown; app.get('/protected', async (request) => { capturedUser = request.user; return { ok: true }; }); await app.ready(); const res = await app.inject({ method: 'GET', url: '/protected', headers: { authorization: 'Bearer valid-token' }, }); expect(res.statusCode).toBe(200); expect(capturedUser).toEqual({ keycloakId: 'kc-1', email: 'test@example.com', displayName: 'testuser', roles: ['member'], householdIds: ['hh1'], }); }); it('handles missing optional fields in JWT payload', async () => { vi.mocked(jose.jwtVerify).mockResolvedValue({ payload: { // sub, email, preferred_username, realm_access, householdIds all missing iss: 'http://localhost:8080/realms/meshitrack', aud: 'meshitrack-api', }, protectedHeader: { alg: 'RS256' }, key: {} as never, } as never); const app = buildApp(); await app.register(authPlugin); let capturedUser: unknown; app.get('/protected', async (request) => { capturedUser = request.user; return { ok: true }; }); await app.ready(); const res = await app.inject({ method: 'GET', url: '/protected', headers: { authorization: 'Bearer token' }, }); expect(res.statusCode).toBe(200); expect(capturedUser).toEqual({ keycloakId: '', email: '', displayName: '', roles: [], householdIds: [], }); }); });