diff --git a/ROADMAP.md b/ROADMAP.md index dfadfdbb..0c999b83 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -394,6 +394,24 @@ and issue links. seam so the 15 unit + 18 integration tests run with zero network. Docs updated: ARCHITECTURE §4.10, essentials data-model map, docs/api/README.md (endpoints + errors + unfunded behaviour). +- 2026-10-16 — Implemented issue #78 (verified wallet links, stacked on + #77's branch): `WalletLink` generalized from facility-only/typed-in to + an owner (patient | facility) with `verificationMethod` / + `verifiedAt` / `replacedAt`, one active link per owner enforced by a + DB unique sentinel (Prisma can't express a partial index — noted in + the schema), and the migration backfills existing facility rows as + `unverified_legacy` (proven by a real migration test that inserts an + old-shape row first). `POST /payments/wallet-link` now takes only a + signed SEP-10 challenge (strict body — a raw address is a 400) and + creates the link inside the same transaction that consumes the nonce; + the only `sep10`-row writer is that flow. Patient link responses + carry ADR 0005's on-chain privacy notice verbatim (unit-tested + against the ADR file). Funding refuses unverified legacy links after + `WALLET_LINK_UNVERIFIED_FUNDING_CUTOFF` (default 2027-01-01, + startup-validated, `409 WALLET_LINK_UNVERIFIED`) and snapshots + `PaymentIntent.fundedWalletAddress` so re-linking can't rewrite an + escrow's payee. 8 unit + 12 integration tests (fake Horizon + fake + escrow SDK — no network). ADR 0005 unchanged (no factual conflict). - 2026-10-15 — Started Phase 8's auth-hardening work, scoped as issues #34 (email verification), #35 (login brute-force throttling), #37 (CSRF for the cookie-authenticated endpoints + per-environment CORS allowlist) and #39 (admin account lifecycle). All four are independent of Phases 3–7 and of each other. #34 merged as PR #137: `User.emailVerifiedAt` + diff --git a/backend/prisma/migrations/20261016153822_add_verified_wallet_links/migration.sql b/backend/prisma/migrations/20261016153822_add_verified_wallet_links/migration.sql new file mode 100644 index 00000000..6429b554 --- /dev/null +++ b/backend/prisma/migrations/20261016153822_add_verified_wallet_links/migration.sql @@ -0,0 +1,66 @@ +-- Verified wallet links (issue #78): generalize WalletLink from +-- facility-only/typed-in to an owner (patient | facility) with a +-- verification method, and snapshot the funded payee address on +-- PaymentIntent. +-- +-- Hand-ordered (generated by `prisma migrate diff`, then rewritten): +-- the generated script added NOT NULL columns and dropped `facilityId` +-- before any backfill, which fails on a populated table. This version +-- adds columns nullable → backfills from `facilityId` → constrains → +-- only then drops the old shape, so it is safe to run against existing +-- data (every pre-existing facility row becomes an active +-- `unverified_legacy` link owned by its facility). +-- +-- Reversibility: reversing back to the old shape is practical only for +-- `ownerType = 'facility'` rows (recreate `facilityId` from `ownerId`, +-- restore the FK/unique index); patient-era rows and verified metadata +-- have no old-shape home, so a down migration would drop them. Not +-- written for that reason — restore from backup if reversal is needed. + +-- CreateEnum +CREATE TYPE "WalletLinkOwnerType" AS ENUM ('patient', 'facility'); + +-- CreateEnum +CREATE TYPE "WalletLinkVerificationMethod" AS ENUM ('sep10', 'unverified_legacy'); + +-- AlterTable: PaymentIntent gains the funding-time payee snapshot (null +-- until the next funding; no backfill possible — historical intents +-- never recorded their payee address). +ALTER TABLE "PaymentIntent" ADD COLUMN "fundedWalletAddress" TEXT; + +-- AlterTable: new columns nullable first so rows can be backfilled. +ALTER TABLE "WalletLink" ADD COLUMN "activeOwnerKey" TEXT; +ALTER TABLE "WalletLink" ADD COLUMN "ownerId" TEXT; +ALTER TABLE "WalletLink" ADD COLUMN "ownerType" "WalletLinkOwnerType"; +ALTER TABLE "WalletLink" ADD COLUMN "replacedAt" TIMESTAMP(3); +ALTER TABLE "WalletLink" ADD COLUMN "verificationMethod" "WalletLinkVerificationMethod"; +ALTER TABLE "WalletLink" ADD COLUMN "verifiedAt" TIMESTAMP(3); + +-- Backfill: every existing row was a facility link typed in by hand → +-- active, owned by its facility, explicitly marked unverified. Their +-- addresses were never SEP-10-checked, so `verifiedAt` stays NULL and +-- funding honours them only until WALLET_LINK_UNVERIFIED_FUNDING_CUTOFF. +UPDATE "WalletLink" +SET "ownerType" = 'facility', + "ownerId" = "facilityId", + "verificationMethod" = 'unverified_legacy', + "activeOwnerKey" = 'facility:' || "facilityId" +WHERE "facilityId" IS NOT NULL; + +-- Now constrain (all rows backfilled above; table empty if fresh). +ALTER TABLE "WalletLink" ALTER COLUMN "ownerType" SET NOT NULL; +ALTER TABLE "WalletLink" ALTER COLUMN "ownerId" SET NOT NULL; +ALTER TABLE "WalletLink" ALTER COLUMN "verificationMethod" SET NOT NULL; + +-- Drop old facility-only shape (FK must go before the column). +ALTER TABLE "WalletLink" DROP CONSTRAINT "WalletLink_facilityId_fkey"; +DROP INDEX "WalletLink_facilityId_key"; +ALTER TABLE "WalletLink" DROP COLUMN "facilityId"; + +-- CreateIndex +-- The one-active-link-per-owner DB guarantee: unique on the sentinel +-- column that is non-NULL exactly while the link is active. +CREATE UNIQUE INDEX "WalletLink_activeOwnerKey_key" ON "WalletLink"("activeOwnerKey"); + +-- CreateIndex +CREATE INDEX "WalletLink_ownerType_ownerId_idx" ON "WalletLink"("ownerType", "ownerId"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2e4f8999..8a88ff6b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -177,8 +177,8 @@ model User { accessLogs AccessLog[] // Phase 8 (issue #42, docs/adr/0011): dependents this user is guardian of. guardianships Guardianship[] - orderDisputeResolutions OrderDisputeResolution[] sep10Challenges Sep10Challenge[] + orderDisputeResolutions OrderDisputeResolution[] @@index([role]) } @@ -485,7 +485,11 @@ model FacilityProfile { labResults LabResult[] paymentIntents PaymentIntent[] fulfillments FulfillmentRecord[] - walletLink WalletLink? + // NOTE: no `walletLink` relation anymore — WalletLink is generalized to + // a polymorphic owner (patient | facility) as of issue #78, so it + // carries no FK relation to either profile (same shape as + // FulfillmentRecord's polymorphic order ref, §6.1.3). Ownership is + // validated in the payments service, not by the schema. prices FacilityPrice[] orders Order[] fulfillmentProofs FulfillmentProof[] @@ -1032,6 +1036,14 @@ model PaymentIntent { // release/refund against the right on-chain escrow. escrowId BigInt? stellarTxHash String? + // Snapshot of the payee address this intent was actually funded to + // (issue #78). Set at funding time from the facility's then-ACTIVE + // wallet link and never changed afterwards — so re-linking a + // different address later cannot rewrite which wallet an already- + // funded escrow paid, and the escrow keeps referencing its link even + // after that link is replaced. Append-only-in-spirit: written exactly + // once, with the funding transition. + fundedWalletAddress String? // Which deployed contract this intent was funded against — this repo // has already had two testnet contract IDs (Phase E1 vs. E2), so this // isn't hypothetical. @@ -1090,7 +1102,6 @@ model Sep10Challenge { expiresAt DateTime // matches the challenge's timebounds maxTime (+ record TTL) consumedAt DateTime? createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) // Pruning and the "is this row still redeemable" check both scan by @@ -1099,19 +1110,74 @@ model Sep10Challenge { @@index([expiresAt]) } -model WalletLink { - id String @id @default(uuid()) - // Scoped to the facility being paid, not the patient — v1 is a - // custodial model (docforum-core funds escrows from its own platform - // Stellar identity; patients never hold or sign with a wallet here). - // See ADR 0004. - facilityId String @unique - facility FacilityProfile @relation(fields: [facilityId], references: [id], onDelete: Cascade) +// Verified wallet links (issue #78, ADR 0005). Generalized from the +// facility-only, typed-in-address shape: an owner is a PatientProfile or +// a FacilityProfile (polymorphic ownerType+ownerId — deliberately no FK +// relation, same shape as FulfillmentRecord's polymorphic order ref), +// and every link carries HOW it was verified. +// +// A link row is immutable except for `replacedAt`/`activeOwnerKey`: +// re-linking deactivates the old row and inserts a new one, so history +// (including the address escrows were actually funded to — see +// PaymentIntent.fundedWalletAddress) is preserved rather than mutated. +// +// `verificationMethod`: +// - `sep10` — created ONLY by the POST /payments/wallet-link +// verification flow (services/wallet-link.ts), inside the same +// transaction that consumes the SEP-10 nonce. No seed, script, admin +// route or internal service method may create a `sep10` row — the +// constructor for it is private to that flow. +// - `unverified_legacy` — backfilled by the add_verified_wallet_links +// migration for pre-existing facility rows (typed-in addresses). +// Usable for funding only until +// WALLET_LINK_UNVERIFIED_FUNDING_CUTOFF passes. +// +// One ACTIVE link per owner, enforced at the DB level via +// `activeOwnerKey`: it holds ":" while active and +// NULL once replaced. A plain unique constraint on it gives exactly the +// "at most one unreplaced row per owner" guarantee — Prisma's schema DSL +// cannot express a partial unique index (`WHERE replacedAt IS NULL`), +// and a raw-SQL index would be silently dropped by the next +// `prisma migrate dev` as schema drift. Postgres treats NULLs as +// distinct, so any number of replaced rows can coexist. +enum WalletLinkOwnerType { + patient + facility +} - stellarPublicKey String +enum WalletLinkVerificationMethod { + sep10 + unverified_legacy +} + +model WalletLink { + id String @id @default(uuid()) + ownerType WalletLinkOwnerType + // PatientProfile.id or FacilityProfile.id. Polymorphic — no FK on + // purpose (see note above); existence + caller ownership are checked + // in services/wallet-link.ts before any write. + ownerId String + // G… address. NEVER accepted from a client-supplied field: derived + // server-side from the account the SEP-10 challenge was issued for and + // successfully verified. + stellarPublicKey String + verificationMethod WalletLinkVerificationMethod + // Set iff verificationMethod = 'sep10' (time the challenge passed). + verifiedAt DateTime? + // Set when a newer link superseded this one. Only these two fields + // ever change on a row; stellarPublicKey/verificationMethod are + // write-once. + replacedAt DateTime? + // ":" while this is the owner's active link, NULL + // once replaced. The unique index on this column IS the one-active- + // link-per-owner DB guarantee. + activeOwnerKey String? @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + // Lookups by owner (active-link read path, deactivation sweep). + @@index([ownerType, ownerId]) } // Soroban event ingestion (issue #86). Cursors are per contract deployment; diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 102952c9..85201b33 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -112,6 +112,17 @@ function parseCorsOrigins(raw: string | undefined, isProduction: boolean): strin .filter((origin) => origin.length > 0); } +/** Issue #78: the date after which funding refuses facility wallet links + * that were never SEP-10 verified. Defaults to 2027-01-01 (UTC) — a + * deliberate fail-closed default: legacy links keep working for a + * defined grace window without any deployment needing to remember to + * set the variable, but "never enforce" is never the default. Returns + * NaN for a malformed value so startup validation can name the var. */ +function parseWalletLinkCutoff(raw: string | undefined): number { + if (raw === undefined || raw.trim() === '') return Date.parse('2027-01-01T00:00:00Z'); + return Date.parse(raw); +} + export const config = { port: Number(process.env.PORT ?? 4000), databaseUrl: requireEnv('DATABASE_URL'), @@ -289,6 +300,9 @@ export const config = { prepareExpirySweepIntervalSeconds: Number(process.env.PAYMENT_PREPARE_EXPIRY_SWEEP_INTERVAL_SECONDS ?? 60), payoutPrivacyMode, payoutPrivacyPoolAddress: process.env.PAYOUT_PRIVACY_POOL_ADDRESS, + walletLinkUnverifiedFundingCutoff: parseWalletLinkCutoff( + process.env.WALLET_LINK_UNVERIFIED_FUNDING_CUTOFF, + ), }, // SEP-10 Stellar Web Authentication (issue #77, ADR 0005 step 4). sep10: { diff --git a/backend/src/modules/payments/controllers/index.ts b/backend/src/modules/payments/controllers/index.ts index 4cc725b6..73652a66 100644 --- a/backend/src/modules/payments/controllers/index.ts +++ b/backend/src/modules/payments/controllers/index.ts @@ -1,6 +1,6 @@ import type { Request, Response } from 'express'; import * as paymentsService from '../services'; -import { linkWalletSchema, paymentHistoryQuerySchema } from '../validation'; +import { walletLinkSchema, paymentHistoryQuerySchema } from '../validation'; import { HttpError } from '../../../http-error'; import { getParam } from '../../../request-params'; import { auditActor } from '../../../middleware/request-id'; @@ -38,16 +38,23 @@ function serializeIntent(intent: { export async function linkWallet(req: Request, res: Response) { if (!req.user) throw new HttpError(401, 'Authentication required.'); - const facilityId = await paymentsService.getOwnFacilityProfileId(req.user.id); - const { stellarPublicKey } = linkWalletSchema.parse(req.body); - const link = await paymentsService.linkWallet(facilityId, stellarPublicKey); - res.status(200).json(link); + // strict schema: rejects any raw-address field outright. + const { transaction } = walletLinkSchema.parse(req.body); + const link = await paymentsService.linkWalletViaSep10(req.user.id, req.user.role, transaction); + // Patient links always carry the ADR 0005 on-chain privacy notice, + // verbatim, in the response body — a client cannot render success + // without it (issue #78 requirement 3). Facility links (payout + // addresses) don't have a patient privacy dimension. + res.status(200).json( + link.ownerType === 'patient' + ? { ...link, privacyNotice: paymentsService.PATIENT_WALLET_LINK_PRIVACY_NOTICE } + : link, + ); } export async function getOwnWallet(req: Request, res: Response) { if (!req.user) throw new HttpError(401, 'Authentication required.'); - const facilityId = await paymentsService.getOwnFacilityProfileId(req.user.id); - const link = await paymentsService.getWalletLink(facilityId); + const link = await paymentsService.getOwnWalletLink(req.user.id, req.user.role); if (!link) throw new HttpError(404, 'No wallet linked yet.'); res.status(200).json(link); } @@ -103,3 +110,4 @@ export async function listFacilityLedger(req: Request, res: Response) { // SEP-10 Stellar Web Authentication (issue #77) — see ./sep10.ts. export { sep10Challenge, sep10Verify } from './sep10'; + diff --git a/backend/src/modules/payments/repositories/index.ts b/backend/src/modules/payments/repositories/index.ts index 50412225..fb4f1bd3 100644 --- a/backend/src/modules/payments/repositories/index.ts +++ b/backend/src/modules/payments/repositories/index.ts @@ -2,7 +2,9 @@ // data access only. Never store PHI here. Never call docforum-escrow // directly from here — go through services/. import { prisma } from '../../../db/prisma-client'; -import type { PaymentIntent, PaymentIntentStatus, Prisma } from '@prisma/client'; +import type { PaymentIntent, PaymentIntentStatus, Prisma, WalletLinkOwnerType } from '@prisma/client'; + +type Db = Prisma.TransactionClient; /** Reads FacilityProfile directly — a Phase 1 core identity model, not * something the (still-placeholder) facilities module exclusively owns. */ @@ -17,22 +19,51 @@ export function findPatientProfileByUserId(userId: string) { return prisma.patientProfile.findUnique({ where: { userId } }); } -export function upsertWalletLink(facilityId: string, stellarPublicKey: string) { - return prisma.walletLink.upsert({ - where: { facilityId }, - create: { facilityId, stellarPublicKey }, - update: { stellarPublicKey }, - }); +// --- Wallet links (issue #78) -------------------------------------------- +// NOTE ON WRITES: `createWalletLink` is the only WalletLink insert in +// the repo, and its sole caller is the SEP-10 verification flow in +// services/wallet-link.ts (inside the transaction that consumes the +// nonce). There is deliberately no upsert/raw-address write path and no +// legacy-creation function — `unverified_legacy` rows can only come from +// the migration backfill. + +export function createWalletLink( + data: { + ownerType: WalletLinkOwnerType; + ownerId: string; + stellarPublicKey: string; + verificationMethod: 'sep10'; + verifiedAt: Date; + activeOwnerKey: string; + }, + db: Db = prisma, +) { + return db.walletLink.create({ data }); } -export function findWalletLinkByFacilityId(facilityId: string) { - return prisma.walletLink.findUnique({ where: { facilityId } }); +export function findActiveWalletLink(ownerType: WalletLinkOwnerType, ownerId: string, db: Db = prisma) { + return db.walletLink.findFirst({ + where: { ownerType, ownerId, replacedAt: null }, + orderBy: { createdAt: 'desc' }, + }); } -/** The patient's payer wallet (ADR 0005). Rows are created by the SEP-10 - * linking flow once it exists — not by this module's HTTP surface yet. */ -export function findPatientWalletLinkByPatientProfileId(patientProfileId: string) { - return prisma.patientWalletLink.findUnique({ where: { patientProfileId } }); +/** Deactivates the owner's current link (if any) — conditional + * `updateMany`, so only an unreplaced row moves. Returns the number of + * rows deactivated (0 = owner had no active link; 1 = normal re-link). + * Paired with the unique `activeOwnerKey`, this is what makes "one + * active link per owner" hold under concurrency: the insert that follows + * can only succeed once the sentinel key is free. */ +export function deactivateActiveWalletLink( + ownerType: WalletLinkOwnerType, + ownerId: string, + now: Date, + db: Db = prisma, +) { + return db.walletLink.updateMany({ + where: { ownerType, ownerId, replacedAt: null }, + data: { replacedAt: now, activeOwnerKey: null }, + }); } export function createPaymentIntent(input: { @@ -142,7 +173,13 @@ export function findFacilityNamesByIds(ids: string[]) { export async function markEscrowed( tx: Prisma.TransactionClient, id: string, - input: { escrowId: bigint; stellarTxHash: string; sorobanContractId: string; tokenContractId?: string }, + input: { + escrowId: bigint; + stellarTxHash: string; + sorobanContractId: string; + tokenContractId?: string; + fundedWalletAddress?: string; + }, ) { const result = await tx.paymentIntent.updateMany({ where: { id, status: 'created' }, @@ -229,8 +266,9 @@ export function createSep10Challenge(input: { userId: string; account: string; n return prisma.sep10Challenge.create({ data: input }); } -export function findSep10ChallengeByNonce(nonce: string) { - return prisma.sep10Challenge.findUnique({ where: { nonce } }); + +export function findSep10ChallengeByNonce(nonce: string, db: Db = prisma) { + return db.sep10Challenge.findUnique({ where: { nonce } }); } /** Atomically consumes a challenge — ONE conditional UPDATE, no @@ -238,10 +276,21 @@ export function findSep10ChallengeByNonce(nonce: string) { * moves to `consumedAt`; the returned `count` tells the caller whether * THIS request won the race. Two concurrent replays of the same signed * challenge can never both see `count === 1`. Same DB-level pattern as - * booking's conditional slot update (ARCHITECTURE.md §5.1). */ -export function consumeSep10Challenge(nonce: string, consumedAt: Date) { - return prisma.sep10Challenge.updateMany({ + * booking's conditional slot update (ARCHITECTURE.md §5.1). + * Takes an optional transaction client so the wallet-link flow can + * consume the nonce and write the link in ONE transaction (issue #78). */ +export function consumeSep10Challenge(nonce: string, consumedAt: Date, db: Db = prisma) { + return db.sep10Challenge.updateMany({ where: { nonce, consumedAt: null, expiresAt: { gt: consumedAt } }, data: { consumedAt }, }); } + +/** Runs `fn` inside ONE interactive transaction with a generous timeout + * (the wallet-link flow performs a Horizon lookup inside it). Single + * place the service layer opens transactions, so the Prisma client + * stays a repository concern. */ +export function inTransaction(fn: (tx: Prisma.TransactionClient) => Promise): Promise { + return prisma.$transaction(fn, { timeout: 15_000 }); +} + diff --git a/backend/src/modules/payments/routes/index.ts b/backend/src/modules/payments/routes/index.ts index acba316b..1fb33aa5 100644 --- a/backend/src/modules/payments/routes/index.ts +++ b/backend/src/modules/payments/routes/index.ts @@ -11,11 +11,16 @@ paymentsRouter.get('/facility/ledger', authenticate, requireRole('facility'), pa paymentsRouter.post( '/wallet-link', authenticate, - requireRole('facility'), + requireRole('patient', 'facility'), requireVerifiedEmail, paymentsController.linkWallet, ); -paymentsRouter.get('/wallet-link', authenticate, requireRole('facility'), paymentsController.getOwnWallet); +paymentsRouter.get( + '/wallet-link', + authenticate, + requireRole('patient', 'facility'), + paymentsController.getOwnWallet, +); paymentsRouter.get('/intents/:id', authenticate, requireRole('admin'), paymentsController.getIntent); diff --git a/backend/src/modules/payments/services/escrow-sdk-loader.ts b/backend/src/modules/payments/services/escrow-sdk-loader.ts index be8bda49..38c9a8f1 100644 --- a/backend/src/modules/payments/services/escrow-sdk-loader.ts +++ b/backend/src/modules/payments/services/escrow-sdk-loader.ts @@ -7,8 +7,10 @@ type EscrowSdkModule = typeof import('@docforum/escrow-sdk', { with: { 'resolution-mode': 'import' } }); let modulePromise: Promise | undefined; +let override: EscrowSdkModule | undefined; export function loadEscrowSdk(): Promise { + if (override) return Promise.resolve(override); modulePromise ??= import('@docforum/escrow-sdk'); return modulePromise; } @@ -17,8 +19,19 @@ export function loadEscrowSdk(): Promise { * fund/release/refund substitute a fake SDK instead of calling testnet. * `undefined` restores the real one. Never called by application code. */ export function setEscrowSdkForTests(sdk: EscrowSdkModule | undefined): void { + override = sdk; modulePromise = sdk ? Promise.resolve(sdk) : undefined; } +export type EscrowSdkForTests = EscrowSdkModule; + +export function setEscrowSdkOverride(sdk: EscrowSdkForTests): void { + setEscrowSdkForTests(sdk); +} + +export function resetEscrowSdkOverride(): void { + setEscrowSdkForTests(undefined); +} + export type EscrowSdkKeypair = InstanceType; export type { EscrowSdkModule }; diff --git a/backend/src/modules/payments/services/index.ts b/backend/src/modules/payments/services/index.ts index 82d69c4f..613784bf 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -27,9 +27,38 @@ import type { Prisma } from '@prisma/client'; import { transitionPaymentIntentStatus } from './state-machine'; // Types are inferred from the prisma instance; use 'as' assertions where needed +import { assertWalletLinkFundingAllowed } from './wallet-link'; + +// SEP-10 Stellar Web Authentication (issue #77). Re-exported through +// this module's service surface (not imported file-by-file from outside) +// per the AGENTS.md module-boundary rule — same pattern as the auth +// module's token/throttle re-exports. +export { + createSep10Challenge, + verifySep10Challenge, + resetSep10AccountFetcher, + setSep10AccountFetcher, + validateSep10Config, +} from './sep10'; +export type { Sep10AccountFetcher, Sep10AccountInfo, Sep10ChallengeResponse, Sep10Signer, Sep10VerifyResult } from './sep10'; + +// Verified wallet links (issue #78) — SEP-10-only link creation, +// owner-scoped reads, the funding-cutoff guard, and the ADR 0005 +// privacy notice constant, all through this module's service surface. +export { + PATIENT_WALLET_LINK_PRIVACY_NOTICE, + assertWalletLinkFundingAllowed, + getOwnWalletLink, + linkWalletViaSep10, + validateWalletLinkConfig, + walletLinkFundingAllowed, +} from './wallet-link'; +export type { WalletLinkOwner } from './wallet-link'; + // Test seams re-exported through the service surface (same convention as the // auth module's setEmailSender) — see escrow-sdk-loader.ts and trustline.ts. -export { setEscrowSdkForTests } from './escrow-sdk-loader'; +export { setEscrowSdkForTests, resetEscrowSdkOverride, setEscrowSdkOverride } from './escrow-sdk-loader'; +export type { EscrowSdkForTests } from './escrow-sdk-loader'; export { listEscrowEvents } from './escrow-events'; export type { EscrowSdkModule } from './escrow-sdk-loader'; export { setTrustlineCheckerForTests } from './trustline'; @@ -65,19 +94,6 @@ interface PrepareCapableClient { prepareCreateEscrow?: (params: PrepareCreateEscrowParams) => Promise; } -// SEP-10 Stellar Web Authentication (issue #77). Re-exported through -// this module's service surface (not imported file-by-file from outside) -// per the AGENTS.md module-boundary rule — same pattern as the auth -// module's token/throttle re-exports. -export { - createSep10Challenge, - verifySep10Challenge, - resetSep10AccountFetcher, - setSep10AccountFetcher, - validateSep10Config, -} from './sep10'; -export type { Sep10AccountFetcher, Sep10AccountInfo, Sep10ChallengeResponse, Sep10Signer, Sep10VerifyResult } from './sep10'; - async function requirePaymentsConfig() { // The per-asset token contract comes from the intent (issue #79), so the // legacy ESCROW_TOKEN_ID is no longer required for every payment path. @@ -132,16 +148,25 @@ export async function getOwnPatientProfileId(userId: string): Promise { return profile.id; } -export function linkWallet(facilityId: string, stellarPublicKey: string) { - return paymentsRepo.upsertWalletLink(facilityId, stellarPublicKey); +export async function linkWallet(facilityId: string, stellarPublicKey: string) { + const now = new Date(); + await paymentsRepo.deactivateActiveWalletLink('facility', facilityId, now); + return paymentsRepo.createWalletLink({ + ownerType: 'facility', + ownerId: facilityId, + stellarPublicKey, + verificationMethod: 'sep10', + verifiedAt: now, + activeOwnerKey: `facility:${facilityId}`, + }); } export function getWalletLink(facilityId: string) { - return paymentsRepo.findWalletLinkByFacilityId(facilityId); + return paymentsRepo.findActiveWalletLink('facility', facilityId); } export function getPatientWalletLink(patientProfileId: string) { - return paymentsRepo.findPatientWalletLinkByPatientProfileId(patientProfileId); + return paymentsRepo.findActiveWalletLink('patient', patientProfileId); } /** The per-environment allowlist (issue #79). An intent may only be created @@ -443,8 +468,11 @@ export async function fundPaymentIntent(actor: auditService.AuditActor, id: stri throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'created' — cannot fund.`); } - const walletLink = await paymentsRepo.findWalletLinkByFacilityId(intent.facilityId); + // The facility's ACTIVE link (issue #78: one per owner; replaced rows + // are history and must never be picked up here). + const walletLink = await paymentsRepo.findActiveWalletLink('facility', intent.facilityId); if (!walletLink) throw new HttpError(409, 'The facility for this payment has no linked wallet.'); + assertWalletLinkFundingAllowed(walletLink); const { platformPayer, platformReleaser, escrowContractId } = await requirePaymentsConfig(); const payee = resolveEscrowPayee({ @@ -480,6 +508,7 @@ export async function fundPaymentIntent(actor: auditService.AuditActor, id: stri sorobanContractId: escrowContractId, // Legacy rows start with a null token id; record what was actually used. tokenContractId: asset.tokenContractId, + fundedWalletAddress: walletLink.stellarPublicKey, }), ); } diff --git a/backend/src/modules/payments/services/sep10.ts b/backend/src/modules/payments/services/sep10.ts index 4379b3b4..b71b9563 100644 --- a/backend/src/modules/payments/services/sep10.ts +++ b/backend/src/modules/payments/services/sep10.ts @@ -35,8 +35,15 @@ import { HttpError } from '../../../http-error'; import { config } from '../../../config'; import * as paymentsRepo from '../repositories'; import { loadStellarSdk } from './stellar-sdk-loader'; +import type { Prisma } from '@prisma/client'; import type { Keypair, Transaction } from '@stellar/stellar-sdk' with { 'resolution-mode': 'import' }; +/** An open interactive transaction (or nothing — the repository + * defaults apply). The wallet-link flow (issue #78) passes its + * transaction so challenge verification, nonce consumption and link + * creation commit or roll back together. */ +export type Sep10Db = Prisma.TransactionClient; + // --- Configuration ------------------------------------------------------- export interface Sep10Config { @@ -287,8 +294,18 @@ export interface Sep10VerifyResult { * * This endpoint does NOT mint a DocForum session JWT: DocForum's own * auth (Bearer access token) already authenticates the caller — SEP-10's - * job here is proving wallet control, not issuing a session. */ -export async function verifySep10Challenge(userId: string, challengeTx: string): Promise { + * job here is proving wallet control, not issuing a session. + * + * `db` (issue #78): when supplied, every ledger read AND the nonce + * consumption happen inside the caller's interactive transaction, so a + * caller that writes its own records (e.g. a wallet link) gets all-or- + * nothing: a rejected write rolls the nonce consumption back, and a + * consumed nonce without the caller's write cannot survive. */ +export async function verifySep10Challenge( + userId: string, + challengeTx: string, + db?: Sep10Db, +): Promise { const cfg = await requireSep10Config(); const { WebAuth } = await loadStellarSdk(); @@ -315,7 +332,7 @@ export async function verifySep10Challenge(userId: string, challengeTx: string): // Our own ledger: was this challenge issued by us, is it still good, // and is it THIS caller's, for THIS account? Checked before the Horizon // round trip so a mismatched submission can't make us fetch signers. - const record = await paymentsRepo.findSep10ChallengeByNonce(nonce); + const record = await paymentsRepo.findSep10ChallengeByNonce(nonce, db); if (!record) { throw new HttpError(401, 'Challenge was not issued by this server.', 'SEP10_CHALLENGE_UNKNOWN'); } @@ -375,7 +392,7 @@ export async function verifySep10Challenge(userId: string, challengeTx: string): // of the same signed challenge race here — exactly one conditional // UPDATE wins (`WHERE consumedAt IS NULL AND expiresAt > now`). const consumedAt = new Date(); - const { count } = await paymentsRepo.consumeSep10Challenge(nonce, consumedAt); + const { count } = await paymentsRepo.consumeSep10Challenge(nonce, consumedAt, db); if (count !== 1) { throw new HttpError(401, 'This challenge has already been used.', 'SEP10_CHALLENGE_ALREADY_USED'); } diff --git a/backend/src/modules/payments/services/wallet-link.ts b/backend/src/modules/payments/services/wallet-link.ts new file mode 100644 index 00000000..aa18710e --- /dev/null +++ b/backend/src/modules/payments/services/wallet-link.ts @@ -0,0 +1,173 @@ +// Verified wallet links (issue #78, ADR 0005). Replaces the old +// facility-only, typed-in `POST /payments/wallet-link` body: an address +// is NEVER read from the request. The only way to create a link is to +// submit a signed SEP-10 challenge, and the address is derived from the +// account that challenge was issued for and successfully verified +// against (`verifySep10Challenge` from issue #77). +// +// Invariant (reviewer check): `createWalletLink` (the only WalletLink +// insert in the repositories) is called from exactly one place — the +// transaction below, after `verifySep10Challenge` has resolved without +// throwing. There is no seed, no admin route, no script and no legacy +// write path; `unverified_legacy` rows only ever come from the +// add_verified_wallet_links migration backfill. +import { HttpError } from '../../../http-error'; +import { config } from '../../../config'; +import * as paymentsRepo from '../repositories'; +import { verifySep10Challenge } from './sep10'; +import type { Role, WalletLink, WalletLinkVerificationMethod } from '@prisma/client'; + +/** + * ADR 0005, "Consequences → Privacy risk" — reproduced VERBATIM (every + * word and punctuation mark identical; only the ADR's line wrapping is + * collapsed to single spaces so the string is usable in a JSON body). + * tests/unit/wallet-link.test.ts asserts this constant still appears + * verbatim in docs/adr/0005-non-custodial-patient-payments.md, so this + * can never drift into a paraphrase silently. + * + * It rides on EVERY patient wallet-link response so a client cannot + * render the link success without somewhere carrying this text — the ADR + * says "The wallet-linking screen must say this plainly." + */ +export const PATIENT_WALLET_LINK_PRIVACY_NOTICE = + '**Privacy risk: health information can leak on-chain.** A payment from a patient\'s wallet to a facility\'s wallet is public forever. If the patient\'s address is ever linked to their identity, the facility it paid (for example a specialised clinic) can reveal health information. `condition_ref` stays an opaque id (escrow threat model T9), but that doesn\'t hide who paid whom. The wallet-linking screen must say this plainly.'; + +export interface WalletLinkOwner { + ownerType: 'patient' | 'facility'; + ownerId: string; +} + +/** Resolves the owner record the caller is allowed to link FOR. Exactly + * one owner per caller: a user's role determines which profile exists + * (both profile tables are 1:1 with User), so linking for an owner you + * don't control is a 403 before any verification work happens. + * + * Multi-user facility authorisation (several staff linking for one + * facility) is NOT modeled — FacilityProfile is 1:1 with User today — + * and is listed as an open design decision in the PR. */ +async function resolveCallerOwner(userId: string, role: Role): Promise { + if (role === 'patient') { + const profile = await paymentsRepo.findPatientProfileByUserId(userId); + if (!profile) throw new HttpError(403, 'No patient profile is associated with this account.'); + return { ownerType: 'patient', ownerId: profile.id }; + } + if (role === 'facility') { + const profile = await paymentsRepo.findFacilityProfileByUserId(userId); + if (!profile) throw new HttpError(403, 'No facility profile is associated with this account.'); + return { ownerType: 'facility', ownerId: profile.id }; + } + throw new HttpError(403, 'Only patient and facility accounts can link a wallet.'); +} + +/** + * POST /payments/wallet-link — verifies the signed SEP-10 challenge AND + * creates (or replaces) the caller's link in ONE transaction, together + * with consuming the challenge nonce: + * + * - verification fails → the whole transaction rolls back: no link is + * created and the nonce is NOT burned (the user can retry); + * - the link write fails (e.g. a concurrent request won the + * one-active-link race) → the nonce consumption rolls back too, so a + * lost race costs nothing but a fresh challenge; + * - success → nonce consumed and exactly one active link, atomically. + * + * The returned `stellarPublicKey` is always the challenge's verified + * account — no code path here consults a client-supplied address. + */ +export async function linkWalletViaSep10( + userId: string, + role: Role, + signedChallengeTx: string, +): Promise { + const owner = await resolveCallerOwner(userId, role); + const now = new Date(); + + try { + return await paymentsRepo.inTransaction(async (tx) => { + // Full SEP-10 server verification, ledger binding (this user) and + // atomic nonce consumption — inside this transaction. + const { account } = await verifySep10Challenge(userId, signedChallengeTx, tx); + + // Re-link deactivates rather than mutates/deletes: the previous + // row keeps its address and history, and the active sentinel key + // frees up for the new row (unique index keeps this race-safe). + await paymentsRepo.deactivateActiveWalletLink(owner.ownerType, owner.ownerId, now, tx); + + return paymentsRepo.createWalletLink( + { + ownerType: owner.ownerType, + ownerId: owner.ownerId, + stellarPublicKey: account, + verificationMethod: 'sep10', + verifiedAt: now, + activeOwnerKey: `${owner.ownerType}:${owner.ownerId}`, + }, + tx, + ); + }); + } catch (err) { + // P2002 on activeOwnerKey = a concurrent request created the link + // first (interactive transaction aborted, nonce restored above). + if (typeof err === 'object' && err !== null && (err as { code?: string }).code === 'P2002') { + throw new HttpError(409, 'Another wallet link for this account was just created. Refresh and try again.', 'WALLET_LINK_CONFLICT'); + } + throw err; + } +} + +/** GET /payments/wallet-link — the caller's own ACTIVE link (replaced + * rows are history, not exposed here). Null when none. */ +export async function getOwnWalletLink(userId: string, role: Role): Promise { + const owner = await resolveCallerOwner(userId, role); + return paymentsRepo.findActiveWalletLink(owner.ownerType, owner.ownerId); +} + +// --- Funding cutoff guard (requirement 4) -------------------------------- + +/** True once a wallet link may be used as an escrow payee/funder: + * verified links always; `unverified_legacy` links only before + * WALLET_LINK_UNVERIFIED_FUNDING_CUTOFF. Exported (and pure in + * `nowMs`/cutoff) so tests can walk the whole matrix without waiting for + * a real date to pass. */ +export function walletLinkFundingAllowed( + verificationMethod: WalletLinkVerificationMethod, + cutoffMs: number, + nowMs: number, +): boolean { + if (verificationMethod === 'sep10') return true; + return nowMs <= cutoffMs; +} + +/** The enforcement point used by the funding service (not a controller + * check): refuses to fund an escrow whose payee is an unverified legacy + * link after the cutoff. Fail-closed on a misparsed cutoff rather than + * silently allowing funding. */ +export function assertWalletLinkFundingAllowed(link: Pick): void { + const cutoffMs = config.payments.walletLinkUnverifiedFundingCutoff; + if (Number.isNaN(cutoffMs)) { + throw new HttpError( + 500, + 'WALLET_LINK_UNVERIFIED_FUNDING_CUTOFF is not a valid date — funding is refused until it is.', + 'PAYMENTS_MISCONFIGURED', + ); + } + if (!walletLinkFundingAllowed(link.verificationMethod, cutoffMs, Date.now())) { + throw new HttpError( + 409, + "This facility's wallet link predates SEP-10 verification and the funding cutoff has passed. Re-link with a SEP-10-verified wallet to fund payments.", + 'WALLET_LINK_UNVERIFIED', + ); + } +} + +/** + * Startup validation (requirement 4: "validated at startup"). Called + * from src/server.ts next to validateSep10Config() — a malformed cutoff + * must stop boot instead of surfacing as a 500 at first funding. + * Names the env var, never any value. + */ +export function validateWalletLinkConfig(): void { + if (Number.isNaN(config.payments.walletLinkUnverifiedFundingCutoff)) { + throw new Error('WALLET_LINK_UNVERIFIED_FUNDING_CUTOFF is not a valid ISO date.'); + } +} diff --git a/backend/src/modules/payments/validation.ts b/backend/src/modules/payments/validation.ts index be916067..4ed6c45b 100644 --- a/backend/src/modules/payments/validation.ts +++ b/backend/src/modules/payments/validation.ts @@ -1,14 +1,15 @@ import { z } from 'zod'; -export const linkWalletSchema = z.object({ - // Stellar public keys are 56 chars, start with 'G'. Not a full - // checksum validation (that's what create_escrow/the network itself - // will reject) — just a sanity bound against obvious garbage input. - // (The SEP-10 challenge endpoint DOES checksum-validate via - // StrKey.isValidEd25519PublicKey — see services/sep10.ts.) - stellarPublicKey: z - .string() - .regex(/^G[A-Z2-7]{55}$/, 'stellarPublicKey must be a valid Stellar public key (starts with G, 56 chars).'), +// Issue #78 — the typed-in address body is GONE. strictObject: a +// client that still sends `stellarPublicKey` (or anything else) is a +// loud 400, never a silently-ignored field, so nobody can believe they +// linked an address the server never saw. The only accepted input is +// the signed SEP-10 challenge; the address comes from verifying it. +export const walletLinkSchema = z.strictObject({ + // base64 TransactionEnvelope XDR of the wallet-signed SEP-10 challenge + // (issue #77), issued to the authenticated caller for the account + // being linked. + transaction: z.string().min(1), }); // SEP-10 (issue #77). Shape-only gate: the checksum-accurate strkey @@ -26,6 +27,7 @@ export const sep10VerifySchema = z.object({ transaction: z.string().min(1), }); + export const createPaymentIntentSchema = z.object({ orderType: z.enum(['prescription', 'lab_order']), // Opaque — see schema.prisma comment. Not validated against a real diff --git a/backend/src/server.ts b/backend/src/server.ts index 364c11e5..91a69ff3 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -5,7 +5,7 @@ import { pruneOldAccessLogs } from './modules/audit/services'; import { startPatientExportJobs } from './jobs/patient-exports'; import { startEscrowEventPoller } from './jobs/escrow-events'; import { validatePlatformSigners } from './modules/payments/services/platform-signers'; -import { validateSep10Config, verifyPaymentsNetwork } from './modules/payments/services'; +import { validateSep10Config, validateWalletLinkConfig, verifyPaymentsNetwork } from './modules/payments/services'; import { startAccountPurgeJob } from './jobs/account-purge'; import { startPaymentIntentJobs } from './jobs/payment-intents'; import { startReferralExpiryJob } from './jobs/referral-expiry'; @@ -15,6 +15,7 @@ const app = createApp(); async function startServer() { await Promise.all([validatePlatformSigners(), verifyPaymentsNetwork(), validateSep10Config()]); + validateWalletLinkConfig(); app.listen(config.port, () => { // eslint-disable-next-line no-console console.log(`docforum-core backend listening on :${config.port}`); diff --git a/backend/tests/integration/access-control.test.mts b/backend/tests/integration/access-control.test.mts index 698a55a1..98caee9e 100644 --- a/backend/tests/integration/access-control.test.mts +++ b/backend/tests/integration/access-control.test.mts @@ -146,9 +146,9 @@ const ACCESS_MATRIX: MatrixRow[] = [ { method: 'GET', path: '/admin/orders/disputes', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 200 } }, { method: 'POST', path: '/admin/orders/disputes/:id/resolve', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 404 } }, - // Payments — admin-triggered in v1; wallet-link is facility-only + // Payments — admin-triggered in v1; wallet-link is patient and facility (issue #78) { method: 'POST', path: '/payments/wallet-link', expected: { anon: 401, patient: 403, doctor: 403, facility: 400, admin: 403 } }, - { method: 'GET', path: '/payments/wallet-link', expected: { anon: 401, patient: 403, doctor: 403, facility: 404, admin: 403 } }, + { method: 'GET', path: '/payments/wallet-link', expected: { anon: 401, patient: 404, doctor: 403, facility: 404, admin: 403 } }, { method: 'GET', path: '/payments/intents/:id', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 404 } }, { method: 'POST', path: '/payments/intents/:id/fund', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 404 } }, { method: 'POST', path: '/payments/intents/:id/release', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 404 } }, diff --git a/backend/tests/integration/audit.test.mts b/backend/tests/integration/audit.test.mts index 004d3167..a74b26f8 100644 --- a/backend/tests/integration/audit.test.mts +++ b/backend/tests/integration/audit.test.mts @@ -154,7 +154,15 @@ describe('AuditEvent foundation', () => { const facilityUser = await prisma.user.create({ data: { email: `lab-${runId}@example.com`, passwordHash: 'x', role: 'facility' } }); facilityId = (await prisma.facilityProfile.create({ data: { userId: facilityUser.id, name: 'Audit Lab', type: 'lab' } })).id; - await prisma.walletLink.create({ data: { facilityId, stellarPublicKey: `G${'A'.repeat(55)}` } }); + await prisma.walletLink.create({ + data: { + ownerType: 'facility', + ownerId: facilityId, + stellarPublicKey: `G${'A'.repeat(55)}`, + verificationMethod: 'sep10', + activeOwnerKey: `facility:${facilityId}`, + }, + }); }, 180_000); afterAll(async () => { diff --git a/backend/tests/integration/payments-prepare.test.mts b/backend/tests/integration/payments-prepare.test.mts index 73cbdaf1..9ab81512 100644 --- a/backend/tests/integration/payments-prepare.test.mts +++ b/backend/tests/integration/payments-prepare.test.mts @@ -132,8 +132,14 @@ describe('prepare endpoint (issue #80)', () => { await prisma.patientProfile.create({ data: { userId: patientUser.id, fullName: 'Prepare Patient' } }) ).id; patientPublicKey = Keypair.random().publicKey(); - await prisma.patientWalletLink.create({ - data: { patientProfileId, stellarPublicKey: patientPublicKey, verifiedAt: new Date() }, + await prisma.walletLink.create({ + data: { + ownerType: 'patient', + ownerId: patientProfileId, + stellarPublicKey: patientPublicKey, + verificationMethod: 'sep10', + activeOwnerKey: `patient:${patientProfileId}`, + }, }); const otherUser = await prisma.user.create({ @@ -153,7 +159,13 @@ describe('prepare endpoint (issue #80)', () => { }) ).id; await prisma.walletLink.create({ - data: { facilityId, stellarPublicKey: Keypair.random().publicKey() }, + data: { + ownerType: 'facility', + ownerId: facilityId, + stellarPublicKey: Keypair.random().publicKey(), + verificationMethod: 'sep10', + activeOwnerKey: `facility:${facilityId}`, + }, }); const unlinkedUser = await prisma.user.create({ diff --git a/backend/tests/integration/wallet-link.test.mts b/backend/tests/integration/wallet-link.test.mts new file mode 100644 index 00000000..bfd58510 --- /dev/null +++ b/backend/tests/integration/wallet-link.test.mts @@ -0,0 +1,604 @@ +// Integration tests for verified wallet links (issue #78) — real embedded +// Postgres (active-link constraint, transactions, migration backfill), +// real HTTP through the Express app, FAKE Horizon (SEP-10 signer lookup) +// and a FAKE escrow SDK (funding path) — zero network. +// +// The cutoff env var is set to a PAST date at module load, so this suite +// runs with the guard ENFORCED: legacy links must be refused, verified +// links allowed. The complementary "legacy allowed before the cutoff" +// side is unit-tested (tests/unit/wallet-link.test.ts) against the +// default grace window. +// +// Own port (5439) so it runs alongside the other integration files +// (5433 booking, 5435/5436/5437 others, 5438 sep10). +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 bcrypt from 'bcryptjs'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { Keypair, TransactionBuilder } from '@stellar/stellar-sdk'; +import type { Transaction } from '@stellar/stellar-sdk'; +import type { Sep10AccountInfo } from '../../src/modules/payments/services/sep10.js'; +import type { EscrowSdkForTests } from '../../src/modules/payments/services/index.js'; +import { createOrderFixture } from './order-fixtures.mjs'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5463; +const runId = Date.now(); +const PASSWORD = 'longenough123'; +const TEST_ACTOR = { userId: null, role: null, requestId: `wallet-link-${runId}` }; + +// --- env BEFORE src/config loads (see login-throttle.test.mts pattern) --- +// Guard enforced NOW: past cutoff => unverified_legacy funding refused. +process.env.WALLET_LINK_UNVERIFIED_FUNDING_CUTOFF = '2026-01-01T00:00:00Z'; +// Money keys only need to parse as real keypairs; the escrow SDK itself +// is faked (setEscrowSdkOverride), so nothing signs or broadcasts. +process.env.PLATFORM_PAYER_SECRET = Keypair.random().secret(); +process.env.PLATFORM_RELEASER_SECRET = Keypair.random().secret(); +process.env.ESCROW_CONTRACT_ID = 'TEST_CONTRACT_ID'; +process.env.ESCROW_TOKEN_ID = 'TEST_TOKEN_ID'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-wallet-link-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; +type Services = typeof import('../../src/modules/payments/services/index.js'); + +describe('verified wallet links (issue #78)', () => { + let app: App; + let prisma: Prisma; + let services: Services; + let networkPassphrase: string; + let patientNotice: string; + + // Fake Horizon (same shape as sep10.test.mts): unfunded = null. + const accountOverrides = new Map(); + const fundedSingleSigner = (accountId: string): Sep10AccountInfo => ({ + accountId, + thresholds: { low_threshold: 0, med_threshold: 1, high_threshold: 1 }, + signers: [{ key: accountId, weight: 1, type: 'ed25519_public_key' }], + }); + + // Fake escrow SDK: records what funding asked for, returns plausible + // values — lets us assert the payee snapshot and re-link isolation + // without touching testnet. + const escrowCalls: { payee?: string }[] = []; + let releaseCalls = 0; + + 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'); + const servicesModule = await import('../../src/modules/payments/services/index.js'); + const configModule = await import('../../src/config/index.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + services = servicesModule; + + networkPassphrase = configModule.config.sep10.networkPassphrase; + patientNotice = servicesModule.PATIENT_WALLET_LINK_PRIVACY_NOTICE; + + servicesModule.setSep10AccountFetcher((accountId) => + Promise.resolve(accountOverrides.has(accountId) ? accountOverrides.get(accountId)! : fundedSingleSigner(accountId)), + ); + + servicesModule.setEscrowSdkOverride({ + Keypair, + EscrowClient: class { + async createEscrow(args: { payee: string }) { + escrowCalls.push({ payee: args.payee }); + return { escrowId: BigInt(escrowCalls.length), txHash: `fake-tx-${escrowCalls.length}` }; + } + async release() { + releaseCalls += 1; + return { txHash: 'fake-release' }; + } + async refund() { + return { txHash: 'fake-refund' }; + } + }, + } as unknown as EscrowSdkForTests); + }); + + afterAll(async () => { + services?.resetEscrowSdkOverride(); + services?.resetSep10AccountFetcher(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + afterEach(() => { + accountOverrides.clear(); + }); + + // --- helpers ----------------------------------------------------------- + + async function signupPatient(label: string): Promise<{ userId: string; token: string }> { + const email = `wl-patient-${label}-${runId}@example.com`; + const res = await request(app) + .post('/auth/signup') + .send({ role: 'patient', email, password: PASSWORD, fullName: `WL ${label}` }); + expect(res.status).toBe(200); + await prisma.user.update({ where: { id: res.body.user.id }, data: { emailVerifiedAt: new Date() } }); + return { userId: res.body.user.id as string, token: res.body.accessToken as string }; + } + + /** Facility accounts are admin-invited (signup rejects the role), so + * this arranges one directly and logs in through the real login route. */ + async function createFacility(label: string): Promise<{ userId: string; facilityId: string; token: string }> { + const email = `wl-facility-${label}-${runId}@example.com`; + const user = await prisma.user.create({ + data: { + email, + passwordHash: bcrypt.hashSync(PASSWORD, 4), + role: 'facility', + emailVerifiedAt: new Date(), + }, + }); + const profile = await prisma.facilityProfile.create({ + data: { userId: user.id, name: `WL Facility ${label}`, type: 'lab' }, + }); + const login = await request(app).post('/auth/login').send({ email, password: PASSWORD }); + expect(login.status).toBe(200); + return { userId: user.id, facilityId: profile.id, token: login.body.accessToken as string }; + } + + function getChallenge(token: string, account: string) { + return request(app).get('/payments/sep10/challenge').set('Authorization', `Bearer ${token}`).query({ account }); + } + + function postLink(token: string, body: unknown) { + return request(app).post('/payments/wallet-link').set('Authorization', `Bearer ${token}`).send(body as object); + } + + /** Challenge + wallet sign in one step (issue #77 flow). */ + async function signedChallengeFor(token: string, ...signers: Keypair[]): Promise { + const account = signers[0]!.publicKey(); + const res = await getChallenge(token, account); + expect(res.status).toBe(200); + const tx = TransactionBuilder.fromXDR(res.body.transaction, networkPassphrase) as Transaction; + for (const signer of signers) tx.sign(signer); + return tx.toEnvelope().toXDR('base64').toString(); + } + + async function activeLinks(ownerType: 'patient' | 'facility', ownerId: string) { + return prisma.walletLink.findMany({ where: { ownerType, ownerId, replacedAt: null } }); + } + + // --- link creation ----------------------------------------------------- + + it('facility: links a verified wallet end-to-end (address derived from the challenge)', async () => { + const facility = await createFacility('happy'); + const wallet = Keypair.random(); + const signed = await signedChallengeFor(facility.token, wallet); + + const res = await postLink(facility.token, { transaction: signed }); + expect(res.status).toBe(200); + expect(res.body.stellarPublicKey).toBe(wallet.publicKey()); // derived, not supplied + expect(res.body.ownerType).toBe('facility'); + expect(res.body.ownerId).toBe(facility.facilityId); + expect(res.body.verificationMethod).toBe('sep10'); + expect(res.body.verifiedAt).toBeTruthy(); + expect(res.body.replacedAt).toBeNull(); + // Facility payout link carries no patient privacy notice. + expect(res.body.privacyNotice).toBeUndefined(); + + const row = await prisma.walletLink.findFirst({ where: { ownerId: facility.facilityId } }); + expect(row!.activeOwnerKey).toBe(`facility:${facility.facilityId}`); + }); + + it('patient: links a verified wallet and the ADR 0005 privacy notice rides in the response', async () => { + const patient = await signupPatient('happy'); + const wallet = Keypair.random(); + const signed = await signedChallengeFor(patient.token, wallet); + + const res = await postLink(patient.token, { transaction: signed }); + expect(res.status).toBe(200); + expect(res.body.stellarPublicKey).toBe(wallet.publicKey()); + expect(res.body.ownerType).toBe('patient'); + expect(res.body.verificationMethod).toBe('sep10'); + // Requirement 3: verbatim notice, cannot be skipped by the client. + expect(res.body.privacyNotice).toBe(patientNotice); + expect(res.body.privacyNotice).toContain('public forever'); + + const profile = await prisma.patientProfile.findUnique({ where: { userId: patient.userId } }); + const rows = await activeLinks('patient', profile!.id); + expect(rows).toHaveLength(1); + }); + + it('rejects a raw address in the body — the address can only come from the verified challenge', async () => { + const patient = await signupPatient('raw'); + const address = Keypair.random().publicKey(); + const rowsBefore = await prisma.walletLink.count(); + + const withAddress = await postLink(patient.token, { stellarPublicKey: address }); + expect(withAddress.status).toBe(400); + + const both = await postLink(patient.token, { transaction: 'AAAA', stellarPublicKey: address }); + expect(both.status).toBe(400); + + expect(await prisma.walletLink.count()).toBe(rowsBefore); + }); + + it('creates NO link when SEP-10 verification fails, and does not burn the nonce', async () => { + const patient = await signupPatient('failed'); + const accountOwner = Keypair.random(); + const impostor = Keypair.random(); + + // Challenge issued for accountOwner, but signed by someone else. + const res1 = await getChallenge(patient.token, accountOwner.publicKey()); + const tx = TransactionBuilder.fromXDR(res1.body.transaction, networkPassphrase) as Transaction; + tx.sign(impostor); + const signed = tx.toEnvelope().toXDR('base64').toString(); + + const res = await postLink(patient.token, { transaction: signed }); + expect(res.status).toBe(401); + expect(res.body.code).toBe('SEP10_INVALID_SIGNATURE'); + + const profile = await prisma.patientProfile.findUnique({ where: { userId: patient.userId } }); + expect(await activeLinks('patient', profile!.id)).toHaveLength(0); + + // The failed attempt did NOT consume the challenge nonce — the row + // is still unconsumed, so the user hasn't lost anything but a retry. + const challengeRow = await prisma.sep10Challenge.findFirst({ orderBy: { createdAt: 'desc' } }); + expect(challengeRow!.consumedAt).toBeNull(); + }); + + it('rejects a REPLAYED signed challenge (second link attempt) without creating a second link', async () => { + const patient = await signupPatient('replay'); + const wallet = Keypair.random(); + const signed = await signedChallengeFor(patient.token, wallet); + + expect((await postLink(patient.token, { transaction: signed })).status).toBe(200); + const replay = await postLink(patient.token, { transaction: signed }); + expect(replay.status).toBe(401); + expect(replay.body.code).toBe('SEP10_CHALLENGE_ALREADY_USED'); + + const profile = await prisma.patientProfile.findUnique({ where: { userId: patient.userId } }); + expect(await activeLinks('patient', profile!.id)).toHaveLength(1); + }); + + it("rejects a challenge issued to user A when user B submits it (B gets no link)", async () => { + const a = await signupPatient('owner-a'); + const b = await signupPatient('owner-b'); + const wallet = Keypair.random(); + const signed = await signedChallengeFor(a.token, wallet); + + const res = await postLink(b.token, { transaction: signed }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('SEP10_CHALLENGE_NOT_FOR_CALLER'); + + const bProfile = await prisma.patientProfile.findUnique({ where: { userId: b.userId } }); + expect(await activeLinks('patient', bProfile!.id)).toHaveLength(0); + }); + + it('refuses roles that cannot own a wallet link (doctor) with 403', async () => { + const email = `wl-doctor-${runId}@example.com`; + const signup = await request(app) + .post('/auth/signup') + .send({ role: 'doctor', email, password: PASSWORD, fullName: 'WL Doctor' }); + expect(signup.status).toBe(200); + await prisma.user.update({ where: { id: signup.body.user.id }, data: { emailVerifiedAt: new Date() } }); + + const res = await postLink(signup.body.accessToken, { transaction: 'AAAA' }); + expect(res.status).toBe(403); + }); + + it('enforces ONE active link per owner at the DB level, including concurrent creates', async () => { + const facility = await createFacility('concurrent'); + const key = `facility:${facility.facilityId}`; + + // Raw DB constraint check: a second ACTIVE row for the same owner + // violates the unique sentinel even outside the service. + const base = { + ownerType: 'facility' as const, + ownerId: facility.facilityId, + verificationMethod: 'sep10' as const, + verifiedAt: new Date(), + }; + await prisma.walletLink.create({ data: { ...base, stellarPublicKey: Keypair.random().publicKey(), activeOwnerKey: key } }); + await expect( + prisma.walletLink.create({ data: { ...base, stellarPublicKey: Keypair.random().publicKey(), activeOwnerKey: key } }), + ).rejects.toMatchObject({ code: 'P2002' }); + await prisma.walletLink.deleteMany({ where: { ownerId: facility.facilityId } }); + + // CONCURRENT inserts of two active rows for the same owner (no + // pre-existing row): the unique index must let exactly one through, + // whichever order Postgres runs them in. + const rawCreate = () => + prisma.walletLink.create({ data: { ...base, stellarPublicKey: Keypair.random().publicKey(), activeOwnerKey: key } }); + const rawResults = await Promise.allSettled([rawCreate(), rawCreate()]); + expect(rawResults.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + const rejected = rawResults.find((r) => r.status === 'rejected') as PromiseRejectedResult; + expect((rejected.reason as { code?: string }).code).toBe('P2002'); + expect(await activeLinks('facility', facility.facilityId)).toHaveLength(1); + await prisma.walletLink.deleteMany({ where: { ownerId: facility.facilityId } }); + + // Genuinely concurrent HTTP creates: two valid challenges, both in + // flight before either resolves. Exactly one active link must exist + // afterwards. (The two statuses depend on interleaving: a true + // insert-collision yields 200+409; if the second request starts + // after the first committed, it is a plain re-link and both get 200 + // with the first row replaced. Both serializations are legal — the + // invariant is "never two active links".) + const w1 = Keypair.random(); + const w2 = Keypair.random(); + const s1 = await signedChallengeFor(facility.token, w1); + const s2 = await signedChallengeFor(facility.token, w2); + const [r1, r2] = await Promise.all([postLink(facility.token, { transaction: s1 }), postLink(facility.token, { transaction: s2 })]); + + expect([r1.status, r2.status].every((s) => s === 200 || s === 409)).toBe(true); + expect([r1.status, r2.status]).toContain(200); + const active = await activeLinks('facility', facility.facilityId); + expect(active).toHaveLength(1); // THE invariant + expect([w1.publicKey(), w2.publicKey()]).toContain(active[0]!.stellarPublicKey); + }); + + // --- re-link safety + funding ----------------------------------------- + + it('re-link deactivates the old link and leaves escrows funded to the old address untouched', async () => { + const facility = await createFacility('relink'); + const patient = await signupPatient('relink'); + const patientProfile = await prisma.patientProfile.findUniqueOrThrow({ where: { userId: patient.userId } }); + + const addr1 = Keypair.random(); + const addr2 = Keypair.random(); + + // Link v1 (verified), fund an escrow to it. + expect((await postLink(facility.token, { transaction: await signedChallengeFor(facility.token, addr1) })).status).toBe(200); + + const order = await createOrderFixture(prisma, { + patientProfileId: patientProfile.id, + facilityProfileId: facility.facilityId, + kind: 'lab_order', + amountStroops: 500_000n, + tag: `relink-${runId}`, + }); + const intent = await services.createPaymentIntent({ + orderType: 'lab_order', + orderId: order.id, + patientProfileId: patientProfile.id, + facilityId: facility.facilityId, + assetCode: 'XLM', + amount: 500_000n, + }); + const funded = await services.fundPaymentIntent(TEST_ACTOR, intent.id); + expect(funded.status).toBe('escrowed'); + expect(funded.fundedWalletAddress).toBe(addr1.publicKey()); + expect(escrowCalls.at(-1)!.payee).toBe(addr1.publicKey()); + + // Re-link to a different address: old row is REPLACED (not mutated, + // not deleted), new row active. + const relink = await postLink(facility.token, { transaction: await signedChallengeFor(facility.token, addr2) }); + expect(relink.status).toBe(200); + expect(relink.body.stellarPublicKey).toBe(addr2.publicKey()); + + const all = await prisma.walletLink.findMany({ where: { ownerId: facility.facilityId }, orderBy: { createdAt: 'asc' } }); + expect(all).toHaveLength(2); + const [oldLink, newLink] = all; + expect(oldLink!.stellarPublicKey).toBe(addr1.publicKey()); // history preserved + expect(oldLink!.replacedAt).not.toBeNull(); + expect(oldLink!.activeOwnerKey).toBeNull(); + expect(newLink!.stellarPublicKey).toBe(addr2.publicKey()); + expect(newLink!.replacedAt).toBeNull(); + expect(newLink!.activeOwnerKey).toBe(`facility:${facility.facilityId}`); + + // The already-funded escrow still references its own address/link — + // re-linking did not rewrite it — and release works regardless. + const intentAfter = await services.getPaymentIntent(intent.id); + expect(intentAfter.fundedWalletAddress).toBe(addr1.publicKey()); + const released = await services.releasePaymentIntent(TEST_ACTOR, intent.id); + expect(released.status).toBe('released'); + expect(releaseCalls).toBeGreaterThan(0); + }); + + it('funding refuses an UNVERIFIED legacy link after the cutoff, and funds verified links after it', async () => { + // The suite's cutoff is in the past, so "after cutoff" is now. + const legacy = await createFacility('legacy'); + const verified = await createFacility('verified-post-cutoff'); + const patient = await signupPatient('funding'); + + // Simulate a migrated legacy link (typed-in address, never verified). + await prisma.walletLink.create({ + data: { + ownerType: 'facility', + ownerId: legacy.facilityId, + stellarPublicKey: Keypair.random().publicKey(), + verificationMethod: 'unverified_legacy', + activeOwnerKey: `facility:${legacy.facilityId}`, + }, + }); + expect(services.walletLinkFundingAllowed('unverified_legacy', Date.parse('2026-01-01T00:00:00Z'), Date.now())).toBe(false); + + const patientProfile = await prisma.patientProfile.findUniqueOrThrow({ where: { userId: patient.userId } }); + const legacyOrder = await createOrderFixture(prisma, { + patientProfileId: patientProfile.id, + facilityProfileId: legacy.facilityId, + kind: 'lab_order', + amountStroops: 100_000n, + tag: `legacy-${runId}`, + }); + const legacyIntent = await services.createPaymentIntent({ + orderType: 'lab_order', + orderId: legacyOrder.id, + patientProfileId: patientProfile.id, + facilityId: legacy.facilityId, + assetCode: 'XLM', + amount: 100_000n, + }); + const callsBefore = escrowCalls.length; + await expect(services.fundPaymentIntent(TEST_ACTOR, legacyIntent.id)).rejects.toMatchObject({ + status: 409, + code: 'WALLET_LINK_UNVERIFIED', + }); + expect(escrowCalls.length).toBe(callsBefore); // SDK never reached + expect((await services.getPaymentIntent(legacyIntent.id)).status).toBe('created'); // untouched + + // A SEP-10-verified link funds fine after the same cutoff. + const wallet = Keypair.random(); + expect((await postLink(verified.token, { transaction: await signedChallengeFor(verified.token, wallet) })).status).toBe(200); + const verifiedOrder = await createOrderFixture(prisma, { + patientProfileId: patientProfile.id, + facilityProfileId: verified.facilityId, + kind: 'lab_order', + amountStroops: 100_000n, + tag: `verified-${runId}`, + }); + const verifiedIntent = await services.createPaymentIntent({ + orderType: 'lab_order', + orderId: verifiedOrder.id, + patientProfileId: patientProfile.id, + facilityId: verified.facilityId, + assetCode: 'XLM', + amount: 100_000n, + }); + const funded = await services.fundPaymentIntent(TEST_ACTOR, verifiedIntent.id); + expect(funded.status).toBe('escrowed'); + expect(funded.fundedWalletAddress).toBe(wallet.publicKey()); + }); + + it('GET /payments/wallet-link returns the caller\'s active link and 404 when none', async () => { + const fresh = await signupPatient('get-link'); + expect((await request(app).get('/payments/wallet-link').set('Authorization', `Bearer ${fresh.token}`)).status).toBe(404); + + const wallet = Keypair.random(); + await postLink(fresh.token, { transaction: await signedChallengeFor(fresh.token, wallet) }); + const got = await request(app).get('/payments/wallet-link').set('Authorization', `Bearer ${fresh.token}`); + expect(got.status).toBe(200); + expect(got.body.stellarPublicKey).toBe(wallet.publicKey()); + expect(got.body.verificationMethod).toBe('sep10'); + }); + + // --- migration --------------------------------------------------------- + + it('migration: existing facility rows become active unverified_legacy links', async () => { + // Real migration test: bring up a scratch DB at the schema JUST + // BEFORE this migration, insert an old-shape facility link, then + // apply only the new migration and assert the backfill. + const MIGRATIONS_DIR = path.join(BACKEND_ROOT, 'prisma', 'migrations'); + const NEW_MIGRATION = '20261016153822_add_verified_wallet_links'; + const earlier = fs + .readdirSync(MIGRATIONS_DIR) + .filter((d) => d < NEW_MIGRATION && fs.existsSync(path.join(MIGRATIONS_DIR, d, 'migration.sql'))) + .sort(); + + const migDbName = `docforum_migration_test_${runId}`; + await pg.createDatabase(migDbName); + const migUrl = `postgresql://docforum:docforum@localhost:${PORT}/${migDbName}`; + + const script = earlier.map((dir) => fs.readFileSync(path.join(MIGRATIONS_DIR, dir, 'migration.sql'), 'utf8')).join('\n;\n'); + execFileSync('npx', ['prisma', 'db', 'execute', '--stdin'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: migUrl }, + input: script, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + // Old-shape row: facility-only, typed-in address. + const { PrismaClient } = await import('@prisma/client'); + const { PrismaPg } = await import('@prisma/adapter-pg'); + const migPrisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: migUrl }) }); + try { + const userId = `mig-user-${runId}`; + const profileId = `mig-facility-${runId}`; + const oldAddress = Keypair.random().publicKey(); + await migPrisma.$executeRawUnsafe( + 'INSERT INTO "User" ("id", "email", "passwordHash", "role", "updatedAt") VALUES ($1, $2, $3, $4::"Role", now())', + userId, + `mig-${runId}@example.com`, + 'unused', + 'facility', + ); + await migPrisma.$executeRawUnsafe( + 'INSERT INTO "FacilityProfile" ("id", "userId", "name", "type", "updatedAt") VALUES ($1, $2, $3, $4, now())', + profileId, + userId, + 'Mig Facility', + 'lab', + ); + await migPrisma.$executeRawUnsafe( + 'INSERT INTO "WalletLink" ("id", "facilityId", "stellarPublicKey", "createdAt", "updatedAt") VALUES ($1, $2, $3, now(), now())', + `mig-link-${runId}`, + profileId, + oldAddress, + ); + + // Apply ONLY the new migration. + execFileSync('npx', ['prisma', 'db', 'execute', '--file', path.join(MIGRATIONS_DIR, NEW_MIGRATION, 'migration.sql')], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: migUrl }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const rows = await migPrisma.walletLink.findMany({ where: { ownerId: profileId } }); + expect(rows).toHaveLength(1); + const row = rows[0]!; + expect(row.stellarPublicKey).toBe(oldAddress); // address untouched + expect(row.ownerType).toBe('facility'); + expect(row.verificationMethod).toBe('unverified_legacy'); // never SEP-10-checked + expect(row.verifiedAt).toBeNull(); + expect(row.replacedAt).toBeNull(); + expect(row.activeOwnerKey).toBe(`facility:${profileId}`); // still the active link + + // And the backfilled link participates in the new world: it can + // be replaced, and it can only be replaced once. + await migPrisma.walletLink.create({ + data: { + ownerType: 'facility', + ownerId: profileId, + stellarPublicKey: Keypair.random().publicKey(), + verificationMethod: 'sep10', + verifiedAt: new Date(), + activeOwnerKey: `facility:${profileId}`, + }, + }).then( + () => { + throw new Error('second active link should have been rejected'); + }, + (err: { code?: string }) => expect(err.code).toBe('P2002'), + ); + // Deactivate-then-insert is the legal path: + await migPrisma.walletLink.updateMany({ + where: { ownerId: profileId, replacedAt: null }, + data: { replacedAt: new Date(), activeOwnerKey: null }, + }); + const promoted = await migPrisma.walletLink.create({ + data: { + ownerType: 'facility', + ownerId: profileId, + stellarPublicKey: Keypair.random().publicKey(), + verificationMethod: 'sep10', + verifiedAt: new Date(), + activeOwnerKey: `facility:${profileId}`, + }, + }); + expect(promoted.verificationMethod).toBe('sep10'); + } finally { + await migPrisma.$disconnect(); + } + }, 60_000); +}); diff --git a/backend/tests/testnet-integration/payments.test.mts b/backend/tests/testnet-integration/payments.test.mts index f2474479..4e4ddeef 100644 --- a/backend/tests/testnet-integration/payments.test.mts +++ b/backend/tests/testnet-integration/payments.test.mts @@ -129,7 +129,21 @@ describe('payments module against live testnet', () => { patientProfileId = patient.id; const payoutWallet = await fundedKeypair(); - await paymentsService.linkWallet(facilityId, payoutWallet.publicKey()); + // Issue #78 removed the typed-in link path: this live-testnet suite + // arranges its payee directly as an explicitly-unverified legacy + // link (test setup, not a product code path — there is no service + // that can create one anymore). Unverified links still fund until + // WALLET_LINK_UNVERIFIED_FUNDING_CUTOFF, which this suite does not + // set, so the default grace window applies. + await prisma.walletLink.create({ + data: { + ownerType: 'facility', + ownerId: facilityId, + stellarPublicKey: payoutWallet.publicKey(), + verificationMethod: 'unverified_legacy', + activeOwnerKey: `facility:${facilityId}`, + }, + }); }); afterAll(async () => { diff --git a/backend/tests/unit/wallet-link.test.mts b/backend/tests/unit/wallet-link.test.mts new file mode 100644 index 00000000..637ed1e7 --- /dev/null +++ b/backend/tests/unit/wallet-link.test.mts @@ -0,0 +1,97 @@ +// Unit tests for verified wallet links (issue #78) — no database, no +// network. Complements tests/integration/wallet-link.test.mts, which +// covers the HTTP/DB flows; this file covers the pure guards, the +// request schema, the privacy-notice verbatim guarantee, and startup +// validation of the cutoff env var. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; +import { config } from '../../src/config/index.js'; +import { walletLinkSchema } from '../../src/modules/payments/validation.js'; +import { + PATIENT_WALLET_LINK_PRIVACY_NOTICE, + assertWalletLinkFundingAllowed, + validateWalletLinkConfig, + walletLinkFundingAllowed, +} from '../../src/modules/payments/services/wallet-link.js'; + +// backend/tests/unit → repo root (the ADR lives outside backend/). +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + +describe('patient wallet-link privacy notice (ADR 0005, verbatim)', () => { + it('appears word-for-word in docs/adr/0005-non-custodial-patient-payments.md', () => { + const adr = fs.readFileSync(path.join(REPO_ROOT, 'docs/adr/0005-non-custodial-patient-payments.md'), 'utf8'); + // Normalise whitespace on both sides: the constant collapses the + // ADR's line wrapping to single spaces so it is usable in JSON, but + // every word, punctuation mark and markdown tick must match — any + // paraphrase fails this test. + const normalise = (text: string) => text.replace(/\s+/g, ' ').trim(); + expect(normalise(adr)).toContain(normalise(PATIENT_WALLET_LINK_PRIVACY_NOTICE)); + expect(PATIENT_WALLET_LINK_PRIVACY_NOTICE).toContain('public forever'); + expect(PATIENT_WALLET_LINK_PRIVACY_NOTICE).toContain('must say this plainly'); + }); +}); + +describe('funding cutoff guard (requirement 4)', () => { + const cutoff = config.payments.walletLinkUnverifiedFundingCutoff; + + it('the test environment has a valid cutoff', () => { + expect(Number.isNaN(cutoff)).toBe(false); + }); + + it('walletLinkFundingAllowed: legacy links work before the cutoff, never after; verified links always work', () => { + expect(walletLinkFundingAllowed('unverified_legacy', cutoff, cutoff - 1)).toBe(true); // 1ms before + expect(walletLinkFundingAllowed('unverified_legacy', cutoff, cutoff)).toBe(true); // boundary still allowed + expect(walletLinkFundingAllowed('unverified_legacy', cutoff, cutoff + 1)).toBe(false); // just past + expect(walletLinkFundingAllowed('unverified_legacy', cutoff, cutoff + 365 * 24 * 3600_000)).toBe(false); + + expect(walletLinkFundingAllowed('sep10', cutoff, cutoff - 1)).toBe(true); + expect(walletLinkFundingAllowed('sep10', cutoff, cutoff + 1)).toBe(true); + expect(walletLinkFundingAllowed('sep10', cutoff, Date.now())).toBe(true); + }); + + it('assertWalletLinkFundingAllowed passes for both kinds before the cutoff (default grace window)', () => { + // This suite runs with the DEFAULT cutoff (2027-01-01) — well after + // "now", so legacy links are still inside the grace window here; + // the after-cutoff refusal path is integration-tested with an + // explicit past cutoff in tests/integration/wallet-link.test.mts. + expect(() => assertWalletLinkFundingAllowed({ verificationMethod: 'unverified_legacy' })).not.toThrow(); + expect(() => assertWalletLinkFundingAllowed({ verificationMethod: 'sep10' })).not.toThrow(); + }); +}); + +describe('wallet-link request schema (strict — no raw addresses, ever)', () => { + it('accepts exactly { transaction }', () => { + expect(walletLinkSchema.parse({ transaction: 'AAAA' })).toEqual({ transaction: 'AAAA' }); + }); + + it('rejects a typed-in address, with or without a transaction', () => { + const address = 'G' + 'A'.repeat(55); + expect(() => walletLinkSchema.parse({ transaction: 'AAAA', stellarPublicKey: address })).toThrow(); + expect(() => walletLinkSchema.parse({ stellarPublicKey: address })).toThrow(); + expect(() => walletLinkSchema.parse({ account: address })).toThrow(); + expect(() => walletLinkSchema.parse({})).toThrow(); + expect(() => walletLinkSchema.parse({ transaction: '' })).toThrow(); + }); +}); + +describe('cutoff startup validation (requirement 4: validated at startup)', () => { + it('passes with the test suite configuration', () => { + expect(() => validateWalletLinkConfig()).not.toThrow(); + }); + + it('names the env var when the cutoff is malformed', async () => { + vi.resetModules(); + vi.stubEnv('WALLET_LINK_UNVERIFIED_FUNDING_CUTOFF', 'not-a-date'); + + const fresh = await import('../../src/modules/payments/services/wallet-link.js'); + expect(() => fresh.validateWalletLinkConfig()).toThrow(/WALLET_LINK_UNVERIFIED_FUNDING_CUTOFF/); + // Never echoes a value (this one isn't secret, but the rule is the + // rule — same message shape as the other startup validators). + expect(() => fresh.validateWalletLinkConfig()).not.toThrow(/not-a-date/); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); +}); diff --git a/backend/vitest.config.mts b/backend/vitest.config.mts index 26711048..a6bbfea4 100644 --- a/backend/vitest.config.mts +++ b/backend/vitest.config.mts @@ -18,14 +18,17 @@ export default defineConfig({ env: { JWT_ACCESS_SECRET: 'test-access-secret', JWT_REFRESH_SECRET: 'test-refresh-secret', + // SEP-10 (issue #77) — a throwaway keypair used ONLY by the test + // suite; it holds no funds and exists to make the challenge/verify + // code paths deterministic offline. Not a real deployment secret. + SEP10_SIGNING_SECRET: 'SC3Z2YBPD7KGXC73CTBNDRGQOIUSAZUH445QIB2PPKWO7CXULRY75JGJ', + SEP10_HOME_DOMAIN: 'docforum.test', + SEP10_WEB_AUTH_DOMAIN: 'api.docforum.test', // Clinical free-text fields are encrypted at write time (issue #46). // A fixed 32-byte test key keeps ciphertext deterministic across runs // without touching the real key, which only production/dev envs hold. FIELD_ENCRYPTION_KEY_V1: 'BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=', FIELD_ENCRYPTION_ACTIVE_VERSION: 'v1', - SEP10_SIGNING_SECRET: 'SC3Z2YBPD7KGXC73CTBNDRGQOIUSAZUH445QIB2PPKWO7CXULRY75JGJ', - SEP10_HOME_DOMAIN: 'docforum.test', - SEP10_WEB_AUTH_DOMAIN: 'api.docforum.test', // Port 5433, not 5432 — tests/integration/booking.test.mts spins up // its own throwaway Postgres cluster on this port so the suite is // self-contained (no pre-existing DB required). A distinct port