diff --git a/.env.example b/.env.example index ca76c15..b94c058 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ PORT=3000 HOST=0.0.0.0 +# Rate limiting is enabled only when NODE_ENV=production. NODE_ENV=development DATABASE_URL=postgres://postgres:password@localhost:5432/fastify_dev JWT_SECRET=change_this_to_a_random_string_at_least_32_characters @@ -11,3 +12,9 @@ OTEL_ENDPOINT= LOG_LEVEL=info # Required — shared secret for mobile clients; generate with: openssl rand -hex 32 MOBILE_API_KEY=change_this_to_a_random_string_at_least_32_characters +# Required in production — comma-separated list of allowed CORS origins +# Example: CORS_ORIGIN=https://app.example.com,https://admin.example.com +CORS_ORIGIN= +# Set in production behind a reverse proxy so request.ip uses the real client IP. +# Examples: TRUST_PROXY=127.0.0.1, TRUST_PROXY=10.0.0.0/8, TRUST_PROXY=1 +TRUST_PROXY= diff --git a/README.md b/README.md index b15aba5..857c611 100644 --- a/README.md +++ b/README.md @@ -94,10 +94,11 @@ cp .env.example .env | `TEST_DATABASE_URL` | | *(empty)* | PostgreSQL connection string used by test runs | | `PORT` | | `3000` | Server port | | `HOST` | | `0.0.0.0` | Server host | -| `NODE_ENV` | | `development` | `development` \| `production` \| `test` | +| `NODE_ENV` | | `development` | `development` \| `production` \| `test`. Rate limiting is enabled only in production. | | `LOG_LEVEL` | | `info` | Pino log level | | `COOKIE_SECRET` | | *(JWT_SECRET)* | Secret for signed cookies — falls back to `JWT_SECRET` if empty | | `OTEL_ENDPOINT` | | *(disabled)* | OTLP HTTP endpoint (e.g. `http://localhost:4318/v1/traces`). Leave empty to disable tracing. | +| `TRUST_PROXY` | | *(disabled)* | Fastify trusted proxy setting for production client IPs, e.g. `127.0.0.1`, `10.0.0.0/8`, or `1` trusted hop | ## API Documentation @@ -128,6 +129,8 @@ All scripts run via `nub` (or `nubx` inside containers). See [package.json](pack | POST | `/api/v1/auth/mobile/login` | `x-mobile-api-key` | Login for mobile — returns `token` in body, no cookie | | POST | `/api/v1/auth/logout` | — | Logout — clears the `token` cookie | +Auth entry points (`register`, `login`, and `mobile/login`) are rate-limited to 5 requests per 15 minutes per client key in production. Development and test runs skip rate limiting so local API clients and integration tests are not throttled. + ### Users | Method | Path | Permission | Description | @@ -193,6 +196,10 @@ Each log entry records `action`, `resource_type`, `resource_id`, `metadata`, and > **Production note:** `/metrics` should be restricted at the network/gateway level and not exposed publicly. +### Deployment Notes + +In production, rate-limit keys prefer the leftmost `X-Forwarded-For` entry so clients behind a reverse proxy are keyed by their originating IP. Only allow trusted proxies or gateways to set or forward `X-Forwarded-For`; if clients can send that header directly to the app, they can spoof different IPs and evade per-IP limits. Deployments should strip inbound forwarding headers at the edge and re-add them from the trusted proxy layer. + ### Pagination All list endpoints accept `?page=1&limit=10` query parameters. @@ -361,7 +368,7 @@ Errors from the server surface as `RpcError` (with `.status` and `.data`) on the - **Zod is the single source of truth** for types — no manual interfaces. All types are derived via `z.infer<>` from schemas in each module's `schemas/index.ts`. - **Services have no Fastify imports** — they receive `db` as a parameter, making them independently testable. - **Error handling** is centralized in `app.ts` via `setErrorHandler`. All domain errors extend `AppError`. -- **Rate limiting** uses Valkey as the store — safe for multi-instance / horizontally scaled deployments. +- **Rate limiting** uses Valkey as the store — safe for multi-instance / horizontally scaled deployments. It is active only when `NODE_ENV=production`; development and test runs skip it. - **Request context** (`@fastify/request-context`) stores `requestId`, `userId`, `permissions`, and `isSuperAdmin` via AsyncLocalStorage, accessible anywhere in the call stack without passing them explicitly. - **Audit logging** is fire-and-forget (`logAudit` in `src/modules/audit-logs/helpers/`) — inserts never block the request path. Failures are silently swallowed so a logging error never surfaces to the caller. - **Graceful shutdown** is handled in `server.ts` — `SIGINT`/`SIGTERM` closes Fastify (draining connections) and flushes OpenTelemetry spans before exiting. diff --git a/docker-compose.yml b/docker-compose.yml index b25c903..b51a74f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,8 @@ services: OTEL_ENDPOINT: '' LOG_LEVEL: info MOBILE_API_KEY: change_this_to_a_random_string_at_least_32_characters + CORS_ORIGIN: '' + TRUST_PROXY: '' ports: - '3000:3000' volumes: diff --git a/migrations/0008_audit_logs_user_id_idx.sql b/migrations/0008_audit_logs_user_id_idx.sql new file mode 100644 index 0000000..809b5cd --- /dev/null +++ b/migrations/0008_audit_logs_user_id_idx.sql @@ -0,0 +1 @@ +CREATE INDEX "audit_logs_user_id_idx" ON "audit_logs" ("user_id"); diff --git a/migrations/0009_noop_admin_role_not_system.sql b/migrations/0009_noop_admin_role_not_system.sql new file mode 100644 index 0000000..8628d31 --- /dev/null +++ b/migrations/0009_noop_admin_role_not_system.sql @@ -0,0 +1,35 @@ +INSERT INTO "permissions" ("id", "resource", "action", "scope") +VALUES + (gen_random_uuid(), 'product', 'read', 'any'), + (gen_random_uuid(), 'product', 'create', 'any'), + (gen_random_uuid(), 'product', 'update', 'any'), + (gen_random_uuid(), 'product', 'delete', 'any'), + (gen_random_uuid(), 'metrics', 'read', 'any'), + (gen_random_uuid(), 'health', 'read', 'details') +ON CONFLICT ("resource", "action", "scope") DO NOTHING; +--> statement-breakpoint +INSERT INTO "role_permissions" ("role_id", "permission_id") +SELECT r."id", p."id" +FROM "roles" r +JOIN ( + VALUES + ('product', 'read', 'any'), + ('product', 'create', 'any'), + ('product', 'update', 'any'), + ('product', 'delete', 'any'), + ('metrics', 'read', 'any'), + ('health', 'read', 'details') +) AS added_permissions("resource", "action", "scope") + ON true +JOIN "permissions" p ON ( + p."resource" = added_permissions."resource" + AND p."action" = added_permissions."action" + AND p."scope" = added_permissions."scope" +) +WHERE r."name" = 'super-admin' + OR (r."name" = 'admin' AND ( + added_permissions."resource" = 'product' + OR added_permissions."resource" = 'health' + )) + OR (r."name" = 'user' AND added_permissions."resource" = 'product' AND added_permissions."action" = 'read') +ON CONFLICT DO NOTHING; diff --git a/migrations/meta/0008_snapshot.json b/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..368ee26 --- /dev/null +++ b/migrations/meta/0008_snapshot.json @@ -0,0 +1,579 @@ +{ + "id": "84d08f40-1e00-42d1-8275-a5273c528bd2", + "prevId": "f88fd5ad-2028-4215-9d83-afbed5ac55b1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_user_id_idx": { + "name": "audit_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_resource_action_scope_unique": { + "name": "permissions_resource_action_scope_unique", + "columns": [ + { + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permissions": { + "name": "role_permissions", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "role_permissions_role_id_roles_id_fk": { + "name": "role_permissions_role_id_roles_id_fk", + "tableFrom": "role_permissions", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permissions_permission_id_permissions_id_fk": { + "name": "role_permissions_permission_id_permissions_id_fk", + "tableFrom": "role_permissions", + "tableTo": "permissions", + "columnsFrom": [ + "permission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permissions_role_id_permission_id_pk": { + "name": "role_permissions_role_id_permission_id_pk", + "columns": [ + "role_id", + "permission_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "stock": { + "name": "stock", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "birth_date": { + "name": "birth_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profiles_user_id_users_id_fk": { + "name": "profiles_user_id_users_id_fk", + "tableFrom": "profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profiles_user_id_unique": { + "name": "profiles_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system_role": { + "name": "is_system_role", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/migrations/meta/0009_snapshot.json b/migrations/meta/0009_snapshot.json new file mode 100644 index 0000000..aaad50d --- /dev/null +++ b/migrations/meta/0009_snapshot.json @@ -0,0 +1,579 @@ +{ + "id": "4d3758db-8b41-488e-a3b9-dbe5ad18275d", + "prevId": "84d08f40-1e00-42d1-8275-a5273c528bd2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_user_id_idx": { + "name": "audit_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_resource_action_scope_unique": { + "name": "permissions_resource_action_scope_unique", + "columns": [ + { + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permissions": { + "name": "role_permissions", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "role_permissions_role_id_roles_id_fk": { + "name": "role_permissions_role_id_roles_id_fk", + "tableFrom": "role_permissions", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permissions_permission_id_permissions_id_fk": { + "name": "role_permissions_permission_id_permissions_id_fk", + "tableFrom": "role_permissions", + "tableTo": "permissions", + "columnsFrom": [ + "permission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permissions_role_id_permission_id_pk": { + "name": "role_permissions_role_id_permission_id_pk", + "columns": [ + "role_id", + "permission_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "stock": { + "name": "stock", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "birth_date": { + "name": "birth_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profiles_user_id_users_id_fk": { + "name": "profiles_user_id_users_id_fk", + "tableFrom": "profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profiles_user_id_unique": { + "name": "profiles_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system_role": { + "name": "is_system_role", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 9be704e..5e262d2 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -57,6 +57,20 @@ "when": 1782398877539, "tag": "0007_wet_victor_mancha", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1751030400000, + "tag": "0008_audit_logs_user_id_idx", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1751034000000, + "tag": "0009_noop_admin_role_not_system", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/app.ts b/src/app.ts index b17876b..d7449ff 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,5 @@ import type { ZodTypeProvider } from 'fastify-type-provider-zod' +import { existsSync, readFileSync } from 'node:fs' import process from 'node:process' import envPlugin from '@fastify/env' import Fastify from 'fastify' @@ -22,6 +23,7 @@ import dbPlugin from './plugins/db.js' import helmetPlugin from './plugins/helmet.js' import jwtPlugin from './plugins/jwt.js' import metricsPlugin from './plugins/metrics.js' +import mobileAuthPlugin from './plugins/mobile-auth.js' import multipartPlugin from './plugins/multipart.js' import rateLimitPlugin from './plugins/rate-limit.js' import requestContextPlugin from './plugins/request-context.js' @@ -30,8 +32,41 @@ import sensiblePlugin from './plugins/sensible.js' import underPressurePlugin from './plugins/under-pressure.js' import valkeyPlugin from './plugins/valkey.js' +function parseTrustProxy(value: string | undefined) { + if (!value) + return undefined + const normalized = value.trim().toLowerCase() + if (normalized === 'true') + return true + if (normalized === 'false') + return undefined + if (/^\d+$/.test(normalized)) + return Number.parseInt(normalized, 10) + return value +} + +function readDotEnvValue(key: string) { + if (!existsSync('.env')) + return undefined + const prefix = `${key}=` + const line = readFileSync('.env', 'utf8') + .split(/\r?\n/) + .find(line => line.trim().startsWith(prefix)) + const value = line?.trim().slice(prefix.length).trim() + if (!value) + return undefined + const quote = value[0] + if (quote === '"' || quote === '\'') { + const end = value.indexOf(quote, 1) + return end === -1 ? value.slice(1) : value.slice(1, end) + } + return value.replace(/\s+#.*$/, '').trim() +} + export async function buildApp() { + const trustProxy = parseTrustProxy(process.env.TRUST_PROXY ?? readDotEnvValue('TRUST_PROXY')) const fastify = Fastify({ + ...(trustProxy !== undefined && { trustProxy }), logger: { level: process.env.LOG_LEVEL ?? 'info', transport: @@ -51,44 +86,45 @@ export async function buildApp() { dotenv: true, }) - // Core utilities - await fastify.register(sensiblePlugin) + // Data layer + await fastify.register(dbPlugin) + await fastify.register(valkeyPlugin) + await fastify.register(rateLimitPlugin) // Security & transport await fastify.register(helmetPlugin) - await fastify.register(compressPlugin) await fastify.register(corsPlugin) await fastify.register(cookiePlugin) + // Auth + await fastify.register(jwtPlugin) + await fastify.register(requestContextPlugin) + await fastify.register(mobileAuthPlugin) + await fastify.register(authDecorator) + await fastify.register(requestIdHook) + + // Core utilities + await fastify.register(sensiblePlugin) + await fastify.register(compressPlugin) + // API docs await fastify.register(scalarPlugin) - // Data layer - await fastify.register(valkeyPlugin) - await fastify.register(rateLimitPlugin) - await fastify.register(dbPlugin) - // Reliability await fastify.register(underPressurePlugin) // Request lifecycle await fastify.register(multipartPlugin) - await fastify.register(requestContextPlugin) - // Observability + // Observability — registered after auth so fastify.authenticate is available await fastify.register(metricsPlugin) - // Auth - await fastify.register(jwtPlugin) - await fastify.register(authDecorator) - await fastify.register(requestIdHook) - // Global error handler fastify.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { return reply.status(error.statusCode).send(error.toJSON()) } - const err = error as Error & { validation?: Array> } + const err = error as Error & { code?: string, statusCode?: number, validation?: Array> } if (err.validation) { return reply.status(400).send({ success: false, @@ -108,6 +144,15 @@ export async function buildApp() { }, }) } + if (err.statusCode === 429) { + return reply.status(429).send({ + success: false, + error: { + code: err.code ?? 'HTTP_ERROR', + message: err.message, + }, + }) + } request.log.error({ err }, 'unhandled error') return reply.status(500).send({ success: false, diff --git a/src/common/constants/index.ts b/src/common/constants/index.ts index 2eb8fc1..d655f79 100644 --- a/src/common/constants/index.ts +++ b/src/common/constants/index.ts @@ -27,7 +27,19 @@ export const PERMISSIONS = { PERMISSION: { READ_ANY: 'permission:read:any', }, + PRODUCT: { + READ_ANY: 'product:read:any', + CREATE_ANY: 'product:create:any', + UPDATE_ANY: 'product:update:any', + DELETE_ANY: 'product:delete:any', + }, AUDIT_LOG: { READ_ANY: 'audit-log:read:any', }, + METRICS: { + READ_ANY: 'metrics:read:any', + }, + HEALTH: { + READ_DETAILS: 'health:read:details', + }, } as const diff --git a/src/common/hooks/request-id.ts b/src/common/hooks/request-id.ts index c68bdbe..9e21745 100644 --- a/src/common/hooks/request-id.ts +++ b/src/common/hooks/request-id.ts @@ -2,9 +2,12 @@ import type { FastifyPluginAsync } from 'fastify' import { randomUUID } from 'node:crypto' import fp from 'fastify-plugin' +const REQUEST_ID_PATTERN = /^[\w\-]{1,64}$/ + const requestIdHook: FastifyPluginAsync = async (fastify) => { fastify.addHook('onRequest', async (request, reply) => { - const id = (request.headers['x-request-id'] as string) ?? randomUUID() + const raw = request.headers['x-request-id'] as string | undefined + const id = raw && REQUEST_ID_PATTERN.test(raw) ? raw : randomUUID() reply.header('x-request-id', id) request.requestContext.set('requestId', id) }) diff --git a/src/common/schemas/index.ts b/src/common/schemas/index.ts index f79c71a..2ebe2a2 100644 --- a/src/common/schemas/index.ts +++ b/src/common/schemas/index.ts @@ -1,5 +1,13 @@ import { z } from 'zod' +export const passwordSchema = z.string() + .min(8) + .max(72) + .regex( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, + 'Password must contain at least one uppercase letter, one lowercase letter, and one number', + ) + export const paginationQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1).meta({ examples: [1] }), limit: z.coerce.number().int().min(1).max(100).default(10).meta({ examples: [10] }), diff --git a/src/config/README.md b/src/config/README.md index 13df43a..fa9d0df 100644 --- a/src/config/README.md +++ b/src/config/README.md @@ -45,3 +45,4 @@ No other files need to change — `fastify.config.MY_VAR` is immediately availab | `LOG_LEVEL` | `info` | Pino log levels | | `COOKIE_SECRET` | *(empty → JWT_SECRET)* | Secret for signed cookies | | `OTEL_ENDPOINT` | *(empty → disabled)* | OTLP HTTP trace exporter URL | +| `TRUST_PROXY` | *(empty → disabled)* | Fastify trusted proxy setting for deriving `request.ip` behind a reverse proxy | diff --git a/src/config/schema.ts b/src/config/schema.ts index 55c01b8..0fd3200 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -16,6 +16,8 @@ export interface AppConfig { COOKIE_SECRET: string OTEL_ENDPOINT: string MOBILE_API_KEY: string + CORS_ORIGIN: string + TRUST_PROXY: string } export const configSchema = { @@ -37,5 +39,7 @@ export const configSchema = { COOKIE_SECRET: { type: 'string', default: '' }, OTEL_ENDPOINT: { type: 'string', default: '' }, MOBILE_API_KEY: { type: 'string' }, + CORS_ORIGIN: { type: 'string', default: '' }, + TRUST_PROXY: { type: 'string', default: '' }, }, } as const diff --git a/src/contract/schemas/audit-logs.ts b/src/contract/schemas/audit-logs.ts index d271523..297005d 100644 --- a/src/contract/schemas/audit-logs.ts +++ b/src/contract/schemas/audit-logs.ts @@ -14,6 +14,7 @@ export const auditLogsSchema = { 200: apiListSchema(auditLogSchema), 401: apiErrorSchema, 403: apiErrorSchema, + 429: apiErrorSchema, }, }, listForUser: { @@ -27,6 +28,7 @@ export const auditLogsSchema = { 200: apiListSchema(auditLogSchema), 401: apiErrorSchema, 403: apiErrorSchema, + 429: apiErrorSchema, }, }, } satisfies RouteMap diff --git a/src/contract/schemas/auth.ts b/src/contract/schemas/auth.ts index c9a3579..984b77e 100644 --- a/src/contract/schemas/auth.ts +++ b/src/contract/schemas/auth.ts @@ -13,9 +13,12 @@ export const authSchema = { method: 'POST' as const, path: '/api/v1/auth/register', tags: ['Auth'], + rateLimit: { max: 5, timeWindow: '15 minutes' }, body: registerBodySchema, responses: { 201: apiSuccessSchema(authUserSchema), + 400: apiErrorSchema, + 429: apiErrorSchema, 409: apiErrorSchema, }, }, @@ -23,21 +26,25 @@ export const authSchema = { method: 'POST' as const, path: '/api/v1/auth/login', tags: ['Auth'], + rateLimit: { max: 5, timeWindow: '15 minutes' }, body: loginBodySchema, responses: { 200: apiSuccessSchema(authUserSchema), 401: apiErrorSchema, + 429: apiErrorSchema, }, }, mobileLogin: { method: 'POST' as const, path: '/api/v1/auth/mobile/login', tags: ['Auth'], + rateLimit: { max: 5, timeWindow: '15 minutes' }, body: loginBodySchema, responses: { 200: apiSuccessSchema(loginResponseSchema), 401: apiErrorSchema, 403: apiErrorSchema, + 429: apiErrorSchema, }, }, logout: { @@ -47,6 +54,7 @@ export const authSchema = { optionalAuth: true, responses: { 200: apiSuccessSchema(z.null()), + 429: apiErrorSchema, }, }, } satisfies RouteMap diff --git a/src/contract/schemas/permissions.ts b/src/contract/schemas/permissions.ts index 9bcd38c..28b7caf 100644 --- a/src/contract/schemas/permissions.ts +++ b/src/contract/schemas/permissions.ts @@ -13,6 +13,7 @@ export const permissionsSchema = { 200: apiListSchema(permissionSchema), 401: apiErrorSchema, 403: apiErrorSchema, + 429: apiErrorSchema, }, }, } satisfies RouteMap diff --git a/src/contract/schemas/products.ts b/src/contract/schemas/products.ts index b065691..793debf 100644 --- a/src/contract/schemas/products.ts +++ b/src/contract/schemas/products.ts @@ -1,5 +1,6 @@ import type { RouteMap } from '@/contract/types.js' import { z } from 'zod' +import { PERMISSIONS } from '@/common/constants/index.js' import { apiErrorSchema, apiListSchema, apiSuccessSchema, paginationQuerySchema, uuidParamSchema } from '@/common/schemas/index.js' import { createProductBodySchema, @@ -12,59 +13,69 @@ export const productsSchema = { method: 'GET' as const, path: '/api/v1/products', tags: ['Products'], - auth: true, + permission: PERMISSIONS.PRODUCT.READ_ANY, query: paginationQuerySchema, responses: { 200: apiListSchema(productSchema), 401: apiErrorSchema, + 403: apiErrorSchema, + 429: apiErrorSchema, }, }, get: { method: 'GET' as const, path: '/api/v1/products/:id', tags: ['Products'], - auth: true, + permission: PERMISSIONS.PRODUCT.READ_ANY, params: uuidParamSchema, responses: { 200: apiSuccessSchema(productSchema), 401: apiErrorSchema, + 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, create: { method: 'POST' as const, path: '/api/v1/products', tags: ['Products'], - auth: true, + permission: PERMISSIONS.PRODUCT.CREATE_ANY, body: createProductBodySchema, responses: { 201: apiSuccessSchema(productSchema), 401: apiErrorSchema, + 403: apiErrorSchema, + 429: apiErrorSchema, }, }, update: { method: 'PATCH' as const, path: '/api/v1/products/:id', tags: ['Products'], - auth: true, + permission: PERMISSIONS.PRODUCT.UPDATE_ANY, params: uuidParamSchema, body: updateProductBodySchema, responses: { 200: apiSuccessSchema(productSchema), 401: apiErrorSchema, + 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, delete: { method: 'DELETE' as const, path: '/api/v1/products/:id', tags: ['Products'], - auth: true, + permission: PERMISSIONS.PRODUCT.DELETE_ANY, params: uuidParamSchema, responses: { 204: z.null(), 401: apiErrorSchema, + 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, } satisfies RouteMap diff --git a/src/contract/schemas/profile.ts b/src/contract/schemas/profile.ts index 975d0e9..2c115bc 100644 --- a/src/contract/schemas/profile.ts +++ b/src/contract/schemas/profile.ts @@ -11,6 +11,7 @@ export const profileSchema = { responses: { 200: apiSuccessSchema(userSchema), 401: apiErrorSchema, + 429: apiErrorSchema, }, }, } satisfies RouteMap diff --git a/src/contract/schemas/roles.ts b/src/contract/schemas/roles.ts index 0e3f862..67e7b27 100644 --- a/src/contract/schemas/roles.ts +++ b/src/contract/schemas/roles.ts @@ -19,6 +19,7 @@ export const rolesSchema = { 200: apiListSchema(roleSchema), 401: apiErrorSchema, 403: apiErrorSchema, + 429: apiErrorSchema, }, }, get: { @@ -32,6 +33,7 @@ export const rolesSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, create: { @@ -45,6 +47,7 @@ export const rolesSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 409: apiErrorSchema, + 429: apiErrorSchema, }, }, update: { @@ -60,6 +63,7 @@ export const rolesSchema = { 403: apiErrorSchema, 404: apiErrorSchema, 409: apiErrorSchema, + 429: apiErrorSchema, }, }, delete: { @@ -73,6 +77,7 @@ export const rolesSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, assignPermission: { @@ -86,6 +91,7 @@ export const rolesSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, removePermission: { @@ -99,6 +105,7 @@ export const rolesSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, } satisfies RouteMap diff --git a/src/contract/schemas/users.ts b/src/contract/schemas/users.ts index 89728e6..c025702 100644 --- a/src/contract/schemas/users.ts +++ b/src/contract/schemas/users.ts @@ -24,6 +24,7 @@ export const usersSchema = { 200: apiListSchema(userSchema), 401: apiErrorSchema, 403: apiErrorSchema, + 429: apiErrorSchema, }, }, get: { @@ -37,6 +38,7 @@ export const usersSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, create: { @@ -50,6 +52,7 @@ export const usersSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 409: apiErrorSchema, + 429: apiErrorSchema, }, }, update: { @@ -65,6 +68,7 @@ export const usersSchema = { 403: apiErrorSchema, 404: apiErrorSchema, 409: apiErrorSchema, + 429: apiErrorSchema, }, }, delete: { @@ -78,6 +82,7 @@ export const usersSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, assignRole: { @@ -91,6 +96,7 @@ export const usersSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, removeRole: { @@ -104,6 +110,7 @@ export const usersSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, } satisfies RouteMap diff --git a/src/contract/types.ts b/src/contract/types.ts index c31bb17..a66eb83 100644 --- a/src/contract/types.ts +++ b/src/contract/types.ts @@ -13,6 +13,8 @@ export interface RouteSchema< auth?: boolean optionalAuth?: boolean permission?: string + tags?: string[] + rateLimit?: { max: number, timeWindow: string } query?: TQuery params?: TParams body?: TBody @@ -26,6 +28,7 @@ export type RouteMap = Record [ + index('audit_logs_user_id_idx').on(t.userId), +]) export type AuditLogRow = typeof auditLogs.$inferSelect export type NewAuditLogRow = typeof auditLogs.$inferInsert diff --git a/src/db/seed.ts b/src/db/seed.ts index bd00d2e..706c611 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -10,7 +10,7 @@ const SEED_ROLES = [ { name: 'user', description: 'Standard user access', isSystemRole: false }, ] -const SEED_PERMISSIONS = [ +export const SEED_PERMISSIONS = [ { resource: 'user', action: 'create', scope: 'any' }, { resource: 'user', action: 'read', scope: 'any' }, { resource: 'user', action: 'update', scope: 'any' }, @@ -25,12 +25,19 @@ const SEED_PERMISSIONS = [ { resource: 'permission', action: 'read', scope: 'any' }, { resource: 'permission', action: 'update', scope: 'any' }, { resource: 'permission', action: 'delete', scope: 'any' }, + { resource: 'product', action: 'read', scope: 'any' }, + { resource: 'product', action: 'create', scope: 'any' }, + { resource: 'product', action: 'update', scope: 'any' }, + { resource: 'product', action: 'delete', scope: 'any' }, { resource: 'audit-log', action: 'read', scope: 'any' }, + { resource: 'metrics', action: 'read', scope: 'any' }, + { resource: 'health', action: 'read', scope: 'details' }, ] const ROLE_PERMISSIONS: Record = { 'super-admin': SEED_PERMISSIONS.map(p => `${p.resource}:${p.action}:${p.scope}`), 'admin': [ + 'health:read:details', 'user:create:any', 'user:read:any', 'user:update:any', @@ -39,9 +46,13 @@ const ROLE_PERMISSIONS: Record = { 'user:update:own', 'role:read:any', 'permission:read:any', + 'product:read:any', + 'product:create:any', + 'product:update:any', + 'product:delete:any', 'audit-log:read:any', ], - 'user': ['user:read:own', 'user:update:own'], + 'user': ['user:read:own', 'user:update:own', 'product:read:any'], } export async function seedRoles(db: Db) { diff --git a/src/modules/auth/README.md b/src/modules/auth/README.md index 07d3ed7..2c35810 100644 --- a/src/modules/auth/README.md +++ b/src/modules/auth/README.md @@ -51,4 +51,4 @@ Authentication module — user registration and login. ## Password hashing -`bcryptjs` with the default salt rounds (10). Hashing happens in the service layer; controllers never touch raw passwords beyond passing the request body through. +`bcryptjs` with 12 salt rounds. Hashing happens in the service layer; controllers never touch raw passwords beyond passing the request body through. diff --git a/src/modules/auth/routes/index.ts b/src/modules/auth/routes/index.ts index e893899..915fe3f 100644 --- a/src/modules/auth/routes/index.ts +++ b/src/modules/auth/routes/index.ts @@ -1,4 +1,5 @@ import type { FastifyReply } from 'fastify' +import { createHash, timingSafeEqual } from 'node:crypto' import { ForbiddenError } from '@/common/errors/AppError.js' import { authSchema } from '@/contract/schemas/auth.js' import { logAudit } from '@/modules/audit-logs/helpers/log-audit.js' @@ -24,23 +25,35 @@ export default createFastifyRpcPlugin(authSchema, { secure: request.server.config.NODE_ENV === 'production', sameSite: 'strict', }) - logAudit(request.server.db, { userId: user.id, action: 'auth.logged_in', resourceType: 'user', resourceId: user.id, metadata: { email: user.email, ip: request.ip, ua: request.headers['user-agent'] ?? null } }) + logAudit(request.server.db, { userId: user.id, action: 'auth.logged_in', resourceType: 'user', resourceId: user.id, metadata: { ip: request.ip, ua: request.headers['user-agent'] ?? null } }) return { status: 200 as const, body: { success: true as const, data: { id: user.id, email: user.email } } } }, mobileLogin: async ({ body, request, reply }) => { - if (request.headers['x-mobile-api-key'] !== request.server.config.MOBILE_API_KEY) + if (!request.server.config.MOBILE_API_KEY) + throw new ForbiddenError('Mobile login is restricted to mobile clients') + // Hash both values to a fixed 32-byte digest before comparing so the + // comparison is always constant-time and leaks neither key length nor content. + const mobileApiKey = request.headers['x-mobile-api-key'] + const provided = createHash('sha256').update(Array.isArray(mobileApiKey) ? mobileApiKey[0] ?? '' : mobileApiKey ?? '').digest() + const expected = createHash('sha256').update(request.server.config.MOBILE_API_KEY).digest() + if (!timingSafeEqual(provided, expected)) throw new ForbiddenError('Mobile login is restricted to mobile clients') const user = await authService.loginUser(request.server.db, body) const token = await signToken(user, reply) - logAudit(request.server.db, { userId: user.id, action: 'auth.logged_in', resourceType: 'user', resourceId: user.id, metadata: { email: user.email, ip: request.ip, ua: request.headers['user-agent'] ?? null } }) + logAudit(request.server.db, { userId: user.id, action: 'auth.logged_in', resourceType: 'user', resourceId: user.id, metadata: { ip: request.ip, ua: request.headers['user-agent'] ?? null } }) return { status: 200 as const, body: { success: true as const, data: { id: user.id, email: user.email, token } } } }, logout: async ({ request, reply }) => { const userId = request.requestContext.get('userId') ?? null logAudit(request.server.db, { userId, action: 'auth.logged_out', resourceType: userId ? 'user' : 'anonymous', resourceId: userId, metadata: { ip: request.ip, ua: request.headers['user-agent'] ?? null } }) - reply.clearCookie('token', { path: '/' }) + reply.clearCookie('token', { + path: '/', + httpOnly: true, + secure: request.server.config.NODE_ENV === 'production', + sameSite: 'strict', + }) return { status: 200 as const, body: { success: true as const, data: null } } }, }) diff --git a/src/modules/auth/schemas/index.ts b/src/modules/auth/schemas/index.ts index 9cf5e28..0884c66 100644 --- a/src/modules/auth/schemas/index.ts +++ b/src/modules/auth/schemas/index.ts @@ -2,7 +2,10 @@ import { z } from 'zod' export const registerBodySchema = z.object({ email: z.email().meta({ examples: ['alice@example.com'] }), - password: z.string().min(8).max(72).meta({ examples: ['securepassword123'] }), + password: z.string().min(1).max(72).meta({ + description: 'New accounts require uppercase, lowercase, and digit; legacy account reactivation may use the original password.', + examples: ['SecurePassword1'], + }), }) export const loginBodySchema = z.object({ diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 3b2afdf..94ed171 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -2,18 +2,28 @@ import type { Db } from '@/db/index.js' import type { LoginBody, RegisterBody } from '@/modules/auth/schemas/index.js' import bcrypt from 'bcryptjs' import { and, eq, isNotNull, isNull } from 'drizzle-orm' -import { PG_UNIQUE_VIOLATION } from '@/common/constants/index.js' -import { ConflictError, UnauthorizedError } from '@/common/errors/AppError.js' +import { PG_UNIQUE_VIOLATION, ROLES } from '@/common/constants/index.js' +import { AppError, ConflictError, UnauthorizedError } from '@/common/errors/AppError.js' +import { passwordSchema } from '@/common/schemas/index.js' import { profiles, roles, userRoles, users } from '@/db/schema/index.js' import { logAudit } from '@/modules/audit-logs/helpers/log-audit.js' +// Constant-time dummy hash used to normalize timing when no dead account exists, +// preventing a timing oracle that distinguishes "email never registered" from +// "email soft-deleted" via the presence/absence of a bcrypt comparison. +const DUMMY_HASH = '$2b$12$Dn0etVDxKuYEzAYFGnb4hO3dJ3P1bLnRMOqFqDjNYr2JHM1vvD7Ui' + export async function registerUser(db: Db, body: RegisterBody) { // An email may have at most one active row but several soft-deleted rows // (the partial unique index only covers deletedAt IS NULL), so the conflict // check must be scoped to active rows specifically. const active = await db.query.users.findFirst({ where: and(eq(users.email, body.email), isNull(users.deletedAt)) }) - if (active) - throw new ConflictError(`Email '${body.email}' is already registered`) + if (active) { + // Normalize timing — run a dummy compare so active-conflict responses take + // the same bcrypt time as the dead-account path, preventing enumeration. + await bcrypt.compare(body.password, DUMMY_HASH) + throw new ConflictError('An account with this email already exists') + } // Reactivate a soft-deleted account if the password matches (within the 90-day // window before the cleanup cron hard-deletes it). Profile row still exists, so @@ -21,16 +31,22 @@ export async function registerUser(db: Db, body: RegisterBody) { const dead = await db.query.users.findFirst({ where: and(eq(users.email, body.email), isNotNull(users.deletedAt)) }) if (dead) { if (await bcrypt.compare(body.password, dead.passwordHash)) { - await db.update(users).set({ deletedAt: null, deletedBy: null }).where(eq(users.id, dead.id)) - const [userRole] = await db.select({ id: roles.id }).from(roles).where(eq(roles.name, 'user')).limit(1) - if (userRole) - await db.insert(userRoles).values({ userId: dead.id, roleId: userRole.id }).onConflictDoNothing() - logAudit(db, { userId: dead.id, action: 'auth.account_restored', resourceType: 'user', resourceId: dead.id, metadata: { email: dead.email } }) + await db.transaction(async (tx) => { + await tx.update(users).set({ deletedAt: null, deletedBy: null }).where(eq(users.id, dead.id)) + const [userRole] = await tx.select({ id: roles.id }).from(roles).where(eq(roles.name, ROLES.USER)).limit(1) + if (userRole) + await tx.insert(userRoles).values({ userId: dead.id, roleId: userRole.id }).onConflictDoNothing() + }) + logAudit(db, { userId: dead.id, action: 'auth.account_restored', resourceType: 'user', resourceId: dead.id }) return { id: dead.id, email: dead.email } } - throw new ConflictError(`Email '${body.email}' is already registered`) + throw new ConflictError('An account with this email already exists') } + const password = passwordSchema.safeParse(body.password) + if (!password.success) + throw new AppError(400, 'VALIDATION_ERROR', password.error.issues[0]?.message ?? 'Invalid password') + const passwordHash = await bcrypt.hash(body.password, 12) try { @@ -43,13 +59,13 @@ export async function registerUser(db: Db, body: RegisterBody) { await tx.insert(profiles).values({ userId: row.id }) // Assign the default 'user' role if seed has been run - const [userRole] = await tx.select({ id: roles.id }).from(roles).where(eq(roles.name, 'user')).limit(1) + const [userRole] = await tx.select({ id: roles.id }).from(roles).where(eq(roles.name, ROLES.USER)).limit(1) if (userRole) await tx.insert(userRoles).values({ userId: row.id, roleId: userRole.id }) return row }) - logAudit(db, { userId: user.id, action: 'auth.registered', resourceType: 'user', resourceId: user.id, metadata: { email: user.email } }) + logAudit(db, { userId: user.id, action: 'auth.registered', resourceType: 'user', resourceId: user.id }) return user } catch (err) { @@ -57,18 +73,21 @@ export async function registerUser(db: Db, body: RegisterBody) { // check above; the partial unique index rejects the loser with 23505. const pgCode = (err as { cause?: { code?: string } })?.cause?.code if (pgCode === PG_UNIQUE_VIOLATION) - throw new ConflictError(`Email '${body.email}' is already registered`) + throw new ConflictError('An account with this email already exists') throw err } } export async function loginUser(db: Db, body: LoginBody) { const user = await db.query.users.findFirst({ where: and(eq(users.email, body.email), isNull(users.deletedAt)) }) - if (!user) - throw new UnauthorizedError('Invalid email or password') - const valid = await bcrypt.compare(body.password, user.passwordHash) - if (!valid) + // Always run bcrypt to prevent timing-based email enumeration. When no user + // is found the DUMMY_HASH comparison ensures a constant-time response. + const valid = user + ? await bcrypt.compare(body.password, user.passwordHash) + : await bcrypt.compare(body.password, DUMMY_HASH).then(() => false) + + if (!user || !valid) throw new UnauthorizedError('Invalid email or password') return { id: user.id, email: user.email } diff --git a/src/modules/health/controllers/health.controller.ts b/src/modules/health/controllers/health.controller.ts index 4a8927c..e635820 100644 --- a/src/modules/health/controllers/health.controller.ts +++ b/src/modules/health/controllers/health.controller.ts @@ -1,6 +1,20 @@ import type { FastifyReply, FastifyRequest } from 'fastify' +import { performance } from 'node:perf_hooks' +import process from 'node:process' import { checkDb, checkValkey } from '@/modules/health/services/health.service.js' +interface PressureMetrics { + heapUsed: number + rssBytes: number + eventLoopDelay: number + eventLoopUtilized: number +} + +interface PressureDecorators { + memoryUsage?: () => PressureMetrics + isUnderPressure?: () => boolean +} + export async function liveness(_request: FastifyRequest, _reply: FastifyReply) { return { success: true as const, data: { status: 'ok' } } } @@ -25,18 +39,27 @@ export async function readiness(request: FastifyRequest, reply: FastifyReply) { } export async function details(request: FastifyRequest, _reply: FastifyReply) { - const memory = request.server.memoryUsage() + const pressure = request.server as FastifyRequest['server'] & PressureDecorators + const processMemory = process.memoryUsage() + const memory = pressure.memoryUsage?.() ?? { + heapUsed: processMemory.heapUsed, + rssBytes: processMemory.rss, + eventLoopDelay: 0, + eventLoopUtilized: performance.eventLoopUtilization().utilization, + } + const underPressure = pressure.isUnderPressure?.() ?? false + return { success: true as const, data: { - status: request.server.isUnderPressure() ? 'degraded' : 'ok', + status: underPressure ? 'degraded' : 'ok', memory: { heapUsed: memory.heapUsed, rssBytes: memory.rssBytes, eventLoopDelay: memory.eventLoopDelay, eventLoopUtilized: memory.eventLoopUtilized, }, - underPressure: request.server.isUnderPressure(), + underPressure, }, } } diff --git a/src/modules/health/routes/index.ts b/src/modules/health/routes/index.ts index 45da0ff..8c45710 100644 --- a/src/modules/health/routes/index.ts +++ b/src/modules/health/routes/index.ts @@ -1,5 +1,6 @@ import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { z } from 'zod' +import { PERMISSIONS } from '@/common/constants/index.js' import { apiErrorSchema, apiSuccessSchema } from '@/common/schemas/index.js' import * as controller from '@/modules/health/controllers/health.controller.js' @@ -31,6 +32,7 @@ const healthRoutes: FastifyPluginAsyncZod = async (fastify) => { schema: { tags: ['Health'], summary: 'System details — memory, event loop, pressure status', + security: [{ cookieAuth: [] }, { bearerAuth: [] }], response: { 200: apiSuccessSchema(z.object({ status: z.string(), @@ -42,8 +44,12 @@ const healthRoutes: FastifyPluginAsyncZod = async (fastify) => { }), underPressure: z.boolean(), })), + 401: apiErrorSchema, + 403: apiErrorSchema, + 429: apiErrorSchema, }, }, + preValidation: [fastify.authenticate, fastify.requirePermission(PERMISSIONS.HEALTH.READ_DETAILS)], handler: controller.details, }) } diff --git a/src/modules/roles/services/role.service.ts b/src/modules/roles/services/role.service.ts index 1332902..a872e9f 100644 --- a/src/modules/roles/services/role.service.ts +++ b/src/modules/roles/services/role.service.ts @@ -51,7 +51,10 @@ export async function updateRole(db: Db, id: string, body: UpdateRoleBody, calle if (existing.isSystemRole && body.name !== undefined && body.name !== existing.name) throw new ForbiddenError('System role names cannot be changed') try { - const [row] = await db.update(roles).set(body).where(eq(roles.id, id)).returning() + const [row] = await db.update(roles).set({ + ...(body.name !== undefined && { name: body.name }), + ...(body.description !== undefined && { description: body.description }), + }).where(eq(roles.id, id)).returning() if (!row) throw new NotFoundError('Role', id) return toRole(row) diff --git a/src/modules/users/routes/index.ts b/src/modules/users/routes/index.ts index 6cc61c7..a0e8d27 100644 --- a/src/modules/users/routes/index.ts +++ b/src/modules/users/routes/index.ts @@ -31,14 +31,14 @@ export default createFastifyRpcPlugin(usersSchema, { create: async ({ body, request }) => { const user = await userService.createUser(request.server.db, body) - logAudit(request.server.db, { userId: request.requestContext.get('userId'), action: 'user.created', resourceType: 'user', resourceId: user.id, metadata: { email: user.email } }) + logAudit(request.server.db, { userId: request.requestContext.get('userId'), action: 'user.created', resourceType: 'user', resourceId: user.id }) return { status: 201 as const, body: { success: true as const, data: user } } }, update: async ({ params, body, request }) => { assertSelfOrAdmin(request, params.id, PERMISSIONS.USER.UPDATE_ANY) const user = await userService.updateUser(request.server.db, params.id, body) - logAudit(request.server.db, { userId: request.requestContext.get('userId'), action: 'user.updated', resourceType: 'user', resourceId: params.id, metadata: { changes: body } }) + logAudit(request.server.db, { userId: request.requestContext.get('userId'), action: 'user.updated', resourceType: 'user', resourceId: params.id, metadata: { changedFields: Object.keys(body) } }) return { status: 200 as const, body: { success: true as const, data: user } } }, diff --git a/src/modules/users/schemas/index.ts b/src/modules/users/schemas/index.ts index 97f36ae..dad6425 100644 --- a/src/modules/users/schemas/index.ts +++ b/src/modules/users/schemas/index.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { paginationQuerySchema, uuidParamSchema } from '@/common/schemas/index.js' +import { paginationQuerySchema, passwordSchema, uuidParamSchema } from '@/common/schemas/index.js' export const profileSchema = z.object({ firstName: z.string().nullable().meta({ examples: ['John'] }), @@ -26,7 +26,7 @@ export const userSchema = z.object({ export const createUserBodySchema = z.object({ email: z.email().meta({ examples: ['user@example.com'] }), - password: z.string().min(8).max(72).meta({ examples: ['securepassword123'] }), + password: passwordSchema.meta({ examples: ['SecurePassword1'] }), }) export const updateProfileBodySchema = z.object({ diff --git a/src/plugins/README.md b/src/plugins/README.md index a9b31fd..9a2c8ee 100644 --- a/src/plugins/README.md +++ b/src/plugins/README.md @@ -36,7 +36,7 @@ requestIdHook ← writes requestId into request context | `cors.ts` | `@fastify/cors` | CORS — production-restricted by `NODE_ENV` | | `cookie.ts` | `@fastify/cookie` | Signed cookie parsing (`COOKIE_SECRET` \| `JWT_SECRET`) | | `valkey.ts` | `@valkey/valkey-glide` | `fastify.valkey` — shared Valkey GLIDE client | -| `rate-limit.ts` | `@fastify/rate-limit` | Per-IP rate limiting (Valkey store, 100 req / 15 min) | +| `rate-limit.ts` | `@fastify/rate-limit` | Production-only per-IP rate limiting (Redis store, 100 req / 15 min) | | `db.ts` | `drizzle-orm` | `fastify.db` — typed Drizzle ORM instance | | `under-pressure.ts` | `@fastify/under-pressure` | Auto-503 when heap > 200 MB, RSS > 300 MB, or loop delay > 1 s | | `multipart.ts` | `@fastify/multipart` | File upload support (10 MB / file, max 10 files) | diff --git a/src/plugins/cookie.ts b/src/plugins/cookie.ts index cb822ca..f42ce42 100644 --- a/src/plugins/cookie.ts +++ b/src/plugins/cookie.ts @@ -3,9 +3,11 @@ import cookie from '@fastify/cookie' import fp from 'fastify-plugin' const cookiePlugin: FastifyPluginAsync = async (fastify) => { + if (fastify.config.NODE_ENV === 'production' && !fastify.config.COOKIE_SECRET) { + throw new Error('COOKIE_SECRET must be set in production to isolate cookie signing from JWT signing') + } + await fastify.register(cookie, { - // Falls back to JWT_SECRET so the app works without a separate COOKIE_SECRET. - // Set COOKIE_SECRET in prod to isolate cookie signing from JWT signing. secret: fastify.config.COOKIE_SECRET || fastify.config.JWT_SECRET, parseOptions: { httpOnly: true, diff --git a/src/plugins/cors.ts b/src/plugins/cors.ts index 49a6b97..c431c27 100644 --- a/src/plugins/cors.ts +++ b/src/plugins/cors.ts @@ -3,10 +3,22 @@ import cors from '@fastify/cors' import fp from 'fastify-plugin' const corsPlugin: FastifyPluginAsync = async (fastify) => { + const configuredOrigins = fastify.config.CORS_ORIGIN + ? fastify.config.CORS_ORIGIN.split(',').map(s => s.trim()).filter(Boolean) + : [] + + if (fastify.config.NODE_ENV === 'production' && configuredOrigins.length === 0) { + throw new Error('CORS_ORIGIN must be set in production') + } + + const origin = fastify.config.NODE_ENV === 'production' + ? configuredOrigins + : ['http://localhost:3000', 'http://localhost:5173'] + await fastify.register(cors, { - origin: fastify.config.NODE_ENV === 'production' ? ['https://yourdomain.com'] : true, + origin, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Mobile-Api-Key'], credentials: true, }) } diff --git a/src/plugins/metrics.ts b/src/plugins/metrics.ts index ba03ae2..e084bc6 100644 --- a/src/plugins/metrics.ts +++ b/src/plugins/metrics.ts @@ -1,6 +1,7 @@ import type { FastifyPluginAsync } from 'fastify' import fp from 'fastify-plugin' import { collectDefaultMetrics, Registry } from 'prom-client' +import { PERMISSIONS } from '@/common/constants/index.js' declare module 'fastify' { interface FastifyInstance { @@ -17,6 +18,7 @@ const metricsPlugin: FastifyPluginAsync = async (fastify) => { fastify.get('/metrics', { schema: { hide: true }, + preValidation: [fastify.authenticate, fastify.requirePermission(PERMISSIONS.METRICS.READ_ANY)], handler: async (_request, reply) => { reply.header('Content-Type', registry.contentType) return registry.metrics() diff --git a/src/plugins/mobile-auth.ts b/src/plugins/mobile-auth.ts new file mode 100644 index 0000000..8a3a45e --- /dev/null +++ b/src/plugins/mobile-auth.ts @@ -0,0 +1,9 @@ +import type { FastifyPluginAsync } from 'fastify' +import fp from 'fastify-plugin' + +const mobileAuthPlugin: FastifyPluginAsync = async (fastify) => { + if (fastify.config.NODE_ENV === 'production' && !fastify.config.MOBILE_API_KEY) + throw new Error('MOBILE_API_KEY must be set in production') +} + +export default fp(mobileAuthPlugin, { name: 'mobile-auth' }) diff --git a/src/plugins/rate-limit.ts b/src/plugins/rate-limit.ts index 925b0f5..d7563d8 100644 --- a/src/plugins/rate-limit.ts +++ b/src/plugins/rate-limit.ts @@ -4,20 +4,18 @@ import fp from 'fastify-plugin' import { createValkeyRateLimitStore } from './rate-limit-store.js' const rateLimitPlugin: FastifyPluginAsync = async (fastify) => { - // Skip in dev — hot reloads would exhaust the window constantly. - if (fastify.config.NODE_ENV === 'development') + // Keep local development and integration tests unthrottled. Production uses + // Redis-backed limits so counters are shared across app instances. + if (fastify.config.NODE_ENV !== 'production') return await fastify.register(rateLimit, { + global: true, max: 100, timeWindow: '15 minutes', - allowList: ['127.0.0.1'], store: createValkeyRateLimitStore(fastify.valkey), - // x-forwarded-for first so clients behind a reverse proxy are keyed by - // their real IP, not the proxy's. allowList is the real spoofing defence. - keyGenerator: (request) => { - return request.headers['x-forwarded-for'] as string ?? request.ip - }, + allowList: request => request.url === '/health/live' || request.url === '/health/ready', + keyGenerator: request => request.ip, }) } diff --git a/src/plugins/rpc.ts b/src/plugins/rpc.ts index 6fb2887..b1cffb4 100644 --- a/src/plugins/rpc.ts +++ b/src/plugins/rpc.ts @@ -42,6 +42,7 @@ export function createFastifyRpcPlugin( fastify.route({ method: route.method as any, url: route.path, + ...(route.rateLimit !== undefined && { config: { rateLimit: route.rateLimit } }), schema: { ...(route.tags !== undefined && { tags: route.tags }), ...((route.auth || route.permission) && { security: [{ cookieAuth: [] }, { bearerAuth: [] }] }), diff --git a/src/tests/fixtures/index.ts b/src/tests/fixtures/index.ts index 9d4ff07..37e6e41 100644 --- a/src/tests/fixtures/index.ts +++ b/src/tests/fixtures/index.ts @@ -20,7 +20,7 @@ export async function resetDb(app: Awaited>) { export async function registerAndLogin( app: Awaited>, - user = { email: 'test@example.com', password: 'password123' }, + user = { email: 'test@example.com', password: 'Password123' }, ) { await app.inject({ method: 'POST', @@ -51,11 +51,11 @@ async function registerWithRoleAndLogin( return extractTokenFromCookie(res.headers['set-cookie']) } -export function registerAdminAndLogin(app: Awaited>, user = { email: 'admin@example.com', password: 'password123' }) { +export function registerAdminAndLogin(app: Awaited>, user = { email: 'admin@example.com', password: 'Password123' }) { return registerWithRoleAndLogin(app, 'admin', user) } -export function registerSuperAdminAndLogin(app: Awaited>, user = { email: 'superadmin@example.com', password: 'password123' }) { +export function registerSuperAdminAndLogin(app: Awaited>, user = { email: 'superadmin@example.com', password: 'Password123' }) { return registerWithRoleAndLogin(app, 'super-admin', user) } @@ -84,7 +84,7 @@ export async function eventually(read: () => Promise, done: (value: T) => export async function registerAndLoginWithUser( app: Awaited>, - user = { email: 'test@example.com', password: 'password123' }, + user = { email: 'test@example.com', password: 'Password123' }, ) { const registerRes = await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload: user }) const { data } = registerRes.json<{ data: { id: string, email: string } }>() diff --git a/src/tests/modules/audit-logs.test.ts b/src/tests/modules/audit-logs.test.ts index ff5aacd..1956e7f 100644 --- a/src/tests/modules/audit-logs.test.ts +++ b/src/tests/modules/audit-logs.test.ts @@ -84,7 +84,7 @@ describe('audit logs API', () => { it('returns 403 when requesting another user logs', async () => { const token = await registerAndLogin(app) - const otherToken = await registerAndLogin(app, { email: 'other@example.com', password: 'password123' }) + const otherToken = await registerAndLogin(app, { email: 'other@example.com', password: 'Password123' }) // Get the other user's id by logging in and hitting /profile const profileRes = await app.inject({ @@ -143,13 +143,12 @@ describe('audit logs API', () => { describe('mutation logging smoke tests', () => { it('records product.created after creating a product', async () => { - const userToken = await registerAndLogin(app) const adminToken = await registerAdminAndLogin(app) await app.inject({ method: 'POST', url: '/api/v1/products', - headers: { authorization: `Bearer ${userToken}` }, + headers: { authorization: `Bearer ${adminToken}` }, payload: { name: 'Widget', price: 9.99, stock: 5 }, }) @@ -167,13 +166,12 @@ describe('audit logs API', () => { }) it('records product.deleted with metadata after deleting a product', async () => { - const userToken = await registerAndLogin(app) const adminToken = await registerAdminAndLogin(app) const created = await app.inject({ method: 'POST', url: '/api/v1/products', - headers: { authorization: `Bearer ${userToken}` }, + headers: { authorization: `Bearer ${adminToken}` }, payload: { name: 'Doomed Widget', price: 4.99, stock: 1 }, }) const productId = created.json().data.id @@ -181,7 +179,7 @@ describe('audit logs API', () => { await app.inject({ method: 'DELETE', url: `/api/v1/products/${productId}`, - headers: { authorization: `Bearer ${userToken}` }, + headers: { authorization: `Bearer ${adminToken}` }, }) const body = await eventually( @@ -209,12 +207,12 @@ describe('audit logs API', () => { ) const log = body.data.find((l: { action: string }) => l.action === 'auth.logged_in') expect(log).toBeDefined() - expect(log.metadata).toMatchObject({ email: expect.any(String), ip: expect.any(String) }) + expect(log.metadata).toMatchObject({ ip: expect.any(String) }) }) it('records user.deleted after deleting a user', async () => { - const observerToken = await registerSuperAdminAndLogin(app, { email: 'observer@example.com', password: 'password123' }) - const adminToken = await registerAdminAndLogin(app, { email: 'todelete@example.com', password: 'password123' }) + const observerToken = await registerSuperAdminAndLogin(app, { email: 'observer@example.com', password: 'Password123' }) + const adminToken = await registerAdminAndLogin(app, { email: 'todelete@example.com', password: 'Password123' }) const profileRes = await app.inject({ method: 'GET', url: '/api/v1/profile', diff --git a/src/tests/modules/auth.test.ts b/src/tests/modules/auth.test.ts index 836352e..0cac9b2 100644 --- a/src/tests/modules/auth.test.ts +++ b/src/tests/modules/auth.test.ts @@ -1,7 +1,9 @@ import type { FastifyInstance } from 'fastify' +import bcrypt from 'bcryptjs' import { and, eq } from 'drizzle-orm' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import { auditLogs } from '@/db/schema/index.js' +import { ROLES } from '@/common/constants/index.js' +import { auditLogs, profiles, roles, userRoles, users } from '@/db/schema/index.js' import { createTestApp, eventually, extractTokenFromCookie, firstCookieHeader, registerAndLogin, registerAndLoginWithUser, resetDb } from '@/tests/fixtures/index.js' describe('auth API', () => { @@ -26,7 +28,7 @@ describe('auth API', () => { const res = await app.inject({ method: 'POST', url: '/api/v1/auth/register', - payload: { email: 'alice@example.com', password: 'password123' }, + payload: { email: 'alice@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(201) const { data } = res.json<{ data: { id: string, email: string } }>() @@ -38,13 +40,13 @@ describe('auth API', () => { const res = await app.inject({ method: 'POST', url: '/api/v1/auth/register', - payload: { email: 'alice@example.com', password: 'password123' }, + payload: { email: 'alice@example.com', password: 'Password123' }, }) expect(res.json()).not.toHaveProperty('data.passwordHash') }) it('returns 409 for duplicate email', async () => { - const payload = { email: 'bob@example.com', password: 'password123' } + const payload = { email: 'bob@example.com', password: 'Password123' } await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload }) const res = await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload }) expect(res.statusCode).toBe(409) @@ -59,11 +61,20 @@ describe('auth API', () => { expect(res.statusCode).toBe(400) }) + it('returns 400 for password meeting length but not complexity', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { email: 'alice@example.com', password: 'password123' }, + }) + expect(res.statusCode).toBe(400) + }) + it('returns 400 for invalid email format', async () => { const res = await app.inject({ method: 'POST', url: '/api/v1/auth/register', - payload: { email: 'not-an-email', password: 'password123' }, + payload: { email: 'not-an-email', password: 'Password123' }, }) expect(res.statusCode).toBe(400) }) @@ -72,7 +83,7 @@ describe('auth API', () => { const res = await app.inject({ method: 'POST', url: '/api/v1/auth/register', - payload: { password: 'password123' }, + payload: { password: 'Password123' }, }) expect(res.statusCode).toBe(400) }) @@ -94,7 +105,7 @@ describe('auth API', () => { await app.inject({ method: 'POST', url: '/api/v1/auth/register', - payload: { email: 'dave@example.com', password: 'password123' }, + payload: { email: 'dave@example.com', password: 'Password123' }, }) }) @@ -102,7 +113,7 @@ describe('auth API', () => { const res = await app.inject({ method: 'POST', url: '/api/v1/auth/login', - payload: { email: 'dave@example.com', password: 'password123' }, + payload: { email: 'dave@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(200) const cookie = firstCookieHeader(res.headers['set-cookie']) @@ -114,7 +125,7 @@ describe('auth API', () => { const res = await app.inject({ method: 'POST', url: '/api/v1/auth/login', - payload: { email: 'dave@example.com', password: 'password123' }, + payload: { email: 'dave@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(200) const { data } = res.json<{ data: { id: string, email: string } }>() @@ -127,7 +138,7 @@ describe('auth API', () => { const loginRes = await app.inject({ method: 'POST', url: '/api/v1/auth/login', - payload: { email: 'dave@example.com', password: 'password123' }, + payload: { email: 'dave@example.com', password: 'Password123' }, }) const token = firstCookieHeader(loginRes.headers['set-cookie']).split(';')[0] const res = await app.inject({ @@ -151,7 +162,7 @@ describe('auth API', () => { const res = await app.inject({ method: 'POST', url: '/api/v1/auth/login', - payload: { email: 'ghost@example.com', password: 'password123' }, + payload: { email: 'ghost@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(401) }) @@ -173,7 +184,7 @@ describe('auth API', () => { await app.inject({ method: 'POST', url: '/api/v1/auth/register', - payload: { email: 'mobile@example.com', password: 'password123' }, + payload: { email: 'mobile@example.com', password: 'Password123' }, }) }) @@ -182,7 +193,7 @@ describe('auth API', () => { method: 'POST', url: '/api/v1/auth/mobile/login', headers: { 'x-mobile-api-key': app.config.MOBILE_API_KEY }, - payload: { email: 'mobile@example.com', password: 'password123' }, + payload: { email: 'mobile@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(200) const { data } = res.json<{ data: { id: string, email: string, token: string } }>() @@ -197,7 +208,7 @@ describe('auth API', () => { method: 'POST', url: '/api/v1/auth/mobile/login', headers: { 'x-mobile-api-key': 'wrong-key' }, - payload: { email: 'mobile@example.com', password: 'password123' }, + payload: { email: 'mobile@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(403) }) @@ -206,7 +217,7 @@ describe('auth API', () => { const res = await app.inject({ method: 'POST', url: '/api/v1/auth/mobile/login', - payload: { email: 'mobile@example.com', password: 'password123' }, + payload: { email: 'mobile@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(403) }) @@ -216,7 +227,7 @@ describe('auth API', () => { method: 'POST', url: '/api/v1/auth/mobile/login', headers: { 'x-mobile-api-key': app.config.MOBILE_API_KEY }, - payload: { email: 'mobile@example.com', password: 'password123' }, + payload: { email: 'mobile@example.com', password: 'Password123' }, }) const { data } = loginRes.json<{ data: { token: string } }>() @@ -243,7 +254,7 @@ describe('auth API', () => { method: 'POST', url: '/api/v1/auth/mobile/login', headers: { 'x-mobile-api-key': app.config.MOBILE_API_KEY }, - payload: { email: 'ghost@example.com', password: 'password123' }, + payload: { email: 'ghost@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(401) }) @@ -263,7 +274,7 @@ describe('auth API', () => { describe('/api/v1/auth/logout POST ', () => { it('clears the token cookie', async () => { - const token = await registerAndLogin(app, { email: 'logout@example.com', password: 'password123' }) + const token = await registerAndLogin(app, { email: 'logout@example.com', password: 'Password123' }) const res = await app.inject({ method: 'POST', url: '/api/v1/auth/logout', headers: { cookie: `token=${token}` } }) expect(res.statusCode).toBe(200) const cookie = firstCookieHeader(res.headers['set-cookie']) @@ -272,7 +283,7 @@ describe('auth API', () => { }) it('records the logged-out user in the audit log', async () => { - const { user, token } = await registerAndLoginWithUser(app, { email: 'logout-audit@example.com', password: 'password123' }) + const { user, token } = await registerAndLoginWithUser(app, { email: 'logout-audit@example.com', password: 'Password123' }) await app.inject({ method: 'POST', url: '/api/v1/auth/logout', headers: { cookie: `token=${token}` } }) @@ -306,14 +317,14 @@ describe('auth API', () => { const registerRes = await app.inject({ method: 'POST', url: '/api/v1/auth/register', - payload: { email: 'mobile-logout@example.com', password: 'password123' }, + payload: { email: 'mobile-logout@example.com', password: 'Password123' }, }) const { data: user } = registerRes.json<{ data: { id: string } }>() const loginRes = await app.inject({ method: 'POST', url: '/api/v1/auth/mobile/login', headers: { 'x-mobile-api-key': app.config.MOBILE_API_KEY }, - payload: { email: 'mobile-logout@example.com', password: 'password123' }, + payload: { email: 'mobile-logout@example.com', password: 'Password123' }, }) const { data: { token } } = loginRes.json<{ data: { token: string } }>() @@ -345,25 +356,46 @@ describe('auth API', () => { // Register, then soft-delete the account. Returns the created id. async function registerThenDelete(email: string) { - const { data: created } = (await register(email, 'password123')).json<{ data: { id: string } }>() - const token = extractTokenFromCookie((await login(email, 'password123')).headers['set-cookie']) + const { data: created } = (await register(email, 'Password123')).json<{ data: { id: string } }>() + const token = extractTokenFromCookie((await login(email, 'Password123')).headers['set-cookie']) await app.inject({ method: 'DELETE', url: `/api/v1/users/${created.id}`, headers: { authorization: `Bearer ${token}` } }) return created.id } + async function createLegacyUser(email: string, password: string) { + const [user] = await app.db.insert(users).values({ email, passwordHash: await bcrypt.hash(password, 12) }).returning({ id: users.id }) + await app.db.insert(profiles).values({ userId: user.id }) + const role = await app.db.query.roles.findFirst({ where: eq(roles.name, ROLES.USER) }) + if (role) + await app.db.insert(userRoles).values({ userId: user.id, roleId: role.id }) + return user.id + } + it('reactivates the same account on re-register with the correct password', async () => { const id = await registerThenDelete('erin@example.com') - const again = await register('erin@example.com', 'password123') + const again = await register('erin@example.com', 'Password123') + expect(again.statusCode).toBe(201) + expect(again.json<{ data: { id: string } }>().data.id).toBe(id) + expect((await login('erin@example.com', 'Password123')).statusCode).toBe(200) + }) + + it('reactivates a legacy account with a correct weak password', async () => { + const email = 'legacy@example.com' + const password = 'password123' + const id = await createLegacyUser(email, password) + const token = extractTokenFromCookie((await login(email, password)).headers['set-cookie']) + await app.inject({ method: 'DELETE', url: `/api/v1/users/${id}`, headers: { authorization: `Bearer ${token}` } }) + + const again = await register(email, password) expect(again.statusCode).toBe(201) expect(again.json<{ data: { id: string } }>().data.id).toBe(id) - expect((await login('erin@example.com', 'password123')).statusCode).toBe(200) }) it('restores the account\'s profile data on reactivation', async () => { const email = 'iris@example.com' - const { data: created } = (await register(email, 'password123')).json<{ data: { id: string } }>() - const token = extractTokenFromCookie((await login(email, 'password123')).headers['set-cookie']) + const { data: created } = (await register(email, 'Password123')).json<{ data: { id: string } }>() + const token = extractTokenFromCookie((await login(email, 'Password123')).headers['set-cookie']) const headers = { authorization: `Bearer ${token}` } // set some profile data, then self-delete @@ -371,8 +403,8 @@ describe('auth API', () => { await app.inject({ method: 'DELETE', url: `/api/v1/users/${created.id}`, headers }) // reactivate, then confirm the profile data survived - await register(email, 'password123') - const token2 = extractTokenFromCookie((await login(email, 'password123')).headers['set-cookie']) + await register(email, 'Password123') + const token2 = extractTokenFromCookie((await login(email, 'Password123')).headers['set-cookie']) const me = await app.inject({ method: 'GET', url: '/api/v1/profile', headers: { authorization: `Bearer ${token2}` } }) expect(me.statusCode).toBe(200) expect(me.json<{ data: { profile: { firstName: string | null } } }>().data.profile.firstName).toBe('Iris') @@ -381,8 +413,8 @@ describe('auth API', () => { it('reactivated account retains the user role and can access own-account routes', async () => { const id = await registerThenDelete('jay@example.com') - await register('jay@example.com', 'password123') - const token = extractTokenFromCookie((await login('jay@example.com', 'password123')).headers['set-cookie']) + await register('jay@example.com', 'Password123') + const token = extractTokenFromCookie((await login('jay@example.com', 'Password123')).headers['set-cookie']) // user:update:own permission must be restored — self-PATCH requires it const patch = await app.inject({ @@ -397,23 +429,23 @@ describe('auth API', () => { it('returns 409 when re-registering a soft-deleted account with the wrong password', async () => { await registerThenDelete('frank@example.com') - const again = await register('frank@example.com', 'differentpw') + const again = await register('frank@example.com', 'DifferentPassword1') expect(again.statusCode).toBe(409) }) it('does not let a soft-deleted account log in', async () => { await registerThenDelete('grace@example.com') - expect((await login('grace@example.com', 'password123')).statusCode).toBe(401) + expect((await login('grace@example.com', 'Password123')).statusCode).toBe(401) }) it('does not block correct-password reactivation after a failed wrong-password attempt', async () => { const id = await registerThenDelete('henry@example.com') // wrong password → 409, no orphan account created - expect((await register('henry@example.com', 'wrongpassword')).statusCode).toBe(409) + expect((await register('henry@example.com', 'WrongPassword1')).statusCode).toBe(409) // original password still reactivates the same account - const again = await register('henry@example.com', 'password123') + const again = await register('henry@example.com', 'Password123') expect(again.statusCode).toBe(201) expect(again.json<{ data: { id: string } }>().data.id).toBe(id) }) diff --git a/src/tests/modules/health.test.ts b/src/tests/modules/health.test.ts new file mode 100644 index 0000000..242466a --- /dev/null +++ b/src/tests/modules/health.test.ts @@ -0,0 +1,91 @@ +import type { FastifyInstance } from 'fastify' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { createTestApp, registerAdminAndLogin, registerAndLogin, registerSuperAdminAndLogin, resetDb } from '@/tests/fixtures/index.js' + +describe('health API', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await createTestApp() + }) + + beforeEach(async () => { + await resetDb(app) + }) + + afterAll(async () => { + await app.close() + }) + + describe('get /health/live', () => { + it('returns 200 without authentication', async () => { + const res = await app.inject({ method: 'GET', url: '/health/live' }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ success: true, data: { status: 'ok' } }) + }) + }) + + describe('get /health/ready', () => { + it('returns 200 without authentication when dependencies are reachable', async () => { + const res = await app.inject({ method: 'GET', url: '/health/ready' }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ success: true, data: { status: 'ready' } }) + }) + }) + + describe('get /health/details', () => { + const expectHealthDetails = (body: { + success: boolean + data: { + status: string + memory: Record + underPressure: boolean + } + }) => { + expect(body.success).toBe(true) + expect(['ok', 'degraded']).toContain(body.data.status) + expect(body.data.underPressure).toEqual(expect.any(Boolean)) + expect(body.data.memory.heapUsed).toEqual(expect.any(Number)) + expect(body.data.memory.rssBytes).toEqual(expect.any(Number)) + expect(body.data.memory.eventLoopDelay).toEqual(expect.any(Number)) + expect(body.data.memory.eventLoopUtilized).toEqual(expect.any(Number)) + } + + it('returns 401 without a token', async () => { + const res = await app.inject({ method: 'GET', url: '/health/details' }) + expect(res.statusCode).toBe(401) + }) + + it('returns 403 for regular user role', async () => { + const token = await registerAndLogin(app, { email: 'user@example.com', password: 'Password123' }) + const res = await app.inject({ + method: 'GET', + url: '/health/details', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(403) + }) + + it('returns 200 with system details for admin role', async () => { + const token = await registerAdminAndLogin(app) + const res = await app.inject({ + method: 'GET', + url: '/health/details', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + expectHealthDetails(res.json()) + }) + + it('returns 200 with system details for super-admin role', async () => { + const token = await registerSuperAdminAndLogin(app) + const res = await app.inject({ + method: 'GET', + url: '/health/details', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + expectHealthDetails(res.json()) + }) + }) +}) diff --git a/src/tests/modules/metrics.test.ts b/src/tests/modules/metrics.test.ts new file mode 100644 index 0000000..be24008 --- /dev/null +++ b/src/tests/modules/metrics.test.ts @@ -0,0 +1,54 @@ +import type { FastifyInstance } from 'fastify' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { createTestApp, registerAdminAndLogin, registerAndLogin, registerSuperAdminAndLogin, resetDb } from '@/tests/fixtures/index.js' + +describe('metrics API', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await createTestApp() + }) + + beforeEach(async () => { + await resetDb(app) + }) + + afterAll(async () => { + await app.close() + }) + + it('returns 401 without a token', async () => { + const res = await app.inject({ method: 'GET', url: '/metrics' }) + expect(res.statusCode).toBe(401) + }) + + it('returns 403 with a regular user JWT (no metrics:read:any permission)', async () => { + const token = await registerAndLogin(app, { email: 'user@example.com', password: 'Password123' }) + const res = await app.inject({ + method: 'GET', + url: '/metrics', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(403) + }) + + it('returns 403 with an admin JWT (admin role does not have metrics:read:any)', async () => { + const token = await registerAdminAndLogin(app) + const res = await app.inject({ + method: 'GET', + url: '/metrics', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(403) + }) + + it('returns 200 with a super-admin JWT', async () => { + const token = await registerSuperAdminAndLogin(app) + const res = await app.inject({ + method: 'GET', + url: '/metrics', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + }) +}) diff --git a/src/tests/modules/permissions.test.ts b/src/tests/modules/permissions.test.ts index 2f4cc45..b1824bb 100644 --- a/src/tests/modules/permissions.test.ts +++ b/src/tests/modules/permissions.test.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from 'fastify' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { SEED_PERMISSIONS } from '@/db/seed.js' import { createTestApp, registerAdminAndLogin, registerAndLogin, registerSuperAdminAndLogin, resetDb } from '@/tests/fixtures/index.js' interface Permission { @@ -57,10 +58,10 @@ describe('permissions API', () => { expect(res.statusCode).toBe(200) }) - it('returns all 15 seeded permissions', async () => { + it('returns every seeded permission', async () => { const res = await app.inject({ method: 'GET', url: '/api/v1/permissions', headers: auth(adminToken) }) const { data } = res.json<{ data: Permission[] }>() - expect(data).toHaveLength(15) + expect(data).toHaveLength(SEED_PERMISSIONS.length) }) it('returns permissions with correct shape', async () => { @@ -79,23 +80,7 @@ describe('permissions API', () => { const { data } = res.json<{ data: Permission[] }>() const keys = data.map(p => `${p.resource}:${p.action}:${p.scope}`) - const expected = [ - 'user:create:any', - 'user:read:any', - 'user:update:any', - 'user:delete:any', - 'user:read:own', - 'user:update:own', - 'role:create:any', - 'role:read:any', - 'role:update:any', - 'role:delete:any', - 'permission:create:any', - 'permission:read:any', - 'permission:update:any', - 'permission:delete:any', - 'audit-log:read:any', - ] + const expected = SEED_PERMISSIONS.map(p => `${p.resource}:${p.action}:${p.scope}`) for (const key of expected) { expect(keys).toContain(key) diff --git a/src/tests/modules/products.test.ts b/src/tests/modules/products.test.ts index 60b3700..d41f1dc 100644 --- a/src/tests/modules/products.test.ts +++ b/src/tests/modules/products.test.ts @@ -1,6 +1,9 @@ import type { FastifyInstance } from 'fastify' +import { and, eq } from 'drizzle-orm' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import { createTestApp, registerAndLogin, resetDb } from '@/tests/fixtures/index.js' +import { PERMISSIONS } from '@/common/constants/index.js' +import { permissions, rolePermissions, roles } from '@/db/schema/index.js' +import { createTestApp, registerAdminAndLogin, registerAndLogin, resetDb } from '@/tests/fixtures/index.js' describe('products API', () => { let app: FastifyInstance @@ -12,7 +15,7 @@ describe('products API', () => { beforeEach(async () => { await resetDb(app) - token = await registerAndLogin(app) + token = await registerAdminAndLogin(app) }) afterAll(async () => { @@ -86,4 +89,109 @@ describe('products API', () => { }) expect(del.statusCode).toBe(204) }) + + describe('regular user role :RBAC', () => { + let userToken: string + + beforeEach(async () => { + userToken = await registerAndLogin(app, { email: 'regular@example.com', password: 'Password123' }) + }) + + async function revokeProductReadPermission() { + const [resource, action, scope] = PERMISSIONS.PRODUCT.READ_ANY.split(':') + const [role] = await app.db.select({ id: roles.id }).from(roles).where(eq(roles.name, 'user')).limit(1) + const [permission] = await app.db + .select({ id: permissions.id }) + .from(permissions) + .where(and( + eq(permissions.resource, resource), + eq(permissions.action, action), + eq(permissions.scope, scope), + )) + .limit(1) + if (!role || !permission) + throw new Error('seeded user role or product read permission not found') + + await app.db + .delete(rolePermissions) + .where(and( + eq(rolePermissions.roleId, role.id), + eq(rolePermissions.permissionId, permission.id), + )) + } + + it('returns 403 for GET /api/v1/products without product read permission', async () => { + await revokeProductReadPermission() + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/products', + headers: { authorization: `Bearer ${userToken}` }, + }) + expect(res.statusCode).toBe(403) + }) + + it('returns 403 for GET /api/v1/products/:id without product read permission', async () => { + const create = await app.inject({ + method: 'POST', + url: '/api/v1/products', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Gadget', price: 19.99, stock: 50 }, + }) + const { data: created } = create.json<{ data: { id: string } }>() + await revokeProductReadPermission() + + const res = await app.inject({ + method: 'GET', + url: `/api/v1/products/${created.id}`, + headers: { authorization: `Bearer ${userToken}` }, + }) + expect(res.statusCode).toBe(403) + }) + + it('returns 403 for POST /api/v1/products', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/products', + headers: { authorization: `Bearer ${userToken}` }, + payload: { name: 'Widget', price: 9.99, stock: 100 }, + }) + expect(res.statusCode).toBe(403) + }) + + it('returns 403 for PATCH /api/v1/products/:id', async () => { + const create = await app.inject({ + method: 'POST', + url: '/api/v1/products', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Gadget', price: 19.99, stock: 50 }, + }) + const { data: created } = create.json<{ data: { id: string } }>() + + const res = await app.inject({ + method: 'PATCH', + url: `/api/v1/products/${created.id}`, + headers: { authorization: `Bearer ${userToken}` }, + payload: { price: 24.99 }, + }) + expect(res.statusCode).toBe(403) + }) + + it('returns 403 for DELETE /api/v1/products/:id', async () => { + const create = await app.inject({ + method: 'POST', + url: '/api/v1/products', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Doohickey', price: 4.99, stock: 10 }, + }) + const { data: created } = create.json<{ data: { id: string } }>() + + const res = await app.inject({ + method: 'DELETE', + url: `/api/v1/products/${created.id}`, + headers: { authorization: `Bearer ${userToken}` }, + }) + expect(res.statusCode).toBe(403) + }) + }) }) diff --git a/src/tests/modules/profile.test.ts b/src/tests/modules/profile.test.ts index 0e81bba..117afb2 100644 --- a/src/tests/modules/profile.test.ts +++ b/src/tests/modules/profile.test.ts @@ -47,7 +47,7 @@ describe('profile API', () => { }) it('returns the user that matches the JWT, not another user', async () => { - const otherToken = await registerAndLogin(app, { email: 'other@example.com', password: 'password123' }) + const otherToken = await registerAndLogin(app, { email: 'other@example.com', password: 'Password123' }) const res = await app.inject({ method: 'GET', url: '/api/v1/profile', diff --git a/src/tests/modules/proxy.test.ts b/src/tests/modules/proxy.test.ts new file mode 100644 index 0000000..b9b7a79 --- /dev/null +++ b/src/tests/modules/proxy.test.ts @@ -0,0 +1,69 @@ +import type { FastifyInstance } from 'fastify' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { auditLogs } from '@/db/schema/index.js' +import { createTestApp, eventually, resetDb } from '@/tests/fixtures/index.js' + +describe('trusted proxy handling', () => { + let app: FastifyInstance + let originalCwd: string + let originalTrustProxy: string | undefined + let tempDir: string + + beforeAll(async () => { + originalCwd = process.cwd() + originalTrustProxy = process.env.TRUST_PROXY + delete process.env.TRUST_PROXY + tempDir = mkdtempSync(join(tmpdir(), 'fastify-api-proxy-')) + writeFileSync(join(tempDir, '.env'), 'TRUST_PROXY=127.0.0.1 # local reverse proxy\n') + process.chdir(tempDir) + app = await createTestApp() + }) + + beforeEach(async () => { + await resetDb(app) + }) + + afterAll(async () => { + await app.close() + process.chdir(originalCwd) + rmSync(tempDir, { recursive: true, force: true }) + if (originalTrustProxy === undefined) + delete process.env.TRUST_PROXY + else + process.env.TRUST_PROXY = originalTrustProxy + }) + + it('uses TRUST_PROXY from .env for request.ip', async () => { + await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { email: 'proxy@example.com', password: 'Password123' }, + }) + + await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + headers: { 'x-forwarded-for': '203.0.113.7' }, + payload: { email: 'proxy@example.com', password: 'Password123' }, + }) + + const log = await eventually( + async () => { + const [row] = await app.db + .select({ metadata: auditLogs.metadata }) + .from(auditLogs) + .where(eq(auditLogs.action, 'auth.logged_in')) + .limit(1) + return row + }, + row => row !== undefined, + ) + + expect(log.metadata).toMatchObject({ ip: '203.0.113.7' }) + }) +}) diff --git a/src/tests/modules/users.test.ts b/src/tests/modules/users.test.ts index eba488c..12e9d2c 100644 --- a/src/tests/modules/users.test.ts +++ b/src/tests/modules/users.test.ts @@ -24,7 +24,7 @@ describe('users API', () => { // ── helpers ──────────────────────────────────────────────────────────────── - async function createUser(email = 'user@example.com', password = 'password123') { + async function createUser(email = 'user@example.com', password = 'Password123') { const res = await app.inject({ method: 'POST', url: '/api/v1/users', @@ -152,7 +152,7 @@ describe('users API', () => { }) it('returns assigned roles for a user registered via auth', async () => { - const reg = await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload: { email: 'withrole@example.com', password: 'password123' } }) + const reg = await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload: { email: 'withrole@example.com', password: 'Password123' } }) const userId = reg.json<{ data: { id: string } }>().data.id const res = await app.inject({ method: 'GET', @@ -206,7 +206,7 @@ describe('users API', () => { method: 'POST', url: '/api/v1/users', headers: { authorization: `Bearer ${token}` }, - payload: { email: 'new@example.com', password: 'password123' }, + payload: { email: 'new@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(201) const { data } = res.json<{ data: User }>() @@ -228,7 +228,7 @@ describe('users API', () => { method: 'POST', url: '/api/v1/users', headers: { authorization: `Bearer ${token}` }, - payload: { email: 'new@example.com', password: 'password123' }, + payload: { email: 'new@example.com', password: 'Password123' }, }) expect(res.json()).not.toHaveProperty('data.passwordHash') }) @@ -239,7 +239,7 @@ describe('users API', () => { method: 'POST', url: '/api/v1/users', headers: { authorization: `Bearer ${token}` }, - payload: { email: 'dup@example.com', password: 'password123' }, + payload: { email: 'dup@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(409) }) @@ -255,7 +255,7 @@ describe('users API', () => { method: 'POST', url: '/api/v1/users', headers: { authorization: `Bearer ${token}` }, - payload: { email: 'reuse@example.com', password: 'password123' }, + payload: { email: 'reuse@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(201) }) @@ -265,7 +265,7 @@ describe('users API', () => { method: 'POST', url: '/api/v1/users', headers: { authorization: `Bearer ${token}` }, - payload: { email: 'bad-email', password: 'password123' }, + payload: { email: 'bad-email', password: 'Password123' }, }) expect(res.statusCode).toBe(400) }) @@ -410,9 +410,9 @@ describe('users API', () => { describe('authorization', () => { // Register a normal (non-admin) user and return { id, token }. async function registerNormal(email: string) { - const reg = await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload: { email, password: 'password123' } }) + const reg = await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload: { email, password: 'Password123' } }) const id = reg.json<{ data: { id: string } }>().data.id - const loginRes = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email, password: 'password123' } }) + const loginRes = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email, password: 'Password123' } }) const userToken = extractTokenFromCookie(loginRes.headers['set-cookie']) return { id, token: userToken } } @@ -434,7 +434,7 @@ describe('users API', () => { it('forbids a non-admin from creating users', async () => { const { token: t } = await registerNormal('n3@example.com') - const res = await app.inject({ method: 'POST', url: '/api/v1/users', headers: auth(t), payload: { email: 'x@example.com', password: 'password123' } }) + const res = await app.inject({ method: 'POST', url: '/api/v1/users', headers: auth(t), payload: { email: 'x@example.com', password: 'Password123' } }) expect(res.statusCode).toBe(403) }) @@ -550,7 +550,7 @@ describe('users API', () => { // attempt mass-assignment of role through the self-update path await app.inject({ method: 'PATCH', url: `/api/v1/users/${id}`, headers: auth(t), payload: { email: 'escalate@example.com', role: 'admin' } }) // re-login so the token reflects any (un)changed role, then probe an admin-only route - const reloginRes = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email: 'escalate@example.com', password: 'password123' } }) + const reloginRes = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email: 'escalate@example.com', password: 'Password123' } }) const fresh = extractTokenFromCookie(reloginRes.headers['set-cookie']) const res = await app.inject({ method: 'GET', url: '/api/v1/users', headers: auth(fresh) }) expect(res.statusCode).toBe(403)