From df5b36feaff2661aa82a9c4e0a7adf4db7dd2907 Mon Sep 17 00:00:00 2001 From: feyishola Date: Fri, 28 Aug 2026 19:46:15 +0100 Subject: [PATCH] feat(users): replace mock user helpers with the Prisma-backed Profile API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserController carried five private helpers that never touched a database: findUserById returned a hard-coded test@example.com record, updateUserProfile echoed the request body back, validatePassword returned false unconditionally, updateUserPassword threw "Not implemented", and updateUserWallet faked a persisted user. Worse, the fixture described a schema that does not exist — firstName/lastName/bio/avatar are not columns on User — so /users/me and /users/:id served a shape no query could produce, and the OpenAPI User / PublicUser / UpdateUserInput components documented that shape as real. The persistence was already there, built by earlier issues and explicitly deferred to this one by ADRs 0002 and 0003: LearnerProfile with its visibility model and serializers, OnboardingProgress and ConsentRecord, and the audited-mutation helper. - GET /users/me returns an owner aggregate: account identity, profile, profile completion, onboarding state and current consent per purpose, in one read. The account field list is closed (field-by-field copy, not a spread), so `password` and any column added to User later must be opted in to be disclosed. Completion and outstanding onboarding steps are computed on read, so they cannot disagree with the rows they summarise. - PATCH /users/me is bounded by one strict Zod object, shared with PATCH /users/me/profile so the two routes cannot diverge on what an owner may write. Account fields (status, isVerified, role, email, password, walletAddress) and internal columns (id, userId, archived*) are absent from the allow-list and therefore 400, not silently ignored. The write goes through auditedMutation and records which fields changed, never their values. - GET /users/:id layers two consent gates on the existing visibility threshold: only an ACTIVE account is disclosed, and an explicit data_sharing withdrawal overrides a stale `visibility: public`. Absence of a data_sharing record does not block disclosure — it is optional consent, and setting visibility above private is itself a deliberate choice. Every refusal returns the identical redacted stub, so a caller cannot tell a private profile from a withdrawn consent from a deactivated account. Reads via findFirst so the archive-exclusion extension applies. - PATCH /users/password verifies, rehashes, and revokes every session and refresh-token family in the same transaction as the password write. Doing the revocation separately would leave a window where the password has changed and the attacker's stolen session is still alive. - PATCH /users/wallet persists the learner's Stellar public key, is idempotent, and returns 409 for an address claimed elsewhere — checked up front for a clear error and again by catching P2002, since two concurrent claims both pass the up-front read. Also removes the now-dead mock-era request types, replaces the OpenAPI components, drops the "Preview / not-yet-implemented" banners, and unmounts (with a do-not-rewire comment) the deprecated validateProfileUpdate middleware. Verification evidence: tests/mock-user-scan.test.ts (static scan for mock literals, removed helpers and direct Prisma access from the controller), docs/evidence/profile-api-curl.txt (21 redacted requests against a running server on a disposable test database), and tests/integration/profile-api.test.ts (51 tests through the real app, middleware, JWT and database). Rationale and the debatable consent call are written up in docs/decisions/0004-profile-api.md. Co-Authored-By: Claude Opus 5 (1M context) --- ROADMAP.md | 4 +- docs/API.md | 132 +++- docs/decisions/0004-profile-api.md | 208 ++++++ docs/evidence/profile-api-curl.txt | 473 +++++++++++++ src/config/swagger.ts | 4 - src/controllers/profile.controller.ts | 38 +- src/controllers/user.controller.ts | 840 ++++++++++++------------ src/docs/schemas.ts | 258 +++++++- src/middleware/validation.middleware.ts | 8 +- src/routes/v1/users.routes.ts | 77 ++- src/schemas/index.ts | 1 + src/schemas/profile.schema.ts | 75 +++ src/services/profile-serializer.ts | 187 +++++- src/services/profile.service.ts | 161 ++++- src/services/user-account.service.ts | 169 +++++ src/types/profile.types.ts | 50 ++ src/types/user.types.ts | 42 +- src/utils/audit-context.ts | 23 + tests/contract/openapi.test.ts | 121 ++++ tests/integration/profile-api.test.ts | 716 ++++++++++++++++++++ tests/mock-user-scan.test.ts | 99 +++ tests/profile-serializer.test.ts | 250 ++++++- tests/profile.controller.test.ts | 45 +- tests/profile.service.test.ts | 331 +++++++++- tests/user-account.service.test.ts | 304 +++++++++ tests/user.controller.test.ts | 792 ++++++++++++++-------- 26 files changed, 4538 insertions(+), 870 deletions(-) create mode 100644 docs/decisions/0004-profile-api.md create mode 100644 docs/evidence/profile-api-curl.txt create mode 100644 src/schemas/profile.schema.ts create mode 100644 src/services/user-account.service.ts create mode 100644 src/utils/audit-context.ts create mode 100644 tests/contract/openapi.test.ts create mode 100644 tests/integration/profile-api.test.ts create mode 100644 tests/mock-user-scan.test.ts create mode 100644 tests/user-account.service.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index 840d0eec..0530de70 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,7 +16,7 @@ Status baseline: 17 July 2026. The present schema supports users, flat modules, - [x] Express/TypeScript service, versioned router, Prisma/PostgreSQL setup, security headers, logging, and error middleware exist. - [~] JWT registration/login exists with rotating refresh sessions, reuse detection, and logout; verification, recovery, session management, and wallet provisioning flows are partly present. -- [~] User routes exist, but user persistence helpers currently return mock users. +- [x] User routes are Prisma-backed: account/profile aggregate read, validated and audited profile update, consent-aware public profile read, real password change with session revocation, and wallet address persistence. No mock user helpers remain. - [~] Flat module list/detail/start/complete routes exist without Course, LearningPath, Lesson, Quiz, Question, Attempt, Enrollment, or detailed Progress models. - [~] Reward, credential, referral, offline-sync, notification, webhook, employer, Stellar, and Soroban services/routes exist at varying levels of completeness. - [~] 155 tests currently pass, but seven suites fail to load in the local environment. @@ -65,7 +65,7 @@ Status baseline: 17 July 2026. The present schema supports users, flat modules, - [ ] Extend `User` with account status and verification fields; separate private identity from public profile data. - [ ] Add `LearnerProfile` for display name, bio, avatar, country, timezone, languages, skill level, interests, goals, and profile visibility. - [ ] Add onboarding state/version, consent records, terms/privacy versions, analytics consent, and data-sharing consent. -- [ ] Add account/profile read and update endpoints using Prisma; remove all mock user helpers. +- [x] Add account/profile read and update endpoints using Prisma; remove all mock user helpers — owner account/profile aggregate on `GET /users/me` (identity, profile, completion, onboarding, consents), allow-listed audited update on `PATCH /users/me`, consent-aware public read on `GET /users/{id}`, real password change with in-transaction session revocation, and wallet address persistence with conflict handling. See [`docs/decisions/0004-profile-api.md`](docs/decisions/0004-profile-api.md). - [ ] Add preferences endpoints for locale, timezone, low-data mode, accessibility, content, notifications, and privacy. - [ ] Add avatar signed-upload/finalization/delete flow with validation and image processing. - [ ] Add data export, account deactivation, deletion request, retention, and irreversible deletion workflows. diff --git a/docs/API.md b/docs/API.md index f2a63d11..11d3a248 100644 --- a/docs/API.md +++ b/docs/API.md @@ -301,65 +301,152 @@ Verifies the code from `otp/request`. Codes are single-use, expire after 5 minut ### `GET /users/me` 🔒 -Returns the authenticated user's full profile. +Owner-only aggregate: account identity, learner profile, profile completion, +onboarding state, and the current consent record per purpose. One read, so a +client does not have to fan out across four endpoints to render a settings or +"finish setting up" screen. ```json { - "id": "uuid", "email": "...", "username": "...", - "firstName": null, "lastName": null, - "bio": null, "avatar": null, "walletAddress": null, - "isActive": true, "createdAt": "...", "updatedAt": "..." + "data": { + "account": { + "id": "uuid", "email": "...", "username": "...", + "role": "LEARNER", "status": "ACTIVE", + "isVerified": true, "phoneVerifiedAt": null, + "walletAddress": null, + "createdAt": "...", "updatedAt": "...", "lastLoginAt": null + }, + "profile": { + "id": "uuid", "userId": "uuid", + "displayName": null, "bio": null, "avatarUrl": null, + "country": null, "timezone": null, + "languages": [], "level": "beginner", + "interests": [], "goals": [], + "visibility": "private", + "createdAt": "...", "updatedAt": "..." + }, + "completion": { "percent": 0, "missingFields": ["displayName", "bio", "..."] }, + "onboarding": { + "version": "v1", "status": "in_progress", "currentStep": "profile_basics", + "completedSteps": ["profile_basics"], "requiredStepsRemaining": ["consent"], + "startedAt": "...", "completedAt": null + }, + "consents": [ + { "purpose": "terms_of_service", "status": "granted", "required": true, + "policyVersion": "2026-01", "grantedAt": "...", "withdrawnAt": null } + ], + "requiredConsentsGranted": false + } } ``` +The profile row is created on first access, so a brand-new learner gets a +deterministic 0%-complete profile rather than a `null`. `onboarding` is `null` +until the learner starts onboarding. The password hash is never included. + +**Responses:** `200` · `401` no/invalid token · `404` account unknown or tombstoned + --- ### `PATCH /users/me` 🔒 -Update profile fields. All fields optional. +Partial learner-profile update. At least one field is required, and the body is +**closed**: any property outside the table below — including account fields such +as `status`, `isVerified`, `role`, `email`, `password` or `walletAddress` — is a +`400`, not a silently ignored key. Every accepted change is written together with +its audit event in one transaction. -**Request body** (any subset of): +**Request body** (any non-empty subset of): | Field | Type | Constraints | |-------|------|-------------| -| `username` | string | 3–30 chars, alphanumeric + underscore | -| `firstName` | string | max 50 | -| `lastName` | string | max 50 | -| `bio` | string | max 500 | -| `avatar` | string (URL) | | +| `displayName` | string \| null | 1–80 chars | +| `bio` | string \| null | max 1000 | +| `avatarUrl` | string (URL) \| null | normally set by the avatar upload flow | +| `country` | string \| null | 2–60 chars | +| `timezone` | string \| null | | +| `languages` | string[] | max 20 | +| `level` | enum | `beginner` \| `intermediate` \| `advanced` \| `expert` | +| `interests` | string[] | max 50 | +| `goals` | string[] | max 20 | +| `visibility` | enum | `private` \| `employer` \| `public` | + +**Response:** `200` `{ "message": "...", "data": { … } }` — the same aggregate as +`GET /users/me`, recomputed from the persisted row. -**Response:** `200` full user object (same as `GET /users/me`) +**Responses:** `200` · `400` validation failed · `401` · `404` + +`PATCH /users/me/profile` accepts the same body and returns the profile alone +(without the account aggregate). --- ### `GET /users/:id` -Public — no auth needed. Returns a reduced public profile. +Public — no auth needed. Returns the learner's public profile subset, or the +redacted stub `{ "id": "...", "visible": false }`. ```json -{ "id": "...", "username": "...", "firstName": null, "lastName": null, "avatar": null, "role": "learner", "createdAt": "..." } +{ + "data": { + "id": "uuid", "displayName": "Grace H.", "bio": "Learning Soroban", + "avatarUrl": null, "country": "NG", "level": "intermediate", + "interests": ["soroban"], "visible": true + } +} ``` +Disclosure requires **all** of: + +1. `profile.visibility === "public"`, +2. the account status is `ACTIVE`, and +3. `data_sharing` consent has not been withdrawn. + +Any refusal returns the same stub, so a caller cannot tell a private profile +from a withdrawn consent from a deactivated account. Archived profiles read as +`404`. Private account data (email, username, wallet address, status, +verification, password) never appears here — not even for the owner, who gets +the same public view as anyone else on this route and uses +`GET /users/me` or `GET /users/:id/profile` for their own full record. + +**Responses:** `200` · `400` malformed id · `404` unknown learner + --- ### `PATCH /users/password` 🔒 -> ⚠️ **Preview** — the service implementation is stubbed. Will return `500` until completed. +Verifies the current password, stores a new bcrypt hash, and revokes every +session and refresh-token family for the account **in the same transaction** — +so the caller must sign in again, and so does anyone holding a stolen session. +The change is audited; neither password reaches the audit trail. **Request body:** `{ "currentPassword": "...", "newPassword": "..." }` Password rules: min 8 chars, must contain uppercase, lowercase, digit, and special character (`@$!%*?&`). Must differ from current. +**Response:** `200` `{ "message": "...", "revokedSessionCount": 2 }` + +**Responses:** `200` · `400` validation failed · `401` no token, or wrong current +password (`code: STEP_UP_FAILED`) · `404` account unknown or tombstoned + --- ### `PATCH /users/wallet` 🔒 -> ⚠️ **Preview** — the wallet update may not persist to the database until the service layer is completed. +Sets the learner's Stellar **public** key on their account. Never accepts or +returns a secret seed. The change is audited. **Request body:** `{ "walletAddress": "G..." }` Address must match `^G[A-Z0-9]{55}$`. +Re-sending the address already on file is a no-op (`200`, `"Wallet address +unchanged"`, no second audit event). Addresses are unique across accounts. + +**Responses:** `200` · `400` invalid address · `401` · `404` account unknown or +tombstoned · `409` address already claimed by another account +(`code: WALLET_ADDRESS_TAKEN`) + --- ## Modules — `/modules` @@ -806,14 +893,9 @@ Record a candidate outreach attempt. Requires **pro** or **enterprise** plan — ## Unimplemented / Stubbed Routes -The following routes are wired but not fully implemented: - -| Route | Status | -|-------|--------| -| `PATCH /users/password` | Service method throws "Not implemented" — returns 500 | -| `PATCH /users/wallet` | Service method uses mock data — changes do not persist | - -These are marked as **Preview** in the OpenAPI spec (`/api-docs`). +None on `/users`. `PATCH /users/password` and `PATCH /users/wallet` were the last +two stubs here; both are Prisma-backed and audited as of the Profile API work +(see [`docs/decisions/0004-profile-api.md`](decisions/0004-profile-api.md)). --- diff --git a/docs/decisions/0004-profile-api.md b/docs/decisions/0004-profile-api.md new file mode 100644 index 00000000..34e35069 --- /dev/null +++ b/docs/decisions/0004-profile-api.md @@ -0,0 +1,208 @@ +# 0004 — Replace Mock User Helpers with the Profile API + +- **Status:** Accepted +- **Date:** 2026-08-28 +- **Roadmap item:** Phase 1 / "Replace Mock User Helpers with Profile API" + +## Context + +`UserController` carried five private helpers that were never wired to a +database: + +| Helper | What it did | +|---|---| +| `findUserById` | returned a hard-coded `test@example.com` / `testuser` record | +| `updateUserProfile` | echoed the request body back as a fake persisted user | +| `validatePassword` | returned `false`, unconditionally | +| `updateUserPassword` | `throw new Error('Not implemented')` | +| `updateUserWallet` | returned a fake user with the requested address | + +Every `/users` route was therefore either a fixture or a 500. Worse, the fixture +described a schema that does not exist: `firstName`, `lastName`, `bio` and +`avatar` are not columns on `User`. `GET /users/me` and `GET /users/:id` served a +shape no query could ever produce, and the OpenAPI `User` / `PublicUser` / +`UpdateUserInput` components documented that shape as if it were real. + +The persistence those routes needed had meanwhile been built by three earlier +issues: `LearnerProfile` with its visibility model and serializers +([0002](0002-learner-profile-visibility.md)), `OnboardingProgress` and +`ConsentRecord` ([0003](0003-onboarding-consent-persistence.md)), and the +audited-mutation helper (`src/audit/`). Both earlier ADRs explicitly deferred +wiring `UserController` to this issue. + +## Decision + +Delete the helpers and back every `/users` route with Prisma, through two +services and one schema module. + +### Where the code lives + +| Concern | Module | +|---|---| +| Owner-updatable field allow-list, password and wallet body schemas | `src/schemas/profile.schema.ts` | +| Profile reads/writes, the owner aggregate, the disclosure gate | `src/services/profile.service.ts` | +| Password change, wallet address | `src/services/user-account.service.ts` | +| Response shaping | `src/services/profile-serializer.ts` | +| Audit actor/context from a request | `src/utils/audit-context.ts` | + +`UserController` holds HTTP concerns only: auth check, parse, map a result kind +to a status code. It does not import the Prisma client — a rule the mock scan +enforces (`tests/mock-user-scan.test.ts`), so the persistence and its audit +cannot drift apart again by someone adding "just one query" to the controller. + +### `GET /users/me` — the owner aggregate + +Returns account identity, profile, profile completion, onboarding state, and the +current consent record per purpose, in one read. Four round trips to render one +settings screen is the shape a client would otherwise be pushed into, and three +of the four are already needed to answer "what should this learner do next". + +Two properties are deliberate: + +- **The account field list is closed.** `toAccountSummary` copies eleven named + fields rather than spreading the row, so `password` — and any column added to + `User` later — has to be opted in to be disclosed. The `select` in the service + is closed for the same reason; the serializer is the second line, because a + `select` is easy to widen by accident and a spread would then carry the + widening straight into the response. +- **Completion and outstanding onboarding steps are computed on read.** + Consistent with `computeProfileCompletion` from 0002: a stored percentage can + disagree with the row it summarises, a computed one cannot. + +A tombstoned (`status = DELETED`) or missing account is a `404`, and no profile +row is created for it. + +### `PATCH /users/me` — validated, audited, allow-listed + +One Zod object (`updateProfileSchema`) is the allow-list, shared with +`PATCH /users/me/profile` so the two routes cannot diverge on what an owner may +write. `.strict()` is what enforces it: an unrecognised key is a `400` rather +than a silently dropped field. The fields deliberately *absent* are the point — +`id`, `userId`, the `archived*` columns, and every account field (`status`, +`isVerified`, `phoneVerifiedAt`, `role`, `email`, `password`, `walletAddress`). + +The write goes through `auditedMutation`, so the profile change and its +`learner_profile.updated` event commit together. The event records **which** +fields changed, never their values: a bio in an append-only trail is PII that +cannot be scrubbed afterwards. + +### `GET /users/:id` — consent-aware public read + +The visibility threshold from 0002 still applies, with two further gates that can +only ever *narrow* disclosure (`isDisclosureAllowed`): + +1. **Account status.** Only an `ACTIVE` account is disclosed, so deactivating + drops third-party visibility immediately without the learner also having to + flip `visibility` on the way out. +2. **Withdrawn `data_sharing` consent.** An explicit withdrawal overrides the + `visibility` setting, so revoking consent takes effect even against a stale + `visibility: public`. + +**The absence of a `data_sharing` record does not block disclosure.** That +consent is optional (`REQUIRED_CONSENT_PURPOSES`), and setting `visibility` above +`private` is itself a deliberate disclosure choice; treating "never asked" as a +refusal would make the visibility control inoperative for every learner who +skipped an optional prompt. This is the one genuinely debatable call here, and it +is why the rule is a single named function with its own tests rather than an +`if` in a controller. + +Every refusal returns the identical stub `{ id, visible: false }`. A +distinguishable refusal would leak the state it is refusing to disclose. + +The read uses `findFirst`, not `findUnique`, so the archive-exclusion client +extension applies and an archived profile is invisible rather than merely +redacted (`src/audit/archive.ts` exempts `findUnique` on purpose). + +The owner gets the same public view as anyone else on this route. Their own full +record is at `GET /users/me` and `GET /users/:id/profile`, so there is exactly +one route whose output does not depend on who is asking — easier to reason about +than a route that sometimes returns private data. + +### `PATCH /users/password` — real, and session-revoking + +bcrypt verify, bcrypt hash, then **every session and refresh-token family for the +account is revoked in the same transaction as the password write**. Doing the +revocation afterwards, as a separate call, would leave a window where the +password has changed and the attacker's stolen session is still alive — +precisely when the victim believes they have locked them out. + +A wrong current password is `401` (`code: STEP_UP_FAILED`), not `400`: it is a +failed re-authentication, and the body that carried it was well-formed. This +matches the step-up behaviour in `AccountController`. + +### `PATCH /users/wallet` — real, unique, idempotent + +Persists the learner's Stellar **public** key to `User.walletAddress`. Re-sending +the address on file is a no-op with no second audit event. An address claimed by +another account is a `409`; the check is done up front for a clear error *and* +again by catching Prisma's `P2002`, because two accounts claiming the same +address concurrently both pass the up-front read. + +### Removed and deprecated + +- The five mock helpers, and `PublicUserInfo` / `UpdateUserData` / + `ChangePasswordData` / `UpdateWalletData` in `types/user.types.ts` — their + replacements are inferred from the Zod schemas that actually validate the + requests, so a shape and its validation can no longer drift. +- The OpenAPI `PublicUser` and `UpdateUserInput` components, replaced by + `AccountSummary`, `LearnerProfile`, `ProfileCompletion`, `OnboardingSummary`, + `ConsentSummary`, `OwnerAccountProfile`, `PublicProfile` and + `UpdateProfileInput`. The "Preview / not-yet-implemented" banner is gone from + `src/config/swagger.ts` and `docs/API.md`. +- `validateProfileUpdate` in `src/middleware/validation.middleware.ts` is + **deprecated and unmounted**. It validates the mock-era + `firstName`/`lastName`/`bio`/`avatar` body. It is left exported so its existing + test suite keeps passing, with a comment saying not to wire it back up; the + mock scan asserts no route imports it. + +`User` in `types/user.types.ts` survives because `LoginResponse` refers to it, +with a comment marking the four non-persisted fields. + +### Response envelope + +`{ data }` on success, `{ error, details }` on validation failure — matching the +profile, preference, onboarding, consent and account controllers. Note this is +**not** the `createSuccessEnvelope` shape from `src/schemas/api.schema.ts`; +migrating the whole service onto that envelope is a separate Phase 0 item, and +half-migrating one route family would leave clients with two shapes on `/users`. + +## Out of scope + +- Wiring `LearnerProfile` into `employer.controller.ts` candidate search. 0002 + listed it under this issue, but that controller has no mock user helpers — it + is a search-surface change, not a mock removal. +- Rejecting a wallet-address change when a managed custodial `Wallet` row already + exists for the learner. `User.walletAddress` and `Wallet.publicKey` can + currently disagree; reconciling them belongs with the wallet provisioning work, + not here. +- Moving `/users` onto the `createSuccessEnvelope` / pagination envelope. + +## Verification evidence + +- **Mock scan:** `tests/mock-user-scan.test.ts` — 24 assertions over the seven + source files, covering mock literals, the sentinel values the old helpers + returned (`test@example.com`, `testuser`, `GABC123456789…`), not-implemented + stubs, each removed helper by name, direct Prisma access from the controller, + the deprecated middleware, and `password: true` in any profile select. +- **Redacted curl transcript:** `docs/evidence/profile-api-curl.txt` — 20 + requests against a running server on a dedicated test database, covering auth, + the aggregate read, each rejected forbidden field, the public/private/redacted + reads, step-up failure, and wallet validation/idempotence. Bearer tokens and + passwords are redacted in the echoed commands. +- **Profile integration tests:** `tests/integration/profile-api.test.ts` — 51 + tests through the real Express app, real middleware, real JWT verification and + a real database, asserting persisted rows and audit rows rather than mock + calls. Skipped (not failed) when no test database is reachable. +- **Unit tests:** `tests/user.controller.test.ts` (55), + `tests/profile.service.test.ts` (27), `tests/user-account.service.test.ts` (15), + `tests/profile-serializer.test.ts` (33), `tests/profile.controller.test.ts` (20). +- **Audit tests:** transaction ordering (`['mutate', 'audit']`), actor + attribution, request-id propagation, field-names-not-values metadata, rollback + on a failed audit write, and refusal of an unattributable USER actor — in both + the service unit tests and the integration suite. +- **OpenAPI:** `tests/contract/openapi.test.ts` — the document builds, every + `$ref` resolves, all seven user operations are documented, the removed + components are gone and the new ones present, the public-profile schema + mentions no private field, security is `[]` on the public read and `bearerAuth` + on the owner reads, `UpdateProfileInput` is `additionalProperties: false` and + lists no account field, and the wallet route documents its `409`. diff --git a/docs/evidence/profile-api-curl.txt b/docs/evidence/profile-api-curl.txt new file mode 100644 index 00000000..8fc9eff4 --- /dev/null +++ b/docs/evidence/profile-api-curl.txt @@ -0,0 +1,473 @@ +Profile API — redacted curl transcript +Issue: Phase 1 / "Replace Mock User Helpers with Profile API" +See docs/decisions/0004-profile-api.md + +Captured 2026-08-28 against a locally running server (NODE_ENV=test) on a +dedicated, disposable Postgres test database seeded with three learners: + 11111111-… owner: empty profile, onboarding started, ToS granted + 22222222-… visibility: public, fully filled profile + 33333333-… visibility: private, holds wallet address GBBB… + +Bearer tokens and passwords are redacted in the echoed commands; the real +values were used. Responses are verbatim, only pretty-printed. + + +====================================================================== +1. Authentication +====================================================================== + +### GET /users/me with no token → 401 +$ curl $B/users/me +HTTP 401 +{ + "message": "Authorization token required" +} + +### GET /users/me with a bad token → 401 +$ curl -H 'Authorization: Bearer not-a-jwt' $B/users/me +HTTP 401 +{ + "message": "Invalid token" +} + +====================================================================== +2. Owner account/profile aggregate — real persisted rows, no fixture +====================================================================== + +### GET /users/me → 200 +$ curl -H 'Authorization: Bearer ' $B/users/me +HTTP 200 +{ + "data": { + "account": { + "id": "11111111-1111-4111-8111-111111111111", + "email": "ada@example.com", + "username": "ada_learner", + "role": "LEARNER", + "status": "ACTIVE", + "isVerified": true, + "phoneVerifiedAt": null, + "walletAddress": null, + "createdAt": "2026-08-28T18:31:53.293Z", + "updatedAt": "2026-08-28T18:31:53.293Z", + "lastLoginAt": null + }, + "profile": { + "id": "25bfdbe5-d872-4c01-bc28-73b00bd281d9", + "userId": "11111111-1111-4111-8111-111111111111", + "displayName": null, + "bio": null, + "avatarUrl": null, + "country": null, + "timezone": null, + "languages": [], + "level": "beginner", + "interests": [], + "goals": [], + "visibility": "private", + "createdAt": "2026-08-28T18:32:07.014Z", + "updatedAt": "2026-08-28T18:32:07.014Z" + }, + "completion": { + "percent": 0, + "missingFields": [ + "displayName", + "bio", + "avatarUrl", + "country", + "timezone", + "languages", + "interests", + "goals" + ] + }, + "onboarding": { + "version": "v1", + "status": "in_progress", + "currentStep": "profile_basics", + "completedSteps": [ + "profile_basics" + ], + "requiredStepsRemaining": [ + "consent" + ], + "startedAt": "2026-08-28T18:31:54.799Z", + "completedAt": null + }, + "consents": [ + { + "purpose": "terms_of_service", + "status": "granted", + "required": true, + "policyVersion": "2026-01", + "grantedAt": "2026-08-28T18:31:54.806Z", + "withdrawnAt": null + } + ], + "requiredConsentsGranted": false + } +} + +====================================================================== +3. Owners update only allow-listed fields +====================================================================== + +### PATCH /users/me {role, isVerified} → 400 (account fields refused) +$ curl -X PATCH -H 'Authorization: Bearer ' -H 'Content-Type: application/json' -d '{"role":"ADMIN","isVerified":true}' $B/users/me +HTTP 400 +{ + "error": "Validation failed", + "details": { + "_errors": [ + "Unrecognized key(s) in object: 'role', 'isVerified'", + "At least one profile field is required" + ] + } +} + +### PATCH /users/me {walletAddress} → 400 (not a profile field) +$ curl -X PATCH -H 'Authorization: Bearer ' -H 'Content-Type: application/json' -d '{"walletAddress":"GAAA…"}' $B/users/me +HTTP 400 +{ + "error": "Validation failed", + "details": { + "_errors": [ + "Unrecognized key(s) in object: 'walletAddress'", + "At least one profile field is required" + ] + } +} + +### PATCH /users/me {} → 400 (at least one field required) +$ curl -X PATCH -H 'Authorization: Bearer ' -H 'Content-Type: application/json' -d '{}' $B/users/me +HTTP 400 +{ + "error": "Validation failed", + "details": { + "_errors": [ + "At least one profile field is required" + ] + } +} + +### PATCH /users/me {level: wizard} → 400 (bounded enum) +$ curl -X PATCH -H 'Authorization: Bearer ' -H 'Content-Type: application/json' -d '{"level":"wizard"}' $B/users/me +HTTP 400 +{ + "error": "Validation failed", + "details": { + "_errors": [], + "level": { + "_errors": [ + "Level must be one of: beginner, intermediate, advanced, expert" + ] + } + } +} + +### PATCH /users/me {displayName, bio, country, interests} → 200 (audited write) +$ curl -X PATCH -H 'Authorization: Bearer ' -H 'Content-Type: application/json' -d '{"displayName":"Ada L.","bio":"Building on Stellar","country":"NG","interests":["soroban"]}' $B/users/me +HTTP 200 +{ + "message": "Profile updated successfully", + "data": { + "account": { + "id": "11111111-1111-4111-8111-111111111111", + "email": "ada@example.com", + "username": "ada_learner", + "role": "LEARNER", + "status": "ACTIVE", + "isVerified": true, + "phoneVerifiedAt": null, + "walletAddress": null, + "createdAt": "2026-08-28T18:31:53.293Z", + "updatedAt": "2026-08-28T18:31:53.293Z", + "lastLoginAt": null + }, + "profile": { + "id": "25bfdbe5-d872-4c01-bc28-73b00bd281d9", + "userId": "11111111-1111-4111-8111-111111111111", + "displayName": "Ada L.", + "bio": "Building on Stellar", + "avatarUrl": null, + "country": "NG", + "timezone": null, + "languages": [], + "level": "beginner", + "interests": [ + "soroban" + ], + "goals": [], + "visibility": "private", + "createdAt": "2026-08-28T18:32:07.014Z", + "updatedAt": "2026-08-28T18:32:09.201Z" + }, + "completion": { + "percent": 50, + "missingFields": [ + "avatarUrl", + "timezone", + "languages", + "goals" + ] + }, + "onboarding": { + "version": "v1", + "status": "in_progress", + "currentStep": "profile_basics", + "completedSteps": [ + "profile_basics" + ], + "requiredStepsRemaining": [ + "consent" + ], + "startedAt": "2026-08-28T18:31:54.799Z", + "completedAt": null + }, + "consents": [ + { + "purpose": "terms_of_service", + "status": "granted", + "required": true, + "policyVersion": "2026-01", + "grantedAt": "2026-08-28T18:31:54.806Z", + "withdrawnAt": null + } + ], + "requiredConsentsGranted": false + } +} + +====================================================================== +4. Private data never enters a public response +====================================================================== + +### GET /users/{public-profile id} anonymous → 200, public subset only +$ curl $B/users/$PUB_ID +HTTP 200 +{ + "data": { + "id": "d1589aaf-c46b-4a9c-bb99-6597c7550db9", + "displayName": "Grace H.", + "bio": "Learning Soroban", + "avatarUrl": null, + "country": "NG", + "level": "intermediate", + "interests": [ + "soroban", + "stellar" + ], + "visible": true + } +} + +### GET /users/{private-profile id} anonymous → 200, redacted stub +$ curl $B/users/$PRIV_ID +HTTP 200 +{ + "data": { + "id": "ea2039b1-7a93-4644-8a78-28541d2d244b", + "visible": false + } +} + +### GET /users/{owner id} with the owner's own token → 200, still the public view +$ curl -H 'Authorization: Bearer ' $B/users/$OWNER_ID +HTTP 200 +{ + "data": { + "id": "25bfdbe5-d872-4c01-bc28-73b00bd281d9", + "visible": false + } +} + +### GET /users/not-a-uuid → 400 +$ curl $B/users/not-a-uuid +HTTP 400 +{ + "error": "Validation failed", + "details": { + "_errors": [], + "id": { + "_errors": [ + "Invalid user id" + ] + } + } +} + +### GET /users/{unknown uuid} → 404 +$ curl $B/users/00000000-0000-4000-8000-000000000000 +HTTP 404 +{ + "error": "User not found" +} + +====================================================================== +5. Password change — step-up, revocation, no plaintext echoed +====================================================================== + +### PATCH /users/password wrong current password → 401 +$ curl -X PATCH -H 'Authorization: Bearer ' -H 'Content-Type: application/json' -d '{"currentPassword":"","newPassword":""}' $B/users/password +HTTP 401 +{ + "error": "Current password is incorrect", + "code": "STEP_UP_FAILED" +} + +### PATCH /users/password weak new password → 400 +$ curl -X PATCH -H 'Authorization: Bearer ' -H 'Content-Type: application/json' -d '{"currentPassword":"","newPassword":"weak"}' $B/users/password +HTTP 400 +{ + "error": "Validation failed", + "details": { + "_errors": [], + "newPassword": { + "_errors": [ + "Password must be at least 8 characters long", + "Password must contain at least one uppercase letter", + "Password must contain at least one number", + "Password must contain at least one special character" + ] + } + } +} + +====================================================================== +6. Wallet address — validation, conflict, idempotence +====================================================================== + +### PATCH /users/wallet malformed address → 400 +$ curl -X PATCH -H 'Authorization: Bearer ' -H 'Content-Type: application/json' -d '{"walletAddress":"invalid-address"}' $B/users/wallet +HTTP 400 +{ + "error": "Validation failed", + "details": { + "_errors": [], + "walletAddress": { + "_errors": [ + "Invalid Stellar wallet address format" + ] + } + } +} + +### PATCH /users/wallet secret seed in the address field → 400 +$ curl -X PATCH -H 'Authorization: Bearer ' -H 'Content-Type: application/json' -d '{"walletAddress":"S…(seed)"}' $B/users/wallet +HTTP 400 +{ + "error": "Validation failed", + "details": { + "_errors": [], + "walletAddress": { + "_errors": [ + "Invalid Stellar wallet address format" + ] + } + } +} + +### PATCH /users/wallet valid public key → 200 (audited) +$ curl -X PATCH -H 'Authorization: Bearer ' -H 'Content-Type: application/json' -d '{"walletAddress":"GAAA…"}' $B/users/wallet +HTTP 200 +{ + "message": "Wallet address updated successfully", + "data": { + "walletAddress": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } +} + +### PATCH /users/wallet same address again → 200, no-op +$ curl -X PATCH -H 'Authorization: Bearer ' -H 'Content-Type: application/json' -d '{"walletAddress":"GAAA…"}' $B/users/wallet +HTTP 200 +{ + "message": "Wallet address unchanged", + "data": { + "walletAddress": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } +} + +### PATCH /users/wallet address held by another account → 409 +$ curl -X PATCH -H 'Authorization: Bearer ' -H 'Content-Type: application/json' -d '{"walletAddress":"GBBB…"}' $B/users/wallet +HTTP 409 +{ + "error": "Wallet address is already associated with another account", + "code": "WALLET_ADDRESS_TAKEN" +} + +====================================================================== +7. GET /users/me after the updates — completion and wallet reflected +====================================================================== + +### GET /users/me → 200 +$ curl -H 'Authorization: Bearer ' $B/users/me +HTTP 200 +{ + "data": { + "account": { + "id": "11111111-1111-4111-8111-111111111111", + "email": "ada@example.com", + "username": "ada_learner", + "role": "LEARNER", + "status": "ACTIVE", + "isVerified": true, + "phoneVerifiedAt": null, + "walletAddress": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "createdAt": "2026-08-28T18:31:53.293Z", + "updatedAt": "2026-08-28T18:32:12.118Z", + "lastLoginAt": null + }, + "profile": { + "id": "25bfdbe5-d872-4c01-bc28-73b00bd281d9", + "userId": "11111111-1111-4111-8111-111111111111", + "displayName": "Ada L.", + "bio": "Building on Stellar", + "avatarUrl": null, + "country": "NG", + "timezone": null, + "languages": [], + "level": "beginner", + "interests": [ + "soroban" + ], + "goals": [], + "visibility": "private", + "createdAt": "2026-08-28T18:32:07.014Z", + "updatedAt": "2026-08-28T18:32:09.201Z" + }, + "completion": { + "percent": 50, + "missingFields": [ + "avatarUrl", + "timezone", + "languages", + "goals" + ] + }, + "onboarding": { + "version": "v1", + "status": "in_progress", + "currentStep": "profile_basics", + "completedSteps": [ + "profile_basics" + ], + "requiredStepsRemaining": [ + "consent" + ], + "startedAt": "2026-08-28T18:31:54.799Z", + "completedAt": null + }, + "consents": [ + { + "purpose": "terms_of_service", + "status": "granted", + "required": true, + "policyVersion": "2026-01", + "grantedAt": "2026-08-28T18:31:54.806Z", + "withdrawnAt": null + } + ], + "requiredConsentsGranted": false + } +} + diff --git a/src/config/swagger.ts b/src/config/swagger.ts index a4216ab8..bd821ecd 100644 --- a/src/config/swagger.ts +++ b/src/config/swagger.ts @@ -22,10 +22,6 @@ const options: swaggerJsdoc.Options = { '```json', '{ "success": false, "error": { "message": "...", "code": 500 } }', '```', - '', - '> ⚠️ **Preview / not-yet-implemented routes** — `PATCH /api/v1/users/password` and', - '> `PATCH /api/v1/users/wallet` are wired but their underlying service methods are stubs.', - '> They will return errors in production until the service layer is completed.', ].join('\n'), contact: { name: 'Learnault Contributors', diff --git a/src/controllers/profile.controller.ts b/src/controllers/profile.controller.ts index 5a009985..7ba176d9 100644 --- a/src/controllers/profile.controller.ts +++ b/src/controllers/profile.controller.ts @@ -1,30 +1,13 @@ import { Request, Response } from 'express' -import { z } from 'zod' import { ProfileService } from '../services/profile.service' -import { LEARNER_LEVELS, PROFILE_VISIBILITIES } from '../types/profile.types' +import { requestAuditContext } from '../utils/audit-context' +// One allow-list for owner-updatable profile fields, shared with +// `PATCH /users/me` — two copies would drift, and a drifted copy is how a +// field becomes writable on one route and not the other. +import { updateProfileSchema } from '../schemas/profile.schema' const profileService = new ProfileService() -const updateProfileSchema = z - .object({ - displayName: z.string().min(1).max(80).nullable().optional(), - bio: z.string().max(1000).nullable().optional(), - avatarUrl: z.string().url().nullable().optional(), - country: z.string().min(2).max(60).nullable().optional(), - timezone: z.string().nullable().optional(), - languages: z.array(z.string().min(1)).max(20).optional(), - level: z.enum(LEARNER_LEVELS, { - errorMap: () => ({ message: `Level must be one of: ${LEARNER_LEVELS.join(', ')}` }), - }).optional(), - interests: z.array(z.string().min(1)).max(50).optional(), - goals: z.array(z.string().min(1)).max(20).optional(), - visibility: z.enum(PROFILE_VISIBILITIES, { - errorMap: () => ({ message: `Visibility must be one of: ${PROFILE_VISIBILITIES.join(', ')}` }), - }).optional(), - }) - .strict() - .refine(data => Object.keys(data).length > 0, { message: 'At least one profile field is required' }) - export class ProfileController { /** * @openapi @@ -63,9 +46,18 @@ export class ProfileController { * /users/me/profile: * patch: * summary: Partially update the authenticated user's learner profile + * description: > + * Closed body: only owner-updatable profile fields are accepted, and the + * change is written together with its audit event in one transaction. * tags: [Profiles] * security: * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UpdateProfileInput' * responses: * 200: * description: Profile updated successfully @@ -93,7 +85,7 @@ export class ProfileController { return } - await profileService.updateProfile(userId, validation.data) + await profileService.updateProfileAudited(userId, validation.data, requestAuditContext(req)) const profile = await profileService.getOwnerView(userId) res.status(200).json({ diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index c8ada4f8..f5efd847 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -1,402 +1,438 @@ -import { ChangePasswordData, PublicUserInfo, UpdateUserData, User } from '../types/user.types' -import { Request, Response } from 'express' - -export class UserController { - /** - * @openapi - * /users/me: - * get: - * operationId: usersGetMe - * summary: Get the authenticated user's profile - * tags: [Users] - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: User profile retrieved successfully - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/User' - * 401: - * description: Unauthorized - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 404: - * description: User not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - */ - - async getCurrentUser (req: Request, res: Response): Promise { - try { - const userId = (req as any).user?.id - if (!userId) { - res.status(401).json({ error: 'Unauthorized' }) - - return - } - const user = await this.findUserById(userId) - if (!user) { - res.status(404).json({ error: 'User not found' }) - - return - } - res.json({ - id: user.id, - email: user.email, - username: user.username, - firstName: user.firstName, - lastName: user.lastName, - bio: user.bio, - avatar: user.avatar, - walletAddress: user.walletAddress, - isActive: user.isActive, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }) - } catch { - res.status(500).json({ error: 'Internal server error' }) - } - } - - /** - * @openapi - * /users/me: - * patch: - * operationId: usersUpdateProfile - * summary: Update the authenticated user's profile - * tags: [Users] - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/UpdateUserInput' - * responses: - * 200: - * description: Profile updated successfully - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/User' - * 401: - * description: Unauthorized - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - */ - - async updateProfile (req: Request, res: Response): Promise { - try { - const userId = (req as any).user?.id - if (!userId) { - res.status(401).json({ error: 'Unauthorized' }) - - return - } - const data = req.body as UpdateUserData - const user = await this.updateUserProfile(userId, data) - res.json({ - id: user.id, - email: user.email, - username: user.username, - firstName: user.firstName, - lastName: user.lastName, - bio: user.bio, - avatar: user.avatar, - walletAddress: user.walletAddress, - isActive: user.isActive, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }) - } catch { - res.status(500).json({ error: 'Internal server error' }) - } - } - - /** - * @openapi - * /users/{id}: - * get: - * operationId: usersGetById - * summary: Get a user's public profile by ID - * tags: [Users] - * security: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * format: uuid - * responses: - * 200: - * description: Public user info - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/PublicUser' - * 404: - * description: User not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - */ - async getUserById (req: Request, res: Response): Promise { - try { - const { id } = req.params - - const user = await this.findUserById(id) - if (!user) { - res.status(404).json({ error: 'User not found' }) - - return - } - - const publicInfo: PublicUserInfo = { - id: user.id, - username: user.username, - firstName: user.firstName, - lastName: user.lastName, - avatar: user.avatar, - role: user.role, - createdAt: user.createdAt, - } - - res.json(publicInfo) - } catch (error) { - console.error('Error getting user by ID:', error) - res.status(500).json({ error: 'Internal server error' }) - } - } - - /** - * @openapi - * /users/password: - * patch: - * operationId: usersChangePassword - * summary: Change the authenticated user's password - * description: > - * ⚠️ **Preview** — the underlying service method is not yet fully implemented. - * Calling this endpoint will return a 500 error until the service is completed. - * tags: [Users] - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ChangePasswordInput' - * responses: - * 200: - * description: Password changed successfully. - * 400: - * description: Current password is incorrect or validation failed. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 401: - * description: Unauthorized - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 500: - * description: Not yet implemented. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - */ - async changePassword (req: Request, res: Response): Promise { - try { - const userId = (req as any).user?.id - const { currentPassword, newPassword }: ChangePasswordData = req.body - - const user = await this.findUserById(userId) - if (!user) { - res.status(404).json({ error: 'User not found' }) - - - return - } - - const isCurrentPasswordValid = await this.validatePassword(user, currentPassword) - if (!isCurrentPasswordValid) { - res.status(400).json({ error: 'Current password is incorrect' }) - - - return - } - - await this.updateUserPassword(userId, newPassword) - - res.json({ message: 'Password updated successfully' }) - } catch (error: unknown) { - console.error('Error changing password:', error) - res.status(500).json({ error: 'Internal server error' }) - } - } - - /** - * @openapi - * /users/wallet: - * patch: - * operationId: usersUpdateWallet - * summary: Update the authenticated user's Stellar wallet address - * description: > - * ⚠️ **Preview** — the underlying service method is not yet fully implemented. - * The wallet update is accepted but may not persist to the database until the - * service layer is completed. - * tags: [Users] - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/UpdateWalletInput' - * responses: - * 200: - * description: Wallet address updated successfully. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/User' - * 400: - * description: Invalid Stellar wallet address format. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 401: - * description: Unauthorized - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - */ - - async updateWalletAddress (req: Request, res: Response): Promise { - try { - const userId = (req as any).user?.id - if (!userId) { - res.status(401).json({ error: 'Unauthorized' }) - - return - } - const { walletAddress } = req.body as { walletAddress: string } - if (!this.isValidStellarAddress(walletAddress)) { - res.status(400).json({ error: 'Invalid Stellar wallet address' }) - - return - } - const user = await this.updateUserWallet(userId, walletAddress) - res.json({ - id: user.id, - email: user.email, - username: user.username, - firstName: (user as any).firstName, - lastName: (user as any).lastName, - bio: (user as any).bio, - avatar: (user as any).avatar, - walletAddress: user.walletAddress, - isActive: user.isActive, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }) - } catch { - res.status(500).json({ error: 'Internal server error' }) - } - } - - private async findUserById (id: string): Promise { - const mockUser: User = { - id, - email: 'test@example.com', - username: 'testuser', - firstName: 'Test', - lastName: 'User', - bio: 'Test bio', - avatar: 'https://example.com/avatar.jpg', - walletAddress: 'GABC123456789012345678901234567890123456789012345678901234567890', - isActive: true, - role: 'LEARNER' as any, - status: 'active' as any, - createdAt: new Date(), - updatedAt: new Date(), - } - - return mockUser - } - - private async updateUserProfile (id: string, data: UpdateUserData): Promise { - const mockUser: User = { - id, - email: 'test@example.com', - username: data.username || 'testuser', - firstName: data.firstName, - lastName: data.lastName, - bio: data.bio, - avatar: data.avatar, - walletAddress: 'GABC123456789012345678901234567890123456789012345678901234567890', - isActive: true, - role: 'LEARNER' as any, - status: 'active' as any, - createdAt: new Date(), - updatedAt: new Date(), - } - - return mockUser - } - - private async validatePassword (_user: User, _password: string): Promise { - return false - } - - private async updateUserPassword (_id: string, _newPassword: string): Promise { - throw new Error('Not implemented') - } - - private async updateUserWallet (id: string, walletAddress: string): Promise { - const mockUser: User = { - id, - email: 'test@example.com', - username: 'testuser', - walletAddress, - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - } as never - - return mockUser - } - - private isValidStellarAddress (address: string): boolean { - return /^G[A-Z0-9]{50,55}$/.test(address) - } -} +import { Request, Response } from 'express' +import logger from '../utils/logger' +import { profileService } from '../services/profile.service' +import { userAccountService } from '../services/user-account.service' +import { requestAuditContext } from '../utils/audit-context' +import { + changePasswordSchema, + updateProfileSchema, + updateWalletSchema, + userIdParamSchema, +} from '../schemas/profile.schema' + +/** + * User account and profile routes, backed by Prisma. + * + * Everything here reads and writes real rows. The mock `findUserById` / + * `updateUserProfile` / `validatePassword` / `updateUserPassword` / + * `updateUserWallet` helpers this controller used to carry are gone, along with + * the `firstName` / `lastName` / `bio` / `avatar` fields they invented: profile + * data lives on `LearnerProfile` (see docs/decisions/0002), and the only + * profile-ish column on `User` is `username`, which is an identity field and is + * not editable through the profile API. + */ +export class UserController { + /** + * @openapi + * /users/me: + * get: + * operationId: usersGetMe + * summary: Get the authenticated user's account and profile + * description: > + * Owner-only aggregate read: account identity, learner profile, profile + * completion, onboarding state, and current consent per purpose. Private + * account fields are included because the caller is the owner; the same + * data is never served through `GET /users/{id}`. + * tags: [Users] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Account and profile retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * $ref: '#/components/schemas/OwnerAccountProfile' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 404: + * description: User not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async getCurrentUser(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const aggregate = await profileService.getOwnerAccountProfile(userId) + if (!aggregate) { + res.status(404).json({ error: 'User not found' }) + + return + } + + res.status(200).json({ data: aggregate }) + } catch (error) { + logger.error('Get current user error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /users/me: + * patch: + * operationId: usersUpdateProfile + * summary: Update the authenticated user's learner profile + * description: > + * Partial update, restricted to the owner-updatable profile fields. The + * request body is closed: any field outside the allow-list — including + * account fields such as `status`, `isVerified` or `role` — is a 400, + * not a silently ignored key. Every accepted change is written with an + * audit event in the same transaction. + * tags: [Users] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UpdateProfileInput' + * responses: + * 200: + * description: Profile updated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * data: + * $ref: '#/components/schemas/OwnerAccountProfile' + * 400: + * description: Validation failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 404: + * description: User not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async updateProfile(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const validation = updateProfileSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + await profileService.updateProfileAudited( + userId, + validation.data, + requestAuditContext(req) + ) + + const aggregate = await profileService.getOwnerAccountProfile(userId) + if (!aggregate) { + res.status(404).json({ error: 'User not found' }) + + return + } + + res.status(200).json({ message: 'Profile updated successfully', data: aggregate }) + } catch (error) { + logger.error('Update profile error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /users/{id}: + * get: + * operationId: usersGetById + * summary: Get a learner's public profile by user ID + * description: > + * Public, consent-aware read. Returns the public field subset only when + * the profile's visibility is `public`, the account is active, and + * data-sharing consent has not been withdrawn; otherwise it returns the + * redacted stub `{ id, visible: false }`. Private account data (email, + * status, verification, wallet address) is never included. + * tags: [Users] + * security: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * responses: + * 200: + * description: Public profile, or a redacted stub + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * $ref: '#/components/schemas/PublicProfile' + * 400: + * description: Invalid user id + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 404: + * description: User not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async getUserById(req: Request, res: Response): Promise { + try { + const params = userIdParamSchema.safeParse(req.params) + if (!params.success) { + res.status(400).json({ + error: 'Validation failed', + details: params.error.format(), + }) + + return + } + + const profile = await profileService.getPublicView(params.data.id) + if (!profile) { + res.status(404).json({ error: 'User not found' }) + + return + } + + res.status(200).json({ data: profile }) + } catch (error) { + logger.error('Get user by id error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /users/password: + * patch: + * operationId: usersChangePassword + * summary: Change the authenticated user's password + * description: > + * Verifies the current password, stores a new bcrypt hash, and revokes + * every session and refresh-token family for the account in the same + * transaction — so the caller must sign in again, and so does anyone + * holding a stolen session. The change is audited. + * tags: [Users] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ChangePasswordInput' + * responses: + * 200: + * description: Password changed; all sessions revoked + * 400: + * description: Validation failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Unauthorized, or the current password is incorrect + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 404: + * description: User not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async changePassword(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const validation = changePasswordSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const result = await userAccountService.changePassword( + userId, + validation.data.currentPassword, + validation.data.newPassword, + requestAuditContext(req) + ) + + if (result.kind === 'not-found') { + res.status(404).json({ error: 'User not found' }) + + return + } + + // 401, not 400: a wrong current password is a failed re-authentication, + // and the body that carried it was perfectly well-formed. + if (result.kind === 'invalid-password') { + res.status(401).json({ error: 'Current password is incorrect', code: 'STEP_UP_FAILED' }) + + return + } + + res.status(200).json({ + message: 'Password updated successfully. All sessions have been signed out.', + revokedSessionCount: result.revokedSessionCount, + }) + } catch (error) { + logger.error('Change password error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /users/wallet: + * patch: + * operationId: usersUpdateWallet + * summary: Set the authenticated user's Stellar wallet address + * description: > + * Persists the learner's Stellar public key on their account. Addresses + * are unique across accounts, so one already claimed elsewhere is a 409. + * Re-sending the address already on file is a no-op. The change is + * audited. This endpoint never accepts or returns a secret seed. + * tags: [Users] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UpdateWalletInput' + * responses: + * 200: + * description: Wallet address updated (or already set to this value) + * 400: + * description: Invalid Stellar wallet address + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 404: + * description: User not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 409: + * description: Wallet address is already claimed by another account + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async updateWalletAddress(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const validation = updateWalletSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const result = await userAccountService.updateWalletAddress( + userId, + validation.data.walletAddress, + requestAuditContext(req) + ) + + if (result.kind === 'not-found') { + res.status(404).json({ error: 'User not found' }) + + return + } + + if (result.kind === 'conflict') { + res.status(409).json({ + error: 'Wallet address is already associated with another account', + code: 'WALLET_ADDRESS_TAKEN', + }) + + return + } + + res.status(200).json({ + message: + result.kind === 'unchanged' + ? 'Wallet address unchanged' + : 'Wallet address updated successfully', + data: { walletAddress: result.walletAddress }, + }) + } catch (error) { + logger.error('Update wallet address error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } +} diff --git a/src/docs/schemas.ts b/src/docs/schemas.ts index 536f7376..9af8f4c7 100644 --- a/src/docs/schemas.ts +++ b/src/docs/schemas.ts @@ -247,10 +247,14 @@ * components: * schemas: * - * # ── Users ───────────────────────────────────────────────────────────── + * # ── Users, accounts and profiles ─────────────────────────────────────── * - * User: + * AccountSummary: * type: object + * description: > + * Owner-only view of the `User` row. Served exclusively through + * `GET /users/me`; none of these fields appears in a public or + * employer-facing response. * properties: * id: * type: string @@ -260,81 +264,265 @@ * format: email * username: * type: string - * firstName: - * type: string - * nullable: true - * lastName: + * role: * type: string - * nullable: true - * bio: + * enum: [ADMIN, LEARNER, INSTRUCTOR] + * status: * type: string - * nullable: true - * avatar: + * enum: [ACTIVE, DEACTIVATED, PENDING_DELETION, DELETED] + * isVerified: + * type: boolean + * phoneVerifiedAt: * type: string - * format: uri + * format: date-time * nullable: true * walletAddress: * type: string * nullable: true * example: GABC1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGH - * isActive: - * type: boolean - * role: - * type: string - * enum: [learner, employer, admin] * createdAt: * type: string * format: date-time * updatedAt: * type: string * format: date-time + * lastLoginAt: + * type: string + * format: date-time + * nullable: true * - * PublicUser: + * LearnerProfile: * type: object - * description: Publicly visible subset of a user profile. + * description: The learner-authored profile record, in full (owner view). * properties: * id: * type: string * format: uuid - * username: + * userId: * type: string - * firstName: + * format: uuid + * displayName: * type: string * nullable: true - * lastName: + * maxLength: 80 + * bio: * type: string * nullable: true - * avatar: + * maxLength: 1000 + * avatarUrl: * type: string * format: uri * nullable: true - * role: + * country: * type: string - * enum: [learner, employer, admin] + * nullable: true + * timezone: + * type: string + * nullable: true + * languages: + * type: array + * items: + * type: string + * level: + * type: string + * enum: [beginner, intermediate, advanced, expert] + * interests: + * type: array + * items: + * type: string + * goals: + * type: array + * items: + * type: string + * visibility: + * type: string + * enum: [private, employer, public] * createdAt: * type: string * format: date-time + * updatedAt: + * type: string + * format: date-time * - * UpdateUserInput: + * ProfileCompletion: * type: object - * description: All fields are optional; send only what you want to change. + * description: Computed on read, never stored, so it cannot drift. * properties: - * username: + * percent: + * type: integer + * minimum: 0 + * maximum: 100 + * missingFields: + * type: array + * items: + * type: string + * + * OnboardingSummary: + * type: object + * nullable: true + * description: Null when the learner has never started onboarding. + * properties: + * version: * type: string - * minLength: 3 - * maxLength: 30 - * firstName: + * status: + * type: string + * enum: [in_progress, completed] + * currentStep: + * type: string + * enum: [profile_basics, consent, preferences] + * completedSteps: + * type: array + * items: + * type: string + * requiredStepsRemaining: + * type: array + * items: + * type: string + * startedAt: + * type: string + * format: date-time + * completedAt: + * type: string + * format: date-time + * nullable: true + * + * ConsentSummary: + * type: object + * description: Current state of one consent purpose. History lives at /consents/history. + * properties: + * purpose: + * type: string + * enum: [terms_of_service, privacy_policy, marketing_emails, analytics, data_sharing, custodial_wallet] + * status: + * type: string + * enum: [granted, withdrawn] + * required: + * type: boolean + * policyVersion: + * type: string + * grantedAt: * type: string - * maxLength: 50 - * lastName: + * format: date-time + * nullable: true + * withdrawnAt: + * type: string + * format: date-time + * nullable: true + * + * OwnerAccountProfile: + * type: object + * description: The aggregate returned by GET and PATCH /users/me. + * properties: + * account: + * $ref: '#/components/schemas/AccountSummary' + * profile: + * $ref: '#/components/schemas/LearnerProfile' + * completion: + * $ref: '#/components/schemas/ProfileCompletion' + * onboarding: + * $ref: '#/components/schemas/OnboardingSummary' + * consents: + * type: array + * items: + * $ref: '#/components/schemas/ConsentSummary' + * requiredConsentsGranted: + * type: boolean + * + * PublicProfile: + * description: > + * Either the public field subset or the redacted stub + * `{ id, visible: false }`. The stub is returned identically for a + * non-public profile, a withdrawn data-sharing consent, and an inactive + * account, so the refusal itself discloses nothing. + * oneOf: + * - type: object + * properties: + * id: + * type: string + * format: uuid + * displayName: + * type: string + * nullable: true + * bio: + * type: string + * nullable: true + * avatarUrl: + * type: string + * format: uri + * nullable: true + * country: + * type: string + * nullable: true + * level: + * type: string + * enum: [beginner, intermediate, advanced, expert] + * interests: + * type: array + * items: + * type: string + * visible: + * type: boolean + * enum: [true] + * - type: object + * properties: + * id: + * type: string + * format: uuid + * visible: + * type: boolean + * enum: [false] + * + * UpdateProfileInput: + * type: object + * additionalProperties: false + * description: > + * Partial update. Every field is optional but at least one is required, + * and any property not listed here is rejected with a 400 — this object + * is the complete set of fields an owner may write. + * minProperties: 1 + * properties: + * displayName: * type: string - * maxLength: 50 + * nullable: true + * minLength: 1 + * maxLength: 80 * bio: * type: string - * maxLength: 500 - * avatar: + * nullable: true + * maxLength: 1000 + * avatarUrl: * type: string * format: uri + * nullable: true + * country: + * type: string + * nullable: true + * minLength: 2 + * maxLength: 60 + * timezone: + * type: string + * nullable: true + * languages: + * type: array + * maxItems: 20 + * items: + * type: string + * level: + * type: string + * enum: [beginner, intermediate, advanced, expert] + * interests: + * type: array + * maxItems: 50 + * items: + * type: string + * goals: + * type: array + * maxItems: 20 + * items: + * type: string + * visibility: + * type: string + * enum: [private, employer, public] * * ChangePasswordInput: * type: object diff --git a/src/middleware/validation.middleware.ts b/src/middleware/validation.middleware.ts index e91455e5..9291ebec 100644 --- a/src/middleware/validation.middleware.ts +++ b/src/middleware/validation.middleware.ts @@ -132,7 +132,13 @@ export const validate = (schemas: ValidationSchemas) => { } } -// Specific validation middlewares for backward compatibility +// Specific validation middlewares for backward compatibility. +// +// `validateProfileUpdate` is **deprecated and no longer mounted on any route**. +// It validates the mock-era `firstName`/`lastName`/`bio`/`avatar` body, none of +// which is a persisted column: learner profile data lives on `LearnerProfile`. +// The live allow-list is `updateProfileSchema` in src/schemas/profile.schema.ts, +// applied inside the controller. Do not wire this back up. export const validateProfileUpdate = validate({ body: z.object({ username: commonSchemas.username.optional(), diff --git a/src/routes/v1/users.routes.ts b/src/routes/v1/users.routes.ts index c71def64..5845c20c 100644 --- a/src/routes/v1/users.routes.ts +++ b/src/routes/v1/users.routes.ts @@ -1,36 +1,41 @@ -import express, { Router } from 'express' -import { UserController } from '../../controllers/user.controller' -import { PreferenceController } from '../../controllers/preference.controller' -import { ProfileController } from '../../controllers/profile.controller' -import { authenticate, optionalAuthenticate } from '../../middleware/auth.middleware' -import { validateProfileUpdate, validatePasswordChange, validateWalletAddress } from '../../middleware/validation.middleware' -import avatarRoutes from './avatar.routes' - -const router: express.Router = Router() -const userController = new UserController() -const preferenceController = new PreferenceController() -const profileController = new ProfileController() - -router.get('/me', authenticate, userController.getCurrentUser.bind(userController)) - -router.patch('/me', authenticate, validateProfileUpdate, userController.updateProfile.bind(userController)) - -router.get('/me/preferences', authenticate, preferenceController.getPreferences.bind(preferenceController)) - -router.patch('/me/preferences', authenticate, preferenceController.updatePreferences.bind(preferenceController)) - -router.get('/me/profile', authenticate, profileController.getMyProfile.bind(profileController)) - -router.patch('/me/profile', authenticate, profileController.updateMyProfile.bind(profileController)) - -router.get('/:id/profile', optionalAuthenticate, profileController.getProfileById.bind(profileController)) - -router.get('/:id', userController.getUserById.bind(userController)) - -router.patch('/password', authenticate, validatePasswordChange, userController.changePassword.bind(userController)) - -router.patch('/wallet', authenticate, validateWalletAddress, userController.updateWalletAddress.bind(userController)) - -router.use('/me/avatar', avatarRoutes) - -export default router +import express, { Router } from 'express' +import { UserController } from '../../controllers/user.controller' +import { PreferenceController } from '../../controllers/preference.controller' +import { ProfileController } from '../../controllers/profile.controller' +import { authenticate, optionalAuthenticate } from '../../middleware/auth.middleware' +import avatarRoutes from './avatar.routes' + +const router: express.Router = Router() +const userController = new UserController() +const preferenceController = new PreferenceController() +const profileController = new ProfileController() + +// Bodies and path params are validated inside the controllers with the Zod +// schemas in src/schemas/profile.schema.ts, the same way every other Prisma- +// backed controller in this service does it. The legacy `validateProfileUpdate` +// middleware is deliberately not mounted here: it validated the mock-era +// firstName/lastName/bio/avatar body, none of which is a persisted column. + +router.get('/me', authenticate, userController.getCurrentUser.bind(userController)) + +router.patch('/me', authenticate, userController.updateProfile.bind(userController)) + +router.get('/me/preferences', authenticate, preferenceController.getPreferences.bind(preferenceController)) + +router.patch('/me/preferences', authenticate, preferenceController.updatePreferences.bind(preferenceController)) + +router.get('/me/profile', authenticate, profileController.getMyProfile.bind(profileController)) + +router.patch('/me/profile', authenticate, profileController.updateMyProfile.bind(profileController)) + +router.patch('/password', authenticate, userController.changePassword.bind(userController)) + +router.patch('/wallet', authenticate, userController.updateWalletAddress.bind(userController)) + +router.use('/me/avatar', avatarRoutes) + +router.get('/:id/profile', optionalAuthenticate, profileController.getProfileById.bind(profileController)) + +router.get('/:id', userController.getUserById.bind(userController)) + +export default router diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 4e4b8676..939ebd6c 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -1,3 +1,4 @@ export * from './api.schema' export * from './account.schema' export * from './auth.schema' +export * from './profile.schema' diff --git a/src/schemas/profile.schema.ts b/src/schemas/profile.schema.ts new file mode 100644 index 00000000..68ab0dc8 --- /dev/null +++ b/src/schemas/profile.schema.ts @@ -0,0 +1,75 @@ +import { z } from 'zod' +import { LEARNER_LEVELS, PROFILE_VISIBILITIES } from '../types/profile.types' +import { commonSchemas } from '../middleware/validation.middleware' + +/** + * The owner-updatable profile field set — the single source of truth for + * "which fields may an owner change". + * + * `.strict()` is what enforces the allow-list: anything outside this object is + * a 400, not a silently ignored key. That matters more than it looks, because + * the fields deliberately *absent* here are the ones an owner must never be + * able to set through a profile write — `id`, `userId`, the `archived*` columns, + * and every account-private field on `User` (`status`, `isVerified`, + * `phoneVerifiedAt`, `role`, `password`). + * + * `avatarUrl` is present because the profile record owns it, but the avatar + * upload flow (src/services/avatar.service.ts) is the normal way it changes. + */ +export const profileUpdateFieldsShape = { + displayName: z.string().min(1).max(80).nullable().optional(), + bio: z.string().max(1000).nullable().optional(), + avatarUrl: z.string().url().nullable().optional(), + country: z.string().min(2).max(60).nullable().optional(), + timezone: z.string().nullable().optional(), + languages: z.array(z.string().min(1)).max(20).optional(), + level: z + .enum(LEARNER_LEVELS, { + errorMap: () => ({ message: `Level must be one of: ${LEARNER_LEVELS.join(', ')}` }), + }) + .optional(), + interests: z.array(z.string().min(1)).max(50).optional(), + goals: z.array(z.string().min(1)).max(20).optional(), + visibility: z + .enum(PROFILE_VISIBILITIES, { + errorMap: () => ({ message: `Visibility must be one of: ${PROFILE_VISIBILITIES.join(', ')}` }), + }) + .optional(), +} as const + +/** Field names an owner is allowed to write, derived from the schema itself. */ +export const OWNER_UPDATABLE_PROFILE_FIELDS = Object.keys( + profileUpdateFieldsShape +) as readonly (keyof typeof profileUpdateFieldsShape)[] + +export const updateProfileSchema = z + .object(profileUpdateFieldsShape) + .strict() + .refine(data => Object.keys(data).length > 0, { + message: 'At least one profile field is required', + }) + +export const changePasswordSchema = z + .object({ + currentPassword: z.string().min(1, 'Current password is required'), + newPassword: commonSchemas.password, + }) + .strict() + .refine(data => data.currentPassword !== data.newPassword, { + message: 'New password must be different from current password', + path: ['newPassword'], + }) + +export const updateWalletSchema = z + .object({ + walletAddress: commonSchemas.walletAddress, + }) + .strict() + +export const userIdParamSchema = z.object({ + id: z.string().uuid('Invalid user id'), +}) + +export type UpdateProfileInput = z.infer; +export type ChangePasswordInput = z.infer; +export type UpdateWalletInput = z.infer; diff --git a/src/services/profile-serializer.ts b/src/services/profile-serializer.ts index 33d7a828..4dce0af1 100644 --- a/src/services/profile-serializer.ts +++ b/src/services/profile-serializer.ts @@ -1,7 +1,11 @@ import { AccountPrivateFields, + AccountSummary, + ConsentSummary, EmployerProfileView, LearnerProfileRecord, + OnboardingSummary, + OwnerAccountProfileView, OwnerProfileView, PrivateProfileView, PROFILE_COMPLETION_FIELDS, @@ -9,6 +13,7 @@ import { PublicProfileView, VISIBILITY_RANK, } from '../types/profile.types' +import { REQUIRED_ONBOARDING_STEPS } from '../types/onboarding.types' function isFilled(value: unknown): boolean { if (value === null || value === undefined) { @@ -32,8 +37,37 @@ export function computeProfileCompletion(profile: LearnerProfileRecord): Profile return { percent, missingFields: [...missingFields] } } +/** + * Narrow a loaded row to the declared profile fields. + * + * The Prisma model also carries the archive bookkeeping columns (`archivedAt`, + * `archivedById`, `archivedReason`), which are lifecycle machinery, not profile + * data — they are not in `LearnerProfileRecord`, not in the documented + * `LearnerProfile` schema, and a spread of the row would put them in the + * response anyway, because TypeScript checks the declared type and not the + * object that actually arrives at runtime. + */ +export function toProfileRecord(profile: LearnerProfileRecord): LearnerProfileRecord { + return { + id: profile.id, + userId: profile.userId, + displayName: profile.displayName, + bio: profile.bio, + avatarUrl: profile.avatarUrl, + country: profile.country, + timezone: profile.timezone, + languages: profile.languages, + level: profile.level, + interests: profile.interests, + goals: profile.goals, + visibility: profile.visibility, + createdAt: profile.createdAt, + updatedAt: profile.updatedAt, + } +} + export function toOwnerProfile(profile: LearnerProfileRecord): OwnerProfileView { - return { ...profile, completion: computeProfileCompletion(profile) } + return { ...toProfileRecord(profile), completion: computeProfileCompletion(profile) } } export function toEmployerProfile(profile: LearnerProfileRecord): EmployerProfileView { @@ -79,5 +113,154 @@ export function toPrivateProfile( profile: LearnerProfileRecord, account: AccountPrivateFields ): PrivateProfileView { - return { ...profile, ...account } + return { ...toProfileRecord(profile), ...account } +} + +// ── Consent-aware disclosure gate ────────────────────────────────────────── + +/** + * The consent purpose that governs showing a learner's profile to anyone other + * than the learner. `data_sharing` is optional consent, so most learners have + * no record for it at all — see {@link isDisclosureAllowed} for what that means. + */ +export const DISCLOSURE_CONSENT_PURPOSE = 'data_sharing' + +/** The only account status whose profile is disclosed to a third party. */ +export const DISCLOSABLE_ACCOUNT_STATUS = 'ACTIVE' + +/** + * Whether a profile may be shown to someone other than its owner, on grounds + * other than the `visibility` setting. + * + * Two independent gates, both of which can only ever *narrow* disclosure: + * + * 1. **Account status.** A deactivated or pending-deletion account stops being + * visible to third parties immediately, without the learner having to also + * flip `visibility` on the way out. + * 2. **Withdrawn data-sharing consent.** An explicit withdrawal wins over the + * `visibility` setting, so revoking consent takes effect even if a stale + * `visibility: public` is still on the row. + * + * The *absence* of a `data_sharing` record does not block disclosure. That + * consent is optional (`REQUIRED_CONSENT_PURPOSES` in src/types/consent.types.ts), + * and setting `visibility` above `private` is itself an explicit, deliberate + * disclosure choice — treating "never asked" as a refusal would make the + * visibility control silently inoperative for every learner who skipped an + * optional prompt. + */ +export function isDisclosureAllowed(input: { + accountStatus: string + consents: readonly { purpose: string; status: string }[] +}): boolean { + if (input.accountStatus !== DISCLOSABLE_ACCOUNT_STATUS) { + return false + } + + const dataSharing = input.consents.find( + consent => consent.purpose === DISCLOSURE_CONSENT_PURPOSE + ) + + return dataSharing?.status !== 'withdrawn' +} + +/** + * The redacted stub returned when disclosure is refused. + * + * Identical to the stub `toPublicProfile`/`toEmployerProfile` return below their + * visibility threshold, and that is the point: a viewer cannot tell a private + * profile from a withdrawn consent from a deactivated account. Distinguishable + * refusals would leak the very state they refuse to disclose. + */ +export function redactedProfile(profileId: string): { id: string; visible: false } { + return { id: profileId, visible: false } +} + +// ── Owner account/profile aggregate ──────────────────────────────────────── + +/** + * The owner's view of their own account row. + * + * Field-by-field rather than a spread, so the `password` column — and anything + * else added to `User` later — cannot reach a response by being present on the + * input object. The compiler enforces the shape; this enforces the contents. + */ +export function toAccountSummary(account: AccountSummary): AccountSummary { + return { + id: account.id, + email: account.email, + username: account.username, + role: account.role, + status: account.status, + isVerified: account.isVerified, + phoneVerifiedAt: account.phoneVerifiedAt, + walletAddress: account.walletAddress, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + lastLoginAt: account.lastLoginAt, + } +} + +export function toOnboardingSummary(progress: { + version: string + status: string + currentStep: string + completedSteps: string[] + startedAt: Date + completedAt: Date | null +}): OnboardingSummary { + return { + version: progress.version, + status: progress.status, + currentStep: progress.currentStep, + completedSteps: [...progress.completedSteps], + // Computed rather than stored, for the same reason completion is: it can + // then never disagree with `completedSteps`. + requiredStepsRemaining: REQUIRED_ONBOARDING_STEPS.filter( + step => !progress.completedSteps.includes(step) + ), + startedAt: progress.startedAt, + completedAt: progress.completedAt, + } +} + +/** + * Consent state, reduced to what a client needs to render a settings screen. + * + * `id`, `userId` and `source` are dropped: the aggregate is about the current + * state of each purpose, and the full audit trail is served by the consent + * history endpoint. + */ +export function toConsentSummary(record: { + purpose: string + status: string + required: boolean + policyVersion: string + grantedAt: Date | null + withdrawnAt: Date | null +}): ConsentSummary { + return { + purpose: record.purpose, + status: record.status, + required: record.required, + policyVersion: record.policyVersion, + grantedAt: record.grantedAt, + withdrawnAt: record.withdrawnAt, + } +} + +export function toOwnerAccountProfile(input: { + account: AccountSummary + profile: LearnerProfileRecord + onboarding: Parameters[0] | null + consents: Parameters[0][] + requiredConsentsGranted: boolean +}): OwnerAccountProfileView { + return { + account: toAccountSummary(input.account), + profile: toProfileRecord(input.profile), + completion: computeProfileCompletion(input.profile), + onboarding: input.onboarding ? toOnboardingSummary(input.onboarding) : null, + consents: input.consents.map(toConsentSummary), + requiredConsentsGranted: input.requiredConsentsGranted, + } } diff --git a/src/services/profile.service.ts b/src/services/profile.service.ts index 901ab2c9..2fb9fdf4 100644 --- a/src/services/profile.service.ts +++ b/src/services/profile.service.ts @@ -1,12 +1,40 @@ import prisma from '../config/database' -import { LearnerProfileRecord, UpdateLearnerProfileData } from '../types/profile.types' +import { AuditContext, auditedMutation } from '../audit' import { + AccountSummary, + LearnerProfileRecord, + OwnerAccountProfileView, + UpdateLearnerProfileData, +} from '../types/profile.types' +import { REQUIRED_CONSENT_PURPOSES } from '../types/consent.types' +import { + isDisclosureAllowed, + redactedProfile, toEmployerProfile, + toOwnerAccountProfile, toOwnerProfile, toPrivateProfile, toPublicProfile, } from './profile-serializer' +/** `User` columns the aggregate read discloses. Never includes `password`. */ +const ACCOUNT_SELECT = { + id: true, + email: true, + username: true, + role: true, + status: true, + isVerified: true, + phoneVerifiedAt: true, + walletAddress: true, + createdAt: true, + updatedAt: true, + lastLoginAt: true, +} as const + +/** Status of a tombstoned account: it must read as "not found", not as data. */ +const DELETED_STATUS = 'DELETED' + export class ProfileService { async getOrCreateProfile(userId: string): Promise { return prisma.learnerProfile.upsert({ @@ -24,28 +52,114 @@ export class ProfileService { }) as unknown as LearnerProfileRecord } + /** + * Partial profile update, with its audit event committed in the same + * transaction (see src/audit/audited-mutation.ts). + * + * `data` must already have been parsed by `updateProfileSchema`, which is what + * bounds the write to owner-updatable fields — this method does not re-derive + * that allow-list, it relies on having been handed a validated object. + * + * The metadata records which fields changed, never their values: a bio or a + * display name in an append-only trail is PII that cannot be scrubbed later. + */ + async updateProfileAudited( + userId: string, + data: UpdateLearnerProfileData, + context: AuditContext + ): Promise { + return auditedMutation({ + action: 'learner_profile.updated', + actor: context.actor, + target: { type: 'LearnerProfile' }, + source: 'api.users.update_profile', + requestId: context.requestId, + ipAddress: context.ipAddress, + userAgent: context.userAgent, + metadata: { userId, fields: Object.keys(data).sort() }, + mutate: tx => + tx.learnerProfile.upsert({ + where: { userId }, + update: data, + create: { userId, ...data }, + }) as unknown as Promise, + resolveTargetId: profile => profile.id, + }) + } + async getOwnerView(userId: string) { const profile = await this.getOrCreateProfile(userId) return toOwnerProfile(profile) } + /** + * Everything `GET /users/me` needs, in one read: account identity, profile, + * completion percentage, onboarding state, and current consent per purpose. + * + * Returns null for an unknown or tombstoned account, so the route answers 404 + * rather than materialising a profile row for a user that no longer exists. + */ + async getOwnerAccountProfile(userId: string): Promise { + const account = await prisma.user.findUnique({ + where: { id: userId }, + select: ACCOUNT_SELECT, + }) + + if (!account || account.status === DELETED_STATUS) { + return null + } + + const [profile, onboarding, consents] = await Promise.all([ + this.getOrCreateProfile(userId), + prisma.onboardingProgress.findUnique({ where: { userId } }), + prisma.consentRecord.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + distinct: ['purpose'], + }), + ]) + + return toOwnerAccountProfile({ + account: account as unknown as AccountSummary, + profile, + onboarding, + consents, + requiredConsentsGranted: REQUIRED_CONSENT_PURPOSES.every(purpose => + consents.some(consent => consent.purpose === purpose && consent.status === 'granted') + ), + }) + } + + /** + * Employer-facing read. Layers the consent/status gate on top of the profile's + * own `visibility` threshold; either one refusing yields the same stub. + */ async getEmployerView(userId: string) { - const profile = await prisma.learnerProfile.findUnique({ where: { userId } }) - if (!profile) { + const context = await this.disclosureContext(userId) + if (!context) { return null } - return toEmployerProfile(profile as unknown as LearnerProfileRecord) + if (!context.allowed) { + return redactedProfile(context.profile.id) + } + + return toEmployerProfile(context.profile) } + /** Public (possibly unauthenticated) read. Same gating as the employer view. */ async getPublicView(userId: string) { - const profile = await prisma.learnerProfile.findUnique({ where: { userId } }) - if (!profile) { + const context = await this.disclosureContext(userId) + if (!context) { return null } - return toPublicProfile(profile as unknown as LearnerProfileRecord) + if (!context.allowed) { + return redactedProfile(context.profile.id) + } + + return toPublicProfile(context.profile) } async getPrivateView(userId: string) { @@ -63,4 +177,37 @@ export class ProfileService { return toPrivateProfile(profile, user) } + + /** + * Load the profile plus the two inputs to the disclosure gate. + * + * Null means "nothing to disclose at all" (no profile row, or no account) and + * maps to 404. A loaded context with `allowed: false` means the profile exists + * but must be redacted — a distinction the caller keeps to itself. + */ + private async disclosureContext(userId: string): Promise< + { profile: LearnerProfileRecord; allowed: boolean } | null + > { + const [profile, account, consents] = await Promise.all([ + prisma.learnerProfile.findFirst({ where: { userId } }), + prisma.user.findUnique({ where: { id: userId }, select: { status: true } }), + prisma.consentRecord.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + distinct: ['purpose'], + select: { purpose: true, status: true }, + }), + ]) + + if (!profile || !account) { + return null + } + + return { + profile: profile as unknown as LearnerProfileRecord, + allowed: isDisclosureAllowed({ accountStatus: account.status, consents }), + } + } } + +export const profileService = new ProfileService() diff --git a/src/services/user-account.service.ts b/src/services/user-account.service.ts new file mode 100644 index 00000000..41a9597a --- /dev/null +++ b/src/services/user-account.service.ts @@ -0,0 +1,169 @@ +import prisma from '../config/database' +import { AuditContext, auditedMutation } from '../audit' +import { comparePassword, hashPassword } from '../utils/password' + +/** Status of a tombstoned account: it must read as "not found", not as data. */ +const DELETED_STATUS = 'DELETED' + +/** Prisma's unique-constraint violation. */ +const UNIQUE_VIOLATION = 'P2002' + +export type ChangePasswordResult = + | { kind: 'changed'; revokedSessionCount: number } + | { kind: 'not-found' } + | { kind: 'invalid-password' } + +export type UpdateWalletAddressResult = + | { kind: 'updated'; walletAddress: string } + | { kind: 'unchanged'; walletAddress: string } + | { kind: 'not-found' } + | { kind: 'conflict' } + +function isUniqueViolation(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (error as { code?: string }).code === UNIQUE_VIOLATION + ) +} + +/** + * Account-level operations on `User` that are neither profile data nor part of + * the authentication flow: the credential change and the learner's wallet + * address. Both are audited, because both are account-takeover relevant. + */ +export class UserAccountService { + /** + * Change the owner's password after verifying the current one, and revoke + * every session in the same transaction. + * + * Revocation is inside the mutation rather than after it on purpose. A + * password change whose session revocation fails separately would leave the + * attacker's stolen session alive precisely when the victim believes they have + * locked them out — so either both land or neither does. + */ + async changePassword( + userId: string, + currentPassword: string, + newPassword: string, + context: AuditContext + ): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, password: true, status: true }, + }) + + if (!user || user.status === DELETED_STATUS) { + return { kind: 'not-found' } + } + + if (!(await comparePassword(currentPassword, user.password))) { + return { kind: 'invalid-password' } + } + + const passwordHash = await hashPassword(newPassword) + + const revokedSessionCount = await auditedMutation({ + action: 'user.password_changed', + actor: context.actor, + target: { type: 'User', id: userId }, + source: 'api.users.change_password', + requestId: context.requestId, + ipAddress: context.ipAddress, + userAgent: context.userAgent, + mutate: async tx => { + await tx.user.update({ where: { id: userId }, data: { password: passwordHash } }) + + const sessions = await tx.session.findMany({ + where: { userId, isRevoked: false }, + select: { id: true }, + }) + const sessionIds = sessions.map(session => session.id) + + if (sessionIds.length === 0) { + return 0 + } + + await tx.session.updateMany({ + where: { id: { in: sessionIds } }, + data: { isRevoked: true, revokedAt: new Date() }, + }) + await tx.refreshToken.updateMany({ + where: { sessionId: { in: sessionIds }, status: { not: 'REVOKED' } }, + data: { status: 'REVOKED' }, + }) + + return sessionIds.length + }, + // The password itself never appears here, and cannot: `redaction.ts` + // denies every `*password*` key. The count is the reviewable part. + resolveMetadata: count => ({ revokedSessionCount: count }), + }) + + return { kind: 'changed', revokedSessionCount } + } + + /** + * Set the owner's Stellar wallet address. + * + * `User.walletAddress` is unique, so an address already claimed by another + * account is a 409 rather than a silent overwrite. The check is done up front + * for a clear error and again by catching the constraint violation, because + * two accounts claiming the same address concurrently would both pass the + * up-front read. + */ + async updateWalletAddress( + userId: string, + walletAddress: string, + context: AuditContext + ): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, status: true, walletAddress: true }, + }) + + if (!user || user.status === DELETED_STATUS) { + return { kind: 'not-found' } + } + + if (user.walletAddress === walletAddress) { + return { kind: 'unchanged', walletAddress } + } + + const claimedByOther = await prisma.user.findFirst({ + where: { walletAddress, id: { not: userId } }, + select: { id: true }, + }) + + if (claimedByOther) { + return { kind: 'conflict' } + } + + try { + await auditedMutation({ + action: 'user.wallet_address_changed', + actor: context.actor, + target: { type: 'User', id: userId }, + source: 'api.users.update_wallet_address', + requestId: context.requestId, + ipAddress: context.ipAddress, + userAgent: context.userAgent, + // A Stellar *public* key is not a secret; the corresponding seed never + // touches this path. Recording it is what makes a hijacked payout + // address traceable afterwards. + metadata: { walletAddress, hadPreviousAddress: user.walletAddress !== null }, + mutate: tx => tx.user.update({ where: { id: userId }, data: { walletAddress } }), + }) + } catch (error) { + if (isUniqueViolation(error)) { + return { kind: 'conflict' } + } + + throw error + } + + return { kind: 'updated', walletAddress } + } +} + +export const userAccountService = new UserAccountService() diff --git a/src/types/profile.types.ts b/src/types/profile.types.ts index 4f49c4e0..483adccc 100644 --- a/src/types/profile.types.ts +++ b/src/types/profile.types.ts @@ -70,6 +70,56 @@ export type PublicProfileView = export interface PrivateProfileView extends LearnerProfileRecord, AccountPrivateFields {} +// ── Owner account/profile aggregate ──────────────────────────────────────── + +// The `User` columns an owner may see about their own account. Deliberately a +// closed list rather than "the row minus password": a column added to `User` +// later must be opted in here, not disclosed by default. +export interface AccountSummary { + id: string + email: string + username: string + role: string + status: string + isVerified: boolean + phoneVerifiedAt: Date | null + walletAddress: string | null + createdAt: Date + updatedAt: Date + lastLoginAt: Date | null +} + +export interface OnboardingSummary { + version: string + status: string + currentStep: string + completedSteps: string[] + requiredStepsRemaining: string[] + startedAt: Date + completedAt: Date | null +} + +export interface ConsentSummary { + purpose: string + status: string + required: boolean + policyVersion: string + grantedAt: Date | null + withdrawnAt: Date | null +} + +// What `GET /users/me` returns: identity, profile, and the two pieces of state +// a client needs to decide what to show next — how complete the profile is and +// where onboarding stands. +export interface OwnerAccountProfileView { + account: AccountSummary + profile: LearnerProfileRecord + completion: ProfileCompletion + onboarding: OnboardingSummary | null + consents: ConsentSummary[] + requiredConsentsGranted: boolean +} + // Fields counted toward profile-completion percentage. `level` is excluded // because it always has a default value and can never read as "empty". export const PROFILE_COMPLETION_FIELDS = [ diff --git a/src/types/user.types.ts b/src/types/user.types.ts index 2ba9321b..4f13fb31 100644 --- a/src/types/user.types.ts +++ b/src/types/user.types.ts @@ -15,6 +15,16 @@ export enum UserStatus { // ── Core models ──────────────────────────────────────────── +/** + * Auth-facing user shape (see `LoginResponse` in api.types.ts). + * + * `firstName`, `lastName`, `bio` and `avatar` are **not** persisted columns — + * they are leftovers from the mock user helpers. Learner-authored profile data + * lives on `LearnerProfile` and is served by the profile API; see + * `LearnerProfileRecord` in types/profile.types.ts and + * docs/decisions/0002-learner-profile-visibility.md. Do not read them expecting + * a value, and do not add new ones here. + */ export interface User { id: string; email: string; @@ -32,16 +42,6 @@ export interface User { lastLoginAt?: Date; } -export interface PublicUserInfo { - id: string; - username: string; - firstName?: string; - lastName?: string; - avatar?: string; - role: UserRole; - createdAt: Date; -} - export interface UserProfile extends User { totalCredentials: number; totalPoints: number; @@ -59,22 +59,12 @@ export interface CreateUserData { role?: UserRole; } -export interface UpdateUserData { - username?: string; - firstName?: string; - lastName?: string; - bio?: string; - avatar?: string; -} - -export interface ChangePasswordData { - currentPassword: string; - newPassword: string; -} - -export interface UpdateWalletData { - walletAddress: string; -} +// `UpdateUserData`, `ChangePasswordData`, `UpdateWalletData` and +// `PublicUserInfo` used to live here as the input/output shapes of the mock user +// helpers. Their replacements are inferred from the Zod schemas that actually +// validate the requests — `UpdateProfileInput`, `ChangePasswordInput`, +// `UpdateWalletInput` in src/schemas/profile.schema.ts — and `PublicProfileView` +// in types/profile.types.ts, so a shape and its validation can no longer drift. export interface UpdateUserRoleData { role: UserRole; diff --git a/src/utils/audit-context.ts b/src/utils/audit-context.ts new file mode 100644 index 00000000..72cf222b --- /dev/null +++ b/src/utils/audit-context.ts @@ -0,0 +1,23 @@ +import { Request } from 'express' +import { actorFromRequest, AuditContext } from '../audit' + +/** + * Build an {@link AuditContext} from an authenticated Express request. + * + * `actorFromRequest` reads `req.actor`, which the request-context middleware + * populates — but that middleware runs before authentication (src/app.ts), so on + * an authenticated route `req.actor` is still unset while `req.user` is not. + * Falling back to `req.user` here is what keeps the actor attributable; without + * it every user-initiated audit event would be recorded as ANONYMOUS, and + * `auditedMutation` rejects a USER/ADMIN actor with no id. + */ +export function requestAuditContext(req: Request): AuditContext { + return actorFromRequest({ + actor: + req.actor ?? + (req.user ? { id: req.user.id, role: req.user.role } : undefined), + requestId: req.requestId, + ip: req.ip, + headers: req.headers as unknown as Record, + }) +} diff --git a/tests/contract/openapi.test.ts b/tests/contract/openapi.test.ts new file mode 100644 index 00000000..a33c0222 --- /dev/null +++ b/tests/contract/openapi.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { specs } from '../../src/config/swagger' + +type Spec = { + openapi: string + paths: Record> + components: { schemas: Record } +} + +const spec = specs as unknown as Spec + +/** Every `$ref` string anywhere in the document. */ +function collectRefs(node: unknown, found: string[] = []): string[] { + if (Array.isArray(node)) { + node.forEach(child => collectRefs(child, found)) + + return found + } + + if (node && typeof node === 'object') { + for (const [key, value] of Object.entries(node as Record)) { + if (key === '$ref' && typeof value === 'string') { + found.push(value) + } else { + collectRefs(value, found) + } + } + } + + return found +} + +describe('OpenAPI document', () => { + it('builds a 3.x document with paths and component schemas', () => { + expect(spec.openapi).toMatch(/^3\./) + expect(Object.keys(spec.paths).length).toBeGreaterThan(0) + expect(Object.keys(spec.components.schemas).length).toBeGreaterThan(0) + }) + + it('resolves every component $ref', () => { + const dangling = [...new Set(collectRefs(spec))] + .filter(ref => ref.startsWith('#/components/schemas/')) + .map(ref => ref.replace('#/components/schemas/', '')) + .filter(name => !(name in spec.components.schemas)) + + expect(dangling).toEqual([]) + }) + + it('documents every user account and profile operation', () => { + expect(spec.paths['/users/me']).toHaveProperty('get') + expect(spec.paths['/users/me']).toHaveProperty('patch') + expect(spec.paths['/users/{id}']).toHaveProperty('get') + expect(spec.paths['/users/password']).toHaveProperty('patch') + expect(spec.paths['/users/wallet']).toHaveProperty('patch') + expect(spec.paths['/users/me/profile']).toHaveProperty('get') + expect(spec.paths['/users/me/profile']).toHaveProperty('patch') + }) + + it('no longer defines the mock-era user schemas', () => { + // `User`, `PublicUser` and `UpdateUserInput` described the mock helpers' + // firstName/lastName/bio/avatar shape. Their replacements are + // `AccountSummary`, `LearnerProfile`, `PublicProfile` and + // `UpdateProfileInput`. + for (const removed of ['PublicUser', 'UpdateUserInput']) { + expect(spec.components.schemas).not.toHaveProperty(removed) + } + + for (const added of [ + 'AccountSummary', + 'LearnerProfile', + 'ProfileCompletion', + 'OnboardingSummary', + 'ConsentSummary', + 'OwnerAccountProfile', + 'PublicProfile', + 'UpdateProfileInput', + ]) { + expect(spec.components.schemas).toHaveProperty(added) + } + }) + + it('keeps private account fields out of the documented public profile', () => { + const publicProfile = JSON.stringify(spec.components.schemas.PublicProfile) + + for (const leak of ['email', 'password', 'walletAddress', 'isVerified', 'phoneVerifiedAt', 'status']) { + expect(publicProfile).not.toContain(leak) + } + }) + + it('marks the public profile read as unauthenticated and the owner reads as bearer-authenticated', () => { + expect((spec.paths['/users/{id}'].get as { security: unknown[] }).security).toEqual([]) + expect((spec.paths['/users/me'].get as { security: unknown[] }).security).toEqual([ + { bearerAuth: [] }, + ]) + expect((spec.paths['/users/me'].patch as { security: unknown[] }).security).toEqual([ + { bearerAuth: [] }, + ]) + }) + + it('closes the profile update body so undocumented fields cannot be sent', () => { + const updateInput = spec.components.schemas.UpdateProfileInput as { + additionalProperties: boolean + properties: Record + } + + expect(updateInput.additionalProperties).toBe(false) + + for (const forbidden of ['status', 'isVerified', 'role', 'userId', 'id', 'password', 'email']) { + expect(updateInput.properties).not.toHaveProperty(forbidden) + } + }) + + it('documents the conflict response on the wallet update', () => { + const responses = (spec.paths['/users/wallet'].patch as { responses: Record }) + .responses + + expect(responses).toHaveProperty('409') + expect(responses).toHaveProperty('401') + expect(responses).toHaveProperty('400') + }) +}) diff --git a/tests/integration/profile-api.test.ts b/tests/integration/profile-api.test.ts new file mode 100644 index 00000000..cb8bb6c3 --- /dev/null +++ b/tests/integration/profile-api.test.ts @@ -0,0 +1,716 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest' +import { Pool } from 'pg' +import request from 'supertest' +import { validateTestDatabaseUrl } from '../helpers/guard' + +/** + * Identity/profile integration suite for the Prisma-backed user routes. + * + * Everything here goes through the real Express app, real middleware, real + * JWT verification and a real database — no service is stubbed. That is the + * point: the mock helpers this feature removed were invisible to unit tests + * precisely because unit tests mock the layer below. + * + * Skipped (not failed) when no test database is reachable, matching + * tests/integration/isolation.test.ts. + */ +async function isDatabaseAvailable(): Promise { + try { + const url = validateTestDatabaseUrl() + const pool = new Pool({ connectionString: url, max: 1, connectionTimeoutMillis: 3000 }) + const client = await pool.connect() + client.release() + await pool.end() + + return true + } catch { + return false + } +} + +const dbAvailable = await isDatabaseAvailable() + +const PUBLIC_KEY_A = `G${'A'.repeat(55)}` +const PUBLIC_KEY_B = `G${'B'.repeat(55)}` + +/** + * Generous, because these are the two slow parts and neither is what is under + * test: importing the app pulls in swagger-jsdoc, the Stellar SDK and + * firebase-admin, and bcrypt hashing per fixture is deliberately expensive. + * The default 5s/10s limits turn CPU contention from a parallel run into a + * spurious failure. + */ +const HOOK_TIMEOUT_MS = 120_000 +const TEST_TIMEOUT_MS = 60_000 + +vi.setConfig({ testTimeout: TEST_TIMEOUT_MS, hookTimeout: HOOK_TIMEOUT_MS }) + +describe.runIf(dbAvailable)('Identity and profile API', () => { + let app: import('express').Application + let prisma: import('@prisma/client').PrismaClient + let issueAccessToken: (claims: { id: string; role: string }) => string + let hashPassword: (password: string) => Promise + let uniqueSuffix = 0 + + beforeAll(async () => { + ;({ default: app } = await import('../../src/app')) + ;({ prisma } = await import('../../src/config/database')) + ;({ issueAccessToken } = await import('../../src/config/jwt')) + ;({ hashPassword } = await import('../../src/utils/password')) + }, HOOK_TIMEOUT_MS) + + afterAll(async () => { + await prisma?.$disconnect() + }, HOOK_TIMEOUT_MS) + + beforeEach(async () => { + // Cascades clear profiles, onboarding, consents, sessions and audit-free + // children; audit events are append-only and are asserted by count deltas. + await prisma.user.deleteMany({}) + }, HOOK_TIMEOUT_MS) + + async function createLearner( + overrides: Partial<{ status: string; password: string; walletAddress: string | null }> = {} + ) { + uniqueSuffix += 1 + const plaintext = overrides.password ?? 'Str0ng!Pass' + + const user = await prisma.user.create({ + data: { + email: `learner_${Date.now()}_${uniqueSuffix}@example.com`, + username: `learner_${Date.now()}_${uniqueSuffix}`, + password: await hashPassword(plaintext), + role: 'LEARNER', + isVerified: true, + status: overrides.status ?? 'ACTIVE', + walletAddress: overrides.walletAddress ?? null, + }, + }) + + return { + user, + plaintext, + token: issueAccessToken({ id: user.id, role: 'learner' }), + auth: `Bearer ${issueAccessToken({ id: user.id, role: 'learner' })}`, + } + } + + async function auditEventsFor(userId: string, action: string) { + return prisma.auditEvent.findMany({ where: { targetId: userId, action } }) + } + + // ── Authentication ─────────────────────────────────────────────────────── + + describe('authentication', () => { + it('rejects GET /users/me without a token', async () => { + await request(app).get('/api/v1/users/me').expect(401) + }) + + it('rejects PATCH /users/me without a token', async () => { + await request(app).patch('/api/v1/users/me').send({ displayName: 'Ada' }).expect(401) + }) + + it('rejects an invalid token', async () => { + await request(app) + .get('/api/v1/users/me') + .set('Authorization', 'Bearer not-a-jwt') + .expect(401) + }) + + it('rejects PATCH /users/password and PATCH /users/wallet without a token', async () => { + await request(app).patch('/api/v1/users/password').send({}).expect(401) + await request(app).patch('/api/v1/users/wallet').send({}).expect(401) + }) + + it('serves GET /users/{id} to an anonymous caller', async () => { + const { user } = await createLearner() + await prisma.learnerProfile.create({ data: { userId: user.id, visibility: 'public' } }) + + await request(app).get(`/api/v1/users/${user.id}`).expect(200) + }) + }) + + // ── Owner aggregate read ───────────────────────────────────────────────── + + describe('GET /users/me', () => { + it('returns real persisted data, not a fixture', async () => { + const { user, auth } = await createLearner() + + const response = await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(200) + + expect(response.body.data.account.id).toBe(user.id) + expect(response.body.data.account.email).toBe(user.email) + expect(response.body.data.account.email).not.toBe('test@example.com') + expect(response.body.data.account.username).not.toBe('testuser') + }) + + it('never returns the password hash', async () => { + const { auth } = await createLearner() + + const response = await request(app).get('/api/v1/users/me').set('Authorization', auth) + + expect(JSON.stringify(response.body)).not.toContain('$2') + expect(response.body.data.account).not.toHaveProperty('password') + }) + + it('returns only the documented profile fields, not the archive bookkeeping', async () => { + const { auth } = await createLearner() + + const response = await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(200) + + expect(Object.keys(response.body.data.profile).sort()).toEqual([ + 'avatarUrl', 'bio', 'country', 'createdAt', 'displayName', 'goals', 'id', + 'interests', 'languages', 'level', 'timezone', 'updatedAt', 'userId', 'visibility', + ]) + }) + + it('creates the profile row on first access and reports 0% completion', async () => { + const { user, auth } = await createLearner() + + const response = await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(200) + + expect(response.body.data.completion.percent).toBe(0) + expect(response.body.data.completion.missingFields).toContain('displayName') + expect(await prisma.learnerProfile.findUnique({ where: { userId: user.id } })).not.toBeNull() + }) + + it('returns onboarding state and outstanding required steps', async () => { + const { user, auth } = await createLearner() + await prisma.onboardingProgress.create({ + data: { userId: user.id, currentStep: 'profile_basics', completedSteps: ['profile_basics'] }, + }) + + const response = await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(200) + + expect(response.body.data.onboarding.status).toBe('in_progress') + expect(response.body.data.onboarding.requiredStepsRemaining).toEqual(['consent']) + }) + + it('reports null onboarding for a learner who never started', async () => { + const { auth } = await createLearner() + + const response = await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(200) + + expect(response.body.data.onboarding).toBeNull() + }) + + it('reports required consents as granted only once both are granted', async () => { + const { user, auth } = await createLearner() + + await prisma.consentRecord.create({ + data: { + userId: user.id, + purpose: 'terms_of_service', + required: true, + policyVersion: '2026-01', + status: 'granted', + source: 'onboarding', + grantedAt: new Date(), + }, + }) + + const partial = await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(200) + expect(partial.body.data.requiredConsentsGranted).toBe(false) + + await prisma.consentRecord.create({ + data: { + userId: user.id, + purpose: 'privacy_policy', + required: true, + policyVersion: '2026-01', + status: 'granted', + source: 'onboarding', + grantedAt: new Date(), + }, + }) + + const complete = await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(200) + expect(complete.body.data.requiredConsentsGranted).toBe(true) + expect(complete.body.data.consents).toHaveLength(2) + }) + + it('returns 404 once the account row is gone', async () => { + const { user, auth } = await createLearner() + await prisma.user.delete({ where: { id: user.id } }) + + await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(404) + }) + + it('returns 404 for a tombstoned account', async () => { + const { user, auth } = await createLearner() + await prisma.user.update({ where: { id: user.id }, data: { status: 'DELETED' } }) + + await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(404) + }) + }) + + // ── Owner update ───────────────────────────────────────────────────────── + + describe('PATCH /users/me', () => { + it('persists a partial update and leaves omitted fields alone', async () => { + const { user, auth } = await createLearner() + + await request(app) + .patch('/api/v1/users/me') + .set('Authorization', auth) + .send({ displayName: 'Ada Lovelace', country: 'NG' }) + .expect(200) + + await request(app) + .patch('/api/v1/users/me') + .set('Authorization', auth) + .send({ bio: 'Building on Stellar' }) + .expect(200) + + const stored = await prisma.learnerProfile.findUnique({ where: { userId: user.id } }) + + expect(stored?.displayName).toBe('Ada Lovelace') + expect(stored?.country).toBe('NG') + expect(stored?.bio).toBe('Building on Stellar') + }) + + it('recomputes profile completion from the persisted row', async () => { + const { auth } = await createLearner() + + const response = await request(app) + .patch('/api/v1/users/me') + .set('Authorization', auth) + .send({ displayName: 'Ada', bio: 'hi', country: 'NG', timezone: 'Africa/Lagos' }) + .expect(200) + + expect(response.body.data.completion.percent).toBe(50) + expect(response.body.data.completion.missingFields).not.toContain('displayName') + }) + + it.each([ + ['status', { status: 'ACTIVE' }], + ['isVerified', { isVerified: true }], + ['role', { role: 'ADMIN' }], + ['email', { email: 'attacker@example.com' }], + ['password', { password: 'Hacked1!pass' }], + ['walletAddress', { walletAddress: PUBLIC_KEY_A }], + ['userId', { userId: '00000000-0000-4000-8000-000000000000' }], + ])('rejects %s and changes nothing', async (_field, body) => { + const { user, auth } = await createLearner() + const before = await prisma.user.findUnique({ where: { id: user.id } }) + + await request(app).patch('/api/v1/users/me').set('Authorization', auth).send(body).expect(400) + + expect(await prisma.user.findUnique({ where: { id: user.id } })).toEqual(before) + }) + + it('rejects an empty body', async () => { + const { auth } = await createLearner() + + await request(app).patch('/api/v1/users/me').set('Authorization', auth).send({}).expect(400) + }) + + it('rejects an out-of-range value without writing a partial update', async () => { + const { user, auth } = await createLearner() + + await request(app) + .patch('/api/v1/users/me') + .set('Authorization', auth) + .send({ displayName: 'Ada', level: 'wizard' }) + .expect(400) + + expect(await prisma.learnerProfile.findUnique({ where: { userId: user.id } })).toBeNull() + }) + + it('cannot touch another learner’s profile', async () => { + const owner = await createLearner() + const victim = await createLearner() + await prisma.learnerProfile.create({ + data: { userId: victim.user.id, displayName: 'Victim' }, + }) + + await request(app) + .patch('/api/v1/users/me') + .set('Authorization', owner.auth) + .send({ displayName: 'Attacker' }) + .expect(200) + + const victimProfile = await prisma.learnerProfile.findUnique({ + where: { userId: victim.user.id }, + }) + + expect(victimProfile?.displayName).toBe('Victim') + }) + + it('writes an audit event naming the changed fields but not their values', async () => { + const { user, auth } = await createLearner() + + await request(app) + .patch('/api/v1/users/me') + .set('Authorization', auth) + .set('x-request-id', 'itest-profile-update') + .send({ displayName: 'Ada Lovelace', bio: 'private medical history' }) + .expect(200) + + const profile = await prisma.learnerProfile.findUnique({ where: { userId: user.id } }) + const events = await auditEventsFor(profile!.id, 'learner_profile.updated') + + expect(events).toHaveLength(1) + expect(events[0].actorType).toBe('USER') + expect(events[0].actorId).toBe(user.id) + expect(events[0].targetType).toBe('LearnerProfile') + expect(events[0].requestId).toBe('itest-profile-update') + expect(events[0].metadata).toContain('displayName') + expect(events[0].metadata).not.toContain('Lovelace') + expect(events[0].metadata).not.toContain('medical history') + }) + + it('writes no audit event for a rejected update', async () => { + const { auth } = await createLearner() + const before = await prisma.auditEvent.count() + + await request(app) + .patch('/api/v1/users/me') + .set('Authorization', auth) + .send({ role: 'ADMIN' }) + .expect(400) + + expect(await prisma.auditEvent.count()).toBe(before) + }) + }) + + // ── Public, consent-aware read ─────────────────────────────────────────── + + describe('GET /users/{id}', () => { + it('rejects a non-uuid id', async () => { + await request(app).get('/api/v1/users/not-a-uuid').expect(400) + }) + + it('returns 404 for an unknown learner', async () => { + await request(app).get('/api/v1/users/00000000-0000-4000-8000-000000000000').expect(404) + }) + + it('serves the public subset for a public profile', async () => { + const { user } = await createLearner() + await prisma.learnerProfile.create({ + data: { + userId: user.id, + displayName: 'Ada', + bio: 'Building on Stellar', + country: 'NG', + timezone: 'Africa/Lagos', + languages: ['en'], + goals: ['ship'], + interests: ['stellar'], + visibility: 'public', + }, + }) + + const response = await request(app).get(`/api/v1/users/${user.id}`).expect(200) + + expect(response.body.data).toMatchObject({ visible: true, displayName: 'Ada', country: 'NG' }) + expect(response.body.data).not.toHaveProperty('goals') + expect(response.body.data).not.toHaveProperty('timezone') + expect(response.body.data).not.toHaveProperty('languages') + }) + + it('never leaks private account data', async () => { + const { user } = await createLearner({ walletAddress: PUBLIC_KEY_A }) + await prisma.learnerProfile.create({ + data: { userId: user.id, displayName: 'Ada', visibility: 'public' }, + }) + + const response = await request(app).get(`/api/v1/users/${user.id}`).expect(200) + const body = JSON.stringify(response.body) + + expect(body).not.toContain(user.email) + expect(body).not.toContain(user.username) + expect(body).not.toContain(PUBLIC_KEY_A) + expect(body).not.toContain('$2') + expect(response.body.data).not.toHaveProperty('userId') + expect(response.body.data).not.toHaveProperty('status') + expect(response.body.data).not.toHaveProperty('isVerified') + expect(response.body.data).not.toHaveProperty('phoneVerifiedAt') + }) + + it('redacts a private profile', async () => { + const { user } = await createLearner() + const profile = await prisma.learnerProfile.create({ + data: { userId: user.id, displayName: 'Ada', visibility: 'private' }, + }) + + const response = await request(app).get(`/api/v1/users/${user.id}`).expect(200) + + expect(response.body.data).toEqual({ id: profile.id, visible: false }) + }) + + it('redacts an employer-only profile from the public route', async () => { + const { user } = await createLearner() + await prisma.learnerProfile.create({ + data: { userId: user.id, displayName: 'Ada', visibility: 'employer' }, + }) + + const response = await request(app).get(`/api/v1/users/${user.id}`).expect(200) + + expect(response.body.data.visible).toBe(false) + expect(response.body.data).not.toHaveProperty('displayName') + }) + + it('redacts a public profile once data-sharing consent is withdrawn', async () => { + const { user } = await createLearner() + await prisma.learnerProfile.create({ + data: { userId: user.id, displayName: 'Ada', visibility: 'public' }, + }) + await prisma.consentRecord.create({ + data: { + userId: user.id, + purpose: 'data_sharing', + required: false, + policyVersion: '2026-01', + status: 'withdrawn', + source: 'settings', + withdrawnAt: new Date(), + }, + }) + + const response = await request(app).get(`/api/v1/users/${user.id}`).expect(200) + + expect(response.body.data.visible).toBe(false) + }) + + it('still discloses a public profile while data-sharing consent is granted', async () => { + const { user } = await createLearner() + await prisma.learnerProfile.create({ + data: { userId: user.id, displayName: 'Ada', visibility: 'public' }, + }) + await prisma.consentRecord.create({ + data: { + userId: user.id, + purpose: 'data_sharing', + required: false, + policyVersion: '2026-01', + status: 'granted', + source: 'settings', + grantedAt: new Date(), + }, + }) + + const response = await request(app).get(`/api/v1/users/${user.id}`).expect(200) + + expect(response.body.data.visible).toBe(true) + }) + + it.each(['DEACTIVATED', 'PENDING_DELETION'])( + 'redacts a public profile for a %s account', + async status => { + const { user } = await createLearner() + await prisma.learnerProfile.create({ + data: { userId: user.id, displayName: 'Ada', visibility: 'public' }, + }) + await prisma.user.update({ where: { id: user.id }, data: { status } }) + + const response = await request(app).get(`/api/v1/users/${user.id}`).expect(200) + + expect(response.body.data.visible).toBe(false) + } + ) + + it('excludes an archived profile entirely', async () => { + const { user } = await createLearner() + await prisma.learnerProfile.create({ + data: { + userId: user.id, + displayName: 'Ada', + visibility: 'public', + archivedAt: new Date(), + archivedReason: 'account deactivated', + }, + }) + + await request(app).get(`/api/v1/users/${user.id}`).expect(404) + }) + + it('serves the owner the same public view as anyone else', async () => { + const { user, auth } = await createLearner() + await prisma.learnerProfile.create({ + data: { userId: user.id, displayName: 'Ada', visibility: 'private' }, + }) + + const asOwner = await request(app) + .get(`/api/v1/users/${user.id}`) + .set('Authorization', auth) + .expect(200) + const anonymous = await request(app).get(`/api/v1/users/${user.id}`).expect(200) + + expect(asOwner.body).toEqual(anonymous.body) + expect(asOwner.body.data.visible).toBe(false) + }) + }) + + // ── Password change ────────────────────────────────────────────────────── + + describe('PATCH /users/password', () => { + it('rejects a wrong current password with 401 and leaves the hash intact', async () => { + const { user, auth } = await createLearner() + const before = await prisma.user.findUnique({ where: { id: user.id } }) + + await request(app) + .patch('/api/v1/users/password') + .set('Authorization', auth) + .send({ currentPassword: 'WrongPass1!', newPassword: 'Another1!Pass' }) + .expect(401) + + const after = await prisma.user.findUnique({ where: { id: user.id } }) + expect(after?.password).toBe(before?.password) + }) + + it('rejects a weak new password', async () => { + const { auth, plaintext } = await createLearner() + + await request(app) + .patch('/api/v1/users/password') + .set('Authorization', auth) + .send({ currentPassword: plaintext, newPassword: 'weak' }) + .expect(400) + }) + + it('rejects reusing the current password', async () => { + const { auth, plaintext } = await createLearner() + + await request(app) + .patch('/api/v1/users/password') + .set('Authorization', auth) + .send({ currentPassword: plaintext, newPassword: plaintext }) + .expect(400) + }) + + it('stores a new hash and revokes every live session', async () => { + const { user, auth, plaintext } = await createLearner() + const before = await prisma.user.findUnique({ where: { id: user.id } }) + + const session = await prisma.session.create({ + data: { + userId: user.id, + token: `tok_${user.id}`, + expiresAt: new Date(Date.now() + 3_600_000), + }, + }) + await prisma.refreshToken.create({ + data: { + sessionId: session.id, + familyId: 'fam-1', + tokenHash: `hash_${user.id}`, + expiresAt: new Date(Date.now() + 3_600_000), + }, + }) + + const response = await request(app) + .patch('/api/v1/users/password') + .set('Authorization', auth) + .send({ currentPassword: plaintext, newPassword: 'Another1!Pass' }) + .expect(200) + + expect(response.body.revokedSessionCount).toBe(1) + expect(JSON.stringify(response.body)).not.toContain('Another1!Pass') + + const after = await prisma.user.findUnique({ where: { id: user.id } }) + expect(after?.password).not.toBe(before?.password) + expect(after?.password).not.toBe('Another1!Pass') + + expect((await prisma.session.findUnique({ where: { id: session.id } }))?.isRevoked).toBe(true) + expect( + await prisma.refreshToken.count({ where: { sessionId: session.id, status: 'REVOKED' } }) + ).toBe(1) + }) + + it('audits the change without recording either password', async () => { + const { user, auth, plaintext } = await createLearner() + + await request(app) + .patch('/api/v1/users/password') + .set('Authorization', auth) + .send({ currentPassword: plaintext, newPassword: 'Another1!Pass' }) + .expect(200) + + const events = await auditEventsFor(user.id, 'user.password_changed') + + expect(events).toHaveLength(1) + expect(events[0].actorId).toBe(user.id) + expect(events[0].metadata).toContain('revokedSessionCount') + expect(JSON.stringify(events[0])).not.toContain(plaintext) + expect(JSON.stringify(events[0])).not.toContain('Another1!Pass') + }) + }) + + // ── Wallet address ─────────────────────────────────────────────────────── + + describe('PATCH /users/wallet', () => { + it('rejects a malformed address', async () => { + const { auth } = await createLearner() + + await request(app) + .patch('/api/v1/users/wallet') + .set('Authorization', auth) + .send({ walletAddress: 'invalid-address' }) + .expect(400) + }) + + it('rejects a secret seed in the address field', async () => { + const { auth } = await createLearner() + + await request(app) + .patch('/api/v1/users/wallet') + .set('Authorization', auth) + .send({ walletAddress: `S${'A'.repeat(55)}` }) + .expect(400) + }) + + it('persists a valid address and audits it', async () => { + const { user, auth } = await createLearner() + + await request(app) + .patch('/api/v1/users/wallet') + .set('Authorization', auth) + .send({ walletAddress: PUBLIC_KEY_A }) + .expect(200) + + expect((await prisma.user.findUnique({ where: { id: user.id } }))?.walletAddress).toBe( + PUBLIC_KEY_A + ) + expect(await auditEventsFor(user.id, 'user.wallet_address_changed')).toHaveLength(1) + }) + + it('is idempotent and writes no second audit event', async () => { + const { user, auth } = await createLearner({ walletAddress: PUBLIC_KEY_A }) + + const response = await request(app) + .patch('/api/v1/users/wallet') + .set('Authorization', auth) + .send({ walletAddress: PUBLIC_KEY_A }) + .expect(200) + + expect(response.body.message).toBe('Wallet address unchanged') + expect(await auditEventsFor(user.id, 'user.wallet_address_changed')).toHaveLength(0) + }) + + it('returns 409 when the address is already claimed by another account', async () => { + await createLearner({ walletAddress: PUBLIC_KEY_B }) + const { user, auth } = await createLearner() + + const response = await request(app) + .patch('/api/v1/users/wallet') + .set('Authorization', auth) + .send({ walletAddress: PUBLIC_KEY_B }) + .expect(409) + + expect(response.body.code).toBe('WALLET_ADDRESS_TAKEN') + expect((await prisma.user.findUnique({ where: { id: user.id } }))?.walletAddress).toBeNull() + }) + + it('returns 404 for a tombstoned account', async () => { + const { user, auth } = await createLearner() + await prisma.user.update({ where: { id: user.id }, data: { status: 'DELETED' } }) + + await request(app) + .patch('/api/v1/users/wallet') + .set('Authorization', auth) + .send({ walletAddress: PUBLIC_KEY_A }) + .expect(404) + }) + }) +}) diff --git a/tests/mock-user-scan.test.ts b/tests/mock-user-scan.test.ts new file mode 100644 index 00000000..4cad3df6 --- /dev/null +++ b/tests/mock-user-scan.test.ts @@ -0,0 +1,99 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +/** + * Static scan: the acceptance criterion "no production user route returns + * hard-coded users" is a property of the source, not of any one request, so it + * is asserted against the source. + * + * These are the files the mock helpers lived in or fed. A future change that + * reintroduces a hard-coded user — as a quick unblock, a demo fixture, a + * placeholder while a service is written — fails here rather than shipping. + */ +const USER_ROUTE_SOURCES = [ + 'src/controllers/user.controller.ts', + 'src/controllers/profile.controller.ts', + 'src/routes/v1/users.routes.ts', + 'src/services/profile.service.ts', + 'src/services/user-account.service.ts', + 'src/services/profile-serializer.ts', + 'src/schemas/profile.schema.ts', +] + +/** The helper names the mock implementations were reached through. */ +const REMOVED_MOCK_HELPERS = [ + 'findUserById', + 'updateUserProfile', + 'validatePassword', + 'updateUserPassword', + 'updateUserWallet', + 'isValidStellarAddress', +] + +function source(file: string): string { + return readFileSync(file, 'utf8') +} + +describe('mock user helper scan', () => { + it.each(USER_ROUTE_SOURCES)('%s declares no mock user literal', file => { + // `mockUser`, `const mock…= {`, and the sentinel values the old helpers + // returned. Comments naming the removed mocks are fine; a literal is not. + expect(source(file)).not.toMatch(/\bmockUser\b/) + expect(source(file)).not.toMatch(/test@example\.com/) + expect(source(file)).not.toMatch(/\btestuser\b/) + expect(source(file)).not.toMatch(/GABC123456789/) + }) + + it.each(USER_ROUTE_SOURCES)('%s contains no not-implemented stub', file => { + expect(source(file)).not.toMatch(/Not implemented/i) + expect(source(file)).not.toMatch(/throw new Error\(\s*['"`]TODO/i) + }) + + it.each(REMOVED_MOCK_HELPERS)('the %s helper is gone from the user controller', helper => { + expect(source('src/controllers/user.controller.ts')).not.toMatch( + new RegExp(`(private|async)\\s+${helper}\\s*\\(`) + ) + }) + + it('reads users through Prisma-backed services rather than in-controller literals', () => { + const controller = source('src/controllers/user.controller.ts') + + expect(controller).toMatch(/profileService/) + expect(controller).toMatch(/userAccountService/) + // Nothing in the controller may talk to Prisma directly either: the point of + // the services is that the persistence and its audit stay in one place. + expect(controller).not.toMatch(/from '\.\.\/config\/database'/) + }) + + it('no longer advertises the password and wallet routes as unimplemented previews', () => { + const swagger = source('src/config/swagger.ts') + const controller = source('src/controllers/user.controller.ts') + + expect(swagger).not.toMatch(/not-yet-implemented/i) + expect(controller).not.toMatch(/⚠️ \*\*Preview\*\*/) + }) + + it('keeps the deprecated mock-era validation middleware off every route', () => { + // `validateProfileUpdate` validates firstName/lastName/bio/avatar, none of + // which is a persisted column. It stays exported for its existing tests but + // must not be mounted — so the check is on the import, not on a prose + // mention of the name in a comment explaining why it is absent. + const routeFiles = ['src/routes/v1/users.routes.ts', 'src/routes/v1/avatar.routes.ts'] + + for (const file of routeFiles) { + const codeLines = source(file) + .split('\n') + .filter(line => !line.trim().startsWith('//')) + + expect(codeLines.join('\n')).not.toMatch(/validateProfileUpdate/) + } + }) + + it('never selects the password column into a profile or account read', () => { + const services = ['src/services/profile.service.ts', 'src/services/profile-serializer.ts'] + + for (const file of services) { + expect(source(file)).not.toMatch(/password:\s*true/) + } + }) +}) diff --git a/tests/profile-serializer.test.ts b/tests/profile-serializer.test.ts index 4bb68fd8..93556861 100644 --- a/tests/profile-serializer.test.ts +++ b/tests/profile-serializer.test.ts @@ -1,12 +1,19 @@ import { describe, it, expect } from 'vitest' import { computeProfileCompletion, + isDisclosureAllowed, + redactedProfile, + toAccountSummary, + toConsentSummary, toEmployerProfile, + toOnboardingSummary, + toOwnerAccountProfile, toOwnerProfile, toPrivateProfile, + toProfileRecord, toPublicProfile, } from '../src/services/profile-serializer' -import { LearnerProfileRecord } from '../src/types/profile.types' +import { AccountSummary, LearnerProfileRecord } from '../src/types/profile.types' const baseProfile: LearnerProfileRecord = { id: 'profile1', @@ -75,6 +82,37 @@ describe('toOwnerProfile', () => { expect(view.bio).toBe('Building on Stellar') expect(view.completion.percent).toBe(100) }) + + it('leaves the archive bookkeeping columns out of the response', () => { + // The Prisma row carries these; the documented profile contract does not. + const loaded = { + ...baseProfile, + archivedAt: null, + archivedById: null, + archivedReason: null, + } as unknown as LearnerProfileRecord + + const view = toOwnerProfile(loaded) as Record + + expect(view).not.toHaveProperty('archivedAt') + expect(view).not.toHaveProperty('archivedById') + expect(view).not.toHaveProperty('archivedReason') + }) +}) + +describe('toProfileRecord', () => { + it('returns exactly the declared profile fields', () => { + const loaded = { + ...baseProfile, + archivedAt: new Date(), + archivedReason: 'moderation', + password: 'should never be here', + } as unknown as LearnerProfileRecord + + expect(Object.keys(toProfileRecord(loaded)).sort()).toEqual( + Object.keys(baseProfile).sort() + ) + }) }) describe('toEmployerProfile', () => { @@ -151,3 +189,213 @@ describe('toPrivateProfile', () => { expect(view.isVerified).toBe(true) }) }) + +// ── Consent-aware disclosure gate ────────────────────────────────────────── + +describe('isDisclosureAllowed', () => { + it('allows disclosure for an active account with no data-sharing record', () => { + expect(isDisclosureAllowed({ accountStatus: 'ACTIVE', consents: [] })).toBe(true) + }) + + it('allows disclosure while data-sharing consent is granted', () => { + expect( + isDisclosureAllowed({ + accountStatus: 'ACTIVE', + consents: [{ purpose: 'data_sharing', status: 'granted' }], + }) + ).toBe(true) + }) + + it('refuses disclosure once data-sharing consent is withdrawn', () => { + expect( + isDisclosureAllowed({ + accountStatus: 'ACTIVE', + consents: [{ purpose: 'data_sharing', status: 'withdrawn' }], + }) + ).toBe(false) + }) + + it('ignores withdrawal of an unrelated purpose', () => { + expect( + isDisclosureAllowed({ + accountStatus: 'ACTIVE', + consents: [{ purpose: 'marketing_emails', status: 'withdrawn' }], + }) + ).toBe(true) + }) + + it.each(['DEACTIVATED', 'PENDING_DELETION', 'DELETED'])( + 'refuses disclosure for a %s account even with consent granted', + status => { + expect( + isDisclosureAllowed({ + accountStatus: status, + consents: [{ purpose: 'data_sharing', status: 'granted' }], + }) + ).toBe(false) + } + ) +}) + +describe('redactedProfile', () => { + it('is indistinguishable from a below-threshold redaction, so a refusal leaks nothing', () => { + const belowThreshold = toPublicProfile({ ...baseProfile, visibility: 'private' }) + + expect(redactedProfile(baseProfile.id)).toEqual(belowThreshold) + }) +}) + +// ── Owner account/profile aggregate ──────────────────────────────────────── + +const baseAccount: AccountSummary = { + id: 'user1', + email: 'ada@example.com', + username: 'ada', + role: 'LEARNER', + status: 'ACTIVE', + isVerified: true, + phoneVerifiedAt: null, + walletAddress: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-02'), + lastLoginAt: null, +} + +describe('toAccountSummary', () => { + it('never carries the password column, even when it is present on the input', () => { + const withSecret = { ...baseAccount, password: '$2b$12$hash' } as unknown as AccountSummary + + const view = toAccountSummary(withSecret) as Record + + expect(view).not.toHaveProperty('password') + expect(view.email).toBe('ada@example.com') + }) + + it('drops any column added to User that has not been opted in', () => { + const withNewColumn = { + ...baseAccount, + internalRiskScore: 99, + } as unknown as AccountSummary + + expect(toAccountSummary(withNewColumn)).not.toHaveProperty('internalRiskScore') + }) +}) + +describe('toOnboardingSummary', () => { + const progress = { + version: 'v1', + status: 'in_progress', + currentStep: 'consent', + completedSteps: ['profile_basics'], + startedAt: new Date('2026-01-01'), + completedAt: null, + } + + it('computes the required steps still outstanding', () => { + expect(toOnboardingSummary(progress).requiredStepsRemaining).toEqual(['consent']) + }) + + it('reports nothing outstanding once every required step is done', () => { + const summary = toOnboardingSummary({ + ...progress, + completedSteps: ['profile_basics', 'consent'], + }) + + expect(summary.requiredStepsRemaining).toEqual([]) + }) + + it('copies completedSteps rather than aliasing the record', () => { + const summary = toOnboardingSummary(progress) + summary.completedSteps.push('preferences') + + expect(progress.completedSteps).toEqual(['profile_basics']) + }) +}) + +describe('toConsentSummary', () => { + it('drops the row identifiers and keeps only current state', () => { + const view = toConsentSummary({ + purpose: 'privacy_policy', + status: 'granted', + required: true, + policyVersion: '2026-01', + grantedAt: new Date('2026-01-01'), + withdrawnAt: null, + }) as Record + + expect(view).toEqual({ + purpose: 'privacy_policy', + status: 'granted', + required: true, + policyVersion: '2026-01', + grantedAt: new Date('2026-01-01'), + withdrawnAt: null, + }) + expect(view).not.toHaveProperty('id') + expect(view).not.toHaveProperty('userId') + expect(view).not.toHaveProperty('source') + }) +}) + +describe('toOwnerAccountProfile', () => { + const aggregate = () => + toOwnerAccountProfile({ + account: baseAccount, + profile: baseProfile, + onboarding: { + version: 'v1', + status: 'in_progress', + currentStep: 'consent', + completedSteps: ['profile_basics'], + startedAt: new Date('2026-01-01'), + completedAt: null, + }, + consents: [ + { + purpose: 'terms_of_service', + status: 'granted', + required: true, + policyVersion: '2026-01', + grantedAt: new Date('2026-01-01'), + withdrawnAt: null, + }, + ], + requiredConsentsGranted: false, + }) + + it('returns profile completion alongside the profile', () => { + expect(aggregate().completion).toEqual({ percent: 100, missingFields: [] }) + }) + + it('returns onboarding state', () => { + expect(aggregate().onboarding).toMatchObject({ + status: 'in_progress', + currentStep: 'consent', + requiredStepsRemaining: ['consent'], + }) + }) + + it('reports null onboarding for a learner who never started', () => { + const view = toOwnerAccountProfile({ + account: baseAccount, + profile: baseProfile, + onboarding: null, + consents: [], + requiredConsentsGranted: false, + }) + + expect(view.onboarding).toBeNull() + }) + + it('never carries the password column', () => { + const view = toOwnerAccountProfile({ + account: { ...baseAccount, password: 'secret' } as unknown as AccountSummary, + profile: baseProfile, + onboarding: null, + consents: [], + requiredConsentsGranted: true, + }) + + expect(JSON.stringify(view)).not.toContain('secret') + }) +}) diff --git a/tests/profile.controller.test.ts b/tests/profile.controller.test.ts index fd6ca38f..62e8ba4a 100644 --- a/tests/profile.controller.test.ts +++ b/tests/profile.controller.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { ProfileController } from '../src/controllers/profile.controller' -const { mockGetOwnerView, mockUpdateProfile, mockGetEmployerView, mockGetPublicView } = vi.hoisted(() => ({ +const { mockGetOwnerView, mockUpdateProfileAudited, mockGetEmployerView, mockGetPublicView } = vi.hoisted(() => ({ mockGetOwnerView: vi.fn(), - mockUpdateProfile: vi.fn(), + mockUpdateProfileAudited: vi.fn(), mockGetEmployerView: vi.fn(), mockGetPublicView: vi.fn(), })) @@ -11,7 +11,7 @@ const { mockGetOwnerView, mockUpdateProfile, mockGetEmployerView, mockGetPublicV vi.mock('../src/services/profile.service', () => ({ ProfileService: class { getOwnerView = mockGetOwnerView - updateProfile = mockUpdateProfile + updateProfileAudited = mockUpdateProfileAudited getEmployerView = mockGetEmployerView getPublicView = mockGetPublicView }, @@ -25,7 +25,7 @@ describe('ProfileController', () => { beforeEach(() => { vi.clearAllMocks() controller = new ProfileController() - req = { user: { id: 'user1' }, body: {}, params: {} } + req = { user: { id: 'user1', role: 'learner' }, body: {}, params: {}, headers: {}, requestId: 'req-1', ip: '203.0.113.7' } res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), @@ -103,18 +103,49 @@ describe('ProfileController', () => { it('accepts a partial update', async () => { req.body = { displayName: 'Ada' } - mockUpdateProfile.mockResolvedValue({ id: 'profile1', displayName: 'Ada' }) + mockUpdateProfileAudited.mockResolvedValue({ id: 'profile1', displayName: 'Ada' }) mockGetOwnerView.mockResolvedValue({ id: 'profile1', displayName: 'Ada' }) await controller.updateMyProfile(req, res) - expect(mockUpdateProfile).toHaveBeenCalledWith('user1', { displayName: 'Ada' }) + expect(mockUpdateProfileAudited).toHaveBeenCalledWith( + 'user1', + { displayName: 'Ada' }, + expect.anything() + ) expect(res.status).toHaveBeenCalledWith(200) }) + it('writes through the audited path, attributed to the owner', async () => { + req.body = { displayName: 'Ada' } + mockUpdateProfileAudited.mockResolvedValue({ id: 'profile1' }) + mockGetOwnerView.mockResolvedValue({ id: 'profile1' }) + + await controller.updateMyProfile(req, res) + + const context = mockUpdateProfileAudited.mock.calls[0][2] + + expect(context.actor).toMatchObject({ type: 'USER', id: 'user1' }) + expect(context.requestId).toBe('req-1') + }) + + it.each([ + ['status', { status: 'ACTIVE' }], + ['role', { role: 'ADMIN' }], + ['isVerified', { isVerified: true }], + ['userId', { userId: 'someone-else' }], + ])('rejects the account field %s', async (_field, body) => { + req.body = body + + await controller.updateMyProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + expect(mockUpdateProfileAudited).not.toHaveBeenCalled() + }) + it('returns 500 on unexpected error', async () => { req.body = { displayName: 'Ada' } - mockUpdateProfile.mockRejectedValue(new Error('db down')) + mockUpdateProfileAudited.mockRejectedValue(new Error('db down')) await controller.updateMyProfile(req, res) diff --git a/tests/profile.service.test.ts b/tests/profile.service.test.ts index 4f2bdfc2..f229a9d8 100644 --- a/tests/profile.service.test.ts +++ b/tests/profile.service.test.ts @@ -1,10 +1,24 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { ProfileService } from '../src/services/profile.service' - -const { mockUpsert, mockFindUnique, mockUserFindUnique } = vi.hoisted(() => ({ +import { ActorType } from '../src/audit/types' + +const { + mockUpsert, + mockFindUnique, + mockFindFirst, + mockUserFindUnique, + mockUserFindFirst, + mockOnboardingFindUnique, + mockConsentFindMany, + mockTransaction, +} = vi.hoisted(() => ({ mockUpsert: vi.fn(), mockFindUnique: vi.fn(), + mockFindFirst: vi.fn(), mockUserFindUnique: vi.fn(), + mockUserFindFirst: vi.fn(), + mockOnboardingFindUnique: vi.fn(), + mockConsentFindMany: vi.fn(), + mockTransaction: vi.fn(), })) vi.mock('../src/config/database', () => ({ @@ -12,13 +26,28 @@ vi.mock('../src/config/database', () => ({ learnerProfile: { upsert: mockUpsert, findUnique: mockFindUnique, + findFirst: mockFindFirst, }, user: { findUnique: mockUserFindUnique, + findFirst: mockUserFindFirst, + }, + onboardingProgress: { + findUnique: mockOnboardingFindUnique, + }, + consentRecord: { + findMany: mockConsentFindMany, }, + $transaction: mockTransaction, }, })) +vi.mock('../src/utils/logger', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})) + +import { ProfileService } from '../src/services/profile.service' + const baseProfile = { id: 'profile1', userId: 'user1', @@ -36,6 +65,59 @@ const baseProfile = { updatedAt: new Date(), } +const baseAccount = { + id: 'user1', + email: 'ada@example.com', + username: 'ada', + role: 'LEARNER', + status: 'ACTIVE', + isVerified: true, + phoneVerifiedAt: null, + walletAddress: null, + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: null, +} + +const ownerContext = { + actor: { type: ActorType.USER, id: 'user1', role: 'LEARNER' }, + requestId: 'req-1', + ipAddress: '203.0.113.7', + userAgent: 'curl/8.0', +} + +/** + * A stand-in transaction client, so a test can assert the audit row was written + * inside the same transaction as the profile write. + */ +function fakeTransaction(result: unknown = baseProfile) { + const calls: string[] = [] + const auditCreate = vi.fn(async () => { + calls.push('audit') + + return {} + }) + const profileUpsert = vi.fn(async () => { + calls.push('mutate') + + return result + }) + + mockTransaction.mockImplementation( + async (callback: (client: unknown) => Promise) => + callback({ + auditEvent: { create: auditCreate }, + learnerProfile: { upsert: profileUpsert }, + }) + ) + + return { calls, auditCreate, profileUpsert } +} + +function auditRow(auditCreate: ReturnType): Record { + return (auditCreate.mock.calls[0][0] as { data: Record }).data +} + describe('ProfileService', () => { let service: ProfileService @@ -74,6 +156,79 @@ describe('ProfileService', () => { }) }) + describe('updateProfileAudited', () => { + it('writes the profile change and its audit event in one transaction', async () => { + const { calls, auditCreate, profileUpsert } = fakeTransaction() + + await service.updateProfileAudited('user1', { displayName: 'Ada' }, ownerContext) + + expect(mockTransaction).toHaveBeenCalledOnce() + expect(profileUpsert).toHaveBeenCalledWith({ + where: { userId: 'user1' }, + update: { displayName: 'Ada' }, + create: { userId: 'user1', displayName: 'Ada' }, + }) + // Order matters: an audit row written after the transaction commits is a + // trail with holes in it. + expect(calls).toEqual(['mutate', 'audit']) + expect(auditCreate).toHaveBeenCalledOnce() + }) + + it('attributes the event to the owner and the affected profile row', async () => { + const { auditCreate } = fakeTransaction() + + await service.updateProfileAudited('user1', { displayName: 'Ada' }, ownerContext) + + expect(auditRow(auditCreate)).toMatchObject({ + action: 'learner_profile.updated', + actorType: ActorType.USER, + actorId: 'user1', + targetType: 'LearnerProfile', + targetId: 'profile1', + requestId: 'req-1', + source: 'api.users.update_profile', + }) + }) + + it('records which fields changed but never their values', async () => { + const { auditCreate } = fakeTransaction() + + await service.updateProfileAudited( + 'user1', + { bio: 'my private medical history', displayName: 'Ada Lovelace' }, + ownerContext + ) + + const metadata = auditRow(auditCreate).metadata as string + + expect(metadata).toContain('bio') + expect(metadata).toContain('displayName') + expect(metadata).not.toContain('medical history') + expect(metadata).not.toContain('Lovelace') + }) + + it('propagates a failed audit write, so the profile change cannot land alone', async () => { + mockTransaction.mockRejectedValue(new Error('audit trail unavailable')) + + await expect( + service.updateProfileAudited('user1', { displayName: 'Ada' }, ownerContext) + ).rejects.toThrow('audit trail unavailable') + }) + + it('refuses an unattributable owner change rather than writing it anonymously', async () => { + fakeTransaction() + + await expect( + service.updateProfileAudited( + 'user1', + { displayName: 'Ada' }, + { ...ownerContext, actor: { type: ActorType.USER } } + ) + ).rejects.toThrow(/unattributable/) + expect(mockTransaction).not.toHaveBeenCalled() + }) + }) + describe('getOwnerView', () => { it('returns the full profile with a completion summary', async () => { mockUpsert.mockResolvedValue(baseProfile) @@ -85,41 +240,185 @@ describe('ProfileService', () => { }) }) + describe('getOwnerAccountProfile', () => { + beforeEach(() => { + mockUpsert.mockResolvedValue(baseProfile) + mockOnboardingFindUnique.mockResolvedValue(null) + mockConsentFindMany.mockResolvedValue([]) + }) + + it('returns null for an unknown account', async () => { + mockUserFindUnique.mockResolvedValue(null) + + expect(await service.getOwnerAccountProfile('missing')).toBeNull() + }) + + it('returns null for a tombstoned account instead of materialising a profile', async () => { + mockUserFindUnique.mockResolvedValue({ ...baseAccount, status: 'DELETED' }) + + expect(await service.getOwnerAccountProfile('user1')).toBeNull() + expect(mockUpsert).not.toHaveBeenCalled() + }) + + it('never selects the password column', async () => { + mockUserFindUnique.mockResolvedValue(baseAccount) + + await service.getOwnerAccountProfile('user1') + + const select = mockUserFindUnique.mock.calls[0][0].select + + expect(select).not.toHaveProperty('password') + expect(select.email).toBe(true) + }) + + it('aggregates account, profile, completion, onboarding and consents', async () => { + mockUserFindUnique.mockResolvedValue(baseAccount) + mockOnboardingFindUnique.mockResolvedValue({ + version: 'v1', + status: 'in_progress', + currentStep: 'consent', + completedSteps: ['profile_basics'], + startedAt: new Date(), + completedAt: null, + }) + mockConsentFindMany.mockResolvedValue([ + { + purpose: 'terms_of_service', + status: 'granted', + required: true, + policyVersion: '2026-01', + grantedAt: new Date(), + withdrawnAt: null, + }, + ]) + + const result = await service.getOwnerAccountProfile('user1') + + expect(result?.account.email).toBe('ada@example.com') + expect(result?.profile.displayName).toBe('Ada') + expect(result?.completion.percent).toBeTypeOf('number') + expect(result?.onboarding?.requiredStepsRemaining).toEqual(['consent']) + expect(result?.consents).toHaveLength(1) + }) + + it('reports required consents as granted only when every required purpose is granted', async () => { + mockUserFindUnique.mockResolvedValue(baseAccount) + mockConsentFindMany.mockResolvedValue([ + { purpose: 'terms_of_service', status: 'granted', required: true, policyVersion: '1', grantedAt: new Date(), withdrawnAt: null }, + { purpose: 'privacy_policy', status: 'withdrawn', required: true, policyVersion: '1', grantedAt: null, withdrawnAt: new Date() }, + ]) + + const partial = await service.getOwnerAccountProfile('user1') + expect(partial?.requiredConsentsGranted).toBe(false) + + mockConsentFindMany.mockResolvedValue([ + { purpose: 'terms_of_service', status: 'granted', required: true, policyVersion: '1', grantedAt: new Date(), withdrawnAt: null }, + { purpose: 'privacy_policy', status: 'granted', required: true, policyVersion: '1', grantedAt: new Date(), withdrawnAt: null }, + ]) + + const complete = await service.getOwnerAccountProfile('user1') + expect(complete?.requiredConsentsGranted).toBe(true) + }) + }) + describe('getEmployerView', () => { + beforeEach(() => { + mockConsentFindMany.mockResolvedValue([]) + mockUserFindUnique.mockResolvedValue({ status: 'ACTIVE' }) + }) + it('returns null when the profile does not exist', async () => { - mockFindUnique.mockResolvedValue(null) + mockFindFirst.mockResolvedValue(null) + + expect(await service.getEmployerView('missing')).toBeNull() + }) - const result = await service.getEmployerView('missing') + it('returns null when the account does not exist', async () => { + mockFindFirst.mockResolvedValue(baseProfile) + mockUserFindUnique.mockResolvedValue(null) - expect(result).toBeNull() + expect(await service.getEmployerView('user1')).toBeNull() }) it('redacts fields when visibility is below employer', async () => { - mockFindUnique.mockResolvedValue(baseProfile) + mockFindFirst.mockResolvedValue(baseProfile) + + expect(await service.getEmployerView('user1')).toEqual({ id: 'profile1', visible: false }) + }) + + it('exposes the employer subset when visibility allows it', async () => { + mockFindFirst.mockResolvedValue({ ...baseProfile, visibility: 'employer' }) + + expect(await service.getEmployerView('user1')).toMatchObject({ visible: true, displayName: 'Ada' }) + }) - const result = await service.getEmployerView('user1') + it('redacts an employer-visible profile once data-sharing consent is withdrawn', async () => { + mockFindFirst.mockResolvedValue({ ...baseProfile, visibility: 'employer' }) + mockConsentFindMany.mockResolvedValue([{ purpose: 'data_sharing', status: 'withdrawn' }]) - expect(result).toEqual({ id: 'profile1', visible: false }) + expect(await service.getEmployerView('user1')).toEqual({ id: 'profile1', visible: false }) }) }) describe('getPublicView', () => { - it('redacts fields when visibility is below public', async () => { - mockFindUnique.mockResolvedValue(baseProfile) + beforeEach(() => { + mockConsentFindMany.mockResolvedValue([]) + mockUserFindUnique.mockResolvedValue({ status: 'ACTIVE' }) + }) - const result = await service.getPublicView('user1') + it('reads through findFirst, so archived profiles are excluded by the client extension', async () => { + mockFindFirst.mockResolvedValue(baseProfile) - expect(result).toEqual({ id: 'profile1', visible: false }) + await service.getPublicView('user1') + + expect(mockFindFirst).toHaveBeenCalledWith({ where: { userId: 'user1' } }) + expect(mockFindUnique).not.toHaveBeenCalled() + }) + + it('redacts fields when visibility is below public', async () => { + mockFindFirst.mockResolvedValue(baseProfile) + + expect(await service.getPublicView('user1')).toEqual({ id: 'profile1', visible: false }) }) it('exposes the public subset when visibility is public', async () => { - mockFindUnique.mockResolvedValue({ ...baseProfile, visibility: 'public', displayName: 'Ada' }) + mockFindFirst.mockResolvedValue({ ...baseProfile, visibility: 'public' }) const result = await service.getPublicView('user1') expect(result).toMatchObject({ visible: true, displayName: 'Ada' }) expect(result).not.toHaveProperty('goals') }) + + it('never leaks account-private fields, even for a fully public profile', async () => { + mockFindFirst.mockResolvedValue({ ...baseProfile, visibility: 'public' }) + + const result = (await service.getPublicView('user1')) as Record + + expect(result).not.toHaveProperty('userId') + expect(result).not.toHaveProperty('status') + expect(result).not.toHaveProperty('isVerified') + expect(result).not.toHaveProperty('phoneVerifiedAt') + expect(result).not.toHaveProperty('email') + expect(result).not.toHaveProperty('walletAddress') + }) + + it('redacts a public profile once data-sharing consent is withdrawn', async () => { + mockFindFirst.mockResolvedValue({ ...baseProfile, visibility: 'public' }) + mockConsentFindMany.mockResolvedValue([{ purpose: 'data_sharing', status: 'withdrawn' }]) + + expect(await service.getPublicView('user1')).toEqual({ id: 'profile1', visible: false }) + }) + + it.each(['DEACTIVATED', 'PENDING_DELETION'])( + 'redacts a public profile for a %s account', + async status => { + mockFindFirst.mockResolvedValue({ ...baseProfile, visibility: 'public' }) + mockUserFindUnique.mockResolvedValue({ status }) + + expect(await service.getPublicView('user1')).toEqual({ id: 'profile1', visible: false }) + } + ) }) describe('getPrivateView', () => { @@ -127,9 +426,7 @@ describe('ProfileService', () => { mockUpsert.mockResolvedValue(baseProfile) mockUserFindUnique.mockResolvedValue(null) - const result = await service.getPrivateView('missing') - - expect(result).toBeNull() + expect(await service.getPrivateView('missing')).toBeNull() }) it('joins account-private fields onto the full profile', async () => { diff --git a/tests/user-account.service.test.ts b/tests/user-account.service.test.ts new file mode 100644 index 00000000..c336e274 --- /dev/null +++ b/tests/user-account.service.test.ts @@ -0,0 +1,304 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ActorType } from '../src/audit/types' + +const { + mockUserFindUnique, + mockUserFindFirst, + mockTransaction, + mockCompare, + mockHash, +} = vi.hoisted(() => ({ + mockUserFindUnique: vi.fn(), + mockUserFindFirst: vi.fn(), + mockTransaction: vi.fn(), + mockCompare: vi.fn(), + mockHash: vi.fn(), +})) + +vi.mock('../src/config/database', () => ({ + default: { + user: { findUnique: mockUserFindUnique, findFirst: mockUserFindFirst }, + $transaction: mockTransaction, + }, +})) + +vi.mock('bcryptjs', () => ({ + default: { compare: mockCompare, hash: mockHash }, +})) + +vi.mock('../src/utils/logger', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})) + +import { UserAccountService } from '../src/services/user-account.service' + +const context = { + actor: { type: ActorType.USER, id: 'user1', role: 'LEARNER' }, + requestId: 'req-1', + ipAddress: '203.0.113.7', + userAgent: 'curl/8.0', +} + +const VALID_ADDRESS = `G${'A'.repeat(55)}` +const OTHER_ADDRESS = `G${'B'.repeat(55)}` + +/** + * A stand-in transaction client covering the tables the mutation touches, so a + * test can assert what ran inside the transaction and in what order. + */ +function fakeTransaction(options: { sessions?: { id: string }[]; updateResult?: unknown } = {}) { + const calls: string[] = [] + const auditCreate = vi.fn(async () => { + calls.push('audit') + + return {} + }) + const userUpdate = vi.fn(async () => { + calls.push('user.update') + + return options.updateResult ?? { id: 'user1' } + }) + const sessionUpdateMany = vi.fn(async () => { + calls.push('session.updateMany') + + return { count: options.sessions?.length ?? 0 } + }) + const refreshTokenUpdateMany = vi.fn(async () => { + calls.push('refreshToken.updateMany') + + return { count: 0 } + }) + + const tx = { + auditEvent: { create: auditCreate }, + user: { update: userUpdate }, + session: { + findMany: vi.fn(async () => options.sessions ?? []), + updateMany: sessionUpdateMany, + }, + refreshToken: { updateMany: refreshTokenUpdateMany }, + } + + mockTransaction.mockImplementation( + async (callback: (client: unknown) => Promise) => callback(tx) + ) + + return { calls, auditCreate, userUpdate, sessionUpdateMany, refreshTokenUpdateMany } +} + +function auditRow(auditCreate: ReturnType): Record { + return (auditCreate.mock.calls[0][0] as { data: Record }).data +} + +describe('UserAccountService', () => { + let service: UserAccountService + + beforeEach(() => { + vi.clearAllMocks() + mockHash.mockResolvedValue('$2b$12$newhash') + service = new UserAccountService() + }) + + describe('changePassword', () => { + const account = { id: 'user1', password: '$2b$12$oldhash', status: 'ACTIVE' } + + it('reports not-found for an unknown account', async () => { + mockUserFindUnique.mockResolvedValue(null) + + expect(await service.changePassword('missing', 'old', 'New1!pass', context)).toEqual({ + kind: 'not-found', + }) + }) + + it('reports not-found for a tombstoned account', async () => { + mockUserFindUnique.mockResolvedValue({ ...account, status: 'DELETED' }) + + expect(await service.changePassword('user1', 'old', 'New1!pass', context)).toEqual({ + kind: 'not-found', + }) + }) + + it('rejects a wrong current password without writing anything', async () => { + mockUserFindUnique.mockResolvedValue(account) + mockCompare.mockResolvedValue(false) + + expect(await service.changePassword('user1', 'wrong', 'New1!pass', context)).toEqual({ + kind: 'invalid-password', + }) + expect(mockTransaction).not.toHaveBeenCalled() + expect(mockHash).not.toHaveBeenCalled() + }) + + it('stores a hash, never the plaintext', async () => { + mockUserFindUnique.mockResolvedValue(account) + mockCompare.mockResolvedValue(true) + const { userUpdate } = fakeTransaction() + + await service.changePassword('user1', 'old', 'New1!pass', context) + + expect(mockHash).toHaveBeenCalledWith('New1!pass', expect.any(Number)) + expect(userUpdate).toHaveBeenCalledWith({ + where: { id: 'user1' }, + data: { password: '$2b$12$newhash' }, + }) + }) + + it('revokes every live session and refresh token in the same transaction', async () => { + mockUserFindUnique.mockResolvedValue(account) + mockCompare.mockResolvedValue(true) + const { calls, sessionUpdateMany, refreshTokenUpdateMany } = fakeTransaction({ + sessions: [{ id: 's1' }, { id: 's2' }], + }) + + const result = await service.changePassword('user1', 'old', 'New1!pass', context) + + expect(result).toEqual({ kind: 'changed', revokedSessionCount: 2 }) + expect(sessionUpdateMany).toHaveBeenCalledWith({ + where: { id: { in: ['s1', 's2'] } }, + data: { isRevoked: true, revokedAt: expect.any(Date) }, + }) + expect(refreshTokenUpdateMany).toHaveBeenCalledWith({ + where: { sessionId: { in: ['s1', 's2'] }, status: { not: 'REVOKED' } }, + data: { status: 'REVOKED' }, + }) + expect(calls).toEqual([ + 'user.update', + 'session.updateMany', + 'refreshToken.updateMany', + 'audit', + ]) + expect(mockTransaction).toHaveBeenCalledOnce() + }) + + it('succeeds with a zero count when there is nothing to revoke', async () => { + mockUserFindUnique.mockResolvedValue(account) + mockCompare.mockResolvedValue(true) + const { sessionUpdateMany } = fakeTransaction({ sessions: [] }) + + expect(await service.changePassword('user1', 'old', 'New1!pass', context)).toEqual({ + kind: 'changed', + revokedSessionCount: 0, + }) + expect(sessionUpdateMany).not.toHaveBeenCalled() + }) + + it('audits the change without recording either password', async () => { + mockUserFindUnique.mockResolvedValue(account) + mockCompare.mockResolvedValue(true) + const { auditCreate } = fakeTransaction({ sessions: [{ id: 's1' }] }) + + await service.changePassword('user1', 'old-secret', 'New1!pass', context) + + const row = auditRow(auditCreate) + + expect(row).toMatchObject({ + action: 'user.password_changed', + actorType: ActorType.USER, + actorId: 'user1', + targetType: 'User', + targetId: 'user1', + requestId: 'req-1', + }) + expect(row.metadata).toContain('revokedSessionCount') + expect(JSON.stringify(row)).not.toContain('old-secret') + expect(JSON.stringify(row)).not.toContain('New1!pass') + expect(JSON.stringify(row)).not.toContain('newhash') + }) + + it('leaves the password unchanged when the audit write fails', async () => { + mockUserFindUnique.mockResolvedValue(account) + mockCompare.mockResolvedValue(true) + mockTransaction.mockRejectedValue(new Error('audit trail unavailable')) + + await expect( + service.changePassword('user1', 'old', 'New1!pass', context) + ).rejects.toThrow('audit trail unavailable') + }) + }) + + describe('updateWalletAddress', () => { + const account = { id: 'user1', status: 'ACTIVE', walletAddress: null } + + it('reports not-found for an unknown account', async () => { + mockUserFindUnique.mockResolvedValue(null) + + expect(await service.updateWalletAddress('missing', VALID_ADDRESS, context)).toEqual({ + kind: 'not-found', + }) + }) + + it('reports not-found for a tombstoned account', async () => { + mockUserFindUnique.mockResolvedValue({ ...account, status: 'DELETED' }) + + expect(await service.updateWalletAddress('user1', VALID_ADDRESS, context)).toEqual({ + kind: 'not-found', + }) + }) + + it('is a no-op when the address is already the one on file', async () => { + mockUserFindUnique.mockResolvedValue({ ...account, walletAddress: VALID_ADDRESS }) + + expect(await service.updateWalletAddress('user1', VALID_ADDRESS, context)).toEqual({ + kind: 'unchanged', + walletAddress: VALID_ADDRESS, + }) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('conflicts when the address is already claimed by another account', async () => { + mockUserFindUnique.mockResolvedValue(account) + mockUserFindFirst.mockResolvedValue({ id: 'user2' }) + + expect(await service.updateWalletAddress('user1', VALID_ADDRESS, context)).toEqual({ + kind: 'conflict', + }) + expect(mockUserFindFirst).toHaveBeenCalledWith({ + where: { walletAddress: VALID_ADDRESS, id: { not: 'user1' } }, + select: { id: true }, + }) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('conflicts when a concurrent write wins the unique constraint', async () => { + mockUserFindUnique.mockResolvedValue(account) + mockUserFindFirst.mockResolvedValue(null) + mockTransaction.mockRejectedValue(Object.assign(new Error('unique'), { code: 'P2002' })) + + expect(await service.updateWalletAddress('user1', VALID_ADDRESS, context)).toEqual({ + kind: 'conflict', + }) + }) + + it('rethrows a failure that is not a unique-constraint violation', async () => { + mockUserFindUnique.mockResolvedValue(account) + mockUserFindFirst.mockResolvedValue(null) + mockTransaction.mockRejectedValue(new Error('connection reset')) + + await expect( + service.updateWalletAddress('user1', VALID_ADDRESS, context) + ).rejects.toThrow('connection reset') + }) + + it('persists the address and audits it', async () => { + mockUserFindUnique.mockResolvedValue({ ...account, walletAddress: OTHER_ADDRESS }) + mockUserFindFirst.mockResolvedValue(null) + const { calls, auditCreate, userUpdate } = fakeTransaction() + + const result = await service.updateWalletAddress('user1', VALID_ADDRESS, context) + + expect(result).toEqual({ kind: 'updated', walletAddress: VALID_ADDRESS }) + expect(userUpdate).toHaveBeenCalledWith({ + where: { id: 'user1' }, + data: { walletAddress: VALID_ADDRESS }, + }) + expect(calls).toEqual(['user.update', 'audit']) + expect(auditRow(auditCreate)).toMatchObject({ + action: 'user.wallet_address_changed', + actorId: 'user1', + targetType: 'User', + targetId: 'user1', + }) + expect(auditRow(auditCreate).metadata).toContain('hadPreviousAddress') + }) + }) +}) diff --git a/tests/user.controller.test.ts b/tests/user.controller.test.ts index 6137cc5d..889a8ee3 100644 --- a/tests/user.controller.test.ts +++ b/tests/user.controller.test.ts @@ -1,282 +1,510 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { Request, Response } from 'express' -import { UserController } from '../src/controllers/user.controller' -import { User } from '../src/types/user.types' - -interface AuthRequest extends Request { - user?: { - id: string; - email: string; - }; -} - -describe('UserController', () => { - let userController: UserController - let mockRequest: Partial - let mockResponse: Partial - - beforeEach(() => { - userController = new UserController() - mockRequest = {} - mockResponse = { - json: vi.fn(), - status: vi.fn().mockReturnThis(), - } - }) - - describe('getCurrentUser', () => { - it('should return current user profile', async () => { - const mockUser: User = { - id: '1', - email: 'test@example.com', - username: 'testuser', - firstName: 'Test', - lastName: 'User', - bio: 'Test bio', - avatar: 'https://example.com/avatar.jpg', - walletAddress: 'GABC123456789012345678901234567890123456789012345678901234567890', - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - } - - mockRequest.user = { id: '1', email: 'test@example.com' } - - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(mockUser) - - await userController.getCurrentUser(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.json).toHaveBeenCalledWith({ - id: mockUser.id, - email: mockUser.email, - username: mockUser.username, - firstName: mockUser.firstName, - lastName: mockUser.lastName, - bio: mockUser.bio, - avatar: mockUser.avatar, - walletAddress: mockUser.walletAddress, - isActive: mockUser.isActive, - createdAt: mockUser.createdAt, - updatedAt: mockUser.updatedAt, - }) - }) - - it('should return 404 if user not found', async () => { - mockRequest.user = { id: '1', email: 'test@example.com' } - - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(null) - - await userController.getCurrentUser(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(404) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'User not found' }) - }) - }) - - describe('updateProfile', () => { - it('should update user profile successfully', async () => { - const mockUser: User = { - id: '1', - email: 'test@example.com', - username: 'updateduser', - firstName: 'Updated', - lastName: 'User', - bio: 'Updated bio', - avatar: 'https://example.com/new-avatar.jpg', - walletAddress: 'GABC123456789012345678901234567890123456789012345678901234567890', - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - } - - mockRequest.user = { id: '1', email: 'test@example.com' } - mockRequest.body = { - username: 'updateduser', - firstName: 'Updated', - lastName: 'User', - bio: 'Updated bio', - avatar: 'https://example.com/new-avatar.jpg', - } - - vi.spyOn(userController as any, 'updateUserProfile').mockResolvedValue(mockUser) - - await userController.updateProfile(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.json).toHaveBeenCalledWith({ - id: mockUser.id, - email: mockUser.email, - username: mockUser.username, - firstName: mockUser.firstName, - lastName: mockUser.lastName, - bio: mockUser.bio, - avatar: mockUser.avatar, - walletAddress: mockUser.walletAddress, - isActive: mockUser.isActive, - createdAt: mockUser.createdAt, - updatedAt: mockUser.updatedAt, - }) - }) - }) - - describe('getUserById', () => { - it('should return public user info', async () => { - const mockUser: User = { - id: '1', - email: 'test@example.com', - username: 'testuser', - firstName: 'Test', - lastName: 'User', - bio: 'Test bio', - avatar: 'https://example.com/avatar.jpg', - walletAddress: 'GABC123456789012345678901234567890123456789012345678901234567890', - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - } - - mockRequest.params = { id: '1' } - - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(mockUser) - - await userController.getUserById(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.json).toHaveBeenCalledWith({ - id: mockUser.id, - username: mockUser.username, - firstName: mockUser.firstName, - lastName: mockUser.lastName, - avatar: mockUser.avatar, - createdAt: mockUser.createdAt, - }) - }) - - it('should return 404 if user not found', async () => { - mockRequest.params = { id: '1' } - - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(null) - - await userController.getUserById(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(404) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'User not found' }) - }) - }) - - describe('changePassword', () => { - it('should change password successfully', async () => { - const mockUser: User = { - id: '1', - email: 'test@example.com', - username: 'testuser', - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - } - - mockRequest.user = { id: '1', email: 'test@example.com' } - mockRequest.body = { - currentPassword: 'oldpassword', - newPassword: 'NewPassword123!', - } - - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(mockUser) - vi.spyOn(userController as any, 'validatePassword').mockResolvedValue(true) - vi.spyOn(userController as any, 'updateUserPassword').mockResolvedValue(undefined) - - await userController.changePassword(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Password updated successfully' }) - }) - - it('should return 400 if current password is incorrect', async () => { - const mockUser: User = { - id: '1', - email: 'test@example.com', - username: 'testuser', - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - } - - mockRequest.user = { id: '1', email: 'test@example.com' } - mockRequest.body = { - currentPassword: 'wrongpassword', - newPassword: 'NewPassword123!', - } - - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(mockUser) - vi.spyOn(userController as any, 'validatePassword').mockResolvedValue(false) - - await userController.changePassword(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Current password is incorrect' }) - }) - }) - - describe('updateWalletAddress', () => { - it('should update wallet address successfully', async () => { - const mockUser: User = { - id: '1', - email: 'test@example.com', - username: 'testuser', - walletAddress: 'GABC1234567890123456789012345678901234567890123456789', - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - } - - mockRequest.user = { id: '1', email: 'test@example.com' } - mockRequest.body = { - walletAddress: 'GABC1234567890123456789012345678901234567890123456789', - } - - vi.spyOn(userController as any, 'updateUserWallet').mockResolvedValue(mockUser) - - await userController.updateWalletAddress(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.json).toHaveBeenCalledWith({ - id: mockUser.id, - email: mockUser.email, - username: mockUser.username, - firstName: mockUser.firstName, - lastName: mockUser.lastName, - bio: mockUser.bio, - avatar: mockUser.avatar, - walletAddress: mockUser.walletAddress, - isActive: mockUser.isActive, - createdAt: mockUser.createdAt, - updatedAt: mockUser.updatedAt, - }) - }) - - it('should return 400 for invalid wallet address', async () => { - mockRequest.user = { id: '1', email: 'test@example.com' } - mockRequest.body = { - walletAddress: 'invalid-address', - } - - await userController.updateWalletAddress(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Invalid Stellar wallet address' }) - }) - }) - - describe('isValidStellarAddress', () => { - it('should validate correct Stellar address', () => { - const validAddress = 'GABC1234567890123456789012345678901234567890123456789' - expect((userController as any).isValidStellarAddress(validAddress)).toBe(true) - }) - - it('should reject invalid Stellar address', () => { - const invalidAddress = 'invalid-address' - expect((userController as any).isValidStellarAddress(invalidAddress)).toBe(false) - }) - - it('should reject address with wrong length', () => { - const shortAddress = 'GABC123' - expect((userController as any).isValidStellarAddress(shortAddress)).toBe(false) - }) - }) -}) +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Request, Response } from 'express' + +const { + mockGetOwnerAccountProfile, + mockUpdateProfileAudited, + mockGetPublicView, + mockChangePassword, + mockUpdateWalletAddress, +} = vi.hoisted(() => ({ + mockGetOwnerAccountProfile: vi.fn(), + mockUpdateProfileAudited: vi.fn(), + mockGetPublicView: vi.fn(), + mockChangePassword: vi.fn(), + mockUpdateWalletAddress: vi.fn(), +})) + +vi.mock('../src/services/profile.service', () => ({ + profileService: { + getOwnerAccountProfile: mockGetOwnerAccountProfile, + updateProfileAudited: mockUpdateProfileAudited, + getPublicView: mockGetPublicView, + }, +})) + +vi.mock('../src/services/user-account.service', () => ({ + userAccountService: { + changePassword: mockChangePassword, + updateWalletAddress: mockUpdateWalletAddress, + }, +})) + +vi.mock('../src/utils/logger', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})) + +import { UserController } from '../src/controllers/user.controller' + +const USER_ID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' +const OTHER_ID = '3f1a2b3c-4d5e-4f60-8a71-9b2c3d4e5f60' +const VALID_ADDRESS = `G${'A'.repeat(55)}` + +const aggregate = { + account: { + id: USER_ID, + email: 'ada@example.com', + username: 'ada', + role: 'LEARNER', + status: 'ACTIVE', + isVerified: true, + phoneVerifiedAt: null, + walletAddress: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-02'), + lastLoginAt: null, + }, + profile: { id: 'profile1', userId: USER_ID, displayName: 'Ada' }, + completion: { percent: 25, missingFields: ['bio'] }, + onboarding: { status: 'in_progress', currentStep: 'consent', requiredStepsRemaining: ['consent'] }, + consents: [], + requiredConsentsGranted: false, +} + +describe('UserController', () => { + let controller: UserController + let req: any + let res: Partial + + beforeEach(() => { + vi.clearAllMocks() + controller = new UserController() + req = { + user: { id: USER_ID, email: 'ada@example.com', role: 'learner' }, + body: {}, + params: {}, + headers: {}, + requestId: 'req-1', + ip: '203.0.113.7', + } + res = { + status: vi.fn().mockReturnThis() as unknown as Response['status'], + json: vi.fn().mockReturnThis() as unknown as Response['json'], + } + }) + + const call = (handler: keyof UserController) => + (controller[handler] as (r: Request, s: Response) => Promise)( + req as Request, + res as Response + ) + + describe('getCurrentUser', () => { + it('returns 401 when unauthenticated', async () => { + req.user = undefined + + await call('getCurrentUser') + + expect(res.status).toHaveBeenCalledWith(401) + expect(mockGetOwnerAccountProfile).not.toHaveBeenCalled() + }) + + it('returns 404 when the account no longer exists', async () => { + mockGetOwnerAccountProfile.mockResolvedValue(null) + + await call('getCurrentUser') + + expect(res.status).toHaveBeenCalledWith(404) + expect(res.json).toHaveBeenCalledWith({ error: 'User not found' }) + }) + + it('returns the account/profile aggregate for the authenticated owner', async () => { + mockGetOwnerAccountProfile.mockResolvedValue(aggregate) + + await call('getCurrentUser') + + expect(mockGetOwnerAccountProfile).toHaveBeenCalledWith(USER_ID) + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ data: aggregate }) + }) + + it('returns profile completion and onboarding state', async () => { + mockGetOwnerAccountProfile.mockResolvedValue(aggregate) + + await call('getCurrentUser') + + const body = (res.json as ReturnType).mock.calls[0][0] + + expect(body.data.completion).toEqual({ percent: 25, missingFields: ['bio'] }) + expect(body.data.onboarding.currentStep).toBe('consent') + expect(body.data.requiredConsentsGranted).toBe(false) + }) + + it('reads its own account only — never an id taken from the request', async () => { + req.params = { id: OTHER_ID } + mockGetOwnerAccountProfile.mockResolvedValue(aggregate) + + await call('getCurrentUser') + + expect(mockGetOwnerAccountProfile).toHaveBeenCalledWith(USER_ID) + }) + + it('returns 500 on an unexpected failure', async () => { + mockGetOwnerAccountProfile.mockRejectedValue(new Error('db down')) + + await call('getCurrentUser') + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) + + describe('updateProfile', () => { + it('returns 401 when unauthenticated', async () => { + req.user = undefined + req.body = { displayName: 'Ada' } + + await call('updateProfile') + + expect(res.status).toHaveBeenCalledWith(401) + expect(mockUpdateProfileAudited).not.toHaveBeenCalled() + }) + + it('returns 400 on an empty body', async () => { + await call('updateProfile') + + expect(res.status).toHaveBeenCalledWith(400) + expect(mockUpdateProfileAudited).not.toHaveBeenCalled() + }) + + it.each([ + ['status', { status: 'ACTIVE' }], + ['isVerified', { isVerified: true }], + ['role', { role: 'ADMIN' }], + ['phoneVerifiedAt', { phoneVerifiedAt: new Date().toISOString() }], + ['userId', { userId: OTHER_ID }], + ['id', { id: 'profile-hijack' }], + ['archivedAt', { archivedAt: null }], + ['password', { password: 'Hacked1!pass' }], + ['email', { email: 'attacker@example.com' }], + ['walletAddress', { walletAddress: VALID_ADDRESS }], + ])('rejects %s: an owner may only write allow-listed profile fields', async (_field, body) => { + req.body = body + + await call('updateProfile') + + expect(res.status).toHaveBeenCalledWith(400) + expect(mockUpdateProfileAudited).not.toHaveBeenCalled() + }) + + it('rejects an allowed field carried alongside a forbidden one', async () => { + req.body = { displayName: 'Ada', role: 'ADMIN' } + + await call('updateProfile') + + expect(res.status).toHaveBeenCalledWith(400) + expect(mockUpdateProfileAudited).not.toHaveBeenCalled() + }) + + it.each([ + ['level', { level: 'wizard' }], + ['visibility', { visibility: 'friends' }], + ['avatarUrl', { avatarUrl: 'not-a-url' }], + ['displayName', { displayName: 'x'.repeat(81) }], + ['bio', { bio: 'x'.repeat(1001) }], + ])('returns 400 on an invalid %s value', async (_field, body) => { + req.body = body + + await call('updateProfile') + + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('applies an audited partial update and returns the refreshed aggregate', async () => { + req.body = { displayName: 'Ada', interests: ['stellar'] } + mockUpdateProfileAudited.mockResolvedValue({ id: 'profile1' }) + mockGetOwnerAccountProfile.mockResolvedValue(aggregate) + + await call('updateProfile') + + expect(mockUpdateProfileAudited).toHaveBeenCalledWith( + USER_ID, + { displayName: 'Ada', interests: ['stellar'] }, + expect.anything() + ) + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ + message: 'Profile updated successfully', + data: aggregate, + }) + }) + + it('passes an audit context attributed to the authenticated owner', async () => { + req.body = { displayName: 'Ada' } + mockUpdateProfileAudited.mockResolvedValue({ id: 'profile1' }) + mockGetOwnerAccountProfile.mockResolvedValue(aggregate) + + await call('updateProfile') + + const context = mockUpdateProfileAudited.mock.calls[0][2] + + expect(context.actor).toMatchObject({ type: 'USER', id: USER_ID }) + expect(context.requestId).toBe('req-1') + }) + + it('returns 404 when the account vanished mid-request', async () => { + req.body = { displayName: 'Ada' } + mockUpdateProfileAudited.mockResolvedValue({ id: 'profile1' }) + mockGetOwnerAccountProfile.mockResolvedValue(null) + + await call('updateProfile') + + expect(res.status).toHaveBeenCalledWith(404) + }) + + it('returns 500 when the audited write fails', async () => { + req.body = { displayName: 'Ada' } + mockUpdateProfileAudited.mockRejectedValue(new Error('audit trail unavailable')) + + await call('updateProfile') + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) + + describe('getUserById', () => { + it('returns 400 for a non-uuid id', async () => { + req.params = { id: 'not-a-uuid' } + + await call('getUserById') + + expect(res.status).toHaveBeenCalledWith(400) + expect(mockGetPublicView).not.toHaveBeenCalled() + }) + + it('returns 404 when no such learner exists', async () => { + req.params = { id: OTHER_ID } + mockGetPublicView.mockResolvedValue(null) + + await call('getUserById') + + expect(res.status).toHaveBeenCalledWith(404) + }) + + it('serves the public view, never the owner view, even to the owner', async () => { + req.params = { id: USER_ID } + mockGetPublicView.mockResolvedValue({ id: 'profile1', visible: true, displayName: 'Ada' }) + + await call('getUserById') + + expect(mockGetPublicView).toHaveBeenCalledWith(USER_ID) + expect(mockGetOwnerAccountProfile).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(200) + }) + + it('returns the redacted stub unchanged when disclosure is refused', async () => { + req.params = { id: OTHER_ID } + mockGetPublicView.mockResolvedValue({ id: 'profile2', visible: false }) + + await call('getUserById') + + expect(res.json).toHaveBeenCalledWith({ data: { id: 'profile2', visible: false } }) + }) + + it('never emits private account data in the public response', async () => { + req.params = { id: OTHER_ID } + mockGetPublicView.mockResolvedValue({ + id: 'profile2', + visible: true, + displayName: 'Ada', + bio: null, + avatarUrl: null, + country: 'NG', + level: 'beginner', + interests: [], + }) + + await call('getUserById') + + const body = JSON.stringify((res.json as ReturnType).mock.calls[0][0]) + + for (const leak of ['email', 'password', 'walletAddress', 'status', 'isVerified', 'phoneVerifiedAt', 'userId']) { + expect(body).not.toContain(leak) + } + }) + + it('returns 500 on an unexpected failure', async () => { + req.params = { id: OTHER_ID } + mockGetPublicView.mockRejectedValue(new Error('db down')) + + await call('getUserById') + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) + + describe('changePassword', () => { + const body = { currentPassword: 'OldPass1!', newPassword: 'NewPass1!' } + + it('returns 401 when unauthenticated', async () => { + req.user = undefined + req.body = body + + await call('changePassword') + + expect(res.status).toHaveBeenCalledWith(401) + expect(mockChangePassword).not.toHaveBeenCalled() + }) + + it.each([ + ['a missing current password', { newPassword: 'NewPass1!' }], + ['a weak new password', { currentPassword: 'OldPass1!', newPassword: 'short' }], + ['reusing the current password', { currentPassword: 'NewPass1!', newPassword: 'NewPass1!' }], + ['an unknown extra field', { ...body, userId: OTHER_ID }], + ])('returns 400 for %s', async (_case, invalid) => { + req.body = invalid + + await call('changePassword') + + expect(res.status).toHaveBeenCalledWith(400) + expect(mockChangePassword).not.toHaveBeenCalled() + }) + + it('returns 401 when the current password is wrong', async () => { + req.body = body + mockChangePassword.mockResolvedValue({ kind: 'invalid-password' }) + + await call('changePassword') + + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith({ + error: 'Current password is incorrect', + code: 'STEP_UP_FAILED', + }) + }) + + it('returns 404 when the account no longer exists', async () => { + req.body = body + mockChangePassword.mockResolvedValue({ kind: 'not-found' }) + + await call('changePassword') + + expect(res.status).toHaveBeenCalledWith(404) + }) + + it('reports the revoked session count on success', async () => { + req.body = body + mockChangePassword.mockResolvedValue({ kind: 'changed', revokedSessionCount: 3 }) + + await call('changePassword') + + expect(mockChangePassword).toHaveBeenCalledWith( + USER_ID, + 'OldPass1!', + 'NewPass1!', + expect.anything() + ) + expect(res.status).toHaveBeenCalledWith(200) + expect((res.json as ReturnType).mock.calls[0][0]).toMatchObject({ + revokedSessionCount: 3, + }) + }) + + it('never echoes either password back to the caller', async () => { + req.body = body + mockChangePassword.mockResolvedValue({ kind: 'changed', revokedSessionCount: 0 }) + + await call('changePassword') + + const responseBody = JSON.stringify((res.json as ReturnType).mock.calls[0][0]) + + expect(responseBody).not.toContain('OldPass1!') + expect(responseBody).not.toContain('NewPass1!') + }) + + it('returns 500 on an unexpected failure', async () => { + req.body = body + mockChangePassword.mockRejectedValue(new Error('db down')) + + await call('changePassword') + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) + + describe('updateWalletAddress', () => { + it('returns 401 when unauthenticated', async () => { + req.user = undefined + req.body = { walletAddress: VALID_ADDRESS } + + await call('updateWalletAddress') + + expect(res.status).toHaveBeenCalledWith(401) + expect(mockUpdateWalletAddress).not.toHaveBeenCalled() + }) + + it.each([ + ['a malformed address', { walletAddress: 'invalid-address' }], + ['a too-short address', { walletAddress: 'GABC123' }], + ['a secret seed', { walletAddress: `S${'A'.repeat(55)}` }], + ['a missing address', {}], + ['an unknown extra field', { walletAddress: VALID_ADDRESS, userId: OTHER_ID }], + ])('returns 400 for %s', async (_case, body) => { + req.body = body + + await call('updateWalletAddress') + + expect(res.status).toHaveBeenCalledWith(400) + expect(mockUpdateWalletAddress).not.toHaveBeenCalled() + }) + + it('returns 409 when the address belongs to another account', async () => { + req.body = { walletAddress: VALID_ADDRESS } + mockUpdateWalletAddress.mockResolvedValue({ kind: 'conflict' }) + + await call('updateWalletAddress') + + expect(res.status).toHaveBeenCalledWith(409) + expect((res.json as ReturnType).mock.calls[0][0]).toMatchObject({ + code: 'WALLET_ADDRESS_TAKEN', + }) + }) + + it('returns 404 when the account no longer exists', async () => { + req.body = { walletAddress: VALID_ADDRESS } + mockUpdateWalletAddress.mockResolvedValue({ kind: 'not-found' }) + + await call('updateWalletAddress') + + expect(res.status).toHaveBeenCalledWith(404) + }) + + it('persists a valid address for the authenticated owner', async () => { + req.body = { walletAddress: VALID_ADDRESS } + mockUpdateWalletAddress.mockResolvedValue({ kind: 'updated', walletAddress: VALID_ADDRESS }) + + await call('updateWalletAddress') + + expect(mockUpdateWalletAddress).toHaveBeenCalledWith( + USER_ID, + VALID_ADDRESS, + expect.anything() + ) + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ + message: 'Wallet address updated successfully', + data: { walletAddress: VALID_ADDRESS }, + }) + }) + + it('is idempotent when the address is already on file', async () => { + req.body = { walletAddress: VALID_ADDRESS } + mockUpdateWalletAddress.mockResolvedValue({ kind: 'unchanged', walletAddress: VALID_ADDRESS }) + + await call('updateWalletAddress') + + expect(res.status).toHaveBeenCalledWith(200) + expect((res.json as ReturnType).mock.calls[0][0].message).toBe( + 'Wallet address unchanged' + ) + }) + + it('returns 500 on an unexpected failure', async () => { + req.body = { walletAddress: VALID_ADDRESS } + mockUpdateWalletAddress.mockRejectedValue(new Error('db down')) + + await call('updateWalletAddress') + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) +})