From d5f0661a017de48c13aeb33784fd9b981f3ea952 Mon Sep 17 00:00:00 2001 From: yinkscss Date: Sat, 30 May 2026 13:22:07 +0100 Subject: [PATCH 1/5] Add immutable admin impersonation session ledger with expiry enforcement. Sessions require explicit start/stop with reason and actor audit trail, and impersonation reads reject expired sessions unless a new session is created. --- backend/.env.example | 4 + .../migration.sql | 49 ++ backend/prisma/schema.prisma | 37 ++ backend/src/__tests__/governance.test.ts | 25 +- .../__tests__/impersonationSessions.test.ts | 140 +++++ backend/src/impersonationSessionService.ts | 499 ++++++++++++++++++ backend/src/index.ts | 247 ++++++++- backend/src/tracing.ts | 9 +- 8 files changed, 1002 insertions(+), 8 deletions(-) create mode 100644 backend/prisma/migrations/20260530000000_add_impersonation_sessions/migration.sql create mode 100644 backend/src/__tests__/impersonationSessions.test.ts create mode 100644 backend/src/impersonationSessionService.ts diff --git a/backend/.env.example b/backend/.env.example index 60e1c3a6..45e98af4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -33,6 +33,10 @@ PRISMA_QUERY_TIMEOUT_MS=5000 # Admin audit log persistence mode: memory | prisma | hybrid ADMIN_AUDIT_LOG_STORAGE=hybrid +# Impersonation session ledger +IMPERSONATION_SESSION_TTL_SECONDS=900 +IMPERSONATION_SESSION_STORAGE=hybrid + # Prisma runtime connection settings PRISMA_POOL_SIZE=10 PRISMA_POOL_TIMEOUT_SEC=10 diff --git a/backend/prisma/migrations/20260530000000_add_impersonation_sessions/migration.sql b/backend/prisma/migrations/20260530000000_add_impersonation_sessions/migration.sql new file mode 100644 index 00000000..5b80bb35 --- /dev/null +++ b/backend/prisma/migrations/20260530000000_add_impersonation_sessions/migration.sql @@ -0,0 +1,49 @@ +-- CreateTable +CREATE TABLE "AdminImpersonationSession" ( + "id" TEXT NOT NULL PRIMARY KEY, + "actor" TEXT NOT NULL, + "apiKeyHash" TEXT NOT NULL, + "targetWallet" TEXT NOT NULL, + "reason" TEXT NOT NULL, + "startedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" DATETIME NOT NULL, + "endedAt" DATETIME, + "status" TEXT NOT NULL DEFAULT 'active', + "ipAddress" TEXT NOT NULL, + "userAgent" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "AdminImpersonationLedgerEntry" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionId" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "actor" TEXT NOT NULL, + "metadata" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AdminImpersonationLedgerEntry_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "AdminImpersonationSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "AdminImpersonationSession_status_idx" ON "AdminImpersonationSession"("status"); + +-- CreateIndex +CREATE INDEX "AdminImpersonationSession_actor_idx" ON "AdminImpersonationSession"("actor"); + +-- CreateIndex +CREATE INDEX "AdminImpersonationSession_targetWallet_idx" ON "AdminImpersonationSession"("targetWallet"); + +-- CreateIndex +CREATE INDEX "AdminImpersonationSession_startedAt_idx" ON "AdminImpersonationSession"("startedAt"); + +-- CreateIndex +CREATE INDEX "AdminImpersonationSession_expiresAt_idx" ON "AdminImpersonationSession"("expiresAt"); + +-- CreateIndex +CREATE INDEX "AdminImpersonationLedgerEntry_sessionId_idx" ON "AdminImpersonationLedgerEntry"("sessionId"); + +-- CreateIndex +CREATE INDEX "AdminImpersonationLedgerEntry_eventType_idx" ON "AdminImpersonationLedgerEntry"("eventType"); + +-- CreateIndex +CREATE INDEX "AdminImpersonationLedgerEntry_createdAt_idx" ON "AdminImpersonationLedgerEntry"("createdAt"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 14d8434c..863cdac7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -169,6 +169,43 @@ model AdminActionReceipt { @@index([timestamp]) } +model AdminImpersonationSession { + id String @id @default(uuid()) + actor String + apiKeyHash String + targetWallet String + reason String + startedAt DateTime @default(now()) + expiresAt DateTime + endedAt DateTime? + status String @default("active") + ipAddress String + userAgent String + + ledgerEntries AdminImpersonationLedgerEntry[] + + @@index([status]) + @@index([actor]) + @@index([targetWallet]) + @@index([startedAt]) + @@index([expiresAt]) +} + +model AdminImpersonationLedgerEntry { + id String @id @default(uuid()) + sessionId String + eventType String + actor String + metadata String + createdAt DateTime @default(now()) + + session AdminImpersonationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + + @@index([sessionId]) + @@index([eventType]) + @@index([createdAt]) +} + model EmailQueue { id String @id @default(uuid()) to String diff --git a/backend/src/__tests__/governance.test.ts b/backend/src/__tests__/governance.test.ts index 9043f61c..a595c2ff 100644 --- a/backend/src/__tests__/governance.test.ts +++ b/backend/src/__tests__/governance.test.ts @@ -3,18 +3,24 @@ import app from '../index'; import { idempotencyStore } from '../idempotency'; import { getJobMetrics, resetJobGovernance, runJobWithRetry } from '../jobGovernance'; import { clearAdminAuditLogsForTests } from '../adminAudit'; +import { clearImpersonationSessionsForTests } from '../impersonationSessionService'; import { registerApiKey } from '../middleware/apiKeyAuth'; +import { normalizeWalletAddress } from '../walletUtils'; describe('Backend governance', () => { const adminApiKey = 'admin-test-key'; const superAdminApiKey = 'super-admin-test-key'; - const targetWallet = 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567'; + const targetWallet = normalizeWalletAddress( + 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567', + ); beforeEach(() => { idempotencyStore.clear(); resetJobGovernance(); clearAdminAuditLogsForTests(); + clearImpersonationSessionsForTests(); process.env.ADMIN_AUDIT_LOG_STORAGE = 'memory'; + process.env.IMPERSONATION_SESSION_STORAGE = 'memory'; registerApiKey(adminApiKey); registerApiKey(superAdminApiKey, { role: 'super-admin' }); }); @@ -30,7 +36,7 @@ describe('Backend governance', () => { const payload = { amount: 250, asset: 'USDC', - walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567', + walletAddress: targetWallet, }; const first = await request(app) @@ -56,7 +62,7 @@ describe('Backend governance', () => { .send({ amount: 250, asset: 'USDC', - walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567', + walletAddress: targetWallet, }); const second = await request(app) @@ -65,7 +71,7 @@ describe('Backend governance', () => { .send({ amount: 300, asset: 'USDC', - walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567', + walletAddress: targetWallet, }); expect(first.status).toBe(201); @@ -155,10 +161,19 @@ describe('Backend governance', () => { request(app).get(`/api/v1/referrals/code/${targetWallet}`), ]); + const sessionResponse = await request(app) + .post('/admin/impersonate/sessions') + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-id', 'GADMIN000000000000000000000000000000000000000000000001') + .send({ targetWallet, reason: 'governance test impersonation' }); + + expect(sessionResponse.status).toBe(201); + const response = await request(app) .get(`/admin/impersonate/${targetWallet}`) .set('Authorization', `ApiKey ${superAdminApiKey}`) - .set('x-admin-id', 'GADMIN000000000000000000000000000000000000000000000001'); + .set('x-admin-id', 'GADMIN000000000000000000000000000000000000000000000001') + .set('x-impersonation-session-id', sessionResponse.body.session.id); expect(response.status).toBe(200); expect(response.body.walletAddress).toBe(targetWallet); diff --git a/backend/src/__tests__/impersonationSessions.test.ts b/backend/src/__tests__/impersonationSessions.test.ts new file mode 100644 index 00000000..1ffa0255 --- /dev/null +++ b/backend/src/__tests__/impersonationSessions.test.ts @@ -0,0 +1,140 @@ +import request from 'supertest'; +import app from '../index'; +import { clearAdminAuditLogsForTests } from '../adminAudit'; +import { clearImpersonationSessionsForTests } from '../impersonationSessionService'; +import { registerApiKey } from '../middleware/apiKeyAuth'; +import { normalizeWalletAddress } from '../walletUtils'; + +describe('Impersonation session ledger', () => { + const superAdminApiKey = 'super-admin-impersonation-key'; + const adminApiKey = 'admin-impersonation-key'; + const targetWallet = normalizeWalletAddress( + 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567', + ); + const actingAdmin = 'GADMIN000000000000000000000000000000000000000000000001'; + + beforeEach(() => { + clearAdminAuditLogsForTests(); + clearImpersonationSessionsForTests(); + process.env.ADMIN_AUDIT_LOG_STORAGE = 'memory'; + process.env.IMPERSONATION_SESSION_STORAGE = 'memory'; + process.env.IMPERSONATION_SESSION_TTL_SECONDS = '900'; + registerApiKey(adminApiKey); + registerApiKey(superAdminApiKey, { role: 'super-admin' }); + }); + + async function startSession(reason = 'support investigation') { + return request(app) + .post('/admin/impersonate/sessions') + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-id', actingAdmin) + .send({ targetWallet, reason }); + } + + it('creates an immutable session record with actor, reason, and expiry', async () => { + const response = await startSession('customer support ticket #42'); + + expect(response.status).toBe(201); + expect(response.body.session).toMatchObject({ + actor: actingAdmin, + targetWallet, + reason: 'customer support ticket #42', + status: 'active', + }); + expect(response.body.session.id).toBeTruthy(); + expect(response.body.session.expiresAt).toBeTruthy(); + expect(Date.parse(response.body.session.expiresAt)).toBeGreaterThan(Date.now()); + }); + + it('requires a valid session to impersonate a wallet', async () => { + const sessionResponse = await startSession(); + const sessionId = sessionResponse.body.session.id; + + const response = await request(app) + .get(`/admin/impersonate/${targetWallet}`) + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-id', actingAdmin) + .set('x-impersonation-session-id', sessionId); + + expect(response.status).toBe(200); + expect(response.body.walletAddress).toBe(targetWallet); + expect(response.body.impersonationSession).toMatchObject({ + id: sessionId, + reason: 'support investigation', + }); + }); + + it('rejects impersonation without a session header', async () => { + const response = await request(app) + .get(`/admin/impersonate/${targetWallet}`) + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-id', actingAdmin); + + expect(response.status).toBe(400); + expect(response.body.message).toMatch(/x-impersonation-session-id/i); + }); + + it('rejects expired sessions and requires a new session record', async () => { + process.env.IMPERSONATION_SESSION_TTL_SECONDS = '1'; + + const sessionResponse = await startSession(); + const sessionId = sessionResponse.body.session.id; + + await new Promise((resolve) => setTimeout(resolve, 1100)); + + const expiredResponse = await request(app) + .get(`/admin/impersonate/${targetWallet}`) + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-id', actingAdmin) + .set('x-impersonation-session-id', sessionId); + + expect(expiredResponse.status).toBe(403); + expect(expiredResponse.body.message).toMatch(/expired/i); + + const endResponse = await request(app) + .delete(`/admin/impersonate/sessions/${sessionId}`) + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-id', actingAdmin); + + expect(endResponse.status).toBe(404); + + const newSessionResponse = await startSession('renewed after expiry'); + expect(newSessionResponse.status).toBe(201); + expect(newSessionResponse.body.session.id).not.toBe(sessionId); + }); + + it('lists active and historical sessions for super-admins', async () => { + const sessionResponse = await startSession(); + const sessionId = sessionResponse.body.session.id; + + await request(app) + .delete(`/admin/impersonate/sessions/${sessionId}`) + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-id', actingAdmin); + + const listResponse = await request(app) + .get('/admin/impersonate/sessions') + .query({ status: 'all', limit: 10 }) + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-id', actingAdmin); + + expect(listResponse.status).toBe(200); + expect(listResponse.body.count).toBeGreaterThanOrEqual(1); + expect(listResponse.body.sessions[0]).toMatchObject({ + id: sessionId, + actor: actingAdmin, + targetWallet, + reason: 'support investigation', + }); + }); + + it('denies non-super-admin session management', async () => { + const response = await request(app) + .post('/admin/impersonate/sessions') + .set('Authorization', `ApiKey ${adminApiKey}`) + .set('x-admin-id', actingAdmin) + .send({ targetWallet, reason: 'should fail' }); + + expect(response.status).toBe(403); + }); +}); diff --git a/backend/src/impersonationSessionService.ts b/backend/src/impersonationSessionService.ts new file mode 100644 index 00000000..75a001b8 --- /dev/null +++ b/backend/src/impersonationSessionService.ts @@ -0,0 +1,499 @@ +import type { Request } from 'express'; +import { prisma } from './prisma'; +import { normalizeWalletAddress } from './walletUtils'; + +export type ImpersonationSessionStatus = 'active' | 'ended' | 'expired'; + +export type ImpersonationLedgerEventType = + | 'session.started' + | 'session.ended' + | 'session.expired' + | 'session.access'; + +export interface ImpersonationSessionRecord { + id: string; + actor: string; + apiKeyHash: string; + targetWallet: string; + reason: string; + startedAt: string; + expiresAt: string; + endedAt: string | null; + status: ImpersonationSessionStatus; + ipAddress: string; + userAgent: string; +} + +export interface ImpersonationLedgerEntryRecord { + id: string; + sessionId: string; + eventType: ImpersonationLedgerEventType; + actor: string; + metadata: Record; + createdAt: string; +} + +export interface StartImpersonationSessionInput { + actor: string; + apiKeyHash: string; + targetWallet: string; + reason: string; + ipAddress: string; + userAgent: string; +} + +export interface ListImpersonationSessionsFilters { + status?: ImpersonationSessionStatus | 'all'; + actor?: string; + targetWallet?: string; + limit: number; +} + +type StorageMode = 'memory' | 'prisma' | 'hybrid'; + +const inMemorySessions = new Map(); +const inMemoryLedger: ImpersonationLedgerEntryRecord[] = []; + +function normalizeStorageMode(raw: string | undefined): StorageMode { + if (raw === 'prisma' || raw === 'memory' || raw === 'hybrid') { + return raw; + } + return 'hybrid'; +} + +function getSessionTtlMs(): number { + const raw = process.env.IMPERSONATION_SESSION_TTL_SECONDS; + const seconds = raw ? Number.parseInt(raw, 10) : 900; + if (!Number.isFinite(seconds) || seconds <= 0) { + return 900_000; + } + return seconds * 1000; +} + +function createId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; +} + +function resolveEffectiveStatus( + session: ImpersonationSessionRecord, + now = Date.now(), +): ImpersonationSessionStatus { + if (session.status === 'ended') { + return 'ended'; + } + if (Date.parse(session.expiresAt) <= now) { + return 'expired'; + } + return session.status === 'active' ? 'active' : session.status; +} + +function toSessionRecord(row: { + id: string; + actor: string; + apiKeyHash: string; + targetWallet: string; + reason: string; + startedAt: Date; + expiresAt: Date; + endedAt: Date | null; + status: string; + ipAddress: string; + userAgent: string; +}): ImpersonationSessionRecord { + return { + id: row.id, + actor: row.actor, + apiKeyHash: row.apiKeyHash, + targetWallet: row.targetWallet, + reason: row.reason, + startedAt: row.startedAt.toISOString(), + expiresAt: row.expiresAt.toISOString(), + endedAt: row.endedAt ? row.endedAt.toISOString() : null, + status: row.status as ImpersonationSessionStatus, + ipAddress: row.ipAddress, + userAgent: row.userAgent, + }; +} + +async function appendLedgerEntry( + storageMode: StorageMode, + entry: ImpersonationLedgerEntryRecord, +): Promise { + if (storageMode === 'memory') { + inMemoryLedger.unshift(entry); + return; + } + + try { + await prisma.adminImpersonationLedgerEntry.create({ + data: { + id: entry.id, + sessionId: entry.sessionId, + eventType: entry.eventType, + actor: entry.actor, + metadata: JSON.stringify(entry.metadata), + }, + }); + } catch { + if (storageMode === 'prisma') { + throw new Error('Failed to persist impersonation ledger entry'); + } + inMemoryLedger.unshift(entry); + } +} + +async function persistSession( + storageMode: StorageMode, + session: ImpersonationSessionRecord, +): Promise { + if (storageMode === 'memory') { + inMemorySessions.set(session.id, session); + return; + } + + try { + await prisma.adminImpersonationSession.upsert({ + where: { id: session.id }, + create: { + id: session.id, + actor: session.actor, + apiKeyHash: session.apiKeyHash, + targetWallet: session.targetWallet, + reason: session.reason, + startedAt: new Date(session.startedAt), + expiresAt: new Date(session.expiresAt), + endedAt: session.endedAt ? new Date(session.endedAt) : null, + status: session.status, + ipAddress: session.ipAddress, + userAgent: session.userAgent, + }, + update: { + endedAt: session.endedAt ? new Date(session.endedAt) : null, + status: session.status, + }, + }); + } catch { + if (storageMode === 'prisma') { + throw new Error('Failed to persist impersonation session'); + } + inMemorySessions.set(session.id, session); + } +} + +async function getSessionById( + storageMode: StorageMode, + sessionId: string, +): Promise { + if (storageMode === 'memory') { + return inMemorySessions.get(sessionId) || null; + } + + try { + const row = await prisma.adminImpersonationSession.findUnique({ + where: { id: sessionId }, + }); + return row ? toSessionRecord(row) : null; + } catch { + if (storageMode === 'prisma') { + throw new Error('Failed to read impersonation session'); + } + return inMemorySessions.get(sessionId) || null; + } +} + +export async function startImpersonationSession( + input: StartImpersonationSessionInput, +): Promise { + const storageMode = normalizeStorageMode(process.env.IMPERSONATION_SESSION_STORAGE); + const now = Date.now(); + const targetWallet = normalizeWalletAddress(input.targetWallet); + const session: ImpersonationSessionRecord = { + id: createId('imp_sess'), + actor: input.actor, + apiKeyHash: input.apiKeyHash, + targetWallet, + reason: input.reason.trim(), + startedAt: new Date(now).toISOString(), + expiresAt: new Date(now + getSessionTtlMs()).toISOString(), + endedAt: null, + status: 'active', + ipAddress: input.ipAddress, + userAgent: input.userAgent, + }; + + await persistSession(storageMode, session); + await appendLedgerEntry(storageMode, { + id: createId('imp_led'), + sessionId: session.id, + eventType: 'session.started', + actor: input.actor, + metadata: { + targetWallet, + reason: session.reason, + expiresAt: session.expiresAt, + }, + createdAt: new Date(now).toISOString(), + }); + + return session; +} + +export async function endImpersonationSession( + sessionId: string, + actor: string, +): Promise { + const storageMode = normalizeStorageMode(process.env.IMPERSONATION_SESSION_STORAGE); + const session = await getSessionById(storageMode, sessionId); + if (!session) { + return null; + } + + const effectiveStatus = resolveEffectiveStatus(session); + if (effectiveStatus === 'expired') { + return null; + } + if (effectiveStatus === 'ended') { + return session; + } + + const endedSession: ImpersonationSessionRecord = { + ...session, + status: 'ended', + endedAt: new Date().toISOString(), + }; + + await persistSession(storageMode, endedSession); + await appendLedgerEntry(storageMode, { + id: createId('imp_led'), + sessionId: endedSession.id, + eventType: 'session.ended', + actor, + metadata: { + targetWallet: endedSession.targetWallet, + endedAt: endedSession.endedAt, + }, + createdAt: new Date().toISOString(), + }); + + return endedSession; +} + +async function markSessionExpired( + session: ImpersonationSessionRecord, + actor: string, +): Promise { + const storageMode = normalizeStorageMode(process.env.IMPERSONATION_SESSION_STORAGE); + const expiredSession: ImpersonationSessionRecord = { + ...session, + status: 'expired', + endedAt: session.endedAt || new Date().toISOString(), + }; + + await persistSession(storageMode, expiredSession); + await appendLedgerEntry(storageMode, { + id: createId('imp_led'), + sessionId: expiredSession.id, + eventType: 'session.expired', + actor, + metadata: { + targetWallet: expiredSession.targetWallet, + expiresAt: expiredSession.expiresAt, + }, + createdAt: new Date().toISOString(), + }); + + return expiredSession; +} + +export async function validateImpersonationSession( + sessionId: string, + targetWallet: string, + actor: string, +): Promise< + | { ok: true; session: ImpersonationSessionRecord } + | { ok: false; reason: 'not_found' | 'expired' | 'ended' | 'wallet_mismatch' | 'actor_mismatch' } +> { + const storageMode = normalizeStorageMode(process.env.IMPERSONATION_SESSION_STORAGE); + const session = await getSessionById(storageMode, sessionId); + if (!session) { + return { ok: false, reason: 'not_found' }; + } + + if (session.actor !== actor) { + return { ok: false, reason: 'actor_mismatch' }; + } + + const normalizedTarget = normalizeWalletAddress(targetWallet); + if (session.targetWallet !== normalizedTarget) { + return { ok: false, reason: 'wallet_mismatch' }; + } + + const effectiveStatus = resolveEffectiveStatus(session); + if (effectiveStatus === 'ended') { + return { ok: false, reason: 'ended' }; + } + + if (effectiveStatus === 'expired') { + if (session.status !== 'expired') { + await markSessionExpired(session, actor); + } + return { ok: false, reason: 'expired' }; + } + + await appendLedgerEntry(storageMode, { + id: createId('imp_led'), + sessionId: session.id, + eventType: 'session.access', + actor, + metadata: { + targetWallet: normalizedTarget, + }, + createdAt: new Date().toISOString(), + }); + + return { ok: true, session }; +} + +export async function listImpersonationSessions( + filters: ListImpersonationSessionsFilters, +): Promise { + const storageMode = normalizeStorageMode(process.env.IMPERSONATION_SESSION_STORAGE); + const statusFilter = filters.status || 'all'; + + if (storageMode === 'memory') { + return Array.from(inMemorySessions.values()) + .filter((session) => { + const effectiveStatus = resolveEffectiveStatus(session); + if (statusFilter !== 'all' && effectiveStatus !== statusFilter) { + return false; + } + if (filters.actor && session.actor !== filters.actor) { + return false; + } + if (filters.targetWallet && session.targetWallet !== normalizeWalletAddress(filters.targetWallet)) { + return false; + } + return true; + }) + .sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt)) + .slice(0, filters.limit) + .map((session) => ({ + ...session, + status: resolveEffectiveStatus(session), + })); + } + + try { + const rows = await prisma.adminImpersonationSession.findMany({ + where: { + ...(filters.actor ? { actor: filters.actor } : {}), + ...(filters.targetWallet + ? { targetWallet: normalizeWalletAddress(filters.targetWallet) } + : {}), + ...(statusFilter !== 'all' ? { status: statusFilter } : {}), + }, + orderBy: { startedAt: 'desc' }, + take: filters.limit, + }); + + return rows.map((row) => { + const session = toSessionRecord(row); + return { + ...session, + status: resolveEffectiveStatus(session), + }; + }); + } catch { + if (storageMode === 'prisma') { + throw new Error('Failed to list impersonation sessions'); + } + return Array.from(inMemorySessions.values()) + .filter((session) => { + const effectiveStatus = resolveEffectiveStatus(session); + if (statusFilter !== 'all' && effectiveStatus !== statusFilter) { + return false; + } + if (filters.actor && session.actor !== filters.actor) { + return false; + } + if (filters.targetWallet && session.targetWallet !== normalizeWalletAddress(filters.targetWallet)) { + return false; + } + return true; + }) + .sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt)) + .slice(0, filters.limit) + .map((session) => ({ + ...session, + status: resolveEffectiveStatus(session), + })); + } +} + +export async function listImpersonationLedgerEntries( + sessionId: string, +): Promise { + const storageMode = normalizeStorageMode(process.env.IMPERSONATION_SESSION_STORAGE); + + if (storageMode === 'memory') { + return inMemoryLedger + .filter((entry) => entry.sessionId === sessionId) + .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); + } + + try { + const rows = await prisma.adminImpersonationLedgerEntry.findMany({ + where: { sessionId }, + orderBy: { createdAt: 'desc' }, + }); + + return rows.map((row) => ({ + id: row.id, + sessionId: row.sessionId, + eventType: row.eventType as ImpersonationLedgerEventType, + actor: row.actor, + metadata: safeParseMetadata(row.metadata), + createdAt: row.createdAt.toISOString(), + })); + } catch { + if (storageMode === 'prisma') { + throw new Error('Failed to list impersonation ledger entries'); + } + return inMemoryLedger.filter((entry) => entry.sessionId === sessionId); + } +} + +function safeParseMetadata(raw: string): Record { + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + return parsed as Record; + } + return {}; + } catch { + return {}; + } +} + +export function resolveImpersonationSessionContext(req: Request): { + actor: string; + apiKeyHash: string; + ipAddress: string; + userAgent: string; +} { + return { + actor: + req.get('x-admin-address') || + req.get('x-admin-id') || + req.get('x-wallet-address') || + 'unknown', + apiKeyHash: req.authApiKeyHash || 'unknown', + ipAddress: req.ip || 'unknown', + userAgent: req.get('user-agent') || 'unknown', + }; +} + +export function clearImpersonationSessionsForTests(): void { + inMemorySessions.clear(); + inMemoryLedger.length = 0; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index ec91fa17..ae38e46e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -20,6 +20,13 @@ import { import { idempotencyStore } from './idempotency'; import { createAdminAuditMiddleware, getAuditLogs, getAuditLogMetrics } from './auditLog'; import { recordAdminAuditLog } from './adminAudit'; +import { + startImpersonationSession, + endImpersonationSession, + validateImpersonationSession, + listImpersonationSessions, + resolveImpersonationSessionContext, +} from './impersonationSessionService'; import { generateAdminReceipt, getAdminReceipt, listAdminReceipts, verifyReceiptSignature } from './adminReceipt'; import { startApySnapshotScheduler } from './apySnapshot'; import { sorobanCircuitBreaker } from './circuitBreaker'; @@ -1063,18 +1070,209 @@ app.get('/admin/allowlist', validateApiKey, (_req: Request, res: Response) => { }); /** - * GET /admin/impersonate/:wallet - inspect vault state as a specific wallet + * POST /admin/impersonate/sessions - start a time-bounded impersonation session + * Requires super-admin API key. + */ +app.post('/admin/impersonate/sessions', validateApiKey, async (req: Request, res: Response) => { + const actingAdminAddress = resolveActingAdminAddress(req); + const { actor, apiKeyHash, ipAddress, userAgent } = resolveImpersonationSessionContext(req); + const targetWallet = typeof req.body?.targetWallet === 'string' ? req.body.targetWallet.trim() : ''; + const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim() : ''; + + req.adminAuditActor = actingAdminAddress; + req.adminAuditMetadata = { + actingAdminAddress, + adminRole: req.authApiKeyRole || 'admin', + targetWallet: targetWallet || 'unknown', + impersonation: true, + }; + + if (!targetWallet || !reason) { + req.adminAuditAction = 'admin.impersonate.session.invalid'; + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'targetWallet and reason are required', + }); + return; + } + + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + req.adminAuditAction = 'admin.impersonate.session.denied'; + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required for impersonation sessions', + }); + return; + } + + try { + const session = await startImpersonationSession({ + actor: actingAdminAddress, + apiKeyHash, + targetWallet, + reason, + ipAddress, + userAgent, + }); + + req.adminAuditAction = 'admin.impersonate.session.started'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + sessionId: session.id, + expiresAt: session.expiresAt, + }; + + res.status(201).json({ session }); + } catch (error) { + req.adminAuditAction = 'admin.impersonate.session.failed'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + error: error instanceof Error ? error.message : String(error), + }; + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to start impersonation session', + }); + } +}); + +/** + * GET /admin/impersonate/sessions - list active and historical impersonation sessions * Requires super-admin API key. */ +app.get('/admin/impersonate/sessions', validateApiKey, async (req: Request, res: Response) => { + const actingAdminAddress = resolveActingAdminAddress(req); + + req.adminAuditActor = actingAdminAddress; + req.adminAuditMetadata = { + actingAdminAddress, + adminRole: req.authApiKeyRole || 'admin', + impersonation: true, + }; + + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + req.adminAuditAction = 'admin.impersonate.session.list.denied'; + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to list impersonation sessions', + }); + return; + } + + const statusRaw = typeof req.query.status === 'string' ? req.query.status : 'all'; + const status = + statusRaw === 'active' || statusRaw === 'ended' || statusRaw === 'expired' ? statusRaw : 'all'; + const actor = typeof req.query.actor === 'string' ? req.query.actor : undefined; + const targetWallet = typeof req.query.targetWallet === 'string' ? req.query.targetWallet : undefined; + const limit = typeof req.query.limit === 'string' ? parseInt(req.query.limit, 10) : 50; + + try { + const sessions = await listImpersonationSessions({ + status, + actor, + targetWallet, + limit: Number.isFinite(limit) && limit > 0 ? limit : 50, + }); + + req.adminAuditAction = 'admin.impersonate.session.list'; + res.status(200).json({ sessions, count: sessions.length }); + } catch (error) { + req.adminAuditAction = 'admin.impersonate.session.list.failed'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + error: error instanceof Error ? error.message : String(error), + }; + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to list impersonation sessions', + }); + } +}); + +/** + * DELETE /admin/impersonate/sessions/:id - end an active impersonation session + * Requires super-admin API key. + */ +app.delete('/admin/impersonate/sessions/:id', validateApiKey, async (req: Request, res: Response) => { + const actingAdminAddress = resolveActingAdminAddress(req); + const sessionId = String(req.params.id || '').trim(); + + req.adminAuditActor = actingAdminAddress; + req.adminAuditMetadata = { + actingAdminAddress, + adminRole: req.authApiKeyRole || 'admin', + sessionId: sessionId || 'unknown', + impersonation: true, + }; + + if (!sessionId) { + req.adminAuditAction = 'admin.impersonate.session.end.invalid'; + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'session id is required', + }); + return; + } + + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + req.adminAuditAction = 'admin.impersonate.session.end.denied'; + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to end impersonation sessions', + }); + return; + } + + try { + const session = await endImpersonationSession(sessionId, actingAdminAddress); + if (!session) { + req.adminAuditAction = 'admin.impersonate.session.end.not_found'; + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Impersonation session not found or already expired', + }); + return; + } + + req.adminAuditAction = 'admin.impersonate.session.ended'; + res.status(200).json({ session }); + } catch (error) { + req.adminAuditAction = 'admin.impersonate.session.end.failed'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + error: error instanceof Error ? error.message : String(error), + }; + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to end impersonation session', + }); + } +}); + +/** + * GET /admin/impersonate/:wallet - inspect vault state as a specific wallet + * Requires super-admin API key and a valid non-expired impersonation session. + */ app.get('/admin/impersonate/:wallet', validateApiKey, async (req: Request, res: Response) => { const wallet = String(req.params.wallet || '').trim(); const actingAdminAddress = resolveActingAdminAddress(req); + const sessionId = String(req.get('x-impersonation-session-id') || '').trim(); req.adminAuditActor = actingAdminAddress; req.adminAuditMetadata = { actingAdminAddress, adminRole: req.authApiKeyRole || 'admin', targetWallet: wallet || 'unknown', + sessionId: sessionId || undefined, impersonation: true, }; @@ -1098,11 +1296,56 @@ app.get('/admin/impersonate/:wallet', validateApiKey, async (req: Request, res: return; } + if (!sessionId) { + req.adminAuditAction = 'admin.impersonate.session.required'; + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'x-impersonation-session-id header is required', + }); + return; + } + + const validation = await validateImpersonationSession(sessionId, wallet, actingAdminAddress); + if (!validation.ok) { + const statusCode = validation.reason === 'not_found' ? 404 : 403; + req.adminAuditAction = + validation.reason === 'expired' + ? 'admin.impersonate.expired' + : 'admin.impersonate.session.invalid'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + validationReason: validation.reason, + }; + res.status(statusCode).json({ + error: statusCode === 404 ? 'Not Found' : 'Forbidden', + status: statusCode, + message: + validation.reason === 'expired' + ? 'Impersonation session has expired; start a new session to continue' + : validation.reason === 'ended' + ? 'Impersonation session has ended; start a new session to continue' + : validation.reason === 'wallet_mismatch' + ? 'Session target wallet does not match requested wallet' + : validation.reason === 'actor_mismatch' + ? 'Session actor does not match requesting admin' + : 'Impersonation session not found', + }); + return; + } + req.adminAuditAction = 'admin.impersonate'; try { const snapshot = await buildImpersonatedVaultState(wallet); - res.status(200).json(snapshot); + res.status(200).json({ + ...snapshot, + impersonationSession: { + id: validation.session.id, + expiresAt: validation.session.expiresAt, + reason: validation.session.reason, + }, + }); } catch (error) { req.adminAuditAction = 'admin.impersonate.failed'; req.adminAuditMetadata = { diff --git a/backend/src/tracing.ts b/backend/src/tracing.ts index 4ea55949..a7d784c6 100644 --- a/backend/src/tracing.ts +++ b/backend/src/tracing.ts @@ -88,12 +88,19 @@ export function getTracer(): Tracer { * Wraps an async function in a named span. * Automatically records exceptions and sets error status. */ +const NOOP_SPAN = { + setAttributes: () => {}, + setStatus: () => {}, + recordException: () => {}, + end: () => {}, +} as unknown as Span; + export async function withSpan( name: string, fn: (span: Span) => Promise, attributes?: Record, ): Promise { - if (!OTEL_ENABLED) return fn(trace.getTracer(SERVICE_NAME).startSpan(name)); + if (!OTEL_ENABLED) return fn(NOOP_SPAN); const tracer = getTracer(); return tracer.startActiveSpan(name, async (span) => { From 20de6b2facf6466bda6026e03c7a9fcb89ff4b6a Mon Sep 17 00:00:00 2001 From: yinkscss Date: Sat, 30 May 2026 14:19:06 +0100 Subject: [PATCH 2/5] Fix backend test fixtures for API key auth and Stellar wallet validation. Registers default admin keys in test setup and updates test wallets to valid 56-character base32 addresses so governance checks pass in CI. --- backend/src/__tests__/allowlist.test.ts | 7 ++++--- backend/src/__tests__/auth.test.ts | 3 ++- backend/src/__tests__/governance.test.ts | 2 +- backend/src/__tests__/issues481-484.test.ts | 2 +- backend/src/__tests__/setup.ts | 10 ++++++++++ backend/src/__tests__/walletUtils.test.ts | 6 ++---- 6 files changed, 20 insertions(+), 10 deletions(-) diff --git a/backend/src/__tests__/allowlist.test.ts b/backend/src/__tests__/allowlist.test.ts index 25c4cd58..38e8e58e 100644 --- a/backend/src/__tests__/allowlist.test.ts +++ b/backend/src/__tests__/allowlist.test.ts @@ -4,6 +4,7 @@ import request from 'supertest'; import app from '../index'; +import { VALID_TEST_WALLET } from './setup'; import { addAddress, removeAddress, @@ -67,8 +68,8 @@ describe('Allowlist store', () => { // ─── Middleware integration tests ──────────────────────────────────────────── describe('allowlistMiddleware – ALLOWLIST_ENABLED=true', () => { - const ALLOWED_WALLET = 'GTEST_ALLOWED_WALLET_ADDRESS_001'; - const BLOCKED_WALLET = 'GBLOCKED_WALLET_999'; + const ALLOWED_WALLET = VALID_TEST_WALLET; + const BLOCKED_WALLET = 'G345678ABCDEFGHIJKLMNOPQRSTUVWXYZ345678ABCDEFGHIJKLMNOPQR'; beforeAll(() => { // Ensure feature is enabled @@ -150,7 +151,7 @@ describe('allowlistMiddleware – ALLOWLIST_ENABLED=false', () => { .send({ amount: '100', asset: 'USDC', - walletAddress: 'GANYONE', + walletAddress: VALID_TEST_WALLET, }); // Feature disabled → allowlist check skipped → business logic runs expect([201, 503]).toContain(res.status); diff --git a/backend/src/__tests__/auth.test.ts b/backend/src/__tests__/auth.test.ts index aff2f2e8..b84ae861 100644 --- a/backend/src/__tests__/auth.test.ts +++ b/backend/src/__tests__/auth.test.ts @@ -11,8 +11,9 @@ import { SessionRevokedError, InvalidRefreshTokenError, } from '../auth'; +import { VALID_TEST_WALLET } from './setup'; -const TEST_WALLET = 'GTEST_WALLET_ADDRESS_JWT_001'; +const TEST_WALLET = VALID_TEST_WALLET; // ─── issueTokenPair unit tests ─────────────────────────────────────────────── diff --git a/backend/src/__tests__/governance.test.ts b/backend/src/__tests__/governance.test.ts index a595c2ff..45a38825 100644 --- a/backend/src/__tests__/governance.test.ts +++ b/backend/src/__tests__/governance.test.ts @@ -11,7 +11,7 @@ describe('Backend governance', () => { const adminApiKey = 'admin-test-key'; const superAdminApiKey = 'super-admin-test-key'; const targetWallet = normalizeWalletAddress( - 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567', + 'G234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQ', ); beforeEach(() => { diff --git a/backend/src/__tests__/issues481-484.test.ts b/backend/src/__tests__/issues481-484.test.ts index 1d819388..f7034831 100644 --- a/backend/src/__tests__/issues481-484.test.ts +++ b/backend/src/__tests__/issues481-484.test.ts @@ -18,7 +18,7 @@ import { backfillApySnapshots } from '../apySnapshot'; import { parseUtcDateRange, DateRangeParseError } from '../dateRange'; const ADMIN_KEY = process.env.ADMIN_API_KEY || 'test-admin-key'; -const AUTH_HEADER = { 'x-api-key': ADMIN_KEY }; +const AUTH_HEADER = { Authorization: `ApiKey ${ADMIN_KEY}` }; // ─── #481: Maintenance Mode Gate ───────────────────────────────────────────── diff --git a/backend/src/__tests__/setup.ts b/backend/src/__tests__/setup.ts index 07e26376..a59dc821 100644 --- a/backend/src/__tests__/setup.ts +++ b/backend/src/__tests__/setup.ts @@ -39,3 +39,13 @@ class PatchedPrismaClient extends OriginalPrismaClient { // Replace the exported PrismaClient PrismaClientModule.PrismaClient = PatchedPrismaClient; + +// Register default admin API keys for integration tests that use test-admin-key. +const { registerApiKey } = require('../middleware/apiKeyAuth') as typeof import('../middleware/apiKeyAuth'); +const defaultAdminKey = process.env.ADMIN_API_KEY || 'test-admin-key'; +registerApiKey(defaultAdminKey); +registerApiKey('super-admin-test-key', { role: 'super-admin' }); + +/** Valid 56-character Stellar test wallet (G + 55 base32 chars). */ +export const VALID_TEST_WALLET = + 'G234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQ'; diff --git a/backend/src/__tests__/walletUtils.test.ts b/backend/src/__tests__/walletUtils.test.ts index 1162d2ef..13466fc2 100644 --- a/backend/src/__tests__/walletUtils.test.ts +++ b/backend/src/__tests__/walletUtils.test.ts @@ -24,14 +24,12 @@ describe('Wallet Utilities', () => { describe('isValidStellarAddress', () => { it('should return true for valid uppercase address', () => { - const address = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUV'; - // Total length 56 - const validAddress = 'GDH36P55N6WQX3QJ6F7E3S2C7Z6Z5Q5X4Y3W2V1U0T9S8R7Q6P5O4N3M'; + const validAddress = 'G234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQ'; expect(isValidStellarAddress(validAddress)).toBe(true); }); it('should return true for valid lowercase address', () => { - const validAddress = 'gdh36p55n6wqx3qj6f7e3s2c7z6z5q5x4y3w2v1u0t9s8r7q6p5o4n3m'; + const validAddress = 'g234567abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq'; expect(isValidStellarAddress(validAddress)).toBe(true); }); From 471e98499946e8c9a7631ab87771b5b5ccd3c930 Mon Sep 17 00:00:00 2001 From: yinkscss Date: Sat, 30 May 2026 14:21:30 +0100 Subject: [PATCH 3/5] Use valid Stellar wallet fixtures across remaining backend tests. Aligns validation, referral, and transaction tests with the 56-character base32 address format enforced by request schemas. --- backend/src/__tests__/referral.test.ts | 5 +++-- backend/src/__tests__/setup.ts | 4 ++++ backend/src/__tests__/transactions.test.ts | 15 ++++++++------- backend/src/__tests__/validation.test.ts | 9 +++++---- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/backend/src/__tests__/referral.test.ts b/backend/src/__tests__/referral.test.ts index 53752ed0..fe5aeec0 100644 --- a/backend/src/__tests__/referral.test.ts +++ b/backend/src/__tests__/referral.test.ts @@ -2,13 +2,14 @@ import request from 'supertest'; import app from '../index'; import { getPrismaClient, disconnectPrismaClient } from '../prismaClient'; import { referralService } from '../referralService'; +import { VALID_TEST_WALLET, SECOND_TEST_WALLET } from './setup'; // Use the centralized Prisma Client instance const getPrisma = () => getPrismaClient(); describe('Referral System Integration', () => { - const referrerWallet = 'G_REFERRER_WALLET_ADDRESS'; - const referredWallet = 'G_REFERRED_WALLET_ADDRESS'; + const referrerWallet = VALID_TEST_WALLET; + const referredWallet = SECOND_TEST_WALLET; const referralCode = 'WELCOME2026'; beforeAll(async () => { diff --git a/backend/src/__tests__/setup.ts b/backend/src/__tests__/setup.ts index a59dc821..f0069b0e 100644 --- a/backend/src/__tests__/setup.ts +++ b/backend/src/__tests__/setup.ts @@ -49,3 +49,7 @@ registerApiKey('super-admin-test-key', { role: 'super-admin' }); /** Valid 56-character Stellar test wallet (G + 55 base32 chars). */ export const VALID_TEST_WALLET = 'G234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQ'; + +/** Second valid wallet for multi-wallet test scenarios. */ +export const SECOND_TEST_WALLET = + 'G345678ABCDEFGHIJKLMNOPQRSTUVWXYZ345678ABCDEFGHIJKLMNOPQR'; diff --git a/backend/src/__tests__/transactions.test.ts b/backend/src/__tests__/transactions.test.ts index ddb26a5e..7499a8e9 100644 --- a/backend/src/__tests__/transactions.test.ts +++ b/backend/src/__tests__/transactions.test.ts @@ -1,8 +1,9 @@ import request from 'supertest'; import app from '../index'; import { registerApiKey } from '../middleware/apiKeyAuth'; +import { VALID_TEST_WALLET, SECOND_TEST_WALLET } from './setup'; -const DEFAULT_WALLET = 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567'; +const DEFAULT_WALLET = VALID_TEST_WALLET; async function issueAccessToken(walletAddress: string): Promise { const response = await request(app).post('/api/v1/auth/login').send({ walletAddress }); @@ -117,7 +118,7 @@ describe('GET /api/v1/transactions', () => { }); it('rejects export when authenticated user tries to export another wallet', async () => { - const token = await issueAccessToken('GUSERWALLET123'); + const token = await issueAccessToken(SECOND_TEST_WALLET); const response = await request(app) .get(`/api/v1/vault/transactions/export?format=json&walletAddress=${encodeURIComponent(DEFAULT_WALLET)}`) .set('Authorization', `Bearer ${token}`); @@ -151,7 +152,7 @@ describe('GET /api/v1/transactions', () => { describe('GET /api/v1/vault/transactions/export', () => { it('exports the authenticated user transaction history as JSON', async () => { const login = await request(app).post('/api/v1/auth/login').send({ - walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567', + walletAddress: VALID_TEST_WALLET, }); const response = await request(app) @@ -164,17 +165,17 @@ describe('GET /api/v1/vault/transactions/export', () => { expect(Array.isArray(response.body.data)).toBe(true); expect(response.body.data.length).toBeGreaterThan(0); response.body.data.forEach((transaction: { walletAddress: string }) => { - expect(transaction.walletAddress).toBe('GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567'); + expect(transaction.walletAddress).toBe(VALID_TEST_WALLET); }); }); it('rejects exporting a different wallet for a bearer-authenticated user', async () => { const login = await request(app).post('/api/v1/auth/login').send({ - walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567', + walletAddress: VALID_TEST_WALLET, }); const response = await request(app) - .get('/api/v1/vault/transactions/export?format=json&walletAddress=GDIFFERENTWALLET123456789') + .get(`/api/v1/vault/transactions/export?format=json&walletAddress=${encodeURIComponent(SECOND_TEST_WALLET)}`) .set('Authorization', `Bearer ${login.body.accessToken}`); expect(response.status).toBe(403); @@ -191,7 +192,7 @@ describe('GET /api/v1/vault/transactions/export', () => { const response = await request(app) .get( - `/api/v1/vault/transactions/export?format=csv&walletAddress=GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567&startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}` + `/api/v1/vault/transactions/export?format=csv&walletAddress=${encodeURIComponent(VALID_TEST_WALLET)}&startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}` ) .set('Authorization', `ApiKey ${apiKey}`); diff --git a/backend/src/__tests__/validation.test.ts b/backend/src/__tests__/validation.test.ts index b75a3a4a..86330dc7 100644 --- a/backend/src/__tests__/validation.test.ts +++ b/backend/src/__tests__/validation.test.ts @@ -1,5 +1,6 @@ import request from 'supertest'; import app from '../index'; +import { VALID_TEST_WALLET } from './setup'; describe('Schema validation middleware', () => { // ─── POST /api/v1/auth/login ────────────────────────────────────────────── @@ -23,14 +24,14 @@ describe('Schema validation middleware', () => { it('rejects unknown fields with 400', async () => { const res = await request(app) .post('/api/v1/auth/login') - .send({ walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWX', extra: 'field' }); + .send({ walletAddress: VALID_TEST_WALLET, extra: 'field' }); expect(res.status).toBe(400); }); it('accepts a valid Stellar wallet address', async () => { const res = await request(app) .post('/api/v1/auth/login') - .send({ walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWX' }); + .send({ walletAddress: VALID_TEST_WALLET }); expect(res.status).toBe(200); }); }); @@ -65,7 +66,7 @@ describe('Schema validation middleware', () => { const validPayload = { amount: 100, asset: 'USDC', - walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWX', + walletAddress: VALID_TEST_WALLET, }; it('rejects missing required fields with 400', async () => { @@ -125,7 +126,7 @@ describe('Schema validation middleware', () => { const validPayload = { amount: 50, asset: 'USDC', - walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWX', + walletAddress: VALID_TEST_WALLET, }; it('rejects missing walletAddress with 400', async () => { From 0bf94b4529e5391acae67fd91ce5ef3abc1a6051 Mon Sep 17 00:00:00 2001 From: yinkscss Date: Sat, 30 May 2026 14:26:00 +0100 Subject: [PATCH 4/5] Stop tracking local Prisma dev.db and ignore SQLite files. Prevents CI migrate deploy from failing when a stale dev.db already contains tables pending migration. --- .gitignore | 2 ++ backend/prisma/dev.db | Bin 114688 -> 0 bytes 2 files changed, 2 insertions(+) delete mode 100644 backend/prisma/dev.db diff --git a/.gitignore b/.gitignore index 092645a0..09278cc3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ # Node /frontend/node_modules /backend/node_modules +/backend/prisma/*.db +/backend/prisma/*.db-journal # Environment Variables - NEVER COMMIT THESE .env diff --git a/backend/prisma/dev.db b/backend/prisma/dev.db deleted file mode 100644 index fc0520814cea19b4d9dfb6a19108a1b9b76a1aa4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 114688 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 I9(Ysn|9SL`t^fc4 From af97071d68d0ed5f7f50241571f168a4e458ef9d Mon Sep 17 00:00:00 2001 From: yinkscss Date: Sat, 30 May 2026 14:32:43 +0100 Subject: [PATCH 5/5] Stabilize backend tests with relaxed rate limits and valid referral wallets. Sets high rate-limit ceilings before app bootstrap and fixes referral integration fixtures to use valid Stellar addresses. --- backend/src/__tests__/preload.js | 6 ++++++ backend/src/__tests__/referral.test.ts | 6 +++--- backend/src/__tests__/setup.ts | 4 ++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/backend/src/__tests__/preload.js b/backend/src/__tests__/preload.js index ff5eaaa7..4c14f7d4 100644 --- a/backend/src/__tests__/preload.js +++ b/backend/src/__tests__/preload.js @@ -1,2 +1,8 @@ process.env.NODE_ENV = 'test'; process.env.OTEL_ENABLED = 'false'; +process.env.ALLOWLIST_ENABLED = process.env.ALLOWLIST_ENABLED || 'false'; +process.env.RATE_LIMIT_AUTH_MAX = process.env.RATE_LIMIT_AUTH_MAX || '100000'; +process.env.RATE_LIMIT_WRITES_MAX = process.env.RATE_LIMIT_WRITES_MAX || '100000'; +process.env.RATE_LIMIT_READS_MAX = process.env.RATE_LIMIT_READS_MAX || '100000'; +process.env.RATE_LIMIT_ADMIN_MAX = process.env.RATE_LIMIT_ADMIN_MAX || '100000'; +process.env.API_RATE_LIMIT_MAX_REQUESTS = process.env.API_RATE_LIMIT_MAX_REQUESTS || '100000'; diff --git a/backend/src/__tests__/referral.test.ts b/backend/src/__tests__/referral.test.ts index fe5aeec0..3d2ec5d6 100644 --- a/backend/src/__tests__/referral.test.ts +++ b/backend/src/__tests__/referral.test.ts @@ -2,7 +2,7 @@ import request from 'supertest'; import app from '../index'; import { getPrismaClient, disconnectPrismaClient } from '../prismaClient'; import { referralService } from '../referralService'; -import { VALID_TEST_WALLET, SECOND_TEST_WALLET } from './setup'; +import { VALID_TEST_WALLET, SECOND_TEST_WALLET, THIRD_TEST_WALLET } from './setup'; // Use the centralized Prisma Client instance const getPrisma = () => getPrismaClient(); @@ -92,14 +92,14 @@ describe('Referral System Integration', () => { }); it('should return 404 for wallet with no referral activity', async () => { - const response = await request(app).get('/api/v1/referrals/G_UNKNOWN_WALLET'); + const response = await request(app).get(`/api/v1/referrals/${THIRD_TEST_WALLET}`); expect(response.status).toBe(404); }); }); describe('Reward Calculation Precision', () => { it('should handle small yield values with precision', async () => { - const smallReferredWallet = 'G_SMALL_REFERRED'; + const smallReferredWallet = THIRD_TEST_WALLET; // Record small deposit await request(app) diff --git a/backend/src/__tests__/setup.ts b/backend/src/__tests__/setup.ts index f0069b0e..26925ccf 100644 --- a/backend/src/__tests__/setup.ts +++ b/backend/src/__tests__/setup.ts @@ -53,3 +53,7 @@ export const VALID_TEST_WALLET = /** Second valid wallet for multi-wallet test scenarios. */ export const SECOND_TEST_WALLET = 'G345678ABCDEFGHIJKLMNOPQRSTUVWXYZ345678ABCDEFGHIJKLMNOPQR'; + +/** Third valid wallet for multi-wallet test scenarios. */ +export const THIRD_TEST_WALLET = + 'G456789ABCDEFGHIJKLMNOPQRSTUVWXYZ456789ABCDEFGHIJKLMNOPQR';