diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 27556fe..c4975ee 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -60,6 +60,45 @@ headroom for the worker process's own pool. If you see `P2024` errors, increase `DATABASE_POOL_SIZE` rather than `DATABASE_POOL_TIMEOUT` — a longer timeout merely delays failures under sustained pressure. +## Restricted worker execution + +The current worker binary deliberately does **not** run `blockchain-indexer`, +`notifications`, or `fraud-activity-cleanup`. It registers no consumers or recurring +jobs for them and does not replay historical events. Pending jobs and existing +repeat definitions remain stored; they are not drained, cancelled or executed by +this binary. Polling/checkpoint progress, queued notification delivery and fraud +retention cleanup are unavailable. API endpoints remain available under their +existing authorization rules; this does not make the application fully operational. + +Historically the worker intended to schedule polling and daily cleanup and start +all three consumers. Missing imports prevented startup. The explicit restriction +replaces that accidental failure while restoring compilation. The process now +runs only its liveness heartbeat and shutdown handling. Startup logs report +`workerExecution.status: blocked`, each queue's reason, and `workerCount: 0`. +`GET /health/queue` includes the same fixed `workerExecution` state and reports +`degraded` even for empty queues (existing backend/stall failures still return503). +`GET /health/indexer` reports503/degraded with the same restriction and +`healthy: false` for each contract, even when its lag snapshot is small. +The heartbeat and `GET /health` prove process/dependency liveness only, **not job +availability or deployment readiness**. + +There is no environment override for activation. A separately reviewed code change +and rollout decision must first establish: + +- Indexer consumer completion, ordering/idempotency and recovery, complete event + coverage and safe checkpoint progression; historical replay needs its own policy. +- Notification intent/outbox deduplication and recovery for ambiguous SMTP outcomes, + including pending backlog treatment without duplicate or unapproved sends. +- Retention/window consistency, a reliable processing frontier aligned with evaluator + time, bounded deletion and restart/concurrency recovery without loss of required data. + +Do not substitute the unfinished M10 system or globally enable execution to make +status green. Before rollout, inventory existing workers and recurring schedules: +older binaries must not keep consuming these queues. This binary does not remotely +pause queues or disable other processes. Expect backlog/storage growth and stalled +indexer lag; monitor capacity and communicate the unavailable services. Existing +schema/session migration and recovery requirements still apply. + ## Health Checks - `GET /health` — liveness/readiness: database + Redis reachability (see `src/shared/http/routes/health.ts`). **Point your orchestrator's readiness probe at this endpoint.** diff --git a/docs/SESSION_SECURITY.md b/docs/SESSION_SECURITY.md new file mode 100644 index 0000000..5683b82 --- /dev/null +++ b/docs/SESSION_SECURITY.md @@ -0,0 +1,13 @@ +# Session revocation policy + +Each new login creates an independent persisted refresh-token family. Rotation locks the user row and commits token consumption and successor insertion together. A cryptographically valid, unexpired token whose persisted row is already revoked is a reuse signal: the whole family is revoked, including any successor committed by a concurrent rotation. There is no retry grace window. Clients must serialize refresh requests; concurrent reuse may invalidate the winning response too. Unknown/mismatched/expired tokens do not revoke unrelated sessions. + +Family revocation affects refresh capability. Already issued access JWTs remain usable until their normal expiry; this change does not claim family-scoped immediate access revocation. Password reset and administrator role changes invalidate access through the user token version. + +Historical refresh-token ancestry is unknown. The additive migration leaves historical family IDs null and revokes historical refresh tokens. Users must log in again; existing access tokens retain their previous expiry/version behavior. Do not backfill guessed families. During deployment quiesce old application instances before migrating and restart only upgraded instances: old binaries do not enforce family revocation. Retain revoked token rows for replay detection through their JWT expiry. No cleanup is part of this change. + +Password reset serializes against rotation and login using the user row lock. Password hash, all refresh revocations and token-version increment commit atomically. Failed transactions preserve the old credentials. Login rechecks the current password under the same lock, so a pending login cannot issue a new family based on pre-reset credentials. + +Access authentication reads role/version from the primary database on every request; Redis auth-context entries are no longer trusted or populated by the guard. This deliberately trades caching performance for a cross-instance revocation guarantee. Redis eviction failures and stale fills cannot extend access. Database failure denies authorization. A request whose authoritative check completed before reset committed can already be in flight; this does not cancel running handlers. New checks after commit reject the old version. Deployment must replace all old guard instances; no replica lag is permitted for this lookup. + +Administrator role changes acquire PostgreSQL transaction advisory lock `(1835101729, 1)` before reading or mutating the administrator set. The existing transaction includes count, role change, refresh-family/token revocation, version increment and audit insertion. Rollback/crash releases the lock. No automatic transaction retries are introduced. All future runtime role-mutation or account-deletion paths must share this lock and check the invariant; Phase 1 and the implementation check found no runtime user-deletion endpoint. Direct DBA writes and bootstrap seed scripts are outside the HTTP invariant and must not be used as account-removal APIs. diff --git a/docs/contracts/escrow-token-verification.md b/docs/contracts/escrow-token-verification.md new file mode 100644 index 0000000..7975e0b --- /dev/null +++ b/docs/contracts/escrow-token-verification.md @@ -0,0 +1,35 @@ +# Escrow token verification + +`Escrow.isVerified` records acceptance by the token policy when `escrow_funded` +is ingested. It is off-chain state, separate from `User.emailVerifiedAt`, wallet +signature verification and driver KYC. It is not a transaction authorization +credential. The chain reader cannot supply the local allowlist decision. + +The introducing change (`7ebc2b5`, issue #383) persisted the decision and filtered +released GMV by it. The schema field and repository write were lost in merge +`5c96b86`; the policy, domain field and analytics predicate survived. No historical +migration added `is_verified`. Restore the persisted contract rather than remove +the GMV restriction or derive historical acceptance from today's configuration. + +New ingestion keeps the existing policy: a configured allowlist decides membership; +without one, the existing compatibility policy accepts tokens. This repair does +not change that configuration policy. Repository callers without a decision and +rows predating the migration default to false. False means acceptance has not +been established, including unknown historical state. Only explicit true enters +released GMV. Status updates retain the stored decision. Changing the allowlist +does not retroactively reclassify stored rows. + +No reliable historical allowlist snapshots or per-row decisions are available. +The additive migration therefore excludes historical rows from GMV; it does not +infer truth from token names, release status, email or wallet timestamps. Any +future historical reclassification needs separately reviewed reliable evidence. +The public escrow DTO continues to omit this internal flag. Authentication, +role restrictions, email verification and wallet verification are unchanged. + +Before rollout, inspect redacted target migration history and catalog. Apply the +new migration through the approved process before starting a matching generated +Prisma client/binary. An unexpected existing column is schema drift requiring +investigation, not a reason to replay or overwrite its values. Preserve all +applied migrations and stored decisions during recovery. Fresh replay currently +fails earlier at the duplicate analytics index; a component upgrade test does +not certify the full chain or a representative deployment upgrade. diff --git a/docs/contracts/mp4-evidence-header.md b/docs/contracts/mp4-evidence-header.md new file mode 100644 index 0000000..1713062 --- /dev/null +++ b/docs/contracts/mp4-evidence-header.md @@ -0,0 +1,26 @@ +# MP4 evidence header recognition + +Dispute upload checks bytes before hashing, saving or inserting evidence. The +service, signature helper and exported schema helper share `matchesMp4Header`. +The HTTP schema still checks the MIME allowlist and upload sizes; the upload +service performs the header check after the existing dispute/ownership guards. +No file extension or client MIME declaration substitutes for matching bytes. + +The recognizer follows the basic box and FileTypeBox syntax in +[ISO/IEC 14496-12:2008, sections 4.2–4.3](https://cdn.standards.iteh.ai/samples/51533/c8233bd62f144bdda13c99156e984616/ISO-IEC-14496-12-2008.pdf). +The leading box type is at byte4, after its size. Standard, extended-size and +size-to-EOF forms are supported. The complete declared box must fit the supplied +view, contain major-brand/minor-version fields and an aligned array of printable +four-character brands. Extended sizes are compared exactly with bigint. + +This is bounded container-header recognition, **not whole-video validation or a +content-safety guarantee**. It does not scan for a marker elsewhere, traverse +preceding boxes, verify a brand registry/profile, inspect later boxes or decode +tracks/codecs. A matching header alone does not establish that a file is a valid, +playable or safe video. A leading `ftyp` remains the supported recognition scope. + +Malformed/truncated MP4 headers retain the existing400/VALIDATION_ERROR response +before storage or metadata writes. Authorization, stable evidence ownership, +SHA256 of the exact uploaded bytes, configured decoded/base64/body limits and +storage/failure behavior are unchanged. Other MIME checks are unchanged. Worker +execution remains restricted; this repair does not enable any background job. diff --git a/prisma/migrations/.gitkeep b/prisma/migrations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/prisma/migrations/20261030120000_session_families/migration.sql b/prisma/migrations/20261030120000_session_families/migration.sql new file mode 100644 index 0000000..f2d5b9f --- /dev/null +++ b/prisma/migrations/20261030120000_session_families/migration.sql @@ -0,0 +1,10 @@ +-- No historical ancestry is known: legacy tokens stay unassigned and are invalidated. +CREATE TABLE "session_families" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "revoked_at" TIMESTAMP(3) +); +CREATE INDEX "session_families_user_id_idx" ON "session_families"("user_id"); +ALTER TABLE "refresh_tokens" ADD COLUMN "family_id" TEXT REFERENCES "session_families"("id") ON DELETE CASCADE ON UPDATE CASCADE; +CREATE INDEX "refresh_tokens_family_id_idx" ON "refresh_tokens"("family_id"); +UPDATE "refresh_tokens" SET "revoked_at" = CURRENT_TIMESTAMP, "updated_at" = CURRENT_TIMESTAMP WHERE "revoked_at" IS NULL; diff --git a/prisma/migrations/20261103120000_escrow_token_verification/migration.sql b/prisma/migrations/20261103120000_escrow_token_verification/migration.sql new file mode 100644 index 0000000..9ea3747 --- /dev/null +++ b/prisma/migrations/20261103120000_escrow_token_verification/migration.sql @@ -0,0 +1,4 @@ +-- Restore the escrow token-policy result introduced in 7ebc2b5, lost at merge. +-- No historical allowlist snapshot exists: unknown rows must not contribute to GMV. +-- Do not infer verification from RELEASED status, token name, or user timestamps. +ALTER TABLE "escrows" ADD COLUMN "is_verified" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2ee320b..4fef012 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -138,6 +138,7 @@ model User { wallets WalletAddress[] refreshTokens RefreshToken[] + sessionFamilies SessionFamily[] notifications Notification[] auditLogs AuditLog[] ownedFleets Fleet[] @@ -147,18 +148,32 @@ model User { @@map("users") } +model SessionFamily { + id String @id @default(uuid()) + userId String @map("user_id") + revokedAt DateTime? @map("revoked_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + tokens RefreshToken[] + + @@index([userId]) + @@map("session_families") +} + model RefreshToken { - id String @id @default(uuid()) - userId String @map("user_id") - tokenHash String @unique @map("token_hash") - expiresAt DateTime @map("expires_at") - revokedAt DateTime? @map("revoked_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + familyId String? @map("family_id") + family SessionFamily? @relation(fields: [familyId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) + userId String @map("user_id") + tokenHash String @unique @map("token_hash") + expiresAt DateTime @map("expires_at") + revokedAt DateTime? @map("revoked_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) + @@index([familyId]) @@map("refresh_tokens") } @@ -244,6 +259,9 @@ model Escrow { releasedAt DateTime? @map("released_at") refundedAt DateTime? @map("refunded_at") createdAtChain DateTime @map("created_at_chain") + /// Token accepted by the ingestion allowlist policy; unrelated to user verification. + /// False also covers historical rows with no recorded verification evidence. + isVerified Boolean @default(false) @map("is_verified") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -475,6 +493,7 @@ model Notification { type String payload Json status NotificationStatus @default(PENDING) + readAt DateTime? @map("read_at") sentAt DateTime? @map("sent_at") failureReason String? @map("failure_reason") attempts Int @default(0) diff --git a/src/blockchain/xdr/sc-val.spec.ts b/src/blockchain/xdr/sc-val.spec.ts index 63e5a82..a291d82 100644 --- a/src/blockchain/xdr/sc-val.spec.ts +++ b/src/blockchain/xdr/sc-val.spec.ts @@ -1,6 +1,7 @@ import { Address, Keypair, xdr } from '@stellar/stellar-sdk'; import { describe, expect, it } from 'vitest'; import { + i128ToScVal, addressToScVal, boolToScVal, namedStructToScVal, @@ -156,3 +157,15 @@ describe('native -> ScVal encoders', () => { }); }); }); + +describe('signed i128 financial encoding', () => { + it.each([-(1n << 127n), (1n << 127n) - 1n, -1n, 0n, 1n, 9007199254740993n])( + 'round-trips exactly %s', + (value) => { + expect(scValToNative(i128ToScVal(value))).toBe(value.toString()); + }, + ); + it.each([-(1n << 127n) - 1n, 1n << 127n])('rejects overflow %s instead of wrapping', (value) => { + expect(() => i128ToScVal(value)).toThrow('signed 128-bit bounds'); + }); +}); diff --git a/src/blockchain/xdr/sc-val.ts b/src/blockchain/xdr/sc-val.ts index a0cc706..e15d3d6 100644 --- a/src/blockchain/xdr/sc-val.ts +++ b/src/blockchain/xdr/sc-val.ts @@ -1,4 +1,5 @@ import { Address, xdr } from '@stellar/stellar-sdk'; +import { assertI128 } from '../../shared/validation/i128.js'; /** * Converts a Soroban `xdr.ScVal` into a plain JS value. Covers the variants @@ -118,6 +119,7 @@ export function u64ToScVal(value: bigint | number): xdr.ScVal { * always non-negative in practice, but the Rust type is signed (`i128`), so * this accepts any bigint rather than assuming non-negativity. */ export function i128ToScVal(value: bigint): xdr.ScVal { + assertI128(value); const mask64 = (1n << 64n) - 1n; const lo = value & mask64; const hi = value >> 64n; diff --git a/src/modules/admin/infrastructure/admin-security.integration.spec.ts b/src/modules/admin/infrastructure/admin-security.integration.spec.ts new file mode 100644 index 0000000..f240ed5 --- /dev/null +++ b/src/modules/admin/infrastructure/admin-security.integration.spec.ts @@ -0,0 +1,98 @@ +import { randomUUID } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import { afterAll, afterEach, describe, expect, it } from 'vitest'; +import { createUpdateUserRoleUseCase } from '../application/update-user-role.js'; +import { createPrismaAdminUnitOfWork } from './prisma-admin-unit-of-work.js'; + +const prisma = new PrismaClient(); +const ids: string[] = []; +afterEach(async () => { + await prisma.auditLog.deleteMany({ where: { actorId: { in: ids } } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); + ids.length = 0; +}); +afterAll(async () => { + await prisma.$disconnect(); +}); +const change = createUpdateUserRoleUseCase({ + unitOfWork: createPrismaAdminUnitOfWork(prisma), + authCacheInvalidator: { async invalidateUser() {} }, +}); + +async function admins() { + // This test requires a disposable database, not a shared account catalog. + expect(await prisma.user.count({ where: { role: 'ADMIN' } })).toBe(0); + const first = await prisma.user.create({ + data: { email: `${randomUUID()}@admin.invalid`, passwordHash: 'fixture', role: 'ADMIN' }, + }); + const second = await prisma.user.create({ + data: { email: `${randomUUID()}@admin.invalid`, passwordHash: 'fixture', role: 'ADMIN' }, + }); + ids.push(first.id, second.id); + return { first, second }; +} + +describe('administrator set invariant on PostgreSQL', () => { + it.each([1, 2, 3, 4, 5])( + 'preserves one administrator under simultaneous mutual demotions, round %s', + async () => { + const { first, second } = await admins(); + const results = await Promise.allSettled([ + change({ actorId: first.id, userId: second.id, role: 'CUSTOMER' }), + change({ actorId: second.id, userId: first.id, role: 'CUSTOMER' }), + ]); + expect(results.filter((x) => x.status === 'fulfilled')).toHaveLength(1); + expect(results.filter((x) => x.status === 'rejected')).toHaveLength(1); + expect(await prisma.user.count({ where: { role: 'ADMIN' } })).toBe(1); + expect(await prisma.auditLog.count({ where: { actorId: { in: ids } } })).toBe(1); + const demoted = await prisma.user.findFirstOrThrow({ + where: { id: { in: ids }, role: 'CUSTOMER' }, + }); + expect(demoted.tokenVersion).toBe(1); + }, + ); + + it('rolls back role, sessions and audit and releases the invariant lock', async () => { + const { first, second } = await admins(); + const family = await prisma.sessionFamily.create({ data: { userId: second.id } }); + const refresh = await prisma.refreshToken.create({ + data: { + userId: second.id, + familyId: family.id, + tokenHash: randomUUID(), + expiresAt: new Date(Date.now() + 60000), + }, + }); + const real = createPrismaAdminUnitOfWork(prisma); + const failing = createUpdateUserRoleUseCase({ + unitOfWork: { + run: (work) => + real.run(async (ports) => { + await work(ports); + throw new Error('precommit failure'); + }), + }, + authCacheInvalidator: { async invalidateUser() {} }, + }); + await expect( + failing({ actorId: first.id, userId: second.id, role: 'CUSTOMER' }), + ).rejects.toThrow('precommit failure'); + expect(await prisma.user.findUnique({ where: { id: second.id } })).toMatchObject({ + role: 'ADMIN', + tokenVersion: 0, + }); + expect(await prisma.refreshToken.findUnique({ where: { id: refresh.id } })).toMatchObject({ + revokedAt: null, + }); + expect(await prisma.sessionFamily.findUnique({ where: { id: family.id } })).toMatchObject({ + revokedAt: null, + }); + expect(await prisma.auditLog.count({ where: { actorId: { in: ids } } })).toBe(0); + await expect( + change({ actorId: first.id, userId: second.id, role: 'CUSTOMER' }), + ).resolves.toMatchObject({ role: 'CUSTOMER' }); + expect(await prisma.sessionFamily.findUnique({ where: { id: family.id } })).toMatchObject({ + revokedAt: expect.any(Date), + }); + }); +}); diff --git a/src/modules/admin/infrastructure/prisma-admin-unit-of-work.ts b/src/modules/admin/infrastructure/prisma-admin-unit-of-work.ts index ad2cf31..56c812a 100644 --- a/src/modules/admin/infrastructure/prisma-admin-unit-of-work.ts +++ b/src/modules/admin/infrastructure/prisma-admin-unit-of-work.ts @@ -16,13 +16,16 @@ import { createPrismaUserRoleRepository } from './prisma-user-role-repository.js export function createPrismaAdminUnitOfWork(prisma: PrismaClient): AdminUnitOfWork { return { async run(work) { - return prisma.$transaction(async (tx) => - work({ + return prisma.$transaction(async (tx) => { + // Serialize the global administrator set before reading its count. + // Transaction-scoped: rollback/crash releases the lock automatically. + await tx.$queryRaw`SELECT pg_advisory_xact_lock(1835101729, 1)::text`; + return work({ userRoleRepository: createPrismaUserRoleRepository(tx), auditLogRepository: createPrismaAuditLogRepository(tx), sessionRevoker: createPrismaSessionRevoker(tx), - }), - ); + }); + }); }, }; } diff --git a/src/modules/admin/infrastructure/prisma-session-revoker.ts b/src/modules/admin/infrastructure/prisma-session-revoker.ts index dd66021..eab05ad 100644 --- a/src/modules/admin/infrastructure/prisma-session-revoker.ts +++ b/src/modules/admin/infrastructure/prisma-session-revoker.ts @@ -18,6 +18,10 @@ import type { SessionRevoker } from '../domain/index.js'; export function createPrismaSessionRevoker(prisma: Prisma.TransactionClient): SessionRevoker { return { async revokeAllForUser(userId) { + await prisma.sessionFamily.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); await prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() }, diff --git a/src/modules/analytics/infrastructure/prisma-analytics-reader.integration.spec.ts b/src/modules/analytics/infrastructure/prisma-analytics-reader.integration.spec.ts index 1e1af0f..6339557 100644 --- a/src/modules/analytics/infrastructure/prisma-analytics-reader.integration.spec.ts +++ b/src/modules/analytics/infrastructure/prisma-analytics-reader.integration.spec.ts @@ -64,6 +64,7 @@ describe.skipIf(!dbAvailable)('Prisma analytics reader (integration)', () => { token, amount: '100', status: 'RELEASED', + isVerified: true, createdAtChain: new Date(), }, }); @@ -76,6 +77,7 @@ describe.skipIf(!dbAvailable)('Prisma analytics reader (integration)', () => { token, amount: '170141183460469231731687303715884105727', status: 'RELEASED', + isVerified: true, createdAtChain: new Date(), }, }); diff --git a/src/modules/auth/application/__fixtures__/fakes.ts b/src/modules/auth/application/__fixtures__/fakes.ts index 0bde8ce..9dda15e 100644 --- a/src/modules/auth/application/__fixtures__/fakes.ts +++ b/src/modules/auth/application/__fixtures__/fakes.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import type { + AuthUnitOfWork, Clock, IssuedRefreshToken, Mailer, @@ -68,11 +69,13 @@ export function createInMemoryUserRepository(): UserRepository & { seed(user: Us export function createInMemoryRefreshTokenRepository(): RefreshTokenRepository { const records = new Map(); + const revokedFamilies = new Set(); return { async create(input) { const record: RefreshTokenRecord = { id: randomUUID(), + familyId: input.familyId ?? randomUUID(), userId: input.userId, tokenHash: input.tokenHash, expiresAt: input.expiresAt, @@ -92,6 +95,14 @@ export function createInMemoryRefreshTokenRepository(): RefreshTokenRepository { const record = records.get(id); if (record) record.revokedAt = new Date(); }, + async isFamilyActive(familyId) { + return !revokedFamilies.has(familyId); + }, + async revokeFamily(familyId) { + revokedFamilies.add(familyId); + for (const record of records.values()) + if (record.familyId === familyId) record.revokedAt = new Date(); + }, async revokeAllForUser(userId) { for (const record of records.values()) { if (record.userId === userId && record.revokedAt === null) { @@ -239,3 +250,14 @@ export function buildUser(overrides: Partial = {}): User { ...overrides, }; } + +export function createFakeAuthUnitOfWork( + userRepository: UserRepository, + refreshTokenRepository: RefreshTokenRepository, +): AuthUnitOfWork { + return { + async run(_userId, work) { + return work({ userRepository, refreshTokenRepository }); + }, + }; +} diff --git a/src/modules/auth/application/login.spec.ts b/src/modules/auth/application/login.spec.ts index c17ece8..43ac4fb 100644 --- a/src/modules/auth/application/login.spec.ts +++ b/src/modules/auth/application/login.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { createLoginUseCase } from './login.js'; import { InvalidCredentialsError } from '../domain/index.js'; import { + createFakeAuthUnitOfWork, buildUser, createFakePasswordHasher, createFakeTokenService, @@ -18,7 +19,7 @@ function setup() { userRepository, passwordHasher, tokenService, - refreshTokenRepository, + unitOfWork: createFakeAuthUnitOfWork(userRepository, refreshTokenRepository), }); return { userRepository, refreshTokenRepository, login }; } diff --git a/src/modules/auth/application/login.ts b/src/modules/auth/application/login.ts index 8dafb08..3678cc6 100644 --- a/src/modules/auth/application/login.ts +++ b/src/modules/auth/application/login.ts @@ -1,6 +1,6 @@ import type { PasswordHasher, - RefreshTokenRepository, + AuthUnitOfWork, TokenService, UserRepository, UserRole, @@ -11,7 +11,7 @@ export interface LoginDeps { userRepository: UserRepository; passwordHasher: PasswordHasher; tokenService: TokenService; - refreshTokenRepository: RefreshTokenRepository; + unitOfWork: AuthUnitOfWork; } export interface LoginInput { @@ -43,35 +43,39 @@ export interface LoginResult { export function createLoginUseCase(deps: LoginDeps) { return async function login(input: LoginInput): Promise { const email = input.email.trim().toLowerCase(); - const user = await deps.userRepository.findByEmail(email); + const candidate = await deps.userRepository.findByEmail(email); // Deliberately identical failure for "no such user" and "wrong password" // — distinguishing them lets an attacker enumerate registered emails. - if (!user) { - throw new InvalidCredentialsError(); - } - const passwordMatches = await deps.passwordHasher.compare(input.password, user.passwordHash); - if (!passwordMatches) { + if (!candidate) { throw new InvalidCredentialsError(); } + return deps.unitOfWork.run(candidate.id, async ({ userRepository, refreshTokenRepository }) => { + const user = await userRepository.findById(candidate.id); + if (!user) throw new InvalidCredentialsError(); + const passwordMatches = await deps.passwordHasher.compare(input.password, user.passwordHash); + if (!passwordMatches) { + throw new InvalidCredentialsError(); + } - const accessToken = deps.tokenService.issueAccessToken(user); - const issued = deps.tokenService.issueRefreshToken(user); - await deps.refreshTokenRepository.create({ - userId: user.id, - tokenHash: issued.tokenHash, - expiresAt: issued.expiresAt, - }); + const accessToken = deps.tokenService.issueAccessToken(user); + const issued = deps.tokenService.issueRefreshToken(user); + await refreshTokenRepository.create({ + userId: user.id, + tokenHash: issued.tokenHash, + expiresAt: issued.expiresAt, + }); - return { - accessToken, - refreshToken: issued.token, - user: { - id: user.id, - email: user.email, - role: user.role, - emailVerifiedAt: user.emailVerifiedAt, - }, - }; + return { + accessToken, + refreshToken: issued.token, + user: { + id: user.id, + email: user.email, + role: user.role, + emailVerifiedAt: user.emailVerifiedAt, + }, + }; + }); }; } diff --git a/src/modules/auth/application/refresh-session.spec.ts b/src/modules/auth/application/refresh-session.spec.ts index f122902..97ee990 100644 --- a/src/modules/auth/application/refresh-session.spec.ts +++ b/src/modules/auth/application/refresh-session.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { createRefreshSessionUseCase } from './refresh-session.js'; import { InvalidRefreshTokenError } from '../domain/index.js'; import { + createFakeAuthUnitOfWork, buildUser, createFakeClock, createFakeTokenService, @@ -26,8 +27,7 @@ async function setupWithSession() { }); const refreshSession = createRefreshSessionUseCase({ - userRepository, - refreshTokenRepository, + unitOfWork: createFakeAuthUnitOfWork(userRepository, refreshTokenRepository), tokenService, clock, }); diff --git a/src/modules/auth/application/refresh-session.ts b/src/modules/auth/application/refresh-session.ts index dd2072f..4e6d390 100644 --- a/src/modules/auth/application/refresh-session.ts +++ b/src/modules/auth/application/refresh-session.ts @@ -1,14 +1,8 @@ -import type { - Clock, - RefreshTokenRepository, - TokenService, - UserRepository, -} from '../domain/index.js'; +import type { Clock, AuthUnitOfWork, TokenService } from '../domain/index.js'; import { InvalidRefreshTokenError, UserNotFoundError } from '../domain/index.js'; export interface RefreshSessionDeps { - refreshTokenRepository: RefreshTokenRepository; - userRepository: UserRepository; + unitOfWork: AuthUnitOfWork; tokenService: TokenService; clock: Clock; } @@ -36,30 +30,36 @@ export function createRefreshSessionUseCase(deps: RefreshSessionDeps) { throw new InvalidRefreshTokenError(); } - const record = await deps.refreshTokenRepository.findByTokenHash(verified.tokenHash); - if ( - !record || - record.revokedAt !== null || - record.expiresAt.getTime() < deps.clock.now().getTime() - ) { - throw new InvalidRefreshTokenError(); - } - - const user = await deps.userRepository.findById(verified.userId); - if (!user) { - throw new UserNotFoundError(); - } - - await deps.refreshTokenRepository.revoke(record.id); - - const accessToken = deps.tokenService.issueAccessToken(user); - const issued = deps.tokenService.issueRefreshToken(user); - await deps.refreshTokenRepository.create({ - userId: user.id, - tokenHash: issued.tokenHash, - expiresAt: issued.expiresAt, - }); - - return { accessToken, refreshToken: issued.token }; + // Invalid reuse must return from the transaction normally so revocation commits. + const result = await deps.unitOfWork.run( + verified.userId, + async ({ userRepository, refreshTokenRepository }) => { + const record = await refreshTokenRepository.findByTokenHash(verified.tokenHash); + if (!record || record.userId !== verified.userId || !record.familyId) return null; + if (record.revokedAt !== null) { + await refreshTokenRepository.revokeFamily(record.familyId); + return null; + } + if ( + record.expiresAt.getTime() <= deps.clock.now().getTime() || + !(await refreshTokenRepository.isFamilyActive(record.familyId)) + ) + return null; + const user = await userRepository.findById(verified.userId); + if (!user) throw new UserNotFoundError(); + await refreshTokenRepository.revoke(record.id); + const accessToken = deps.tokenService.issueAccessToken(user); + const issued = deps.tokenService.issueRefreshToken(user); + await refreshTokenRepository.create({ + userId: user.id, + tokenHash: issued.tokenHash, + expiresAt: issued.expiresAt, + familyId: record.familyId, + }); + return { accessToken, refreshToken: issued.token }; + }, + ); + if (!result) throw new InvalidRefreshTokenError(); + return result; }; } diff --git a/src/modules/auth/application/reset-password.spec.ts b/src/modules/auth/application/reset-password.spec.ts index e8eac9a..66a7ef8 100644 --- a/src/modules/auth/application/reset-password.spec.ts +++ b/src/modules/auth/application/reset-password.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { createResetPasswordUseCase } from './reset-password.js'; import { InvalidPasswordResetTokenError } from '../domain/index.js'; import { + createFakeAuthUnitOfWork, buildUser, createFakePasswordHasher, createFakeTokenService, @@ -15,10 +16,9 @@ function setup() { const tokenService = createFakeTokenService(); const refreshTokenRepository = createInMemoryRefreshTokenRepository(); const resetPassword = createResetPasswordUseCase({ - userRepository, + unitOfWork: createFakeAuthUnitOfWork(userRepository, refreshTokenRepository), passwordHasher, tokenService, - refreshTokenRepository, }); return { userRepository, tokenService, refreshTokenRepository, resetPassword }; } diff --git a/src/modules/auth/application/reset-password.ts b/src/modules/auth/application/reset-password.ts index 74e49f6..a9e525a 100644 --- a/src/modules/auth/application/reset-password.ts +++ b/src/modules/auth/application/reset-password.ts @@ -1,16 +1,10 @@ -import type { - PasswordHasher, - RefreshTokenRepository, - TokenService, - UserRepository, -} from '../domain/index.js'; +import type { PasswordHasher, AuthUnitOfWork, TokenService } from '../domain/index.js'; import { InvalidPasswordResetTokenError } from '../domain/index.js'; export interface ResetPasswordDeps { - userRepository: UserRepository; + unitOfWork: AuthUnitOfWork; passwordHasher: PasswordHasher; tokenService: TokenService; - refreshTokenRepository: RefreshTokenRepository; } export interface ResetPasswordInput { @@ -18,24 +12,9 @@ export interface ResetPasswordInput { newPassword: string; } -/** - * Creates the reset-password use case. - * - * The returned function completes a password reset for a user who presents a - * valid reset token: it resolves the token's subject, verifies the token - * against the user's current password hash, stores the new password hash, and - * revokes all of the user's existing refresh tokens. - * - * @param deps - Collaborators required by the use case: - * - `userRepository`: looks up the user and persists the new password hash. - * - `passwordHasher`: hashes the new password before it is stored. - * - `tokenService`: peeks the token subject and verifies the reset token. - * - `refreshTokenRepository`: revokes every refresh token for the user. - * @returns An async function that accepts a {@link ResetPasswordInput} - * (`token` and `newPassword`) and resolves once the password has been - * updated and all sessions invalidated. It rejects with - * {@link InvalidPasswordResetTokenError} when the token is missing, unknown, - * or no longer matches the user's current password hash. +/** Password, refresh families and access-token version commit together. + * Authentication reads the authoritative DB version on every request; Redis is + * not an authorization source, so failed eviction or stale refill cannot extend access. */ export function createResetPasswordUseCase(deps: ResetPasswordDeps) { return async function resetPassword(input: ResetPasswordInput): Promise { @@ -44,32 +23,18 @@ export function createResetPasswordUseCase(deps: ResetPasswordDeps) { throw new InvalidPasswordResetTokenError(); } - const user = await deps.userRepository.findById(claimedUserId); - if (!user) { - throw new InvalidPasswordResetTokenError(); - } - - try { - // The token embeds a fingerprint of the password hash that was - // current when it was issued — verifying against the *current* hash - // means a token becomes invalid the instant the password changes, - // without needing a revocation table (docs/AUTHENTICATION.md). - deps.tokenService.verifyPasswordResetToken(input.token, user.passwordHash); - } catch { - throw new InvalidPasswordResetTokenError(); - } - - const newHash = await deps.passwordHasher.hash(input.newPassword); - await deps.userRepository.updatePasswordHash(user.id, newHash); - - // A password reset is a strong signal to invalidate every existing - // session, not just issue the caller a new one. Two paths invalidate - // sessions: (1) revokeAllForUser marks refresh_tokens as revoked so - // the token refresh endpoint rejects them, and (2) incrementTokenVersion - // invalidates already-issued access tokens on their next use (security - // issue #12), closing the ~15-minute window during which an attacker - // with a stolen session could continue making authenticated requests. - await deps.refreshTokenRepository.revokeAllForUser(user.id); - await deps.userRepository.incrementTokenVersion(user.id); + await deps.unitOfWork.run(claimedUserId, async ({ userRepository, refreshTokenRepository }) => { + const user = await userRepository.findById(claimedUserId); + if (!user) throw new InvalidPasswordResetTokenError(); + try { + deps.tokenService.verifyPasswordResetToken(input.token, user.passwordHash); + } catch { + throw new InvalidPasswordResetTokenError(); + } + const newHash = await deps.passwordHasher.hash(input.newPassword); + await userRepository.updatePasswordHash(user.id, newHash); + await refreshTokenRepository.revokeAllForUser(user.id); + await userRepository.incrementTokenVersion(user.id); + }); }; } diff --git a/src/modules/auth/domain/entities.ts b/src/modules/auth/domain/entities.ts index 9ddff25..506e4e5 100644 --- a/src/modules/auth/domain/entities.ts +++ b/src/modules/auth/domain/entities.ts @@ -17,6 +17,7 @@ export interface User { } export interface RefreshTokenRecord { + familyId: string | null; id: string; userId: string; tokenHash: string; diff --git a/src/modules/auth/domain/index.ts b/src/modules/auth/domain/index.ts index 86e9661..8c2335d 100644 --- a/src/modules/auth/domain/index.ts +++ b/src/modules/auth/domain/index.ts @@ -1,5 +1,6 @@ export type { User, UserRole, RefreshTokenRecord } from './entities.js'; export type { + AuthUnitOfWork, UserRepository, RefreshTokenRepository, PasswordHasher, diff --git a/src/modules/auth/domain/ports.ts b/src/modules/auth/domain/ports.ts index 813a344..3c3863c 100644 --- a/src/modules/auth/domain/ports.ts +++ b/src/modules/auth/domain/ports.ts @@ -32,9 +32,12 @@ export interface RefreshTokenRepository { userId: string; tokenHash: string; expiresAt: Date; + familyId?: string; }): Promise; findByTokenHash(tokenHash: string): Promise; revoke(id: string): Promise; + isFamilyActive(familyId: string): Promise; + revokeFamily(familyId: string): Promise; revokeAllForUser(userId: string): Promise; } @@ -92,3 +95,14 @@ export interface Mailer { export interface Clock { now(): Date; } + +/** Serializes session mutations against the user's row, then commits all effects together. */ +export interface AuthUnitOfWork { + run( + userId: string, + work: (ports: { + userRepository: UserRepository; + refreshTokenRepository: RefreshTokenRepository; + }) => Promise, + ): Promise; +} diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts index 692cec0..fd97279 100644 --- a/src/modules/auth/index.ts +++ b/src/modules/auth/index.ts @@ -16,6 +16,7 @@ import { createJwtTokenService, createPrismaRefreshTokenRepository, createPrismaUserRepository, + createPrismaAuthUnitOfWork, selectMailer, } from './infrastructure/index.js'; import { createAuthRoutes } from './interface/routes.js'; @@ -47,13 +48,12 @@ export function createAuthModule(prisma: PrismaClient): FastifyPluginAsyncZod { }), login: createLoginUseCase({ userRepository, + unitOfWork: createPrismaAuthUnitOfWork(prisma), passwordHasher, tokenService, - refreshTokenRepository, }), refreshSession: createRefreshSessionUseCase({ - userRepository, - refreshTokenRepository, + unitOfWork: createPrismaAuthUnitOfWork(prisma), tokenService, clock: { now: () => new Date() }, }), @@ -65,10 +65,9 @@ export function createAuthModule(prisma: PrismaClient): FastifyPluginAsyncZod { mailer, }), resetPassword: createResetPasswordUseCase({ - userRepository, + unitOfWork: createPrismaAuthUnitOfWork(prisma), passwordHasher, tokenService, - refreshTokenRepository, }), }; diff --git a/src/modules/auth/infrastructure/index.ts b/src/modules/auth/infrastructure/index.ts index d13e63d..5235fb9 100644 --- a/src/modules/auth/infrastructure/index.ts +++ b/src/modules/auth/infrastructure/index.ts @@ -5,3 +5,4 @@ export { createSmtpMailer } from './smtp-mailer.js'; export { selectMailer } from './select-mailer.js'; export { createPrismaUserRepository } from './prisma-user-repository.js'; export { createPrismaRefreshTokenRepository } from './prisma-refresh-token-repository.js'; +export { createPrismaAuthUnitOfWork } from './prisma-auth-unit-of-work.js'; diff --git a/src/modules/auth/infrastructure/prisma-auth-unit-of-work.ts b/src/modules/auth/infrastructure/prisma-auth-unit-of-work.ts new file mode 100644 index 0000000..998cee2 --- /dev/null +++ b/src/modules/auth/infrastructure/prisma-auth-unit-of-work.ts @@ -0,0 +1,19 @@ +import type { PrismaClient } from '@prisma/client'; +import type { AuthUnitOfWork } from '../domain/index.js'; +import { createPrismaUserRepository } from './prisma-user-repository.js'; +import { createPrismaRefreshTokenRepository } from './prisma-refresh-token-repository.js'; + +export function createPrismaAuthUnitOfWork(prisma: PrismaClient): AuthUnitOfWork { + return { + async run(userId, work) { + return prisma.$transaction(async (tx) => { + // One lock order for rotation/reset: user first, then family/token rows. + await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`; + return work({ + userRepository: createPrismaUserRepository(tx), + refreshTokenRepository: createPrismaRefreshTokenRepository(tx), + }); + }); + }, + }; +} diff --git a/src/modules/auth/infrastructure/prisma-refresh-token-repository.ts b/src/modules/auth/infrastructure/prisma-refresh-token-repository.ts index ea0a66f..a9285c2 100644 --- a/src/modules/auth/infrastructure/prisma-refresh-token-repository.ts +++ b/src/modules/auth/infrastructure/prisma-refresh-token-repository.ts @@ -1,9 +1,10 @@ -import type { PrismaClient, RefreshToken as PrismaRefreshToken } from '@prisma/client'; +import type { Prisma, RefreshToken as PrismaRefreshToken } from '@prisma/client'; import type { RefreshTokenRecord, RefreshTokenRepository } from '../domain/index.js'; function toDomain(record: PrismaRefreshToken): RefreshTokenRecord { return { id: record.id, + familyId: record.familyId, userId: record.userId, tokenHash: record.tokenHash, expiresAt: record.expiresAt, @@ -12,11 +13,20 @@ function toDomain(record: PrismaRefreshToken): RefreshTokenRecord { }; } -export function createPrismaRefreshTokenRepository(prisma: PrismaClient): RefreshTokenRepository { +export function createPrismaRefreshTokenRepository( + prisma: Prisma.TransactionClient, +): RefreshTokenRepository { return { async create(input) { const record = await prisma.refreshToken.create({ - data: { userId: input.userId, tokenHash: input.tokenHash, expiresAt: input.expiresAt }, + data: { + user: { connect: { id: input.userId } }, + tokenHash: input.tokenHash, + expiresAt: input.expiresAt, + family: input.familyId + ? { connect: { id: input.familyId } } + : { create: { userId: input.userId } }, + }, }); return toDomain(record); }, @@ -27,7 +37,25 @@ export function createPrismaRefreshTokenRepository(prisma: PrismaClient): Refres async revoke(id) { await prisma.refreshToken.update({ where: { id }, data: { revokedAt: new Date() } }); }, + async isFamilyActive(familyId) { + const family = await prisma.sessionFamily.findUnique({ where: { id: familyId } }); + return family !== null && family.revokedAt === null; + }, + async revokeFamily(familyId) { + await prisma.sessionFamily.update({ + where: { id: familyId }, + data: { revokedAt: new Date() }, + }); + await prisma.refreshToken.updateMany({ + where: { familyId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + }, async revokeAllForUser(userId) { + await prisma.sessionFamily.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); await prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() }, diff --git a/src/modules/auth/infrastructure/prisma-user-repository.ts b/src/modules/auth/infrastructure/prisma-user-repository.ts index 4050aa3..0b5babc 100644 --- a/src/modules/auth/infrastructure/prisma-user-repository.ts +++ b/src/modules/auth/infrastructure/prisma-user-repository.ts @@ -1,4 +1,4 @@ -import type { PrismaClient, User as PrismaUser } from '@prisma/client'; +import type { Prisma, User as PrismaUser } from '@prisma/client'; import type { User, UserRepository } from '../domain/index.js'; function toDomain(record: PrismaUser): User { @@ -20,7 +20,7 @@ function toDomain(record: PrismaUser): User { * test-database client per docs/DATABASE.md's "no mocking the repository * layer" rule) without this module knowing or caring. */ -export function createPrismaUserRepository(prisma: PrismaClient): UserRepository { +export function createPrismaUserRepository(prisma: Prisma.TransactionClient): UserRepository { return { async findByEmail(email) { const record = await prisma.user.findUnique({ where: { email } }); diff --git a/src/modules/auth/infrastructure/reset-security.integration.spec.ts b/src/modules/auth/infrastructure/reset-security.integration.spec.ts new file mode 100644 index 0000000..e0c1dec --- /dev/null +++ b/src/modules/auth/infrastructure/reset-security.integration.spec.ts @@ -0,0 +1,199 @@ +import { randomUUID } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { PrismaClient } from '@prisma/client'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { createResetPasswordUseCase } from '../application/reset-password.js'; +import { createLoginUseCase } from '../application/login.js'; +import { createPrismaAuthUnitOfWork } from './prisma-auth-unit-of-work.js'; +import { createPrismaRefreshTokenRepository } from './prisma-refresh-token-repository.js'; +import { createPrismaUserRepository } from './prisma-user-repository.js'; +import { createJwtTokenService } from './jwt-token-service.js'; +import { authenticate } from '../../../shared/http/plugins/auth-guard.js'; +import { disconnectPrisma } from '../../../shared/database/index.js'; +import { writeAuthContext, readAuthContext } from '../../../shared/cache/index.js'; +import { getRedisClient } from '../../../shared/cache/redis-client.js'; + +vi.mock('../../../shared/cache/redis-client.js', async () => { + const { default: Redis } = await import('ioredis'); + const redis = new Redis( + process.env.SECURITY_TEST_REDIS_SOCKET ?? process.env.REDIS_URL ?? 'redis://localhost:6379', + { + maxRetriesPerRequest: 0, + retryStrategy: () => null, + }, + ); + return { getRedisClient: () => redis, disconnectRedis: () => redis.disconnect() }; +}); +const prisma = new PrismaClient(); +const createdUserIds: string[] = []; +const tokens = createJwtTokenService(); +const hasher = { + async hash(value: string) { + return `hashed:${value}`; + }, + async compare(value: string, hash: string) { + return hash === `hashed:${value}`; + }, +}; +const reset = createResetPasswordUseCase({ + unitOfWork: createPrismaAuthUnitOfWork(prisma), + tokenService: tokens, + passwordHasher: hasher, +}); +const request = (token: string) => + ({ headers: { authorization: `Bearer ${token}` } }) as FastifyRequest; +const reply = {} as FastifyReply; +afterAll(async () => { + await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } }); + await prisma.$disconnect(); + await disconnectPrisma(); + getRedisClient().disconnect(); +}); + +async function setup() { + const user = await prisma.user.create({ + data: { email: `${randomUUID()}@reset.invalid`, passwordHash: 'hashed:old' }, + }); + createdUserIds.push(user.id); + const refresh = tokens.issueRefreshToken(user); + const row = await createPrismaRefreshTokenRepository(prisma).create({ + userId: user.id, + ...refresh, + }); + const access = tokens.issueAccessToken(user); + await writeAuthContext(user.id, { role: user.role, tokenVersion: user.tokenVersion }); + return { user, refresh, row, access, resetToken: tokens.issuePasswordResetToken(user) }; +} + +describe('atomic reset with authoritative authentication', () => { + it('rejects old access despite populated Redis and a delayed stale refill', async () => { + const { user, access, row, resetToken } = await setup(); + expect(await readAuthContext(user.id)).toMatchObject({ tokenVersion: 0 }); + await authenticate(request(access), reply); + await reset({ token: resetToken, newPassword: 'new' }); + // A different/lagging instance writes its previously-read context after commit. + await writeAuthContext(user.id, { role: 'ADMIN', tokenVersion: 0 }); + expect(await readAuthContext(user.id)).toMatchObject({ tokenVersion: 0 }); + await expect(authenticate(request(access), reply)).rejects.toThrow( + 'Invalid or expired access token', + ); + const current = await prisma.user.findUniqueOrThrow({ where: { id: user.id } }); + await expect( + authenticate(request(tokens.issueAccessToken(current)), reply), + ).resolves.toBeUndefined(); + expect(await prisma.sessionFamily.findUnique({ where: { id: row.familyId! } })).toMatchObject({ + revokedAt: expect.any(Date), + }); + }); + + it('rejects pre-reset access in a separate application process', async () => { + const { user, access, resetToken } = await setup(); + await reset({ token: resetToken, newPassword: 'new' }); + await writeAuthContext(user.id, { role: user.role, tokenVersion: 0 }); + const output = execFileSync( + process.execPath, + [ + '--import', + 'tsx', + '--input-type=module', + '-e', + ` + const { authenticate } = await import('./src/shared/http/plugins/auth-guard.ts'); + const { disconnectPrisma } = await import('./src/shared/database/index.ts'); + try { + await authenticate({ headers: { authorization: 'Bearer ' + process.env.SECURITY_TEST_ACCESS } }, {}); + process.exitCode = 1; + } catch (error) { + if (error.message !== 'Invalid or expired access token') throw error; + console.log('revoked'); + } finally { await disconnectPrisma(); } + `, + ], + { env: { ...process.env, SECURITY_TEST_ACCESS: access }, encoding: 'utf8' }, + ); + expect(output.trim()).toBe('revoked'); + }); + + it('rejects old access even when Redis reads and eviction fail', async () => { + const { access, resetToken } = await setup(); + const redis = getRedisClient(); + const get = vi.spyOn(redis, 'get').mockRejectedValue(new Error('cache unavailable')); + const del = vi.spyOn(redis, 'del').mockRejectedValue(new Error('eviction unavailable')); + try { + await reset({ token: resetToken, newPassword: 'new' }); + await expect(authenticate(request(access), reply)).rejects.toThrow( + 'Invalid or expired access token', + ); + expect(get).not.toHaveBeenCalled(); + expect(del).not.toHaveBeenCalled(); + } finally { + get.mockRestore(); + del.mockRestore(); + } + }); + + it('rolls back password, revocations and version together and preserves old access', async () => { + const { user, access, row, resetToken } = await setup(); + const atomic = createPrismaAuthUnitOfWork(prisma); + const failing = createResetPasswordUseCase({ + tokenService: tokens, + passwordHasher: hasher, + unitOfWork: { + run: (id, work) => + atomic.run(id, async (ports) => { + await work(ports); + throw new Error('precommit failure'); + }), + }, + }); + await expect(failing({ token: resetToken, newPassword: 'new' })).rejects.toThrow( + 'precommit failure', + ); + expect(await prisma.user.findUnique({ where: { id: user.id } })).toMatchObject({ + passwordHash: 'hashed:old', + tokenVersion: 0, + }); + expect(await prisma.sessionFamily.findUnique({ where: { id: row.familyId! } })).toMatchObject({ + revokedAt: null, + }); + expect(await prisma.refreshToken.findUnique({ where: { id: row.id } })).toMatchObject({ + revokedAt: null, + }); + await expect(authenticate(request(access), reply)).resolves.toBeUndefined(); + }); + + it('permits only one concurrent use of a reset link', async () => { + const { user, resetToken } = await setup(); + const results = await Promise.allSettled([ + reset({ token: resetToken, newPassword: 'first' }), + reset({ token: resetToken, newPassword: 'second' }), + ]); + expect(results.filter((x) => x.status === 'fulfilled')).toHaveLength(1); + expect(await prisma.user.findUnique({ where: { id: user.id } })).toMatchObject({ + tokenVersion: 1, + }); + }); + + it('rechecks a pending login password after reset instead of issuing a new family', async () => { + const { user, resetToken } = await setup(); + const users = createPrismaUserRepository(prisma); + const login = createLoginUseCase({ + userRepository: { + ...users, + async findByEmail(email) { + const stale = await users.findByEmail(email); + await reset({ token: resetToken, newPassword: 'new' }); + return stale; + }, + }, + unitOfWork: createPrismaAuthUnitOfWork(prisma), + tokenService: tokens, + passwordHasher: hasher, + }); + await expect(login({ email: user.email, password: 'old' })).rejects.toThrow(); + expect(await prisma.sessionFamily.count({ where: { userId: user.id, revokedAt: null } })).toBe( + 0, + ); + }); +}); diff --git a/src/modules/auth/infrastructure/session-security.integration.spec.ts b/src/modules/auth/infrastructure/session-security.integration.spec.ts new file mode 100644 index 0000000..af0548d --- /dev/null +++ b/src/modules/auth/infrastructure/session-security.integration.spec.ts @@ -0,0 +1,140 @@ +import { randomUUID } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { PrismaClient } from '@prisma/client'; +import { afterAll, describe, expect, it } from 'vitest'; +import { createRefreshSessionUseCase } from '../application/refresh-session.js'; +import { createPrismaAuthUnitOfWork } from './prisma-auth-unit-of-work.js'; +import { createPrismaRefreshTokenRepository } from './prisma-refresh-token-repository.js'; +import { createJwtTokenService } from './jwt-token-service.js'; + +const prisma = new PrismaClient(); +const createdUserIds: string[] = []; +const tokens = createJwtTokenService(); +const clients: PrismaClient[] = [prisma]; +afterAll(async () => { + await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } }); + for (const client of clients) await client.$disconnect(); +}); + +async function session() { + const user = await prisma.user.create({ + data: { email: `${randomUUID()}@security.invalid`, passwordHash: 'test-hash' }, + }); + createdUserIds.push(user.id); + const issued = tokens.issueRefreshToken(user); + const repo = createPrismaRefreshTokenRepository(prisma); + const row = await repo.create({ userId: user.id, ...issued }); + const refresh = createRefreshSessionUseCase({ + unitOfWork: createPrismaAuthUnitOfWork(prisma), + tokenService: tokens, + clock: { now: () => new Date() }, + }); + return { user, issued, repo, row, refresh }; +} + +describe('persistent session family rotation', () => { + it('serializes simultaneous reuse, revokes the winner and preserves unrelated logins', async () => { + const { user, issued, repo, row, refresh } = await session(); + const other = tokens.issueRefreshToken(user); + const independent = await repo.create({ userId: user.id, ...other }); + const attempts = await Promise.allSettled([ + refresh({ refreshToken: issued.token }), + refresh({ refreshToken: issued.token }), + ]); + expect(attempts.filter((x) => x.status === 'fulfilled')).toHaveLength(1); + expect(attempts.filter((x) => x.status === 'rejected')).toHaveLength(1); + expect( + await prisma.refreshToken.count({ where: { familyId: row.familyId, revokedAt: null } }), + ).toBe(0); + const winner = attempts.find((x) => x.status === 'fulfilled'); + if (winner?.status !== 'fulfilled') throw new Error('Expected winning response'); + await expect(refresh({ refreshToken: winner.value.refreshToken })).rejects.toThrow(); + expect(await repo.isFamilyActive(independent.familyId!)).toBe(true); + await expect(refresh({ refreshToken: other.token })).resolves.toHaveProperty('refreshToken'); + }); + + it('persists replay revocation across fresh clients and invalidates every descendant', async () => { + const { issued, row, refresh } = await session(); + const first = await refresh({ refreshToken: issued.token }); + const second = await refresh({ refreshToken: first.refreshToken }); + const client = new PrismaClient(); + clients.push(client); + const restarted = createRefreshSessionUseCase({ + unitOfWork: createPrismaAuthUnitOfWork(client), + tokenService: createJwtTokenService(), + clock: { now: () => new Date() }, + }); + await expect(restarted({ refreshToken: issued.token })).rejects.toThrow(); + expect(await client.sessionFamily.findUnique({ where: { id: row.familyId! } })).toMatchObject({ + revokedAt: expect.any(Date), + }); + await expect(restarted({ refreshToken: second.refreshToken })).rejects.toThrow(); + }); + + it('rejects a revoked successor in a completely new process', async () => { + const { issued, refresh } = await session(); + const successor = await refresh({ refreshToken: issued.token }); + await expect(refresh({ refreshToken: issued.token })).rejects.toThrow(); + const output = execFileSync( + process.execPath, + [ + '--import', + 'tsx', + '--input-type=module', + '-e', + ` + const { PrismaClient } = await import('@prisma/client'); + const { createPrismaAuthUnitOfWork } = await import('./src/modules/auth/infrastructure/prisma-auth-unit-of-work.ts'); + const { createRefreshSessionUseCase } = await import('./src/modules/auth/application/refresh-session.ts'); + const { createJwtTokenService } = await import('./src/modules/auth/infrastructure/jwt-token-service.ts'); + const prisma = new PrismaClient(); + try { + const refresh = createRefreshSessionUseCase({ unitOfWork: createPrismaAuthUnitOfWork(prisma), tokenService: createJwtTokenService(), clock: { now: () => new Date() } }); + await refresh({ refreshToken: process.env.SECURITY_TEST_REFRESH }); + process.exitCode = 1; + } catch (error) { + if (error.code !== 'UNAUTHORIZED' || error.message !== 'Refresh token is invalid, expired, or has been revoked') throw error; + console.log('revoked'); + } finally { await prisma.$disconnect(); } + `, + ], + { env: { ...process.env, SECURITY_TEST_REFRESH: successor.refreshToken }, encoding: 'utf8' }, + ); + expect(output.trim()).toBe('revoked'); + }); + + it('rolls back consumption if successor persistence fails', async () => { + const { issued, repo, row } = await session(); + // The existing token hash forces the real unique constraint to reject the insert. + const refresh = createRefreshSessionUseCase({ + unitOfWork: createPrismaAuthUnitOfWork(prisma), + tokenService: { ...tokens, issueRefreshToken: () => issued }, + clock: { now: () => new Date() }, + }); + await expect(refresh({ refreshToken: issued.token })).rejects.toThrow(); + expect(await repo.findByTokenHash(issued.tokenHash)).toMatchObject({ revokedAt: null }); + expect(await repo.isFamilyActive(row.familyId!)).toBe(true); + expect(await prisma.refreshToken.count({ where: { familyId: row.familyId } })).toBe(1); + }); + + it('rejects historical unassigned sessions without inventing ancestry', async () => { + const { user, refresh } = await session(); + const legacy = tokens.issueRefreshToken(user); + await prisma.refreshToken.create({ + data: { userId: user.id, tokenHash: legacy.tokenHash, expiresAt: legacy.expiresAt }, + }); + await expect(refresh({ refreshToken: legacy.token })).rejects.toThrow(); + }); + + it('rolls back both family revocation and consumption on transaction failure', async () => { + const { user, issued, row, repo } = await session(); + await expect( + createPrismaAuthUnitOfWork(prisma).run(user.id, async (ports) => { + await ports.refreshTokenRepository.revokeFamily(row.familyId!); + throw new Error('forced rollback'); + }), + ).rejects.toThrow('forced rollback'); + expect(await repo.isFamilyActive(row.familyId!)).toBe(true); + expect(await repo.findByTokenHash(issued.tokenHash)).toMatchObject({ revokedAt: null }); + }); +}); diff --git a/src/modules/deliveries/interface/deliveries-routes.integration.spec.ts b/src/modules/deliveries/interface/deliveries-routes.integration.spec.ts index 5750143..2d9c4d5 100644 --- a/src/modules/deliveries/interface/deliveries-routes.integration.spec.ts +++ b/src/modules/deliveries/interface/deliveries-routes.integration.spec.ts @@ -288,13 +288,13 @@ describe.skipIf(!dbAvailable)('deliveries routes (integration)', () => { // fallback, not a 404 (which would mean the route doesn't exist) or a // generic 500. it('reaches the raise-delivery-dispute handler and returns 502 BLOCKCHAIN_ERROR when DELIVERY_CONTRACT_ID is unconfigured', async () => { - const { accessToken } = await registerUser(); + const { accessToken, address } = await registerWithWallet(); const response = await app.inject({ method: 'POST', url: '/api/v1/transactions/build/raise-delivery-dispute', headers: { authorization: `Bearer ${accessToken}` }, - payload: { callerAddress: Keypair.random().publicKey(), chainDeliveryId: '1' }, + payload: { callerAddress: address, chainDeliveryId: '1' }, }); expect(response.statusCode).toBe(502); diff --git a/src/modules/deliveries/interface/routes.ts b/src/modules/deliveries/interface/routes.ts index ee87f3e..36a3b43 100644 --- a/src/modules/deliveries/interface/routes.ts +++ b/src/modules/deliveries/interface/routes.ts @@ -1,6 +1,7 @@ -import type { FastifyRequest } from 'fastify'; import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; -import { authenticate, ok } from '../../../shared/http/index.js'; +import { authenticate, ok, requireUser } from '../../../shared/http/index.js'; +import { ForbiddenError } from '../../../shared/errors/app-error.js'; +import type { WalletAddressRepository } from '../../users/domain/index.js'; import { serializeDelivery } from '../domain/index.js'; import type { createBuildDeliveryTransactionsUseCases, @@ -27,7 +28,27 @@ export interface DeliveriesUseCases { buildTransactions: ReturnType; } -export function createDeliveriesRoutes(useCases: DeliveriesUseCases): FastifyPluginAsyncZod { +export interface DeliveriesRoutesConfig { + walletAddressRepository: WalletAddressRepository; +} + +/** Ownership belongs to the shared wallet-linking repository. Check it before + * building the two caller-address transactions, as required by #381. */ +async function ensureCallerOwnedByUser( + config: DeliveriesRoutesConfig, + userId: string, + callerAddress: string, +): Promise { + const wallet = await config.walletAddressRepository.findByAddress(callerAddress); + if (!wallet || wallet.userId !== userId) { + throw new ForbiddenError('Caller address is not linked to your account'); + } +} + +export function createDeliveriesRoutes( + useCases: DeliveriesUseCases, + config: DeliveriesRoutesConfig, +): FastifyPluginAsyncZod { return async function deliveriesRoutes(app) { app.get( '/deliveries', @@ -86,7 +107,7 @@ export function createDeliveriesRoutes(useCases: DeliveriesUseCases): FastifyPlu schema: { body: assignDriverBodySchema, response: { 200: transactionResponseSchema } }, }, async (request, reply) => { - await ensureCallerOwnedByUser(config, request); + await ensureCallerOwnedByUser(config, requireUser(request).id, request.body.callerAddress); const xdrEnvelope = await useCases.buildTransactions.buildAssignDriverTransaction({ ...request.body, chainDeliveryId: BigInt(request.body.chainDeliveryId), @@ -163,7 +184,7 @@ export function createDeliveriesRoutes(useCases: DeliveriesUseCases): FastifyPlu schema: { body: raiseDisputeBodySchema, response: { 200: transactionResponseSchema } }, }, async (request, reply) => { - await ensureCallerOwnedByUser(config, request); + await ensureCallerOwnedByUser(config, requireUser(request).id, request.body.callerAddress); const xdrEnvelope = await useCases.buildTransactions.buildRaiseDisputeTransaction({ ...request.body, chainDeliveryId: BigInt(request.body.chainDeliveryId), diff --git a/src/modules/disputes/application/__fixtures__/fakes.ts b/src/modules/disputes/application/__fixtures__/fakes.ts index c9d0cf3..917ab25 100644 --- a/src/modules/disputes/application/__fixtures__/fakes.ts +++ b/src/modules/disputes/application/__fixtures__/fakes.ts @@ -107,6 +107,9 @@ export function createFakeEvidenceStorage(): EvidenceStorage & { if (!bytes) throw new Error(`No fake file at ${storageUrl}`); return bytes; }, + async delete(storageUrl) { + files.delete(storageUrl); + }, }; } diff --git a/src/modules/disputes/application/download-evidence.spec.ts b/src/modules/disputes/application/download-evidence.spec.ts index a49f991..f6dea53 100644 --- a/src/modules/disputes/application/download-evidence.spec.ts +++ b/src/modules/disputes/application/download-evidence.spec.ts @@ -8,7 +8,7 @@ import { createInMemoryDisputeRepository, createInMemoryEvidenceRepository, } from './__fixtures__/fakes.js'; -import { DisputeStatus } from '../domain/dispute-status.js'; +import { DisputeStatus } from '@prisma/client'; import { EvidenceNotFoundError, ForbiddenEvidenceAccessError } from '../domain/index.js'; function setup() { @@ -228,11 +228,11 @@ describe('downloadEvidence', () => { }); describe('dispute lifecycle access policy', () => { - const participantStatuses: DisputeStatus[] = [DisputeStatus.OPEN, DisputeStatus.UNDER_REVIEW]; + const participantStatuses: DisputeStatus[] = [DisputeStatus.OPEN]; const restrictedStatuses: DisputeStatus[] = [ - DisputeStatus.RESOLVED, - DisputeStatus.CANCELLED, - DisputeStatus.CLOSED, + DisputeStatus.RESOLVED_REFUND, + DisputeStatus.RESOLVED_PAYOUT, + DisputeStatus.SPLIT, ]; it.each(participantStatuses)( diff --git a/src/modules/disputes/application/download-evidence.ts b/src/modules/disputes/application/download-evidence.ts index 7af8572..5c8e75d 100644 --- a/src/modules/disputes/application/download-evidence.ts +++ b/src/modules/disputes/application/download-evidence.ts @@ -1,8 +1,10 @@ -import type { DisputeRepository } from '../domain/dispute-repository.js'; -import type { EvidenceRepository } from '../domain/evidence-repository.js'; -import type { EvidenceStorage } from '../domain/evidence-storage.js'; -import type { WalletOwnershipRepository } from '../domain/wallet-ownership-repository.js'; -import { DisputeStatus } from '../domain/dispute-status.js'; +import type { + DisputeRepository, + DisputeStatus, + EvidenceRepository, + EvidenceStorage, + WalletOwnershipRepository, +} from '../domain/index.js'; import { EvidenceNotFoundError, ForbiddenEvidenceAccessError } from '../domain/index.js'; export interface DownloadEvidenceInput { @@ -24,14 +26,11 @@ export interface DownloadEvidenceDeps { } /** - * Dispute lifecycle states in which evidence remains downloadable by the - * dispute participants. Once a dispute is resolved, cancelled, or closed, + * The persisted dispute state in which evidence remains downloadable by + * dispute participants. Once a dispute is resolved, * evidence is no longer exposed to participants — only ADMIN retains access. */ -const PARTICIPANT_ACCESSIBLE_STATUSES: ReadonlySet = new Set([ - DisputeStatus.OPEN, - DisputeStatus.UNDER_REVIEW, -]); +const PARTICIPANT_ACCESSIBLE_STATUSES: ReadonlySet = new Set(['OPEN']); export function createDownloadEvidenceUseCase(deps: DownloadEvidenceDeps) { const { evidenceRepository, evidenceStorage, disputeRepository, walletOwnershipRepository } = @@ -42,12 +41,12 @@ export function createDownloadEvidenceUseCase(deps: DownloadEvidenceDeps) { ): Promise { const evidence = await evidenceRepository.findById(input.evidenceId); if (!evidence) { - throw new EvidenceNotFoundError(input.evidenceId); + throw new EvidenceNotFoundError(); } const dispute = await disputeRepository.findById(evidence.disputeId); if (!dispute) { - throw new EvidenceNotFoundError(input.evidenceId); + throw new EvidenceNotFoundError(); } if (input.requesterRole === 'ADMIN') { @@ -55,11 +54,10 @@ export function createDownloadEvidenceUseCase(deps: DownloadEvidenceDeps) { return { bytes: stored, contentType: evidence.contentType }; } - // Lifecycle gate: participants may only download evidence while the - // dispute is still open/under review. Resolved, cancelled, or closed - // disputes stop exposing evidence to participants. + // Lifecycle gate: participants may only download evidence while OPEN. + // All three persisted resolution outcomes remain restricted to ADMIN. if (!PARTICIPANT_ACCESSIBLE_STATUSES.has(dispute.status)) { - throw new ForbiddenEvidenceAccessError(input.evidenceId, input.requesterId); + throw new ForbiddenEvidenceAccessError(); } const isRaiser = dispute.raisedByUserId === input.requesterId; @@ -71,16 +69,16 @@ export function createDownloadEvidenceUseCase(deps: DownloadEvidenceDeps) { // Legacy evidence rows (no uploadedByUserId captured at upload time) // fall back to current wallet ownership of the uploadedBy address. if (!evidence.uploadedByUserId) { - const ownsWallet = await walletOwnershipRepository.isOwnedBy( - evidence.uploadedBy, + const ownsWallet = await walletOwnershipRepository.isOwnedByUser( input.requesterId, + evidence.uploadedBy, ); isAuthorized = ownsWallet; } } if (!isAuthorized) { - throw new ForbiddenEvidenceAccessError(input.evidenceId, input.requesterId); + throw new ForbiddenEvidenceAccessError(); } const stored = await evidenceStorage.read(evidence.storageUrl); diff --git a/src/modules/disputes/application/file-signature.ts b/src/modules/disputes/application/file-signature.ts index ecdb8de..4f63543 100644 --- a/src/modules/disputes/application/file-signature.ts +++ b/src/modules/disputes/application/file-signature.ts @@ -1,4 +1,4 @@ -import { ValidationError } from '../../../shared/errors.js'; +import { ValidationError } from '../../../shared/errors/app-error.js'; const MAGIC_BYTE_LENGTH = 16; @@ -25,8 +25,39 @@ function matchesMp3(buffer: Buffer): boolean { return startsWith(buffer, [0x49, 0x44, 0x33]) || startsWith(buffer, [0xff, 0xfb]); } -function matchesMp4(buffer: Buffer): boolean { - return buffer.length >= 8 && buffer.subarray(4, 8).toString('ascii') === 'ftyp'; +/** + * Recognizes a complete leading ISO BMFF FileTypeBox (ISO/IEC 14496-12, + * sections 4.2–4.3). This checks the container header, not video tracks, + * codecs, later boxes, playability or content safety. No marker scanning. + */ +export function matchesMp4Header(bytes: Uint8Array): boolean { + if (bytes.byteLength < 16) return false; + if (bytes[4] !== 0x66 || bytes[5] !== 0x74 || bytes[6] !== 0x79 || bytes[7] !== 0x70) { + return false; + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const size = view.getUint32(0); // ISO fields are big-endian. + let headerSize = 8; + let boxSize = size === 0 ? BigInt(bytes.byteLength) : BigInt(size); + if (size === 1) { + if (bytes.byteLength < 24) return false; + headerSize = 16; + boxSize = view.getBigUint64(8); + } + const minimumSize = BigInt(headerSize + 8); // major brand + minor version + if (boxSize < minimumSize || boxSize > BigInt(bytes.byteLength)) return false; + if ((boxSize - minimumSize) % 4n !== 0n) return false; + + // Each brand is a printable four-character code. The minor version is + // binary; skip it. No codec/brand allowlist or whole-file claim is made. + for (let offset = headerSize; BigInt(offset) < boxSize; offset += 4) { + if (offset === headerSize + 4) continue; + for (let index = offset; index < offset + 4; index++) { + const byte = bytes[index]; + if (byte === undefined || byte < 0x20 || byte > 0x7e) return false; + } + } + return true; } const SIGNATURE_CHECKS: Record boolean> = { @@ -35,13 +66,15 @@ const SIGNATURE_CHECKS: Record boolean> = { 'application/pdf': matchesPdf, 'audio/mpeg': matchesMp3, 'audio/mp3': matchesMp3, - 'video/mp4': matchesMp4, }; -export function assertFileSignatureMatchesContentType( - buffer: Buffer, - contentType: string, -): void { +export function assertFileSignatureMatchesContentType(buffer: Buffer, contentType: string): void { + if (contentType === 'video/mp4') { + if (!matchesMp4Header(buffer)) { + throw new ValidationError('File content does not match declared contentType'); + } + return; + } const check = SIGNATURE_CHECKS[contentType]; if (!check) { return; diff --git a/src/modules/disputes/application/upload-evidence.spec.ts b/src/modules/disputes/application/upload-evidence.spec.ts index 30548d0..ce837d5 100644 --- a/src/modules/disputes/application/upload-evidence.spec.ts +++ b/src/modules/disputes/application/upload-evidence.spec.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { createUploadEvidenceUseCase } from './upload-evidence.js'; import { buildDispute, @@ -80,11 +80,17 @@ describe('uploadEvidence', () => { }); it('stores the file and records the sha256 hex hash matching the bytes, once the requester owns uploadedBy', async () => { - const { disputeRepository, walletOwnershipRepository, uploadEvidence } = setup(); + const { + disputeRepository, + walletOwnershipRepository, + evidenceRepository, + evidenceStorage, + uploadEvidence, + } = setup(); const dispute = buildDispute({ chainDeliveryId: 1n, status: 'OPEN' }); disputeRepository.seed(dispute); walletOwnershipRepository.seed('user-1', 'GSENDER'); - const bytes = Buffer.from('evidence-file-contents'); + const bytes = Buffer.from('%PDF-1.7\nevidence-file-contents'); const expectedHash = createHash('sha256').update(bytes).digest('hex'); const result = await uploadEvidence({ @@ -98,9 +104,16 @@ describe('uploadEvidence', () => { expect(result.evidence.hash).toBe(expectedHash); expect(result.evidence.disputeId).toBe(dispute.id); expect(result.evidence.storageUrl).toContain(dispute.id); + expect(result.evidence).toMatchObject({ + contentType: 'application/pdf', + uploadedBy: 'GSENDER', + uploadedByUserId: 'user-1', + }); + expect(await evidenceRepository.findById(result.evidence.id)).toEqual(result.evidence); + expect(await evidenceStorage.read(result.evidence.storageUrl)).toEqual(bytes); }); - it('records the requester\'s account id as uploadedByUserId (wallet-relink evidence authorization)', async () => { + it("records the requester's account id as uploadedByUserId (wallet-relink evidence authorization)", async () => { const { disputeRepository, walletOwnershipRepository, evidenceRepository, uploadEvidence } = setup(); disputeRepository.seed(buildDispute({ chainDeliveryId: 1n, status: 'OPEN' })); @@ -110,7 +123,7 @@ describe('uploadEvidence', () => { chainDeliveryId: 1n, contentType: 'application/pdf', uploadedBy: 'GSENDER', - bytes: Buffer.from('evidence-file-contents'), + bytes: Buffer.from('%PDF-1.7\nevidence-file-contents'), requesterId: 'user-1', }); @@ -119,4 +132,62 @@ describe('uploadEvidence', () => { 'user-1', ); }); + it('propagates a storage failure without creating an evidence row', async () => { + const deps = setup(); + const dispute = buildDispute({ chainDeliveryId: 1n, status: 'OPEN' }); + deps.disputeRepository.seed(dispute); + deps.walletOwnershipRepository.seed('user-1', 'GSENDER'); + const failure = new Error('isolated storage failure'); + vi.spyOn(deps.evidenceStorage, 'save').mockRejectedValueOnce(failure); + const create = vi.spyOn(deps.evidenceRepository, 'create'); + + await expect( + deps.uploadEvidence({ + chainDeliveryId: 1n, + contentType: 'application/pdf', + uploadedBy: 'GSENDER', + requesterId: 'user-1', + bytes: Buffer.from('%PDF-1.7\nsynthetic storage fixture'), + }), + ).rejects.toBe(failure); + expect(create).not.toHaveBeenCalled(); + expect(await deps.evidenceRepository.listByDisputeId(dispute.id)).toEqual([]); + }); + + it('propagates a persistence failure after saving; the test cleans up its fixture bytes', async () => { + const deps = setup(); + const dispute = buildDispute({ chainDeliveryId: 1n, status: 'OPEN' }); + deps.disputeRepository.seed(dispute); + deps.walletOwnershipRepository.seed('user-1', 'GSENDER'); + const failure = new Error('isolated repository failure'); + vi.spyOn(deps.evidenceRepository, 'create').mockRejectedValueOnce(failure); + const originalSave = deps.evidenceStorage.save; + let storageUrl: string | undefined; + const save = vi.spyOn(deps.evidenceStorage, 'save').mockImplementation(async (input) => { + const saved = await originalSave(input); + storageUrl = saved.storageUrl; + return saved; + }); + const bytes = Buffer.from('%PDF-1.7\nsynthetic persistence fixture'); + try { + await expect( + deps.uploadEvidence({ + chainDeliveryId: 1n, + contentType: 'application/pdf', + uploadedBy: 'GSENDER', + requesterId: 'user-1', + bytes, + }), + ).rejects.toBe(failure); + expect(save).toHaveBeenCalledExactlyOnceWith({ + disputeId: dispute.id, + contentType: 'application/pdf', + bytes, + }); + expect(await deps.evidenceRepository.listByDisputeId(dispute.id)).toEqual([]); + } finally { + // Fixture teardown only, not a claim of production compensation policy. + if (storageUrl !== undefined) await deps.evidenceStorage.delete(storageUrl); + } + }); }); diff --git a/src/modules/disputes/application/upload-evidence.test.ts b/src/modules/disputes/application/upload-evidence.test.ts index 73b835d..264ae47 100644 --- a/src/modules/disputes/application/upload-evidence.test.ts +++ b/src/modules/disputes/application/upload-evidence.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it, vi } from 'vitest'; -import { ValidationError } from '../../../shared/errors.js'; +import { ValidationError } from '../../../shared/errors/app-error.js'; +import { testAddress } from '../../../shared/testing/addresses.js'; +import { + buildDispute, + createFakeEvidenceStorage, + createFakeWalletOwnershipRepository, + createInMemoryDisputeRepository, + createInMemoryEvidenceRepository, +} from './__fixtures__/fakes.js'; +import type { UploadEvidenceInput } from './upload-evidence.js'; import { createUploadEvidenceUseCase } from './upload-evidence.js'; const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); @@ -7,31 +16,37 @@ const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); const PDF_MAGIC = Buffer.from('%PDF-1.7'); const MP3_ID3_MAGIC = Buffer.from('ID3'); const MP3_FRAME_MAGIC = Buffer.from([0xff, 0xfb, 0x90, 0x00]); -const MP4_MAGIC = Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]); +// Complete synthetic 24-byte ftyp box: size, type, major brand, version, compatible brands. +const MP4_MAGIC = Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex'); + +const uploader = testAddress('upload-magic-owner'); function buildDeps() { - const save = vi.fn(async () => ({ storageKey: 'evidence/key' })); + const disputeRepository = createInMemoryDisputeRepository(); + const evidenceRepository = createInMemoryEvidenceRepository(); + const evidenceStorage = createFakeEvidenceStorage(); + const walletOwnershipRepository = createFakeWalletOwnershipRepository(); + disputeRepository.seed(buildDispute({ chainDeliveryId: 1n, status: 'OPEN' })); + walletOwnershipRepository.seed('user-1', uploader); + const save = vi.spyOn(evidenceStorage, 'save'); + vi.spyOn(evidenceRepository, 'create'); return { + disputeRepository, + evidenceRepository, + evidenceStorage, + walletOwnershipRepository, save, - evidenceRepository: { - create: vi.fn(async () => ({ id: 'evidence-1' })), - }, }; } -function buildInput(contentType: string, content: Buffer) { - return { - disputeId: 'dispute-1', - uploaderId: 'user-1', - contentType, - content, - }; +function buildInput(contentType: string, bytes: Buffer): UploadEvidenceInput { + return { chainDeliveryId: 1n, uploadedBy: uploader, requesterId: 'user-1', contentType, bytes }; } describe('createUploadEvidenceUseCase magic byte validation', () => { it('accepts a valid PNG with image/png', async () => { const deps = buildDeps(); - const useCase = createUploadEvidenceUseCase(deps as never); + const useCase = createUploadEvidenceUseCase(deps); await expect( useCase(buildInput('image/png', Buffer.concat([PNG_MAGIC, Buffer.from('payload')]))), @@ -41,7 +56,7 @@ describe('createUploadEvidenceUseCase magic byte validation', () => { it('accepts a valid JPEG with image/jpeg', async () => { const deps = buildDeps(); - const useCase = createUploadEvidenceUseCase(deps as never); + const useCase = createUploadEvidenceUseCase(deps); await expect( useCase(buildInput('image/jpeg', Buffer.concat([JPEG_MAGIC, Buffer.from('payload')]))), @@ -51,7 +66,7 @@ describe('createUploadEvidenceUseCase magic byte validation', () => { it('accepts a valid PDF with application/pdf', async () => { const deps = buildDeps(); - const useCase = createUploadEvidenceUseCase(deps as never); + const useCase = createUploadEvidenceUseCase(deps); await expect( useCase(buildInput('application/pdf', Buffer.concat([PDF_MAGIC, Buffer.from('payload')]))), @@ -61,7 +76,7 @@ describe('createUploadEvidenceUseCase magic byte validation', () => { it('accepts MP3 files with ID3 and frame sync magic bytes', async () => { const deps = buildDeps(); - const useCase = createUploadEvidenceUseCase(deps as never); + const useCase = createUploadEvidenceUseCase(deps); await expect( useCase(buildInput('audio/mpeg', Buffer.concat([MP3_ID3_MAGIC, Buffer.from('payload')]))), @@ -73,17 +88,15 @@ describe('createUploadEvidenceUseCase magic byte validation', () => { it('accepts MP4 files with ftyp magic bytes', async () => { const deps = buildDeps(); - const useCase = createUploadEvidenceUseCase(deps as never); + const useCase = createUploadEvidenceUseCase(deps); - await expect( - useCase(buildInput('video/mp4', Buffer.concat([MP4_MAGIC, Buffer.from('payload')]))), - ).resolves.toBeDefined(); + await expect(useCase(buildInput('video/mp4', MP4_MAGIC))).resolves.toBeDefined(); expect(deps.save).toHaveBeenCalledOnce(); }); it('rejects plain text declared as image/png', async () => { const deps = buildDeps(); - const useCase = createUploadEvidenceUseCase(deps as never); + const useCase = createUploadEvidenceUseCase(deps); await expect( useCase(buildInput('image/png', Buffer.from('this is not a png'))), @@ -92,25 +105,28 @@ describe('createUploadEvidenceUseCase magic byte validation', () => { useCase(buildInput('image/png', Buffer.from('this is not a png'))), ).rejects.toThrow('File content does not match declared contentType'); expect(deps.save).not.toHaveBeenCalled(); + expect(deps.evidenceRepository.create).not.toHaveBeenCalled(); }); it('rejects an executable declared as image/jpeg', async () => { const deps = buildDeps(); - const useCase = createUploadEvidenceUseCase(deps as never); + const useCase = createUploadEvidenceUseCase(deps); await expect( useCase(buildInput('image/jpeg', Buffer.from('MZ\x90\x00\x03\x00\x00\x00'))), ).rejects.toThrow('File content does not match declared contentType'); expect(deps.save).not.toHaveBeenCalled(); + expect(deps.evidenceRepository.create).not.toHaveBeenCalled(); }); it('rejects a PNG declared as application/pdf', async () => { const deps = buildDeps(); - const useCase = createUploadEvidenceUseCase(deps as never); + const useCase = createUploadEvidenceUseCase(deps); await expect( useCase(buildInput('application/pdf', Buffer.concat([PNG_MAGIC, Buffer.from('payload')]))), ).rejects.toThrow('File content does not match declared contentType'); expect(deps.save).not.toHaveBeenCalled(); + expect(deps.evidenceRepository.create).not.toHaveBeenCalled(); }); }); diff --git a/src/modules/disputes/application/upload-evidence.ts b/src/modules/disputes/application/upload-evidence.ts index fae266f..6501cfc 100644 --- a/src/modules/disputes/application/upload-evidence.ts +++ b/src/modules/disputes/application/upload-evidence.ts @@ -1,3 +1,4 @@ +import { matchesMp4Header } from './file-signature.js'; import { createHash } from 'node:crypto'; import type { DisputeRepository, @@ -10,8 +11,8 @@ import { DisputeNotFoundError, DisputeNotOpenError, ForbiddenEvidenceUploadError, - ValidationError, } from '../domain/index.js'; +import { ValidationError } from '../../../shared/errors/app-error.js'; export interface UploadEvidenceDeps { disputeRepository: DisputeRepository; @@ -50,16 +51,22 @@ const MAGIC_BYTES: Record = { [0x49, 0x44, 0x33], // ID3 [0xff, 0xfb], ], - 'video/mp4': [[0x66, 0x74, 0x79, 0x70]], // ftyp }; /** - * Verifies the first 16 bytes of `bytes` against the known magic byte + * Checks a complete leading FileTypeBox for MP4; for other types, verifies + * the first 16 bytes of `bytes` against the known magic byte * signatures for `contentType`. Throws `ValidationError` when the content * does not match the declared type. Content types without a registered * signature are left to the schema-level allowlist check. */ function assertMagicBytesMatch(contentType: string, bytes: Buffer): void { + if (contentType === 'video/mp4') { + if (!matchesMp4Header(bytes)) { + throw new ValidationError('File content does not match declared contentType'); + } + return; + } const signatures = MAGIC_BYTES[contentType]; if (!signatures) { return; @@ -68,8 +75,7 @@ function assertMagicBytesMatch(contentType: string, bytes: Buffer): void { const header = bytes.subarray(0, 16); const matches = signatures.some( (signature) => - header.length >= signature.length && - signature.every((byte, index) => header[index] === byte), + header.length >= signature.length && signature.every((byte, index) => header[index] === byte), ); if (!matches) { diff --git a/src/modules/disputes/domain/ports.ts b/src/modules/disputes/domain/ports.ts index 4e388f4..1159204 100644 --- a/src/modules/disputes/domain/ports.ts +++ b/src/modules/disputes/domain/ports.ts @@ -222,57 +222,3 @@ export interface WalletOwnershipRepository { export interface DisputeContractReader { getDispute(chainDeliveryId: bigint): Promise; } - -export type EscrowStatusForDispute = 'LOCKED' | 'RELEASED' | 'REFUNDED' | 'PAUSED'; - -/** - * Reads `escrow_contract`'s own current status directly — added to - * disambiguate `escrow.dispute_resolved` (see `sync-dispute-from-event.ts`'s - * header comment for the full rationale). Mirrors `reputation`'s - * `LegacyDriverProfileReader`: a narrow port reading a single field off a - * second, genuinely different deployed contract (`escrow_contract`, not - * `dispute_resolution_contract`) rather than folding it into - * `DisputeContractReader` above. - */ -export interface DisputeEscrowStateReader { - getEscrowStatus(chainDeliveryId: bigint): Promise; -} - -export interface RaiseDisputeTxInput { - callerAddress: string; - chainDeliveryId: bigint; -} - -export interface AddEvidenceHashTxInput { - callerAddress: string; - chainDeliveryId: bigint; - /** Hex-encoded 32-byte content hash. */ - evidenceHash: string; -} - -export interface ResolveDisputeTxInput { - callerAddress: string; - chainDeliveryId: bigint; -} - -export interface ResolveDisputeSplitFundsTxInput extends ResolveDisputeTxInput { - senderShareBps: number; -} - -/** - * Builds unsigned XDR for `dispute_resolution_contract`'s five mutating - * calls (PHASE_1_DOMAIN_ANALYSIS.md §5). `escrow_contract`'s own - * `raise_dispute`/`resolve_dispute`/`resolve_dispute_split` (Layer A) are - * deliberately not exposed anywhere in this backend — `escrow`'s domain/ports.ts - * already documents that the full two-layer dispute/arbitration flow belongs - * here, in `disputes`, and this module owns only the richer Layer B contract. - */ -export interface DisputeTransactionBuilder { - buildRaiseDispute(input: RaiseDisputeTxInput): Promise; - buildAddEvidenceHash(input: AddEvidenceHashTxInput): Promise; - buildResolveDisputeRefundSender(input: ResolveDisputeTxInput): Promise; - buildResolveDisputePayDriver(input: ResolveDisputeTxInput): Promise; - buildResolveDisputeSplitFunds(input: ResolveDisputeSplitFundsTxInput): Promise; -} - - diff --git a/src/modules/disputes/infrastructure/local-evidence-storage.test.ts b/src/modules/disputes/infrastructure/local-evidence-storage.test.ts index ebb7704..5a5fc15 100644 --- a/src/modules/disputes/infrastructure/local-evidence-storage.test.ts +++ b/src/modules/disputes/infrastructure/local-evidence-storage.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createLocalEvidenceStorage } from './local-evidence-storage'; +import { createLocalEvidenceStorage } from './local-evidence-storage.js'; describe('createLocalEvidenceStorage', () => { let baseDir: string; @@ -16,14 +16,13 @@ describe('createLocalEvidenceStorage', () => { }); it('saves, reads, and deletes evidence files from disk', async () => { - const storage = createLocalEvidenceStorage({ baseDir }); + const storage = createLocalEvidenceStorage(baseDir); const data = Buffer.from('evidence-bytes'); const { storageUrl } = await storage.save({ disputeId: 'dispute-1', - fileName: 'photo.png', contentType: 'image/png', - data, + bytes: data, }); const absolute = join(baseDir, storageUrl.replace('local://evidence/', '')); @@ -36,13 +35,12 @@ describe('createLocalEvidenceStorage', () => { }); it('removes empty dispute directories on delete', async () => { - const storage = createLocalEvidenceStorage({ baseDir }); + const storage = createLocalEvidenceStorage(baseDir); const { storageUrl } = await storage.save({ disputeId: 'dispute-2', - fileName: 'video.mp4', contentType: 'video/mp4', - data: Buffer.from('video-bytes'), + bytes: Buffer.from('video-bytes'), }); const disputeDir = join(baseDir, 'dispute-2'); @@ -54,10 +52,8 @@ describe('createLocalEvidenceStorage', () => { }); it('is idempotent when deleting a missing file', async () => { - const storage = createLocalEvidenceStorage({ baseDir }); + const storage = createLocalEvidenceStorage(baseDir); - await expect( - storage.delete('local://evidence/dispute-3/missing.png'), - ).resolves.toBeUndefined(); + await expect(storage.delete('local://evidence/dispute-3/missing.png')).resolves.toBeUndefined(); }); }); diff --git a/src/modules/disputes/infrastructure/local-evidence-storage.ts b/src/modules/disputes/infrastructure/local-evidence-storage.ts index 19959da..a25b4fe 100644 --- a/src/modules/disputes/infrastructure/local-evidence-storage.ts +++ b/src/modules/disputes/infrastructure/local-evidence-storage.ts @@ -1,53 +1,63 @@ -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { dirname, join, resolve } from 'node:path'; +import { mkdir, readFile, rm, rmdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve, sep } from 'node:path'; import { randomUUID } from 'node:crypto'; -import type { EvidenceStorage } from '../domain/ports'; - -export interface LocalEvidenceStorageOptions { - baseDir: string; -} +import type { EvidenceStorage } from '../domain/index.js'; +import { EvidenceNotFoundError } from '../domain/index.js'; const STORAGE_URL_PREFIX = 'local://evidence/'; -export function createLocalEvidenceStorage(options: LocalEvidenceStorageOptions): EvidenceStorage { - const baseDir = resolve(options.baseDir); +/** Local keys retain the original / layout. Reads and + * deletes also accept keys written with the later local://evidence/ prefix; + * stored evidence references do not need rewriting. */ +export function createLocalEvidenceStorage(baseDir: string): EvidenceStorage { + const resolvedBaseDir = resolve(baseDir); + const basePrefix = resolvedBaseDir.endsWith(sep) ? resolvedBaseDir : `${resolvedBaseDir}${sep}`; function resolveStoragePath(storageUrl: string): string { - if (!storageUrl.startsWith(STORAGE_URL_PREFIX)) { - throw new Error(`Invalid evidence storage url: ${storageUrl}`); + if (storageUrl.includes('://') && !storageUrl.startsWith(STORAGE_URL_PREFIX)) { + throw new EvidenceNotFoundError(); } - const relative = storageUrl.slice(STORAGE_URL_PREFIX.length); - const absolute = resolve(join(baseDir, relative)); - if (absolute !== baseDir && !absolute.startsWith(`${baseDir}/`)) { - throw new Error(`Evidence storage url escapes base directory: ${storageUrl}`); + const relative = storageUrl.startsWith(STORAGE_URL_PREFIX) + ? storageUrl.slice(STORAGE_URL_PREFIX.length) + : storageUrl; + const absolute = resolve(resolvedBaseDir, relative); + if (absolute === resolvedBaseDir || !absolute.startsWith(basePrefix)) { + throw new EvidenceNotFoundError(); } return absolute; } return { - async save(input) { - const safeName = input.fileName.replace(/[^a-zA-Z0-9._-]/g, '_'); - const relative = join(input.disputeId, `${randomUUID()}-${safeName}`); - const absolute = resolve(join(baseDir, relative)); + async save({ disputeId, bytes }) { + const storageUrl = `${disputeId}/${randomUUID()}`; + const absolute = resolveStoragePath(storageUrl); await mkdir(dirname(absolute), { recursive: true }); - await writeFile(absolute, input.data); - return { storageUrl: `${STORAGE_URL_PREFIX}${relative}` }; + await writeFile(absolute, bytes); + return { storageUrl }; }, async read(storageUrl) { const absolute = resolveStoragePath(storageUrl); - return readFile(absolute); + try { + return await readFile(absolute); + } catch (error: unknown) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + throw new EvidenceNotFoundError(); + } + throw error; + } }, async delete(storageUrl) { const absolute = resolveStoragePath(storageUrl); await rm(absolute, { force: true }); - // Remove now-empty dispute directories up to (but not including) the base dir. + // Remove only empty ancestors, preserving the configured base directory + // and directories containing other evidence. let current = dirname(absolute); - while (current !== baseDir && current.startsWith(`${baseDir}/`)) { + while (current !== resolvedBaseDir && current.startsWith(basePrefix)) { try { - await rm(current, { recursive: false }); + await rmdir(current); } catch { break; } diff --git a/src/modules/disputes/interface/schemas.ts b/src/modules/disputes/interface/schemas.ts index 54a84f5..5e3a9c5 100644 --- a/src/modules/disputes/interface/schemas.ts +++ b/src/modules/disputes/interface/schemas.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { matchesMp4Header } from '../application/file-signature.js'; import { chainId } from '../../../shared/validation/chain-id.js'; import { stellarAddress } from '../../../shared/validation/stellar-address.js'; @@ -27,9 +28,12 @@ export type EvidenceContentType = (typeof allowedEvidenceContentTypes)[number]; * Magic-byte signatures for each allowed evidence content type. Each entry * lists one or more candidate signatures; a file matches the declared type * when its header starts with any of them. Signatures are compared against - * the first 16 bytes of the decoded payload. + * the first 16 bytes of the decoded payload (MP4 uses the complete box below). */ -const evidenceMagicBytes: Record = { +const evidenceMagicBytes: Record< + Exclude, + readonly (readonly number[])[] +> = { 'image/jpeg': [[0xff, 0xd8, 0xff]], 'image/png': [[0x89, 0x50, 0x4e, 0x47]], 'image/webp': [[0x52, 0x49, 0x46, 0x46]], // RIFF....WEBP @@ -40,7 +44,6 @@ const evidenceMagicBytes: Record - matchesSignature(header, signature), - ); + return evidenceMagicBytes[contentType].some((signature) => matchesSignature(header, signature)); } /** diff --git a/src/modules/escrow/application/__fixtures__/fakes.ts b/src/modules/escrow/application/__fixtures__/fakes.ts index d6dd4bf..c8b20d0 100644 --- a/src/modules/escrow/application/__fixtures__/fakes.ts +++ b/src/modules/escrow/application/__fixtures__/fakes.ts @@ -29,7 +29,7 @@ export function createInMemoryEscrowRepository(): EscrowRepository & { platformFee: null, releasedAt: null, refundedAt: null, - isVerified: record.isVerified ?? true, + isVerified: record.isVerified ?? false, }; escrows.set(key(escrow.chainDeliveryId), escrow); return escrow; diff --git a/src/modules/escrow/application/build-escrow-transactions.spec.ts b/src/modules/escrow/application/build-escrow-transactions.spec.ts index 9bd75c5..96bfb67 100644 --- a/src/modules/escrow/application/build-escrow-transactions.spec.ts +++ b/src/modules/escrow/application/build-escrow-transactions.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { createBuildEscrowTransactionsUseCases } from './build-escrow-transactions.js'; import { createFakeEscrowTransactionBuilder } from './__fixtures__/fakes.js'; import { InvalidEscrowStateError } from '../domain/errors.js'; @@ -77,10 +77,9 @@ describe('buildEscrowTransactions use cases', () => { }); it('builds a refund transaction for a LOCKED escrow', async () => { - const transactionBuilder = createFakeEscrowTransactionBuilder({ - escrowStatus: 'LOCKED', - }); - const useCases = createBuildEscrowTransactionsUseCases({ transactionBuilder }); + const transactionBuilder = createFakeEscrowTransactionBuilder(); + const getEscrowStatus = vi.fn(async (_chainDeliveryId: bigint) => 'LOCKED' as const); + const useCases = createBuildEscrowTransactionsUseCases({ transactionBuilder, getEscrowStatus }); await expect( useCases.buildRefundEscrowTransaction({ callerAddress: 'GA', chainDeliveryId: 1n }), @@ -88,10 +87,9 @@ describe('buildEscrowTransactions use cases', () => { }); it('builds a refund transaction for a PAUSED escrow', async () => { - const transactionBuilder = createFakeEscrowTransactionBuilder({ - escrowStatus: 'PAUSED', - }); - const useCases = createBuildEscrowTransactionsUseCases({ transactionBuilder }); + const transactionBuilder = createFakeEscrowTransactionBuilder(); + const getEscrowStatus = vi.fn(async (_chainDeliveryId: bigint) => 'PAUSED' as const); + const useCases = createBuildEscrowTransactionsUseCases({ transactionBuilder, getEscrowStatus }); await expect( useCases.buildRefundEscrowTransaction({ callerAddress: 'GA', chainDeliveryId: 1n }), @@ -99,10 +97,9 @@ describe('buildEscrowTransactions use cases', () => { }); it('rejects refund transaction build for a RELEASED escrow', async () => { - const transactionBuilder = createFakeEscrowTransactionBuilder({ - escrowStatus: 'RELEASED', - }); - const useCases = createBuildEscrowTransactionsUseCases({ transactionBuilder }); + const transactionBuilder = createFakeEscrowTransactionBuilder(); + const getEscrowStatus = vi.fn(async (_chainDeliveryId: bigint) => 'RELEASED' as const); + const useCases = createBuildEscrowTransactionsUseCases({ transactionBuilder, getEscrowStatus }); await expect( useCases.buildRefundEscrowTransaction({ callerAddress: 'GA', chainDeliveryId: 1n }), @@ -110,10 +107,9 @@ describe('buildEscrowTransactions use cases', () => { }); it('rejects refund transaction build for a REFUNDED escrow', async () => { - const transactionBuilder = createFakeEscrowTransactionBuilder({ - escrowStatus: 'REFUNDED', - }); - const useCases = createBuildEscrowTransactionsUseCases({ transactionBuilder }); + const transactionBuilder = createFakeEscrowTransactionBuilder(); + const getEscrowStatus = vi.fn(async (_chainDeliveryId: bigint) => 'REFUNDED' as const); + const useCases = createBuildEscrowTransactionsUseCases({ transactionBuilder, getEscrowStatus }); await expect( useCases.buildRefundEscrowTransaction({ callerAddress: 'GA', chainDeliveryId: 1n }), diff --git a/src/modules/escrow/application/build-escrow-transactions.ts b/src/modules/escrow/application/build-escrow-transactions.ts index 0b26f9e..2c23c67 100644 --- a/src/modules/escrow/application/build-escrow-transactions.ts +++ b/src/modules/escrow/application/build-escrow-transactions.ts @@ -1,5 +1,6 @@ -import { ValidationError } from '../../../shared/errors/index.js'; +import { assertEscrowAmount } from '../../../shared/validation/i128.js'; import { InvalidEscrowStateError } from '../domain/errors.js'; +import { assertEscrowDeliveryId } from '../domain/identifiers.js'; import type { CreateEscrowTxInput, EscrowStatus, @@ -15,7 +16,7 @@ export interface BuildEscrowTransactionsDeps { * transaction. When provided, refunds are rejected for escrows that are no * longer eligible (e.g. already released or refunded). */ - getEscrowStatus?: (chainDeliveryId: string) => Promise; + getEscrowStatus?: (chainDeliveryId: bigint) => Promise; } const REFUNDABLE_STATUSES: readonly EscrowStatus[] = ['LOCKED', 'PAUSED']; @@ -32,16 +33,18 @@ const REFUNDABLE_STATUSES: readonly EscrowStatus[] = ['LOCKED', 'PAUSED']; export function createBuildEscrowTransactionsUseCases(deps: BuildEscrowTransactionsDeps) { return { buildCreateEscrowTransaction: async (input: CreateEscrowTxInput): Promise => { - if (input.amount <= 0n) { - throw new ValidationError('Escrow amount must be greater than zero'); - } + assertEscrowAmount(input.amount); + assertEscrowDeliveryId(input.chainDeliveryId); return deps.transactionBuilder.buildCreateEscrow(input); }, - buildReleaseEscrowTransaction: (input: ReleaseEscrowTxInput): Promise => - deps.transactionBuilder.buildReleaseEscrow(input), + buildReleaseEscrowTransaction: async (input: ReleaseEscrowTxInput): Promise => { + assertEscrowDeliveryId(input.chainDeliveryId); + return deps.transactionBuilder.buildReleaseEscrow(input); + }, buildRefundEscrowTransaction: async (input: RefundEscrowTxInput): Promise => { + assertEscrowDeliveryId(input.chainDeliveryId); if (deps.getEscrowStatus) { const status = await deps.getEscrowStatus(input.chainDeliveryId); if (!REFUNDABLE_STATUSES.includes(status)) { diff --git a/src/modules/escrow/domain/entities.ts b/src/modules/escrow/domain/entities.ts index 340c4d5..0bb3daa 100644 --- a/src/modules/escrow/domain/entities.ts +++ b/src/modules/escrow/domain/entities.ts @@ -31,6 +31,8 @@ export interface Escrow { releasedAt: Date | null; refundedAt: Date | null; createdAtChain: Date; + /** Token accepted by ingestion policy, not email/wallet/KYC verification. + * False includes unknown historical state; only true contributes to GMV. */ isVerified: boolean; } diff --git a/src/modules/escrow/domain/identifiers.ts b/src/modules/escrow/domain/identifiers.ts new file mode 100644 index 0000000..f156f85 --- /dev/null +++ b/src/modules/escrow/domain/identifiers.ts @@ -0,0 +1,11 @@ +import { ValidationError } from '../../../shared/errors/app-error.js'; + +/** escrow_contract uses a bare u64 delivery key, not a database/user UUID + * or delivery_contract's tuple-wrapped DeliveryId. */ +export const ESCROW_DELIVERY_ID_MAX = (1n << 64n) - 1n; + +export function assertEscrowDeliveryId(value: unknown): asserts value is bigint { + if (typeof value !== 'bigint' || value < 0n || value > ESCROW_DELIVERY_ID_MAX) { + throw new ValidationError('chainDeliveryId must be an unsigned 64-bit integer'); + } +} diff --git a/src/modules/escrow/infrastructure/escrow-scval-mapping.ts b/src/modules/escrow/infrastructure/escrow-scval-mapping.ts index eec846f..1b0676a 100644 --- a/src/modules/escrow/infrastructure/escrow-scval-mapping.ts +++ b/src/modules/escrow/infrastructure/escrow-scval-mapping.ts @@ -1,7 +1,8 @@ +import { assertEscrowAmount } from '../../../shared/validation/i128.js'; +import { assertEscrowDeliveryId } from '../domain/identifiers.js'; import type { EscrowStatus } from '@prisma/client'; import type { xdr } from '@stellar/stellar-sdk'; import { addressToScVal, i128ToScVal, u64ToScVal } from '../../../blockchain/xdr/sc-val.js'; -import { ValidationError } from '../../../shared/errors/index.js'; import type { ChainEscrowRecord, CreateEscrowTxInput } from '../domain/index.js'; /** @@ -35,9 +36,8 @@ const ESCROW_STATUS_FROM_RUST: Record = { /** `create_escrow(sender, recipient, driver, delivery_id, token, amount)`. */ export function createEscrowArgsToScVal(input: CreateEscrowTxInput): xdr.ScVal[] { - if (input.amount <= 0n) { - throw new ValidationError('Escrow amount must be greater than zero'); - } + assertEscrowAmount(input.amount); + assertEscrowDeliveryId(input.chainDeliveryId); return [ addressToScVal(input.senderAddress), addressToScVal(input.recipientAddress), diff --git a/src/modules/escrow/infrastructure/prisma-escrow-repository.integration.spec.ts b/src/modules/escrow/infrastructure/prisma-escrow-repository.integration.spec.ts index 0ca648b..cdb960e 100644 --- a/src/modules/escrow/infrastructure/prisma-escrow-repository.integration.spec.ts +++ b/src/modules/escrow/infrastructure/prisma-escrow-repository.integration.spec.ts @@ -54,6 +54,21 @@ describe.skipIf(!dbAvailable)('Prisma escrow repository (integration)', () => { expect(found).toMatchObject({ chainDeliveryId, token: 'GTOKENX', status: 'LOCKED' }); }); + it.each([true, false, undefined])( + 'persists explicit token verification %s and excludes unknown state', + async (isVerified) => { + const chainDeliveryId = await nextChainId(); + const record = buildChainEscrowRecord({ chainDeliveryId }); + // Undefined models a caller without an ingestion-policy result. + if (isVerified !== undefined) record.isVerified = isVerified; + const created = await escrowRepository.create(record); + expect(created.isVerified).toBe(isVerified ?? false); + await escrowRepository.updateStatus(chainDeliveryId, { status: 'RELEASED' }); + const found = await escrowRepository.findByChainDeliveryId(chainDeliveryId); + expect(found?.isVerified).toBe(isVerified ?? false); + }, + ); + it('updates status, platformFee, and releasedAt via updateStatus', async () => { const chainDeliveryId = await nextChainId(); await escrowRepository.create(buildChainEscrowRecord({ chainDeliveryId, status: 'LOCKED' })); diff --git a/src/modules/escrow/infrastructure/prisma-escrow-repository.ts b/src/modules/escrow/infrastructure/prisma-escrow-repository.ts index 42a5edd..7e2b9fd 100644 --- a/src/modules/escrow/infrastructure/prisma-escrow-repository.ts +++ b/src/modules/escrow/infrastructure/prisma-escrow-repository.ts @@ -91,6 +91,7 @@ export function createPrismaEscrowRepository(prisma: PrismaClientOrTx): EscrowRe disputedBy: record.disputedBy, disputedAt: record.disputedAt, createdAtChain: record.createdAtChain, + isVerified: record.isVerified ?? false, }, }); return toDomain(created); diff --git a/src/modules/escrow/infrastructure/soroban-escrow-contract-client.ts b/src/modules/escrow/infrastructure/soroban-escrow-contract-client.ts index 6ae7c7f..31617bf 100644 --- a/src/modules/escrow/infrastructure/soroban-escrow-contract-client.ts +++ b/src/modules/escrow/infrastructure/soroban-escrow-contract-client.ts @@ -1,3 +1,5 @@ +import { assertEscrowAmount } from '../../../shared/validation/i128.js'; +import { assertEscrowDeliveryId } from '../domain/identifiers.js'; import { addressToScVal, buildInvokeTransaction, @@ -5,7 +7,7 @@ import { u64ToScVal, type SorobanClient, } from '../../../blockchain/index.js'; -import { InsufficientFundsError, ValidationError } from '../../../shared/errors/index.js'; +import { InsufficientFundsError } from '../../../shared/errors/index.js'; import type { ChainEscrowRecord, EscrowContractReader, @@ -66,9 +68,8 @@ export function createSorobanEscrowContractClient( }, async buildCreateEscrow(input) { - if (input.amount <= 0n) { - throw new ValidationError('Escrow amount must be greater than zero'); - } + assertEscrowAmount(input.amount); + assertEscrowDeliveryId(input.chainDeliveryId); const balance = await getTokenBalance(client, input.token, input.senderAddress); if (balance !== null && balance < input.amount) { @@ -90,6 +91,7 @@ export function createSorobanEscrowContractClient( }, async buildReleaseEscrow(input) { + assertEscrowDeliveryId(input.chainDeliveryId); return buildInvokeTransaction(client, { contractId, method: 'release_escrow', @@ -99,6 +101,7 @@ export function createSorobanEscrowContractClient( }, async buildRefundEscrow(input) { + assertEscrowDeliveryId(input.chainDeliveryId); return buildInvokeTransaction(client, { contractId, method: 'refund_escrow', diff --git a/src/modules/escrow/interface/schemas.ts b/src/modules/escrow/interface/schemas.ts index 90b3742..7d8c35e 100644 --- a/src/modules/escrow/interface/schemas.ts +++ b/src/modules/escrow/interface/schemas.ts @@ -1,11 +1,20 @@ import { z } from 'zod'; +import { I128_MAX } from '../../../shared/validation/i128.js'; import { chainId } from '../../../shared/validation/chain-id.js'; import { stellarAddress } from '../../../shared/validation/stellar-address.js'; import { tokenAddress } from '../../../shared/validation/token-address.js'; +import { ESCROW_DELIVERY_ID_MAX } from '../domain/identifiers.js'; export { transactionResponseSchema } from '../../../shared/validation/transaction-response.js'; const chainDeliveryId = chainId.max(128, 'chainDeliveryId must be at most 128 characters'); +const builderChainDeliveryId = chainDeliveryId.refine((value) => { + try { + return BigInt(value) <= ESCROW_DELIVERY_ID_MAX; + } catch { + return false; + } +}, 'chainDeliveryId must be an unsigned 64-bit integer'); // Module-specific: a token amount that happens to share the digit-string // shape of `chainId` but is a distinct concept — kept local by design. const amount = z @@ -14,12 +23,12 @@ const amount = z .refine( (val) => { try { - return BigInt(val) > 0n; + return BigInt(val) > 0n && BigInt(val) <= I128_MAX; } catch { return false; } }, - { message: 'amount must be greater than zero' }, + { message: 'amount must be greater than zero and within signed 128-bit bounds' }, ); const escrowStatus = z.enum(['LOCKED', 'RELEASED', 'REFUNDED', 'PAUSED']); @@ -51,17 +60,17 @@ export const createEscrowBodySchema = z.object({ senderAddress: stellarAddress, recipientAddress: stellarAddress, driverAddress: stellarAddress, - chainDeliveryId, + chainDeliveryId: builderChainDeliveryId, token: tokenAddress, amount, }); export const releaseEscrowBodySchema = z.object({ callerAddress: stellarAddress, - chainDeliveryId, + chainDeliveryId: builderChainDeliveryId, }); export const refundEscrowBodySchema = z.object({ callerAddress: stellarAddress, - chainDeliveryId, + chainDeliveryId: builderChainDeliveryId, }); diff --git a/src/modules/escrow/security-bounds.integration.spec.ts b/src/modules/escrow/security-bounds.integration.spec.ts new file mode 100644 index 0000000..7e2a16b --- /dev/null +++ b/src/modules/escrow/security-bounds.integration.spec.ts @@ -0,0 +1,139 @@ +import { randomBytes, randomUUID } from 'node:crypto'; +import { Account, Address, Keypair, Transaction, TransactionBuilder } from '@stellar/stellar-sdk'; +import { PrismaClient } from '@prisma/client'; +import Fastify from 'fastify'; +import { validatorCompiler, serializerCompiler } from 'fastify-type-provider-zod'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { createBuildEscrowTransactionsUseCases } from './application/build-escrow-transactions.js'; +import { createSorobanEscrowContractClient } from './infrastructure/soroban-escrow-contract-client.js'; +import { createEscrowArgsToScVal } from './infrastructure/escrow-scval-mapping.js'; +import { createEscrowRoutes } from './interface/routes.js'; +import { SorobanClient } from '../../blockchain/soroban-client.js'; +import { scValToNative } from '../../blockchain/xdr/sc-val.js'; +import { I128_MAX } from '../../shared/validation/i128.js'; +import { handleError } from '../../shared/errors/error-handler.js'; +import { signAccessToken } from '../../shared/jwt/index.js'; +import { disconnectPrisma } from '../../shared/database/index.js'; + +// Use the real route dependencies without loading unrelated metrics/worker barrels. +vi.mock('../../shared/http/index.js', async () => ({ + ...(await import('../../shared/http/plugins/auth-guard.js')), + ...(await import('../../shared/http/response-envelope.js')), +})); +const prisma = new PrismaClient(); +const createdUserIds: string[] = []; +afterAll(async () => { + await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } }); + await prisma.$disconnect(); + await disconnectPrisma(); +}); +const sender = Keypair.random().publicKey(); +const input = { + senderAddress: sender, + recipientAddress: Keypair.random().publicKey(), + driverAddress: Keypair.random().publicKey(), + chainDeliveryId: 1n, + token: Address.contract(randomBytes(32)).toString(), + amount: I128_MAX, +}; + +function builder() { + const client = new SorobanClient('http://127.0.0.1:9'); + const balance = vi.fn(async () => null); + const account = vi.spyOn(client, 'getAccount').mockResolvedValue(new Account(sender, '100')); + vi.spyOn(client, 'prepareTransaction').mockImplementation(async (transaction) => { + if (!(transaction instanceof Transaction)) throw new Error('Expected ordinary transaction'); + return transaction; + }); + return { + client, + account, + balance, + adapter: createSorobanEscrowContractClient( + client, + Address.contract(randomBytes(32)).toString(), + { getTokenBalance: balance }, + ), + }; +} + +describe('escrow amount bounds at callers', () => { + it('rejects an oversized direct builder input before balance/account RPC', async () => { + const { adapter, account, balance } = builder(); + await expect( + adapter.buildCreateEscrow({ ...input, amount: I128_MAX + 1n }), + ).rejects.toMatchObject({ statusCode: 400 }); + expect(balance).not.toHaveBeenCalled(); + expect(account).not.toHaveBeenCalled(); + expect(() => createEscrowArgsToScVal({ ...input, amount: I128_MAX + 1n })).toThrow( + 'signed 128-bit bounds', + ); + }); + + it('rejects overflow at the application port before delegation', async () => { + const { adapter, balance, account } = builder(); + const build = createBuildEscrowTransactionsUseCases({ transactionBuilder: adapter }); + await expect( + build.buildCreateEscrowTransaction({ ...input, amount: I128_MAX + 1n }), + ).rejects.toMatchObject({ statusCode: 400 }); + expect(balance).not.toHaveBeenCalled(); + expect(account).not.toHaveBeenCalled(); + }); + + it.each([0n, -1n])('preserves strictly positive escrow amount rules for %s', async (amount) => { + const { adapter, balance } = builder(); + await expect(adapter.buildCreateEscrow({ ...input, amount })).rejects.toThrow( + 'greater than zero', + ); + expect(balance).not.toHaveBeenCalled(); + }); + + it.each([1n, 10000000n, I128_MAX])( + 'returns unsigned bytes encoding exactly %s', + async (amount) => { + const { adapter } = builder(); + const encoded = await adapter.buildCreateEscrow({ ...input, amount }); + const tx = TransactionBuilder.fromXDR(encoded, 'Test SDF Network ; September 2015'); + const operation = tx.operations[0]; + if (!operation || operation.type !== 'invokeHostFunction') + throw new Error('Expected invocation'); + expect(scValToNative(operation.func.invokeContract().args()[5]!)).toBe(amount.toString()); + expect(tx.signatures).toHaveLength(0); + }, + ); + + it('returns HTTP 400 for overflow through the real authenticated escrow route', async () => { + const { adapter, balance } = builder(); + const app = Fastify(); + app.setValidatorCompiler(validatorCompiler); + app.setSerializerCompiler(serializerCompiler); + app.setErrorHandler(handleError); + const user = await prisma.user.create({ + data: { email: `${randomUUID()}@amount.invalid`, passwordHash: 'test' }, + }); + createdUserIds.push(user.id); + await app.register( + createEscrowRoutes({ + getEscrow: async () => { + throw new Error('unused'); + }, + buildTransactions: createBuildEscrowTransactionsUseCases({ transactionBuilder: adapter }), + }), + ); + try { + const response = await app.inject({ + method: 'POST', + url: '/transactions/build/create-escrow', + headers: { + authorization: `Bearer ${signAccessToken({ sub: user.id, role: user.role, tokenVersion: user.tokenVersion })}`, + }, + payload: { ...input, chainDeliveryId: '1', amount: (I128_MAX + 1n).toString() }, + }); + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ error: { code: 'VALIDATION_ERROR' } }); + expect(balance).not.toHaveBeenCalled(); + } finally { + await app.close(); + } + }); +}); diff --git a/src/modules/fleet/application/__fixtures__/fakes.ts b/src/modules/fleet/application/__fixtures__/fakes.ts index 02b27f1..6616ae0 100644 --- a/src/modules/fleet/application/__fixtures__/fakes.ts +++ b/src/modules/fleet/application/__fixtures__/fakes.ts @@ -9,7 +9,9 @@ import type { FleetRepository, FleetTransactionBuilder, FleetWithDrivers, + FindFleetOptions, } from '../../domain/index.js'; +import { ValidationError } from '../../../../shared/errors/index.js'; interface FleetRow extends Fleet { drivers: FleetDriver[]; @@ -21,11 +23,14 @@ export function createInMemoryFleetRepository(): FleetRepository & { const fleets = new Map(); const key = (chainFleetId: bigint): string => chainFleetId.toString(); - function toFleetWithDrivers(row: FleetRow): FleetWithDrivers { - const drivers = row.drivers; - const totalActiveDrivers = drivers.filter( + function toFleetWithDrivers(row: FleetRow, options?: FindFleetOptions): FleetWithDrivers { + const totalActiveDrivers = row.drivers.filter( (d) => d.status === 'ACTIVE' && d.removedAt === null, ).length; + const drivers = row.drivers + .filter((driver) => options?.includeRemoved || driver.removedAt === null) + .sort((a, b) => b.invitedAt.getTime() - a.invitedAt.getTime()) + .slice(0, options?.driverLimit ?? 100); return { ...row, drivers, totalActiveDrivers }; } @@ -33,9 +38,32 @@ export function createInMemoryFleetRepository(): FleetRepository & { seed(fleet) { fleets.set(fleet.chainFleetId.toString(), { ...fleet, drivers: [...fleet.drivers] }); }, - async findByChainFleetId(chainFleetId) { + async findByChainFleetId(chainFleetId, options) { const row = fleets.get(key(chainFleetId)); - return row ? toFleetWithDrivers(row) : null; + return row ? toFleetWithDrivers(row, options) : null; + }, + async listFleetsByOwner(ownerAddress, options) { + const page = options?.page ?? 1; + const limit = options?.limit ?? 20; + const offset = (page - 1) * limit; + if ( + !Number.isSafeInteger(page) || + page < 1 || + !Number.isSafeInteger(limit) || + limit < 1 || + !Number.isSafeInteger(offset) + ) { + throw new ValidationError('Invalid fleet pagination'); + } + return [...fleets.values()] + .filter((fleet) => fleet.ownerAddress === ownerAddress) + .sort( + (a, b) => + b.createdAt.getTime() - a.createdAt.getTime() || + (a.id < b.id ? 1 : a.id > b.id ? -1 : 0), + ) + .slice(offset, offset + limit) + .map((fleet) => toFleetWithDrivers(fleet)); }, async create(record) { const fleet: FleetRow = { diff --git a/src/modules/fleet/application/build-fleet-transactions.ts b/src/modules/fleet/application/build-fleet-transactions.ts index 01c9e7c..da3d248 100644 --- a/src/modules/fleet/application/build-fleet-transactions.ts +++ b/src/modules/fleet/application/build-fleet-transactions.ts @@ -13,7 +13,7 @@ export interface BuildFleetTransactionsDeps { /** Optional read model used to reject duplicate driver invitations before * building an on-chain transaction. */ fleetRepository?: { - isDriverActiveInFleet(chainFleetId: string, driverAddress: string): Promise; + isDriverActiveInFleet(chainFleetId: bigint, driverAddress: string): Promise; }; } diff --git a/src/modules/fleet/application/get-fleet.spec.ts b/src/modules/fleet/application/get-fleet.spec.ts index 4ebcea4..92be794 100644 --- a/src/modules/fleet/application/get-fleet.spec.ts +++ b/src/modules/fleet/application/get-fleet.spec.ts @@ -25,8 +25,15 @@ describe('getFleet', () => { const result = await getFleet({ chainFleetId: 42n }); expect(result.chainFleetId).toBe(42n); - expect(result.drivers).toHaveLength(3); + expect(result.drivers).toHaveLength(2); + expect(result.drivers.every((driver) => driver.removedAt === null)).toBe(true); expect(result.totalActiveDrivers).toBe(1); + const history = await getFleet({ chainFleetId: 42n, includeRemoved: true }); + expect(history.drivers).toHaveLength(3); + expect( + history.drivers.find((driver) => driver.driverAddress === 'GREMOVED')?.removedAt, + ).toBeInstanceOf(Date); + expect(history.totalActiveDrivers).toBe(1); }); it('rejects an unknown chain fleet id', async () => { diff --git a/src/modules/fleet/application/list-my-fleets.ts b/src/modules/fleet/application/list-my-fleets.ts deleted file mode 100644 index 76a84ab..0000000 --- a/src/modules/fleet/application/list-my-fleets.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { FleetRepository, PaginationOptions } from "../domain/ports"; -import type { Fleet } from "../domain/fleet"; - -export interface ListMyFleetsInput { - ownerAddresses: string[]; - options?: PaginationOptions; -} - -export interface ListMyFleetsResult { - fleets: Fleet[]; - total: number; - page: number; - pageSize: number; -} - -export type ListMyFleetsUseCase = ( - input: ListMyFleetsInput, -) => Promise; - -export function createListMyFleetsUseCase( - fleetRepository: FleetRepository, -): ListMyFleetsUseCase { - return async ({ ownerAddresses, options }) => { - const uniqueOwners = Array.from( - new Set( - ownerAddresses - .map((address) => address.trim()) - .filter((address) => address.length > 0), - ), - ); - - if (uniqueOwners.length === 0) { - return { - fleets: [], - total: 0, - page: options?.page ?? 1, - pageSize: options?.pageSize ?? 20, - }; - } - - const fleets = await fleetRepository.listFleetsByOwner(uniqueOwners, options); - - return { - fleets, - total: fleets.length, - page: options?.page ?? 1, - pageSize: options?.pageSize ?? 20, - }; - }; -} diff --git a/src/modules/fleet/application/sync-fleet-from-event.spec.ts b/src/modules/fleet/application/sync-fleet-from-event.spec.ts index 22db24f..5edfd73 100644 --- a/src/modules/fleet/application/sync-fleet-from-event.spec.ts +++ b/src/modules/fleet/application/sync-fleet-from-event.spec.ts @@ -115,7 +115,8 @@ describe('syncFleetFromEvent', () => { }), ); - const stored = await fleetRepository.findByChainFleetId(1n); + expect((await fleetRepository.findByChainFleetId(1n))?.drivers).toEqual([]); + const stored = await fleetRepository.findByChainFleetId(1n, { includeRemoved: true }); expect(stored?.drivers).toHaveLength(1); expect(stored?.drivers[0]?.removedAt).toEqual(closedAt); expect(stored?.totalActiveDrivers).toBe(0); diff --git a/src/modules/fleet/domain/errors.ts b/src/modules/fleet/domain/errors.ts index 94a77f9..a7ef561 100644 --- a/src/modules/fleet/domain/errors.ts +++ b/src/modules/fleet/domain/errors.ts @@ -1,13 +1,28 @@ -import { AppError } from '../../../shared/errors/app-error.js'; +import { AppError, ConflictError, NotFoundError } from '../../../shared/errors/app-error.js'; -export class FleetNotFoundError extends AppError { +export class FleetNotFoundError extends NotFoundError { constructor(message = 'Fleet not found') { - super(message, 404, 'FLEET_NOT_FOUND'); + super(message); + } +} + +export class DriverFleetRecordNotFoundError extends NotFoundError { + constructor() { + super('No fleet-membership record found for this driver'); + } +} + +export class DriverAlreadyInFleetError extends ConflictError { + constructor(driverAddress: string, chainFleetId: bigint) { + super(`Driver ${driverAddress} is already active in fleet ${chainFleetId}`); } } export class DriverPayoutAddressNotFoundError extends AppError { + readonly statusCode = 404; + readonly code = 'DRIVER_NOT_FOUND'; + constructor(message = 'Driver payout address not found') { - super(message, 404, 'DRIVER_NOT_FOUND'); + super(message); } } diff --git a/src/modules/fleet/domain/index.ts b/src/modules/fleet/domain/index.ts index fe980c0..3e9726d 100644 --- a/src/modules/fleet/domain/index.ts +++ b/src/modules/fleet/domain/index.ts @@ -5,7 +5,12 @@ export type { FleetDriverStatus, FleetWithDrivers, } from './entities.js'; -export { FleetNotFoundError, DriverFleetRecordNotFoundError } from './errors.js'; +export { + FleetNotFoundError, + DriverFleetRecordNotFoundError, + DriverAlreadyInFleetError, + DriverPayoutAddressNotFoundError, +} from './errors.js'; export type { AcceptFleetInviteTxInput, AddDriverToFleetTxInput, diff --git a/src/modules/fleet/domain/ports.ts b/src/modules/fleet/domain/ports.ts index 585b3bc..0e6da7d 100644 --- a/src/modules/fleet/domain/ports.ts +++ b/src/modules/fleet/domain/ports.ts @@ -30,14 +30,12 @@ export interface FleetRepository { chainFleetId: bigint, options?: FindFleetOptions, ): Promise; - /** Lists every fleet whose `ownerAddress` matches one of the given - * addresses, newest first, paginated. Used by `GET /fleets` so a fleet - * manager can discover the fleets they own without knowing every - * `chainFleetId` out-of-band. */ - listFleetsByOwner( - ownerAddress: string, - options?: PaginationOptions, - ): Promise; + /** Internal single-wallet read, scoped by exact on-chain owner address, + * not a local user ID. Newest first, with descending local ID breaking + * timestamp ties. Page is 1-based and limit defaults to 20; both must be + * positive safe integers. Returns this page only, not an unpaged total. + * No HTTP owner-list route currently wires this method. */ + listFleetsByOwner(ownerAddress: string, options?: PaginationOptions): Promise; create(record: ChainFleetRecord): Promise; updateTreasury(chainFleetId: bigint, treasuryAddress: string): Promise; inviteDriver(chainFleetId: bigint, driverAddress: string, invitedAt: Date): Promise; diff --git a/src/modules/fleet/infrastructure/prisma-fleet-repository.integration.spec.ts b/src/modules/fleet/infrastructure/prisma-fleet-repository.integration.spec.ts index 1f5213f..fdbde51 100644 --- a/src/modules/fleet/infrastructure/prisma-fleet-repository.integration.spec.ts +++ b/src/modules/fleet/infrastructure/prisma-fleet-repository.integration.spec.ts @@ -81,6 +81,9 @@ describe.skipIf(!dbAvailable)('Prisma fleet repository (integration)', () => { const removedAt = new Date('2026-01-03T00:00:00Z'); await fleetRepository.removeDriver(chainFleetId, 'GDRIVER', removedAt); found = await fleetRepository.findByChainFleetId(chainFleetId); + expect(found?.drivers).toEqual([]); + expect(found?.totalActiveDrivers).toBe(0); + found = await fleetRepository.findByChainFleetId(chainFleetId, { includeRemoved: true }); expect(found?.drivers).toHaveLength(1); expect(found?.drivers[0]?.removedAt).toEqual(removedAt); expect(found?.totalActiveDrivers).toBe(0); @@ -122,7 +125,8 @@ describe.skipIf(!dbAvailable)('Prisma fleet repository (integration)', () => { const removedAt = new Date('2026-01-03T00:00:00Z'); await fleetRepository.removeDriver(chainFleetId, 'GDRIVER', removedAt); - let found = await fleetRepository.findByChainFleetId(chainFleetId); + expect((await fleetRepository.findByChainFleetId(chainFleetId))?.drivers).toEqual([]); + let found = await fleetRepository.findByChainFleetId(chainFleetId, { includeRemoved: true }); expect(found?.drivers[0]?.removedAt).toEqual(removedAt); const reacceptedAt = new Date('2026-02-01T00:00:00Z'); @@ -143,4 +147,75 @@ describe.skipIf(!dbAvailable)('Prisma fleet repository (integration)', () => { expect(activeOnly?.drivers).toHaveLength(1); expect(activeOnly?.drivers[0]).toMatchObject({ driverAddress: 'GDRIVER', status: 'ACTIVE' }); }); + + it('ignores removal when the fleet has not been indexed', async () => { + const chainFleetId = nextChainId(); + await fleetRepository.removeDriver(chainFleetId, 'GMISSING_FLEET_DRIVER', new Date()); + expect(await fleetRepository.findByChainFleetId(chainFleetId)).toBeNull(); + expect( + await prisma.fleetDriver.count({ where: { driverAddress: 'GMISSING_FLEET_DRIVER' } }), + ).toBe(0); + }); + + it('records an out-of-order removal once and hides it from the default listing', async () => { + const chainFleetId = nextChainId(); + await fleetRepository.create({ + chainFleetId, + ownerAddress: 'GOWNER', + treasuryAddress: 'GTREASURY', + }); + const removedAt = new Date('2026-01-03T00:00:00Z'); + await fleetRepository.removeDriver(chainFleetId, 'GOUTOFORDER', removedAt); + const first = await fleetRepository.findByChainFleetId(chainFleetId, { includeRemoved: true }); + await fleetRepository.removeDriver(chainFleetId, 'GOUTOFORDER', removedAt); + const replayed = await fleetRepository.findByChainFleetId(chainFleetId, { + includeRemoved: true, + }); + expect(replayed?.drivers).toHaveLength(1); + expect(replayed?.drivers[0]).toMatchObject({ + id: first?.drivers[0]?.id, + driverAddress: 'GOUTOFORDER', + status: 'PENDING', + removedAt, + }); + const visible = await fleetRepository.findByChainFleetId(chainFleetId); + expect(visible?.drivers).toEqual([]); + expect(visible?.totalActiveDrivers).toBe(0); + expect(visible?.treasuryAddress).toBe('GTREASURY'); + }); + + it('soft-removes only the selected fleet membership and preserves acceptance history', async () => { + const chainFleetId = nextChainId(); + const otherChainFleetId = nextChainId(); + const invitedAt = new Date('2026-01-01T00:00:00Z'); + const acceptedAt = new Date('2026-01-02T00:00:00Z'); + const removedAt = new Date('2026-01-03T00:00:00Z'); + for (const id of [chainFleetId, otherChainFleetId]) { + await fleetRepository.create({ + chainFleetId: id, + ownerAddress: 'GOWNER', + treasuryAddress: 'GTREASURY', + }); + await fleetRepository.inviteDriver(id, 'GSHARED_DRIVER', invitedAt); + await fleetRepository.acceptInvite(id, 'GSHARED_DRIVER', acceptedAt); + } + await fleetRepository.removeDriver(chainFleetId, 'GSHARED_DRIVER', removedAt); + await fleetRepository.removeDriver(chainFleetId, 'GSHARED_DRIVER', removedAt); + const removed = await fleetRepository.findByChainFleetId(chainFleetId, { + includeRemoved: true, + }); + expect(removed?.drivers).toHaveLength(1); + expect(removed?.drivers[0]).toMatchObject({ + status: 'ACTIVE', + invitedAt, + acceptedAt, + removedAt, + }); + expect(removed?.totalActiveDrivers).toBe(0); + expect((await fleetRepository.findByChainFleetId(chainFleetId))?.drivers).toEqual([]); + const other = await fleetRepository.findByChainFleetId(otherChainFleetId); + expect(other?.drivers).toHaveLength(1); + expect(other?.drivers[0]).toMatchObject({ status: 'ACTIVE', acceptedAt, removedAt: null }); + expect(other?.totalActiveDrivers).toBe(1); + }); }); diff --git a/src/modules/fleet/infrastructure/prisma-fleet-repository.ts b/src/modules/fleet/infrastructure/prisma-fleet-repository.ts index 1b01ce3..c1b990f 100644 --- a/src/modules/fleet/infrastructure/prisma-fleet-repository.ts +++ b/src/modules/fleet/infrastructure/prisma-fleet-repository.ts @@ -4,6 +4,7 @@ import type { PrismaClient, } from '@prisma/client'; import type { Fleet, FleetDriver, FleetRepository, FleetWithDrivers } from '../domain/index.js'; +import { ValidationError } from '../../../shared/errors/index.js'; function toDriver(record: PrismaFleetDriver): FleetDriver { return { @@ -90,11 +91,18 @@ export function createPrismaFleetRepository(prisma: PrismaClient): FleetReposito async listFleetsByOwner(ownerAddress, options) { const limit = options?.limit ?? 20; - const offset = options?.offset ?? 0; + const page = options?.page ?? 1; + if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(limit) || limit < 1) { + throw new ValidationError('Fleet page and limit must be positive safe integers'); + } + const offset = (page - 1) * limit; + if (!Number.isSafeInteger(offset)) { + throw new ValidationError('Fleet pagination offset exceeds the safe integer range'); + } const records = await prisma.fleet.findMany({ where: { ownerAddress }, - orderBy: { createdAt: 'desc' }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], take: limit, skip: offset, include: { @@ -209,6 +217,21 @@ export function createPrismaFleetRepository(prisma: PrismaClient): FleetReposito const fleet = await prisma.fleet.findUnique({ where: { chainFleetId } }); if (!fleet) { console.debug( - `[fleet] driver_removed skipped — no indexed fleet fo + `[fleet] driver_removed skipped — no indexed fleet for chainFleetId=${chainFleetId}.`, + ); + return; + } -/* … truncated 630 chars — edit only what you need near the top … */ + // `upsert`, not `update` — same out-of-order rationale as + // `acceptInvite` above: `driver_removed` carries the full + // `(fleetId, driverAddress)` unique key, so a driver row that was + // never observed being invited/accepted can still be recorded as + // removed rather than throwing P2025. + await prisma.fleetDriver.upsert({ + where: { fleetId_driverAddress: { fleetId: fleet.id, driverAddress } }, + create: { fleetId: fleet.id, driverAddress, removedAt }, + update: { removedAt }, + }); + }, + }; +} diff --git a/src/modules/fleet/infrastructure/soroban-fleet-contract-client.spec.ts b/src/modules/fleet/infrastructure/soroban-fleet-contract-client.spec.ts index 4bac4d6..4278793 100644 --- a/src/modules/fleet/infrastructure/soroban-fleet-contract-client.spec.ts +++ b/src/modules/fleet/infrastructure/soroban-fleet-contract-client.spec.ts @@ -135,6 +135,7 @@ describe('createSorobanFleetContractClient — getPayoutAddress', () => { const client = new SorobanClient(); const driver = Keypair.random().publicKey(); const treasury = Keypair.random().publicKey(); + vi.spyOn(client, 'getAccount').mockResolvedValue(new Account(driver, '100')); vi.spyOn(client, 'simulateTransaction').mockResolvedValue({ id: '1', @@ -155,6 +156,8 @@ describe('createSorobanFleetContractClient — getPayoutAddress', () => { it('throws when simulation fails', async () => { const client = new SorobanClient(); + const driver = Keypair.random().publicKey(); + vi.spyOn(client, 'getAccount').mockResolvedValue(new Account(driver, '100')); vi.spyOn(client, 'simulateTransaction').mockResolvedValue({ id: '1', latestLedger: 1000, @@ -165,6 +168,8 @@ describe('createSorobanFleetContractClient — getPayoutAddress', () => { const contractClient = createSorobanFleetContractClient(client, CONTRACT_ID); - await expect(contractClient.getPayoutAddress('GDRIVER', 999n)).rejects.toThrow(); + await expect(contractClient.getPayoutAddress(driver, 999n)).rejects.toThrow( + 'get_payout_address simulation failed: contract not found', + ); }); }); diff --git a/src/modules/fleet/infrastructure/soroban-fleet-contract-client.ts b/src/modules/fleet/infrastructure/soroban-fleet-contract-client.ts index 6dec8cc..eb01ad5 100644 --- a/src/modules/fleet/infrastructure/soroban-fleet-contract-client.ts +++ b/src/modules/fleet/infrastructure/soroban-fleet-contract-client.ts @@ -1,100 +1,97 @@ -import { Address, Contract, nativeToScVal, scValToNative, xdr } from '@stellar/stellar-sdk'; -import type { SorobanClient } from '../../blockchain/soroban-client.js'; +import { Contract, scValToNative, TransactionBuilder } from '@stellar/stellar-sdk'; +import { getConfig } from '../../../shared/config/index.js'; import { DriverPayoutAddressNotFoundError } from '../domain/errors.js'; +import { + addressToScVal, + buildInvokeTransaction, + u64ToScVal, + type SorobanClient, +} from '../../../blockchain/index.js'; +import type { FleetContractReader, FleetTransactionBuilder } from '../domain/index.js'; -export interface SorobanFleetContractClient { - buildRegisterFleet(params: { ownerAddress: string; treasuryAddress: string }): Promise; - buildUpdateFleetTreasury(params: { - ownerAddress: string; - chainFleetId: bigint; - treasuryAddress: string; - }): Promise; - buildAddDriverToFleet(params: { - callerAddress: string; - chainFleetId: bigint; - driverAddress: string; - }): Promise; - buildAcceptFleetInvite(params: { chainFleetId: bigint; driverAddress: string }): Promise; - buildRemoveDriverFromFleet(params: { - callerAddress: string; - chainFleetId: bigint; - driverAddress: string; - }): Promise; - getPayoutAddress(driverAddress: string, chainFleetId: bigint): Promise; -} - +/** + * Real implementation of both fleet-module contract ports, backed by the + * shared resilient Soroban client — mirrors + * escrow/infrastructure/soroban-escrow-contract-client.ts. `fleet_id` is a + * bare `u64` for every `fleet_management_contract` call (verified against + * `pub type FleetId = u64` in fleet_management_contract/lib.rs), so there's + * no tuple-struct wrapping to handle. No separate scval-mapping file: unlike + * escrow/deliveries there's no struct to decode here — `get_payout_address` + * returns a bare `Address`, which `scValToNative` already turns into a plain + * string. + */ export function createSorobanFleetContractClient( client: SorobanClient, contractId: string, -): SorobanFleetContractClient { - const contract = new Contract(contractId); - - async function buildInvocation( - sourceAddress: string, - method: string, - args: xdr.ScVal[], - ): Promise { - const account = await client.getAccount(sourceAddress); - const tx = new (await import('@stellar/stellar-sdk')).TransactionBuilder(account, { - fee: '100', - networkPassphrase: client.networkPassphrase, - }) - .addOperation(contract.call(method, ...args)) - .setTimeout(60) - .build(); - return client.prepareTransaction(tx); - } - +): FleetContractReader & FleetTransactionBuilder { return { - buildRegisterFleet({ ownerAddress, treasuryAddress }) { - return buildInvocation(ownerAddress, 'register_fleet', [ - new Address(ownerAddress).toScVal(), - new Address(treasuryAddress).toScVal(), - ]); + async buildRegisterFleet(input) { + return buildInvokeTransaction(client, { + contractId, + method: 'register_fleet', + args: [addressToScVal(input.ownerAddress), addressToScVal(input.treasuryAddress)], + sourceAddress: input.ownerAddress, + }); }, - buildUpdateFleetTreasury({ ownerAddress, chainFleetId, treasuryAddress }) { - return buildInvocation(ownerAddress, 'update_fleet_treasury', [ - new Address(ownerAddress).toScVal(), - nativeToScVal(chainFleetId, { type: 'u64' }), - new Address(treasuryAddress).toScVal(), - ]); + async buildUpdateFleetTreasury(input) { + return buildInvokeTransaction(client, { + contractId, + method: 'update_fleet_treasury', + args: [ + addressToScVal(input.ownerAddress), + u64ToScVal(input.chainFleetId), + addressToScVal(input.treasuryAddress), + ], + sourceAddress: input.ownerAddress, + }); }, - buildAddDriverToFleet({ callerAddress, chainFleetId, driverAddress }) { - return buildInvocation(callerAddress, 'add_driver_to_fleet', [ - new Address(callerAddress).toScVal(), - nativeToScVal(chainFleetId, { type: 'u64' }), - new Address(driverAddress).toScVal(), - ]); + async buildAddDriverToFleet(input) { + return buildInvokeTransaction(client, { + contractId, + method: 'add_driver_to_fleet', + args: [ + addressToScVal(input.callerAddress), + u64ToScVal(input.chainFleetId), + addressToScVal(input.driverAddress), + ], + sourceAddress: input.callerAddress, + }); }, - buildAcceptFleetInvite({ chainFleetId, driverAddress }) { - return buildInvocation(driverAddress, 'accept_fleet_invite', [ - nativeToScVal(chainFleetId, { type: 'u64' }), - new Address(driverAddress).toScVal(), - ]); + async buildAcceptFleetInvite(input) { + return buildInvokeTransaction(client, { + contractId, + method: 'accept_fleet_invite', + args: [u64ToScVal(input.chainFleetId), addressToScVal(input.driverAddress)], + sourceAddress: input.driverAddress, + }); }, - buildRemoveDriverFromFleet({ callerAddress, chainFleetId, driverAddress }) { - return buildInvocation(callerAddress, 'remove_driver_from_fleet', [ - nativeToScVal(chainFleetId, { type: 'u64' }), - new Address(callerAddress).toScVal(), - new Address(driverAddress).toScVal(), - ]); + async buildRemoveDriverFromFleet(input) { + return buildInvokeTransaction(client, { + contractId, + method: 'remove_driver_from_fleet', + args: [ + u64ToScVal(input.chainFleetId), + addressToScVal(input.callerAddress), + addressToScVal(input.driverAddress), + ], + sourceAddress: input.callerAddress, + }); }, - async getPayoutAddress(driverAddress, chainFleetId) { const account = await client.getAccount(driverAddress); - const tx = new (await import('@stellar/stellar-sdk')).TransactionBuilder(account, { + const tx = new TransactionBuilder(account, { fee: '100', - networkPassphrase: client.networkPassphrase, + networkPassphrase: getConfig().STELLAR_NETWORK_PASSPHRASE, }) .addOperation( - contract.call( + new Contract(contractId).call( 'get_payout_address', - nativeToScVal(chainFleetId, { type: 'u64' }), - new Address(driverAddress).toScVal(), + addressToScVal(driverAddress), + u64ToScVal(chainFleetId), ), ) .setTimeout(60) diff --git a/src/modules/fleet/interface/fleet-routes.integration.spec.ts b/src/modules/fleet/interface/fleet-routes.integration.spec.ts index 5337508..48ec38d 100644 --- a/src/modules/fleet/interface/fleet-routes.integration.spec.ts +++ b/src/modules/fleet/interface/fleet-routes.integration.spec.ts @@ -20,6 +20,21 @@ describe.skipIf(!dbAvailable)('fleet routes (integration)', () => { let app: Awaited>; const prisma = new PrismaClient(); const createdChainIds: bigint[] = []; + const createdUserIds: string[] = []; + + async function authenticatedHeaders() { + const user = await prisma.user.create({ + data: { + email: `${randomUUID()}@fleet-tests.invalid`, + passwordHash: 'fixture-only', + role: 'ADMIN', + }, + }); + createdUserIds.push(user.id); + return { + authorization: `Bearer ${signAccessToken({ sub: user.id, role: 'ADMIN', tokenVersion: 0 })}`, + }; + } beforeAll(async () => { app = await buildApp(); @@ -27,6 +42,7 @@ describe.skipIf(!dbAvailable)('fleet routes (integration)', () => { afterAll(async () => { await app.close(); + await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } }); if (createdChainIds.length > 0) { await prisma.fleet.deleteMany({ where: { chainFleetId: { in: createdChainIds } } }); } @@ -58,6 +74,7 @@ describe.skipIf(!dbAvailable)('fleet routes (integration)', () => { const response = await app.inject({ method: 'GET', url: `/api/v1/fleets/${chainFleetId.toString()}`, + headers: await authenticatedHeaders(), }); expect(response.statusCode).toBe(200); @@ -67,7 +84,11 @@ describe.skipIf(!dbAvailable)('fleet routes (integration)', () => { }); it('returns 404 for an unknown fleet', async () => { - const response = await app.inject({ method: 'GET', url: '/api/v1/fleets/999999999999999' }); + const response = await app.inject({ + method: 'GET', + url: '/api/v1/fleets/999999999999999', + headers: await authenticatedHeaders(), + }); expect(response.statusCode).toBe(404); expect(response.json().error.code).toBe('NOT_FOUND'); @@ -99,12 +120,10 @@ describe.skipIf(!dbAvailable)('fleet routes (integration)', () => { // the createUnconfiguredContractClient() fallback in ../index.ts — rather // than a generic failure. Fails if that fallback wiring is removed. it('returns 502 BLOCKCHAIN_ERROR from a build endpoint when the contract id is unconfigured', async () => { - const token = signAccessToken({ sub: randomUUID(), role: 'ADMIN' }); - const response = await app.inject({ method: 'POST', url: '/api/v1/transactions/build/register-fleet', - headers: { authorization: `Bearer ${token}` }, + headers: await authenticatedHeaders(), payload: { ownerAddress: Keypair.random().publicKey(), treasuryAddress: Keypair.random().publicKey(), diff --git a/src/modules/fleet/interface/schemas.spec.ts b/src/modules/fleet/interface/schemas.spec.ts index 8333c9f..881103b 100644 --- a/src/modules/fleet/interface/schemas.spec.ts +++ b/src/modules/fleet/interface/schemas.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { addDriverToFleetBodySchema, payoutAddressResponseSchema } from './schemas.js'; +import { testAddress } from '../../../shared/testing/addresses.js'; describe('payoutAddressResponseSchema', () => { it('accepts payout addresses up to the maximum address length', () => { @@ -20,13 +21,13 @@ describe('payoutAddressResponseSchema', () => { }); describe('addDriverToFleetBodySchema', () => { - const callerAddress = 'G' + 'A'.repeat(55); - const driverAddress = 'G' + 'B'.repeat(55); + const callerAddress = testAddress('fleet-schema-owner'); + const driverAddress = testAddress('fleet-schema-driver'); it('accepts distinct caller and driver addresses', () => { const result = addDriverToFleetBodySchema.safeParse({ callerAddress, - chainFleetId: 1, + chainFleetId: '1', driverAddress, }); @@ -36,7 +37,7 @@ describe('addDriverToFleetBodySchema', () => { it('rejects when driver address equals caller address', () => { const result = addDriverToFleetBodySchema.safeParse({ callerAddress, - chainFleetId: 1, + chainFleetId: '1', driverAddress: callerAddress, }); diff --git a/src/modules/fraud-detection/application/__fixtures__/fakes.ts b/src/modules/fraud-detection/application/__fixtures__/fakes.ts index 2cdc93f..7256460 100644 --- a/src/modules/fraud-detection/application/__fixtures__/fakes.ts +++ b/src/modules/fraud-detection/application/__fixtures__/fakes.ts @@ -24,10 +24,11 @@ export function createInMemoryActorActivityRepository(): ActorActivityRepository async record(input) { rows.push({ address: input.address, category: input.category, occurredAt: input.occurredAt }); }, - async countSince(address, category, since) { - return rows.filter( + async countSince(address, category, since, limit) { + const count = rows.filter( (row) => row.address === address && row.category === category && row.occurredAt >= since, ).length; + return limit === undefined ? count : Math.min(count, limit); }, async deleteOlderThan(olderThan) { let removed = 0; diff --git a/src/modules/fraud-detection/application/assess-actor.spec.ts b/src/modules/fraud-detection/application/assess-actor.spec.ts index 56f54a3..2d13ca6 100644 --- a/src/modules/fraud-detection/application/assess-actor.spec.ts +++ b/src/modules/fraud-detection/application/assess-actor.spec.ts @@ -1,19 +1,37 @@ -import { describe, expect, it, vi } from 'vitest'; -import { createAssessActorUseCase } from './assess-actor.js'; +import { describe, it, expect, vi } from 'vitest'; +import { createAssessActorUseCase, RULES, type RuleDefinition } from './assess-actor.js'; import { createFixedClock, createInMemoryActorActivityRepository } from './__fixtures__/fakes.js'; +import type { ActorAssessment, ActorActivityCategory } from '../domain/index.js'; + +const now = new Date('2024-01-01T00:00:00.000Z'); +function makeRepository(count: number, category: ActorActivityCategory = 'DELIVERY_CREATED') { + const repository = createInMemoryActorActivityRepository(); + for (let i = 0; i < count; i += 1) repository.seed('actor-1', category, now); + return repository; +} + +describe('createAssessActorUseCase', () => { + it('uses default RULES when no ruleDefinitions are provided', async () => { + const activityRepository = makeRepository(11); + const assessActor = createAssessActorUseCase({ + activityRepository, + clock: createFixedClock(now), + }); + const result = await assessActor({ address: 'actor-1' }); expect(result.flagged).toBe(true); - expect(result.triggeredRules).toContain('DELIVERY_CREATION_VELOCITY'); + expect(result.signals).toContainEqual( + expect.objectContaining({ ruleType: 'DELIVERY_CREATION_VELOCITY', triggered: true }), + ); }); it('does not flag an actor below the default threshold', async () => { - const activities = Array.from({ length: 9 }, (_, i) => - makeActivity({ id: `activity-${i}` }), - ); - const repository = makeRepository(activities); - const assessActor = createAssessActorUseCase({ repository }); - - const result = await assessActor({ actorId: 'actor-1' }); + const activityRepository = makeRepository(9); + const assessActor = createAssessActorUseCase({ + activityRepository, + clock: createFixedClock(now), + }); + const result = await assessActor({ address: 'actor-1' }); expect(result.flagged).toBe(false); }); @@ -21,55 +39,192 @@ import { createFixedClock, createInMemoryActorActivityRepository } from './__fix it('flags an actor after 3 activities when a custom threshold of 2 is injected', async () => { const customRules: RuleDefinition[] = [ { - name: 'DELIVERY_CREATION_VELOCITY', - activityType: 'DELIVERY_CREATION', + ruleType: 'DELIVERY_CREATION_VELOCITY', + category: 'DELIVERY_CREATED', windowHours: 1, threshold: 2, }, ]; - const activities = Array.from({ length: 3 }, (_, i) => - makeActivity({ - id: `activity-${i}`, - occurredAt: new Date('2024-01-01T00:00:00.000Z'), - }), - ); - const repository = makeRepository(activities); + const activityRepository = makeRepository(3); const assessActor = createAssessActorUseCase({ - repository, + activityRepository, + clock: createFixedClock(now), ruleDefinitions: customRules, }); - - const result = await assessActor({ actorId: 'actor-1' }); + const result = await assessActor({ address: 'actor-1' }); expect(result.flagged).toBe(true); - expect(result.triggeredRules).toContain('DELIVERY_CREATION_VELOCITY'); + expect(result.signals).toContainEqual( + expect.objectContaining({ ruleType: 'DELIVERY_CREATION_VELOCITY', triggered: true }), + ); }); - it('does not flag an actor below a custom threshold', async () => { + it('does not flag an actor exactly at a custom threshold', async () => { const customRules: RuleDefinition[] = [ { - name: 'DELIVERY_CREATION_VELOCITY', - activityType: 'DELIVERY_CREATION', + ruleType: 'DELIVERY_CREATION_VELOCITY', + category: 'DELIVERY_CREATED', windowHours: 1, threshold: 2, }, ]; - const activities = Array.from({ length: 2 }, (_, i) => - makeActivity({ id: `activity-${i}` }), - ); - const repository = makeRepository(activities); + const activityRepository = makeRepository(2); const assessActor = createAssessActorUseCase({ - repository, + activityRepository, + clock: createFixedClock(now), ruleDefinitions: customRules, }); - - const result = await assessActor({ actorId: 'actor-1' }); + const result = await assessActor({ address: 'actor-1' }); expect(result.flagged).toBe(false); }); + it.each(RULES)('uses a strict boundary for $ruleType', async (rule) => { + const activityRepository = makeRepository(rule.threshold - 1, rule.category); + const assessActor = createAssessActorUseCase({ + activityRepository, + clock: createFixedClock(now), + }); + const belowThreshold = await assessActor({ address: 'actor-1' }); + expect(belowThreshold.flagged).toBe(false); + expect(belowThreshold.signals.find((signal) => signal.ruleType === rule.ruleType)).toEqual({ + ...rule, + count: rule.threshold - 1, + triggered: false, + }); + activityRepository.seed('actor-1', rule.category, now); + const atThreshold = await assessActor({ address: 'actor-1' }); + expect(atThreshold.flagged).toBe(false); + expect(atThreshold.signals.find((signal) => signal.ruleType === rule.ruleType)).toEqual({ + ...rule, + count: rule.threshold, + triggered: false, + }); + activityRepository.seed('actor-1', rule.category, now); + const aboveThreshold = await assessActor({ address: 'actor-1' }); + expect(aboveThreshold.flagged).toBe(true); + expect(aboveThreshold.signals.find((signal) => signal.ruleType === rule.ruleType)).toEqual({ + ...rule, + count: rule.threshold + 1, + triggered: true, + }); + }); + + it('queries each default category, address and window with a threshold-plus-one limit', async () => { + const activityRepository = makeRepository(20); + const countSince = vi.spyOn(activityRepository, 'countSince'); + const record = vi.spyOn(activityRepository, 'record'); + const deleteOlderThan = vi.spyOn(activityRepository, 'deleteOlderThan'); + const assessActor = createAssessActorUseCase({ + activityRepository, + clock: createFixedClock(now), + }); + const result = await assessActor({ address: 'actor-1' }); + expect(countSince).toHaveBeenCalledTimes(3); + for (const rule of RULES) { + expect(countSince).toHaveBeenCalledWith( + 'actor-1', + rule.category, + new Date(now.getTime() - rule.windowHours * 60 * 60 * 1000), + rule.threshold + 1, + ); + } + expect(result.signals[0]).toMatchObject({ count: 11, triggered: true }); + expect(record).not.toHaveBeenCalled(); + expect(deleteOlderThan).not.toHaveBeenCalled(); + }); + + it('honors a custom window and excludes other actors and categories', async () => { + const activityRepository = makeRepository(0); + const since = new Date(now.getTime() - 2 * 60 * 60 * 1000); + activityRepository.seed('actor-1', 'ESCROW_RELEASED', since); + activityRepository.seed('actor-1', 'ESCROW_RELEASED', now); + activityRepository.seed('actor-1', 'ESCROW_RELEASED', new Date(since.getTime() - 1)); + activityRepository.seed('other-actor', 'ESCROW_RELEASED', now); + activityRepository.seed('actor-1', 'DELIVERY_CREATED', now); + const countSince = vi.spyOn(activityRepository, 'countSince'); + const rule: RuleDefinition = { + ruleType: 'ESCROW_RELEASE_VELOCITY', + category: 'ESCROW_RELEASED', + windowHours: 2, + threshold: 1, + }; + const assessActor = createAssessActorUseCase({ + activityRepository, + clock: createFixedClock(now), + ruleDefinitions: [rule], + }); + const result = await assessActor({ address: 'actor-1' }); + expect(countSince).toHaveBeenCalledExactlyOnceWith('actor-1', 'ESCROW_RELEASED', since, 2); + expect(result).toEqual({ + address: 'actor-1', + flagged: true, + signals: [{ ...rule, count: 2, triggered: true }], + }); + }); + it('exposes the default RULES with the documented thresholds', () => { - const byName = Object.fromEntries(RULES.map((rule) => [rule.name, rule])); + const byName = Object.fromEntries(RULES.map((rule) => [rule.ruleType, rule])); + + expect(byName.DELIVERY_CREATION_VELOCITY).toMatchObject({ + windowHours: 1, + threshold: 10, + }); + expect(byName.ESCROW_RELEASE_VELOCITY).toMatchObject({ + windowHours: 1, + threshold: 10, + }); + expect(byName.DISPUTE_RAISE_VELOCITY).toMatchObject({ + windowHours: 24, + threshold: 3, + }); + }); + + it('returns not-flagged, zero counts for an actor with no activity', async () => { + const activityRepository = createInMemoryActorActivityRepository(); + const assessActor = createAssessActorUseCase({ activityRepository }); + + const result = await assessActor({ address: 'GNEW' }); + + expect(result.flagged).toBe(false); + expect(result.signals).toHaveLength(3); + expect(result.signals.every((s) => s.count === 0 && !s.triggered)).toBe(true); + }); + + it('flags DELIVERY_CREATION_VELOCITY once the count exceeds the threshold within the window', async () => { + const activityRepository = createInMemoryActorActivityRepository(); + for (let i = 0; i < 11; i += 1) { + activityRepository.seed('GBOT', 'DELIVERY_CREATED', new Date()); + } + const assessActor = createAssessActorUseCase({ activityRepository }); + + const result = await assessActor({ address: 'GBOT' }); + + const signal = result.signals.find((s) => s.ruleType === 'DELIVERY_CREATION_VELOCITY'); + expect(signal).toMatchObject({ count: 11, threshold: 10, triggered: true }); + expect(result.flagged).toBe(true); + }); + + it('does not count activity outside the rule window', async () => { + const activityRepository = createInMemoryActorActivityRepository(); + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + for (let i = 0; i < 20; i += 1) { + activityRepository.seed('GOLDACTIVITY', 'DELIVERY_CREATED', twoHoursAgo); + } + const assessActor = createAssessActorUseCase({ activityRepository }); + + const result = await assessActor({ address: 'GOLDACTIVITY' }); + + const signal = result.signals.find((s) => s.ruleType === 'DELIVERY_CREATION_VELOCITY'); + expect(signal).toMatchObject({ count: 0, triggered: false }); + }); + + it('flags DISPUTE_RAISE_VELOCITY on its own, wider 24h window and lower threshold', async () => { + const activityRepository = createInMemoryActorActivityRepository(); + for (let i = 0; i < 4; i += 1) { + activityRepository.seed('GSCAMMER', 'DISPUTE_RAISED', new Date()); + } + const assessActor = createAssessActorUseCase({ activityRepository }); const result = await assessActor({ address: 'GSCAMMER' }); @@ -79,37 +234,61 @@ import { createFixedClock, createInMemoryActorActivityRepository } from './__fix }); describe('alerting', () => { - it('dispatches an actor_flagged alert with the triggered signals when an actor is flagged', async () => { + it('dispatches the full assessment through the alert callback when an actor is flagged', async () => { const activityRepository = createInMemoryActorActivityRepository(); for (let i = 0; i < 11; i += 1) { activityRepository.seed('GBOTALERT', 'DELIVERY_CREATED', new Date()); } - const alertPublisher = { publish: vi.fn().mockResolvedValue(undefined) }; + const alertPublisher = vi + .fn<(assessment: ActorAssessment) => Promise>() + .mockResolvedValue(undefined); const assessActor = createAssessActorUseCase({ activityRepository, alertPublisher }); const result = await assessActor({ address: 'GBOTALERT' }); expect(result.flagged).toBe(true); - expect(alertPublisher.publish).toHaveBeenCalledTimes(1); - const event = alertPublisher.publish.mock.calls[0][0]; - expect(event).toMatchObject({ type: 'actor_flagged', address: 'GBOTALERT' }); + expect(alertPublisher).toHaveBeenCalledTimes(1); + const event = alertPublisher.mock.calls[0]![0]; + expect(alertPublisher).toHaveBeenCalledWith(result); + expect(event).toMatchObject({ address: 'GBOTALERT' }); expect(event.signals).toEqual( expect.arrayContaining([ expect.objectContaining({ ruleType: 'DELIVERY_CREATION_VELOCITY', triggered: true }), ]), ); - expect(event.signals.every((s: { triggered: boolean }) => s.triggered)).toBe(true); + expect(event.signals).toEqual(result.signals); + expect(event.signals).toHaveLength(3); + expect(event.signals.filter((signal) => !signal.triggered)).toEqual([ + { + ruleType: 'ESCROW_RELEASE_VELOCITY', + category: 'ESCROW_RELEASED', + windowHours: 1, + threshold: 10, + count: 0, + triggered: false, + }, + { + ruleType: 'DISPUTE_RAISE_VELOCITY', + category: 'DISPUTE_RAISED', + windowHours: 24, + threshold: 3, + count: 0, + triggered: false, + }, + ]); }); it('does not dispatch an alert when the actor is not flagged', async () => { const activityRepository = createInMemoryActorActivityRepository(); - const alertPublisher = { publish: vi.fn().mockResolvedValue(undefined) }; + const alertPublisher = vi + .fn<(assessment: ActorAssessment) => Promise>() + .mockResolvedValue(undefined); const assessActor = createAssessActorUseCase({ activityRepository, alertPublisher }); const result = await assessActor({ address: 'GCLEAN' }); expect(result.flagged).toBe(false); - expect(alertPublisher.publish).not.toHaveBeenCalled(); + expect(alertPublisher).not.toHaveBeenCalled(); }); }); @@ -134,13 +313,22 @@ import { createFixedClock, createInMemoryActorActivityRepository } from './__fix const signal = result.signals.find((s) => s.ruleType === 'DELIVERY_CREATION_VELOCITY'); expect(signal).toMatchObject({ count: 11, triggered: true }); }); - expect(byName.ESCROW_RELEASE_VELOCITY).toMatchObject({ - windowHours: 1, - threshold: 10, - }); - expect(byName.DISPUTE_RAISE_VELOCITY).toMatchObject({ - windowHours: 24, - threshold: 3, + + it('without a lag-aware clock, comparing lagged occurredAt values against real wall-clock time would incorrectly exclude them', async () => { + const activityRepository = createInMemoryActorActivityRepository(); + // Real "now" is far ahead of the on-chain occurredAt values because + // the indexer has fallen behind — with the default systemClock (no + // clock injected), this activity falls outside every rule window. + const longAgo = new Date(Date.now() - 5 * 60 * 60 * 1000); + for (let i = 0; i < 11; i += 1) { + activityRepository.seed('GSTALE', 'DELIVERY_CREATED', longAgo); + } + const assessActor = createAssessActorUseCase({ activityRepository }); + + const result = await assessActor({ address: 'GSTALE' }); + + const signal = result.signals.find((s) => s.ruleType === 'DELIVERY_CREATION_VELOCITY'); + expect(signal).toMatchObject({ count: 0, triggered: false }); }); }); }); diff --git a/src/modules/fraud-detection/application/assess-actor.ts b/src/modules/fraud-detection/application/assess-actor.ts index fcd4677..1ad02bf 100644 --- a/src/modules/fraud-detection/application/assess-actor.ts +++ b/src/modules/fraud-detection/application/assess-actor.ts @@ -18,6 +18,8 @@ export interface AssessActorDeps { * the `occurredAt` values they're compared against, immune to indexer lag. */ clock?: Clock; + /** Optional environment-configured rules; omitted callers retain RULES. */ + ruleDefinitions?: RuleDefinition[]; /** * Optional alert publisher invoked whenever an assessment flags an actor. * Receives the full {@link ActorAssessment} (address + per-rule signals) so diff --git a/src/modules/fraud-detection/fraud-detection.module.ts b/src/modules/fraud-detection/fraud-detection.module.ts deleted file mode 100644 index 490c8e2..0000000 --- a/src/modules/fraud-detection/fraud-detection.module.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createAssessActorUseCase } from './application/assess-actor'; -import { createFraudDetectionRouter } from './infrastructure/fraud-detection.router'; -import { PrismaActorActivityRepository } from './infrastructure/prisma-actor-activity-repository'; -import type { ActorActivityRepository } from './domain/ports'; -import type { EventBus } from '../../../shared/events'; -import type { NotificationPort } from '../../notifications/domain/ports'; - -export interface FraudDetectionModuleDeps { - actorActivityRepository?: ActorActivityRepository; - eventBus?: EventBus; - notifications?: NotificationPort; -} - -export function createFraudDetectionModule(deps: FraudDetectionModuleDeps = {}) { - const actorActivityRepository = - deps.actorActivityRepository ?? new PrismaActorActivityRepository(); - - const assessActor = createAssessActorUseCase({ - actorActivityRepository, - eventBus: deps.eventBus, - notifications: deps.notifications, - }); - - const router = createFraudDetectionRouter({ assessActor }); - - return { - assessActor, - router, - }; -} diff --git a/src/modules/fraud-detection/index.ts b/src/modules/fraud-detection/index.ts index 10b920c..24a81db 100644 --- a/src/modules/fraud-detection/index.ts +++ b/src/modules/fraud-detection/index.ts @@ -13,7 +13,7 @@ import { subscribeFraudDetectionEventDispatch, } from './infrastructure/index.js'; import { createFraudDetectionRoutes } from './interface/routes.js'; -import { env } from '../../shared/config/env.js'; +import { getConfig } from '../../shared/config/index.js'; /** * Unlike `notifications`, this module writes synchronously in its event @@ -26,6 +26,7 @@ import { env } from '../../shared/config/env.js'; * subscription to actually fire — see that file's header comment). */ export function createFraudDetectionModule(prisma: PrismaClient): FastifyPluginAsyncZod { + const config = getConfig(); const activityRepository = createPrismaActorActivityRepository(prisma); const recordActorActivityFromEvent = createRecordActorActivityFromEventUseCase({ @@ -37,7 +38,26 @@ export function createFraudDetectionModule(prisma: PrismaClient): FastifyPluginA assessActor: createAssessActorUseCase({ activityRepository, clock: createLedgerClock(prisma), - ruleDefinitions: env.fraudRules, + ruleDefinitions: [ + { + ruleType: 'DELIVERY_CREATION_VELOCITY', + category: 'DELIVERY_CREATED', + threshold: config.FRAUD_DELIVERY_VELOCITY_THRESHOLD, + windowHours: config.FRAUD_DELIVERY_VELOCITY_WINDOW_HOURS, + }, + { + ruleType: 'ESCROW_RELEASE_VELOCITY', + category: 'ESCROW_RELEASED', + threshold: config.FRAUD_ESCROW_VELOCITY_THRESHOLD, + windowHours: config.FRAUD_ESCROW_VELOCITY_WINDOW_HOURS, + }, + { + ruleType: 'DISPUTE_RAISE_VELOCITY', + category: 'DISPUTE_RAISED', + threshold: config.FRAUD_DISPUTE_VELOCITY_THRESHOLD, + windowHours: config.FRAUD_DISPUTE_VELOCITY_WINDOW_HOURS, + }, + ], }), }; diff --git a/src/modules/fraud-detection/infrastructure/ledger-clock.spec.ts b/src/modules/fraud-detection/infrastructure/ledger-clock.spec.ts index 565a212..967c129 100644 --- a/src/modules/fraud-detection/infrastructure/ledger-clock.spec.ts +++ b/src/modules/fraud-detection/infrastructure/ledger-clock.spec.ts @@ -1,62 +1,65 @@ import { PrismaClient } from '@prisma/client'; -import { createLedgerClock } from './ledger-clock'; +import type { BlockchainEvent } from '@prisma/client'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createLedgerClock } from './ledger-clock.js'; + +function eventAt(ledgerClosedAt: Date): BlockchainEvent { + return { + id: 'fixture', + contractName: 'delivery', + network: 'testnet', + rpcEventId: 'event', + ledgerSeq: 1n, + txHash: 'hash', + topic: [], + payload: [], + ledgerClosedAt, + ingestedAt: ledgerClosedAt, + processedAt: null, + }; +} describe('createLedgerClock', () => { - let findFirst: jest.Mock; - let prisma: PrismaClient; - - beforeEach(() => { - findFirst = jest.fn(); - prisma = { - blockchainEvent: { findFirst }, - } as unknown as PrismaClient; - }); - - afterEach(() => { - jest.useRealTimers(); + const prisma = new PrismaClient(); + const findFirst = vi.spyOn(prisma.blockchainEvent, 'findFirst'); + beforeEach(() => findFirst.mockReset()); + afterEach(() => vi.useRealTimers()); + afterAll(async () => { + await prisma.$disconnect(); + vi.restoreAllMocks(); }); it('caches the latest ledger time across repeated now() calls', async () => { const ledgerClosedAt = new Date('2024-01-01T00:00:00.000Z'); - findFirst.mockResolvedValue({ ledgerClosedAt }); - + findFirst.mockResolvedValue(eventAt(ledgerClosedAt)); const clock = createLedgerClock(prisma); - for (let i = 0; i < 10; i += 1) { await expect(clock.now()).resolves.toEqual(ledgerClosedAt); } - expect(findFirst).toHaveBeenCalledTimes(1); }); it('refreshes the cache after the 5 second TTL elapses', async () => { - jest.useFakeTimers(); - + vi.useFakeTimers(); const firstLedgerClosedAt = new Date('2024-01-01T00:00:00.000Z'); const secondLedgerClosedAt = new Date('2024-01-01T00:00:10.000Z'); findFirst - .mockResolvedValueOnce({ ledgerClosedAt: firstLedgerClosedAt }) - .mockResolvedValueOnce({ ledgerClosedAt: secondLedgerClosedAt }); - + .mockResolvedValueOnce(eventAt(firstLedgerClosedAt)) + .mockResolvedValueOnce(eventAt(secondLedgerClosedAt)); const clock = createLedgerClock(prisma); - await expect(clock.now()).resolves.toEqual(firstLedgerClosedAt); expect(findFirst).toHaveBeenCalledTimes(1); - - jest.advanceTimersByTime(5_000); - + vi.advanceTimersByTime(5_000); await expect(clock.now()).resolves.toEqual(secondLedgerClosedAt); expect(findFirst).toHaveBeenCalledTimes(2); }); it('falls back to the current time when no blockchain event exists', async () => { findFirst.mockResolvedValue(null); - const clock = createLedgerClock(prisma); const before = Date.now(); const result = await clock.now(); const after = Date.now(); - expect(result.getTime()).toBeGreaterThanOrEqual(before); expect(result.getTime()).toBeLessThanOrEqual(after); expect(findFirst).toHaveBeenCalledTimes(1); diff --git a/src/modules/fraud-detection/infrastructure/ledger-clock.ts b/src/modules/fraud-detection/infrastructure/ledger-clock.ts index 5993fd2..f12079a 100644 --- a/src/modules/fraud-detection/infrastructure/ledger-clock.ts +++ b/src/modules/fraud-detection/infrastructure/ledger-clock.ts @@ -1,5 +1,5 @@ -import { PrismaClient } from '@prisma/client'; -import { Clock } from '../domain/clock'; +import type { PrismaClient } from '@prisma/client'; +import type { Clock } from '../domain/clock.js'; const LEDGER_CACHE_TTL_MS = 5_000; diff --git a/src/modules/fraud-detection/infrastructure/prisma-actor-activity-repository.ts b/src/modules/fraud-detection/infrastructure/prisma-actor-activity-repository.ts index d31adbf..85b581b 100644 --- a/src/modules/fraud-detection/infrastructure/prisma-actor-activity-repository.ts +++ b/src/modules/fraud-detection/infrastructure/prisma-actor-activity-repository.ts @@ -1,26 +1,11 @@ -import type { PrismaClient, ActorActivity, ActorActivityCategory } from '@prisma/client'; -import type { ActorActivityRepository } from '../domain/ports'; +import type { PrismaClient, ActorActivityCategory } from '@prisma/client'; +import type { ActorActivityRepository } from '../domain/ports.js'; -export function createPrismaActorActivityRepository( - prisma: PrismaClient, -): ActorActivityRepository { +export function createPrismaActorActivityRepository(prisma: PrismaClient): ActorActivityRepository { return { - async record(activity: ActorActivity): Promise { - return prisma.actorActivity.create({ data: activity }); - }, - - async findByAddress( - address: string, - category?: ActorActivityCategory, - since?: Date, - ): Promise { - return prisma.actorActivity.findMany({ - where: { - address, - ...(category ? { category } : {}), - ...(since ? { occurredAt: { gte: since } } : {}), - }, - orderBy: { occurredAt: 'desc' }, + async record(input) { + await prisma.actorActivity.create({ + data: { address: input.address, category: input.category, occurredAt: input.occurredAt }, }); }, diff --git a/src/modules/indexer/interface/routes.ts b/src/modules/indexer/interface/routes.ts index c34bfdf..de8c4b5 100644 --- a/src/modules/indexer/interface/routes.ts +++ b/src/modules/indexer/interface/routes.ts @@ -1,5 +1,6 @@ import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import type { createGetIndexerHealthUseCase, TrackedContract } from '../application/index.js'; +import { workerExecutionStatus } from '../../../shared/queue/worker-execution.js'; import { indexerHealthResponseSchema } from './schemas.js'; export interface IndexerRoutesDeps { @@ -31,12 +32,13 @@ export function createIndexerHealthRoutes(deps: IndexerRoutesDeps): FastifyPlugi }); const body = { - status: result.healthy ? ('ok' as const) : ('degraded' as const), + status: 'degraded' as const, + workerExecution: { ...workerExecutionStatus, jobs: [...workerExecutionStatus.jobs] }, latestLedger: result.latestLedger, - contracts: result.contracts, + contracts: result.contracts.map((contract) => ({ ...contract, healthy: false })), }; - void reply.status(result.healthy ? 200 : 503).send(body); + void reply.status(503).send(body); }, ); }; diff --git a/src/modules/indexer/interface/schemas.spec.ts b/src/modules/indexer/interface/schemas.spec.ts index 8e83ee2..cc62981 100644 --- a/src/modules/indexer/interface/schemas.spec.ts +++ b/src/modules/indexer/interface/schemas.spec.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest'; +import { workerExecutionStatus } from '../../../shared/queue/worker-execution.js'; import { indexerHealthResponseSchema } from './schemas.js'; describe('indexerHealthResponseSchema', () => { it('limits contract names to 255 characters', () => { const response = { - status: 'ok', + status: 'degraded', + workerExecution: workerExecutionStatus, latestLedger: 1, contracts: [ { @@ -12,7 +14,7 @@ describe('indexerHealthResponseSchema', () => { configured: true, lastLedgerSeq: '1', lagLedgers: 0, - healthy: true, + healthy: false, }, ], }; diff --git a/src/modules/indexer/interface/schemas.ts b/src/modules/indexer/interface/schemas.ts index 46cb163..c3eb82c 100644 --- a/src/modules/indexer/interface/schemas.ts +++ b/src/modules/indexer/interface/schemas.ts @@ -12,6 +12,12 @@ const contractHealthSchema = z.object({ export const indexerHealthResponseSchema = z.object({ status: z.enum(['ok', 'degraded']), + workerExecution: z.object({ + status: z.literal('blocked'), + jobs: z.array( + z.object({ queue: z.string(), status: z.literal('blocked'), reason: z.string() }), + ), + }), latestLedger: z.number(), contracts: z.array(contractHealthSchema), }); diff --git a/src/modules/indexer/interface/worker-status.spec.ts b/src/modules/indexer/interface/worker-status.spec.ts new file mode 100644 index 0000000..193e5e8 --- /dev/null +++ b/src/modules/indexer/interface/worker-status.spec.ts @@ -0,0 +1,51 @@ +import Fastify from 'fastify'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; +import { expect, it } from 'vitest'; +import { createIndexerHealthRoutes } from './routes.js'; +import { workerExecutionStatus } from '../../../shared/queue/worker-execution.js'; + +it('reports indexer unavailable even when the lag snapshot would otherwise be healthy', async () => { + const app = Fastify(); + app.setValidatorCompiler(validatorCompiler); + app.setSerializerCompiler(serializerCompiler); + try { + await app.register( + createIndexerHealthRoutes({ + network: 'testnet', + trackedContracts: [], + lagAlertThreshold: 100, + getIndexerHealth: async () => ({ + latestLedger: 123, + healthy: true, + contracts: [ + { + contractName: 'escrow', + configured: true, + lastLedgerSeq: '123', + lagLedgers: 0, + healthy: true, + }, + ], + }), + }), + ); + const response = await app.inject({ url: '/health/indexer' }); + expect(response.statusCode).toBe(503); + expect(response.json()).toEqual({ + status: 'degraded', + latestLedger: 123, + workerExecution: workerExecutionStatus, + contracts: [ + { + contractName: 'escrow', + configured: true, + lastLedgerSeq: '123', + lagLedgers: 0, + healthy: false, + }, + ], + }); + } finally { + await app.close(); + } +}); diff --git a/src/modules/notifications/infrastructure/smtp-notification-sender.ts b/src/modules/notifications/infrastructure/smtp-notification-sender.ts index b75fab9..5e829b8 100644 --- a/src/modules/notifications/infrastructure/smtp-notification-sender.ts +++ b/src/modules/notifications/infrastructure/smtp-notification-sender.ts @@ -1,21 +1,23 @@ -import type { NotificationSender } from '../domain/notification-sender'; -import { createSmtpClient, type SmtpClient } from '../../../shared/smtp/smtp-client'; +import { createSmtpClient, type SmtpSettings } from '../../../shared/smtp/index.js'; +import type { NotificationSender } from '../domain/index.js'; /** - * SMTP-backed NotificationSender. Delivers notifications as plain-text (and, - * when the payload carries HTML, multipart) email through the shared SMTP - * relay configured by the SMTP_* environment variables. Transport errors are - * surfaced to the caller so the notification pipeline can retry or dead-letter - * rather than silently dropping mail. + * Production-capable `NotificationSender` for the `smtp`/`sendgrid`/`ses` + * providers — the notifications-module counterpart of auth's + * `smtp-mailer.ts`, delivering over the same SMTP relay (all three providers + * expose one). Selected by `select-notification-sender.ts`; a send failure + * rejects so `sendNotification` maps it to a `FAILED` row and BullMQ's + * retry/backoff takes over (src/modules/notifications/application/ + * send-notification.ts). */ -export function createSmtpNotificationSender(client: SmtpClient = createSmtpClient()): NotificationSender { +export function createSmtpNotificationSender(settings: SmtpSettings): NotificationSender { + const client = createSmtpClient(settings); return { - async send(notification) { - await client.sendMail({ - to: notification.recipient, - subject: notification.subject, - text: notification.body, - ...(notification.html ? { html: notification.html } : {}), + async send({ to, type, payload }) { + await client.send({ + to, + subject: `FaniLab notification: ${type}`, + text: `${type}\n\n${JSON.stringify(payload, null, 2)}\n`, }); }, }; diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts index 8c363ab..28285be 100644 --- a/src/shared/config/env.ts +++ b/src/shared/config/env.ts @@ -169,8 +169,8 @@ const baseEnvSchema = z.object({ * mirror the previously hardcoded RULES constants so behaviour is * unchanged when nothing is configured; operators can tune them per * environment (local dev, testnet staging, high-traffic production) - * without a rebuild. Thresholds are the number of matching activities - * within the window that trigger a flag; windows are in hours. */ + * without a rebuild. A rule triggers when the number of matching activities + * within the window exceeds its threshold; windows are in hours. */ FRAUD_DELIVERY_VELOCITY_THRESHOLD: z.coerce.number().int().positive().default(10), FRAUD_DELIVERY_VELOCITY_WINDOW_HOURS: z.coerce.number().int().positive().default(1), FRAUD_ESCROW_VELOCITY_THRESHOLD: z.coerce.number().int().positive().default(10), diff --git a/src/shared/events/index.ts b/src/shared/events/index.ts index f915d12..afa19c1 100644 --- a/src/shared/events/index.ts +++ b/src/shared/events/index.ts @@ -1,3 +1,249 @@ +import { EventEmitter } from 'node:events'; +import { eventSubscriberErrorsTotal } from '../metrics/index.js'; +import { logger } from '../logger/index.js'; +import type { Logger } from '../logger/index.js'; + +export { parseAddress, parseBigIntId } from './parse.js'; +export { ContractName } from './contract-names.js'; + +/** + * Every durably-stored blockchain event, after decoding, in one shape — + * module handlers (deliveries, escrow, disputes, ...) subscribe here once + * they exist. Deliberately in-process only for v1, not a distributed bus + * (ARCHITECTURE.md §11) — if the indexer ever needs to run as more than one + * instance, this is the seam to swap for Redis Streams or similar. + */ +export interface BlockchainEventEnvelope { + contractName: string; + network: string; + rpcEventId: string; + ledgerSeq: bigint; + txHash: string; + topic: string[]; + payload: unknown; + /** On-chain ledger close time — use this, not `new Date()`, when a + * handler needs an on-chain timestamp. */ + closedAt: Date; +} + +const CHANNEL = 'blockchain-event'; + +const bus = new EventEmitter(); +// Generous default — every future module that consumes blockchain events +// (deliveries, escrow, disputes, reputation, fleet, notifications, +// fraud-detection per ARCHITECTURE.md §10) subscribes on this one channel. +bus.setMaxListeners(50); + +/** + * Upper bound on subscriber executions in flight at any moment, process-wide + * (issue #349). Node's EventEmitter.emit is synchronous and never awaits + * async listeners, so the previous fire-and-forget publish let event + * replay / post-downtime catch-up fire hundreds of database writes in + * parallel at once — swamping the PostgreSQL pool and process memory. + * Every subscriber run (across all publishes) now goes through one shared + * queue that admits at most this many concurrent tasks. No new dependency: + * the pool is a few dozen lines and the semantics above are all we need. + * + * Reentrancy caveat: a subscriber must not itself `await` a + * `publishBlockchainEvent` call (none do — only the indexer's poll loop and + * tests publish), since a task holding a slot while waiting for a queued one + * could stall the queue. + */ +export const EVENT_DISPATCH_CONCURRENCY = 10; + +type SubscriberTask = () => void | Promise; + +interface QueuedDispatch { + event: BlockchainEventEnvelope; + task: SubscriberTask; + settle: () => void; +} + +let activeDispatches = 0; +const pendingDispatches: QueuedDispatch[] = []; +let drainingQueue = false; + +function isPromiseLike(value: void | Promise): value is Promise { + return ( + value !== null && value !== undefined && typeof (value as Promise).then === 'function' + ); +} + +function releaseSlot(): void { + activeDispatches -= 1; + drainPending(); +} + +/** Start queued tasks while capacity allows. A task that completes + * synchronously frees its slot immediately (so its successor can start in + * the same tick, preserving delivery order for synchronous listeners); one + * that returns a promise holds the slot until it settles, and its completion + * re-enters this drain. The flag makes the sync path's nested + * `releaseSlot()` a no-op so the loop — not recursion — drives the drain. */ +function drainPending(): void { + if (drainingQueue) return; + drainingQueue = true; + try { + while (activeDispatches < EVENT_DISPATCH_CONCURRENCY && pendingDispatches.length > 0) { + const next = pendingDispatches.shift(); + if (!next) return; + activeDispatches += 1; + const pending = runTask(next.event, next.task); + if (pending) { + void pending.then(next.settle); + return; // async task — wait for its completion to free the slot + } + next.settle(); + } + } finally { + drainingQueue = false; + } +} + +/** Run one subscriber. Returns `null` when the task finished synchronously + * (slot already released), or a promise that settles — after releasing the + * slot — when it went async. The returned promise never rejects: a subscriber + * failure is recorded (module handlers are wrapped by + * `subscribeBlockchainEventHandler`, which counts and logs with its own + * subscriber label; a raw listener's throw/rejection lands here as a + * last-resort counter) and must not block or fail the publish. */ +function runTask(event: BlockchainEventEnvelope, task: SubscriberTask): Promise | null { + let result: void | Promise; + try { + result = task(); + } catch (error: unknown) { + recordUnhandledFailure(event, error); + releaseSlot(); + return null; + } + if (isPromiseLike(result)) { + return result.then( + () => { + releaseSlot(); + }, + (error: unknown) => { + recordUnhandledFailure(event, error); + releaseSlot(); + }, + ); + } + releaseSlot(); + return null; +} + +/** Last-resort visibility for failures escaping a raw `onBlockchainEvent` + * listener — without it the dispatcher would have to swallow them silently, + * the exact failure mode issue #348 is about. Module handlers are wrapped by + * `subscribeBlockchainEventHandler`, which counts and logs with its own + * subscriber label before any error could reach here. */ +function recordUnhandledFailure(event: BlockchainEventEnvelope, error: unknown): void { + eventSubscriberErrorsTotal.inc({ + contract: event.contractName, + event: event.topic[0] ?? 'unknown', + subscriber: 'onBlockchainEvent', + }); + logger.error({ err: error, event }, 'Event subscriber listener failed'); +} + +function dispatch(event: BlockchainEventEnvelope, task: SubscriberTask): Promise { + if (activeDispatches < EVENT_DISPATCH_CONCURRENCY) { + activeDispatches += 1; + const pending = runTask(event, task); + if (pending) return pending; + return Promise.resolve(); + } + return new Promise((resolve) => { + pendingDispatches.push({ event, task, settle: resolve }); + }); +} + +/** + * Publish one event to every current subscriber and resolve once **all** of + * their executions have finished — with bounded concurrency + * ({@link EVENT_DISPATCH_CONCURRENCY}), never an unbounded fan-out of + * fire-and-forget promises. + * + * Synchronous subscribers are still invoked before this returns (the fast + * path starts a task immediately when a slot is free), so existing + * "publish, then assert" call sites keep working. Async subscribers start + * immediately too, up to the concurrency cap; the remainder are queued. + * + * The indexer's poll loop awaits this before advancing its checkpoint + * (`poll-contract-events.ts`), so batch N is fully handled before batch + * N+1 is ingested. Never rejects — subscriber failures are logged and + * counted (`event_subscriber_errors_total`), not rethrown at the publisher. + */ +export function publishBlockchainEvent(event: BlockchainEventEnvelope): Promise { + const listeners = bus.listeners(CHANNEL) as Array< + (event: BlockchainEventEnvelope) => void | Promise + >; + if (listeners.length === 0) return Promise.resolve(); + return Promise.all(listeners.map((listener) => dispatch(event, () => listener(event)))).then( + () => undefined, + ); +} + +/** Returns an unsubscribe function — call it on module/test teardown. The + * handler may be async; its returned promise is awaited by the dispatch + * (bounded concurrency, see {@link publishBlockchainEvent}). */ +export function onBlockchainEvent( + handler: (event: BlockchainEventEnvelope) => void | Promise, +): () => void { + bus.on(CHANNEL, handler); + return () => { + bus.off(CHANNEL, handler); + }; +} + +/** + * The six-line subscription boilerplate every module's + * infrastructure/event-subscription.ts used to repeat: subscribe to the bus, + * invoke the module's async handler, and catch the rejection. + * The catch must live here rather than becoming an unhandled promise + * rejection — one malformed/unexpected event must not crash the process or + * block subsequent events (docs/EVENT_INDEXER.md's malformed-event handling). + * Besides logging, the catch block increments + * `event_subscriber_errors_total` — without that counter a failing handler + * silently let the read model drift from on-chain state with nothing for + * Prometheus/Grafana to alert on (docs/OBSERVABILITY.md § Metrics). + * + * The callback hands the caught promise back to the dispatch, so + * `publishBlockchainEvent` — and with it the indexer's checkpoint — waits + * for this handler (success *or* failure) to finish. A synchronous throw + * from a non-async handler is recorded here too rather than escaping to the + * dispatcher's generic path, so the metric keeps this subscriber's label. + */ +export function subscribeBlockchainEventHandler( + handler: (event: BlockchainEventEnvelope) => Promise, + log: Logger, + errorMessage: string, +): () => void { + const recordFailure = (event: BlockchainEventEnvelope, error: unknown): void => { + eventSubscriberErrorsTotal.inc({ + contract: event.contractName, + event: event.topic[0] ?? 'unknown', + subscriber: errorMessage, + }); + log.error({ err: error, event }, errorMessage); + }; + + return onBlockchainEvent((event) => { + let result: Promise; + try { + result = handler(event); + } catch (error: unknown) { + recordFailure(event, error); + return; + } + return result.then( + () => undefined, + (error: unknown) => { + recordFailure(event, error); + }, + ); + }); +} + export interface DomainEvent { type: TType; payload: TPayload; diff --git a/src/shared/http/plugins/auth-guard.spec.ts b/src/shared/http/plugins/auth-guard.spec.ts index a77703f..579de50 100644 --- a/src/shared/http/plugins/auth-guard.spec.ts +++ b/src/shared/http/plugins/auth-guard.spec.ts @@ -6,19 +6,8 @@ import { getPrismaClient } from '../../database/index.js'; import { UnauthorizedError } from '../../errors/index.js'; import type * as AuthCacheModule from '../../cache/index.js'; -/** - * Unit coverage for `authenticate`'s DB-backed `tokenVersion` check - * (security issue #12) — stubs the shared Prisma singleton's `user.findUnique` - * directly (same instance `authenticate` itself calls, since - * `getPrismaClient()` is memoized) rather than mocking the whole module, so - * this exercises the real guard logic without a live database. - * - * The Redis-backed auth context cache (#369) is likewise replaced at the - * same singleton boundary `authenticate` consumes it through — an - * in-memory map behind the same two functions — so the guard's real - * cache-interaction logic (miss → DB read → populate, hit → no DB read, - * eviction → fresh read) runs without a live Redis. - */ +/** Guard tests keep a deliberately stale shared cache beside the real guard; + * the database lookup must remain authoritative on every request. */ const cacheEntries = vi.hoisted( () => new Map< @@ -135,7 +124,7 @@ describe('authenticate — tokenVersion check', () => { }); describe('authenticate — auth context cache (#369)', () => { - it('serves the second request from the cache — one database read for two requests', async () => { + it('checks the database on every request even when a cache entry exists', async () => { const spy = vi.spyOn(getPrismaClient().user, 'findUnique').mockResolvedValue({ role: 'COURIER', tokenVersion: 1, @@ -146,7 +135,7 @@ describe('authenticate — auth context cache (#369)', () => { const second = fakeRequest(`Bearer ${token}`); await authenticate(second, fakeReply); - expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(2); expect(second.user).toEqual({ id: 'user-cache', role: 'COURIER' }); }); @@ -169,12 +158,12 @@ describe('authenticate — auth context cache (#369)', () => { expect(spy).toHaveBeenCalledTimes(2); }); - it('trusts the cached role as much as the database one — never the token claim', async () => { + it('uses the current database role even if a stale cached role exists', async () => { vi.spyOn(getPrismaClient().user, 'findUnique').mockResolvedValue({ role: 'CUSTOMER', tokenVersion: 5, } as never); - // First request populates the cache from the DB read above. + cacheEntries.set('user-role', { role: 'ADMIN', tokenVersion: 5 }); await authenticate( fakeRequest( `Bearer ${signAccessToken({ sub: 'user-role', role: 'ADMIN', tokenVersion: 5 })}`, @@ -182,8 +171,7 @@ describe('authenticate — auth context cache (#369)', () => { fakeReply, ); - // Second request is served entirely from cache — still CUSTOMER, never - // the token's stale ADMIN claim. + // A stale cached role must never authorize the request. const request = fakeRequest( `Bearer ${signAccessToken({ sub: 'user-role', role: 'ADMIN', tokenVersion: 5 })}`, ); diff --git a/src/shared/http/plugins/auth-guard.ts b/src/shared/http/plugins/auth-guard.ts index 629f674..6b76569 100644 --- a/src/shared/http/plugins/auth-guard.ts +++ b/src/shared/http/plugins/auth-guard.ts @@ -3,7 +3,6 @@ import type { UserRole } from '@prisma/client'; import { verifyAccessToken } from '../../jwt/index.js'; import { UnauthorizedError, ForbiddenError } from '../../errors/index.js'; import { getPrismaClient } from '../../database/index.js'; -import { readAuthContext, writeAuthContext } from '../../cache/index.js'; declare module 'fastify' { interface FastifyRequest { @@ -11,41 +10,11 @@ declare module 'fastify' { } } -/** - * Route-level auth guard — attached per-route via `{ preHandler: authenticate }`, - * not globally, so public routes (register/login) stay public without an - * allow-list of exceptions to maintain. Verifies the bearer access token and - * attaches the claims to `request.user` for downstream handlers/guards. - * - * Access tokens are otherwise fully stateless (signature + expiry only, per - * docs/AUTHENTICATION.md) — this `users.token_version` check is the one - * deliberate exception, closing security issue #12: without it, an - * already-issued access token keeps granting its original role for the - * rest of its ~15-minute lifetime even after an admin changes that user's - * role, since the role is otherwise only ever read from the token's own - * (now-stale) claim. `updateUserRole` bumps `token_version` on every role - * change, so this rejects any token issued before that change on its very - * next request — not just at token expiry or next refresh. `role` is read - * fresh from this same lookup (not from the token's claim) as defense in - * depth, at no extra cost since the row is already being fetched. - * - * The `role`/`tokenVersion` pair is served from a short-TTL Redis cache - * first (#369, `shared/cache/auth-context-cache.ts`) so the check doesn't - * cost one `users` lookup per authenticated request under load. Two - * properties keep the #12 guarantee intact despite the cache: (1) role - * changes evict the entry the moment their transaction commits - * (`AuthCacheInvalidator`, wired in `src/modules/admin`) — the TTL is only - * a backstop for eviction failures, never the primary invalidation path; - * (2) every cache failure (outage, corrupt entry) degrades to the original - * per-request database lookup rather than skipping the check — an - * unreachable cache must never widen privileges or reject valid requests. - * - * Uses the shared Prisma singleton directly (matching the existing - * `shared/http/routes/health.ts` precedent) rather than threading a - * dependency through every one of this function's ~10 call sites — it's - * referenced by bare function identity (`preHandler: authenticate`) - * throughout the codebase, so changing its signature would mean touching - * every route file and module composition root for a single security check. +/** Authentication checks role and tokenVersion against the authoritative primary + * database on every request. Cached state cannot authorize a revoked credential. + * Requests whose DB check precedes a reset commit may already be in flight; + * checks after commit reject old tokens, across instances and Redis failures. + * Database errors fail closed. Do not point this lookup at a lagging replica. */ export const authGuard = { async authenticate(request: FastifyRequest, _reply: FastifyReply): Promise { @@ -63,17 +32,10 @@ export const authGuard = { throw new UnauthorizedError('Invalid or expired access token'); } - let currentUser = await readAuthContext(claims.sub); - if (!currentUser) { - const row = await getPrismaClient().user.findUnique({ + const currentUser = await getPrismaClient().user.findUnique({ where: { id: claims.sub }, select: { role: true, tokenVersion: true }, }); - if (row) { - currentUser = { role: row.role, tokenVersion: row.tokenVersion }; - await writeAuthContext(claims.sub, currentUser); - } - } // Tokens issued before `tokenVersion` existed carry no such claim — // treated as version 0 (the column's default) so they keep working diff --git a/src/shared/http/routes/health.ts b/src/shared/http/routes/health.ts index 0f9117b..7b17ec4 100644 --- a/src/shared/http/routes/health.ts +++ b/src/shared/http/routes/health.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify'; import { getPrismaClient } from '../../database/index.js'; import { getRedisClient } from '../../cache/index.js'; import { getQueueHealth, type QueueCounts } from '../../queue/index.js'; +import { workerExecutionStatus } from '../../queue/worker-execution.js'; import { withTimeout } from '../../shutdown/with-timeout.js'; interface HealthStatus { @@ -13,6 +14,7 @@ interface HealthStatus { interface QueueHealthStatus { status: 'ok' | 'degraded' | 'unavailable'; queues: QueueCounts[]; + workerExecution: typeof workerExecutionStatus; } /** @@ -46,7 +48,10 @@ export default async function healthRoutes(app: FastifyInstance): Promise 'database health check', ).catch(() => 'error' as const), withTimeout( - () => getRedisClient().ping().then(() => 'ok' as const), + () => + getRedisClient() + .ping() + .then(() => 'ok' as const), HEALTH_CHECK_TIMEOUT_MS, 'redis health check', ).catch(() => 'error' as const), @@ -68,7 +73,8 @@ export default async function healthRoutes(app: FastifyInstance): Promise * `failed` reflects BullMQ's full failure history (jobs are retained for * up to 7 days, see `removeOnFail` in `shared/queue/queues.ts`), so it * never clears on its own after a single transient failure. `degraded` - * (200) means "there is failure history worth looking at"; `unavailable` + * (200) also reports the fixed worker execution restriction, even when + * queues are empty; `unavailable` * (503) means the queue backend itself couldn't be reached, which is the * only case that should be treated as a hard failure by anything acting * on the status code. @@ -77,19 +83,30 @@ export default async function healthRoutes(app: FastifyInstance): Promise try { const queues = await getQueueHealth(); const stalled = queues.some(isQueueStalled); - const hasHistoricalFailures = queues.some((queue) => queue.failed > 0); if (stalled) { - const body: QueueHealthStatus = { status: 'unavailable', queues }; + const body: QueueHealthStatus = { + status: 'unavailable', + queues, + workerExecution: workerExecutionStatus, + }; void reply.status(503).send(body); return; } - const status: QueueHealthStatus['status'] = hasHistoricalFailures ? 'degraded' : 'ok'; - const body: QueueHealthStatus = { status, queues }; + // Empty queues do not mean jobs are operational while execution is blocked. + const body: QueueHealthStatus = { + status: 'degraded', + queues, + workerExecution: workerExecutionStatus, + }; void reply.status(200).send(body); } catch (error) { - const body: QueueHealthStatus = { status: 'unavailable', queues: [] }; + const body: QueueHealthStatus = { + status: 'unavailable', + queues: [], + workerExecution: workerExecutionStatus, + }; app.log.error({ err: error }, '[health/queue] failed to reach queue backend'); void reply.status(503).send(body); } diff --git a/src/shared/http/routes/observability-routes.integration.spec.ts b/src/shared/http/routes/observability-routes.integration.spec.ts index 5d660d1..d5f8bd7 100644 --- a/src/shared/http/routes/observability-routes.integration.spec.ts +++ b/src/shared/http/routes/observability-routes.integration.spec.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { workerExecutionStatus } from '../../queue/worker-execution.js'; import { buildApp } from '../../../app.js'; import { disconnectPrisma } from '../../database/index.js'; import { disconnectRedis } from '../../cache/index.js'; @@ -33,11 +34,13 @@ describe.skipIf(!dbAvailable)('observability routes (integration)', () => { expect(response.statusCode).toBe(200); const body = response.json<{ status: string; + workerExecution: typeof workerExecutionStatus; queues: Array<{ name: string; waiting: number; failed: number }>; }>(); - expect(body.status).toBe('ok'); + expect(body.status).toBe('degraded'); + expect(body.workerExecution).toEqual(workerExecutionStatus); expect(body.queues.map((q) => q.name).sort()).toEqual( - ['blockchain-indexer', 'notifications'].sort(), + ['blockchain-indexer', 'notifications', 'fraud-activity-cleanup'].sort(), ); }); diff --git a/src/shared/http/routes/worker-status.spec.ts b/src/shared/http/routes/worker-status.spec.ts new file mode 100644 index 0000000..5f36d95 --- /dev/null +++ b/src/shared/http/routes/worker-status.spec.ts @@ -0,0 +1,39 @@ +import Fastify from 'fastify'; +import { afterEach, expect, it, vi } from 'vitest'; +import healthRoutes from './health.js'; +import * as queue from '../../queue/index.js'; +import { workerExecutionStatus } from '../../queue/worker-execution.js'; + +afterEach(() => vi.restoreAllMocks()); + +it.each(['empty', 'stalled', 'unreachable'] as const)( + 'exposes blocked execution with %s queues without masking queue failures', + async (state) => { + const lookup = vi.spyOn(queue, 'getQueueHealth'); + if (state === 'unreachable') lookup.mockRejectedValue(new Error('isolated backend failure')); + else + lookup.mockResolvedValue([ + { + name: 'notifications', + waiting: state === 'stalled' ? 100 : 0, + active: 0, + delayed: 0, + completed: 0, + failed: 0, + failedRecent: 0, + }, + ]); + const app = Fastify(); + try { + await app.register(healthRoutes); + const response = await app.inject({ url: '/health/queue' }); + expect(response.statusCode).toBe(state === 'empty' ? 200 : 503); + expect(response.json()).toMatchObject({ + status: state === 'empty' ? 'degraded' : 'unavailable', + workerExecution: workerExecutionStatus, + }); + } finally { + await app.close(); + } + }, +); diff --git a/src/shared/metrics/registry.ts b/src/shared/metrics/registry.ts index 9c14053..31ab6d9 100644 --- a/src/shared/metrics/registry.ts +++ b/src/shared/metrics/registry.ts @@ -50,3 +50,21 @@ export const workerHeartbeatFailuresTotal = new Counter({ help: 'Worker heartbeat file write failures since process start', registers: [registry], }); + +/** + * Incremented every time a registered blockchain-event subscriber + * (`subscribeBlockchainEventHandler`, src/shared/events) fails while handling + * an event. The catch block that logs the failure used to do nothing else, + * so read models could drift permanently from on-chain state with no signal + * for Prometheus/Grafana alert rules — only raw stdout logs (docs/OBSERVABILITY.md). + * Labels mirror the envelope so an alert can identify what stopped syncing: + * `contract` (event.contractName), `event` (first topic segment), and + * `subscriber` (the module's own error-message string, one fixed value per + * registered handler — deliberately low-cardinality). + */ +export const eventSubscriberErrorsTotal = new Counter({ + name: 'event_subscriber_errors_total', + help: 'Total blockchain event subscriber handler failures by contract, event and subscriber', + labelNames: ['contract', 'event', 'subscriber'], + registers: [registry], +}); diff --git a/src/shared/queue/worker-execution.ts b/src/shared/queue/worker-execution.ts new file mode 100644 index 0000000..60d46ae --- /dev/null +++ b/src/shared/queue/worker-execution.ts @@ -0,0 +1,26 @@ +/** + * Fixed execution restriction for the current binary, shared by worker logs + * and queue status. No environment setting can enable these jobs. See + * docs/DEPLOYMENT.md: activation requires reviewed recovery/safety evidence. + * This module has no queue, database, timer or service construction effects. + */ +export const workerExecutionStatus = Object.freeze({ + status: 'blocked' as const, + jobs: Object.freeze([ + Object.freeze({ + queue: 'blockchain-indexer', + status: 'blocked' as const, + reason: 'consumer_completion_and_checkpoint_recovery_unverified', + }), + Object.freeze({ + queue: 'notifications', + status: 'blocked' as const, + reason: 'delivery_outbox_and_ambiguous_send_recovery_unverified', + }), + Object.freeze({ + queue: 'fraud-activity-cleanup', + status: 'blocked' as const, + reason: 'safe_retention_frontier_and_bounded_deletion_unverified', + }), + ]), +}); diff --git a/src/shared/validation/i128.ts b/src/shared/validation/i128.ts new file mode 100644 index 0000000..012d336 --- /dev/null +++ b/src/shared/validation/i128.ts @@ -0,0 +1,15 @@ +import { ValidationError } from '../errors/app-error.js'; + +export const I128_MIN = -(1n << 127n); +export const I128_MAX = (1n << 127n) - 1n; + +export function assertI128(value: bigint): void { + if (value < I128_MIN || value > I128_MAX) { + throw new ValidationError('Value must be within signed 128-bit bounds'); + } +} + +export function assertEscrowAmount(value: bigint): void { + if (value <= 0n) throw new ValidationError('Escrow amount must be greater than zero'); + assertI128(value); +} diff --git a/src/workers/index.spec.ts b/src/workers/index.spec.ts index 3dca66d..524f496 100644 --- a/src/workers/index.spec.ts +++ b/src/workers/index.spec.ts @@ -1,32 +1,77 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { workerExecutionStatus } from '../shared/queue/worker-execution.js'; -vi.mock('../modules/indexer/index.js', () => ({ - createIndexerBackgroundWorker: vi.fn(), - scheduleIndexer: vi.fn().mockResolvedValue(undefined), +const lifecycle = vi.hoisted(() => ({ + write: vi.fn(async () => true), + shutdown: undefined as undefined | ((event: { err?: Error }) => Promise), })); -vi.mock('../modules/notifications/index.js', () => ({ - createNotificationsBackgroundWorker: vi.fn(), +// Loading job composition is unnecessary under the restriction. Fail if the +// entrypoint imports it, rather than allowing mocked factories to hide effects. +vi.mock('../modules/indexer/index.js', () => { + throw new Error('Indexer must not load'); +}); +vi.mock('../modules/notifications/index.js', () => { + throw new Error('Notifications must not load'); +}); +vi.mock('../modules/fraud-detection/index.js', () => { + throw new Error('Cleanup must not load'); +}); +vi.mock('close-with-grace', () => ({ + default: vi.fn((_options: unknown, shutdown: (event: { err?: Error }) => Promise) => { + lifecycle.shutdown = shutdown; + return { close: vi.fn() }; + }), })); -vi.mock('../modules/fraud-detection/index.js', () => ({ - createFraudDetectionCleanupBackgroundWorker: vi.fn(), - scheduleFraudDetectionCleanup: vi.fn().mockResolvedValue(undefined), +vi.mock('./heartbeat.js', () => ({ + HEARTBEAT_INTERVAL_MS: 15_000, + createHeartbeatController: vi.fn(() => ({ write: lifecycle.write })), })); -import { registerWorkers } from './index.js'; -import { createNotificationsBackgroundWorker } from '../modules/notifications/index.js'; -import { - createFraudDetectionCleanupBackgroundWorker, - scheduleFraudDetectionCleanup, -} from '../modules/fraud-detection/index.js'; +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); -describe('worker registration', () => { - it('registers the indexer, notifications, and fraud-cleanup workers (#351, #352)', () => { - expect(registerWorkers).toHaveLength(3); - expect(createNotificationsBackgroundWorker).toBeDefined(); - expect(createFraudDetectionCleanupBackgroundWorker).toBeDefined(); +describe('restricted worker entrypoint', () => { + it('loads without activating the historical registry, scheduling jobs or starting timers', async () => { + vi.useFakeTimers(); + const { registerWorkers } = await import('./index.js'); + expect(registerWorkers).toEqual([]); + expect(Object.isFrozen(registerWorkers)).toBe(true); + expect(vi.getTimerCount()).toBe(0); + expect(lifecycle.write).not.toHaveBeenCalled(); + expect(lifecycle.shutdown).toBeUndefined(); + expect(workerExecutionStatus.status).toBe('blocked'); + expect(workerExecutionStatus.jobs).toEqual([ + { + queue: 'blockchain-indexer', + status: 'blocked', + reason: 'consumer_completion_and_checkpoint_recovery_unverified', + }, + { + queue: 'notifications', + status: 'blocked', + reason: 'delivery_outbox_and_ambiguous_send_recovery_unverified', + }, + { + queue: 'fraud-activity-cleanup', + status: 'blocked', + reason: 'safe_retention_frontier_and_bounded_deletion_unverified', + }, + ]); }); - it('schedules fraud-detection cleanup on worker startup (#353)', () => { - expect(scheduleFraudDetectionCleanup).toHaveBeenCalledTimes(1); + it('runs only the liveness heartbeat and cancels it during graceful shutdown', async () => { + vi.useFakeTimers(); + const { startWorkerProcess } = await import('./index.js'); + await startWorkerProcess(); + expect(lifecycle.write).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(15_000); + expect(lifecycle.write).toHaveBeenCalledTimes(2); + const shutdown = lifecycle.shutdown; + if (!shutdown) throw new Error('Graceful shutdown was not installed'); + await shutdown({}); + expect(vi.getTimerCount()).toBe(0); }); }); diff --git a/src/workers/index.ts b/src/workers/index.ts index 3b6bbe9..d6c4a71 100644 --- a/src/workers/index.ts +++ b/src/workers/index.ts @@ -1,34 +1,29 @@ +import { pathToFileURL } from 'node:url'; import type { Worker } from 'bullmq'; import closeWithGrace from 'close-with-grace'; import { logger } from '../shared/logger/index.js'; -import { getPrismaClient, disconnectPrisma } from '../shared/database/index.js'; +import { disconnectPrisma } from '../shared/database/index.js'; import { disconnectRedis } from '../shared/cache/index.js'; import { disconnectQueueConnection, closeAllQueues } from '../shared/queue/index.js'; -import { createIndexerBackgroundWorker, scheduleIndexer } from '../modules/indexer/index.js'; +import { workerExecutionStatus } from '../shared/queue/worker-execution.js'; import { HEARTBEAT_INTERVAL_MS, createHeartbeatController } from './heartbeat.js'; import { executeWorkerShutdownSteps } from './shutdown.js'; const log = logger.child({ process: 'worker' }); /** - * Registry of BullMQ Workers for the background-processing entrypoint - * (ARCHITECTURE.md §4 — runs as a separate process/container from the API, - * per the Phase 2 §5.3 lesson that in-process cron doesn't survive restarts - * or scale horizontally). Each module contributes its own worker(s) here as - * it's implemented in Phase 5. + * No service factories are constructed while execution is restricted. + * In particular, BullMQ Worker construction starts consumption immediately. + * Restoring the historical registry requires the prerequisites documented in + * docs/DEPLOYMENT.md; this empty registry is an explicit operational restriction. */ -export const registerWorkers: Array<() => Worker> = [ - () => createIndexerBackgroundWorker(getPrismaClient()), - () => createNotificationsBackgroundWorker(getPrismaClient()), - () => createFraudDetectionCleanupBackgroundWorker(getPrismaClient()), -]; - -async function main(): Promise { - // Repeatable job registration is idempotent (BullMQ upserts by - // name+repeat+jobId) — safe to call on every worker-process start. - await scheduleIndexer(); - await scheduleFraudDetectionCleanup(); +export const registerWorkers: ReadonlyArray<() => Worker> = Object.freeze([]); +export async function startWorkerProcess(): Promise { + log.warn( + { workerExecution: workerExecutionStatus, workerCount: 0 }, + 'Worker execution blocked; polling, notification delivery and retention cleanup unavailable', + ); const workers = registerWorkers.map((register) => register()); // Every teardown step runs with its own timeout and best-effort, so a Redis @@ -69,10 +64,16 @@ async function main(): Promise { void heartbeat.write(); }, HEARTBEAT_INTERVAL_MS); - log.info({ workerCount: workers.length }, 'Worker process started'); + log.warn( + { workerCount: workers.length, workerExecution: workerExecutionStatus }, + 'Worker supervisor started for liveness only; no jobs are running', + ); } -main().catch((error: unknown) => { - log.error({ err: error }, 'Failed to start worker process'); - process.exit(1); -}); +// Importing this module must not start workers, schedulers or heartbeat timers. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + startWorkerProcess().catch((error: unknown) => { + log.error({ err: error }, 'Failed to start worker process'); + process.exit(1); + }); +} diff --git a/tests/__fixtures__/mp4-headers.ts b/tests/__fixtures__/mp4-headers.ts new file mode 100644 index 0000000..144d071 --- /dev/null +++ b/tests/__fixtures__/mp4-headers.ts @@ -0,0 +1,75 @@ +// Synthetic ISO BMFF boxes, not playable videos. Independent hex vectors. +export const ftyp24 = Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex'); +export const extendedFtyp32 = Buffer.from( + '000000016674797000000000000000206d7034320000000069736f6d6d703432', + 'hex', +); +export const validMp4Headers = [ + { name: 'isom/iso2, 24-byte box', bytes: ftyp24 }, + { + name: 'mp41, minimum box without compatible brands', + bytes: Buffer.from('00000010667479706d70343100000000', 'hex'), + }, + { name: 'mp42, extended size', bytes: extendedFtyp32 }, + { + name: 'extended size, minimum box', + bytes: Buffer.from('000000016674797000000000000000186d70343200000000', 'hex'), + }, + { + name: 'size zero extends to EOF', + bytes: Buffer.from('00000000667479706d7034320000000069736f6d6d703432', 'hex'), + }, + { + name: 'ftyp then moov and mdat box sequence', + bytes: Buffer.concat([ftyp24, Buffer.from('000000086d6f6f760000000c6d64617400010203', 'hex')]), + }, + { name: 'binary minor version', bytes: Buffer.from('000000106674797069736f6dffffffff', 'hex') }, +]; +export function withSize(size: number): Buffer { + const bytes = Buffer.from(ftyp24); + bytes.writeUInt32BE(size); + return bytes; +} +export function withExtendedSize(size: bigint): Buffer { + const bytes = Buffer.from(extendedFtyp32); + bytes.writeBigUInt64BE(size, 8); + return bytes; +} +export const invalidMp4Headers = [ + ...Array.from({ length: 24 }, (_, length) => ({ + name: `standard truncated to ${length}`, + bytes: ftyp24.subarray(0, length), + })), + ...Array.from({ length: 32 }, (_, length) => ({ + name: `extended truncated to ${length}`, + bytes: extendedFtyp32.subarray(0, length), + })), + ...[2, 7, 8, 12, 15, 17, 18, 19, 21, 22, 23, 25, 28, 0x18000000, 0xffffffff].map((size) => ({ + name: `invalid standard size ${size}`, + bytes: withSize(size), + })), + ...[0n, 1n, 15n, 16n, 23n, 25n, 31n, 33n, 9007199254740993n, 18446744073709551615n].map( + (size) => ({ name: `invalid extended size ${size}`, bytes: withExtendedSize(size) }), + ), + { name: 'raw ftyp at byte zero', bytes: Buffer.from('ftypisom00000000') }, + { + name: 'marker in unrelated payload', + bytes: Buffer.concat([Buffer.from('0000000866726565', 'hex'), ftyp24]), + }, + { + name: 'high-bit byte is not ASCII ftyp', + bytes: Buffer.from('00000018667479f069736f6d0000020069736f6d69736f32', 'hex'), + }, + { + name: 'wrong leading box type', + bytes: Buffer.from('000000186d64617469736f6d0000020069736f6d69736f32', 'hex'), + }, + { + name: 'non-printable major brand', + bytes: Buffer.from('0000001866747970000000000000020069736f6d69736f32', 'hex'), + }, + { + name: 'non-printable compatible brand', + bytes: Buffer.from('000000186674797069736f6d0000020069736f6d69736fff', 'hex'), + }, +]; diff --git a/tests/deliveries-wiring.integration.spec.ts b/tests/deliveries-wiring.integration.spec.ts new file mode 100644 index 0000000..16cb61b --- /dev/null +++ b/tests/deliveries-wiring.integration.spec.ts @@ -0,0 +1,241 @@ +import { randomUUID } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import Fastify from 'fastify'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { createDeliveriesModule } from '../src/modules/deliveries/index.js'; +import { createDeliveriesRoutes } from '../src/modules/deliveries/interface/routes.js'; +import { createPrismaDeliveryRepository } from '../src/modules/deliveries/infrastructure/prisma-delivery-repository.js'; +import { createPrismaWalletAddressRepository } from '../src/modules/users/infrastructure/prisma-wallet-address-repository.js'; +import { + createGetDeliveryUseCase, + createListDeliveriesUseCase, + createBuildDeliveryTransactionsUseCases, +} from '../src/modules/deliveries/application/index.js'; +import { createFakeDeliveryTransactionBuilder } from '../src/modules/deliveries/application/__fixtures__/fakes.js'; +import { disconnectPrisma } from '../src/shared/database/index.js'; +import { handleError } from '../src/shared/errors/index.js'; +import { signAccessToken } from '../src/shared/jwt/index.js'; +import { testAddress } from '../src/shared/testing/addresses.js'; + +const prisma = new PrismaClient(); +const walletRepository = createPrismaWalletAddressRepository(prisma); +const deliveryRepository = createPrismaDeliveryRepository(prisma); +const app = Fastify(); +const ids: string[] = []; +const ownerAddress = testAddress('delivery-wiring-owner'); +const foreignAddress = testAddress('delivery-wiring-foreign'); +const unlinkedAddress = testAddress('delivery-wiring-unlinked'); +const driverAddress = testAddress('delivery-wiring-driver'); +const chainId = 9007199254700100n; +let ownerId: string; +let ownerToken: string; +let adminToken: string; +let revokedToken: string; +const transactionBuilder = createFakeDeliveryTransactionBuilder(); +const assign = vi.spyOn(transactionBuilder, 'buildAssignDriver'); +const dispute = vi.spyOn(transactionBuilder, 'buildRaiseDispute'); +const lookup = vi.spyOn(walletRepository, 'findByAddress'); + +beforeAll(async () => { + for (const role of ['CUSTOMER', 'ADMIN'] as const) { + const user = await prisma.user.create({ + data: { + email: `${randomUUID()}@delivery-wiring.invalid`, + passwordHash: 'fixture-only', + role, + }, + }); + ids.push(user.id); + const token = signAccessToken({ sub: user.id, role, tokenVersion: 0 }); + await walletRepository.create({ + userId: user.id, + address: role === 'CUSTOMER' ? ownerAddress : foreignAddress, + isPrimary: true, + verifiedAt: new Date(), + }); + if (role === 'CUSTOMER') { + ownerId = user.id; + ownerToken = token; + revokedToken = signAccessToken({ sub: user.id, role, tokenVersion: -1 }); + } else adminToken = token; + } + await prisma.delivery.create({ + data: { + chainDeliveryId: chainId, + senderAddress: ownerAddress, + recipientAddress: foreignAddress, + status: 'PENDING', + origin: 'A', + destination: 'B', + cargoCategory: 'GENERAL', + weightGrams: 500, + fragile: false, + createdAtChain: new Date('2026-01-01T00:00:00Z'), + }, + }); + app.setValidatorCompiler(validatorCompiler); + app.setSerializerCompiler(serializerCompiler); + app.setErrorHandler(handleError); + await app.register(createDeliveriesModule(prisma)); + await app.register( + createDeliveriesRoutes( + { + getDelivery: createGetDeliveryUseCase({ deliveryRepository }), + listDeliveries: createListDeliveriesUseCase({ deliveryRepository }), + buildTransactions: createBuildDeliveryTransactionsUseCases({ transactionBuilder }), + }, + { walletAddressRepository: walletRepository }, + ), + { prefix: '/injected' }, + ); + await app.ready(); +}); + +afterAll(async () => { + await app.close(); + await prisma.delivery.deleteMany({ where: { chainDeliveryId: chainId } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); + await prisma.$disconnect(); + await disconnectPrisma(); +}); + +describe.each(['assign-driver', 'raise-delivery-dispute'] as const)( + '%s ownership dependency', + (path) => { + const payload = (callerAddress: string) => ({ + callerAddress, + chainDeliveryId: chainId.toString(), + ...(path === 'assign-driver' ? { driverAddress } : {}), + }); + const request = (address: string, token = ownerToken, prefix = '/injected') => + app.inject({ + method: 'POST', + url: `${prefix}/transactions/build/${path}`, + headers: { authorization: `Bearer ${token}` }, + payload: payload(address), + }); + const builder = () => (path === 'assign-driver' ? assign : dispute); + + it('rejects anonymous and revoked credentials before wallet lookup or builder', async () => { + lookup.mockClear(); + builder().mockClear(); + const response = await app.inject({ + method: 'POST', + url: `/injected/transactions/build/${path}`, + payload: payload(ownerAddress), + }); + expect(response.statusCode).toBe(401); + expect((await request(ownerAddress, revokedToken)).statusCode).toBe(401); + expect(lookup).not.toHaveBeenCalled(); + expect(builder()).not.toHaveBeenCalled(); + }); + + it.each([foreignAddress, unlinkedAddress])( + 'rejects foreign/unlinked wallet %s with the historical403', + async (address) => { + builder().mockClear(); + const response = await request(address); + expect(response.statusCode).toBe(403); + expect(response.json().error).toEqual({ + code: 'FORBIDDEN', + message: 'Caller address is not linked to your account', + }); + expect(builder()).not.toHaveBeenCalled(); + expect((await request(address, ownerToken, '')).statusCode).toBe(403); + }, + ); + + it('does not grant an administrator ownership of another account wallet', async () => { + expect((await request(ownerAddress, adminToken)).statusCode).toBe(403); + }); + + it('injects the actual wallet repository and delegates exact typed input only after ownership', async () => { + builder().mockClear(); + lookup.mockClear(); + const before = await prisma.delivery.findUniqueOrThrow({ + where: { chainDeliveryId: chainId }, + }); + const response = await request(ownerAddress); + expect(response.statusCode).toBe(200); + expect(response.json().data.xdr).toBe( + path === 'assign-driver' ? 'unsigned-xdr:assign-driver' : 'unsigned-xdr:raise-dispute', + ); + expect(lookup).toHaveBeenCalledWith(ownerAddress); + expect(builder()).toHaveBeenCalledWith({ + callerAddress: ownerAddress, + chainDeliveryId: chainId, + ...(path === 'assign-driver' ? { driverAddress } : {}), + }); + expect( + await prisma.delivery.findUniqueOrThrow({ where: { chainDeliveryId: chainId } }), + ).toEqual(before); + const unconfigured = await request(ownerAddress, ownerToken, ''); + expect(unconfigured.statusCode).toBe(502); + expect(unconfigured.json().error.code).toBe('BLOCKCHAIN_ERROR'); + }); + + it('fails closed on lookup failure and preserves builder failures', async () => { + builder().mockClear(); + lookup.mockRejectedValueOnce(new Error('isolated lookup failure')); + expect((await request(ownerAddress)).statusCode).toBe(500); + expect(builder()).not.toHaveBeenCalled(); + builder().mockRejectedValueOnce(new Error('isolated builder failure')); + expect((await request(ownerAddress)).statusCode).toBe(500); + }); + + it('rejects malformed input before invoking the wallet repository', async () => { + lookup.mockClear(); + expect( + ( + await app.inject({ + method: 'POST', + url: `/injected/transactions/build/${path}`, + headers: { authorization: `Bearer ${ownerToken}` }, + payload: { ...payload(ownerAddress), callerAddress: 'bad' }, + }) + ).statusCode, + ).toBe(400); + expect(lookup).not.toHaveBeenCalled(); + }); + }, +); + +describe('unchanged delivery reads and current ownership', () => { + it('uses actual module reads, query filters, serialization and unknown-ID mapping', async () => { + const headers = { authorization: `Bearer ${ownerToken}` }; + expect((await app.inject({ url: `/deliveries/${chainId}` })).statusCode).toBe(401); + const detail = await app.inject({ url: `/deliveries/${chainId}`, headers }); + expect(detail.statusCode).toBe(200); + expect(detail.json().data).toMatchObject({ + chainDeliveryId: chainId.toString(), + status: 'PENDING', + senderAddress: ownerAddress, + weightGrams: 500, + }); + const list = await app.inject({ + url: `/deliveries?senderAddress=${ownerAddress}&status=PENDING`, + headers, + }); + expect(list.statusCode).toBe(200); + expect(list.json().data).toHaveLength(1); + expect((await app.inject({ url: `/deliveries/${chainId + 1n}`, headers })).statusCode).toBe( + 404, + ); + }); + + it('rechecks live wallet links on each request', async () => { + const wallet = await prisma.walletAddress.findUniqueOrThrow({ + where: { address: ownerAddress }, + }); + await walletRepository.remove(wallet.id); + const response = await app.inject({ + method: 'POST', + url: '/transactions/build/assign-driver', + headers: { authorization: `Bearer ${ownerToken}` }, + payload: { callerAddress: ownerAddress, chainDeliveryId: chainId.toString(), driverAddress }, + }); + expect(response.statusCode).toBe(403); + expect((await prisma.user.findUniqueOrThrow({ where: { id: ownerId } })).tokenVersion).toBe(0); + }); +}); diff --git a/tests/dispute-compatibility.spec.ts b/tests/dispute-compatibility.spec.ts new file mode 100644 index 0000000..d7c677b --- /dev/null +++ b/tests/dispute-compatibility.spec.ts @@ -0,0 +1,134 @@ +import { DisputeStatus, EscrowStatus } from '@prisma/client'; +import { describe, expect, it } from 'vitest'; +import type { + DisputeStatus as DomainDisputeStatus, + EscrowStatusForDispute, +} from '../src/modules/disputes/domain/index.js'; +import { ForbiddenEvidenceAccessError } from '../src/modules/disputes/domain/index.js'; +import { ValidationError } from '../src/shared/errors/app-error.js'; +import { testAddress } from '../src/shared/testing/addresses.js'; +import { getDisputeResponseSchema } from '../src/modules/disputes/interface/schemas.js'; +import { nativeToChainDisputeCase } from '../src/modules/disputes/infrastructure/disputes-scval-mapping.js'; +import { createSyncDisputeFromEventUseCase } from '../src/modules/disputes/application/sync-dispute-from-event.js'; +import { createDownloadEvidenceUseCase } from '../src/modules/disputes/application/download-evidence.js'; +import { + buildDispute, + buildEvidence, + buildEscrowDisputeEvent, + createFakeDisputeEscrowStateReader, + createFakeEvidenceStorage, + createFakeWalletOwnershipRepository, + createInMemoryDisputeRepository, + createInMemoryEvidenceRepository, +} from '../src/modules/disputes/application/__fixtures__/fakes.js'; + +describe('dispute domain compatibility', () => { + it('loads the real application exports and canonical validation error', async () => { + const application = await import('../src/modules/disputes/application/index.js'); + for (const name of [ + 'createDownloadEvidenceUseCase', + 'createUploadEvidenceUseCase', + 'createSyncDisputeFromEventUseCase', + 'createBuildDisputeTransactionsUseCases', + ] as const) { + expect(application[name]).toBeTypeOf('function'); + } + const { assertFileSignatureMatchesContentType } = + await import('../src/modules/disputes/application/file-signature.js'); + expect(() => + assertFileSignatureMatchesContentType(Buffer.from('not a PNG'), 'image/png'), + ).toThrow(ValidationError); + }); + + it('keeps the persisted and API dispute status values distinct from escrow states', () => { + expect(Object.values(DisputeStatus)).toEqual([ + 'OPEN', + 'RESOLVED_REFUND', + 'RESOLVED_PAYOUT', + 'SPLIT', + ]); + expect(getDisputeResponseSchema.shape.data.shape.status.options).toEqual( + Object.values(DisputeStatus), + ); + const escrowStates: EscrowStatusForDispute[] = ['LOCKED', 'RELEASED', 'REFUNDED', 'PAUSED']; + expect(Object.values(EscrowStatus)).toEqual(escrowStates); + }); + + it.each([ + ['Open', DisputeStatus.OPEN], + ['ResolvedRefund', DisputeStatus.RESOLVED_REFUND], + ['ResolvedPayout', DisputeStatus.RESOLVED_PAYOUT], + ['Split', DisputeStatus.SPLIT], + ])('preserves chain %s in the domain and API', (variant, status) => { + const decoded = nativeToChainDisputeCase( + { + status: [variant], + raised_by: testAddress('sender'), + raised_at: '1767225600', + evidence_hashes: [], + }, + 7n, + ); + const domainStatus: DomainDisputeStatus = decoded.status; + expect(domainStatus).toBe(status); + expect(getDisputeResponseSchema.shape.data.shape.status.parse(domainStatus)).toBe(status); + }); + + it.each(['UNDER_REVIEW', 'RESOLVED', 'CANCELLED', 'CLOSED', 'PAUSED'])( + 'does not accept or silently map unsupported dispute status %s', + (status) => { + expect(getDisputeResponseSchema.shape.data.shape.status.safeParse(status).success).toBe( + false, + ); + expect(() => nativeToChainDisputeCase({ status: [status] }, 1n)).toThrow( + 'Unknown DisputeStatus', + ); + }, + ); + + it.each<[EscrowStatusForDispute, DomainDisputeStatus]>([ + ['LOCKED', 'OPEN'], + ['PAUSED', 'OPEN'], + ['RELEASED', 'RESOLVED_PAYOUT'], + ['REFUNDED', 'RESOLVED_REFUND'], + ])('preserves the existing escrow %s to dispute %s mapping', async (escrowStatus, expected) => { + const disputeRepository = createInMemoryDisputeRepository(); + const escrowStateReader = createFakeDisputeEscrowStateReader(); + disputeRepository.seed(buildDispute()); + escrowStateReader.seed(1n, escrowStatus); + const sync = createSyncDisputeFromEventUseCase({ + disputeRepository, + escrowStateReader, + walletOwnershipRepository: createFakeWalletOwnershipRepository(), + }); + await sync(buildEscrowDisputeEvent({ topic: ['dispute_resolved', '1'] })); + expect((await disputeRepository.findByChainDeliveryId(1n))?.status).toBe(expected); + }); + + it('does not grant historical raiser access to the current owner of that wallet', async () => { + const disputeRepository = createInMemoryDisputeRepository(); + const evidenceRepository = createInMemoryEvidenceRepository(); + const walletOwnershipRepository = createFakeWalletOwnershipRepository(); + const dispute = buildDispute({ raisedByUserId: null }); + const evidence = buildEvidence({ + disputeId: dispute.id, + uploadedByUserId: 'original-uploader', + }); + disputeRepository.seed(dispute); + evidenceRepository.seed(evidence); + walletOwnershipRepository.seed('new-wallet-owner', dispute.raisedBy); + const download = createDownloadEvidenceUseCase({ + disputeRepository, + evidenceRepository, + walletOwnershipRepository, + evidenceStorage: createFakeEvidenceStorage(), + }); + await expect( + download({ + evidenceId: evidence.id, + requesterId: 'new-wallet-owner', + requesterRole: 'CUSTOMER', + }), + ).rejects.toBeInstanceOf(ForbiddenEvidenceAccessError); + }); +}); diff --git a/tests/dispute-http-compatibility.spec.ts b/tests/dispute-http-compatibility.spec.ts new file mode 100644 index 0000000..9190c67 --- /dev/null +++ b/tests/dispute-http-compatibility.spec.ts @@ -0,0 +1,398 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdtemp, rm, readdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DisputeStatus, UserRole, PrismaClient } from '@prisma/client'; +import Fastify from 'fastify'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { disconnectPrisma } from '../src/shared/database/index.js'; +import { handleError } from '../src/shared/errors/index.js'; +import { signAccessToken } from '../src/shared/jwt/index.js'; +import { testAddress } from '../src/shared/testing/addresses.js'; + +import { ftyp24, extendedFtyp32, invalidMp4Headers } from './__fixtures__/mp4-headers.js'; +import { createLocalEvidenceStorage } from '../src/modules/disputes/infrastructure/local-evidence-storage.js'; +import { getConfig } from '../src/shared/config/index.js'; + +// Real module, Prisma repositories, authentication, schema compilers and local storage. +// The unconfigured chain client cannot submit transactions or make RPC calls. +const prisma = new PrismaClient(); +const app = Fastify(); +const ids: string[] = []; +const chainId = BigInt(Date.now()); +const address = testAddress('http-uploader'); +let baseDir: string; +let disputeId: string; +let evidenceId: string; +const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const users = new Map(); +function headers(key: string) { + const user = users.get(key); + if (!user) throw new Error(`Missing fixture ${key}`); + return { authorization: `Bearer ${user.token}` }; +} +function userId(key: string) { + const user = users.get(key); + if (!user) throw new Error(`Missing fixture ${key}`); + return user.id; +} +beforeAll(async () => { + baseDir = await mkdtemp(join(tmpdir(), 'dispute-http-')); + vi.stubEnv('EVIDENCE_STORAGE_DIR', baseDir); + vi.stubEnv('DISPUTE_RESOLUTION_CONTRACT_ID', ''); + vi.stubEnv('ESCROW_CONTRACT_ID', ''); + const { createDisputesModule } = await import('../src/modules/disputes/index.js'); + for (const role of Object.values(UserRole)) { + for (const subject of ['raiser', 'uploader', 'stranger']) { + const user = await prisma.user.create({ + data: { + email: `${randomUUID()}@dispute.invalid`, + passwordHash: 'fixture-only', + role, + }, + }); + ids.push(user.id); + users.set(`${role}-${subject}`, { + id: user.id, + token: signAccessToken({ sub: user.id, role, tokenVersion: 0 }), + }); + } + } + await prisma.walletAddress.create({ + data: { userId: userId('CUSTOMER-uploader'), address, verifiedAt: new Date() }, + }); + await prisma.delivery.create({ + data: { + chainDeliveryId: chainId, + senderAddress: address, + recipientAddress: testAddress('http-recipient'), + status: 'DISPUTED', + origin: 'A', + destination: 'B', + cargoCategory: 'GENERAL', + weightGrams: 500, + fragile: false, + createdAtChain: new Date(), + }, + }); + const dispute = await prisma.dispute.create({ + data: { + chainDeliveryId: chainId, + status: 'OPEN', + raisedBy: testAddress('http-raiser'), + raisedAt: new Date(), + raisedByUserId: userId('CUSTOMER-raiser'), + }, + }); + disputeId = dispute.id; + app.setValidatorCompiler(validatorCompiler); + app.setSerializerCompiler(serializerCompiler); + app.setErrorHandler(handleError); + await app.register(createDisputesModule(prisma)); + const upload = await app.inject({ + method: 'POST', + url: `/disputes/${chainId}/evidence`, + headers: headers('CUSTOMER-uploader'), + payload: { + uploadedBy: address, + contentType: 'image/png', + base64Content: bytes.toString('base64'), + }, + }); + expect(upload.statusCode).toBe(200); + evidenceId = upload.json().data.evidenceId; +}); +afterAll(async () => { + await app.close(); + await prisma.evidence.deleteMany({ where: { disputeId } }); + await prisma.dispute.deleteMany({ where: { chainDeliveryId: chainId } }); + await prisma.delivery.deleteMany({ where: { chainDeliveryId: chainId } }); + await prisma.walletAddress.deleteMany({ where: { userId: { in: ids } } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); + await prisma.$disconnect(); + await disconnectPrisma(); + vi.unstubAllEnvs(); + if (baseDir) await rm(baseDir, { recursive: true, force: true }); +}); +describe('dispute HTTP compatibility', () => { + for (const status of Object.values(DisputeStatus)) { + for (const role of Object.values(UserRole)) { + for (const subject of ['raiser', 'uploader', 'stranger']) { + it(`${status} / ${role} / ${subject} preserves download policy`, async () => { + const key = `${role}-${subject}`; + await prisma.dispute.update({ + where: { id: disputeId }, + data: { + status, + raisedByUserId: subject === 'raiser' ? userId(key) : userId('CUSTOMER-raiser'), + }, + }); + await prisma.evidence.update({ + where: { id: evidenceId }, + data: { + uploadedByUserId: subject === 'uploader' ? userId(key) : userId('CUSTOMER-uploader'), + }, + }); + const response = await app.inject({ + method: 'GET', + url: `/disputes/evidence/${evidenceId}/download`, + headers: headers(key), + }); + const allowed = role === 'ADMIN' || (status === 'OPEN' && subject !== 'stranger'); + expect(response.statusCode).toBe(allowed ? 200 : 403); + if (allowed) { + expect(response.rawPayload).toEqual(bytes); + expect(response.headers['content-type']).toBe('image/png'); + expect(response.headers['content-disposition']).toBe( + `attachment; filename="${evidenceId}.png"`, + ); + } else expect(response.json().error.code).toBe('FORBIDDEN'); + }); + } + } + } + it('retains legacy uploader fallback and prevents stable uploader relink access', async () => { + await prisma.dispute.update({ + where: { id: disputeId }, + data: { status: 'OPEN', raisedByUserId: null }, + }); + await prisma.evidence.update({ where: { id: evidenceId }, data: { uploadedByUserId: null } }); + const download = () => + app.inject({ + method: 'GET', + url: `/disputes/evidence/${evidenceId}/download`, + headers: headers('CUSTOMER-uploader'), + }); + expect((await download()).statusCode).toBe(200); + await prisma.evidence.update({ + where: { id: evidenceId }, + data: { uploadedByUserId: userId('CUSTOMER-raiser') }, + }); + expect((await download()).statusCode).toBe(403); + }); + it('enforces authentication, UUID schema, missing evidence and upload validation', async () => { + const url = `/disputes/evidence/${evidenceId}/download`; + expect((await app.inject({ url })).statusCode).toBe(401); + expect( + ( + await app.inject({ + url: '/disputes/evidence/invalid/download', + headers: headers('ADMIN-stranger'), + }) + ).statusCode, + ).toBe(400); + expect( + ( + await app.inject({ + url: `/disputes/evidence/${randomUUID()}/download`, + headers: headers('ADMIN-stranger'), + }) + ).statusCode, + ).toBe(404); + for (const status of Object.values(DisputeStatus)) { + await prisma.dispute.update({ where: { id: disputeId }, data: { status } }); + const response = await app.inject({ + method: 'POST', + url: `/disputes/${chainId}/evidence`, + headers: headers('CUSTOMER-uploader'), + payload: { + uploadedBy: address, + contentType: 'image/png', + base64Content: Buffer.from('invalid PNG').toString('base64'), + }, + }); + expect(response.statusCode).toBe(status === 'OPEN' ? 400 : 409); + } + expect(await prisma.evidence.count({ where: { disputeId } })).toBe(1); + }); + it('preserves uploader ownership and ADMIN-only split schema bounds over HTTP', async () => { + await prisma.dispute.update({ where: { id: disputeId }, data: { status: 'OPEN' } }); + const upload = await app.inject({ + method: 'POST', + url: `/disputes/${chainId}/evidence`, + headers: headers('CUSTOMER-stranger'), + payload: { + uploadedBy: address, + contentType: 'image/png', + base64Content: bytes.toString('base64'), + }, + }); + expect(upload.statusCode).toBe(403); + const payload = { + callerAddress: address, + chainDeliveryId: chainId.toString(), + senderShareBps: 5000, + }; + const url = '/transactions/build/resolve-dispute-split-funds'; + expect( + (await app.inject({ method: 'POST', url, headers: headers('CUSTOMER-uploader'), payload })) + .statusCode, + ).toBe(403); + for (const senderShareBps of [-1, 10001, 1.5]) { + expect( + ( + await app.inject({ + method: 'POST', + url, + headers: headers('ADMIN-stranger'), + payload: { ...payload, senderShareBps }, + }) + ).statusCode, + ).toBe(400); + } + // A valid ADMIN request reaches the deliberately unconfigured builder; no RPC is possible. + expect( + (await app.inject({ method: 'POST', url, headers: headers('ADMIN-stranger'), payload })) + .statusCode, + ).toBe(502); + expect(await prisma.evidence.count({ where: { disputeId } })).toBe(1); + }); + it('does not transfer stable historical download rights when a wallet is relinked', async () => { + await prisma.dispute.update({ + where: { id: disputeId }, + data: { status: 'OPEN', raisedBy: address, raisedByUserId: userId('CUSTOMER-raiser') }, + }); + await prisma.evidence.update({ + where: { id: evidenceId }, + data: { uploadedByUserId: userId('CUSTOMER-uploader') }, + }); + await prisma.walletAddress.update({ + where: { address }, + data: { userId: userId('CUSTOMER-stranger') }, + }); + for (const [subject, expected] of [ + ['stranger', 403], + ['raiser', 200], + ['uploader', 200], + ] as const) { + const response = await app.inject({ + url: `/disputes/evidence/${evidenceId}/download`, + headers: headers(`CUSTOMER-${subject}`), + }); + expect(response.statusCode).toBe(expected); + } + }); + it.each([ftyp24, extendedFtyp32])( + 'uploads an owned MP4 header and preserves hash, identity and bytes', + async (mp4) => { + await prisma.dispute.update({ where: { id: disputeId }, data: { status: 'OPEN' } }); + await prisma.walletAddress.update({ + where: { address }, + data: { userId: userId('CUSTOMER-uploader') }, + }); + const response = await app.inject({ + method: 'POST', + url: `/disputes/${chainId}/evidence`, + headers: headers('CUSTOMER-uploader'), + payload: { + uploadedBy: address, + contentType: 'video/mp4', + base64Content: mp4.toString('base64'), + }, + }); + expect(response.statusCode).toBe(200); + const stored = await prisma.evidence.findUniqueOrThrow({ + where: { id: response.json<{ data: { evidenceId: string } }>().data.evidenceId }, + }); + const storage = createLocalEvidenceStorage(baseDir); + try { + expect(stored.uploadedByUserId).toBe(userId('CUSTOMER-uploader')); + expect(stored.hash).toBe(createHash('sha256').update(mp4).digest('hex')); + expect(response.json<{ data: { hash: string } }>().data.hash).toBe(stored.hash); + expect(await storage.read(stored.storageUrl)).toEqual(mp4); + } finally { + await prisma.evidence.delete({ where: { id: stored.id } }); + await storage.delete(stored.storageUrl); + } + }, + ); + + it.each(invalidMp4Headers)( + 'returns400 for $name without writing evidence', + async ({ bytes: invalid }) => { + const before = await prisma.evidence.count({ where: { disputeId } }); + const files = await readdir(join(baseDir, disputeId)); + const response = await app.inject({ + method: 'POST', + url: `/disputes/${chainId}/evidence`, + headers: headers('CUSTOMER-uploader'), + payload: { + uploadedBy: address, + contentType: 'video/mp4', + base64Content: invalid.toString('base64'), + }, + }); + expect(response.statusCode).toBe(400); + expect(response.json<{ error: { code: string } }>().error.code).toBe('VALIDATION_ERROR'); + expect(await prisma.evidence.count({ where: { disputeId } })).toBe(before); + expect(await readdir(join(baseDir, disputeId))).toEqual(files); + }, + ); + + it('preserves401/403/409/404 and upload size errors for MP4', async () => { + const url = `/disputes/${chainId}/evidence`; + const payload = { + uploadedBy: address, + contentType: 'video/mp4', + base64Content: ftyp24.toString('base64'), + }; + expect((await app.inject({ method: 'POST', url, payload })).statusCode).toBe(401); + expect( + (await app.inject({ method: 'POST', url, headers: headers('CUSTOMER-stranger'), payload })) + .statusCode, + ).toBe(403); + await prisma.dispute.update({ where: { id: disputeId }, data: { status: 'RESOLVED_REFUND' } }); + expect( + (await app.inject({ method: 'POST', url, headers: headers('CUSTOMER-uploader'), payload })) + .statusCode, + ).toBe(409); + await prisma.dispute.update({ where: { id: disputeId }, data: { status: 'OPEN' } }); + expect( + ( + await app.inject({ + method: 'POST', + url: `/disputes/${chainId + 999n}/evidence`, + headers: headers('CUSTOMER-uploader'), + payload, + }) + ).statusCode, + ).toBe(404); + expect( + ( + await app.inject({ + method: 'POST', + url, + headers: headers('CUSTOMER-uploader'), + payload: { ...payload, contentType: 'video/not-mp4' }, + }) + ).statusCode, + ).toBe(400); + expect( + ( + await app.inject({ + method: 'POST', + url, + headers: headers('CUSTOMER-uploader'), + payload: { ...payload, contentType: 'image/png' }, + }) + ).statusCode, + ).toBe(400); + const oversized = Buffer.concat([ftyp24, Buffer.alloc(getConfig().EVIDENCE_MAX_BYTES + 1)]); + const sizeResponse = await app.inject({ + method: 'POST', + url, + headers: headers('CUSTOMER-uploader'), + payload: { ...payload, base64Content: oversized.toString('base64') }, + }); + expect(sizeResponse.statusCode).toBe(400); + expect(sizeResponse.json<{ error: { code: string } }>().error.code).toBe('VALIDATION_ERROR'); + const bodyResponse = await app.inject({ + method: 'POST', + url, + headers: headers('CUSTOMER-uploader'), + payload: { ...payload, base64Content: 'A'.repeat(16 * 1024 * 1024) }, + }); + expect(bodyResponse.statusCode).toBe(413); + }); +}); diff --git a/tests/e2e/.gitkeep b/tests/e2e/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/escrow-identifier-compatibility.spec.ts b/tests/escrow-identifier-compatibility.spec.ts new file mode 100644 index 0000000..1027e92 --- /dev/null +++ b/tests/escrow-identifier-compatibility.spec.ts @@ -0,0 +1,360 @@ +import { Account, Address, Transaction, TransactionBuilder } from '@stellar/stellar-sdk'; +import { randomUUID } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import Fastify from 'fastify'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { createBuildEscrowTransactionsUseCases } from '../src/modules/escrow/application/build-escrow-transactions.js'; +import { createFakeEscrowTransactionBuilder } from '../src/modules/escrow/application/__fixtures__/fakes.js'; +import { + assertEscrowDeliveryId, + ESCROW_DELIVERY_ID_MAX, +} from '../src/modules/escrow/domain/identifiers.js'; +import { createSorobanEscrowContractClient } from '../src/modules/escrow/infrastructure/soroban-escrow-contract-client.js'; +import { SorobanClient } from '../src/blockchain/soroban-client.js'; +import { getConfig } from '../src/shared/config/index.js'; +import { ValidationError, handleError } from '../src/shared/errors/index.js'; +import { testAddress } from '../src/shared/testing/addresses.js'; +import { createEscrowRoutes } from '../src/modules/escrow/interface/routes.js'; +import { createGetEscrowUseCase } from '../src/modules/escrow/application/get-escrow.js'; +import { createPrismaEscrowRepository } from '../src/modules/escrow/infrastructure/prisma-escrow-repository.js'; +import { createEscrowArgsToScVal } from '../src/modules/escrow/infrastructure/escrow-scval-mapping.js'; +import { disconnectPrisma } from '../src/shared/database/index.js'; +import { signAccessToken } from '../src/shared/jwt/index.js'; + +const sender = testAddress('escrow-id-sender'); +const recipient = testAddress('escrow-id-recipient'); +const driver = testAddress('escrow-id-driver'); +const token = Address.contract(Buffer.alloc(32, 7)).toString(); +const contractId = Address.contract(Buffer.alloc(32, 8)).toString(); +const amount = (1n << 100n) + 23n; +const validIds = [ + 0n, + 1n, + 9007199254740991n, + 9007199254740993n, + (1n << 63n) - 1n, + 1n << 63n, + ESCROW_DELIVERY_ID_MAX, +]; + +function controlledAdapter() { + const client = new SorobanClient('http://127.0.0.1:9'); + const account = vi + .spyOn(client, 'getAccount') + .mockImplementation(async (address) => new Account(address, '100')); + const prepare = vi.spyOn(client, 'prepareTransaction').mockImplementation(async (transaction) => { + if (!(transaction instanceof Transaction)) + throw new Error('Expected ordinary unsigned transaction'); + return transaction; + }); + const balance = vi.fn(async () => null); + const adapter = createSorobanEscrowContractClient(client, contractId, { + getTokenBalance: balance, + }); + return { adapter, account, prepare, balance }; +} + +function decodeInvocation(encoded: string) { + const transaction = TransactionBuilder.fromXDR(encoded, getConfig().STELLAR_NETWORK_PASSPHRASE); + if (!(transaction instanceof Transaction)) throw new Error('Expected ordinary transaction'); + const operation = transaction.operations[0]; + if (operation?.type !== 'invokeHostFunction') throw new Error('Expected contract invocation'); + return { transaction, invocation: operation.func.invokeContract() }; +} + +describe('escrow delivery identifier representation', () => { + it.each([-1n, 1n << 64n])('rejects out-of-range standalone create mapper input %s', (id) => { + expect(() => + createEscrowArgsToScVal({ + senderAddress: sender, + recipientAddress: recipient, + driverAddress: driver, + token, + amount, + chainDeliveryId: id, + }), + ).toThrow(ValidationError); + }); + it.each(validIds)('uses the exact bigint in the optional refund-status port: %s', async (id) => { + const transactionBuilder = createFakeEscrowTransactionBuilder(); + const getEscrowStatus = vi.fn(async (_id: bigint) => 'LOCKED' as const); + const useCases = createBuildEscrowTransactionsUseCases({ transactionBuilder, getEscrowStatus }); + await expect( + useCases.buildRefundEscrowTransaction({ callerAddress: sender, chainDeliveryId: id }), + ).resolves.toBe('unsigned-xdr:refund-escrow'); + expect(getEscrowStatus).toHaveBeenCalledExactlyOnceWith(id); + expect(typeof getEscrowStatus.mock.calls[0]?.[0]).toBe('bigint'); + }); + + it.each([-1n, 1n << 64n])('rejects %s before optional lookup or builder calls', async (id) => { + const transactionBuilder = createFakeEscrowTransactionBuilder(); + const create = vi.spyOn(transactionBuilder, 'buildCreateEscrow'); + const release = vi.spyOn(transactionBuilder, 'buildReleaseEscrow'); + const refund = vi.spyOn(transactionBuilder, 'buildRefundEscrow'); + const getEscrowStatus = vi.fn(async (_id: bigint) => 'LOCKED' as const); + const useCases = createBuildEscrowTransactionsUseCases({ transactionBuilder, getEscrowStatus }); + await expect( + useCases.buildCreateEscrowTransaction({ + senderAddress: sender, + recipientAddress: recipient, + driverAddress: driver, + token, + amount, + chainDeliveryId: id, + }), + ).rejects.toBeInstanceOf(ValidationError); + await expect( + useCases.buildReleaseEscrowTransaction({ callerAddress: recipient, chainDeliveryId: id }), + ).rejects.toBeInstanceOf(ValidationError); + await expect( + useCases.buildRefundEscrowTransaction({ callerAddress: sender, chainDeliveryId: id }), + ).rejects.toBeInstanceOf(ValidationError); + expect(getEscrowStatus).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(release).not.toHaveBeenCalled(); + expect(refund).not.toHaveBeenCalled(); + }); + + it.each([null, undefined, 1, Number.MAX_SAFE_INTEGER + 1, '1', 'user-uuid', true, {}])( + 'rejects a non-bigint internal representation: %j', + (id) => { + expect(() => assertEscrowDeliveryId(id)).toThrow(ValidationError); + }, + ); +}); + +describe.each(['create', 'release', 'refund'] as const)('%s escrow exact unsigned XDR', (kind) => { + it.each(validIds)( + 'encodes delivery key %s as bare u64 with unchanged arguments and source', + async (id) => { + const { adapter, account, prepare } = controlledAdapter(); + const encoded = + kind === 'create' + ? await adapter.buildCreateEscrow({ + senderAddress: sender, + recipientAddress: recipient, + driverAddress: driver, + token, + amount, + chainDeliveryId: id, + }) + : kind === 'release' + ? await adapter.buildReleaseEscrow({ callerAddress: recipient, chainDeliveryId: id }) + : await adapter.buildRefundEscrow({ callerAddress: sender, chainDeliveryId: id }); + const { transaction, invocation } = decodeInvocation(encoded); + expect(invocation.functionName().toString()).toBe(`${kind}_escrow`); + expect(Address.fromScAddress(invocation.contractAddress()).toString()).toBe(contractId); + expect(transaction.source).toBe(kind === 'release' ? recipient : sender); + expect(account).toHaveBeenCalledWith(transaction.source); + expect(transaction.signatures).toHaveLength(0); + expect(prepare).toHaveBeenCalledTimes(1); + const args = invocation.args(); + expect(args).toHaveLength(kind === 'create' ? 6 : 2); + const key = args[kind === 'create' ? 3 : 1]; + expect(key?.switch().name).toBe('scvU64'); + expect(key?.u64().toString()).toBe(id.toString()); + if (kind === 'create') { + expect(args.slice(0, 3).map((arg) => Address.fromScVal(arg).toString())).toEqual([ + sender, + recipient, + driver, + ]); + expect(Address.fromScVal(args[4]!).toString()).toBe(token); + const parts = args[5]!.i128(); + expect(BigInt(parts.hi().toString()) * (1n << 64n) + BigInt(parts.lo().toString())).toBe( + amount, + ); + } else + expect(Address.fromScVal(args[0]!).toString()).toBe( + kind === 'release' ? recipient : sender, + ); + }, + ); + + it.each([-1n, 1n << 64n])( + 'rejects out-of-range direct adapter ID %s before balance/account/preparation', + async (id) => { + const { adapter, account, prepare, balance } = controlledAdapter(); + const pending = + kind === 'create' + ? adapter.buildCreateEscrow({ + senderAddress: sender, + recipientAddress: recipient, + driverAddress: driver, + token, + amount, + chainDeliveryId: id, + }) + : kind === 'release' + ? adapter.buildReleaseEscrow({ callerAddress: recipient, chainDeliveryId: id }) + : adapter.buildRefundEscrow({ callerAddress: sender, chainDeliveryId: id }); + await expect(pending).rejects.toBeInstanceOf(ValidationError); + expect(balance).not.toHaveBeenCalled(); + expect(account).not.toHaveBeenCalled(); + expect(prepare).not.toHaveBeenCalled(); + }, + ); +}); + +describe('authenticated escrow builder HTTP identifier boundaries', () => { + const prisma = new PrismaClient(); + const app = Fastify(); + const fixture = controlledAdapter(); + const getEscrowStatus = vi.fn(async (_id: bigint) => 'LOCKED' as const); + let userId: string; + let accessToken: string; + + beforeAll(async () => { + const user = await prisma.user.create({ + data: { email: `${randomUUID()}@escrow-id.invalid`, passwordHash: 'fixture-only' }, + }); + userId = user.id; + accessToken = signAccessToken({ sub: userId, role: 'CUSTOMER', tokenVersion: 0 }); + app.setValidatorCompiler(validatorCompiler); + app.setSerializerCompiler(serializerCompiler); + app.setErrorHandler(handleError); + await app.register( + createEscrowRoutes({ + getEscrow: createGetEscrowUseCase({ + escrowRepository: createPrismaEscrowRepository(prisma), + }), + buildTransactions: createBuildEscrowTransactionsUseCases({ + transactionBuilder: fixture.adapter, + getEscrowStatus, + }), + }), + ); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + await prisma.user.deleteMany({ where: { id: userId } }); + await prisma.$disconnect(); + await disconnectPrisma(); + }); + + it('keeps local UUIDs distinct from exact signed-bigint database delivery keys', async () => { + const id = (1n << 63n) - 1n; + try { + const delivery = await prisma.delivery.create({ + data: { + chainDeliveryId: id, + senderAddress: sender, + recipientAddress: recipient, + status: 'PENDING', + origin: 'A', + destination: 'B', + cargoCategory: 'GENERAL', + weightGrams: 500, + fragile: false, + createdAtChain: new Date('2026-01-01T00:00:00Z'), + }, + }); + const escrow = await prisma.escrow.create({ + data: { + chainDeliveryId: id, + senderAddress: sender, + recipientAddress: recipient, + driverAddress: driver, + token, + amount: amount.toString(), + status: 'LOCKED', + createdAtChain: new Date('2026-01-01T00:00:00Z'), + }, + }); + expect(delivery.id).not.toBe(escrow.id); + expect(escrow.id).not.toBe(id.toString()); + expect(escrow.chainDeliveryId).toBe(id); + expect((await prisma.escrow.findUniqueOrThrow({ where: { chainDeliveryId: id } })).id).toBe( + escrow.id, + ); + expect(escrow.amount.toFixed(0)).toBe(amount.toString()); + expect(() => assertEscrowDeliveryId(escrow.id)).toThrow(ValidationError); + } finally { + await prisma.escrow.deleteMany({ where: { chainDeliveryId: id } }); + await prisma.delivery.deleteMany({ where: { chainDeliveryId: id } }); + } + }); + + describe.each(['create', 'release', 'refund'] as const)('%s HTTP input', (kind) => { + const body = (chainDeliveryId: unknown) => + kind === 'create' + ? { + senderAddress: sender, + recipientAddress: recipient, + driverAddress: driver, + token, + amount: amount.toString(), + chainDeliveryId, + } + : { callerAddress: kind === 'release' ? recipient : sender, chainDeliveryId }; + const url = `/transactions/build/${kind}-escrow`; + const invalidIds = [ + '-1', + '+1', + '1.5', + '1e3', + ' 1', + '1 ', + '', + 'nope', + contractId, + '00000000-0000-4000-8000-000000000001', + null, + 1, + true, + {}, + (1n << 64n).toString(), + '9'.repeat(128), + ]; + + it('requires authentication before validating/building', async () => { + fixture.account.mockClear(); + expect((await app.inject({ method: 'POST', url, payload: {} })).statusCode).toBe(401); + expect(fixture.account).not.toHaveBeenCalled(); + }); + + it.each(invalidIds)( + 'rejects malformed/out-of-u64 identifier %j before dependencies', + async (id) => { + fixture.account.mockClear(); + fixture.prepare.mockClear(); + fixture.balance.mockClear(); + getEscrowStatus.mockClear(); + const response = await app.inject({ + method: 'POST', + url, + headers: { authorization: `Bearer ${accessToken}` }, + payload: body(id), + }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('VALIDATION_ERROR'); + expect(fixture.account).not.toHaveBeenCalled(); + expect(fixture.prepare).not.toHaveBeenCalled(); + expect(fixture.balance).not.toHaveBeenCalled(); + expect(getEscrowStatus).not.toHaveBeenCalled(); + }, + ); + + it.each(['0', '0001', '9007199254740993', ESCROW_DELIVERY_ID_MAX.toString()])( + 'retains digit-string API and exact bigint encoding of %s', + async (id) => { + const response = await app.inject({ + method: 'POST', + url, + headers: { authorization: `Bearer ${accessToken}` }, + payload: body(id), + }); + expect(response.statusCode).toBe(200); + const { transaction, invocation } = decodeInvocation(response.json().data.xdr); + const key = invocation.args()[kind === 'create' ? 3 : 1]; + expect(key?.switch().name).toBe('scvU64'); + expect(key?.u64().toString()).toBe(BigInt(id).toString()); + expect(transaction.signatures).toHaveLength(0); + if (kind === 'refund') expect(getEscrowStatus).toHaveBeenLastCalledWith(BigInt(id)); + }, + ); + }); +}); diff --git a/tests/escrow-verification-contract.spec.ts b/tests/escrow-verification-contract.spec.ts new file mode 100644 index 0000000..ebb7f7e --- /dev/null +++ b/tests/escrow-verification-contract.spec.ts @@ -0,0 +1,156 @@ +import { randomUUID } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import Fastify from 'fastify'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; +import { afterAll, beforeAll, expect, it } from 'vitest'; +import { createPrismaEscrowRepository } from '../src/modules/escrow/infrastructure/prisma-escrow-repository.js'; +import { createPrismaAnalyticsReader } from '../src/modules/analytics/infrastructure/prisma-analytics-reader.js'; +import { createSyncEscrowFromEventUseCase } from '../src/modules/escrow/application/sync-escrow-from-event.js'; +import { createGetEscrowUseCase } from '../src/modules/escrow/application/get-escrow.js'; +import { createBuildEscrowTransactionsUseCases } from '../src/modules/escrow/application/build-escrow-transactions.js'; +import { + buildChainEscrowRecord, + buildEscrowEvent, + createFakeEscrowContractReader, + createFakeEscrowTransactionBuilder, +} from '../src/modules/escrow/application/__fixtures__/fakes.js'; +import { createEscrowRoutes } from '../src/modules/escrow/interface/routes.js'; +import { createAnalyticsRoutes } from '../src/modules/analytics/interface/routes.js'; +import { + createGetGmvUseCase, + createGetCompletionRateUseCase, + createGetDisputeRateUseCase, + createGetDriverTierDistributionUseCase, +} from '../src/modules/analytics/application/index.js'; +import { signAccessToken } from '../src/shared/jwt/index.js'; +import { disconnectPrisma } from '../src/shared/database/index.js'; +import { handleError } from '../src/shared/errors/index.js'; + +const prisma = new PrismaClient(); +const repository = createPrismaEscrowRepository(prisma); +const analytics = createPrismaAnalyticsReader(prisma); +const app = Fastify(); +const ids: bigint[] = []; +const allowed = `allowed-${randomUUID()}`; +const rogue = `rogue-${randomUUID()}`; + +beforeAll(async () => { + app.setValidatorCompiler(validatorCompiler); + app.setSerializerCompiler(serializerCompiler); + app.setErrorHandler(handleError); + await app.register( + createEscrowRoutes({ + getEscrow: createGetEscrowUseCase({ escrowRepository: repository }), + buildTransactions: createBuildEscrowTransactionsUseCases({ + transactionBuilder: createFakeEscrowTransactionBuilder(), + }), + }), + ); + await app.register( + createAnalyticsRoutes({ + getGmv: createGetGmvUseCase({ analyticsReader: analytics }), + getCompletionRate: createGetCompletionRateUseCase({ analyticsReader: analytics }), + getDisputeRate: createGetDisputeRateUseCase({ analyticsReader: analytics }), + getDriverTierDistribution: createGetDriverTierDistributionUseCase({ + analyticsReader: analytics, + }), + }), + ); + await app.ready(); +}); +afterAll(async () => { + await app.close(); + await prisma.escrow.deleteMany({ where: { chainDeliveryId: { in: ids } } }); + await prisma.delivery.deleteMany({ where: { chainDeliveryId: { in: ids } } }); + await prisma.$disconnect(); + await disconnectPrisma(); +}); + +it.each([true, false])( + 'persists funded token decision %s across release and the API without changing authorization', + async (verified) => { + const id = BigInt(Date.now()) * 1000000n + BigInt(ids.length); + ids.push(id); + await prisma.delivery.create({ + data: { + chainDeliveryId: id, + senderAddress: 'sender', + recipientAddress: 'recipient', + origin: 'A', + destination: 'B', + cargoCategory: 'GENERAL', + weightGrams: 1, + fragile: false, + createdAtChain: new Date(), + }, + }); + const token = verified ? allowed : rogue; + const reader = createFakeEscrowContractReader(); + // A supplied chain-side flag must never override the local allowlist decision. + reader.seed( + id, + buildChainEscrowRecord({ chainDeliveryId: id, token, amount: 123n, isVerified: !verified }), + ); + const sync = createSyncEscrowFromEventUseCase({ + escrowRepository: repository, + contractReader: reader, + allowedTokens: [allowed], + }); + await sync(buildEscrowEvent({ topic: ['escrow_funded', id.toString()] })); + expect((await repository.findByChainDeliveryId(id))?.isVerified).toBe(verified); + await sync( + buildEscrowEvent({ + topic: ['escrow_released', id.toString()], + payload: ['sender', 'recipient', '1'], + }), + ); + expect((await repository.findByChainDeliveryId(id))?.isVerified).toBe(verified); + const row = (await analytics.getGmvByToken()).find((entry) => entry.token === token); + expect(row).toEqual(verified ? { token, releasedAmount: 123n, releasedCount: 1 } : undefined); + const response = await app.inject({ method: 'GET', url: `/escrow/${id}` }); + expect(response.statusCode).toBe(200); + expect(response.json().data).toMatchObject({ + chainDeliveryId: id.toString(), + token, + amount: '123', + status: 'RELEASED', + }); + // Existing public DTO omits this internal analytics flag for either state. + expect(response.json().data).not.toHaveProperty('isVerified'); + expect( + (await app.inject({ method: 'POST', url: '/transactions/build/release-escrow', payload: {} })) + .statusCode, + ).toBe(401); + }, +); + +it('keeps GMV restricted to authoritative administrator roles for either email state', async () => { + expect((await app.inject({ method: 'GET', url: '/analytics/gmv' })).statusCode).toBe(401); + for (const emailVerifiedAt of [null, new Date('2026-01-01T00:00:00Z')]) { + for (const role of ['CUSTOMER', 'ADMIN'] as const) { + const user = await prisma.user.create({ + data: { + email: `${randomUUID()}@verification.invalid`, + passwordHash: 'synthetic', + role, + emailVerifiedAt, + }, + }); + try { + // A forged role claim must not elevate a customer in the primary database. + const accessToken = signAccessToken({ sub: user.id, role: 'ADMIN', tokenVersion: 0 }); + const response = await app.inject({ + method: 'GET', + url: '/analytics/gmv', + headers: { authorization: `Bearer ${accessToken}` }, + }); + expect(response.statusCode).toBe(role === 'ADMIN' ? 200 : 403); + expect( + (await prisma.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerifiedAt, + ).toEqual(emailVerifiedAt); + } finally { + await prisma.user.delete({ where: { id: user.id } }); + } + } + } +}); diff --git a/tests/event-bus-composition.spec.ts b/tests/event-bus-composition.spec.ts new file mode 100644 index 0000000..df65b4d --- /dev/null +++ b/tests/event-bus-composition.spec.ts @@ -0,0 +1,235 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import Fastify from 'fastify'; +import { createMetricsRoutes } from '../src/shared/http/routes/metrics.js'; +import * as events from '../src/shared/events/index.js'; +import { createInProcessEventBus } from '../src/modules/indexer/infrastructure/in-process-event-bus.js'; +import { createRecordActorActivityFromEventUseCase } from '../src/modules/fraud-detection/application/record-actor-activity-from-event.js'; +import { subscribeFraudDetectionEventDispatch } from '../src/modules/fraud-detection/infrastructure/event-subscription.js'; +import { subscribeDeliveryEventSync } from '../src/modules/deliveries/infrastructure/event-subscription.js'; +import { createInMemoryActorActivityRepository } from '../src/modules/fraud-detection/application/__fixtures__/fakes.js'; +import type { ActorActivityRepository } from '../src/modules/fraud-detection/domain/index.js'; +import { eventSubscriberErrorsTotal } from '../src/shared/metrics/index.js'; +import { testAddress } from '../src/shared/testing/addresses.js'; + +function buildEvent(): events.BlockchainEventEnvelope { + return { + contractName: 'delivery', + network: 'testnet', + rpcEventId: 'composition-event', + ledgerSeq: 42n, + txHash: 'composition-transaction', + topic: ['delivery_created'], + payload: ['7', testAddress('sender')], + closedAt: new Date('2026-01-01T12:00:00Z'), + }; +} + +function createGate() { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +describe('real in-process event-bus composition', () => { + const cleanup: Array<() => void> = []; + + afterEach(() => { + while (cleanup.length) cleanup.pop()?.(); + }); + + it('exports the runtime blockchain contract alongside the actor-alert factory', async () => { + const loaded = await import('../src/shared/events/index.js'); + for (const name of [ + 'publishBlockchainEvent', + 'onBlockchainEvent', + 'subscribeBlockchainEventHandler', + 'parseAddress', + 'parseBigIntId', + 'createActorFlaggedEvent', + ] as const) { + expect(loaded[name]).toBeTypeOf('function'); + } + expect(loaded.EVENT_DISPATCH_CONCURRENCY).toBe(10); + expect(loaded.ContractName.Delivery).toBe('delivery'); + expect(loaded.parseAddress(testAddress('sender'))).toBe(testAddress('sender')); + expect(loaded.parseBigIntId('7')).toBe(7n); + const payload: events.ActorFlaggedPayload = { + address: testAddress('sender'), + signals: ['rule'], + }; + const alert: events.ActorFlaggedEvent = loaded.createActorFlaggedEvent(payload); + expect(alert).toEqual({ type: 'actor_flagged', payload, occurredAt: expect.any(Date) }); + }); + + it('registers the counter once in the shared registry and serves its labels through the real metrics route', async () => { + const metrics = await import('../src/shared/metrics/index.js'); + const definitions = await import('../src/shared/metrics/registry.js'); + expect(metrics.registry).toBe(definitions.registry); + expect(metrics.eventSubscriberErrorsTotal).toBe(definitions.eventSubscriberErrorsTotal); + expect(metrics.registry.getSingleMetric('event_subscriber_errors_total')).toBe( + eventSubscriberErrorsTotal, + ); + expect(metrics.registry.getSingleMetric('worker_heartbeat_failures_total')).toBe( + definitions.workerHeartbeatFailuresTotal, + ); + expect( + (await metrics.registry.getMetricsAsJSON()).filter( + (metric) => metric.name === 'event_subscriber_errors_total', + ), + ).toHaveLength(1); + cleanup.push( + events.onBlockchainEvent(() => { + throw new Error('isolated metrics probe'); + }), + ); + await createInProcessEventBus().publish({ ...buildEvent(), topic: ['metrics_contract_probe'] }); + let refreshes = 0; + const app = Fastify({ logger: false }); + try { + await app.register( + createMetricsRoutes({ + refreshExternalGauges: async () => { + refreshes += 1; + }, + }), + ); + const response = await app.inject({ method: 'GET', url: '/metrics' }); + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toBe(metrics.registry.contentType); + expect(refreshes).toBe(1); + expect(response.body).toContain('# TYPE event_subscriber_errors_total counter'); + expect(response.body).toContain( + 'event_subscriber_errors_total{contract="delivery",event="metrics_contract_probe",subscriber="onBlockchainEvent"} 1', + ); + expect(response.body).toContain('# TYPE worker_heartbeat_failures_total counter'); + } finally { + await app.close(); + } + }); + + it('delivers the original envelope through the indexer adapter and real fraud subscription', async () => { + const repository = createInMemoryActorActivityRepository(); + const record = createRecordActorActivityFromEventUseCase({ activityRepository: repository }); + const off = subscribeFraudDetectionEventDispatch(record); + cleanup.push(off); + const received: events.BlockchainEventEnvelope[] = []; + cleanup.push( + events.onBlockchainEvent((event) => { + received.push(event); + }), + ); + const bus = createInProcessEventBus(); + const event = buildEvent(); + + await bus.publish(event); + + expect(received).toEqual([event]); + expect(received[0]).toBe(event); + expect(repository.all()).toEqual([ + { address: testAddress('sender'), category: 'DELIVERY_CREATED', occurredAt: event.closedAt }, + ]); + off(); + await bus.publish({ ...event, rpcEventId: 'after-unsubscribe' }); + expect(repository.all()).toHaveLength(1); + }); + + it('awaits completion of the real fraud handler before publication resolves', async () => { + const repository = createInMemoryActorActivityRepository(); + const entered = createGate(); + const finish = createGate(); + const activityRepository: ActorActivityRepository = { + ...repository, + async record(input) { + entered.release(); + await finish.promise; + await repository.record(input); + }, + }; + cleanup.push( + subscribeFraudDetectionEventDispatch( + createRecordActorActivityFromEventUseCase({ activityRepository }), + ), + ); + let settled = false; + const published = Promise.resolve(createInProcessEventBus().publish(buildEvent())).then(() => { + settled = true; + }); + try { + await entered.promise; + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + expect(repository.all()).toEqual([]); + } finally { + finish.release(); + await published; + } + expect(settled).toBe(true); + expect(repository.all()).toHaveLength(1); + }); + + it('counts a real subscriber rejection once, resolves publication, and continues delivery', async () => { + const repository = createInMemoryActorActivityRepository(); + let fail = true; + const activityRepository: ActorActivityRepository = { + ...repository, + async record(input) { + if (fail) throw new Error('isolated repository failure'); + await repository.record(input); + }, + }; + cleanup.push( + subscribeFraudDetectionEventDispatch( + createRecordActorActivityFromEventUseCase({ activityRepository }), + ), + ); + const labels = { + contract: 'delivery', + event: 'delivery_created', + subscriber: 'Failed to record actor activity from blockchain event', + }; + const failureCount = async () => { + const metric = await eventSubscriberErrorsTotal.get(); + return ( + metric.values.find( + (value) => + value.labels.contract === labels.contract && + value.labels.event === labels.event && + value.labels.subscriber === labels.subscriber, + )?.value ?? 0 + ); + }; + const before = await failureCount(); + const bus = createInProcessEventBus(); + await expect(bus.publish(buildEvent())).resolves.toBeUndefined(); + expect(await failureCount()).toBe(before + 1); + expect(repository.all()).toEqual([]); + fail = false; + await bus.publish({ ...buildEvent(), rpcEventId: 'following-event' }); + expect(repository.all()).toHaveLength(1); + expect(await failureCount()).toBe(before + 1); + }); + + it('retains the delivery subscription wrapper’s existing fire-and-forget behavior', async () => { + const entered = createGate(); + const finish = createGate(); + let completed = false; + cleanup.push( + subscribeDeliveryEventSync(async () => { + entered.release(); + await finish.promise; + completed = true; + }), + ); + try { + await createInProcessEventBus().publish(buildEvent()); + await entered.promise; + expect(completed).toBe(false); + } finally { + finish.release(); + await new Promise((resolve) => setImmediate(resolve)); + } + expect(completed).toBe(true); + }); +}); diff --git a/tests/evidence-storage-composition.spec.ts b/tests/evidence-storage-composition.spec.ts new file mode 100644 index 0000000..5c2a741 --- /dev/null +++ b/tests/evidence-storage-composition.spec.ts @@ -0,0 +1,194 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { PrismaClient } from '@prisma/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createLocalEvidenceStorage } from '../src/modules/disputes/infrastructure/local-evidence-storage.js'; +import { createUploadEvidenceUseCase } from '../src/modules/disputes/application/upload-evidence.js'; +import { createDownloadEvidenceUseCase } from '../src/modules/disputes/application/download-evidence.js'; +import { createGetDisputeUseCase } from '../src/modules/disputes/application/get-dispute.js'; +import { + EvidenceNotFoundError, + ForbiddenEvidenceAccessError, +} from '../src/modules/disputes/domain/index.js'; +import { + buildChainDisputeCase, + buildDispute, + createFakeDisputeContractReader, + createFakeEvidenceStorage, + createFakeWalletOwnershipRepository, + createInMemoryDisputeRepository, + createInMemoryEvidenceRepository, +} from '../src/modules/disputes/application/__fixtures__/fakes.js'; + +describe('evidence storage port and composition', () => { + let baseDir: string; + + beforeEach(async () => { + baseDir = await mkdtemp(join(tmpdir(), 'evidence-port-')); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await rm(baseDir, { recursive: true, force: true }); + }); + + it('round-trips upload/download through real storage without changing ownership or hash confirmation', async () => { + const evidenceStorage = createLocalEvidenceStorage(baseDir); + const disputeRepository = createInMemoryDisputeRepository(); + const evidenceRepository = createInMemoryEvidenceRepository(); + const walletOwnershipRepository = createFakeWalletOwnershipRepository(); + const dispute = buildDispute(); + disputeRepository.seed(dispute); + walletOwnershipRepository.seed('uploader', dispute.raisedBy); + const upload = createUploadEvidenceUseCase({ + evidenceStorage, + disputeRepository, + evidenceRepository, + walletOwnershipRepository, + }); + const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 42]); + const { evidence } = await upload({ + chainDeliveryId: dispute.chainDeliveryId, + uploadedBy: dispute.raisedBy, + requesterId: 'uploader', + contentType: 'image/png', + bytes, + }); + expect(evidence.hash).toBe(createHash('sha256').update(bytes).digest('hex')); + expect(evidence.uploadedByUserId).toBe('uploader'); + expect(evidence.storageUrl.startsWith(`${dispute.id}/`)).toBe(true); + expect(await readFile(join(baseDir, evidence.storageUrl))).toEqual(bytes); + + const download = createDownloadEvidenceUseCase({ + evidenceStorage, + disputeRepository, + evidenceRepository, + walletOwnershipRepository, + }); + await expect( + download({ evidenceId: evidence.id, requesterId: 'stranger', requesterRole: 'CUSTOMER' }), + ).rejects.toBeInstanceOf(ForbiddenEvidenceAccessError); + await expect( + download({ evidenceId: evidence.id, requesterId: 'uploader', requesterRole: 'CUSTOMER' }), + ).resolves.toEqual({ contentType: 'image/png', bytes }); + + const contractReader = createFakeDisputeContractReader(); + contractReader.seed( + dispute.chainDeliveryId, + buildChainDisputeCase({ evidenceHashes: [evidence.hash] }), + ); + const getDispute = createGetDisputeUseCase({ + disputeRepository, + evidenceRepository, + contractReader, + }); + expect( + (await getDispute({ chainDeliveryId: dispute.chainDeliveryId })).evidence[0] + ?.confirmedOnChain, + ).toBe(true); + contractReader.seed(dispute.chainDeliveryId, buildChainDisputeCase({ evidenceHashes: [] })); + expect( + (await getDispute({ chainDeliveryId: dispute.chainDeliveryId })).evidence[0] + ?.confirmedOnChain, + ).toBe(false); + await evidenceStorage.delete(evidence.storageUrl); + await expect( + download({ evidenceId: evidence.id, requesterId: 'uploader', requesterRole: 'CUSTOMER' }), + ).rejects.toBeInstanceOf(EvidenceNotFoundError); + }); + + it.each(['dispute/old-file', 'local://evidence/dispute/uuid-photo.png'])( + 'reads and deletes an existing key without rewriting it: %s', + async (storageUrl) => { + const relative = storageUrl.replace('local://evidence/', ''); + const absolute = join(baseDir, relative); + await mkdir(join(baseDir, 'dispute')); + await writeFile(absolute, 'existing-evidence'); + const storage = createLocalEvidenceStorage(baseDir); + await expect(storage.read(storageUrl)).resolves.toEqual(Buffer.from('existing-evidence')); + await storage.delete(storageUrl); + await expect(stat(absolute)).rejects.toThrow(); + await expect(storage.delete(storageUrl)).resolves.toBeUndefined(); + await expect(stat(baseDir)).resolves.toBeDefined(); + }, + ); + + it('deletes only the selected file, preserving siblings and the base directory', async () => { + const storage = createLocalEvidenceStorage(baseDir); + const first = await storage.save({ + disputeId: 'shared', + contentType: 'application/pdf', + bytes: Buffer.from('first'), + }); + const second = await storage.save({ + disputeId: 'shared', + contentType: 'application/pdf', + bytes: Buffer.from('second'), + }); + await storage.delete(first.storageUrl); + await expect(storage.read(second.storageUrl)).resolves.toEqual(Buffer.from('second')); + await expect(stat(join(baseDir, 'shared'))).resolves.toBeDefined(); + await storage.delete(second.storageUrl); + await expect(stat(join(baseDir, 'shared'))).rejects.toThrow(); + await expect(stat(baseDir)).resolves.toBeDefined(); + }); + + it('rejects traversal and base-directory references for read, delete and save', async () => { + const storage = createLocalEvidenceStorage(baseDir); + const outside = `${baseDir}-outside`; + await writeFile(outside, 'untouched'); + try { + for (const key of [ + '../' + outside.split('/').pop(), + 'local://evidence/../../outside', + outside, + '.', + 'local://evidence/', + ]) { + await expect(storage.read(key)).rejects.toBeInstanceOf(EvidenceNotFoundError); + await expect(storage.delete(key)).rejects.toBeInstanceOf(EvidenceNotFoundError); + } + await expect( + storage.save({ + disputeId: '../escape', + contentType: 'image/png', + bytes: Buffer.from('bad'), + }), + ).rejects.toBeInstanceOf(EvidenceNotFoundError); + expect(await readFile(outside, 'utf8')).toBe('untouched'); + } finally { + await rm(outside, { force: true }); + } + }); + + it('implements fixture deletion rather than silently retaining deleted bytes', async () => { + const storage = createFakeEvidenceStorage(); + const { storageUrl } = await storage.save({ + disputeId: 'fake', + contentType: 'image/png', + bytes: Buffer.from('fixture'), + }); + await expect(storage.read(storageUrl)).resolves.toEqual(Buffer.from('fixture')); + await storage.delete(storageUrl); + await expect(storage.read(storageUrl)).rejects.toThrow(); + await expect(storage.delete(storageUrl)).resolves.toBeUndefined(); + }); + + it('constructs the actual production dispute module with a temporary configured storage directory', async () => { + // Reparse config for this isolated composition probe, without mocking the factory. + vi.resetModules(); + vi.stubEnv('EVIDENCE_STORAGE_DIR', join(baseDir, 'configured')); + vi.stubEnv('DISPUTE_RESOLUTION_CONTRACT_ID', ''); + vi.stubEnv('ESCROW_CONTRACT_ID', ''); + const { createDisputesModule } = await import('../src/modules/disputes/index.js'); + const prisma = new PrismaClient(); + try { + expect(createDisputesModule(prisma)).toBeTypeOf('function'); + await expect(stat(join(baseDir, 'configured'))).resolves.toBeDefined(); + } finally { + await prisma.$disconnect(); + } + }); +}); diff --git a/tests/fleet-error-contracts.integration.spec.ts b/tests/fleet-error-contracts.integration.spec.ts new file mode 100644 index 0000000..74bc547 --- /dev/null +++ b/tests/fleet-error-contracts.integration.spec.ts @@ -0,0 +1,231 @@ +import { randomUUID } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import Fastify from 'fastify'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + DriverAlreadyInFleetError, + DriverFleetRecordNotFoundError, + DriverPayoutAddressNotFoundError, + FleetNotFoundError, +} from '../src/modules/fleet/domain/index.js'; +import { AppError } from '../src/shared/errors/app-error.js'; +import { handleError } from '../src/shared/errors/index.js'; +import { createFleetModule } from '../src/modules/fleet/index.js'; +import { createPrismaFleetRepository } from '../src/modules/fleet/infrastructure/prisma-fleet-repository.js'; +import { createBuildFleetTransactionsUseCases } from '../src/modules/fleet/application/build-fleet-transactions.js'; +import { createFakeFleetTransactionBuilder } from '../src/modules/fleet/application/__fixtures__/fakes.js'; +import { createGetFleetUseCase } from '../src/modules/fleet/application/get-fleet.js'; +import { createGetPayoutAddressUseCase } from '../src/modules/fleet/application/get-payout-address.js'; +import { createFleetRoutes } from '../src/modules/fleet/interface/routes.js'; +import { disconnectPrisma } from '../src/shared/database/index.js'; +import { signAccessToken } from '../src/shared/jwt/index.js'; +import { testAddress } from '../src/shared/testing/addresses.js'; + +const prisma = new PrismaClient(); +const repository = createPrismaFleetRepository(prisma); +const app = Fastify(); +const chainFleetId = 9007199254740993n; +const driverAddress = testAddress('fleet-errors-driver'); +const callerAddress = testAddress('fleet-errors-owner'); +let userId: string; +let token: string; +const active = vi.fn<(id: bigint, driver: string) => Promise>(); +const transactionBuilder = createFakeFleetTransactionBuilder(); +const build = vi.spyOn(transactionBuilder, 'buildAddDriverToFleet'); +const buildTransactions = createBuildFleetTransactionsUseCases({ + transactionBuilder, + fleetRepository: { isDriverActiveInFleet: active }, +}); + +beforeAll(async () => { + const user = await prisma.user.create({ + data: { email: `${randomUUID()}@fleet-errors.invalid`, passwordHash: 'fixture-only' }, + }); + userId = user.id; + token = signAccessToken({ sub: userId, role: 'CUSTOMER', tokenVersion: 0 }); + app.setValidatorCompiler(validatorCompiler); + app.setSerializerCompiler(serializerCompiler); + app.setErrorHandler(handleError); + await app.register(createFleetModule(prisma)); + // Exercise the existing optional guard; production composition remains unchanged. + await app.register( + createFleetRoutes({ + getFleet: createGetFleetUseCase({ fleetRepository: repository }), + getPayoutAddress: createGetPayoutAddressUseCase({ + contractReader: { + async getPayoutAddress() { + throw new DriverPayoutAddressNotFoundError(); + }, + }, + }), + buildTransactions, + }), + { prefix: '/guarded' }, + ); + await app.ready(); +}); + +afterAll(async () => { + await app.close(); + await prisma.fleet.deleteMany({ where: { chainFleetId } }); + await prisma.user.deleteMany({ where: { id: userId } }); + await prisma.$disconnect(); + await disconnectPrisma(); +}); + +describe('canonical fleet errors', () => { + it.each([ + [new FleetNotFoundError(), 404, 'NOT_FOUND', 'Fleet not found'], + [ + new DriverFleetRecordNotFoundError(), + 404, + 'NOT_FOUND', + 'No fleet-membership record found for this driver', + ], + [ + new DriverPayoutAddressNotFoundError(), + 404, + 'DRIVER_NOT_FOUND', + 'Driver payout address not found', + ], + [ + new DriverAlreadyInFleetError(driverAddress, chainFleetId), + 409, + 'CONFLICT', + `Driver ${driverAddress} is already active in fleet ${chainFleetId}`, + ], + ] as const)('satisfies the application error contract (%s)', (error, status, code, message) => { + expect(error).toBeInstanceOf(AppError); + expect(error.statusCode).toBe(status); + expect(error.code).toBe(code); + expect(error.message).toBe(message); + expect(error.name).toBe(error.constructor.name); + expect(error.details).toBeUndefined(); + }); + + it('keeps constructor custom messages without misusing status as details', () => { + expect(new FleetNotFoundError('custom')).toMatchObject({ + message: 'custom', + statusCode: 404, + code: 'NOT_FOUND', + details: undefined, + }); + }); +}); + +describe('real fleet HTTP/repository contracts', () => { + const headers = () => ({ authorization: `Bearer ${token}` }); + const payload = { callerAddress, driverAddress, chainFleetId: chainFleetId.toString() }; + const url = '/guarded/transactions/build/add-driver-to-fleet'; + + it('authenticates fleet reads before returning the historical not-found mapping', async () => { + expect((await app.inject({ url: `/fleets/${chainFleetId}` })).statusCode).toBe(401); + const response = await app.inject({ url: `/fleets/${chainFleetId}`, headers: headers() }); + expect(response.statusCode).toBe(404); + expect(response.json().error).toEqual({ code: 'NOT_FOUND', message: 'Fleet not found' }); + }); + + it('rejects anonymous and revoked-token invitations before invoking the optional guard', async () => { + active.mockClear(); + expect((await app.inject({ method: 'POST', url, payload })).statusCode).toBe(401); + const revoked = signAccessToken({ sub: userId, role: 'CUSTOMER', tokenVersion: -1 }); + expect( + ( + await app.inject({ + method: 'POST', + url, + payload, + headers: { authorization: `Bearer ${revoked}` }, + }) + ).statusCode, + ).toBe(401); + expect(active).not.toHaveBeenCalled(); + }); + + it('maps active-driver rejection to 409 and retains bigint identity without building', async () => { + active.mockResolvedValue(true); + build.mockClear(); + const response = await app.inject({ method: 'POST', url, payload, headers: headers() }); + expect(response.statusCode).toBe(409); + expect(response.json().error.code).toBe('CONFLICT'); + expect(response.json().error.message).toContain(chainFleetId.toString()); + expect(active).toHaveBeenCalledWith(chainFleetId, driverAddress); + expect(build).not.toHaveBeenCalled(); + }); + + it('preserves false, absent and failed optional-check paths', async () => { + active.mockResolvedValue(false); + const input = { callerAddress, driverAddress, chainFleetId }; + await expect(buildTransactions.buildAddDriverToFleetTransaction(input)).resolves.toBe( + 'unsigned-xdr:add-driver-to-fleet', + ); + expect(build).toHaveBeenCalledWith(input); + await expect( + createBuildFleetTransactionsUseCases({ transactionBuilder }).buildAddDriverToFleetTransaction( + input, + ), + ).resolves.toBe('unsigned-xdr:add-driver-to-fleet'); + const failure = new Error('fixture read failure'); + active.mockRejectedValue(failure); + build.mockClear(); + await expect(buildTransactions.buildAddDriverToFleetTransaction(input)).rejects.toBe(failure); + expect(build).not.toHaveBeenCalled(); + }); + + it('preserves the existing payout error HTTP status and public read policy', async () => { + const response = await app.inject({ + url: `/guarded/fleets/${chainFleetId}/payout-address/${driverAddress}`, + }); + expect(response.statusCode).toBe(404); + expect(response.json().error).toEqual({ + code: 'DRIVER_NOT_FOUND', + message: 'Driver payout address not found', + }); + }); + + it('retains invite, accept, remove, historical listing and re-invite persistence', async () => { + await repository.create({ + chainFleetId, + ownerAddress: callerAddress, + treasuryAddress: callerAddress, + }); + const invitedAt = new Date('2026-01-01T00:00:00.000Z'); + const acceptedAt = new Date('2026-01-02T00:00:00.000Z'); + const removedAt = new Date('2026-01-03T00:00:00.000Z'); + await repository.inviteDriver(chainFleetId, driverAddress, invitedAt); + await repository.acceptInvite(chainFleetId, driverAddress, acceptedAt); + const activeFleet = await repository.findByChainFleetId(chainFleetId); + expect(activeFleet?.totalActiveDrivers).toBe(1); + expect(activeFleet?.drivers[0]).toMatchObject({ + status: 'ACTIVE', + invitedAt, + acceptedAt, + removedAt: null, + }); + await repository.removeDriver(chainFleetId, driverAddress, removedAt); + expect((await repository.findByChainFleetId(chainFleetId))?.drivers).toEqual([]); + const historical = await repository.findByChainFleetId(chainFleetId, { includeRemoved: true }); + expect(historical?.totalActiveDrivers).toBe(0); + expect(historical?.drivers[0]).toMatchObject({ + status: 'ACTIVE', + invitedAt, + acceptedAt, + removedAt, + }); + const response = await app.inject({ + url: `/fleets/${chainFleetId}?includeRemoved=true`, + headers: headers(), + }); + expect(response.statusCode).toBe(200); + expect(response.json().data.drivers[0].removedAt).toBe(removedAt.toISOString()); + await repository.inviteDriver(chainFleetId, driverAddress, invitedAt); + expect((await repository.findByChainFleetId(chainFleetId))?.drivers[0]).toMatchObject({ + id: historical?.drivers[0]?.id, + status: 'PENDING', + invitedAt, + acceptedAt: null, + removedAt: null, + }); + }); +}); diff --git a/tests/fleet-list-contracts.integration.spec.ts b/tests/fleet-list-contracts.integration.spec.ts new file mode 100644 index 0000000..1d1602c --- /dev/null +++ b/tests/fleet-list-contracts.integration.spec.ts @@ -0,0 +1,158 @@ +import { randomUUID } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import Fastify from 'fastify'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createFleetModule } from '../src/modules/fleet/index.js'; +import { createPrismaFleetRepository } from '../src/modules/fleet/infrastructure/prisma-fleet-repository.js'; +import { + buildFleetWithDrivers, + createInMemoryFleetRepository, +} from '../src/modules/fleet/application/__fixtures__/fakes.js'; +import { ValidationError, handleError } from '../src/shared/errors/index.js'; +import { disconnectPrisma } from '../src/shared/database/index.js'; +import { signAccessToken } from '../src/shared/jwt/index.js'; +import { testAddress } from '../src/shared/testing/addresses.js'; + +const prisma = new PrismaClient(); +const repository = createPrismaFleetRepository(prisma); +const fake = createInMemoryFleetRepository(); +const app = Fastify(); +const owner = testAddress('fleet-list-owner'); +const other = testAddress('fleet-list-other'); +const ids: string[] = []; +const chainBase = 9007199254700000n; +let userId: string; +let token: string; + +beforeAll(async () => { + for (let i = 1; i <= 25; i++) { + const id = randomUUID(); + ids.push(id); + const record = { + id, + chainFleetId: chainBase + BigInt(i), + ownerAddress: i <= 22 ? owner : other, + treasuryAddress: owner, + createdAt: new Date(i <= 2 ? '2026-01-01T00:00:00Z' : '2026-02-01T00:00:00Z'), + updatedAt: new Date('2026-02-01T00:00:00Z'), + }; + await prisma.fleet.create({ data: record }); + fake.seed(buildFleetWithDrivers({ ...record, drivers: [] })); + } + const user = await prisma.user.create({ + data: { email: `${randomUUID()}@fleet-list.invalid`, passwordHash: 'fixture-only' }, + }); + userId = user.id; + token = signAccessToken({ sub: userId, role: 'CUSTOMER', tokenVersion: 0 }); + app.setValidatorCompiler(validatorCompiler); + app.setSerializerCompiler(serializerCompiler); + app.setErrorHandler(handleError); + await app.register(createFleetModule(prisma)); + await app.ready(); +}); + +afterAll(async () => { + await app.close(); + await prisma.fleet.deleteMany({ where: { id: { in: ids } } }); + await prisma.user.deleteMany({ where: { id: userId } }); + await prisma.$disconnect(); + await disconnectPrisma(); +}); + +describe.each([ + ['Prisma', repository], + ['in-memory', fake], +] as const)('%s owner-list contract', (_name, repo) => { + it('returns the default first20 for exactly one wallet, newest first with deterministic ties', async () => { + const result = await repo.listFleetsByOwner(owner); + expect(result).toHaveLength(20); + expect(result.every((fleet) => fleet.ownerAddress === owner)).toBe(true); + const expected = ids.slice(2, 22).sort().reverse(); + expect(result.map((fleet) => fleet.id)).toEqual(expected); + expect((await repo.listFleetsByOwner(owner)).map((fleet) => fleet.id)).toEqual(expected); + }); + + it('paginates without overlaps, preserves page size at the last page and returns empty beyond it', async () => { + const first = await repo.listFleetsByOwner(owner, { page: 1, limit: 20 }); + const second = await repo.listFleetsByOwner(owner, { page: 2, limit: 20 }); + expect(second.map((fleet) => fleet.id)).toEqual(ids.slice(0, 2).sort().reverse()); + expect(new Set([...first, ...second].map((fleet) => fleet.id)).size).toBe(22); + expect(await repo.listFleetsByOwner(owner, { page: 3, limit: 20 })).toEqual([]); + expect(await repo.listFleetsByOwner(owner, { page: 23, limit: 1 })).toEqual([]); + expect(await repo.listFleetsByOwner(owner, { page: 22, limit: 1 })).toHaveLength(1); + }); + + it('does not treat a user ID, another wallet, or empty owner as an ownership wildcard', async () => { + expect(await repo.listFleetsByOwner(userId)).toEqual([]); + expect(await repo.listFleetsByOwner('')).toEqual([]); + const theirs = await repo.listFleetsByOwner(other); + expect(theirs).toHaveLength(3); + expect(theirs.every((fleet) => fleet.ownerAddress === other)).toBe(true); + }); + + it.each([ + { page: 0 }, + { page: -1 }, + { page: 1.5 }, + { limit: 0 }, + { limit: -1 }, + { limit: Infinity }, + { page: Number.MAX_SAFE_INTEGER, limit: 2 }, + ])('rejects invalid pagination %j', async (options) => { + await expect(repo.listFleetsByOwner(owner, options)).rejects.toBeInstanceOf(ValidationError); + }); +}); + +describe('existing fleet HTTP visibility and boundaries', () => { + const url = `/fleets/${chainBase + 1n}`; + const headers = () => ({ authorization: `Bearer ${token}` }); + it('preserves authentication and the absence of an owner-list HTTP endpoint', async () => { + expect((await app.inject({ url })).statusCode).toBe(401); + expect((await app.inject({ url: '/fleets', headers: headers() })).statusCode).toBe(404); + }); + + it('bounds current/historical members without changing active counts or persisted removal', async () => { + const fleetId = chainBase + 1n; + const drivers = [ + testAddress('list-active-a'), + testAddress('list-active-b'), + testAddress('list-removed'), + ]; + for (const [index, driver] of drivers.entries()) { + await repository.inviteDriver(fleetId, driver, new Date(Date.UTC(2026, 0, index + 1))); + await repository.acceptInvite(fleetId, driver, new Date('2026-01-04T00:00:00Z')); + } + await repository.removeDriver(fleetId, drivers[2]!, new Date('2026-01-05T00:00:00Z')); + const current = await app.inject({ + url: url + '?includeRemoved=false&driverLimit=1', + headers: headers(), + }); + expect(current.statusCode).toBe(200); + expect(current.json().data.drivers).toHaveLength(1); + expect(current.json().data.drivers[0].removedAt).toBeNull(); + expect(current.json().data.totalActiveDrivers).toBe(2); + const defaults = await app.inject({ url, headers: headers() }); + expect(defaults.json().data.drivers).toHaveLength(2); + const history = await app.inject({ + url: url + '?includeRemoved=true&driverLimit=1', + headers: headers(), + }); + expect(history.json().data.drivers).toHaveLength(1); + expect(history.json().data.drivers[0].driverAddress).toBe(drivers[2]); + expect(history.json().data.drivers[0].removedAt).toBe('2026-01-05T00:00:00.000Z'); + expect(history.json().data.totalActiveDrivers).toBe(2); + expect( + (await repository.findByChainFleetId(fleetId, { includeRemoved: true }))?.drivers, + ).toHaveLength(3); + }); + + it.each(['driverLimit=0', 'driverLimit=501', 'driverLimit=1.5', 'includeRemoved=maybe'])( + 'rejects invalid HTTP boundaries %s', + async (query) => { + const response = await app.inject({ url: url + '?' + query, headers: headers() }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('VALIDATION_ERROR'); + }, + ); +}); diff --git a/tests/fleet-xdr-compatibility.spec.ts b/tests/fleet-xdr-compatibility.spec.ts new file mode 100644 index 0000000..9dfc658 --- /dev/null +++ b/tests/fleet-xdr-compatibility.spec.ts @@ -0,0 +1,252 @@ +import { + Account, + Address, + Contract, + Keypair, + SorobanDataBuilder, + Transaction, + TransactionBuilder, + xdr, +} from '@stellar/stellar-sdk'; +import type { FeeBumpTransaction, rpc } from '@stellar/stellar-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SorobanClient } from '../src/blockchain/soroban-client.js'; +import { addressToScVal, scValToNative } from '../src/blockchain/xdr/sc-val.js'; +import * as config from '../src/shared/config/index.js'; +import { parseEnv } from '../src/shared/config/env.js'; +import { AppError } from '../src/shared/errors/app-error.js'; +import type { + FleetContractReader, + FleetTransactionBuilder, +} from '../src/modules/fleet/domain/ports.js'; +import { DriverPayoutAddressNotFoundError } from '../src/modules/fleet/domain/errors.js'; +import { createSorobanFleetContractClient } from '../src/modules/fleet/infrastructure/soroban-fleet-contract-client.js'; +import { createGetPayoutAddressUseCase } from '../src/modules/fleet/application/get-payout-address.js'; + +const CONTRACT_ID = Address.contract(Buffer.alloc(32, 7)).toString(); +const NETWORK = 'M14 isolated custom network passphrase'; +const OWNER = Keypair.random().publicKey(); +const DRIVER = Keypair.random().publicKey(); +const TREASURY = Keypair.random().publicKey(); + +function setup() { + const client = new SorobanClient('http://127.0.0.1:9'); + const getAccount = vi + .spyOn(client, 'getAccount') + .mockImplementation(async (source) => new Account(source, '100')); + const prepared = new TransactionBuilder(new Account(OWNER, '200'), { + fee: '987', + networkPassphrase: NETWORK, + }) + .addOperation(new Contract(CONTRACT_ID).call('prepared_fixture')) + .setTimeout(60) + .build(); + const prepare = vi.spyOn(client, 'prepareTransaction').mockResolvedValue(prepared); + // Compile-time contract check: the adapter must implement both production ports. + const adapter: FleetContractReader & FleetTransactionBuilder = createSorobanFleetContractClient( + client, + CONTRACT_ID, + ); + return { client, getAccount, prepared, prepare, adapter }; +} + +function invocation(tx: Transaction | FeeBumpTransaction, fee = '100') { + if (!(tx instanceof Transaction)) throw new Error('Expected ordinary transaction'); + expect(tx.signatures).toEqual([]); + expect(tx.networkPassphrase).toBe(NETWORK); + expect(tx.fee).toBe(fee); + expect(tx.operations).toHaveLength(1); + const op = tx.operations[0]; + if (op?.type !== 'invokeHostFunction') throw new Error('Expected contract invocation'); + const call = op.func.invokeContract(); + expect(Address.fromScAddress(call.contractAddress()).toString()).toBe(CONTRACT_ID); + return { tx, method: call.functionName().toString(), args: call.args() }; +} + +function simulation(retval?: xdr.ScVal): rpc.Api.SimulateTransactionSuccessResponse { + return { + id: 'fixture', + latestLedger: 1000, + events: [], + _parsed: true, + transactionData: new SorobanDataBuilder(), + minResourceFee: '100', + cost: { cpuInsns: '1', memBytes: '1' }, + ...(retval ? { result: { auth: [], retval } } : {}), + }; +} + +describe('fleet unsigned-XDR compatibility', () => { + beforeEach(() => { + vi.spyOn(config, 'getConfig').mockReturnValue( + parseEnv({ + ...process.env, + STELLAR_NETWORK_PASSPHRASE: NETWORK, + }), + ); + }); + afterEach(() => vi.restoreAllMocks()); + + const cases = [ + { + method: 'register_fleet', + source: OWNER, + args: [OWNER, TREASURY], + call: (a: FleetTransactionBuilder) => + a.buildRegisterFleet({ ownerAddress: OWNER, treasuryAddress: TREASURY }), + }, + { + method: 'update_fleet_treasury', + source: OWNER, + args: [OWNER, '7', TREASURY], + call: (a: FleetTransactionBuilder) => + a.buildUpdateFleetTreasury({ + ownerAddress: OWNER, + chainFleetId: 7n, + treasuryAddress: TREASURY, + }), + }, + { + method: 'add_driver_to_fleet', + source: OWNER, + args: [OWNER, '7', DRIVER], + call: (a: FleetTransactionBuilder) => + a.buildAddDriverToFleet({ callerAddress: OWNER, chainFleetId: 7n, driverAddress: DRIVER }), + }, + { + method: 'accept_fleet_invite', + source: DRIVER, + args: ['7', DRIVER], + call: (a: FleetTransactionBuilder) => + a.buildAcceptFleetInvite({ chainFleetId: 7n, driverAddress: DRIVER }), + }, + { + method: 'remove_driver_from_fleet', + source: OWNER, + args: ['7', OWNER, DRIVER], + call: (a: FleetTransactionBuilder) => + a.buildRemoveDriverFromFleet({ + callerAddress: OWNER, + chainFleetId: 7n, + driverAddress: DRIVER, + }), + }, + ]; + + it.each(cases)( + '$method uses configured network, exact invocation and prepared unsigned XDR', + async ({ method, source, args, call }) => { + const { adapter, prepared, prepare, getAccount } = setup(); + const before = Math.floor(Date.now() / 1000); + const envelope = await call(adapter); + expect(getAccount).toHaveBeenCalledExactlyOnceWith(source); + expect(prepare).toHaveBeenCalledTimes(1); + const built = invocation(prepare.mock.calls[0]![0]); + expect(built.method).toBe(method); + expect(built.tx.source).toBe(source); + expect(built.tx.sequence).toBe('101'); + expect(built.tx.timeBounds?.minTime).toBe('0'); + expect(Number(built.tx.timeBounds?.maxTime)).toBeGreaterThanOrEqual(before + 60); + expect(Number(built.tx.timeBounds?.maxTime)).toBeLessThanOrEqual( + Math.floor(Date.now() / 1000) + 60, + ); + expect(built.args.map(scValToNative)).toEqual(args); + // Fleet IDs remain bare u64, never a tuple/vector or lossy JS number. + if (method !== 'register_fleet') { + const index = + method === 'accept_fleet_invite' || method === 'remove_driver_from_fleet' ? 0 : 1; + expect(built.args[index]!.switch().name).toBe('scvU64'); + } + expect(envelope).toBe(prepared.toXDR()); + const decoded = TransactionBuilder.fromXDR(envelope, NETWORK); + expect(decoded.signatures).toEqual([]); + expect(decoded.fee).toBe('987'); + expect(invocation(new Transaction(envelope, NETWORK), '987').method).toBe('prepared_fixture'); + }, + ); + + it('preserves account-fetch failure without attempting preparation', async () => { + const { adapter, getAccount, prepare } = setup(); + const failure = new Error('account fixture failure'); + getAccount.mockRejectedValue(failure); + await expect( + adapter.buildRegisterFleet({ ownerAddress: OWNER, treasuryAddress: TREASURY }), + ).rejects.toBe(failure); + expect(prepare).not.toHaveBeenCalled(); + }); + + it('propagates transaction preparation failure unchanged', async () => { + const { adapter, prepare } = setup(); + const failure = new Error('prepare fixture failure'); + prepare.mockRejectedValue(failure); + await expect( + adapter.buildRegisterFleet({ ownerAddress: OWNER, treasuryAddress: TREASURY }), + ).rejects.toBe(failure); + }); + + it('simulates payout with (driver, bare u64 fleet ID) and returns fresh results', async () => { + const { client, adapter, prepare, getAccount } = setup(); + const simulate = vi + .spyOn(client, 'simulateTransaction') + .mockResolvedValueOnce(simulation(addressToScVal(TREASURY))) + .mockResolvedValueOnce(simulation(addressToScVal(DRIVER))); + const getPayout = createGetPayoutAddressUseCase({ contractReader: adapter }); + const fleetId = (1n << 63n) + 7n; + expect(await getPayout({ driverAddress: DRIVER, chainFleetId: fleetId })).toBe(TREASURY); + expect(await getPayout({ driverAddress: DRIVER, chainFleetId: fleetId })).toBe(DRIVER); + expect(getAccount).toHaveBeenCalledWith(DRIVER); + expect(simulate).toHaveBeenCalledTimes(2); + for (const [tx] of simulate.mock.calls) { + const built = invocation(tx); + expect(built.method).toBe('get_payout_address'); + expect(built.args.map(scValToNative)).toEqual([DRIVER, fleetId.toString()]); + expect(built.args[1]!.switch().name).toBe('scvU64'); + } + expect(prepare).not.toHaveBeenCalled(); + }); + + it.each([undefined, xdr.ScVal.scvVoid()])( + 'preserves typed missing-payout errors for %s', + async (retval) => { + const { client, adapter } = setup(); + vi.spyOn(client, 'simulateTransaction').mockResolvedValue(simulation(retval)); + const result = adapter.getPayoutAddress(DRIVER, 7n); + await expect(result).rejects.toBeInstanceOf(DriverPayoutAddressNotFoundError); + await expect(result).rejects.toBeInstanceOf(AppError); + await expect(result).rejects.toMatchObject({ + statusCode: 404, + code: 'DRIVER_NOT_FOUND', + message: 'Driver payout address not found', + }); + }, + ); + + it('rejects a non-address-shaped result instead of returning it', async () => { + const { client, adapter } = setup(); + vi.spyOn(client, 'simulateTransaction').mockResolvedValue(simulation(xdr.ScVal.scvU32(42))); + await expect(adapter.getPayoutAddress(DRIVER, 7n)).rejects.toThrow( + 'Expected get_payout_address to return an Address-as-string', + ); + }); + + it('reports simulation error rather than a missing payout', async () => { + const { client, adapter } = setup(); + vi.spyOn(client, 'simulateTransaction').mockResolvedValue({ + id: 'fixture', + latestLedger: 1, + events: [], + _parsed: true, + error: 'fixture contract failure', + }); + await expect(adapter.getPayoutAddress(DRIVER, 7n)).rejects.toThrow( + 'get_payout_address simulation failed: fixture contract failure', + ); + }); + + it('propagates RPC simulation rejection unchanged', async () => { + const { client, adapter } = setup(); + const failure = new Error('RPC fixture failure'); + vi.spyOn(client, 'simulateTransaction').mockRejectedValue(failure); + await expect(adapter.getPayoutAddress(DRIVER, 7n)).rejects.toBe(failure); + }); +}); diff --git a/tests/fraud-config-composition.spec.ts b/tests/fraud-config-composition.spec.ts new file mode 100644 index 0000000..25b759b --- /dev/null +++ b/tests/fraud-config-composition.spec.ts @@ -0,0 +1,253 @@ +import { PrismaClient } from '@prisma/client'; +import type { BlockchainEvent } from '@prisma/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { parseEnv } from '../src/shared/config/env.js'; +import * as config from '../src/shared/config/index.js'; +import { createFraudDetectionModule } from '../src/modules/fraud-detection/index.js'; +import * as routes from '../src/modules/fraud-detection/interface/routes.js'; +import * as subscriptions from '../src/modules/fraud-detection/infrastructure/event-subscription.js'; +import { createPrismaActorActivityRepository } from '../src/modules/fraud-detection/infrastructure/prisma-actor-activity-repository.js'; +import type { ActorActivityRepository } from '../src/modules/fraud-detection/domain/ports.js'; +import { + RULES, + createAssessActorUseCase, +} from '../src/modules/fraud-detection/application/assess-actor.js'; +import { + createFixedClock, + createInMemoryActorActivityRepository, +} from '../src/modules/fraud-detection/application/__fixtures__/fakes.js'; + +const required = { + DATABASE_URL: 'postgresql://fixture:fixture@localhost:9/fixture', + REDIS_URL: 'redis://127.0.0.1:9', + JWT_ACCESS_SECRET: 'a'.repeat(32), + JWT_REFRESH_SECRET: 'b'.repeat(32), + NODE_ENV: 'production', +}; +const fields = [ + 'FRAUD_DELIVERY_VELOCITY_THRESHOLD', + 'FRAUD_DELIVERY_VELOCITY_WINDOW_HOURS', + 'FRAUD_ESCROW_VELOCITY_THRESHOLD', + 'FRAUD_ESCROW_VELOCITY_WINDOW_HOURS', + 'FRAUD_DISPUTE_VELOCITY_THRESHOLD', + 'FRAUD_DISPUTE_VELOCITY_WINDOW_HOURS', +] as const; +const ledgerTime = new Date('2026-01-01T12:00:00Z'); +const ledgerRow: BlockchainEvent = { + id: 'ledger', + contractName: 'delivery', + network: 'testnet', + rpcEventId: 'event', + ledgerSeq: 123n, + txHash: 'hash', + topic: ['delivery_created'], + payload: [], + ledgerClosedAt: ledgerTime, + ingestedAt: ledgerTime, + processedAt: null, +}; + +afterEach(() => vi.restoreAllMocks()); + +describe('fraud configuration through production composition', () => { + it.each([ + { label: 'defaults', overrides: {}, thresholds: [10, 10, 3], windows: [1, 1, 24] }, + { + label: 'explicit overrides', + overrides: { + FRAUD_DELIVERY_VELOCITY_THRESHOLD: '2', + FRAUD_DELIVERY_VELOCITY_WINDOW_HOURS: '2', + FRAUD_ESCROW_VELOCITY_THRESHOLD: '4', + FRAUD_ESCROW_VELOCITY_WINDOW_HOURS: '3', + FRAUD_DISPUTE_VELOCITY_THRESHOLD: '6', + FRAUD_DISPUTE_VELOCITY_WINDOW_HOURS: '48', + }, + thresholds: [2, 4, 6], + windows: [2, 3, 48], + }, + ])( + 'uses $label in the actual evaluator/repository/ledger-clock graph', + async ({ overrides, thresholds, windows }) => { + vi.spyOn(config, 'getConfig').mockReturnValue(parseEnv({ ...required, ...overrides })); + // Capture only the HTTP boundary and avoid registering a global event + // subscription. The evaluator, clock and repository are real implementations. + const routeFactory = vi.spyOn(routes, 'createFraudDetectionRoutes'); + vi.spyOn(subscriptions, 'subscribeFraudDetectionEventDispatch').mockReturnValue(() => {}); + const prisma = new PrismaClient(); + const latest = vi.spyOn(prisma.blockchainEvent, 'findFirst').mockResolvedValue(ledgerRow); + const find = vi.spyOn(prisma.actorActivity, 'findMany').mockResolvedValue([]); + for (const [index, rule] of RULES.entries()) { + find.mockResolvedValueOnce( + Array.from({ length: thresholds[index]! }, (_, row) => ({ + id: `${index}-${row}`, + address: 'actor', + category: rule.category, + occurredAt: ledgerTime, + createdAt: ledgerTime, + updatedAt: ledgerTime, + })), + ); + } + const count = vi.spyOn(prisma.actorActivity, 'count'); + createFraudDetectionModule(prisma); + expect(routeFactory).toHaveBeenCalledTimes(1); + const assess = routeFactory.mock.calls[0]![0].assessActor; + const result = await assess({ address: 'actor' }); + expect(latest).toHaveBeenCalledWith({ + orderBy: { ledgerClosedAt: 'desc' }, + select: { ledgerClosedAt: true }, + }); + expect(result.signals.map((s) => s.threshold)).toEqual(thresholds); + expect(result.signals.map((s) => s.windowHours)).toEqual(windows); + expect(result.signals.map((s) => s.ruleType)).toEqual(RULES.map((r) => r.ruleType)); + expect(result.signals.map((s) => s.category)).toEqual(RULES.map((r) => r.category)); + expect(result.flagged).toBe(false); + expect(result.signals.map((signal) => signal.count)).toEqual(thresholds); + for (const [index, rule] of RULES.entries()) { + expect(find).toHaveBeenCalledWith({ + where: { + address: 'actor', + category: rule.category, + occurredAt: { gte: new Date(ledgerTime.getTime() - windows[index]! * 3600000) }, + }, + take: thresholds[index]! + 1, + select: { id: true }, + }); + } + expect(count).not.toHaveBeenCalled(); + for (const [index, rule] of RULES.entries()) { + find.mockResolvedValueOnce( + Array.from({ length: thresholds[index]! + 1 }, (_, row) => ({ + id: `${index}-${row}`, + address: 'actor', + category: rule.category, + occurredAt: ledgerTime, + createdAt: ledgerTime, + updatedAt: ledgerTime, + })), + ); + } + const above = await assess({ address: 'actor' }); + expect(above.flagged).toBe(true); + expect(above.signals.every((signal) => signal.triggered)).toBe(true); + expect(above.signals.map((signal) => signal.count)).toEqual( + thresholds.map((threshold) => threshold + 1), + ); + expect(latest).toHaveBeenCalledTimes(1); + await prisma.$disconnect(); + }, + ); + + it.each(fields)('rejects invalid %s at module construction', (field) => { + vi.spyOn(config, 'getConfig').mockImplementation(() => parseEnv({ ...required, [field]: '0' })); + const subscribe = vi.spyOn(subscriptions, 'subscribeFraudDetectionEventDispatch'); + const prisma = new PrismaClient(); + expect(() => createFraudDetectionModule(prisma)).toThrow(field); + expect(subscribe).not.toHaveBeenCalled(); + }); +}); + +describe('fraud configuration validation', () => { + it.each( + fields.flatMap((field) => + ['0', '-1', '1.5', 'abc', '', 'Infinity'].map((value) => ({ field, value })), + ), + )('rejects $field=$value', ({ field, value }) => { + expect(() => parseEnv({ ...required, [field]: value })).toThrow(new RegExp(field)); + }); +}); + +describe('fraud rule boundaries', () => { + it.each( + RULES.flatMap((rule) => + [rule.threshold - 1, rule.threshold, rule.threshold + 1].map((count) => ({ rule, count })), + ), + )('$rule.ruleType count=$count retains the strict threshold', async ({ rule, count }) => { + const activityRepository = createInMemoryActorActivityRepository(); + for (let i = 0; i < count; i += 1) activityRepository.seed('actor', rule.category, ledgerTime); + const assess = createAssessActorUseCase({ + activityRepository, + clock: createFixedClock(ledgerTime), + }); + const result = await assess({ address: 'actor' }); + expect(result.signals.find((s) => s.ruleType === rule.ruleType)).toMatchObject({ + count, + threshold: rule.threshold, + triggered: count > rule.threshold, + }); + expect(result.flagged).toBe(count > rule.threshold); + }); + + it.each(RULES)( + '$ruleType uses injected hours, inclusive cutoff and threshold+1', + async (rule) => { + const custom = { ...rule, windowHours: 2, threshold: 2 }; + const activityRepository = createInMemoryActorActivityRepository(); + const cutoff = new Date(ledgerTime.getTime() - 2 * 3600000); + activityRepository.seed('actor', rule.category, new Date(cutoff.getTime() - 1)); + activityRepository.seed('other', rule.category, ledgerTime); + activityRepository.seed('actor', rule.category, cutoff); + activityRepository.seed('actor', rule.category, ledgerTime); + const countSince = vi.spyOn(activityRepository, 'countSince'); + const assess = createAssessActorUseCase({ + activityRepository, + clock: createFixedClock(ledgerTime), + ruleDefinitions: [custom], + }); + const first = await assess({ address: 'actor' }); + expect(first.signals).toEqual([{ ...custom, count: 2, triggered: false }]); + activityRepository.seed('actor', rule.category, ledgerTime); + expect((await assess({ address: 'actor' })).signals).toEqual([ + { ...custom, count: 3, triggered: true }, + ]); + expect(countSince).toHaveBeenCalledWith('actor', rule.category, cutoff, 3); + }, + ); +}); + +describe('fraud repository port compatibility', () => { + it('records only port fields, returns void and preserves failures', async () => { + const prisma = new PrismaClient(); + const input = { + address: 'actor', + category: 'DELIVERY_CREATED', + occurredAt: ledgerTime, + } as const; + const create = vi.spyOn(prisma.actorActivity, 'create').mockResolvedValue({ + ...input, + id: 'generated', + createdAt: ledgerTime, + updatedAt: ledgerTime, + }); + const repository: ActorActivityRepository = createPrismaActorActivityRepository(prisma); + await expect(repository.record(input)).resolves.toBeUndefined(); + expect(create).toHaveBeenCalledWith({ data: input }); + const failure = new Error('fixture database error'); + create.mockRejectedValue(failure); + await expect(repository.record(input)).rejects.toBe(failure); + await prisma.$disconnect(); + }); + + it('uses capped selection only when requested, otherwise counts with identical filters', async () => { + const prisma = new PrismaClient(); + const repository: ActorActivityRepository = createPrismaActorActivityRepository(prisma); + const find = vi.spyOn(prisma.actorActivity, 'findMany').mockResolvedValue( + Array.from({ length: 4 }, (_, i) => ({ + id: String(i), + address: 'actor', + category: 'DISPUTE_RAISED', + occurredAt: ledgerTime, + createdAt: ledgerTime, + updatedAt: ledgerTime, + })), + ); + const count = vi.spyOn(prisma.actorActivity, 'count').mockResolvedValue(20); + expect(await repository.countSince('actor', 'DISPUTE_RAISED', ledgerTime, 4)).toBe(4); + const where = { address: 'actor', category: 'DISPUTE_RAISED', occurredAt: { gte: ledgerTime } }; + expect(find).toHaveBeenCalledWith({ where, take: 4, select: { id: true } }); + expect(count).not.toHaveBeenCalled(); + expect(await repository.countSince('actor', 'DISPUTE_RAISED', ledgerTime)).toBe(20); + expect(count).toHaveBeenCalledWith({ where }); + await prisma.$disconnect(); + }); +}); diff --git a/tests/fraud-root-http.integration.spec.ts b/tests/fraud-root-http.integration.spec.ts new file mode 100644 index 0000000..7aa76c9 --- /dev/null +++ b/tests/fraud-root-http.integration.spec.ts @@ -0,0 +1,104 @@ +import { randomUUID } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import Fastify from 'fastify'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createFraudDetectionModule } from '../src/modules/fraud-detection/index.js'; +import { RULES } from '../src/modules/fraud-detection/application/assess-actor.js'; +import { disconnectPrisma } from '../src/shared/database/index.js'; +import { handleError } from '../src/shared/errors/index.js'; +import { signAccessToken } from '../src/shared/jwt/index.js'; +import { testAddress } from '../src/shared/testing/addresses.js'; + +const prisma = new PrismaClient(); +const app = Fastify(); +const users: string[] = []; +const address = testAddress('fraud-root-http'); +const other = testAddress('fraud-root-other'); +let adminToken: string; +let customerToken: string; + +beforeAll(async () => { + for (const role of ['ADMIN', 'CUSTOMER'] as const) { + const user = await prisma.user.create({ + data: { email: `${randomUUID()}@fraud-root.invalid`, passwordHash: 'fixture-only', role }, + }); + users.push(user.id); + const token = signAccessToken({ sub: user.id, role: 'ADMIN', tokenVersion: 0 }); + if (role === 'ADMIN') adminToken = token; + else customerToken = token; + } + app.setValidatorCompiler(validatorCompiler); + app.setSerializerCompiler(serializerCompiler); + app.setErrorHandler(handleError); + await app.register(createFraudDetectionModule(prisma)); + await app.ready(); +}); + +afterAll(async () => { + await app.close(); + await prisma.actorActivity.deleteMany({ where: { address: { in: [address, other] } } }); + await prisma.user.deleteMany({ where: { id: { in: users } } }); + await prisma.$disconnect(); + await disconnectPrisma(); +}); + +describe('actual fraud root HTTP composition', () => { + const url = `/fraud-detection/actors/${address}`; + it('requires authentication', async () => { + expect((await app.inject({ url })).statusCode).toBe(401); + }); + + it('requires the primary-database ADMIN role despite an ADMIN token claim', async () => { + expect( + (await app.inject({ url, headers: { authorization: `Bearer ${customerToken}` } })).statusCode, + ).toBe(403); + }); + + it('preserves address validation', async () => { + expect( + ( + await app.inject({ + url: '/fraud-detection/actors/invalid', + headers: { authorization: `Bearer ${adminToken}` }, + }) + ).statusCode, + ).toBe(400); + }); + + it('uses the canonical rule names/default thresholds/hour windows without activity writes', async () => { + const before = await prisma.actorActivity.count(); + const response = await app.inject({ url, headers: { authorization: `Bearer ${adminToken}` } }); + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual({ + address, + flagged: false, + signals: RULES.map((rule) => ({ ...rule, count: 0, triggered: false })), + }); + expect(await prisma.actorActivity.count()).toBe(before); + }); + + it('assesses persisted actor activity with strict threshold and actor isolation', async () => { + const occurredAt = new Date(); + await prisma.actorActivity.createMany({ + data: [address, other].flatMap((actor) => + Array.from({ length: 11 }, () => ({ + address: actor, + category: 'DELIVERY_CREATED' as const, + occurredAt, + })), + ), + }); + const response = await app.inject({ url, headers: { authorization: `Bearer ${adminToken}` } }); + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual({ + address, + flagged: true, + signals: RULES.map((rule) => ({ + ...rule, + count: rule.category === 'DELIVERY_CREATED' ? 11 : 0, + triggered: rule.category === 'DELIVERY_CREATED', + })), + }); + }); +}); diff --git a/tests/mp4-evidence-validation.spec.ts b/tests/mp4-evidence-validation.spec.ts new file mode 100644 index 0000000..d871cc6 --- /dev/null +++ b/tests/mp4-evidence-validation.spec.ts @@ -0,0 +1,92 @@ +import { createHash } from 'node:crypto'; +import { expect, it, vi } from 'vitest'; +import { + matchesMp4Header, + assertFileSignatureMatchesContentType, +} from '../src/modules/disputes/application/file-signature.js'; +import { createUploadEvidenceUseCase } from '../src/modules/disputes/application/upload-evidence.js'; +import { matchesEvidenceMagicBytes } from '../src/modules/disputes/interface/schemas.js'; +import { + buildDispute, + createFakeEvidenceStorage, + createFakeWalletOwnershipRepository, + createInMemoryDisputeRepository, + createInMemoryEvidenceRepository, +} from '../src/modules/disputes/application/__fixtures__/fakes.js'; +import { ValidationError } from '../src/shared/errors/app-error.js'; +import { validMp4Headers, invalidMp4Headers, ftyp24 } from './__fixtures__/mp4-headers.js'; + +function setup() { + const disputeRepository = createInMemoryDisputeRepository(); + const evidenceRepository = createInMemoryEvidenceRepository(); + const evidenceStorage = createFakeEvidenceStorage(); + const walletOwnershipRepository = createFakeWalletOwnershipRepository(); + const dispute = buildDispute({ chainDeliveryId: 1n, status: 'OPEN' }); + disputeRepository.seed(dispute); + walletOwnershipRepository.seed('fixture-user', dispute.raisedBy); + const save = vi.spyOn(evidenceStorage, 'save'); + const create = vi.spyOn(evidenceRepository, 'create'); + const upload = createUploadEvidenceUseCase({ + disputeRepository, + evidenceRepository, + evidenceStorage, + walletOwnershipRepository, + }); + return { + save, + create, + evidenceRepository, + evidenceStorage, + upload, + input: { + chainDeliveryId: 1n, + requesterId: 'fixture-user', + uploadedBy: dispute.raisedBy, + contentType: 'video/mp4', + }, + }; +} + +it.each(validMp4Headers)( + 'recognizes $name without modifying persisted bytes or hashes', + async ({ bytes }) => { + const deps = setup(); + expect(matchesMp4Header(bytes)).toBe(true); + expect(matchesEvidenceMagicBytes(bytes, 'video/mp4')).toBe(true); + expect(() => assertFileSignatureMatchesContentType(bytes, 'video/mp4')).not.toThrow(); + const { evidence } = await deps.upload({ ...deps.input, bytes }); + expect(evidence.hash).toBe(createHash('sha256').update(bytes).digest('hex')); + expect(evidence.uploadedByUserId).toBe('fixture-user'); + expect(await deps.evidenceStorage.read(evidence.storageUrl)).toEqual(bytes); + expect(await deps.evidenceRepository.findById(evidence.id)).toEqual(evidence); + }, +); + +it.each(invalidMp4Headers)( + 'rejects $name consistently before any storage or metadata write', + async ({ bytes }) => { + const deps = setup(); + expect(matchesMp4Header(bytes)).toBe(false); + expect(matchesEvidenceMagicBytes(bytes, 'video/mp4')).toBe(false); + expect(() => assertFileSignatureMatchesContentType(bytes, 'video/mp4')).toThrow( + ValidationError, + ); + await expect(deps.upload({ ...deps.input, bytes })).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + statusCode: 400, + }); + expect(deps.save).not.toHaveBeenCalled(); + expect(deps.create).not.toHaveBeenCalled(); + }, +); + +it('uses view byteOffset/byteLength rather than the backing allocation', () => { + const backing = Buffer.concat([Buffer.alloc(19, 0xff), ftyp24, Buffer.alloc(11, 0xff)]); + const view = backing.subarray(19, 19 + ftyp24.length); + expect(matchesMp4Header(view)).toBe(true); + expect(matchesMp4Header(new Uint8Array(view.buffer, view.byteOffset, view.byteLength))).toBe( + true, + ); + expect(matchesMp4Header(view.subarray(0, 23))).toBe(false); + expect(backing.subarray(0, 19)).toEqual(Buffer.alloc(19, 0xff)); +}); diff --git a/tests/notification-read-state.integration.spec.ts b/tests/notification-read-state.integration.spec.ts new file mode 100644 index 0000000..58312e5 --- /dev/null +++ b/tests/notification-read-state.integration.spec.ts @@ -0,0 +1,180 @@ +import { randomUUID } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import Fastify from 'fastify'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + createGetNotificationUseCase, + createListNotificationsUseCase, + createMarkNotificationsReadUseCase, +} from '../src/modules/notifications/application/index.js'; +import { createPrismaNotificationRepository } from '../src/modules/notifications/infrastructure/prisma-notification-repository.js'; +import { createNotificationsRoutes } from '../src/modules/notifications/interface/routes.js'; +import { disconnectPrisma } from '../src/shared/database/index.js'; +import { handleError } from '../src/shared/errors/index.js'; +import { signAccessToken } from '../src/shared/jwt/index.js'; + +// Real routes, use cases, primary-database authentication and repository. +// No worker, mail transport or full-app initialization is needed for read state. +const prisma = new PrismaClient(); +const repository = createPrismaNotificationRepository(prisma); +const app = Fastify(); +const users: string[] = []; +let owner: string; +let other: string; +let unreadId: string; +let readId: string; +let otherId: string; +let revokedToken: string; +const originalReadAt = new Date('2026-01-01T00:00:00.123Z'); +function headers(id: string) { + return { + authorization: `Bearer ${signAccessToken({ sub: id, role: 'CUSTOMER', tokenVersion: 0 })}`, + }; +} + +beforeAll(async () => { + for (let index = 0; index < 2; index++) { + const user = await prisma.user.create({ + data: { email: `${randomUUID()}@read-state.invalid`, passwordHash: 'fixture-only' }, + }); + users.push(user.id); + } + const [first, second] = users; + if (!first || !second) throw new Error('Missing test users'); + owner = first; + other = second; + const unread = await repository.create({ + userId: owner, + channel: 'EMAIL', + type: 'pending', + payload: {}, + }); + unreadId = unread.id; + const read = await prisma.notification.create({ + data: { + userId: owner, + channel: 'PUSH', + type: 'read', + payload: {}, + readAt: originalReadAt, + status: 'SENT', + }, + }); + readId = read.id; + const theirs = await repository.create({ + userId: other, + channel: 'SMS', + type: 'other', + payload: {}, + }); + otherId = theirs.id; + revokedToken = signAccessToken({ sub: owner, role: 'CUSTOMER', tokenVersion: -1 }); + app.setValidatorCompiler(validatorCompiler); + app.setSerializerCompiler(serializerCompiler); + app.setErrorHandler(handleError); + await app.register( + createNotificationsRoutes({ + getNotification: createGetNotificationUseCase({ notificationRepository: repository }), + listNotifications: createListNotificationsUseCase({ notificationRepository: repository }), + markNotificationsRead: createMarkNotificationsReadUseCase({ + notificationRepository: repository, + }), + }), + ); + await app.ready(); +}); + +afterAll(async () => { + await app.close(); + await prisma.user.deleteMany({ where: { id: { in: users } } }); + await prisma.$disconnect(); + await disconnectPrisma(); +}); + +describe('persisted notification read contract', () => { + it('round-trips null and real timestamps through detail and list without marking reads', async () => { + expect((await repository.findById(unreadId))?.readAt).toBeNull(); + const detail = await app.inject({ url: `/notifications/${readId}`, headers: headers(owner) }); + expect(detail.statusCode).toBe(200); + expect(detail.json().data.readAt).toBe(originalReadAt.toISOString()); + const list = await app.inject({ url: '/notifications', headers: headers(owner) }); + expect(list.statusCode).toBe(200); + expect(list.json().data).toHaveLength(2); + expect(list.json().data).toEqual( + expect.arrayContaining([expect.objectContaining({ id: unreadId, readAt: null })]), + ); + expect((await repository.findById(unreadId))?.readAt).toBeNull(); + }); + + it('rejects unauthenticated and version-revoked marking without writes', async () => { + for (const auth of [undefined, `Bearer ${revokedToken}`]) { + const response = await app.inject({ + method: 'POST', + url: '/notifications/mark-read', + ...(auth ? { headers: { authorization: auth } } : {}), + }); + expect(response.statusCode).toBe(401); + } + expect((await repository.findById(unreadId))?.readAt).toBeNull(); + }); + + it('retains detail ownership and missing-record errors', async () => { + expect( + (await app.inject({ url: `/notifications/${otherId}`, headers: headers(owner) })).statusCode, + ).toBe(403); + expect( + (await app.inject({ url: `/notifications/${randomUUID()}`, headers: headers(owner) })) + .statusCode, + ).toBe(404); + }); + + it('marks only owner unread rows with an actual action timestamp and is idempotent', async () => { + const before = await prisma.notification.findMany({ where: { userId: owner } }); + const start = Date.now(); + const response = await app.inject({ + method: 'POST', + url: '/notifications/mark-read', + headers: headers(owner), + payload: { userId: other }, + }); + const end = Date.now(); + expect(response.statusCode).toBe(200); + expect(response.json().data.count).toBe(1); + const marked = await repository.findById(unreadId); + expect(marked?.readAt?.getTime()).toBeGreaterThanOrEqual(start); + expect(marked?.readAt?.getTime()).toBeLessThanOrEqual(end); + expect((await repository.findById(readId))?.readAt).toEqual(originalReadAt); + expect((await repository.findById(otherId))?.readAt).toBeNull(); + const again = await app.inject({ + method: 'POST', + url: '/notifications/mark-read', + headers: headers(owner), + }); + expect(again.json().data.count).toBe(0); + expect((await repository.findById(unreadId))?.readAt).toEqual(marked?.readAt); + const after = await prisma.notification.findMany({ where: { userId: owner } }); + for (const row of before) { + const current = after.find(({ id }) => id === row.id); + if (!current) throw new Error('Missing notification after mark-read'); + const { readAt: _oldRead, updatedAt: _oldUpdate, ...oldValues } = row; + const { readAt: _newRead, updatedAt: _newUpdate, ...newValues } = current; + expect(newValues).toEqual(oldValues); + } + }); + + it('keeps delivery status changes separate from read state and new rows unread', async () => { + const fresh = await repository.create({ + userId: owner, + channel: 'EMAIL', + type: 'new', + payload: {}, + }); + expect(fresh.readAt).toBeNull(); + await repository.markSent(fresh.id, new Date()); + expect((await repository.findById(fresh.id))?.readAt).toBeNull(); + await repository.markFailed(fresh.id, 'fixture failure'); + expect((await repository.findById(fresh.id))?.readAt).toBeNull(); + expect((await repository.findById(readId))?.readAt).toEqual(originalReadAt); + }); +}); diff --git a/tests/smtp-notification-composition.spec.ts b/tests/smtp-notification-composition.spec.ts new file mode 100644 index 0000000..1afd98b --- /dev/null +++ b/tests/smtp-notification-composition.spec.ts @@ -0,0 +1,204 @@ +import { createServer, type Socket } from 'node:net'; +import { PrismaClient } from '@prisma/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createSmtpNotificationSender } from '../src/modules/notifications/infrastructure/smtp-notification-sender.js'; +import { selectNotificationSender } from '../src/modules/notifications/infrastructure/select-notification-sender.js'; +import { createNotificationsBackgroundWorker } from '../src/modules/notifications/index.js'; +import { createNotificationsWorker } from '../src/modules/notifications/infrastructure/queue.js'; +import * as configModule from '../src/shared/config/index.js'; +import { parseEnv } from '../src/shared/config/env.js'; +import { buildNotification } from '../src/modules/notifications/application/__fixtures__/fakes.js'; + +// Only the queue boundary is replaced: the production factory still constructs +// the real repositories, contact lookup, selector, sender and SMTP client. +// Prisma methods return fixture rows without opening a database connection. +vi.mock('../src/modules/notifications/infrastructure/queue.js', () => ({ + createNotificationsWorker: vi.fn(() => ({ close: vi.fn() })), + createNotificationJobScheduler: vi.fn(), +})); + +async function startRelay(rejectRecipient = false) { + const commands: string[] = []; + const messages: string[] = []; + const sockets = new Set(); + const server = createServer((socket) => { + sockets.add(socket); + let buffer = ''; + let data: string[] | undefined; + socket.setEncoding('utf8'); + socket.write('220 test.invalid ready\r\n'); + socket.on('data', (chunk: string) => { + buffer += chunk; + let end: number; + while ((end = buffer.indexOf('\r\n')) >= 0) { + const line = buffer.slice(0, end); + buffer = buffer.slice(end + 2); + if (data) { + if (line === '.') { + messages.push(data.join('\r\n')); + data = undefined; + socket.write('250 queued\r\n'); + } else data.push(line); + continue; + } + commands.push(line); + if (line.startsWith('EHLO')) socket.write('250 test.invalid\r\n'); + else if (line.startsWith('RCPT') && rejectRecipient) socket.write('550 refused\r\n'); + else if (line === 'DATA') { + data = []; + socket.write('354 continue\r\n'); + } else if (line === 'QUIT') socket.end('221 bye\r\n'); + else socket.write('250 ok\r\n'); + } + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No relay port'); + return { + settings: { host: '127.0.0.1', port: address.port, from: 'FaniLab ' }, + commands, + messages, + async close() { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +describe('SMTP notification composition', () => { + let relay: Awaited> | undefined; + afterEach(async () => { + await relay?.close(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it('constructs the adapter and sends exact recipient, subject and payload text to the relay', async () => { + relay = await startRelay(); + const sender = createSmtpNotificationSender(relay.settings); + const payload = { deliveryId: '42', html: 'café', nested: { amount: 5 } }; + await sender.send({ + to: 'Driver ', + type: 'delivery_confirmed', + payload, + }); + expect(relay.commands).toContain('MAIL FROM:'); + expect(relay.commands).toContain('RCPT TO:'); + expect(relay.messages).toHaveLength(1); + const raw = relay.messages[0]!; + expect(raw).toContain('From: FaniLab \r\n'); + expect(raw).toContain('To: Driver \r\n'); + expect(raw).toContain('Subject: FaniLab notification: delivery_confirmed\r\n'); + expect(raw).toContain('Content-Type: text/plain; charset=utf-8'); + expect(Buffer.from(raw.split('\r\n\r\n')[1]!, 'base64').toString('utf8')).toBe( + `delivery_confirmed\n\n${JSON.stringify(payload, null, 2)}\n`, + ); + }); + + it.each(['smtp', 'sendgrid', 'ses'] as const)( + 'sends through the production %s selector', + async (provider) => { + relay = await startRelay(); + await selectNotificationSender('production', provider, relay.settings).send({ + to: 'recipient@example.invalid', + type: 'event', + payload: {}, + }); + expect(relay.messages).toHaveLength(1); + }, + ); + + it('propagates relay rejection through the adapter', async () => { + relay = await startRelay(true); + await expect( + createSmtpNotificationSender(relay.settings).send({ + to: 'recipient@example.invalid', + type: 'event', + payload: {}, + }), + ).rejects.toThrow(/SMTP reply 550.*refused/); + expect(relay.messages).toEqual([]); + }); + + it('uses configured CA settings at construction', () => { + expect(() => + createSmtpNotificationSender({ + host: '127.0.0.1', + port: 1, + from: 'sender@example.invalid', + caPath: '/nonexistent/test-smtp-ca.pem', + }), + ).toThrow(/ENOENT/); + }); + + it.each([false, true])( + 'production worker preserves send status and error behavior (reject=%s)', + async (reject) => { + relay = await startRelay(reject); + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('NOTIFICATION_PROVIDER', 'smtp'); + vi.stubEnv('SMTP_HOST', relay.settings.host); + vi.stubEnv('SMTP_PORT', String(relay.settings.port)); + vi.stubEnv('SMTP_FROM', relay.settings.from); + const prisma = new PrismaClient(); + const notification = buildNotification({ userId: 'user-1' }); + vi.spyOn(prisma.notification, 'findUnique').mockResolvedValue({ + ...notification, + payload: { deliveryId: '42' }, + updatedAt: notification.createdAt, + }); + vi.spyOn(configModule, 'getConfig').mockReturnValue(parseEnv()); + const findUser = vi.spyOn(prisma.user, 'findUnique').mockResolvedValue({ + id: 'user-1', + email: 'current@example.invalid', + passwordHash: 'unused', + role: 'CUSTOMER', + tokenVersion: 0, + emailVerifiedAt: null, + createdAt: notification.createdAt, + updatedAt: notification.createdAt, + }); + const update = vi.spyOn(prisma.notification, 'update').mockResolvedValue({ + ...notification, + payload: { deliveryId: '42' }, + updatedAt: notification.createdAt, + }); + createNotificationsBackgroundWorker(prisma); + const send = vi.mocked(createNotificationsWorker).mock.calls.at(-1)![0]; + if (reject) { + await expect( + send({ notificationId: notification.id, isFinalAttempt: false }), + ).rejects.toThrow(/SMTP reply 550/); + expect(update).not.toHaveBeenCalled(); + await expect( + send({ notificationId: notification.id, isFinalAttempt: true }), + ).rejects.toThrow(/SMTP reply 550/); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'FAILED', + failureReason: expect.stringContaining('SMTP reply 550'), + }), + }), + ); + } else { + await send({ notificationId: notification.id }); + expect(findUser).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + select: { id: true, email: true }, + }); + expect(relay.commands).toContain('RCPT TO:'); + expect(update).toHaveBeenCalledWith({ + where: { id: notification.id }, + data: { status: 'SENT', sentAt: expect.any(Date) }, + }); + } + await prisma.$disconnect(); + }, + ); +}); diff --git a/tests/worker-execution-restriction.integration.spec.ts b/tests/worker-execution-restriction.integration.spec.ts new file mode 100644 index 0000000..c9dc321 --- /dev/null +++ b/tests/worker-execution-restriction.integration.spec.ts @@ -0,0 +1,186 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { PrismaClient } from '@prisma/client'; +import { Address } from '@stellar/stellar-sdk'; +import { Queue } from 'bullmq'; +import { Redis } from 'ioredis'; +import { afterAll, beforeAll, expect, it } from 'vitest'; +import { workerExecutionStatus } from '../src/shared/queue/worker-execution.js'; + +const prisma = new PrismaClient(); +const redis = new Redis( + process.env.SECURITY_TEST_REDIS_SOCKET ?? process.env.REDIS_URL ?? 'redis://localhost:6379', + { maxRetriesPerRequest: null }, +); +const queues = workerExecutionStatus.jobs.map( + ({ queue }) => new Queue(queue, { connection: redis }), +); +const prefix = randomUUID(); +const address = `restriction-${prefix}`; +const jobs: Array<{ queue: Queue; id: string }> = []; +const repeatKeys: Array<{ queue: Queue; key: string }> = []; +const repeatsBefore = new Map>>(); +let userId: string; +let notificationId: string; +let heartbeatDir: string; + +beforeAll(async () => { + heartbeatDir = await mkdtemp(path.join(tmpdir(), 'restricted-worker-')); + const user = await prisma.user.create({ + data: { email: `${prefix}@worker.invalid`, passwordHash: 'synthetic-only' }, + }); + userId = user.id; + const notification = await prisma.notification.create({ + data: { userId, channel: 'EMAIL', type: 'fixture', payload: { synthetic: true } }, + }); + notificationId = notification.id; + await prisma.actorActivity.createMany({ + data: [1, 100].map((days) => ({ + address, + category: 'DELIVERY_CREATED', + occurredAt: new Date(Date.now() - days * 86400000), + })), + }); + for (const queue of queues) { + const job = await queue.add( + 'restriction-fixture', + { + notificationId, + contractName: 'escrow', + contractId: Address.contract(Buffer.alloc(32, 8)).toString(), + network: 'testnet', + }, + { jobId: `${prefix}-${queue.name}` }, + ); + if (!job.id) throw new Error('Expected fixture job ID'); + jobs.push({ queue, id: job.id }); + const oldKeys = new Set((await queue.getRepeatableJobs()).map((repeat) => repeat.key)); + await queue.add( + 'legacy-fixture', + {}, + { + jobId: `${prefix}-legacy-${queue.name}`, + repeat: { every: 86_400_000 }, + }, + ); + const repeats = await queue.getRepeatableJobs(); + for (const repeat of repeats) { + if (!oldKeys.has(repeat.key)) repeatKeys.push({ queue, key: repeat.key }); + } + repeatsBefore.set(queue.name, repeats); + } +}); + +afterAll(async () => { + for (const { queue, id } of jobs) await (await queue.getJob(id))?.remove(); + for (const { queue, key } of repeatKeys) await queue.removeRepeatableByKey(key); + await Promise.all(queues.map((queue) => queue.close())); + await redis.quit(); + await prisma.actorActivity.deleteMany({ where: { address } }); + if (userId) await prisma.user.delete({ where: { id: userId } }); + await prisma.$disconnect(); + if (heartbeatDir) await rm(heartbeatDir, { recursive: true, force: true }); +}); + +function childProcess(args: string[]) { + const contract = Address.contract(Buffer.alloc(32, 8)).toString(); + const child = spawn(process.execPath, ['--import', 'tsx', ...args], { + env: { + ...process.env, + NODE_ENV: 'production', + LOG_LEVEL: 'info', + WORKER_HEARTBEAT_DIR: heartbeatDir, + // Valid settings and configured contracts must not unlock execution. + NOTIFICATION_PROVIDER: 'smtp', + SMTP_HOST: '127.0.0.1', + SMTP_PORT: '9', + SMTP_FROM: 'fixture@worker.invalid', + ESCROW_CONTRACT_ID: contract, + DELIVERY_CONTRACT_ID: contract, + FLEET_MANAGEMENT_CONTRACT_ID: contract, + DISPUTE_RESOLUTION_CONTRACT_ID: contract, + IDENTITY_REPUTATION_CONTRACT_ID: contract, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout.on('data', (data: Buffer) => { + output += data.toString(); + }); + child.stderr.on('data', (data: Buffer) => { + output += data.toString(); + }); + const finished = new Promise((resolve, reject) => { + child.once('error', reject); + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`Child timeout: ${output}`)); + }, 10_000); + child.once('exit', (code) => { + clearTimeout(timeout); + resolve(code); + }); + }); + return { child, finished, output: () => output }; +} + +async function assertNoEffects() { + expect( + (await prisma.notification.findUniqueOrThrow({ where: { id: notificationId } })).status, + ).toBe('PENDING'); + expect(await prisma.actorActivity.count({ where: { address } })).toBe(2); + for (const { queue, id } of jobs) { + expect(await (await queue.getJob(id))?.getState()).toBe('waiting'); + expect(await queue.getWorkers()).toHaveLength(0); + expect(await queue.getRepeatableJobs()).toEqual(repeatsBefore.get(queue.name)); + } +} + +it('imports the production entrypoint without timers, consumers or scheduler registration', async () => { + const probe = childProcess([ + '--input-type=module', + '-e', + 'await import("./src/workers/index.ts"); console.log("import complete");', + ]); + expect(await probe.finished).toBe(0); + expect(probe.output()).toContain('import complete'); + expect(probe.output()).not.toContain('Worker supervisor started'); + await expect(readFile(path.join(heartbeatDir, 'worker.heartbeat'))).rejects.toThrow(); + await assertNoEffects(); +}); + +it('starts in production with all jobs explicitly blocked, preserves pending data and shuts down', async () => { + const probe = childProcess(['src/workers/index.ts']); + try { + for ( + let attempt = 0; + attempt < 100 && !probe.output().includes('no jobs are running'); + attempt++ + ) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const logs = probe + .output() + .split('\n') + .filter(Boolean) + .map( + (line: string) => JSON.parse(line) as { workerExecution?: unknown; workerCount?: number }, + ); + expect(logs).toContainEqual( + expect.objectContaining({ workerCount: 0, workerExecution: workerExecutionStatus }), + ); + expect(await readFile(path.join(heartbeatDir, 'worker.heartbeat'), 'utf8')).toBeTruthy(); + await assertNoEffects(); + probe.child.kill('SIGTERM'); + expect(await probe.finished).toBe(0); + expect(probe.output()).toContain('Shutting down worker process gracefully'); + expect(probe.output()).not.toContain('Notification delivered'); + expect(probe.output()).not.toContain('retention cleanup complete'); + await assertNoEffects(); + } finally { + if (probe.child.exitCode === null) probe.child.kill('SIGKILL'); + } +});