import { describe, it, expect, vi, beforeEach } from 'vitest'; import Fastify from 'fastify'; import { fastifyAwilixPlugin } from '@fastify/awilix'; import { asValue } from 'awilix'; 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 }, })); const { mockFindByKeycloakId, mockUpsertFromToken } = vi.hoisted(() => ({ mockFindByKeycloakId: vi.fn(), mockUpsertFromToken: vi.fn(), })); import authPlugin from '../../src/plugins/auth.plugin.js'; import * as jose from 'jose'; describe('auth.plugin', () => { async function buildApp() { const app = Fastify({ logger: false }); await app.register(fastifyAwilixPlugin, { disposeOnClose: true, disposeOnResponse: true, strictBooleanEnforced: true, }); app.diContainer.register({ usersRepository: asValue({ findByKeycloakId: mockFindByKeycloakId, upsertFromToken: mockUpsertFromToken, }), }); return app; } beforeEach(() => { vi.clearAllMocks(); mockFindByKeycloakId.mockResolvedValue({ householdIds: ['hh1'] }); }); it('skips auth for routes marked as public', async () => { const app = await 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 = await 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 = await 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 = await 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'] }, iss: 'http://localhost:8080/realms/meshitrack', aud: 'meshitrack-api', }, protectedHeader: { alg: 'RS256' }, key: {} as never, } as never); mockFindByKeycloakId.mockResolvedValue({ householdIds: ['hh1'] }); const app = await 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('rethrows non-JOSE errors as-is', async () => { vi.mocked(jose.jwtVerify).mockRejectedValue(new Error('Network failure')); const app = await 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 some-token' }, }); expect(res.statusCode).toBe(500); }); it('handles missing optional fields in JWT payload', async () => { vi.mocked(jose.jwtVerify).mockResolvedValue({ payload: { // sub, email, preferred_username, realm_access all missing iss: 'http://localhost:8080/realms/meshitrack', aud: 'meshitrack-api', }, protectedHeader: { alg: 'RS256' }, key: {} as never, } as never); mockFindByKeycloakId.mockResolvedValue(null); mockUpsertFromToken.mockResolvedValue({ householdIds: [] }); const app = await 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: [], }); }); });