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) + }) + }) +})