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:
334
project/src/app/tests/users.itest.ts
Normal file
334
project/src/app/tests/users.itest.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user