feat(F-154): completed feature
This commit is contained in:
110
project/src/modules/security/api/security.routes.test.ts
Normal file
110
project/src/modules/security/api/security.routes.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user