diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d759234..75e9f6c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -42,7 +42,7 @@ See `PRD.md` for product scope and `ARCHITECTURE_ESSENTIALS.md` for the quick-re Each module under `backend/src/modules//` owns its own `routes/`, `controllers/`, `services/`, `repositories/`. Cross-module access happens through a module's exported service interface only — **never reach into another module's repository directly.** This is the internal seam that would let us extract a module into its own service later without a rewrite. - `auth` — signup/login, tokens, role checks. -- `users` — shared user profile fields common to all roles. +- `users` — shared user profile fields common to all roles, plus the admin account lifecycle (invites, soft deactivation — issue #39). `auth` calls this module's services to create an invited admin; `users` never calls back into `auth`. - `patients` — patient-specific profile data. - `doctors` — doctor profile, specialties, verification status. - `facilities` — lab/pharmacy accounts, capability tags (what order types they can fulfill). diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index bdbb1d4..7dccf3b 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -20,7 +20,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc 6. Every module = `routes/ controllers/ services/ repositories/`. Keep that shape. ## Data model map (see ARCHITECTURE.md §4 for full field lists) -`User` → `PatientProfile | DoctorProfile | FacilityProfile` +`User` → `PatientProfile | DoctorProfile | FacilityProfile`; `User` → `AdminInvite` (Phase 8, issue #39 — hashed/expiring/single-use; `User.deactivatedAt` soft-deactivates an account, refusing login/refresh/access tokens without deleting anything) `DoctorProfile` —< `DoctorSpecialty` >— `Specialty` `AvailabilitySlot` (doctor's calendar) → `Appointment` → `IntakeForm`, `Consultation` `Consultation` → (outcome=referred) → `Referral` (snapshot!) → new `Appointment` diff --git a/backend/.env.example b/backend/.env.example index e815bea..e1a3f19 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -45,6 +45,11 @@ PATIENT_EXPORT_MAX_ATTEMPTS=3 # a filled-in .env. Generate + fund your own with: # node scripts/gen-platform-identities.mjs # (writes secrets directly to .env, never prints them). +# Admin invites (Phase 8, issue #39). `ADMIN_INVITE_ACCEPT_URL` is where the +# invite link points — docforum-web owns that page. +ADMIN_INVITE_TTL_HOURS=72 +ADMIN_INVITE_ACCEPT_URL=http://localhost:5173/accept-admin-invite + PLATFORM_PAYER_SECRET= PLATFORM_RELEASER_SECRET= # The live testnet docforum-escrow deployment this repo currently targets diff --git a/backend/prisma/migrations/20261015175817_add_admin_lifecycle/migration.sql b/backend/prisma/migrations/20261015175817_add_admin_lifecycle/migration.sql new file mode 100644 index 0000000..58c59ce --- /dev/null +++ b/backend/prisma/migrations/20261015175817_add_admin_lifecycle/migration.sql @@ -0,0 +1,26 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "deactivatedAt" TIMESTAMP(3), +ADD COLUMN "sessionsInvalidatedAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "AdminInvite" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "invitedByUserId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AdminInvite_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminInvite_tokenHash_key" ON "AdminInvite"("tokenHash"); + +-- CreateIndex +CREATE INDEX "AdminInvite_email_idx" ON "AdminInvite"("email"); + +-- AddForeignKey +ALTER TABLE "AdminInvite" ADD CONSTRAINT "AdminInvite_invitedByUserId_fkey" FOREIGN KEY ("invitedByUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7cfb28b..57c4357 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -109,6 +109,17 @@ model User { // DoctorProfile.verificationStatus, which is the separate, manual // admin verification of a doctor's credentials — do not conflate. emailVerifiedAt DateTime? + // Phase 8 (issue #39): null = active. Deactivating never deletes anything + // — no clinical or payment record is keyed to this flag, and none of the + // deactivation paths remove rows. Login, refresh and access tokens are all + // refused while this is set. + deactivatedAt DateTime? + // Phase 8 (issue #39): bumped when an account is deactivated/reactivated, + // so access tokens minted before that moment stop working immediately + // instead of living out their 15-minute TTL. There is still no general + // refresh-token revocation list (issue #18) — this is the narrow "revoke + // everything for one account" case that deactivation needs. + sessionsInvalidatedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -117,6 +128,7 @@ model User { facilityProfile FacilityProfile? emailVerificationTokens EmailVerificationToken[] + adminInvitesSent AdminInvite[] @@index([role]) } @@ -138,6 +150,27 @@ model EmailVerificationToken { @@index([userId]) } +// Phase 8 (issue #39): admin invites. Admin accounts are "seeded or +// invited" (ROADMAP Phase 8) and there was no supported path for either. +// Only the SHA-256 hash of the token is stored — a database leak must not +// hand out working invite links — and every invite is single-use and +// expiring. Admin-only scope for now: PRD OQ-2's facility invites will need +// their own profile fields (name/type) at redemption, so they are +// deliberately not folded in here. +model AdminInvite { + id String @id @default(uuid()) + email String + tokenHash String @unique + expiresAt DateTime + consumedAt DateTime? + invitedByUserId String + createdAt DateTime @default(now()) + + invitedBy User @relation(fields: [invitedByUserId], references: [id], onDelete: Cascade) + + @@index([email]) +} + // Phase 8 (issue #35): login-throttle state. Deliberately in Postgres and // not in process memory — the backend runs as a single Render instance // today but must not assume that forever, and a restart must not hand an diff --git a/backend/scripts/create-admin.mjs b/backend/scripts/create-admin.mjs new file mode 100644 index 0000000..f3acaf8 --- /dev/null +++ b/backend/scripts/create-admin.mjs @@ -0,0 +1,178 @@ +// Admin bootstrap CLI (Phase 8, issue #39). +// +// Admin accounts are "seeded or invited" per the roadmap, but no supported +// path existed: the preview admin was recreated by hand with an +// `UPDATE "User" SET role = 'admin'` against the Render database (ADR 0003's +// renewal runbook). This is that supported path. +// +// Two rules this script exists to honour: +// 1. Credentials are never read from argv. `ps`, shell history and CI logs +// all record command lines, so the password is read from stdin instead — +// silently, when there's a TTY to do it with. A password passed in argv +// is refused rather than quietly ignored. (The email *is* accepted as a +// flag: an address is not a secret.) +// 2. Secrets are never printed or logged — no password, no hash, no echo. +// +// Usage: +// node scripts/create-admin.mjs --email admin@example.com +// printf 'admin@example.com\n\n' | node scripts/create-admin.mjs +// node scripts/create-admin.mjs --email admin@example.com --force +// +// `--force` is required when an active admin already exists, so a stray run +// can't quietly add a second administrator. +import 'dotenv/config'; +import bcrypt from 'bcryptjs'; +import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; + +const SALT_ROUNDS = 12; +const MIN_PASSWORD_LENGTH = 8; + +// Prisma 7 (ADR 0006) requires a driver adapter — `new PrismaClient()` with +// no adapter throws. Same shape as src/db/prisma-client.ts, minus the +// server-only config import (this script runs standalone from the shell). +const adapter = new PrismaPg({ + connectionString: process.env.DATABASE_URL, + connectionTimeoutMillis: 5_000, +}); +const prisma = new PrismaClient({ adapter }); + +function fail(message) { + console.error(`create-admin: ${message}`); + process.exit(1); +} + +/** Refuses every argv form of a password rather than "just this once". */ +function assertNoPasswordInArgv(argv) { + const offending = argv.find((arg) => /^--password(=|$)/.test(arg) || /^--password-hash(=|$)/.test(arg)); + if (offending) { + fail( + 'passwords must not be passed as command-line arguments — they leak via `ps`, shell history and CI logs. ' + + 'Run interactively, or pipe the password on stdin.', + ); + } +} + +function parseArgs(argv) { + const emailFlag = argv.find((arg) => arg.startsWith('--email=')); + const emailIndex = argv.indexOf('--email'); + return { + email: emailFlag ? emailFlag.slice('--email='.length) : emailIndex >= 0 ? argv[emailIndex + 1] : undefined, + force: argv.includes('--force'), + }; +} + +/** Prompts for one line, without echoing it when `silent`. Only used when + * stdin is a TTY — see `readPipedLines` for the other path. */ +function promptLine(prompt, { silent = false } = {}) { + return new Promise((resolve, reject) => { + process.stdout.write(prompt); + process.stdin.setEncoding('utf8'); + process.stdin.resume(); + process.stdin.setRawMode?.(silent); + + let value = ''; + const cleanup = () => { + process.stdin.setRawMode?.(false); + process.stdin.pause(); + process.stdin.off('data', onData); + process.stdin.off('error', onError); + }; + const onError = (err) => { + cleanup(); + reject(err); + }; + const onData = (chunk) => { + for (const char of chunk.toString('utf8')) { + if (char === '\u0003') { + // Ctrl-C — never leave the terminal in raw mode. + cleanup(); + process.stdout.write('\n'); + process.exit(130); + } + if (char === '\n' || char === '\r') { + cleanup(); + if (silent) process.stdout.write('\n'); + resolve(value); + return; + } + if (char === '\u007f' || char === '\b') { + value = value.slice(0, -1); + continue; + } + value += char; + } + }; + + process.stdin.on('data', onData); + process.stdin.on('error', onError); + }); +} + +/** Piped input is read to completion up front — reading line-by-line off a + * stream loses the rest of a chunk, which silently hangs the second prompt. */ +async function readPipedLines() { + let text = ''; + for await (const chunk of process.stdin) text += chunk; + const lines = text.split('\n').map((line) => line.replace(/\r$/, '')); + // A trailing newline leaves one empty element; that isn't a line. + if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + return lines; +} + +async function main() { + const argv = process.argv.slice(2); + assertNoPasswordInArgv(argv); + const { email: emailFlag, force } = parseArgs(argv); + + const activeAdmins = await prisma.user.count({ where: { role: 'admin', deactivatedAt: null } }); + if (activeAdmins > 0 && !force) { + fail( + `${activeAdmins} active admin account(s) already exist. Re-run with --force to add another, or use ` + + 'POST /admin/invites to invite one.', + ); + } + + const interactive = Boolean(process.stdin.isTTY); + const piped = interactive ? [] : await readPipedLines(); + + const email = (emailFlag ?? (interactive ? await promptLine('Admin email: ') : piped.shift() ?? '')).trim(); + if (!email) fail('an email address is required.'); + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) fail(`"${email}" does not look like an email address.`); + + if (await prisma.user.findUnique({ where: { email } })) { + fail(`an account already exists for ${email}. Promote or reactivate it instead of re-creating it.`); + } + + let password; + if (interactive) { + password = await promptLine('Admin password (min 8 chars, not echoed, never in argv): ', { silent: true }); + const confirmation = await promptLine('Confirm password: ', { silent: true }); + if (password !== confirmation) fail('the two passwords did not match.'); + } else { + // Piped input is `email` then `password` — no confirmation line, so a + // password can't be typed twice by accident in a script. + password = piped.shift() ?? ''; + } + if (password.length < MIN_PASSWORD_LENGTH) fail(`the password must be at least ${MIN_PASSWORD_LENGTH} characters.`); + + // Hashed before anything persistent sees it, and never logged. + const passwordHash = await bcrypt.hash(password, SALT_ROUNDS); + + const user = await prisma.user.create({ + data: { + email, + passwordHash, + role: 'admin', + }, + }); + + console.log(`Created admin ${user.email} (${user.id}).`); + console.log('The password was not printed, logged or stored anywhere else — put it in your password manager now.'); +} + +main() + .catch((err) => { + fail(err instanceof Error ? err.message : String(err)); + }) + .finally(() => prisma.$disconnect()); diff --git a/backend/src/app.ts b/backend/src/app.ts index 0397740..834f950 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -9,6 +9,7 @@ import { availabilityRouter } from './modules/availability/routes'; import { appointmentsRouter } from './modules/appointments/routes'; import { paymentsRouter } from './modules/payments/routes'; import { patientsRouter } from './modules/patients/routes'; +import { adminRouter } from './modules/users/routes'; import { registerPatientExportSections } from './export-sections'; import { auditRouter } from './modules/audit/routes'; import { requestId } from './middleware/request-id'; @@ -42,6 +43,9 @@ export function createApp() { app.use('/appointments', appointmentsRouter); app.use('/payments', paymentsRouter); app.use('/patients', patientsRouter); + // Phase 8, issue #39 — admin account lifecycle (invites, deactivate/ + // reactivate) alongside the audit read path. Different paths, no overlap. + app.use('/admin', adminRouter); app.use('/admin', auditRouter); // Must be registered last — Express only routes errors here once every diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 9a01e58..7c2b0c0 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -129,6 +129,12 @@ export const config = { sameSite: process.env.NODE_ENV === 'production' ? ('none' as const) : ('lax' as const), path: '/', }, + // Admin invites (Phase 8, issue #39). `acceptUrl` is where the invite link + // points — docforum-web owns the page that consumes `?token=`. + adminInvites: { + ttlHours: Number(process.env.ADMIN_INVITE_TTL_HOURS ?? 72), + acceptUrl: process.env.ADMIN_INVITE_ACCEPT_URL ?? 'http://localhost:5173/accept-admin-invite', + }, // Deliberately NOT required at startup via requireEnv() — unrelated // parts of the app (auth, availability, etc.) shouldn't fail to boot // just because payments isn't configured. The payments module itself diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 531d21b..8ede224 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,5 +1,5 @@ import type { NextFunction, Request, Response } from 'express'; -import { assertEmailVerified, verifyAccessToken } from '../modules/auth/services'; +import { assertActiveSession, assertEmailVerified, verifyAccessToken } from '../modules/auth/services'; import { HttpError } from '../http-error'; import type { Role } from '@prisma/client'; @@ -13,22 +13,30 @@ declare global { } /** Verifies the `Authorization: Bearer ` header and attaches - * `req.user`. Throws 401 if missing/invalid/expired — there is no - * revocation list yet, so an expired token is the only way an access - * token stops working before its 15-minute TTL (see config/index.ts). */ -export function authenticate(req: Request, _res: Response, next: NextFunction) { + * `req.user`. + * + * Throws 401 if missing/invalid/expired. Beyond expiry, `assertActiveSession` + * is a database read per request that rejects a deactivated account and any + * token minted before that account's sessions were invalidated (Phase 8, + * issue #39) — without it, a deactivated user's access token would keep + * working for the rest of its 15-minute TTL. This is not a general + * revocation list; that is still issue #18. */ +export async function authenticate(req: Request, _res: Response, next: NextFunction) { const header = req.headers.authorization; if (!header?.startsWith('Bearer ')) { throw new HttpError(401, 'Missing or malformed Authorization header.'); } + let payload; try { - const payload = verifyAccessToken(header.slice('Bearer '.length)); - req.user = { id: payload.sub, role: payload.role }; - next(); + payload = verifyAccessToken(header.slice('Bearer '.length)); } catch { throw new HttpError(401, 'Invalid or expired access token.'); } + + await assertActiveSession(payload.sub, payload.iat); + req.user = { id: payload.sub, role: payload.role }; + next(); } /** Must run after `authenticate`. Rejects with `403 EMAIL_NOT_VERIFIED` diff --git a/backend/src/modules/auth/controllers/index.ts b/backend/src/modules/auth/controllers/index.ts index 1fa46dd..8111bb2 100644 --- a/backend/src/modules/auth/controllers/index.ts +++ b/backend/src/modules/auth/controllers/index.ts @@ -1,6 +1,12 @@ import type { Request, Response } from 'express'; import * as authService from '../services'; -import { clearLoginAttemptsSchema, loginSchema, signupSchema, verifyEmailSchema } from '../validation'; +import { + clearLoginAttemptsSchema, + loginSchema, + signupSchema, + signupWithInviteSchema, + verifyEmailSchema, +} from '../validation'; import { HttpError } from '../../../http-error'; import { config } from '../../../config'; import { readCsrfHeader } from '../../../middleware/csrf'; @@ -70,6 +76,18 @@ export async function clearLoginAttempts(req: Request, res: Response) { res.status(200).json({ cleared }); } +/** + * Admin invite redemption (Phase 8, issue #39). Lives in the auth module + * because it ends in a session, but the invite itself is the users module's + * business — see `authService.signupWithInvite`, which calls that module's + * service rather than touching its tables. + */ +export async function signupWithInvite(req: Request, res: Response) { + const input = signupWithInviteSchema.parse(req.body); + const session = await authService.signupWithInvite(input); + sessionResponse(res, session); +} + export async function refresh(req: Request, res: Response) { const token = (req.cookies as Record | undefined)?.[REFRESH_COOKIE]; if (!token) throw new HttpError(401, 'No refresh token provided.'); diff --git a/backend/src/modules/auth/routes/index.ts b/backend/src/modules/auth/routes/index.ts index a08ac73..12064bc 100644 --- a/backend/src/modules/auth/routes/index.ts +++ b/backend/src/modules/auth/routes/index.ts @@ -6,6 +6,9 @@ import * as authController from '../controllers'; export const authRouter = Router(); authRouter.post('/signup', authController.signup); +// Phase 8, issue #39: admin invite redemption (the admin invite itself is +// issued by `POST /admin/invites` in the users module). +authRouter.post('/signup-with-invite', authController.signupWithInvite); authRouter.post('/login', authController.login); // Phase 8, issue #34 — email verification. diff --git a/backend/src/modules/auth/services/index.ts b/backend/src/modules/auth/services/index.ts index 6b3ea13..455c87b 100644 --- a/backend/src/modules/auth/services/index.ts +++ b/backend/src/modules/auth/services/index.ts @@ -26,6 +26,7 @@ import { resetLoginThrottle, } from './login-throttle'; import { csrfTokensMatch, generateCsrfToken } from '../../../middleware/csrf'; +import { redeemAdminInvite } from '../../users/services'; import type { Role } from '@prisma/client'; import type { PatientExportSection } from '../../patients/services'; @@ -142,6 +143,40 @@ export async function signup(input: SignupInput): Promise { return { user: toPublicUser(user), ...tokens }; } +/** + * Redeems an admin invite and signs the new admin in (Phase 8, issue #39). + * + * The invite itself belongs to the users module — this module only hashes + * the password and issues the session, calling that module's service rather + * than its repository (AGENTS.md hard rule 4). + */ +export interface SignupWithInviteInput { + token: string; + email: string; + password: string; +} + +export async function signupWithInvite(input: SignupWithInviteInput): Promise { + const passwordHash = await hashPassword(input.password); + const user = await redeemAdminInvite({ token: input.token, email: input.email, passwordHash }); + + const tokens = issueSession(user); + return { + user: { + id: user.id, + email: user.email, + role: user.role, + // No verification email is sent on the invite path — the invite itself + // is the admin-vetted proof of address, so this stays null rather than + // claiming a verification that never happened. + emailVerifiedAt: null, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }, + ...tokens, + }; +} + export interface LoginInput { email: string; password: string; @@ -187,6 +222,13 @@ export async function login(input: LoginInput): Promise { await resetLoginThrottle(throttleKeys.emailKey); + // Checked *after* the password: a deactivated account must not be + // distinguishable from a non-existent one without knowing the password + // (Phase 8, issue #39). + if (user.deactivatedAt) { + throw new HttpError(403, 'This account has been deactivated. Contact an administrator.'); + } + const tokens = issueSession(user); return { user: toPublicUser(user), ...tokens }; } @@ -324,10 +366,46 @@ export async function refresh(refreshToken: string, csrfToken?: string | null): const user = await findUserById(payload.sub); if (!user) throw new HttpError(401, 'Invalid or expired refresh token.'); + // Deactivated accounts can't refresh out of their deactivation, and a + // session revoked by deactivation/reactivation can't come back to life + // through a refresh cookie either (Phase 8, issue #39). + if (user.deactivatedAt) throw new HttpError(403, 'This account has been deactivated.'); + if (isSessionRevoked(user.sessionsInvalidatedAt, payload.iat)) { + throw new HttpError(401, 'Your session has been revoked. Please sign in again.'); + } + const tokens = issueSession(user); return { user: toPublicUser(user), ...tokens }; } +/** + * Guards every authenticated request (Phase 8, issue #39). The access token + * is otherwise stateless, so a deactivated account's token would keep + * working for up to 15 minutes; this is the DB read that closes that gap. + * + * Deliberately narrow: it answers "is this account still active, and was + * this token issued after the last deactivation/reactivation", not "is this + * token on a revocation list" — a general revocation list is issue #18 and + * is still unbuilt. Because it's a per-request read, tokens carrying no + * `iat` are treated as revoked once `sessionsInvalidatedAt` is set (fail + * closed). + */ +export async function assertActiveSession(userId: string, issuedAtSeconds?: number): Promise { + const user = await findUserById(userId); + if (!user) throw new HttpError(401, 'Invalid or expired access token.'); + if (user.deactivatedAt) throw new HttpError(401, 'This account has been deactivated.'); + if (isSessionRevoked(user.sessionsInvalidatedAt, issuedAtSeconds)) { + throw new HttpError(401, 'Your session has been revoked. Please sign in again.'); + } +} + +function isSessionRevoked(sessionsInvalidatedAt: Date | null, issuedAtSeconds: number | undefined): boolean { + if (!sessionsInvalidatedAt) return false; + // `iat` has second precision, so a token minted in the same second as the + // invalidation counts as revoked — the safe direction. + return (issuedAtSeconds ?? 0) * 1000 < sessionsInvalidatedAt.getTime(); +} + /** This module's contribution to the patient data export (issue #40): the * login identity itself. Explicit allowlist — never the password hash, and * no verification/throttle bookkeeping. */ diff --git a/backend/src/modules/auth/services/tokens.ts b/backend/src/modules/auth/services/tokens.ts index aeefa8b..3b584af 100644 --- a/backend/src/modules/auth/services/tokens.ts +++ b/backend/src/modules/auth/services/tokens.ts @@ -5,6 +5,10 @@ import type { Role } from '@prisma/client'; export interface AccessTokenPayload { sub: string; // user id role: Role; + /** Issued-at, set by `jsonwebtoken` itself. Read by the auth middleware to + * compare against `User.sessionsInvalidatedAt` (Phase 8, issue #39) — + * tokens minted before a deactivation must stop working immediately. */ + iat?: number; } /** diff --git a/backend/src/modules/auth/validation.ts b/backend/src/modules/auth/validation.ts index 9d958c5..e992454 100644 --- a/backend/src/modules/auth/validation.ts +++ b/backend/src/modules/auth/validation.ts @@ -12,6 +12,14 @@ export const loginSchema = z.object({ password: z.string().min(1), }); +// Phase 8, issue #39 — admin invite redemption. No `fullName`: an admin +// account has no Patient/Doctor profile row. +export const signupWithInviteSchema = z.object({ + token: z.string().min(1), + email: z.string().email(), + password: z.string().min(8), +}); + // Phase 8, issue #34. No `email` field on the resend body on purpose — the // resend endpoint is authenticated and targets the caller's own account, // so it can't be used to probe which addresses exist. diff --git a/backend/src/modules/users/controllers/index.ts b/backend/src/modules/users/controllers/index.ts index 385b97c..29b9f05 100644 --- a/backend/src/modules/users/controllers/index.ts +++ b/backend/src/modules/users/controllers/index.ts @@ -1,2 +1,45 @@ -// users controllers — placeholder. -export {}; +// users controllers — admin account lifecycle (Phase 8, issue #39). +import type { Request, Response } from 'express'; +import { HttpError } from '../../../http-error'; +import { getParam } from '../../../request-params'; +import * as usersService from '../services'; +import { createAdminInviteSchema } from '../validation'; + +/** + * Creates a single-use, expiring admin invite. + * + * The raw token is returned in this response **once** — only its hash is + * stored, so it can't be fetched again — and is never logged. The inviting + * admin is responsible for passing the link to the invitee out of band; + * there is no email provider yet (Phase 6, issue #17). + */ +export async function createInvite(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { email } = createAdminInviteSchema.parse(req.body); + const invite = await usersService.createAdminInvite({ email, invitedByUserId: req.user.id }); + // Logged without the token: the invite link is a credential. + // eslint-disable-next-line no-console + console.info(`[users] admin ${req.user.id} created an invite for ${invite.email} (expires ${invite.expiresAt.toISOString()})`); + res.status(201).json(invite); +} + +/** + * Deactivates an account. There is no `AuditEvent` table yet (see + * ARCHITECTURE_ESSENTIALS.md known gaps), so — like the login-throttle clear + * endpoint — the console line is the interim record of who did this. + */ +export async function deactivate(req: Request, res: Response) { + const userId = getParam(req, 'id'); + const user = await usersService.deactivateUser({ userId }); + // eslint-disable-next-line no-console + console.info(`[users] admin ${req.user?.id ?? 'unknown'} deactivated user ${userId}`); + res.status(200).json(user); +} + +export async function reactivate(req: Request, res: Response) { + const userId = getParam(req, 'id'); + const user = await usersService.reactivateUser({ userId }); + // eslint-disable-next-line no-console + console.info(`[users] admin ${req.user?.id ?? 'unknown'} reactivated user ${userId}`); + res.status(200).json(user); +} diff --git a/backend/src/modules/users/repositories/index.ts b/backend/src/modules/users/repositories/index.ts index 2402cb1..5a1005c 100644 --- a/backend/src/modules/users/repositories/index.ts +++ b/backend/src/modules/users/repositories/index.ts @@ -1,3 +1,105 @@ // users repositories — placeholder. Data access only. Not to be imported // by other modules directly — go through services/ instead. -export {}; +// +// Phase 8 (issue #39): this module owns the shared `User` row and admin +// account lifecycle (invites, deactivation). `auth` still owns credentials +// and sessions — it calls into these services for user creation, never the +// other way round. +import { prisma } from '../../../db/prisma-client'; +import type { Role } from '@prisma/client'; + +export function findUserById(id: string) { + return prisma.user.findUnique({ where: { id } }); +} + +export function findUserByEmail(email: string) { + return prisma.user.findUnique({ where: { email } }); +} + +/** Active = not deactivated. Used for the "last active admin" guard. */ +export function countActiveUsersByRole(role: Role) { + return prisma.user.count({ where: { role, deactivatedAt: null } }); +} + +export function setUserDeactivated(userId: string, deactivatedAt: Date) { + return prisma.user.update({ + where: { id: userId }, + data: { deactivatedAt, sessionsInvalidatedAt: deactivatedAt }, + }); +} + +export function setUserReactivated(userId: string, at: Date) { + return prisma.user.update({ + where: { id: userId }, + data: { + deactivatedAt: null, + // Also bumped on reactivation: tokens issued before deactivation must + // not come back to life because the account was re-enabled. + sessionsInvalidatedAt: at, + }, + }); +} + +// --- Admin invites (Phase 8, issue #39) ---------------------------------- + +/** Creates the invite and retires any outstanding invite for the same + * address, in one transaction — issuing a new link shouldn't leave two live + * ones. Retiring means setting `consumedAt`, never deleting. */ +export function replaceAdminInvite(input: { + email: string; + tokenHash: string; + expiresAt: Date; + invitedByUserId: string; +}) { + return prisma.$transaction(async (tx) => { + await tx.adminInvite.updateMany({ + where: { email: input.email, consumedAt: null }, + data: { consumedAt: new Date() }, + }); + return tx.adminInvite.create({ + data: { + email: input.email, + tokenHash: input.tokenHash, + expiresAt: input.expiresAt, + invitedByUserId: input.invitedByUserId, + }, + }); + }); +} + +export function findAdminInviteByTokenHash(tokenHash: string) { + return prisma.adminInvite.findUnique({ where: { tokenHash } }); +} + +/** Consumes the invite and creates the admin account atomically: a + * consumed invite with no account (or an account from an already-consumed + * invite) must never be observable. Returns null if the invite was already + * consumed by a concurrent redemption — see services/redeemAdminInvite. + * + * TODO(issue #34): when email verification lands, an invited/bootstrapped + * admin should be created already verified — an invite is an admin-vouched + * address, and there is no email provider to verify against until issue + * #17. The `emailVerifiedAt` column doesn't exist on this branch, so it + * can't be set here yet; wire it up in the PR that merges both. */ +export function consumeAdminInviteAndCreateAdmin(input: { + inviteId: string; + email: string; + passwordHash: string; + consumedAt: Date; +}) { + return prisma.$transaction(async (tx) => { + const consumed = await tx.adminInvite.updateMany({ + where: { id: input.inviteId, consumedAt: null }, + data: { consumedAt: input.consumedAt }, + }); + if (consumed.count !== 1) return null; + + return tx.user.create({ + data: { + email: input.email, + passwordHash: input.passwordHash, + role: 'admin', + }, + }); + }); +} diff --git a/backend/src/modules/users/routes/index.ts b/backend/src/modules/users/routes/index.ts index 329b96c..c2b810c 100644 --- a/backend/src/modules/users/routes/index.ts +++ b/backend/src/modules/users/routes/index.ts @@ -1,3 +1,20 @@ -// users routes — placeholder. See PRD.md / ARCHITECTURE.md §3 for this module's scope. -// TODO(Phase: see ROADMAP.md): define routes for users. -export {}; +// users routes — admin account lifecycle (Phase 8, issue #39). Mounted at +// `/admin` in app.ts; every route is admin-only. +import { Router } from 'express'; +import { authenticate, requireRole } from '../../../middleware/auth'; +import * as usersController from '../controllers'; + +export const adminRouter = Router(); + +adminRouter.use(authenticate, requireRole('admin')); + +// Invite a new admin. Returns the invite token once — only its hash is +// stored. Redemption happens at `POST /auth/signup-with-invite` (auth's +// module: it owns sessions), which calls this module's service to do the +// invite part. +adminRouter.post('/invites', usersController.createInvite); + +// Soft deactivation: refuses only for the last active admin, and never +// deletes clinical or payment records (nothing is keyed to this flag). +adminRouter.patch('/users/:id/deactivate', usersController.deactivate); +adminRouter.patch('/users/:id/reactivate', usersController.reactivate); diff --git a/backend/src/modules/users/services/index.ts b/backend/src/modules/users/services/index.ts index e95698a..1dc286b 100644 --- a/backend/src/modules/users/services/index.ts +++ b/backend/src/modules/users/services/index.ts @@ -1,3 +1,167 @@ -// users services — placeholder. Other modules must only import from here, -// never from repositories/ directly. See AGENTS.md "Non-negotiable rules". -export {}; +// users services — the only exported surface other modules (and this +// module's own controllers) may call. Never import repositories/* from +// outside this module. See AGENTS.md "Non-negotiable rules". +// +// Phase 8 (issue #39): admin account lifecycle — invites and deactivation. +import { config } from '../../../config'; +import { HttpError } from '../../../http-error'; +import * as usersRepo from '../repositories'; +import { generateAdminInviteToken, hashAdminInviteToken } from './invite-tokens'; +import type { Role } from '@prisma/client'; + +export interface PublicUser { + id: string; + email: string; + role: Role; + deactivatedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +type UserRow = { + id: string; + email: string; + role: Role; + deactivatedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +function toPublicUser(user: UserRow): PublicUser { + return { + id: user.id, + email: user.email, + role: user.role, + deactivatedAt: user.deactivatedAt, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; +} + +// --- Invites ------------------------------------------------------------- + +export interface CreateAdminInviteInput { + email: string; + invitedByUserId: string; +} + +export interface CreatedAdminInvite { + id: string; + email: string; + expiresAt: Date; + /** + * The raw invite token, returned exactly once — only its hash is stored, + * so it can never be retrieved again. There is no email provider yet + * (issue #17), so the inviting admin has to pass the link on out of band; + * both are deliberately never written to a log. + */ + token: string; + inviteUrl: string; +} + +export async function createAdminInvite(input: CreateAdminInviteInput): Promise { + const email = input.email.trim(); + if (!email) throw new HttpError(400, 'An email address is required.'); + + const existing = await usersRepo.findUserByEmail(email); + if (existing) { + // Deliberately not "an account exists": that's already visible from the + // signup endpoint, and an admin inviting a colleague needs the real + // reason rather than a coin flip. Refusing here keeps the invite table + // to addresses that can actually be redeemed. + throw new HttpError(409, 'An account with this email already exists — deactivate or promote it instead of inviting it.'); + } + + const token = generateAdminInviteToken(); + const expiresAt = new Date(Date.now() + config.adminInvites.ttlHours * 60 * 60 * 1000); + const invite = await usersRepo.replaceAdminInvite({ + email, + tokenHash: hashAdminInviteToken(token), + expiresAt, + invitedByUserId: input.invitedByUserId, + }); + + return { + id: invite.id, + email: invite.email, + expiresAt: invite.expiresAt, + token, + inviteUrl: `${config.adminInvites.acceptUrl}?token=${encodeURIComponent(token)}`, + }; +} + +export interface RedeemAdminInviteInput { + token: string; + email: string; + passwordHash: string; +} + +/** Called by the auth module's `POST /auth/signup-with-invite` — the invite + * is the users module's business, the session is auth's. Returns the created + * admin account, or throws a 400/403 that says nothing about which invite + * tokens exist beyond "this one isn't usable". */ +export async function redeemAdminInvite(input: RedeemAdminInviteInput): Promise { + const invite = await usersRepo.findAdminInviteByTokenHash(hashAdminInviteToken(input.token)); + if (!invite) throw new HttpError(400, 'Invalid admin invite token.'); + if (invite.consumedAt) throw new HttpError(400, 'This invite has already been used.'); + if (invite.expiresAt.getTime() <= Date.now()) { + throw new HttpError(400, 'This invite has expired. Ask an admin for a new one.'); + } + if (invite.email.toLowerCase() !== input.email.trim().toLowerCase()) { + throw new HttpError(403, 'This invite was issued for a different email address.'); + } + + const user = await usersRepo.consumeAdminInviteAndCreateAdmin({ + inviteId: invite.id, + email: input.email.trim(), + passwordHash: input.passwordHash, + consumedAt: new Date(), + }); + + // Lost a race with a concurrent redemption of the same invite. + if (!user) throw new HttpError(400, 'This invite has already been used.'); + + return toPublicUser(user); +} + +// --- Deactivation -------------------------------------------------------- + +export interface DeactivateInput { + userId: string; +} + +/** + * Deactivates an account: it can no longer log in, refresh, or use an + * existing access token (see auth's `assertActiveSession`). + * + * Nothing is deleted. No clinical or payment record is keyed to this flag, + * and this function only ever writes `deactivatedAt` / + * `sessionsInvalidatedAt` — that's the whole point of a soft deactivation + * (AGENTS.md hard rules on append-only/mutability of order records). + */ +export async function deactivateUser(input: DeactivateInput): Promise { + const user = await usersRepo.findUserById(input.userId); + if (!user) throw new HttpError(404, 'User not found.'); + if (user.deactivatedAt) return toPublicUser(user); // idempotent + + // The admin console is the only way back in, so the last active admin + // must not be able to lock everyone out of it (explicit in issue #39). + if (user.role === 'admin') { + const activeAdmins = await usersRepo.countActiveUsersByRole('admin'); + if (activeAdmins <= 1) { + throw new HttpError(409, 'Cannot deactivate the last active admin.'); + } + } + + const updated = await usersRepo.setUserDeactivated(user.id, new Date()); + return toPublicUser(updated); +} + +export async function reactivateUser(input: DeactivateInput): Promise { + const user = await usersRepo.findUserById(input.userId); + if (!user) throw new HttpError(404, 'User not found.'); + if (!user.deactivatedAt) return toPublicUser(user); // idempotent + + const updated = await usersRepo.setUserReactivated(user.id, new Date()); + return toPublicUser(updated); +} diff --git a/backend/src/modules/users/services/invite-tokens.ts b/backend/src/modules/users/services/invite-tokens.ts new file mode 100644 index 0000000..446040d --- /dev/null +++ b/backend/src/modules/users/services/invite-tokens.ts @@ -0,0 +1,14 @@ +import { createHash, randomBytes } from 'node:crypto'; + +/** The invite token that goes in the link an admin hands over. Never + * persisted, never logged — only its hash is. */ +export function generateAdminInviteToken(): string { + return randomBytes(32).toString('base64url'); +} + +/** SHA-256 (not bcrypt): this is 256 bits of CSPRNG output, so there is + * nothing to brute-force, and hashing must be cheap because it happens on + * every redemption attempt. */ +export function hashAdminInviteToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} diff --git a/backend/src/modules/users/validation.ts b/backend/src/modules/users/validation.ts new file mode 100644 index 0000000..c33c5d2 --- /dev/null +++ b/backend/src/modules/users/validation.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +// Phase 8, issue #39. Deactivate/reactivate take no body — the target is the +// path parameter. +export const createAdminInviteSchema = z.object({ + email: z.string().email(), +}); diff --git a/backend/tests/integration/admin-lifecycle.test.mts b/backend/tests/integration/admin-lifecycle.test.mts new file mode 100644 index 0000000..5973da2 --- /dev/null +++ b/backend/tests/integration/admin-lifecycle.test.mts @@ -0,0 +1,362 @@ +// Integration tests for the admin account lifecycle (Phase 8, issue #39): +// the bootstrap CLI, invites (hashed/expiring/single-use), deactivation +// (login + refresh + live access tokens all refused), the last-active-admin +// guard, and the promise that deactivation deletes nothing. +// +// Self-contained embedded Postgres, own port (5439) so it runs alongside the +// other integration files. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5439; +const runId = Date.now(); +const PASSWORD = 'longenough123'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-admin-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); + +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +type App = ReturnType; +type Prisma = typeof import('../../src/db/prisma-client.js').prisma; + +function cookiePair(res: { headers: Record }, name: string): string | null { + const raw = res.headers['set-cookie']; + const list = Array.isArray(raw) ? (raw as string[]) : typeof raw === 'string' ? [raw] : []; + const match = list.find((cookie) => cookie.startsWith(`${name}=`)); + return match ? match.split(';')[0]! : null; +} + +/** Runs the bootstrap CLI the way an operator would: password on stdin, + * never in argv. */ +function runCreateAdmin(args: string[], stdin: string) { + try { + const stdout = execFileSync('node', ['scripts/create-admin.mjs', ...args], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + input: stdin, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { status: 0, stdout, stderr: '' }; + } catch (err) { + const failure = err as { status?: number; stdout?: string; stderr?: string }; + return { status: failure.status ?? 1, stdout: failure.stdout ?? '', stderr: failure.stderr ?? '' }; + } +} + +describe('admin account lifecycle', () => { + let app: App; + let prisma: Prisma; + let bootstrapAdminEmail: string; + let bootstrapAdminToken: string; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + }); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + async function signupPatient(label: string) { + const email = `admin-lifecycle-${label}-${runId}@example.com`; + const res = await request(app) + .post('/auth/signup') + .send({ role: 'patient', email, password: PASSWORD, fullName: `Admin Lifecycle ${label}` }); + expect(res.status).toBe(200); + return { email, accessToken: res.body.accessToken as string, cookie: cookiePair(res, 'docforum_refresh')!, raw: res }; + } + + async function loginAs(email: string, password = PASSWORD) { + const res = await request(app).post('/auth/login').send({ email, password }); + expect(res.status).toBe(200); + return { accessToken: res.body.accessToken as string, cookie: cookiePair(res, 'docforum_refresh')!, raw: res }; + } + + it('bootstraps an admin from the CLI, refuses a second without --force, and rejects passwords in argv', async () => { + bootstrapAdminEmail = `bootstrap-admin-${runId}@example.com`; + const created = runCreateAdmin([`--email=${bootstrapAdminEmail}`], `${PASSWORD}\n`); + expect(created.status).toBe(0); + + const admin = await prisma.user.findUniqueOrThrow({ where: { email: bootstrapAdminEmail } }); + expect(admin.role).toBe('admin'); + expect(admin.deactivatedAt).toBeNull(); + // The password is hashed, and does not appear in the CLI's output. + expect(admin.passwordHash).not.toContain(PASSWORD); + expect(created.stdout).not.toContain(PASSWORD); + expect(created.stdout).not.toContain(admin.passwordHash); + + // A stray second run must not quietly add another administrator. + const refused = runCreateAdmin([`--email=second-admin-${runId}@example.com`], `${PASSWORD}\n`); + expect(refused.status).not.toBe(0); + expect(refused.stderr).toMatch(/--force/); + expect(await prisma.user.count({ where: { email: `second-admin-${runId}@example.com` } })).toBe(0); + + const forced = runCreateAdmin([`--email=second-admin-${runId}@example.com`, '--force'], `${PASSWORD}\n`); + expect(forced.status).toBe(0); + + // Credentials never travel in argv (`ps`/history/CI logs). + const argvRefused = runCreateAdmin([`--email=third-${runId}@example.com`, `--password=${PASSWORD}`], ''); + expect(argvRefused.status).not.toBe(0); + expect(argvRefused.stderr).toMatch(/command-line arguments/); + expect(await prisma.user.count({ where: { email: `third-${runId}@example.com` } })).toBe(0); + + bootstrapAdminToken = (await loginAs(bootstrapAdminEmail)).accessToken; + }); + + it('issues a hashed, expiring, single-use invite that redeems into an admin session', async () => { + const inviteeEmail = `invited-admin-${runId}@example.com`; + + const created = await request(app) + .post('/admin/invites') + .set('Authorization', `Bearer ${bootstrapAdminToken}`) + .send({ email: inviteeEmail }); + expect(created.status).toBe(201); + + const token = created.body.token as string; + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(created.body.email).toBe(inviteeEmail); + + // Stored hashed, never raw, with a future expiry. + const stored = await prisma.adminInvite.findFirstOrThrow({ where: { email: inviteeEmail } }); + expect(stored.tokenHash).not.toBe(token); + expect(stored.tokenHash).toMatch(/^[a-f0-9]{64}$/); + expect(stored.expiresAt.getTime()).toBeGreaterThan(Date.now()); + expect(stored.consumedAt).toBeNull(); + + const redeemed = await request(app) + .post('/auth/signup-with-invite') + .send({ token, email: inviteeEmail, password: PASSWORD }); + expect(redeemed.status).toBe(200); + expect(redeemed.body.user.role).toBe('admin'); + + const invite = await prisma.adminInvite.findFirstOrThrow({ where: { email: inviteeEmail } }); + expect(invite.consumedAt).not.toBeNull(); + + // Single-use: the same link can't mint a second admin. + const replay = await request(app) + .post('/auth/signup-with-invite') + .send({ token, email: inviteeEmail, password: PASSWORD }); + expect(replay.status).toBe(400); + expect(await prisma.user.count({ where: { email: inviteeEmail } })).toBe(1); + + // And the new admin really is an admin. + const asNewAdmin = await request(app) + .post('/admin/invites') + .set('Authorization', `Bearer ${redeemed.body.accessToken as string}`) + .send({ email: `invited-by-new-admin-${runId}@example.com` }); + expect(asNewAdmin.status).toBe(201); + }); + + it('refuses invites from non-admins, for a different address, and once expired', async () => { + const patient = await signupPatient('invite-authz'); + const asPatient = await request(app) + .post('/admin/invites') + .set('Authorization', `Bearer ${patient.accessToken}`) + .send({ email: `whatever-${runId}@example.com` }); + expect(asPatient.status).toBe(403); + + // Existing accounts aren't invitable either — that's a promote, + // not an invite. + const forExisting = await request(app) + .post('/admin/invites') + .set('Authorization', `Bearer ${bootstrapAdminToken}`) + .send({ email: patient.email }); + expect(forExisting.status).toBe(409); + + const wrongAddressEmail = `wrong-address-${runId}@example.com`; + const invite = await request(app) + .post('/admin/invites') + .set('Authorization', `Bearer ${bootstrapAdminToken}`) + .send({ email: wrongAddressEmail }); + expect(invite.status).toBe(201); + + const mismatched = await request(app) + .post('/auth/signup-with-invite') + .send({ token: invite.body.token as string, email: `someone-else-${runId}@example.com`, password: PASSWORD }); + expect(mismatched.status).toBe(403); + + // Expiry: age the stored invite rather than waiting it out. + const expiringEmail = `expiring-${runId}@example.com`; + const expiring = await request(app) + .post('/admin/invites') + .set('Authorization', `Bearer ${bootstrapAdminToken}`) + .send({ email: expiringEmail }); + await prisma.adminInvite.updateMany({ + where: { email: expiringEmail }, + data: { expiresAt: new Date(Date.now() - 1000) }, + }); + + const expired = await request(app) + .post('/auth/signup-with-invite') + .send({ token: expiring.body.token as string, email: expiringEmail, password: PASSWORD }); + expect(expired.status).toBe(400); + expect(await prisma.user.count({ where: { email: expiringEmail } })).toBe(0); + }); + + it('deactivation blocks login, refresh and existing access tokens, and reactivation lets them back in', async () => { + const patient = await signupPatient('deactivate'); + const patientRow = await prisma.user.findUniqueOrThrow({ where: { email: patient.email } }); + + // The access token works before deactivation. + const before = await request(app) + .get('/appointments/mine') + .set('Authorization', `Bearer ${patient.accessToken}`); + expect(before.status).toBe(200); + + const deactivated = await request(app) + .patch(`/admin/users/${patientRow.id}/deactivate`) + .set('Authorization', `Bearer ${bootstrapAdminToken}`); + expect(deactivated.status).toBe(200); + expect(deactivated.body.deactivatedAt).not.toBeNull(); + + // Login is refused… + const login = await request(app).post('/auth/login').send({ email: patient.email, password: PASSWORD }); + expect(login.status).toBe(403); + + // …refresh is refused (the refresh cookie is still valid)… + const refresh = await request(app) + .post('/auth/refresh') + .set('Cookie', patient.cookie); + expect(refresh.status).toBe(403); + + // …and the access token that was already in the client's hand stops + // working immediately, rather than living out its 15-minute TTL. + const after = await request(app) + .get('/appointments/mine') + .set('Authorization', `Bearer ${patient.accessToken}`); + expect(after.status).toBe(401); + + // Reactivation: logging in works again… + const reactivated = await request(app) + .patch(`/admin/users/${patientRow.id}/reactivate`) + .set('Authorization', `Bearer ${bootstrapAdminToken}`); + expect(reactivated.status).toBe(200); + expect(reactivated.body.deactivatedAt).toBeNull(); + + // `iat` is second-precision and `isSessionRevoked` deliberately counts a + // token minted in the same second as the invalidation as revoked (the + // safe direction). A login immediately after reactivation can therefore + // mint a same-second token that is correctly refused — wait out the + // second boundary so the fresh token is genuinely fresh. + await new Promise((resolve) => setTimeout(resolve, 1100)); + + const freshLogin = await loginAs(patient.email); + const withFreshToken = await request(app) + .get('/appointments/mine') + .set('Authorization', `Bearer ${freshLogin.accessToken}`); + expect(withFreshToken.status).toBe(200); + + // …but the pre-deactivation token stays dead: sessions invalidated by a + // deactivation must not come back to life with the account. + const reused = await request(app) + .get('/appointments/mine') + .set('Authorization', `Bearer ${patient.accessToken}`); + expect(reused.status).toBe(401); + + // A wrong password is still just a 401, and a missing user is a 404. + const missing = await request(app) + .patch('/admin/users/00000000-0000-0000-0000-000000000000/deactivate') + .set('Authorization', `Bearer ${bootstrapAdminToken}`); + expect(missing.status).toBe(404); + }); + + it('refuses to deactivate the last active admin', async () => { + // Leave exactly one active admin: the two bootstrapped ones, minus one. + const admins = await prisma.user.findMany({ where: { role: 'admin', deactivatedAt: null }, select: { id: true, email: true } }); + expect(admins.length).toBeGreaterThanOrEqual(2); + const [keep, ...others] = admins; + for (const other of others) { + await prisma.user.update({ where: { id: other.id }, data: { deactivatedAt: new Date() } }); + } + + const refused = await request(app) + .patch(`/admin/users/${keep!.id}/deactivate`) + .set('Authorization', `Bearer ${bootstrapAdminToken}`); + expect(refused.status).toBe(409); + expect(refused.body.message).toMatch(/last active admin/i); + + const stillActive = await prisma.user.findUniqueOrThrow({ where: { id: keep!.id } }); + expect(stillActive.deactivatedAt).toBeNull(); + }); + + it('deactivation deletes no clinical or payment records', async () => { + // A booked appointment is the strongest thing this phase can check — + // there are no Prescription/LabOrder/PaymentIntent rows to protect yet. + const doctor = `doctor-${runId}@example.com`; + const doctorSignup = await request(app) + .post('/auth/signup') + .send({ role: 'doctor', email: doctor, password: PASSWORD, fullName: 'Deactivation Test Doctor' }); + expect(doctorSignup.status).toBe(200); + const doctorToken = doctorSignup.body.accessToken as string; + + const doctorMe = await request(app).get('/doctors/me').set('Authorization', `Bearer ${doctorToken}`); + const doctorProfileId = doctorMe.body.id as string; + + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const generate = await request(app) + .post(`/availability/doctors/${doctorProfileId}/slots/generate`) + .set('Authorization', `Bearer ${doctorToken}`) + .send({ date: tomorrow, startHour: 9, endHour: 10, slotLengthMinutes: 30 }); + expect(generate.status).toBe(201); + const slots = await request(app).get(`/availability/doctors/${doctorProfileId}/slots`); + + const patient = await signupPatient('records'); + // Booking is gated on a verified email (Phase 8, issue #34 — merged in + // from main after this test was written). Deletion-resistance is the + // subject here, not verification, so the fixture marks the patient + // verified directly rather than replaying the emailed-token flow. + await prisma.user.update({ + where: { email: patient.email }, + data: { emailVerifiedAt: new Date() }, + }); + const booking = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${patient.accessToken}`) + .send({ availabilitySlotId: slots.body[0].id as string }); + expect(booking.status).toBe(201); + + const patientRow = await prisma.user.findUniqueOrThrow({ where: { email: patient.email } }); + const deactivated = await request(app) + .patch(`/admin/users/${patientRow.id}/deactivate`) + .set('Authorization', `Bearer ${bootstrapAdminToken}`); + expect(deactivated.status).toBe(200); + + // The appointment and the slot it consumed are untouched. + const appointment = await prisma.appointment.findUniqueOrThrow({ where: { id: booking.body.id as string } }); + expect(appointment.patientProfileId).toBeTruthy(); + const slot = await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: appointment.availabilitySlotId } }); + expect(slot.status).toBe('booked'); + }); +}); diff --git a/backend/tests/integration/payments-bigint.test.mts b/backend/tests/integration/payments-bigint.test.mts index 7ef3eea..1b4c0a3 100644 --- a/backend/tests/integration/payments-bigint.test.mts +++ b/backend/tests/integration/payments-bigint.test.mts @@ -28,7 +28,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import EmbeddedPostgres from 'embedded-postgres'; const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5438; +const PORT = 5440; // unique per integration file — 5438 collides with access-control const runId = Date.now(); // 2^53 + 1 — exactly one more than Number.MAX_SAFE_INTEGER, so it only diff --git a/backend/tests/unit/admin-invite-tokens.test.ts b/backend/tests/unit/admin-invite-tokens.test.ts new file mode 100644 index 0000000..2007047 --- /dev/null +++ b/backend/tests/unit/admin-invite-tokens.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { + generateAdminInviteToken, + hashAdminInviteToken, +} from '../../src/modules/users/services/invite-tokens'; + +describe('admin invite tokens (issue #39)', () => { + it('generates a fresh, URL-safe token each time', () => { + const tokens = new Set(Array.from({ length: 50 }, () => generateAdminInviteToken())); + expect(tokens.size).toBe(50); + for (const token of tokens) { + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + } + }); + + it('hashes deterministically and never stores the raw token', () => { + const token = generateAdminInviteToken(); + const hash = hashAdminInviteToken(token); + expect(hash).toBe(hashAdminInviteToken(token)); + expect(hash).toMatch(/^[a-f0-9]{64}$/); + expect(hash).not.toContain(token); + }); + + it('produces different hashes for different tokens', () => { + expect(hashAdminInviteToken(generateAdminInviteToken())).not.toBe(hashAdminInviteToken(generateAdminInviteToken())); + }); +}); diff --git a/backend/vitest.config.mts b/backend/vitest.config.mts index c500601..b184413 100644 --- a/backend/vitest.config.mts +++ b/backend/vitest.config.mts @@ -7,6 +7,12 @@ export default defineConfig({ // Booking-concurrency tests fire real concurrent transactions against // a real Postgres — a bit more headroom than vitest's 5s default. testTimeout: 15_000, + // The integration beforeAll hooks do even more than one test: start an + // embedded Postgres, run `prisma migrate deploy`, import the app and + // seed actors. Several such files run in parallel, so on a loaded CI + // runner the 10s default cut the hook off mid-seed (issue #39 CI run: + // access-control timed out at 101 tests skipped). + hookTimeout: 30_000, env: { JWT_ACCESS_SECRET: 'test-access-secret', JWT_REFRESH_SECRET: 'test-refresh-secret',