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

@@ -145,7 +145,7 @@ export default function AdminUsersPage() {
<select value={filterRole} onChange={e => setFilterRole(e.target.value)}
className="px-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
<option value="">Todos los roles</option>
<option value="admin">Admin</option><option value="editor">Editor</option><option value="customer">Customer</option>
<option value="admin">Admin</option><option value="editor">Editor</option>
</select>
</div>

View File

@@ -159,7 +159,11 @@ describe.skipIf(!hasDb)('users + RBAC flows (real PostgreSQL)', () => {
});
expect(asAdmin.statusCode).toBe(200);
const body = asAdmin.json() as { items: Array<{ id: string }> };
expect(body.items.some((item) => item.id === anaId)).toBe(true);
// F-154: /users now serves storefront customers only (role 'customer').
// ana was promoted to 'admin' -> no longer a customer -> excluded from the list.
// ben is a storefront customer -> included.
expect(body.items.some((item) => item.id === benId)).toBe(true);
expect(body.items.some((item) => item.id === anaId)).toBe(false);
// Admin can also read another user's profile (owner-or-admin).
const adminReadsBen = await app.inject({

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,

View File

@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest';
import { PgProfileRepository } from './pg-profile-repository.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 CUSTOMER_ROW = {
user_id: 'c1',
email: 'ben@example.com',
role: 'customer',
display_name: 'Ben',
phone: '111',
created_at: new Date('2026-01-01T00:00:00Z'),
};
describe('PgProfileRepository.listCustomers — F-154 customers-only', () => {
it('always restricts the list query to role = customer', async () => {
const { pool, query } = mockPool([{ cnt: '1' }], [CUSTOMER_ROW]);
const repo = new PgProfileRepository(pool);
const result = await repo.listCustomers({ offset: 0, limit: 20 });
expect(result.total).toBe(1);
expect(result.items).toHaveLength(1);
expect(result.items[0]!.role).toBe('customer');
expect(result.items[0]!.email).toBe('ben@example.com');
const selectSql = query.mock.calls[1]![0] as string;
expect(selectSql).toContain("iu.role = 'customer'");
});
it('applies the customer filter to the count query too', async () => {
const { pool, query } = mockPool([{ cnt: '1' }], [CUSTOMER_ROW]);
const repo = new PgProfileRepository(pool);
await repo.listCustomers({ offset: 0, limit: 20 });
const countSql = query.mock.calls[0]![0] as string;
expect(countSql).toContain('COUNT');
expect(countSql).toContain("iu.role = 'customer'");
});
it('keeps the email search parameterized alongside the role filter', async () => {
const { pool, query } = mockPool([{ cnt: '1' }], [CUSTOMER_ROW]);
const repo = new PgProfileRepository(pool);
await repo.listCustomers({ q: 'ben', offset: 0, limit: 20 });
const selectSql = query.mock.calls[1]![0] as string;
const params = query.mock.calls[1]![1] as unknown[];
expect(selectSql).toContain("iu.role = 'customer'");
expect(selectSql).toContain('ILIKE $1');
expect(params[0]).toBe('%ben%');
});
});

View File

@@ -94,10 +94,13 @@ export class PgProfileRepository implements ProfileRepository {
async listCustomers(opts: CustomerListOptions): Promise<CustomerListResult> {
const searchFilter = opts.q ? `%${opts.q}%` : null;
// F-154: Customers endpoint serves storefront customers only (role 'customer').
// role is a code literal (DB column compared to a constant), not user input,
// so it does not affect the parameterized $N index of the email search.
const countResult = await this.pool.query<{ cnt: string }>(
`SELECT COUNT(*)::text AS cnt
FROM identity_users iu
WHERE ($1::text IS NULL OR iu.email ILIKE $1)`,
WHERE iu.role = 'customer' AND ($1::text IS NULL OR iu.email ILIKE $1)`,
[searchFilter],
);
const total = parseInt(countResult.rows[0]?.cnt ?? '0', 10);
@@ -107,7 +110,7 @@ export class PgProfileRepository implements ProfileRepository {
up.display_name, up.phone, iu.created_at
FROM identity_users iu
LEFT JOIN users_profiles up ON up.user_id = iu.id
WHERE ($1::text IS NULL OR iu.email ILIKE $1)
WHERE iu.role = 'customer' AND ($1::text IS NULL OR iu.email ILIKE $1)
ORDER BY iu.created_at DESC
LIMIT $2 OFFSET $3`,
[searchFilter, opts.limit, opts.offset],