From c1290f1cf02e795f860daefc6de1531633b4d8e2 Mon Sep 17 00:00:00 2001 From: dev-fani Date: Fri, 30 Oct 2026 10:56:18 +0000 Subject: [PATCH 01/24] fix(auth): rotate refresh tokens atomically and revoke reused families --- docs/SESSION_SECURITY.md | 7 ++ .../migration.sql | 10 ++ prisma/schema.prisma | 29 +++-- .../auth/application/__fixtures__/fakes.ts | 22 ++++ .../auth/application/refresh-session.spec.ts | 4 +- .../auth/application/refresh-session.ts | 66 +++++------ src/modules/auth/domain/entities.ts | 1 + src/modules/auth/domain/index.ts | 1 + src/modules/auth/domain/ports.ts | 14 +++ src/modules/auth/index.ts | 4 +- src/modules/auth/infrastructure/index.ts | 1 + .../prisma-auth-unit-of-work.ts | 19 ++++ .../prisma-refresh-token-repository.ts | 34 +++++- .../infrastructure/prisma-user-repository.ts | 4 +- .../session-security.integration.spec.ts | 104 ++++++++++++++++++ 15 files changed, 271 insertions(+), 49 deletions(-) create mode 100644 docs/SESSION_SECURITY.md create mode 100644 prisma/migrations/20261030120000_session_families/migration.sql create mode 100644 src/modules/auth/infrastructure/prisma-auth-unit-of-work.ts create mode 100644 src/modules/auth/infrastructure/session-security.integration.spec.ts diff --git a/docs/SESSION_SECURITY.md b/docs/SESSION_SECURITY.md new file mode 100644 index 0000000..ae769c6 --- /dev/null +++ b/docs/SESSION_SECURITY.md @@ -0,0 +1,7 @@ +# 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. 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/schema.prisma b/prisma/schema.prisma index 2ee320b..7882264 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") } 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/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/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..4a0b432 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'; @@ -52,8 +53,7 @@ export function createAuthModule(prisma: PrismaClient): FastifyPluginAsyncZod { refreshTokenRepository, }), refreshSession: createRefreshSessionUseCase({ - userRepository, - refreshTokenRepository, + unitOfWork: createPrismaAuthUnitOfWork(prisma), tokenService, clock: { now: () => new Date() }, }), 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/session-security.integration.spec.ts b/src/modules/auth/infrastructure/session-security.integration.spec.ts new file mode 100644 index 0000000..5a6d9c4 --- /dev/null +++ b/src/modules/auth/infrastructure/session-security.integration.spec.ts @@ -0,0 +1,104 @@ +import { randomUUID } from 'node:crypto'; +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 tokens = createJwtTokenService(); +const clients: PrismaClient[] = [prisma]; +afterAll(async () => { + 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' }, + }); + 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('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 }); + }); +}); From 382c900aa4f887f422316baae7513753e391474e Mon Sep 17 00:00:00 2001 From: dev-fani Date: Fri, 30 Oct 2026 10:59:21 +0000 Subject: [PATCH 02/24] fix(auth): reset credentials atomically and authorize against current state --- docs/SESSION_SECURITY.md | 4 + src/modules/auth/application/login.spec.ts | 3 +- src/modules/auth/application/login.ts | 54 ++--- .../auth/application/reset-password.spec.ts | 4 +- .../auth/application/reset-password.ts | 71 ++----- src/modules/auth/index.ts | 5 +- .../reset-security.integration.spec.ts | 193 ++++++++++++++++++ .../session-security.integration.spec.ts | 33 +++ src/shared/http/plugins/auth-guard.spec.ts | 26 +-- src/shared/http/plugins/auth-guard.ts | 50 +---- 10 files changed, 296 insertions(+), 147 deletions(-) create mode 100644 src/modules/auth/infrastructure/reset-security.integration.spec.ts diff --git a/docs/SESSION_SECURITY.md b/docs/SESSION_SECURITY.md index ae769c6..95f3e67 100644 --- a/docs/SESSION_SECURITY.md +++ b/docs/SESSION_SECURITY.md @@ -5,3 +5,7 @@ Each new login creates an independent persisted refresh-token family. Rotation l 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. 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/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/index.ts b/src/modules/auth/index.ts index 4a0b432..fd97279 100644 --- a/src/modules/auth/index.ts +++ b/src/modules/auth/index.ts @@ -48,9 +48,9 @@ export function createAuthModule(prisma: PrismaClient): FastifyPluginAsyncZod { }), login: createLoginUseCase({ userRepository, + unitOfWork: createPrismaAuthUnitOfWork(prisma), passwordHasher, tokenService, - refreshTokenRepository, }), refreshSession: createRefreshSessionUseCase({ unitOfWork: createPrismaAuthUnitOfWork(prisma), @@ -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/reset-security.integration.spec.ts b/src/modules/auth/infrastructure/reset-security.integration.spec.ts new file mode 100644 index 0000000..d1f2425 --- /dev/null +++ b/src/modules/auth/infrastructure/reset-security.integration.spec.ts @@ -0,0 +1,193 @@ +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('/socket/redis.sock', { + maxRetriesPerRequest: 0, + retryStrategy: () => null, + }); + return { getRedisClient: () => redis, disconnectRedis: () => redis.disconnect() }; +}); +const prisma = new PrismaClient(); +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.$disconnect(); + await disconnectPrisma(); + getRedisClient().disconnect(); +}); + +async function setup() { + const user = await prisma.user.create({ + data: { email: `${randomUUID()}@reset.invalid`, passwordHash: 'hashed:old' }, + }); + 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 index 5a6d9c4..6bf11a6 100644 --- a/src/modules/auth/infrastructure/session-security.integration.spec.ts +++ b/src/modules/auth/infrastructure/session-security.integration.spec.ts @@ -1,4 +1,5 @@ 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'; @@ -67,6 +68,38 @@ describe('persistent session family rotation', () => { 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. 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 From 00110f21f5c277979e5c1186ced7348e950c362b Mon Sep 17 00:00:00 2001 From: dev-fani Date: Fri, 30 Oct 2026 11:01:49 +0000 Subject: [PATCH 03/24] fix(escrow): reject signed integer overflow before encoding or RPC --- src/blockchain/xdr/sc-val.spec.ts | 13 ++ src/blockchain/xdr/sc-val.ts | 2 + .../application/build-escrow-transactions.ts | 6 +- .../infrastructure/escrow-scval-mapping.ts | 6 +- .../soroban-escrow-contract-client.ts | 7 +- src/modules/escrow/interface/schemas.ts | 5 +- .../security-bounds.integration.spec.ts | 136 ++++++++++++++++++ src/shared/validation/i128.ts | 15 ++ 8 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 src/modules/escrow/security-bounds.integration.spec.ts create mode 100644 src/shared/validation/i128.ts 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/escrow/application/build-escrow-transactions.ts b/src/modules/escrow/application/build-escrow-transactions.ts index 0b26f9e..2f2280d 100644 --- a/src/modules/escrow/application/build-escrow-transactions.ts +++ b/src/modules/escrow/application/build-escrow-transactions.ts @@ -1,4 +1,4 @@ -import { ValidationError } from '../../../shared/errors/index.js'; +import { assertEscrowAmount } from '../../../shared/validation/i128.js'; import { InvalidEscrowStateError } from '../domain/errors.js'; import type { CreateEscrowTxInput, @@ -32,9 +32,7 @@ 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); return deps.transactionBuilder.buildCreateEscrow(input); }, diff --git a/src/modules/escrow/infrastructure/escrow-scval-mapping.ts b/src/modules/escrow/infrastructure/escrow-scval-mapping.ts index eec846f..50cf6c6 100644 --- a/src/modules/escrow/infrastructure/escrow-scval-mapping.ts +++ b/src/modules/escrow/infrastructure/escrow-scval-mapping.ts @@ -1,7 +1,7 @@ +import { assertEscrowAmount } from '../../../shared/validation/i128.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 +35,7 @@ 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); return [ addressToScVal(input.senderAddress), addressToScVal(input.recipientAddress), diff --git a/src/modules/escrow/infrastructure/soroban-escrow-contract-client.ts b/src/modules/escrow/infrastructure/soroban-escrow-contract-client.ts index 6ae7c7f..2d0fe6f 100644 --- a/src/modules/escrow/infrastructure/soroban-escrow-contract-client.ts +++ b/src/modules/escrow/infrastructure/soroban-escrow-contract-client.ts @@ -1,3 +1,4 @@ +import { assertEscrowAmount } from '../../../shared/validation/i128.js'; import { addressToScVal, buildInvokeTransaction, @@ -5,7 +6,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 +67,7 @@ export function createSorobanEscrowContractClient( }, async buildCreateEscrow(input) { - if (input.amount <= 0n) { - throw new ValidationError('Escrow amount must be greater than zero'); - } + assertEscrowAmount(input.amount); const balance = await getTokenBalance(client, input.token, input.senderAddress); if (balance !== null && balance < input.amount) { diff --git a/src/modules/escrow/interface/schemas.ts b/src/modules/escrow/interface/schemas.ts index 90b3742..1fb5b95 100644 --- a/src/modules/escrow/interface/schemas.ts +++ b/src/modules/escrow/interface/schemas.ts @@ -1,4 +1,5 @@ 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'; @@ -14,12 +15,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']); 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..5d18f00 --- /dev/null +++ b/src/modules/escrow/security-bounds.integration.spec.ts @@ -0,0 +1,136 @@ +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(); +afterAll(async () => { + 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' }, + }); + 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/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); +} From 27b23edcc7c7fa220e04676ab23ebe8c607d2c57 Mon Sep 17 00:00:00 2001 From: dev-fani Date: Fri, 30 Oct 2026 11:02:54 +0000 Subject: [PATCH 04/24] fix(admin): serialize administrator changes and preserve session rollback --- docs/SESSION_SECURITY.md | 2 + .../admin-security.integration.spec.ts | 98 +++++++++++++++++++ .../prisma-admin-unit-of-work.ts | 11 ++- .../infrastructure/prisma-session-revoker.ts | 4 + 4 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 src/modules/admin/infrastructure/admin-security.integration.spec.ts diff --git a/docs/SESSION_SECURITY.md b/docs/SESSION_SECURITY.md index 95f3e67..5683b82 100644 --- a/docs/SESSION_SECURITY.md +++ b/docs/SESSION_SECURITY.md @@ -9,3 +9,5 @@ Historical refresh-token ancestry is unknown. The additive migration leaves hist 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/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() }, From 4b23b865a515bf91259edf8b79918eec0a1dc161 Mon Sep 17 00:00:00 2001 From: dev-fani Date: Fri, 30 Oct 2026 11:05:14 +0000 Subject: [PATCH 05/24] test(security): support configured Redis and isolate generated fixtures --- .../reset-security.integration.spec.ts | 14 ++++++++++---- .../session-security.integration.spec.ts | 3 +++ .../escrow/security-bounds.integration.spec.ts | 3 +++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/modules/auth/infrastructure/reset-security.integration.spec.ts b/src/modules/auth/infrastructure/reset-security.integration.spec.ts index d1f2425..e0c1dec 100644 --- a/src/modules/auth/infrastructure/reset-security.integration.spec.ts +++ b/src/modules/auth/infrastructure/reset-security.integration.spec.ts @@ -16,13 +16,17 @@ 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('/socket/redis.sock', { - maxRetriesPerRequest: 0, - retryStrategy: () => null, - }); + 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) { @@ -41,6 +45,7 @@ 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(); @@ -50,6 +55,7 @@ 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, diff --git a/src/modules/auth/infrastructure/session-security.integration.spec.ts b/src/modules/auth/infrastructure/session-security.integration.spec.ts index 6bf11a6..af0548d 100644 --- a/src/modules/auth/infrastructure/session-security.integration.spec.ts +++ b/src/modules/auth/infrastructure/session-security.integration.spec.ts @@ -8,9 +8,11 @@ import { createPrismaRefreshTokenRepository } from './prisma-refresh-token-repos 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(); }); @@ -18,6 +20,7 @@ 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 }); diff --git a/src/modules/escrow/security-bounds.integration.spec.ts b/src/modules/escrow/security-bounds.integration.spec.ts index 5d18f00..7e2a16b 100644 --- a/src/modules/escrow/security-bounds.integration.spec.ts +++ b/src/modules/escrow/security-bounds.integration.spec.ts @@ -21,7 +21,9 @@ vi.mock('../../shared/http/index.js', async () => ({ ...(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(); }); @@ -109,6 +111,7 @@ describe('escrow amount bounds at callers', () => { const user = await prisma.user.create({ data: { email: `${randomUUID()}@amount.invalid`, passwordHash: 'test' }, }); + createdUserIds.push(user.id); await app.register( createEscrowRoutes({ getEscrow: async () => { From 4cc83eedccda2e74536e22f9f9e19dd09bac8d97 Mon Sep 17 00:00:00 2001 From: dev-fani Date: Fri, 30 Oct 2026 11:11:32 +0000 Subject: [PATCH 06/24] chore: remove empty placeholders from populated directories --- prisma/migrations/.gitkeep | 0 tests/e2e/.gitkeep | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 prisma/migrations/.gitkeep delete mode 100644 tests/e2e/.gitkeep diff --git a/prisma/migrations/.gitkeep b/prisma/migrations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/e2e/.gitkeep b/tests/e2e/.gitkeep deleted file mode 100644 index e69de29..0000000 From adc39d74b33121bcc96965be72f0fd1880de5cc8 Mon Sep 17 00:00:00 2001 From: dev-fani Date: Fri, 23 Oct 2026 13:12:54 +0000 Subject: [PATCH 07/24] fix(fleet): reuse verified repository restoration for security candidate Restore only the missing historical removeDriver tail and retain its three PostgreSQL regressions. The valid prefix and every existing test assertion remain unchanged; both files match the reviewed #576 versions exactly. Reused-from: #576 at 6aa01f07a60fd7256c82562d9ee57bc5422eec84 Original-repair: ec8513232cf59f5caa457eb5a81b41f3d8d12a0d Historical-tail: 75b121ef7c20ab5831278c3d40a485f4e5f677a5 The three regressions and 106 security tests pass. TS1160 is resolved; existing semantic errors, fraud syntax errors, listing conflicts and strict lint warnings remain. This is not merge or deployment approval. --- ...risma-fleet-repository.integration.spec.ts | 71 +++++++++++++++++++ .../infrastructure/prisma-fleet-repository.ts | 19 ++++- 2 files changed, 88 insertions(+), 2 deletions(-) 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..b1f3b8d 100644 --- a/src/modules/fleet/infrastructure/prisma-fleet-repository.integration.spec.ts +++ b/src/modules/fleet/infrastructure/prisma-fleet-repository.integration.spec.ts @@ -143,4 +143,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..7461fa9 100644 --- a/src/modules/fleet/infrastructure/prisma-fleet-repository.ts +++ b/src/modules/fleet/infrastructure/prisma-fleet-repository.ts @@ -209,6 +209,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 }, + }); + }, + }; +} From 8ca335196acee70beff4ad1d1b20a328ee8ee96e Mon Sep 17 00:00:00 2001 From: dev-fani Date: Fri, 30 Oct 2026 13:02:07 +0000 Subject: [PATCH 08/24] test(fraud): reconcile assessment suite with current contracts Recover the thirteen historical assessment cases and exercise strict rule boundaries, configured windows, repository calls and full-assessment alerts. Restore the already-consumed optional rule dependency type and make the in-memory count fake honor the existing cap. Evaluator runtime is unchanged. Based on structural evidence f4a320f8cecbbb4c31de72997f2e3e32d4b62343 and contract reconciliation 01715336a33ea95113f6d2eb1bb7ca71927a3b25; no configuration, retention, worker or processing repair is imported. 18 assessment and 106 security tests pass. Build/typecheck remain blocked by 90/162 existing semantic diagnostics; no new semantic diagnostics. Co-authored-by: dev-fani --- .../application/__fixtures__/fakes.ts | 5 +- .../application/assess-actor.spec.ts | 286 +++++++++++++++--- .../application/assess-actor.ts | 2 + 3 files changed, 242 insertions(+), 51 deletions(-) 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 From 3feca85b10eb32b103f78dc6c39933eca69beaa1 Mon Sep 17 00:00:00 2001 From: dev-fani Date: Fri, 30 Oct 2026 13:11:29 +0000 Subject: [PATCH 09/24] fix(events): restore shared bus and metrics contracts Reuse the independently applicable M03 restoration from 218c17796acac60d588ede0e58fa12388d36dd10. Preserve actor-alert exports, registry metrics, dispatch concurrency and existing async/error semantics. Retain its five real composition regressions and verify counter identity, labels and the real metrics endpoint with one additional test. 88 focused tests (including all 18 assessment cases) and 106 security regressions pass. Real ESM imports succeed; build/typecheck diagnostics fall from 90/162 to 61/116 with no additions. Full checks still fail. No worker registration, business processing, replay, retention or schema changes. Existing loss/retry and process topology limitations remain. Co-authored-by: dev-fani --- src/shared/events/index.ts | 246 ++++++++++++++++++++++++++++ src/shared/metrics/registry.ts | 18 ++ tests/event-bus-composition.spec.ts | 235 ++++++++++++++++++++++++++ 3 files changed, 499 insertions(+) create mode 100644 tests/event-bus-composition.spec.ts 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/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/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); + }); +}); From ff6fa37eb8f3a53414f6a00ab2f59d25df2f73f4 Mon Sep 17 00:00:00 2001 From: dev-fani Date: Fri, 30 Oct 2026 15:57:32 +0000 Subject: [PATCH 10/24] fix(disputes): restore canonical domain and evidence import compatibility Reuse the independently applicable M04 imports/status reconciliation and minimal storage port prerequisite. Preserve persisted/API values, escrow status separation, historical ownership rules and transaction behavior. Add real module HTTP role/status/ownership and schema coverage. Reuse reviewed checkpoints b34d832633103df2472cf5fe5d1ba5222d3075c9 and 76f120c77b6aab246c0b36eead8e355aa6d62a03 without importing their broader repair branch. All 11 reused files match reviewed blobs exactly. Validation: 114 focused dispute tests, 52 HTTP cases and 106 security tests pass; focused strict lint/format pass. Build 61->43 and typecheck 116->91 existing diagnostics, none added. Full repository gates remain blocked. --- .../application/__fixtures__/fakes.ts | 3 + .../application/download-evidence.spec.ts | 10 +- .../disputes/application/download-evidence.ts | 38 ++- .../disputes/application/file-signature.ts | 7 +- .../application/upload-evidence.test.ts | 2 +- .../disputes/application/upload-evidence.ts | 5 +- src/modules/disputes/domain/ports.ts | 54 ---- .../local-evidence-storage.test.ts | 18 +- .../infrastructure/local-evidence-storage.ts | 60 ++-- tests/dispute-compatibility.spec.ts | 134 +++++++++ tests/dispute-http-compatibility.spec.ts | 272 ++++++++++++++++++ tests/evidence-storage-composition.spec.ts | 194 +++++++++++++ 12 files changed, 673 insertions(+), 124 deletions(-) create mode 100644 tests/dispute-compatibility.spec.ts create mode 100644 tests/dispute-http-compatibility.spec.ts create mode 100644 tests/evidence-storage-composition.spec.ts 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..b07fb5d 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; @@ -38,10 +38,7 @@ const SIGNATURE_CHECKS: Record boolean> = { 'video/mp4': matchesMp4, }; -export function assertFileSignatureMatchesContentType( - buffer: Buffer, - contentType: string, -): void { +export function assertFileSignatureMatchesContentType(buffer: Buffer, contentType: string): void { const check = SIGNATURE_CHECKS[contentType]; if (!check) { return; diff --git a/src/modules/disputes/application/upload-evidence.test.ts b/src/modules/disputes/application/upload-evidence.test.ts index 73b835d..1bd2163 100644 --- a/src/modules/disputes/application/upload-evidence.test.ts +++ b/src/modules/disputes/application/upload-evidence.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { ValidationError } from '../../../shared/errors.js'; +import { ValidationError } from '../../../shared/errors/app-error.js'; import { createUploadEvidenceUseCase } from './upload-evidence.js'; const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); diff --git a/src/modules/disputes/application/upload-evidence.ts b/src/modules/disputes/application/upload-evidence.ts index fae266f..774a1de 100644 --- a/src/modules/disputes/application/upload-evidence.ts +++ b/src/modules/disputes/application/upload-evidence.ts @@ -10,8 +10,8 @@ import { DisputeNotFoundError, DisputeNotOpenError, ForbiddenEvidenceUploadError, - ValidationError, } from '../domain/index.js'; +import { ValidationError } from '../../../shared/errors/app-error.js'; export interface UploadEvidenceDeps { disputeRepository: DisputeRepository; @@ -68,8 +68,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/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..2efdbe7 --- /dev/null +++ b/tests/dispute-http-compatibility.spec.ts @@ -0,0 +1,272 @@ +import { randomUUID } from 'node:crypto'; +import { mkdtemp, rm } 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'; + +// 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); + } + }); +}); 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(); + } + }); +}); From 34f43a50fabefcc194d98d8734daf06944c6b4f0 Mon Sep 17 00:00:00 2001 From: dev-fani Date: Fri, 30 Oct 2026 16:22:57 +0000 Subject: [PATCH 11/24] fix(fleet): restore unsigned transaction adapter compatibility Reuse only the four-file reviewed adapter/payout-error slice from bf8124ed184535dc92aed2949e7bf0bef2baa826 (dev-fani, 2026-10-28). Bind canonical fleet ports and shared unsigned-XDR builder, use configured network, restore recorded payout argument order, and retain typed 404 errors. No membership/listing, authorization, financial or processing redesign. Validation: 28 focused tests and all 106 security tests pass; focused lint and format pass. Build 43->39 and typecheck 91->87, no new diagnostics. Broader fleet 27 pass/4 known failures/5 setup-blocked cases. Full repository gates remain red. Preserve ff6fa37 and all earlier security repairs. --- src/modules/fleet/domain/errors.ts | 5 +- .../soroban-fleet-contract-client.spec.ts | 7 +- .../soroban-fleet-contract-client.ts | 149 +++++------ tests/fleet-xdr-compatibility.spec.ts | 252 ++++++++++++++++++ 4 files changed, 335 insertions(+), 78 deletions(-) create mode 100644 tests/fleet-xdr-compatibility.spec.ts diff --git a/src/modules/fleet/domain/errors.ts b/src/modules/fleet/domain/errors.ts index 94a77f9..35c66ba 100644 --- a/src/modules/fleet/domain/errors.ts +++ b/src/modules/fleet/domain/errors.ts @@ -7,7 +7,10 @@ export class FleetNotFoundError extends AppError { } 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/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/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); + }); +}); From 79b35b5a27a9f44dcbcaeae23f3b942daac9d8db Mon Sep 17 00:00:00 2001 From: dev-fani Date: Mon, 2 Nov 2026 14:13:10 +0000 Subject: [PATCH 12/24] fix(notifications): restore SMTP adapter compatibility Selectively reuse the two reviewed files from 2453f1c42fab0104f7aae7a8d772cea2f192b6dd by dev-fani . Restore settings and canonical send-port wiring without changing retry or delivery policy. 57 focused and 106 security tests pass in isolation. Build/typecheck diagnostics decrease 39/87 to 36/84 with no additions. --- .../smtp-notification-sender.ts | 30 +-- tests/smtp-notification-composition.spec.ts | 204 ++++++++++++++++++ 2 files changed, 220 insertions(+), 14 deletions(-) create mode 100644 tests/smtp-notification-composition.spec.ts 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/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(); + }, + ); +}); From 7f5d205930f935fb6a3b61b8524790cecd3f30dc Mon Sep 17 00:00:00 2001 From: dev-fani Date: Mon, 2 Nov 2026 14:27:36 +0000 Subject: [PATCH 13/24] fix(notifications): restore persisted read timestamp schema contract Restore the nullable readAt mapping introduced by 1beff8cd2896d2c890d35c43dc3de43ff388f4d8 (rustdavolee) and lost in merge a13feca. Existing additive migration remains unchanged; no backfill or new migration. Verify owner-scoped read state through real authenticated routes and Prisma, existing-row component migration, timestamp preservation and schema parity. 67 notification and 106 security tests pass. Build/typecheck diagnostics 36/84 to 33/80 with no additions. --- prisma/schema.prisma | 1 + ...otification-read-state.integration.spec.ts | 180 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 tests/notification-read-state.integration.spec.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7882264..884c028 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -490,6 +490,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/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); + }); +}); From c63f3741cea02d34b500f326d7bdc6289898b7d7 Mon Sep 17 00:00:00 2001 From: dev-fani Date: Mon, 2 Nov 2026 14:36:27 +0000 Subject: [PATCH 14/24] fix(fraud): reconcile production configuration and ESM contracts Selectively reuse six reviewed files from 01715336a33ea95113f6d2eb1bb7ca71927a3b25 by dev-fani . Preserve the newer assessment tests, rules, thresholds, hour windows, alert behavior and cleanup implementation. Add five real module HTTP/Prisma authorization and assessment regressions. 95 focused fraud/config tests, five HTTP tests and all 106 security tests pass. Build/typecheck diagnostics fall33/80 to30/56 with no additions. --- src/modules/fraud-detection/index.ts | 24 +- .../infrastructure/ledger-clock.spec.ts | 59 ++-- .../infrastructure/ledger-clock.ts | 4 +- .../prisma-actor-activity-repository.ts | 27 +- src/shared/config/env.ts | 4 +- tests/fraud-config-composition.spec.ts | 253 ++++++++++++++++++ tests/fraud-root-http.integration.spec.ts | 104 +++++++ 7 files changed, 420 insertions(+), 55 deletions(-) create mode 100644 tests/fraud-config-composition.spec.ts create mode 100644 tests/fraud-root-http.integration.spec.ts 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/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/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', + })), + }); + }); +}); From a0ed897d3c856eb678e67b526860746996c3776c Mon Sep 17 00:00:00 2001 From: dev-fani Date: Mon, 2 Nov 2026 14:46:06 +0000 Subject: [PATCH 15/24] fix(fleet): reconcile domain error and chain ID contracts Restore the original d942f79 not-found hierarchy and missing membership export. Preserve custom fleet messages and existing payout mapping. Complete the existing optional duplicate-invite error with the shared ConflictError contract and align its chain ID with canonical bigint callers. No repository, production guard wiring, membership/listing or payout behavior changes. 42 focused fleet and 106 security tests pass; build/typecheck30/56 to25/51 with no additions. Seven baseline broad-suite fixture conflicts remain unchanged. --- .../application/build-fleet-transactions.ts | 2 +- src/modules/fleet/domain/errors.ts | 18 +- src/modules/fleet/domain/index.ts | 7 +- .../fleet-error-contracts.integration.spec.ts | 231 ++++++++++++++++++ 4 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 tests/fleet-error-contracts.integration.spec.ts 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/domain/errors.ts b/src/modules/fleet/domain/errors.ts index 35c66ba..a7ef561 100644 --- a/src/modules/fleet/domain/errors.ts +++ b/src/modules/fleet/domain/errors.ts @@ -1,8 +1,20 @@ -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}`); } } 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/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, + }); + }); +}); From 0458d8c068652ef45827ac42dadb218d6575d809 Mon Sep 17 00:00:00 2001 From: dev-fani Date: Mon, 2 Nov 2026 15:00:09 +0000 Subject: [PATCH 16/24] fix(fleet): honor owner pagination and reconcile listing fixtures Use the established singleton owner/page/limit repository contract from 75b121e. Honor page offsets with validated integer boundaries and stable newest-first ties. Preserve removed-member defaults from f9cd04c while reconciling fake/repository/auth/schema fixtures with those contracts. 86 focused fleet and 106 security tests pass. All seven previous fleet failures resolved. Build25/type50 remain; import repair exposes three latent multi-wallet/pageSize errors in the unwired owner-list skeleton. Its result/API policy is documented as blocked, not guessed. --- .../fleet/application/__fixtures__/fakes.ts | 38 ++++- .../fleet/application/get-fleet.spec.ts | 9 +- .../fleet/application/list-my-fleets.ts | 16 +- .../application/sync-fleet-from-event.spec.ts | 3 +- src/modules/fleet/domain/ports.ts | 14 +- ...risma-fleet-repository.integration.spec.ts | 6 +- .../infrastructure/prisma-fleet-repository.ts | 12 +- .../fleet-routes.integration.spec.ts | 27 ++- src/modules/fleet/interface/schemas.spec.ts | 9 +- .../fleet-list-contracts.integration.spec.ts | 158 ++++++++++++++++++ 10 files changed, 255 insertions(+), 37 deletions(-) create mode 100644 tests/fleet-list-contracts.integration.spec.ts 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/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 index 76a84ab..1be5f78 100644 --- a/src/modules/fleet/application/list-my-fleets.ts +++ b/src/modules/fleet/application/list-my-fleets.ts @@ -1,5 +1,5 @@ -import type { FleetRepository, PaginationOptions } from "../domain/ports"; -import type { Fleet } from "../domain/fleet"; +import type { FleetRepository, PaginationOptions } from '../domain/ports.js'; +import type { Fleet } from '../domain/entities.js'; export interface ListMyFleetsInput { ownerAddresses: string[]; @@ -13,19 +13,13 @@ export interface ListMyFleetsResult { pageSize: number; } -export type ListMyFleetsUseCase = ( - input: ListMyFleetsInput, -) => Promise; +export type ListMyFleetsUseCase = (input: ListMyFleetsInput) => Promise; -export function createListMyFleetsUseCase( - fleetRepository: FleetRepository, -): ListMyFleetsUseCase { +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), + ownerAddresses.map((address) => address.trim()).filter((address) => address.length > 0), ), ); 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/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 b1f3b8d..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'); diff --git a/src/modules/fleet/infrastructure/prisma-fleet-repository.ts b/src/modules/fleet/infrastructure/prisma-fleet-repository.ts index 7461fa9..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: { 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/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'); + }, + ); +}); From c53e38d9f17e335eb005e66cc7b822a50ab3f077 Mon Sep 17 00:00:00 2001 From: dev-fani Date: Mon, 2 Nov 2026 15:08:48 +0000 Subject: [PATCH 17/24] fix(fleet): retire unused unfinished owner-list function Remove only list-my-fleets.ts and its exclusively local input/result/use-case types after verifying no imports, barrel exports, dynamic registration, routes, scripts, tests, or supported package API consume it. Private server package has no library entry point. Retain established singleton-owner repository pagination and every functioning fleet path. 86 fleet and 106 security tests pass with no skips. Affected lint/format pass. Build/typecheck25/50 to22/47: exactly the three retired-file errors removed, no additions. --- .../fleet/application/list-my-fleets.ts | 44 ------------------- 1 file changed, 44 deletions(-) delete mode 100644 src/modules/fleet/application/list-my-fleets.ts 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 1be5f78..0000000 --- a/src/modules/fleet/application/list-my-fleets.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { FleetRepository, PaginationOptions } from '../domain/ports.js'; -import type { Fleet } from '../domain/entities.js'; - -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, - }; - }; -} From 9f8fb336a0bd58347a5021aefd6272c71811d19b Mon Sep 17 00:00:00 2001 From: dev-fani Date: Mon, 2 Nov 2026 15:19:52 +0000 Subject: [PATCH 18/24] fix(deliveries): restore route ownership dependency wiring Restore the wallet ownership contract introduced by fbaa40edc8fdbbcd5db814d366e7a8270825eb7d (Blackdavid1) and lost in merge a358617. Require the existing injected wallet repository and pass typed authenticated user/caller inputs without the historical body cast. Preserve all route/schema/hook/builder and transition contracts. 67 delivery, 86 fleet and 106 security tests pass. Build/typecheck22/47 to17/42 with no additions. Existing fallback test uses an owned wallet to reach its unchanged502 assertions. --- .../deliveries-routes.integration.spec.ts | 4 +- src/modules/deliveries/interface/routes.ts | 31 ++- tests/deliveries-wiring.integration.spec.ts | 241 ++++++++++++++++++ 3 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 tests/deliveries-wiring.integration.spec.ts 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/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); + }); +}); From 5e8e735847ab7c9c46e40a0268a57b2690336075 Mon Sep 17 00:00:00 2001 From: dev-fani Date: Mon, 2 Nov 2026 15:43:34 +0000 Subject: [PATCH 19/24] fix(escrow): preserve bigint delivery IDs and reject u64 overflow Align optional refund-status identifier with canonical bigint ports. Validate the source-contract bare-u64 range at escrow builder HTTP, application and direct encoder/adapter boundaries without Number conversion or changed argument order. Preserve existing six-argument create shape; pinned reference has a separate seventh fleet_id ABI gap requiring deployment evidence. Refund fixtures now inject the existing status dependency, preserving assertions and policy. 125 builder/identifier, 67 delivery, 86 fleet and 106 security tests pass. Build/typecheck17/42 to16/37 with no additions. --- .../build-escrow-transactions.spec.ts | 30 +- .../application/build-escrow-transactions.ts | 11 +- src/modules/escrow/domain/identifiers.ts | 11 + .../infrastructure/escrow-scval-mapping.ts | 2 + .../soroban-escrow-contract-client.ts | 4 + src/modules/escrow/interface/schemas.ts | 14 +- tests/escrow-identifier-compatibility.spec.ts | 360 ++++++++++++++++++ 7 files changed, 409 insertions(+), 23 deletions(-) create mode 100644 src/modules/escrow/domain/identifiers.ts create mode 100644 tests/escrow-identifier-compatibility.spec.ts 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 2f2280d..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 { 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']; @@ -33,13 +34,17 @@ export function createBuildEscrowTransactionsUseCases(deps: BuildEscrowTransacti return { buildCreateEscrowTransaction: async (input: CreateEscrowTxInput): Promise => { 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/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 50cf6c6..1b0676a 100644 --- a/src/modules/escrow/infrastructure/escrow-scval-mapping.ts +++ b/src/modules/escrow/infrastructure/escrow-scval-mapping.ts @@ -1,4 +1,5 @@ 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'; @@ -36,6 +37,7 @@ const ESCROW_STATUS_FROM_RUST: Record = { /** `create_escrow(sender, recipient, driver, delivery_id, token, amount)`. */ export function createEscrowArgsToScVal(input: CreateEscrowTxInput): xdr.ScVal[] { assertEscrowAmount(input.amount); + assertEscrowDeliveryId(input.chainDeliveryId); return [ addressToScVal(input.senderAddress), addressToScVal(input.recipientAddress), diff --git a/src/modules/escrow/infrastructure/soroban-escrow-contract-client.ts b/src/modules/escrow/infrastructure/soroban-escrow-contract-client.ts index 2d0fe6f..31617bf 100644 --- a/src/modules/escrow/infrastructure/soroban-escrow-contract-client.ts +++ b/src/modules/escrow/infrastructure/soroban-escrow-contract-client.ts @@ -1,4 +1,5 @@ import { assertEscrowAmount } from '../../../shared/validation/i128.js'; +import { assertEscrowDeliveryId } from '../domain/identifiers.js'; import { addressToScVal, buildInvokeTransaction, @@ -68,6 +69,7 @@ export function createSorobanEscrowContractClient( async buildCreateEscrow(input) { assertEscrowAmount(input.amount); + assertEscrowDeliveryId(input.chainDeliveryId); const balance = await getTokenBalance(client, input.token, input.senderAddress); if (balance !== null && balance < input.amount) { @@ -89,6 +91,7 @@ export function createSorobanEscrowContractClient( }, async buildReleaseEscrow(input) { + assertEscrowDeliveryId(input.chainDeliveryId); return buildInvokeTransaction(client, { contractId, method: 'release_escrow', @@ -98,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 1fb5b95..7d8c35e 100644 --- a/src/modules/escrow/interface/schemas.ts +++ b/src/modules/escrow/interface/schemas.ts @@ -3,10 +3,18 @@ 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 @@ -52,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/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)); + }, + ); + }); +}); From 93ffedbb16283dba98a8d1ac7d13f537a0eddedc Mon Sep 17 00:00:00 2001 From: dev-fani Date: Tue, 3 Nov 2026 12:02:06 +0000 Subject: [PATCH 20/24] fix: restore escrow token verification persistence contract --- docs/contracts/escrow-token-verification.md | 35 ++++ .../migration.sql | 4 + prisma/schema.prisma | 3 + ...risma-analytics-reader.integration.spec.ts | 2 + .../escrow/application/__fixtures__/fakes.ts | 2 +- src/modules/escrow/domain/entities.ts | 2 + ...isma-escrow-repository.integration.spec.ts | 15 ++ .../prisma-escrow-repository.ts | 1 + tests/escrow-verification-contract.spec.ts | 156 ++++++++++++++++++ 9 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 docs/contracts/escrow-token-verification.md create mode 100644 prisma/migrations/20261103120000_escrow_token_verification/migration.sql create mode 100644 tests/escrow-verification-contract.spec.ts 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/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 884c028..4fef012 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -259,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") 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/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/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/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/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 } }); + } + } + } +}); From 7b2441c5c8c3a3dd240e8334baf53f1909d10b68 Mon Sep 17 00:00:00 2001 From: dev-fani Date: Tue, 3 Nov 2026 12:12:59 +0000 Subject: [PATCH 21/24] fix: retire unused invalid fraud composition root The private application and its tests use fraud-detection/index.ts. The alternate file introduced in 05d04da has no consumers and refers to router, repository and dependency contracts that never existed. Remove that unimportable dead root while preserving the canonical factory, rules, authorization and optional alertPublisher implementation. --- .../fraud-detection/fraud-detection.module.ts | 30 ------------------- 1 file changed, 30 deletions(-) delete mode 100644 src/modules/fraud-detection/fraud-detection.module.ts 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, - }; -} From b20ef9a0f3b73d59fcd3ed9a3e4d88e872451247 Mon Sep 17 00:00:00 2001 From: dev-fani Date: Tue, 3 Nov 2026 12:51:38 +0000 Subject: [PATCH 22/24] fix: restrict unsafe worker execution and report unavailable jobs Keep indexer polling, notification delivery and fraud cleanup inactive under the explicitly authorized execution restriction. Separate loading from liveness startup, omit consumer construction and scheduling, and expose the fixed blocked reasons in logs and queue/indexer status. Preserve pending jobs and repeat definitions; activation requires reviewed recovery and retention prerequisites rather than an environment switch. --- docs/DEPLOYMENT.md | 39 ++++ src/modules/indexer/interface/routes.ts | 8 +- src/modules/indexer/interface/schemas.spec.ts | 6 +- src/modules/indexer/interface/schemas.ts | 6 + .../indexer/interface/worker-status.spec.ts | 51 +++++ src/shared/http/routes/health.ts | 31 ++- .../observability-routes.integration.spec.ts | 7 +- src/shared/http/routes/worker-status.spec.ts | 39 ++++ src/shared/queue/worker-execution.ts | 26 +++ src/workers/index.spec.ts | 89 ++++++--- src/workers/index.ts | 47 ++--- ...-execution-restriction.integration.spec.ts | 186 ++++++++++++++++++ 12 files changed, 476 insertions(+), 59 deletions(-) create mode 100644 src/modules/indexer/interface/worker-status.spec.ts create mode 100644 src/shared/http/routes/worker-status.spec.ts create mode 100644 src/shared/queue/worker-execution.ts create mode 100644 tests/worker-execution-restriction.integration.spec.ts 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/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/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/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/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/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'); + } +}); From 0ca13884df74e28c0f242aa7d4787163da8cbddb Mon Sep 17 00:00:00 2001 From: dev-fani Date: Tue, 3 Nov 2026 13:10:58 +0000 Subject: [PATCH 23/24] test: reconcile dispute upload fixtures with canonical contracts Replace cast incomplete dependencies and stale inputs with scoped canonical repository/storage/ownership fixtures. Preserve MIME acceptance/rejection assertions, correct two synthetic PDF headers, and retain hash/identity/persistence/error coverage. The MP4 acceptance case remains failing on the existing service offset mismatch; production validation and worker execution restrictions are unchanged. --- .../application/upload-evidence.spec.ts | 81 +++++++++++++++++-- .../application/upload-evidence.test.ts | 62 ++++++++------ 2 files changed, 115 insertions(+), 28 deletions(-) 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 1bd2163..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/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(); }); }); From 7f80bab309641eaa272be2cbc54d6231bc75b4c2 Mon Sep 17 00:00:00 2001 From: dev-fani Date: Tue, 3 Nov 2026 13:37:09 +0000 Subject: [PATCH 24/24] fix: validate the leading MP4 file type box at its defined offset Share bounded MP4 header recognition across the upload service and signature helpers. Check standard, extended and EOF box sizes against the supplied view and validate brand-field layout without scanning later content or claiming whole-video safety. Preserve authorization, limits, hashing/storage and worker restrictions; add malformed-header and real HTTP regressions. --- docs/contracts/mp4-evidence-header.md | 26 ++++ .../disputes/application/file-signature.ts | 42 +++++- .../disputes/application/upload-evidence.ts | 11 +- src/modules/disputes/interface/schemas.ts | 17 +-- tests/__fixtures__/mp4-headers.ts | 75 ++++++++++ tests/dispute-http-compatibility.spec.ts | 130 +++++++++++++++++- tests/mp4-evidence-validation.spec.ts | 92 +++++++++++++ 7 files changed, 378 insertions(+), 15 deletions(-) create mode 100644 docs/contracts/mp4-evidence-header.md create mode 100644 tests/__fixtures__/mp4-headers.ts create mode 100644 tests/mp4-evidence-validation.spec.ts 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/src/modules/disputes/application/file-signature.ts b/src/modules/disputes/application/file-signature.ts index b07fb5d..4f63543 100644 --- a/src/modules/disputes/application/file-signature.ts +++ b/src/modules/disputes/application/file-signature.ts @@ -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,10 +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 { + 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.ts b/src/modules/disputes/application/upload-evidence.ts index 774a1de..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, @@ -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; 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/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/dispute-http-compatibility.spec.ts b/tests/dispute-http-compatibility.spec.ts index 2efdbe7..9190c67 100644 --- a/tests/dispute-http-compatibility.spec.ts +++ b/tests/dispute-http-compatibility.spec.ts @@ -1,5 +1,5 @@ -import { randomUUID } from 'node:crypto'; -import { mkdtemp, rm } from 'node:fs/promises'; +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'; @@ -11,6 +11,10 @@ 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(); @@ -269,4 +273,126 @@ describe('dispute HTTP compatibility', () => { 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/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)); +});