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:
@@ -40,8 +40,9 @@ is separate from deployment (no redeploy). Copy `.env.example` to `.env` to star
|
||||
Codes: `NOT_FOUND`, `VALIDATION_ERROR`, `BAD_REQUEST`/Fastify 4xx codes, `INTERNAL_ERROR`.
|
||||
5xx messages are always generic; stack traces stay in server logs only.
|
||||
- Input validation is explicit per route: `parseJson(schema, body)` (zod) in the handler.
|
||||
- Auth codes: `INVALID_CREDENTIALS` (401), `EMAIL_ALREADY_REGISTERED` (409),
|
||||
`TOO_MANY_ATTEMPTS` (429, with `Retry-After` header).
|
||||
- Auth codes: `UNAUTHORIZED` (401, missing/invalid/revoked session),
|
||||
`FORBIDDEN` (403, role or ownership check failed), `INVALID_CREDENTIALS` (401),
|
||||
`EMAIL_ALREADY_REGISTERED` (409), `TOO_MANY_ATTEMPTS` (429, with `Retry-After` header).
|
||||
- Log level via `LOG_LEVEL` env var (default `info`); logs are JSON only.
|
||||
|
||||
## Authentication (identity module)
|
||||
@@ -49,11 +50,11 @@ is separate from deployment (no redeploy). Copy `.env.example` to `.env` to star
|
||||
The server is the only authority for identity; the frontend is never trusted with
|
||||
session or credential state.
|
||||
|
||||
| Route | Result |
|
||||
| ------------------- | --------------------------------------------------- |
|
||||
| POST /auth/register | `201` + `{ id, email, createdAt }` |
|
||||
| POST /auth/login | `200` + `{ id, email }` + `Set-Cookie: mdv_session` |
|
||||
| POST /auth/logout | `204`, cookie cleared, session revoked (idempotent) |
|
||||
| Route | Result |
|
||||
| ------------------- | --------------------------------------------------------- |
|
||||
| POST /auth/register | `201` + `{ id, email, role, createdAt }` |
|
||||
| POST /auth/login | `200` + `{ id, email, role }` + `Set-Cookie: mdv_session` |
|
||||
| POST /auth/logout | `204`, cookie cleared, session revoked (idempotent) |
|
||||
|
||||
- Passwords: argon2id (OWASP parameters). Only the PHC hash is stored, never
|
||||
plaintext or anything reversible.
|
||||
@@ -68,6 +69,33 @@ session or credential state.
|
||||
swap later without touching use cases).
|
||||
- Identity routes are wired only when the app is built with a DB pool.
|
||||
|
||||
## Users and RBAC (users module)
|
||||
|
||||
Every route resolves the session cookie against the DB first (expired/revoked
|
||||
sessions and missing cookies get `401 UNAUTHORIZED`). Roles are `customer`
|
||||
(default) and `admin`; the role is read from `identity_users` on every request,
|
||||
so promotions/demotions apply immediately. Role changes are an out-of-band DB
|
||||
operation in this slice (no admin API yet).
|
||||
|
||||
| Route | Access | Result |
|
||||
| -------------------------------------- | -------------- | ---------------------------------- |
|
||||
| GET /users | admin only | `200` + `{ items: [profile] }` |
|
||||
| GET /users/:id | owner or admin | `200` profile, `404` if none yet |
|
||||
| PATCH /users/:id | owner or admin | `200` upserted profile |
|
||||
| GET /users/:id/addresses | owner or admin | `200` + `{ items: [address] }` |
|
||||
| POST /users/:id/addresses | owner or admin | `201` address |
|
||||
| PATCH /users/:id/addresses/:addressId | owner or admin | `200` address, `404` if not theirs |
|
||||
| DELETE /users/:id/addresses/:addressId | owner or admin | `204`, `404` if not theirs |
|
||||
|
||||
- Authorization is checked before existence: a non-owner gets `403 FORBIDDEN`
|
||||
regardless of whether the target resource exists (no enumeration).
|
||||
- Address queries are scoped by `user_id` in SQL, so a valid foreign address id
|
||||
is unreachable.
|
||||
- `GET /users` lists users that have a profile row (users who have patched their
|
||||
profile at least once).
|
||||
- The users module never imports identity: session resolution arrives as an
|
||||
injected `Authenticate` function from the composition root.
|
||||
|
||||
## Database (local dev)
|
||||
|
||||
```bash
|
||||
@@ -100,7 +128,8 @@ src/
|
||||
├── modules/ # business modules, one folder each
|
||||
│ ├── health/ # exemplar module: public API only via index.ts
|
||||
│ ├── flags/ # feature flags (unknown default OFF, runtime flip)
|
||||
│ └── identity/ # register/login/logout, argon2, sessions, rate limit
|
||||
│ ├── identity/ # register/login/logout, argon2, sessions, rate limit
|
||||
│ └── users/ # profile + address CRUD, owner-or-admin RBAC
|
||||
└── shared/ # cross-cutting helpers (error envelope, input parsing)
|
||||
```
|
||||
|
||||
|
||||
19
project/migrations/003_identity_roles.js
Normal file
19
project/migrations/003_identity_roles.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Identity schema evolution: roles for RBAC (introduced by F-006).
|
||||
* Role is authorization truth and belongs with the account row.
|
||||
* Shipped as its own migration to keep ownership explicit.
|
||||
*/
|
||||
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
export const up = (pgm) => {
|
||||
pgm.sql(`
|
||||
ALTER TABLE identity_users
|
||||
ADD COLUMN role text NOT NULL DEFAULT 'customer'
|
||||
CHECK (role IN ('customer', 'admin'))
|
||||
`);
|
||||
};
|
||||
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
export const down = (pgm) => {
|
||||
pgm.sql('ALTER TABLE identity_users DROP COLUMN role');
|
||||
};
|
||||
40
project/migrations/004_users.js
Normal file
40
project/migrations/004_users.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Users module tables. Module-owned naming: users_<table>.
|
||||
* FKs to identity_users are schema-level integrity only; runtime queries in
|
||||
* the users module touch users_* tables exclusively.
|
||||
*/
|
||||
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
export const up = (pgm) => {
|
||||
pgm.sql(`
|
||||
CREATE TABLE users_profiles (
|
||||
user_id uuid PRIMARY KEY REFERENCES identity_users(id) ON DELETE CASCADE,
|
||||
display_name text,
|
||||
phone text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
pgm.sql(`
|
||||
CREATE TABLE users_addresses (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES identity_users(id) ON DELETE CASCADE,
|
||||
label text,
|
||||
recipient_name text NOT NULL,
|
||||
street text NOT NULL,
|
||||
city text NOT NULL,
|
||||
postal_code text NOT NULL,
|
||||
country text NOT NULL,
|
||||
is_default boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
pgm.sql('CREATE INDEX users_addresses_user_id_idx ON users_addresses (user_id)');
|
||||
};
|
||||
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
export const down = (pgm) => {
|
||||
pgm.sql('DROP TABLE IF EXISTS users_addresses');
|
||||
pgm.sql('DROP TABLE IF EXISTS users_profiles');
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -17,11 +17,13 @@ describe.skipIf(!hasDb)('migrations', () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
it('fresh up creates the full schema (baseline + identity)', async () => {
|
||||
it('fresh up creates the full schema (baseline + identity + users)', async () => {
|
||||
await runMigrations(url, 'up');
|
||||
expect(await tableExists(pool, 'app_meta')).toBe(true);
|
||||
expect(await tableExists(pool, 'identity_users')).toBe(true);
|
||||
expect(await tableExists(pool, 'identity_sessions')).toBe(true);
|
||||
expect(await tableExists(pool, 'users_profiles')).toBe(true);
|
||||
expect(await tableExists(pool, 'users_addresses')).toBe(true);
|
||||
});
|
||||
|
||||
it('second up is a no-op', async () => {
|
||||
@@ -35,6 +37,8 @@ describe.skipIf(!hasDb)('migrations', () => {
|
||||
it('down rolls back the full schema cleanly', async () => {
|
||||
// count 0 reverts every applied migration in reverse order.
|
||||
await runMigrations(url, 'down', 0);
|
||||
expect(await tableExists(pool, 'users_addresses')).toBe(false);
|
||||
expect(await tableExists(pool, 'users_profiles')).toBe(false);
|
||||
expect(await tableExists(pool, 'identity_sessions')).toBe(false);
|
||||
expect(await tableExists(pool, 'identity_users')).toBe(false);
|
||||
expect(await tableExists(pool, 'app_meta')).toBe(false);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* calls use cases, maps domain errors to the shared error envelope.
|
||||
*/
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { z } from 'zod';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
@@ -47,8 +46,6 @@ export async function registerIdentityRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: IdentityRoutesDeps,
|
||||
): Promise<void> {
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
const cookieSecure = deps.cookieSecure ?? true;
|
||||
const hasher = deps.hasher ?? new Argon2PasswordHasher();
|
||||
const users = new PgUserRepository(deps.pool);
|
||||
@@ -70,7 +67,9 @@ export async function registerIdentityRoutes(
|
||||
const input = parseJson(credentialsSchema, request.body);
|
||||
try {
|
||||
const user = await registerUser.execute(input);
|
||||
return reply.code(201).send({ id: user.id, email: user.email, createdAt: user.createdAt });
|
||||
return reply
|
||||
.code(201)
|
||||
.send({ id: user.id, email: user.email, role: user.role, createdAt: user.createdAt });
|
||||
} catch (error) {
|
||||
if (error instanceof EmailAlreadyRegisteredError) {
|
||||
throw new AppError(409, 'EMAIL_ALREADY_REGISTERED', 'Email already registered');
|
||||
@@ -84,7 +83,9 @@ export async function registerIdentityRoutes(
|
||||
try {
|
||||
const result = await login.execute(input);
|
||||
setSessionCookie(reply, result.token, cookieSecure);
|
||||
return reply.code(200).send({ id: result.user.id, email: result.user.email });
|
||||
return reply
|
||||
.code(200)
|
||||
.send({ id: result.user.id, email: result.user.email, role: result.user.role });
|
||||
} catch (error) {
|
||||
if (error instanceof RateLimitedError) {
|
||||
void reply.header('Retry-After', String(Math.ceil(error.retryAfterMs / 1000)));
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
* Identity domain. Pure types and rules: no framework, no infrastructure.
|
||||
*/
|
||||
|
||||
import type { Role } from '../../../shared/auth.js';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@ export {
|
||||
SESSION_COOKIE_NAME,
|
||||
type IdentityRoutesDeps,
|
||||
} from './api/identity.routes.js';
|
||||
export { createSessionAuthenticator } from './infrastructure/session-authenticator.js';
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
import type pg from 'pg';
|
||||
import type { UserRepository } from '../domain/ports.js';
|
||||
import type { NewUser, User } from '../domain/user.js';
|
||||
import type { Role } from '../../../shared/auth.js';
|
||||
import { EmailAlreadyRegisteredError } from '../domain/errors.js';
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
role: Role;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
@@ -24,14 +26,14 @@ export class PgUserRepository implements UserRepository {
|
||||
const result = await this.pool.query<UserRow>(
|
||||
`INSERT INTO identity_users (email, password_hash)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, email, created_at`,
|
||||
RETURNING id, email, role, created_at`,
|
||||
[user.email, user.passwordHash],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
throw new Error('identity_users INSERT returned no row');
|
||||
}
|
||||
return { id: row.id, email: row.email, createdAt: row.created_at };
|
||||
return { id: row.id, email: row.email, role: row.role, createdAt: row.created_at };
|
||||
} catch (error) {
|
||||
if (isPgError(error) && error.code === UNIQUE_VIOLATION) {
|
||||
throw new EmailAlreadyRegisteredError();
|
||||
@@ -42,7 +44,7 @@ export class PgUserRepository implements UserRepository {
|
||||
|
||||
async findByEmail(email: string): Promise<(User & { passwordHash: string }) | undefined> {
|
||||
const result = await this.pool.query<UserRow>(
|
||||
`SELECT id, email, password_hash, created_at
|
||||
`SELECT id, email, password_hash, role, created_at
|
||||
FROM identity_users
|
||||
WHERE email = $1`,
|
||||
[email],
|
||||
@@ -54,6 +56,7 @@ export class PgUserRepository implements UserRepository {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
createdAt: row.created_at,
|
||||
passwordHash: row.password_hash,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Resolves the session cookie into the current user.
|
||||
* Server-side truth: validity (expiry + revocation) and role come from the DB,
|
||||
* never from the client. Exported through the module index so the composition
|
||||
* root can inject it into other modules without cross-module imports.
|
||||
*/
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import type { Authenticate, CurrentUser } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { hashSessionToken } from './session-token.js';
|
||||
import { SESSION_COOKIE_NAME } from '../api/identity.routes.js';
|
||||
|
||||
interface ResolvedRow {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const RESOLVE_SQL = `
|
||||
SELECT u.id, u.email, u.role
|
||||
FROM identity_sessions s
|
||||
JOIN identity_users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1
|
||||
AND s.revoked_at IS NULL
|
||||
AND s.expires_at > now()
|
||||
`;
|
||||
|
||||
export function createSessionAuthenticator(pool: pg.Pool): Authenticate {
|
||||
return async (request: FastifyRequest): Promise<CurrentUser> => {
|
||||
const token = request.cookies[SESSION_COOKIE_NAME];
|
||||
if (!token) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', 'Authentication required');
|
||||
}
|
||||
|
||||
const result = await pool.query<ResolvedRow>(RESOLVE_SQL, [hashSessionToken(token)]);
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', 'Authentication required');
|
||||
}
|
||||
|
||||
return { id: row.id, email: row.email, role: row.role as CurrentUser['role'] };
|
||||
};
|
||||
}
|
||||
163
project/src/modules/users/api/users.routes.ts
Normal file
163
project/src/modules/users/api/users.routes.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Users API adapters. Authorization (role + ownership) runs BEFORE existence
|
||||
* checks, so a non-owner always gets 403 regardless of resource existence.
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type pg from 'pg';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { requireOwnerOrAdmin, requireRole, type Authenticate } from '../../../shared/auth.js';
|
||||
import { GetProfile, ListProfiles, UpdateProfile } from '../application/profile-use-cases.js';
|
||||
import {
|
||||
CreateAddress,
|
||||
DeleteAddress,
|
||||
ListAddresses,
|
||||
UpdateAddress,
|
||||
} from '../application/address-use-cases.js';
|
||||
import { PgProfileRepository } from '../infrastructure/pg-profile-repository.js';
|
||||
import { PgAddressRepository } from '../infrastructure/pg-address-repository.js';
|
||||
import type { Address } from '../domain/address.js';
|
||||
import type { Profile } from '../domain/profile.js';
|
||||
|
||||
export interface UsersRoutesDeps {
|
||||
pool: pg.Pool;
|
||||
/** Injected by the composition root (identity owns session resolution). */
|
||||
authenticate: Authenticate;
|
||||
}
|
||||
|
||||
const uuidParamSchema = z.object({ id: z.uuid() });
|
||||
const addressIdParamSchema = z.object({ id: z.uuid(), addressId: z.uuid() });
|
||||
|
||||
const profilePatchSchema = z
|
||||
.object({
|
||||
displayName: z.string().min(1).max(200).optional(),
|
||||
phone: z.string().min(1).max(50).optional(),
|
||||
})
|
||||
.refine((value) => value.displayName !== undefined || value.phone !== undefined, {
|
||||
message: 'At least one of displayName or phone is required',
|
||||
});
|
||||
|
||||
const newAddressSchema = z.object({
|
||||
label: z.string().max(100).optional().nullable(),
|
||||
recipientName: z.string().min(1).max(200),
|
||||
street: z.string().min(1).max(300),
|
||||
city: z.string().min(1).max(100),
|
||||
postalCode: z.string().min(1).max(20),
|
||||
country: z.string().min(1).max(100),
|
||||
isDefault: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const addressPatchSchema = newAddressSchema
|
||||
.partial()
|
||||
.refine((value) => Object.values(value).some((field) => field !== undefined), {
|
||||
message: 'At least one address field is required',
|
||||
});
|
||||
|
||||
export async function registerUsersRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: UsersRoutesDeps,
|
||||
): Promise<void> {
|
||||
const profiles = new PgProfileRepository(deps.pool);
|
||||
const addresses = new PgAddressRepository(deps.pool);
|
||||
const getProfile = new GetProfile(profiles);
|
||||
const updateProfile = new UpdateProfile(profiles);
|
||||
const listProfiles = new ListProfiles(profiles);
|
||||
const listAddresses = new ListAddresses(addresses);
|
||||
const createAddress = new CreateAddress(addresses);
|
||||
const updateAddress = new UpdateAddress(addresses);
|
||||
const deleteAddress = new DeleteAddress(addresses);
|
||||
|
||||
app.get('/users', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const items = await listProfiles.execute();
|
||||
return reply.send({ items: items.map(serializeProfile) });
|
||||
});
|
||||
|
||||
app.get('/users/:id', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id } = parseJson(uuidParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
const profile = await getProfile.execute(id);
|
||||
if (!profile) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Profile not found');
|
||||
}
|
||||
return reply.send(serializeProfile(profile));
|
||||
});
|
||||
|
||||
app.patch('/users/:id', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id } = parseJson(uuidParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
const patch = parseJson(profilePatchSchema, request.body);
|
||||
const profile = await updateProfile.execute(id, patch);
|
||||
return reply.send(serializeProfile(profile));
|
||||
});
|
||||
|
||||
app.get('/users/:id/addresses', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id } = parseJson(uuidParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
const items = await listAddresses.execute(id);
|
||||
return reply.send({ items: items.map(serializeAddress) });
|
||||
});
|
||||
|
||||
app.post('/users/:id/addresses', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id } = parseJson(uuidParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
const input = parseJson(newAddressSchema, request.body);
|
||||
const address = await createAddress.execute(id, input);
|
||||
return reply.code(201).send(serializeAddress(address));
|
||||
});
|
||||
|
||||
app.patch('/users/:id/addresses/:addressId', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id, addressId } = parseJson(addressIdParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
const patch = parseJson(addressPatchSchema, request.body);
|
||||
const address = await updateAddress.execute(id, addressId, patch);
|
||||
if (!address) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Address not found');
|
||||
}
|
||||
return reply.send(serializeAddress(address));
|
||||
});
|
||||
|
||||
app.delete('/users/:id/addresses/:addressId', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id, addressId } = parseJson(addressIdParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
const deleted = await deleteAddress.execute(id, addressId);
|
||||
if (!deleted) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Address not found');
|
||||
}
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
function serializeProfile(profile: Profile) {
|
||||
return {
|
||||
userId: profile.userId,
|
||||
displayName: profile.displayName,
|
||||
phone: profile.phone,
|
||||
createdAt: profile.createdAt.toISOString(),
|
||||
updatedAt: profile.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeAddress(address: Address) {
|
||||
return {
|
||||
id: address.id,
|
||||
userId: address.userId,
|
||||
label: address.label,
|
||||
recipientName: address.recipientName,
|
||||
street: address.street,
|
||||
city: address.city,
|
||||
postalCode: address.postalCode,
|
||||
country: address.country,
|
||||
isDefault: address.isDefault,
|
||||
createdAt: address.createdAt.toISOString(),
|
||||
updatedAt: address.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
37
project/src/modules/users/application/address-use-cases.ts
Normal file
37
project/src/modules/users/application/address-use-cases.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Address use cases. Every operation is scoped to the owner's userId.
|
||||
*/
|
||||
import type { AddressRepository } from '../domain/ports.js';
|
||||
import type { Address, AddressPatch, NewAddress } from '../domain/address.js';
|
||||
|
||||
export class ListAddresses {
|
||||
constructor(private readonly addresses: AddressRepository) {}
|
||||
async execute(userId: string): Promise<Address[]> {
|
||||
return this.addresses.listByUserId(userId);
|
||||
}
|
||||
}
|
||||
|
||||
export class CreateAddress {
|
||||
constructor(private readonly addresses: AddressRepository) {}
|
||||
async execute(userId: string, input: NewAddress): Promise<Address> {
|
||||
return this.addresses.create(userId, input);
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateAddress {
|
||||
constructor(private readonly addresses: AddressRepository) {}
|
||||
async execute(
|
||||
userId: string,
|
||||
addressId: string,
|
||||
patch: AddressPatch,
|
||||
): Promise<Address | undefined> {
|
||||
return this.addresses.update(userId, addressId, patch);
|
||||
}
|
||||
}
|
||||
|
||||
export class DeleteAddress {
|
||||
constructor(private readonly addresses: AddressRepository) {}
|
||||
async execute(userId: string, addressId: string): Promise<boolean> {
|
||||
return this.addresses.delete(userId, addressId);
|
||||
}
|
||||
}
|
||||
27
project/src/modules/users/application/profile-use-cases.ts
Normal file
27
project/src/modules/users/application/profile-use-cases.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Profile use cases. Thin orchestration over ports; ownership/role checks
|
||||
* happen in the API layer before these run.
|
||||
*/
|
||||
import type { ProfileRepository } from '../domain/ports.js';
|
||||
import type { Profile, ProfilePatch } from '../domain/profile.js';
|
||||
|
||||
export class GetProfile {
|
||||
constructor(private readonly profiles: ProfileRepository) {}
|
||||
async execute(userId: string): Promise<Profile | undefined> {
|
||||
return this.profiles.findByUserId(userId);
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateProfile {
|
||||
constructor(private readonly profiles: ProfileRepository) {}
|
||||
async execute(userId: string, patch: ProfilePatch): Promise<Profile> {
|
||||
return this.profiles.upsert(userId, patch);
|
||||
}
|
||||
}
|
||||
|
||||
export class ListProfiles {
|
||||
constructor(private readonly profiles: ProfileRepository) {}
|
||||
async execute(): Promise<Profile[]> {
|
||||
return this.profiles.list();
|
||||
}
|
||||
}
|
||||
30
project/src/modules/users/domain/address.ts
Normal file
30
project/src/modules/users/domain/address.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Address domain model.
|
||||
*/
|
||||
|
||||
export interface Address {
|
||||
id: string;
|
||||
userId: string;
|
||||
label: string | null;
|
||||
recipientName: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
isDefault: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface NewAddress {
|
||||
label?: string | null;
|
||||
recipientName: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
/** Fields an address update may set. Undefined = leave unchanged. */
|
||||
export type AddressPatch = Partial<NewAddress>;
|
||||
22
project/src/modules/users/domain/ports.ts
Normal file
22
project/src/modules/users/domain/ports.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Ports (driven interfaces). Domain owns them; infrastructure implements them.
|
||||
* All operations are scoped by userId so ownership is enforced in the query.
|
||||
*/
|
||||
import type { Profile, ProfilePatch } from './profile.js';
|
||||
import type { Address, AddressPatch, NewAddress } from './address.js';
|
||||
|
||||
export interface ProfileRepository {
|
||||
findByUserId(userId: string): Promise<Profile | undefined>;
|
||||
/** Idempotent upsert; only provided fields change. */
|
||||
upsert(userId: string, patch: ProfilePatch): Promise<Profile>;
|
||||
list(): Promise<Profile[]>;
|
||||
}
|
||||
|
||||
export interface AddressRepository {
|
||||
listByUserId(userId: string): Promise<Address[]>;
|
||||
create(userId: string, input: NewAddress): Promise<Address>;
|
||||
/** Returns undefined when the address does not belong to userId. */
|
||||
update(userId: string, addressId: string, patch: AddressPatch): Promise<Address | undefined>;
|
||||
/** Returns false when the address does not belong to userId. */
|
||||
delete(userId: string, addressId: string): Promise<boolean>;
|
||||
}
|
||||
17
project/src/modules/users/domain/profile.ts
Normal file
17
project/src/modules/users/domain/profile.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Users domain. Pure types: no framework, no infrastructure.
|
||||
*/
|
||||
|
||||
export interface Profile {
|
||||
userId: string;
|
||||
displayName: string | null;
|
||||
phone: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/** Fields a profile update may set. Undefined = leave unchanged. */
|
||||
export interface ProfilePatch {
|
||||
displayName?: string | null;
|
||||
phone?: string | null;
|
||||
}
|
||||
6
project/src/modules/users/index.ts
Normal file
6
project/src/modules/users/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Public API of the users module. Everything the module exposes to the outside
|
||||
* world goes through this file. Auth arrives by injection (shared contract),
|
||||
* never by importing identity.
|
||||
*/
|
||||
export { registerUsersRoutes, type UsersRoutesDeps } from './api/users.routes.js';
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* PostgreSQL AddressRepository. All operations are scoped by user_id, so a
|
||||
* caller can never read or mutate another user's address even with a valid id.
|
||||
*/
|
||||
import type pg from 'pg';
|
||||
import type { AddressRepository } from '../domain/ports.js';
|
||||
import type { Address, AddressPatch, NewAddress } from '../domain/address.js';
|
||||
|
||||
interface AddressRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
label: string | null;
|
||||
recipient_name: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
is_default: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/** Whitelist of updatable columns -> input key. Prevents SQL building from input. */
|
||||
const UPDATABLE: ReadonlyArray<[keyof AddressPatch, string]> = [
|
||||
['label', 'label'],
|
||||
['recipientName', 'recipient_name'],
|
||||
['street', 'street'],
|
||||
['city', 'city'],
|
||||
['postalCode', 'postal_code'],
|
||||
['country', 'country'],
|
||||
['isDefault', 'is_default'],
|
||||
];
|
||||
|
||||
export class PgAddressRepository implements AddressRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<Address[]> {
|
||||
const result = await this.pool.query<AddressRow>(
|
||||
`SELECT * FROM users_addresses WHERE user_id = $1
|
||||
ORDER BY is_default DESC, created_at`,
|
||||
[userId],
|
||||
);
|
||||
return result.rows.map(toAddress);
|
||||
}
|
||||
|
||||
async create(userId: string, input: NewAddress): Promise<Address> {
|
||||
const result = await this.pool.query<AddressRow>(
|
||||
`INSERT INTO users_addresses
|
||||
(user_id, label, recipient_name, street, city, postal_code, country, is_default)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
input.label ?? null,
|
||||
input.recipientName,
|
||||
input.street,
|
||||
input.city,
|
||||
input.postalCode,
|
||||
input.country,
|
||||
input.isDefault ?? false,
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
throw new Error('users_addresses INSERT returned no row');
|
||||
}
|
||||
return toAddress(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: string,
|
||||
addressId: string,
|
||||
patch: AddressPatch,
|
||||
): Promise<Address | undefined> {
|
||||
const setClauses: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
for (const [key, column] of UPDATABLE) {
|
||||
const value = patch[key];
|
||||
if (value !== undefined) {
|
||||
values.push(value);
|
||||
setClauses.push(`${column} = $${values.length}`);
|
||||
}
|
||||
}
|
||||
if (setClauses.length === 0) {
|
||||
return this.findByIdScoped(userId, addressId);
|
||||
}
|
||||
|
||||
values.push(userId, addressId);
|
||||
const result = await this.pool.query<AddressRow>(
|
||||
`UPDATE users_addresses SET ${setClauses.join(', ')}, updated_at = now()
|
||||
WHERE user_id = $${values.length - 1} AND id = $${values.length}
|
||||
RETURNING *`,
|
||||
values,
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toAddress(row) : undefined;
|
||||
}
|
||||
|
||||
async delete(userId: string, addressId: string): Promise<boolean> {
|
||||
const result = await this.pool.query(
|
||||
`DELETE FROM users_addresses WHERE user_id = $1 AND id = $2`,
|
||||
[userId, addressId],
|
||||
);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
private async findByIdScoped(userId: string, addressId: string): Promise<Address | undefined> {
|
||||
const result = await this.pool.query<AddressRow>(
|
||||
`SELECT * FROM users_addresses WHERE user_id = $1 AND id = $2`,
|
||||
[userId, addressId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toAddress(row) : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function toAddress(row: AddressRow): Address {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
label: row.label,
|
||||
recipientName: row.recipient_name,
|
||||
street: row.street,
|
||||
city: row.city,
|
||||
postalCode: row.postal_code,
|
||||
country: row.country,
|
||||
isDefault: row.is_default,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* PostgreSQL ProfileRepository. Parameterized queries only; update columns are
|
||||
* whitelisted, never built from user input.
|
||||
*/
|
||||
import type pg from 'pg';
|
||||
import type { ProfileRepository } from '../domain/ports.js';
|
||||
import type { Profile, ProfilePatch } from '../domain/profile.js';
|
||||
|
||||
interface ProfileRow {
|
||||
user_id: string;
|
||||
display_name: string | null;
|
||||
phone: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export class PgProfileRepository implements ProfileRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<Profile | undefined> {
|
||||
const result = await this.pool.query<ProfileRow>(
|
||||
`SELECT user_id, display_name, phone, created_at, updated_at
|
||||
FROM users_profiles WHERE user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toProfile(row) : undefined;
|
||||
}
|
||||
|
||||
async upsert(userId: string, patch: ProfilePatch): Promise<Profile> {
|
||||
await this.pool.query(
|
||||
`INSERT INTO users_profiles (user_id) VALUES ($1)
|
||||
ON CONFLICT (user_id) DO NOTHING`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
const setClauses: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
if (patch.displayName !== undefined) {
|
||||
values.push(patch.displayName);
|
||||
setClauses.push(`display_name = $${values.length}`);
|
||||
}
|
||||
if (patch.phone !== undefined) {
|
||||
values.push(patch.phone);
|
||||
setClauses.push(`phone = $${values.length}`);
|
||||
}
|
||||
if (setClauses.length > 0) {
|
||||
values.push(userId);
|
||||
await this.pool.query(
|
||||
`UPDATE users_profiles SET ${setClauses.join(', ')}, updated_at = now()
|
||||
WHERE user_id = $${values.length}`,
|
||||
values,
|
||||
);
|
||||
}
|
||||
|
||||
const profile = await this.findByUserId(userId);
|
||||
if (!profile) {
|
||||
throw new Error('users_profiles upsert did not return a row');
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async list(): Promise<Profile[]> {
|
||||
const result = await this.pool.query<ProfileRow>(
|
||||
`SELECT user_id, display_name, phone, created_at, updated_at
|
||||
FROM users_profiles ORDER BY created_at`,
|
||||
);
|
||||
return result.rows.map(toProfile);
|
||||
}
|
||||
}
|
||||
|
||||
function toProfile(row: ProfileRow): Profile {
|
||||
return {
|
||||
userId: row.user_id,
|
||||
displayName: row.display_name,
|
||||
phone: row.phone,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
35
project/src/shared/auth.ts
Normal file
35
project/src/shared/auth.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Shared auth contracts. Identity implements the authenticator; consumers
|
||||
* (users, future modules) receive it by injection from the composition root.
|
||||
* No module ever imports another module for auth.
|
||||
*/
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { AppError } from './errors.js';
|
||||
|
||||
export type Role = 'customer' | 'admin';
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the session on the request into the current user.
|
||||
* Throws AppError(401) when the request is not authenticated.
|
||||
*/
|
||||
export type Authenticate = (request: FastifyRequest) => Promise<CurrentUser>;
|
||||
|
||||
/** Throws AppError(403) unless the user holds the required role. */
|
||||
export function requireRole(user: CurrentUser, role: Role): void {
|
||||
if (user.role !== role) {
|
||||
throw new AppError(403, 'FORBIDDEN', 'Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws AppError(403) unless the user is the resource owner or an admin. */
|
||||
export function requireOwnerOrAdmin(user: CurrentUser, ownerId: string): void {
|
||||
if (user.role !== 'admin' && user.id !== ownerId) {
|
||||
throw new AppError(403, 'FORBIDDEN', 'Access denied');
|
||||
}
|
||||
}
|
||||
40
project/src/shared/tests/auth.test.ts
Normal file
40
project/src/shared/tests/auth.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AppError } from '../errors.js';
|
||||
import { requireOwnerOrAdmin, requireRole, type CurrentUser } from '../auth.js';
|
||||
|
||||
const customer: CurrentUser = { id: 'user-a', email: 'a@example.com', role: 'customer' };
|
||||
const admin: CurrentUser = { id: 'user-admin', email: 'admin@example.com', role: 'admin' };
|
||||
|
||||
function codeOf(fn: () => void): string | undefined {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
return error instanceof AppError ? error.code : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('requireRole', () => {
|
||||
it('allows a matching role', () => {
|
||||
expect(() => requireRole(admin, 'admin')).not.toThrow();
|
||||
expect(() => requireRole(customer, 'customer')).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws 403 FORBIDDEN for a missing role', () => {
|
||||
expect(codeOf(() => requireRole(customer, 'admin'))).toBe('FORBIDDEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireOwnerOrAdmin', () => {
|
||||
it('allows the owner regardless of role', () => {
|
||||
expect(() => requireOwnerOrAdmin(customer, 'user-a')).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows an admin on any user', () => {
|
||||
expect(() => requireOwnerOrAdmin(admin, 'user-b')).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws 403 FORBIDDEN for a non-owner customer', () => {
|
||||
expect(codeOf(() => requireOwnerOrAdmin(customer, 'user-b'))).toBe('FORBIDDEN');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user