feat(F-154): completed feature

This commit is contained in:
chattie
2026-08-22 08:35:46 +02:00
parent 1415590106
commit 5098723284
19 changed files with 535 additions and 114 deletions

View File

@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import { registerSecurityRoutes } from './security.routes.js';
import type { SecurityRoutesDeps } from '../index.js';
interface QueryResult {
rows: unknown[];
}
/** F-154: mock pg.Pool returning configurable rows keyed by SQL family. */
function mockPool(countRows: unknown[], selectRows: unknown[]) {
const query = vi.fn(async (sql: string, _params: unknown[]): Promise<QueryResult> => {
if (String(sql).includes('COUNT')) return { rows: countRows };
return { rows: selectRows };
});
const pool = { query } as unknown as import('pg').Pool;
return { pool, query };
}
const created: FastifyInstance[] = [];
/** Builds a minimal Fastify app with security routes + mocked deps (no DB). */
async function buildAdminApp(pool: unknown) {
const app = Fastify();
created.push(app);
const deps = {
pool,
authenticate: vi.fn().mockResolvedValue({ id: 'a1', role: 'admin' }),
rateLimiter: vi.fn(),
auditLogger: vi.fn(),
} as unknown as SecurityRoutesDeps;
await registerSecurityRoutes(app, deps);
await app.ready();
const query = (pool as { query: ReturnType<typeof vi.fn> }).query;
return { app, query };
}
const ADMIN_USER = {
id: 'a1',
email: 'ana@example.com',
role: 'admin',
created_at: new Date('2026-01-01T00:00:00Z'),
};
const EDITOR_USER = {
id: 'e1',
email: 'e@example.com',
role: 'editor',
created_at: new Date('2026-01-02T00:00:00Z'),
};
afterEach(async () => {
for (const app of created) {
try {
await app.close();
} catch {
// ignore
}
}
created.length = 0;
});
describe('GET /admin/users — F-154 internal-only by default', () => {
it('returns only internal/backoffice users and never storefront customers', async () => {
const { app, query } = await buildAdminApp(
mockPool([{ count: '1' }], [ADMIN_USER, EDITOR_USER]),
);
const res = await app.inject({ method: 'GET', url: '/admin/users' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body ?? '') as {
items: Array<{ id: string; role: string }>;
total: number;
};
expect(body.items).toHaveLength(2);
expect(body.items.every((u) => u.role !== 'customer')).toBe(true);
expect(body.items.map((u) => u.id)).toEqual(['a1', 'e1']);
expect(body.total).toBe(1);
const selectSql = query.mock.calls[1]![0] as string;
expect(selectSql).toContain("role <> 'customer'");
});
it('narrows with ?role=admin within staff (parametrized)', async () => {
const { app, query } = await buildAdminApp(mockPool([{ count: '1' }], [ADMIN_USER]));
const res = await app.inject({ method: 'GET', url: '/admin/users?role=admin' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body ?? '') as { items: Array<{ id: string }> };
expect(body.items).toHaveLength(1);
expect(body.items[0]!.id).toBe('a1');
const selectSql = query.mock.calls[1]![0] as string;
expect(selectSql).toContain("role <> 'customer'");
expect(selectSql).toContain('role = $1');
expect(query.mock.calls[1]![1]).toContain('admin');
});
it('yields an empty set (never customers) when ?role=customer is requested', async () => {
const { app, query } = await buildAdminApp(mockPool([{ count: '0' }], []));
const res = await app.inject({ method: 'GET', url: '/admin/users?role=customer' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body ?? '') as { items: Array<{ id: string }> };
expect(body.items).toHaveLength(0);
const selectSql = query.mock.calls[1]![0] as string;
expect(selectSql).toContain("role <> 'customer'");
expect(selectSql).toContain('role = $1');
});
});

View File

@@ -189,7 +189,9 @@ export async function registerSecurityRoutes(
const admin = await deps.authenticate(request);
requireRole(admin, 'admin');
const { limit, offset, role, q } = parseJson(userQuerySchema, request.query ?? {});
const conditions: string[] = [];
// F-154: Users module == internal/backoffice only (never storefront customers).
// Base literal excludes customers; ?role= then narrows within internal staff.
const conditions: string[] = ["role <> 'customer'"];
const values: unknown[] = [];
let i = 1;
if (role) {
@@ -200,7 +202,7 @@ export async function registerSecurityRoutes(
conditions.push(`(email ILIKE $${i++})`);
values.push(`%${q}%`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const where = `WHERE ${conditions.join(' AND ')}`;
const countResult = await deps.pool.query<{ count: string }>(
`SELECT COUNT(*) FROM identity_users ${where}`,
values,