feat(F-006): users profile, addresses and RBAC

- users module: profile + address CRUD behind use cases (users_profiles,
  users_addresses)
- roles customer/admin on identity_users; role resolved from DB per request
- shared auth contract (Authenticate, requireRole, requireOwnerOrAdmin)
  injected from composition root; users never imports identity
- authorization runs before existence checks; address SQL scoped by user_id
- @fastify/cookie registered once at app root (cross-module)
- migrations 003_identity_roles + 004_users (reversible)
- no new npm dependencies; tests: unit 52, integration 22

Gates: reviewer/security/qa APPROVED; verify.sh green
This commit is contained in:
rikrdo
2026-08-15 09:27:38 +02:00
parent 75293f39bc
commit 546971280f
37 changed files with 1732 additions and 161 deletions

View File

@@ -5,11 +5,13 @@
import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
import type { IncomingMessage } from 'node:http';
import type pg from 'pg';
import { registerHealthRoutes } from '../modules/health/index.js';
import { registerIdentityRoutes } from '../modules/identity/index.js';
import { registerIdentityRoutes, createSessionAuthenticator } from '../modules/identity/index.js';
import { registerUsersRoutes } from '../modules/users/index.js';
import { createFlagStore, type FeatureFlagProvider } from '../modules/flags/index.js';
import { AppError, errorEnvelope } from '../shared/errors.js';
import { createLogger, type Logger } from '../infrastructure/logging/logger.js';
@@ -113,6 +115,9 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
await registerHealthRoutes(instance);
});
// Cookie infrastructure is cross-module (identity + users): register once at root.
await app.register(fastifyCookie);
if (deps.pool) {
await app.register(async (instance) => {
await registerIdentityRoutes(instance, {
@@ -120,6 +125,16 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
cookieSecure: deps.cookieSecure,
});
});
// Session resolution is identity's; users receives it by injection so no
// module ever imports another module.
const authenticate = createSessionAuthenticator(deps.pool);
await app.register(async (instance) => {
await registerUsersRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate,
});
});
}
return app;

View File

@@ -0,0 +1,334 @@
import type { DestinationStream } from 'pino';
import type pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildApp } from '../build-app.js';
import { createPool } from '../../infrastructure/db/pool.js';
import { createLogger } from '../../infrastructure/logging/logger.js';
import {
getTestDbUrl,
recreateDatabase,
runMigrations,
} from '../../infrastructure/db/tests/db-test-support.js';
import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js';
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
function silentLogger() {
const destination: DestinationStream = { write: () => undefined };
return createLogger({ level: 'info', destination });
}
function cookieValue(setCookieHeader: string | string[] | undefined): string {
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader;
expect(raw).toBeDefined();
const pair = (raw as string).split(';')[0] as string;
return pair.slice(pair.indexOf('=') + 1);
}
describe.skipIf(!hasDb)('users + RBAC flows (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
const ana = { email: 'ana@example.com', password: 'correct horse battery staple' };
const ben = { email: 'ben@example.com', password: 'another valid password' };
let anaId = '';
let anaCookie = '';
let benId = '';
let benCookie = '';
async function registerAndLogin(user: { email: string; password: string }) {
const registered = await app.inject({
method: 'POST',
url: '/auth/register',
headers: { 'content-type': 'application/json' },
payload: user,
});
expect(registered.statusCode).toBe(201);
const login = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: user,
});
expect(login.statusCode).toBe(200);
const body = login.json() as { id: string; role: string };
expect(body.role).toBe('customer');
return { id: body.id, cookie: cookieValue(login.headers['set-cookie']) };
}
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
const a = await registerAndLogin(ana);
anaId = a.id;
anaCookie = a.cookie;
const b = await registerAndLogin(ben);
benId = b.id;
benCookie = b.cookie;
});
afterAll(async () => {
await app.close();
await pool.end();
});
it('unauthenticated and bad-cookie requests get 401', async () => {
const missing = await app.inject({ method: 'GET', url: `/users/${anaId}` });
expect(missing.statusCode).toBe(401);
expect(missing.json().error.code).toBe('UNAUTHORIZED');
const forged = await app.inject({
method: 'GET',
url: `/users/${anaId}`,
cookies: { [SESSION_COOKIE_NAME]: 'forged-token' },
});
expect(forged.statusCode).toBe(401);
});
it('user A requesting user B profile gets 403, own flow works (AC1)', async () => {
const cross = await app.inject({
method: 'GET',
url: `/users/${benId}`,
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
});
expect(cross.statusCode).toBe(403);
expect(cross.json().error.code).toBe('FORBIDDEN');
// Own profile does not exist yet -> 404 (authz passed, resource missing).
const ownMissing = await app.inject({
method: 'GET',
url: `/users/${anaId}`,
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
});
expect(ownMissing.statusCode).toBe(404);
});
it('PATCH own profile upserts; PATCH is validated', async () => {
const emptyBody = await app.inject({
method: 'PATCH',
url: `/users/${anaId}`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
payload: {},
});
expect(emptyBody.statusCode).toBe(400);
const created = await app.inject({
method: 'PATCH',
url: `/users/${anaId}`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
payload: { displayName: 'Ana', phone: '+54 11 5555 0001' },
});
expect(created.statusCode).toBe(200);
expect(created.json().displayName).toBe('Ana');
const partial = await app.inject({
method: 'PATCH',
url: `/users/${anaId}`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
payload: { phone: '+54 11 5555 0002' },
});
expect(partial.statusCode).toBe(200);
const body = partial.json();
expect(body.phone).toBe('+54 11 5555 0002');
expect(body.displayName).toBe('Ana'); // untouched field preserved
});
it('GET /users is admin-only: customer 403, admin 200 (AC2, AC3)', async () => {
const asCustomer = await app.inject({
method: 'GET',
url: '/users',
cookies: { [SESSION_COOKIE_NAME]: benCookie },
});
expect(asCustomer.statusCode).toBe(403);
// Promotion happens out-of-band (DB); session cookie picks up the new role.
await pool.query('UPDATE identity_users SET role = $1 WHERE id = $2', ['admin', anaId]);
const asAdmin = await app.inject({
method: 'GET',
url: '/users',
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
});
expect(asAdmin.statusCode).toBe(200);
const body = asAdmin.json() as { items: Array<{ userId: string }> };
expect(body.items.some((item) => item.userId === anaId)).toBe(true);
// Admin can also read another user's profile (owner-or-admin).
const adminReadsBen = await app.inject({
method: 'GET',
url: `/users/${benId}`,
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
});
// Ben has no profile yet: 404, not 403 (authz passed).
expect(adminReadsBen.statusCode).toBe(404);
});
it('address CRUD works end to end for own addresses (AC4)', async () => {
const addressInput = {
label: 'Casa',
recipientName: 'Ana Torres',
street: 'Av. Siempre Viva 742',
city: 'Springfield',
postalCode: '1000',
country: 'AR',
isDefault: true,
};
const created = await app.inject({
method: 'POST',
url: `/users/${anaId}/addresses`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
payload: addressInput,
});
expect(created.statusCode).toBe(201);
const address = created.json();
expect(address.id).toMatch(/^[0-9a-f-]{36}$/);
expect(address.isDefault).toBe(true);
const invalid = await app.inject({
method: 'POST',
url: `/users/${anaId}/addresses`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
payload: { label: 'sin destinatario' },
});
expect(invalid.statusCode).toBe(400);
const list = await app.inject({
method: 'GET',
url: `/users/${anaId}/addresses`,
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
});
expect(list.statusCode).toBe(200);
expect(list.json().items).toHaveLength(1);
const updated = await app.inject({
method: 'PATCH',
url: `/users/${anaId}/addresses/${address.id}`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
payload: { city: 'Shelbyville', isDefault: false },
});
expect(updated.statusCode).toBe(200);
expect(updated.json().city).toBe('Shelbyville');
expect(updated.json().isDefault).toBe(false);
const deleted = await app.inject({
method: 'DELETE',
url: `/users/${anaId}/addresses/${address.id}`,
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
});
expect(deleted.statusCode).toBe(204);
const empty = await app.inject({
method: 'GET',
url: `/users/${anaId}/addresses`,
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
});
expect(empty.json().items).toHaveLength(0);
});
it("users cannot mutate addresses on another user's resource -> 403", async () => {
const created = await app.inject({
method: 'POST',
url: `/users/${benId}/addresses`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: benCookie },
payload: {
recipientName: 'Ben',
street: 'Calle 1',
city: 'CABA',
postalCode: '1400',
country: 'AR',
},
});
expect(created.statusCode).toBe(201);
const addressId = created.json().id as string;
// Fresh regular customer (Ana was promoted to admin earlier, so she no
// longer represents a plain outsider).
const outsider = await registerAndLogin({
email: 'dani@example.com',
password: 'dani valid password',
});
for (const attempt of [
{
method: 'POST',
url: `/users/${benId}/addresses`,
payload: {
recipientName: 'Intruso',
street: 'x',
city: 'x',
postalCode: 'x',
country: 'x',
},
},
{ method: 'PATCH', url: `/users/${benId}/addresses/${addressId}`, payload: { city: 'x' } },
{ method: 'DELETE', url: `/users/${benId}/addresses/${addressId}`, payload: undefined },
]) {
const response = await app.inject({
method: attempt.method as 'POST' | 'PATCH' | 'DELETE',
url: attempt.url,
headers: attempt.payload === undefined ? {} : { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: outsider.cookie },
payload: attempt.payload,
});
expect(response.statusCode).toBe(403);
}
// Ana is admin now (previous test promoted her): admin bypasses ownership.
const adminDelete = await app.inject({
method: 'DELETE',
url: `/users/${benId}/addresses/${addressId}`,
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
});
expect(adminDelete.statusCode).toBe(204);
});
it('unknown address on own resource -> 404; malformed uuid -> 400', async () => {
const ghostId = '00000000-0000-0000-0000-000000000000';
const missing = await app.inject({
method: 'DELETE',
url: `/users/${anaId}/addresses/${ghostId}`,
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
});
expect(missing.statusCode).toBe(404);
const malformed = await app.inject({
method: 'GET',
url: '/users/not-a-uuid',
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
});
expect(malformed.statusCode).toBe(400);
expect(malformed.json().error.code).toBe('VALIDATION_ERROR');
});
it('logout kills access to protected routes (F-005 regression)', async () => {
const session = await registerAndLogin({
email: 'carla@example.com',
password: 'carla pass 123',
});
const logout = await app.inject({
method: 'POST',
url: '/auth/logout',
cookies: { [SESSION_COOKIE_NAME]: session.cookie },
});
expect(logout.statusCode).toBe(204);
const after = await app.inject({
method: 'GET',
url: `/users/${session.id}`,
cookies: { [SESSION_COOKIE_NAME]: session.cookie },
});
expect(after.statusCode).toBe(401);
});
});