Tests refactor

This commit is contained in:
Aerilyn Weber 2026-05-19 11:06:03 +09:00
parent 245520fb50
commit 99134d8556
165 changed files with 911 additions and 531 deletions

View file

@ -0,0 +1,199 @@
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: [],
});
});
});

View file

@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Fastify from 'fastify';
import { fastifyAwilixPlugin } from '@fastify/awilix';
import { asValue } from 'awilix';
// Mock jose for the auth plugin dependency
vi.mock('jose', () => ({
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
jwtVerify: vi.fn().mockResolvedValue({
payload: {
sub: 'kc-1',
email: 'test@example.com',
preferred_username: 'testuser',
realm_access: { roles: ['member'] },
},
protectedHeader: { alg: 'RS256' },
key: {},
}),
}));
const { mockFindByKeycloakId } = vi.hoisted(() => ({
mockFindByKeycloakId: vi.fn(),
}));
import authPlugin from '../../src/plugins/auth.plugin.js';
import householdPlugin from '../../src/plugins/household.plugin.js';
describe('household.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: vi.fn().mockResolvedValue({ householdIds: ['hh1'] }),
}),
});
await app.register(authPlugin);
await app.register(householdPlugin);
return app;
}
beforeEach(() => {
vi.clearAllMocks();
mockFindByKeycloakId.mockResolvedValue({ householdIds: ['hh1'] });
});
it('skips household check for public routes', async () => {
const app = await buildApp();
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('skips household check for routes with skipHousehold', async () => {
const app = await buildApp();
app.get('/skip', { config: { skipHousehold: true } as never }, async () => ({ ok: true }));
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/skip',
headers: { authorization: 'Bearer valid' },
});
expect(res.statusCode).toBe(200);
});
it('throws 403 when user does not belong to household', async () => {
const app = await buildApp();
app.get('/households/:householdId/data', async () => ({ ok: true }));
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/households/hh-unknown/data',
headers: { authorization: 'Bearer valid' },
});
expect(res.statusCode).toBe(403);
expect(res.json().message).toContain('do not belong');
});
it('sets request.householdId when user belongs to household', async () => {
const app = await buildApp();
let capturedId: string | undefined;
app.get('/households/:householdId/data', async (request) => {
capturedId = request.householdId;
return { ok: true };
});
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/households/hh1/data',
headers: { authorization: 'Bearer valid' },
});
expect(res.statusCode).toBe(200);
expect(capturedId).toBe('hh1');
});
it('skips household check when route has no householdId param', async () => {
const app = await buildApp();
app.get('/no-household', async () => ({ ok: true }));
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/no-household',
headers: { authorization: 'Bearer valid' },
});
expect(res.statusCode).toBe(200);
});
});

View file

@ -0,0 +1,46 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Fastify from 'fastify';
// Mock mongoose and awilix before importing the plugin
vi.mock('mongoose', () => ({
default: {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock('@fastify/awilix', () => ({
diContainer: {
register: vi.fn(),
},
}));
import mongoosePlugin from '../../src/plugins/mongoose.plugin.js';
import mongoose from 'mongoose';
import { diContainer } from '@fastify/awilix';
describe('mongoose.plugin', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('connects to MongoDB on registration', async () => {
const app = Fastify({ logger: false });
await app.register(mongoosePlugin);
await app.ready();
expect(mongoose.connect).toHaveBeenCalled();
expect(diContainer.register).toHaveBeenCalled();
await app.close();
});
it('disconnects from MongoDB on close', async () => {
const app = Fastify({ logger: false });
await app.register(mongoosePlugin);
await app.ready();
await app.close();
expect(mongoose.disconnect).toHaveBeenCalled();
});
});