feat(F-154): completed feature
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
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,
|
||||
|
||||
@@ -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%');
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user