2026-03-27 14:50:34 +09:00
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
|
import Fastify from 'fastify';
|
2026-03-28 08:19:48 +09:00
|
|
|
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
|
|
|
|
import { asValue } from 'awilix';
|
2026-03-27 14:50:34 +09:00
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
}));
|
|
|
|
|
|
2026-03-28 08:19:48 +09:00
|
|
|
const { mockFindByKeycloakId, mockUpsertFromToken } = vi.hoisted(() => ({
|
|
|
|
|
mockFindByKeycloakId: vi.fn(),
|
|
|
|
|
mockUpsertFromToken: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-03-27 14:50:34 +09:00
|
|
|
import authPlugin from './auth.plugin.js';
|
|
|
|
|
import * as jose from 'jose';
|
|
|
|
|
|
|
|
|
|
describe('auth.plugin', () => {
|
2026-03-28 08:19:48 +09:00
|
|
|
async function buildApp() {
|
2026-03-27 14:50:34 +09:00
|
|
|
const app = Fastify({ logger: false });
|
2026-03-28 08:19:48 +09:00
|
|
|
await app.register(fastifyAwilixPlugin, {
|
|
|
|
|
disposeOnClose: true,
|
|
|
|
|
disposeOnResponse: true,
|
|
|
|
|
strictBooleanEnforced: true,
|
|
|
|
|
});
|
|
|
|
|
app.diContainer.register({
|
|
|
|
|
usersRepository: asValue({
|
|
|
|
|
findByKeycloakId: mockFindByKeycloakId,
|
|
|
|
|
upsertFromToken: mockUpsertFromToken,
|
|
|
|
|
}),
|
|
|
|
|
});
|
2026-03-27 14:50:34 +09:00
|
|
|
return app;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
2026-03-28 08:19:48 +09:00
|
|
|
mockFindByKeycloakId.mockResolvedValue({ householdIds: ['hh1'] });
|
2026-03-27 14:50:34 +09:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('skips auth for routes marked as public', async () => {
|
2026-03-28 08:19:48 +09:00
|
|
|
const app = await buildApp();
|
2026-03-27 14:50:34 +09:00
|
|
|
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 () => {
|
2026-03-28 08:19:48 +09:00
|
|
|
const app = await buildApp();
|
2026-03-27 14:50:34 +09:00
|
|
|
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 () => {
|
2026-03-28 08:19:48 +09:00
|
|
|
const app = await buildApp();
|
2026-03-27 14:50:34 +09:00
|
|
|
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'));
|
|
|
|
|
|
2026-03-28 08:19:48 +09:00
|
|
|
const app = await buildApp();
|
2026-03-27 14:50:34 +09:00
|
|
|
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);
|
2026-03-28 08:19:48 +09:00
|
|
|
mockFindByKeycloakId.mockResolvedValue({ householdIds: ['hh1'] });
|
2026-03-27 14:50:34 +09:00
|
|
|
|
2026-03-28 08:19:48 +09:00
|
|
|
const app = await buildApp();
|
2026-03-27 14:50:34 +09:00
|
|
|
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'],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-28 08:19:48 +09:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-27 14:50:34 +09:00
|
|
|
it('handles missing optional fields in JWT payload', async () => {
|
|
|
|
|
vi.mocked(jose.jwtVerify).mockResolvedValue({
|
|
|
|
|
payload: {
|
2026-03-28 08:19:48 +09:00
|
|
|
// sub, email, preferred_username, realm_access all missing
|
2026-03-27 14:50:34 +09:00
|
|
|
iss: 'http://localhost:8080/realms/meshitrack',
|
|
|
|
|
aud: 'meshitrack-api',
|
|
|
|
|
},
|
|
|
|
|
protectedHeader: { alg: 'RS256' },
|
|
|
|
|
key: {} as never,
|
|
|
|
|
} as never);
|
2026-03-28 08:19:48 +09:00
|
|
|
mockFindByKeycloakId.mockResolvedValue(null);
|
|
|
|
|
mockUpsertFromToken.mockResolvedValue({ householdIds: [] });
|
2026-03-27 14:50:34 +09:00
|
|
|
|
2026-03-28 08:19:48 +09:00
|
|
|
const app = await buildApp();
|
2026-03-27 14:50:34 +09:00
|
|
|
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: [],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|