From c1cd5daa9827215c4749c4a276d3f7addab7e140 Mon Sep 17 00:00:00 2001 From: austinesamuel Date: Fri, 27 Mar 2026 16:58:03 +0100 Subject: [PATCH 1/4] Protect the API from oversized payload abuse by centralizing body parser limits --- backend/package-lock.json | 4 - backend/src/common/http/body-parser.config.ts | 55 ++++++++++++++ backend/src/main.ts | 4 +- backend/test/body-parser-limits.e2e-spec.ts | 75 +++++++++++++++++++ 4 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 backend/src/common/http/body-parser.config.ts create mode 100644 backend/test/body-parser-limits.e2e-spec.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index d1e14968..54e9d4f3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -64,10 +64,6 @@ "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0" - }, - "overrides": { - "minimatch": ">=9.0.6", - "multer": ">=2.1.1" } }, "node_modules/@angular-devkit/core": { diff --git a/backend/src/common/http/body-parser.config.ts b/backend/src/common/http/body-parser.config.ts new file mode 100644 index 00000000..af0c8845 --- /dev/null +++ b/backend/src/common/http/body-parser.config.ts @@ -0,0 +1,55 @@ +import { INestApplication } from '@nestjs/common'; +import { json, Request, Response, NextFunction, urlencoded } from 'express'; + +type RequestWithRawBody = Request & { rawBody?: Buffer }; + +const DEFAULT_JSON_LIMIT = '100kb'; +const DEFAULT_URLENCODED_LIMIT = '100kb'; +const DEFAULT_WEBHOOK_JSON_LIMIT = '1mb'; + +function captureRawBody( + req: Request, + _res: Response, + buffer: Buffer, +): void { + if (buffer.length > 0) { + (req as RequestWithRawBody).rawBody = Buffer.from(buffer); + } +} + +export function configureBodyParserLimits(app: INestApplication): void { + const jsonLimit = process.env.BODY_JSON_LIMIT ?? DEFAULT_JSON_LIMIT; + const urlencodedLimit = + process.env.BODY_URLENCODED_LIMIT ?? DEFAULT_URLENCODED_LIMIT; + const webhookJsonLimit = + process.env.BODY_WEBHOOK_JSON_LIMIT ?? DEFAULT_WEBHOOK_JSON_LIMIT; + + // Keep webhook payload support flexible without widening limits globally. + app.use('/v1/webhook', json({ limit: webhookJsonLimit, verify: captureRawBody })); + app.use(json({ limit: jsonLimit, verify: captureRawBody })); + app.use( + urlencoded({ + extended: true, + limit: urlencodedLimit, + verify: captureRawBody, + }), + ); + + app.use((err: unknown, _req: Request, res: Response, next: NextFunction) => { + const bodyParserError = err as { type?: string; status?: number } | undefined; + + if ( + bodyParserError?.type === 'entity.too.large' || + bodyParserError?.status === 413 + ) { + res.status(413).json({ + statusCode: 413, + error: 'Payload Too Large', + message: `Payload too large. Maximum request body size is ${jsonLimit}.`, + }); + return; + } + + next(err); + }); +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 0ec10b27..d156660a 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -5,12 +5,13 @@ import { StartupProbeService } from './health/startup-probe.service'; import { getDataSourceToken } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { validateRequiredSecrets } from './common/secrets-validation'; +import { configureBodyParserLimits } from './common/http/body-parser.config'; async function bootstrap() { // Fail fast if any required secret is absent — before the app is created. validateRequiredSecrets(); - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { bodyParser: false }); // Enable versioning (URI versioning like /v1/...) app.enableVersioning({ @@ -20,6 +21,7 @@ async function bootstrap() { // Global validation pipe app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + configureBodyParserLimits(app); const probeService = app.get(StartupProbeService); diff --git a/backend/test/body-parser-limits.e2e-spec.ts b/backend/test/body-parser-limits.e2e-spec.ts new file mode 100644 index 00000000..86ccfa35 --- /dev/null +++ b/backend/test/body-parser-limits.e2e-spec.ts @@ -0,0 +1,75 @@ +import { Body, Controller, INestApplication, Module, Post } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { configureBodyParserLimits } from '../src/common/http/body-parser.config'; + +@Controller() +class PayloadController { + @Post('payload') + handlePayload(@Body() body: { data: string }) { + return { received: body.data.length }; + } + + @Post('v1/webhook') + handleWebhook(@Body() body: { data: string }) { + return { received: body.data.length }; + } +} + +@Module({ + controllers: [PayloadController], +}) +class PayloadTestModule {} + +describe('Body parser limits (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [PayloadTestModule], + }).compile(); + + app = moduleFixture.createNestApplication({ bodyParser: false }); + configureBodyParserLimits(app); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('accepts normal payloads', async () => { + const response = await request(app.getHttpServer()) + .post('/payload') + .send({ data: 'safe-body' }) + .expect(201); + + expect(response.body).toEqual({ received: 9 }); + }); + + it('rejects oversized payloads with explicit 413 error', async () => { + const oversized = 'a'.repeat(120 * 1024); + + const response = await request(app.getHttpServer()) + .post('/payload') + .send({ data: oversized }) + .expect(413); + + expect(response.body).toEqual({ + statusCode: 413, + error: 'Payload Too Large', + message: 'Payload too large. Maximum request body size is 100kb.', + }); + }); + + it('allows larger payloads on webhook override endpoint', async () => { + const webhookSizedPayload = 'a'.repeat(200 * 1024); + + const response = await request(app.getHttpServer()) + .post('/v1/webhook') + .send({ data: webhookSizedPayload }) + .expect(201); + + expect(response.body).toEqual({ received: 200 * 1024 }); + }); +}); From 82c93a45e8d0f7084c62b2c086cdd8d040bcce58 Mon Sep 17 00:00:00 2001 From: austinesamuel Date: Fri, 27 Mar 2026 17:09:14 +0100 Subject: [PATCH 2/4] feat(backend): add idempotent chain event replay CLI with dry-run summaries --- backend/README.md | 10 ++ backend/docs/CHAIN_EVENT_REPLAY.md | 58 ++++++++ backend/package.json | 3 +- backend/src/cli/replay-chain-events.ts | 88 ++++++++++++ .../replay/chain-event-replay.runner.spec.ts | 104 ++++++++++++++ .../replay/chain-event-replay.runner.ts | 127 ++++++++++++++++++ .../replay/horizon-chain-event.source.ts | 33 +++++ .../replay/postgres-chain-event.store.ts | 93 +++++++++++++ 8 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 backend/docs/CHAIN_EVENT_REPLAY.md create mode 100644 backend/src/cli/replay-chain-events.ts create mode 100644 backend/src/indexer/replay/chain-event-replay.runner.spec.ts create mode 100644 backend/src/indexer/replay/chain-event-replay.runner.ts create mode 100644 backend/src/indexer/replay/horizon-chain-event.source.ts create mode 100644 backend/src/indexer/replay/postgres-chain-event.store.ts diff --git a/backend/README.md b/backend/README.md index 1fdfe763..9f0b137b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -44,6 +44,16 @@ $ npm run start:dev $ npm run start:prod ``` +## Chain replay CLI + +Recover missed historical chain events into DB: + +```bash +$ npm run replay:chain-events -- --start-cursor --end-cursor --dry-run +``` + +Detailed runbook: `docs/CHAIN_EVENT_REPLAY.md` + ## Run tests ```bash diff --git a/backend/docs/CHAIN_EVENT_REPLAY.md b/backend/docs/CHAIN_EVENT_REPLAY.md new file mode 100644 index 00000000..941263d8 --- /dev/null +++ b/backend/docs/CHAIN_EVENT_REPLAY.md @@ -0,0 +1,58 @@ +# Chain Event Replay CLI + +## Purpose + +Replay historical Stellar chain events into PostgreSQL to recover indexer gaps safely. + +## Command + +```bash +npm run replay:chain-events -- --start-cursor [--end-cursor ] [--dry-run] [--limit 200] +``` + +## Required Environment + +- `DATABASE_URL`: PostgreSQL connection string +- `HORIZON_URL` (optional): Horizon base URL (defaults to `https://horizon-testnet.stellar.org`) + +## Parameters + +- `--start-cursor` (required): Inclusive starting cursor for replay. +- `--end-cursor` (optional): Inclusive ending cursor; replay stops once this cursor is reached. +- `--dry-run` (optional): Reads and evaluates events but does not write to DB. +- `--limit` (optional): Page size per request (1..200, default `200`). + +## Idempotency and Safety + +- Replayed records are stored in table `chain_event_replay`. +- Each record is keyed by `paging_token` and deduplicated with `ON CONFLICT DO NOTHING`. +- Re-running the same range is safe; duplicates are skipped and reported in summary output. + +## Dry-Run Workflow (Recommended) + +1. Validate scope and expected volume: + +```bash +npm run replay:chain-events -- --start-cursor 123 --end-cursor 999 --dry-run +``` + +2. Execute replay: + +```bash +npm run replay:chain-events -- --start-cursor 123 --end-cursor 999 +``` + +## Output + +The CLI prints JSON summary: + +- `startCursor` +- `endCursor` +- `finalCursor` +- `pages` +- `fetched` +- `inserted` +- `duplicates` +- `dryRun` + +Use `finalCursor` as a checkpoint for subsequent replay windows. diff --git a/backend/package.json b/backend/package.json index 20014dba..60dcad7d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,8 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "replay:chain-events": "ts-node src/cli/replay-chain-events.ts" }, "dependencies": { "@nestjs/common": "^11.1.17", diff --git a/backend/src/cli/replay-chain-events.ts b/backend/src/cli/replay-chain-events.ts new file mode 100644 index 00000000..024dfa5a --- /dev/null +++ b/backend/src/cli/replay-chain-events.ts @@ -0,0 +1,88 @@ +import { ChainEventReplayRunner, ReplayOptions } from '../indexer/replay/chain-event-replay.runner'; +import { HorizonChainEventSource } from '../indexer/replay/horizon-chain-event.source'; +import { PostgresChainEventStore } from '../indexer/replay/postgres-chain-event.store'; + +interface CliArgs { + startCursor: string; + endCursor?: string; + dryRun: boolean; + limit: number; +} + +function readArg(args: string[], key: string): string | undefined { + const index = args.findIndex((arg) => arg === key); + if (index === -1) return undefined; + return args[index + 1]; +} + +function parseArgs(argv: string[]): CliArgs { + const startCursor = readArg(argv, '--start-cursor'); + if (!startCursor) { + throw new Error('Missing required argument: --start-cursor '); + } + + const endCursor = readArg(argv, '--end-cursor'); + const limitRaw = readArg(argv, '--limit'); + const dryRun = argv.includes('--dry-run'); + + const limit = limitRaw ? Number.parseInt(limitRaw, 10) : 200; + if (!Number.isFinite(limit) || limit < 1 || limit > 200) { + throw new Error('Invalid --limit value. Expected an integer between 1 and 200.'); + } + + return { + startCursor, + endCursor, + dryRun, + limit, + }; +} + +function printUsage(): void { + // Keep this concise for operators running incidents. + console.log( + [ + 'Usage:', + ' npm run replay:chain-events -- --start-cursor [--end-cursor ] [--dry-run] [--limit 200]', + '', + 'Environment:', + ' DATABASE_URL PostgreSQL DSN for replay storage (required)', + ' HORIZON_URL Horizon base URL (default: https://horizon-testnet.stellar.org)', + ].join('\n'), + ); +} + +async function main(): Promise { + try { + const args = parseArgs(process.argv.slice(2)); + const databaseUrl = process.env.DATABASE_URL?.trim(); + if (!databaseUrl) { + throw new Error('DATABASE_URL is required.'); + } + + const horizonUrl = + process.env.HORIZON_URL?.trim() || 'https://horizon-testnet.stellar.org'; + + const source = new HorizonChainEventSource(horizonUrl); + const store = new PostgresChainEventStore(databaseUrl); + const runner = new ChainEventReplayRunner(source, store); + + const replayOptions: ReplayOptions = { + startCursor: args.startCursor, + endCursor: args.endCursor, + dryRun: args.dryRun, + limit: args.limit, + }; + + const summary = await runner.replay(replayOptions); + + console.log(JSON.stringify({ ok: true, summary }, null, 2)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(JSON.stringify({ ok: false, error: message }, null, 2)); + printUsage(); + process.exitCode = 1; + } +} + +void main(); diff --git a/backend/src/indexer/replay/chain-event-replay.runner.spec.ts b/backend/src/indexer/replay/chain-event-replay.runner.spec.ts new file mode 100644 index 00000000..d03deb9c --- /dev/null +++ b/backend/src/indexer/replay/chain-event-replay.runner.spec.ts @@ -0,0 +1,104 @@ +import { + ChainEventRecord, + ChainEventReplayRunner, + ChainEventSource, + ChainEventStore, +} from './chain-event-replay.runner'; + +class InMemorySource implements ChainEventSource { + constructor(private readonly pages: ChainEventRecord[][]) {} + private pageIndex = 0; + + async fetchPage(cursor: string, _limit: number) { + const events = this.pages[this.pageIndex] ?? []; + this.pageIndex += 1; + const nextCursor = + events.length > 0 ? String(events[events.length - 1].paging_token) : cursor; + return { events, nextCursor }; + } +} + +class InMemoryStore implements ChainEventStore { + private readonly seen = new Set(); + + async init() {} + + async persistBatch(events: ChainEventRecord[], dryRun: boolean) { + let inserted = 0; + for (const event of events) { + if (!this.seen.has(event.paging_token)) { + if (!dryRun) { + this.seen.add(event.paging_token); + } + inserted += 1; + } + } + return { inserted, duplicates: events.length - inserted }; + } + + async close() {} +} + +function event(cursor: string): ChainEventRecord { + return { + id: `event-${cursor}`, + paging_token: cursor, + type: 'payment', + }; +} + +describe('ChainEventReplayRunner', () => { + it('replays until source is exhausted', async () => { + const source = new InMemorySource([[event('10'), event('11')], [event('12')], []]); + const store = new InMemoryStore(); + const runner = new ChainEventReplayRunner(source, store); + + const summary = await runner.replay({ + startCursor: '9', + limit: 200, + dryRun: false, + }); + + expect(summary.fetched).toBe(3); + expect(summary.inserted).toBe(3); + expect(summary.duplicates).toBe(0); + expect(summary.finalCursor).toBe('12'); + }); + + it('stops at end cursor and only processes <= end cursor', async () => { + const source = new InMemorySource([[event('10'), event('11')], [event('12')], []]); + const store = new InMemoryStore(); + const runner = new ChainEventReplayRunner(source, store); + + const summary = await runner.replay({ + startCursor: '9', + endCursor: '11', + limit: 200, + dryRun: false, + }); + + expect(summary.fetched).toBe(2); + expect(summary.inserted).toBe(2); + expect(summary.finalCursor).toBe('11'); + }); + + it('reports dry-run inserts without mutating state', async () => { + const source = new InMemorySource([[event('10'), event('11')], []]); + const store = new InMemoryStore(); + const runner = new ChainEventReplayRunner(source, store); + + const dryRunSummary = await runner.replay({ + startCursor: '9', + limit: 200, + dryRun: true, + }); + const replaySummary = await runner.replay({ + startCursor: '9', + limit: 200, + dryRun: false, + }); + + expect(dryRunSummary.inserted).toBe(2); + expect(replaySummary.inserted).toBe(2); + }); +}); diff --git a/backend/src/indexer/replay/chain-event-replay.runner.ts b/backend/src/indexer/replay/chain-event-replay.runner.ts new file mode 100644 index 00000000..a5e5b795 --- /dev/null +++ b/backend/src/indexer/replay/chain-event-replay.runner.ts @@ -0,0 +1,127 @@ +export interface ChainEventRecord { + id: string; + paging_token: string; + type: string; + transaction_hash?: string; + source_account?: string; + created_at?: string; + [key: string]: unknown; +} + +export interface FetchPageResult { + events: ChainEventRecord[]; + nextCursor: string; +} + +export interface ChainEventSource { + fetchPage(cursor: string, limit: number): Promise; +} + +export interface PersistResult { + inserted: number; + duplicates: number; +} + +export interface ChainEventStore { + init(): Promise; + persistBatch(events: ChainEventRecord[], dryRun: boolean): Promise; + close(): Promise; +} + +export interface ReplayOptions { + startCursor: string; + endCursor?: string; + limit: number; + dryRun: boolean; +} + +export interface ReplaySummary { + startCursor: string; + endCursor?: string; + finalCursor: string; + pages: number; + fetched: number; + inserted: number; + duplicates: number; + dryRun: boolean; +} + +function compareCursor(a: string, b: string): number { + try { + const left = BigInt(a); + const right = BigInt(b); + if (left === right) return 0; + return left < right ? -1 : 1; + } catch { + if (a === b) return 0; + return a < b ? -1 : 1; + } +} + +export class ChainEventReplayRunner { + constructor( + private readonly source: ChainEventSource, + private readonly store: ChainEventStore, + ) {} + + async replay(options: ReplayOptions): Promise { + await this.store.init(); + + let cursor = options.startCursor; + let pages = 0; + let fetched = 0; + let inserted = 0; + let duplicates = 0; + let done = false; + + while (!done) { + const page = await this.source.fetchPage(cursor, options.limit); + pages += 1; + + if (page.events.length === 0) { + done = true; + break; + } + + const selectedEvents = + options.endCursor === undefined + ? page.events + : page.events.filter( + (event) => compareCursor(event.paging_token, options.endCursor as string) <= 0, + ); + + fetched += selectedEvents.length; + + if (selectedEvents.length > 0) { + const result = await this.store.persistBatch(selectedEvents, options.dryRun); + inserted += result.inserted; + duplicates += result.duplicates; + } + + const pageLastCursor = page.nextCursor; + cursor = pageLastCursor; + + if ( + options.endCursor !== undefined && + compareCursor(pageLastCursor, options.endCursor) >= 0 + ) { + done = true; + } else if (page.events.length < options.limit) { + done = true; + } + } + + await this.store.close(); + + return { + startCursor: options.startCursor, + endCursor: options.endCursor, + finalCursor: cursor, + pages, + fetched, + inserted, + duplicates, + dryRun: options.dryRun, + }; + } +} diff --git a/backend/src/indexer/replay/horizon-chain-event.source.ts b/backend/src/indexer/replay/horizon-chain-event.source.ts new file mode 100644 index 00000000..a375ece3 --- /dev/null +++ b/backend/src/indexer/replay/horizon-chain-event.source.ts @@ -0,0 +1,33 @@ +import { ChainEventRecord, ChainEventSource, FetchPageResult } from './chain-event-replay.runner'; + +interface HorizonOperationResponse { + _embedded?: { + records?: ChainEventRecord[]; + }; +} + +export class HorizonChainEventSource implements ChainEventSource { + constructor(private readonly horizonBaseUrl: string) {} + + async fetchPage(cursor: string, limit: number): Promise { + const url = new URL('/operations', this.horizonBaseUrl); + url.searchParams.set('order', 'asc'); + url.searchParams.set('cursor', cursor); + url.searchParams.set('limit', String(limit)); + + const response = await fetch(url.toString(), { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`Horizon request failed (${response.status} ${response.statusText})`); + } + + const payload = (await response.json()) as HorizonOperationResponse; + const events = payload._embedded?.records ?? []; + const nextCursor = + events.length > 0 ? String(events[events.length - 1].paging_token) : cursor; + + return { events, nextCursor }; + } +} diff --git a/backend/src/indexer/replay/postgres-chain-event.store.ts b/backend/src/indexer/replay/postgres-chain-event.store.ts new file mode 100644 index 00000000..d1746fc2 --- /dev/null +++ b/backend/src/indexer/replay/postgres-chain-event.store.ts @@ -0,0 +1,93 @@ +import { Pool } from 'pg'; +import { + ChainEventRecord, + ChainEventStore, + PersistResult, +} from './chain-event-replay.runner'; + +export class PostgresChainEventStore implements ChainEventStore { + private readonly pool: Pool; + + constructor(databaseUrl: string) { + this.pool = new Pool({ connectionString: databaseUrl }); + } + + async init(): Promise { + await this.pool.query(` + CREATE TABLE IF NOT EXISTS chain_event_replay ( + paging_token TEXT PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE, + event_type TEXT NOT NULL, + tx_hash TEXT, + source_account TEXT, + ledger_closed_at TIMESTAMPTZ, + payload JSONB NOT NULL, + replayed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + } + + async persistBatch( + events: ChainEventRecord[], + dryRun: boolean, + ): Promise { + if (events.length === 0) { + return { inserted: 0, duplicates: 0 }; + } + + if (dryRun) { + const duplicates = await this.countExisting(events.map((event) => event.paging_token)); + return { inserted: events.length - duplicates, duplicates }; + } + + let inserted = 0; + for (const event of events) { + const result = await this.pool.query( + ` + INSERT INTO chain_event_replay ( + paging_token, + event_id, + event_type, + tx_hash, + source_account, + ledger_closed_at, + payload + ) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) + ON CONFLICT (paging_token) DO NOTHING + `, + [ + String(event.paging_token), + String(event.id), + String(event.type), + event.transaction_hash ? String(event.transaction_hash) : null, + event.source_account ? String(event.source_account) : null, + event.created_at ? new Date(String(event.created_at)) : null, + JSON.stringify(event), + ], + ); + + inserted += result.rowCount ?? 0; + } + + return { inserted, duplicates: events.length - inserted }; + } + + async close(): Promise { + await this.pool.end(); + } + + private async countExisting(pagingTokens: string[]): Promise { + const uniqueTokens = Array.from(new Set(pagingTokens)); + const result = await this.pool.query<{ total: string }>( + ` + SELECT COUNT(*)::text AS total + FROM chain_event_replay + WHERE paging_token = ANY($1::text[]) + `, + [uniqueTokens], + ); + const total = result.rows[0]?.total ?? '0'; + return Number.parseInt(total, 10); + } +} From 37b9462b034455a0838629a7a6d7993a6069663b Mon Sep 17 00:00:00 2001 From: austinesamuel Date: Fri, 27 Mar 2026 18:03:17 +0100 Subject: [PATCH 3/4] added payout history endpoints --- .../src/creators/creators.controller.spec.ts | 31 ++++++ backend/src/creators/creators.controller.ts | 17 ++++ backend/src/creators/creators.module.ts | 3 +- backend/src/creators/creators.service.spec.ts | 30 ++++++ backend/src/creators/creators.service.ts | 19 ++++ .../dto/creator-payout-history-query.dto.ts | 41 ++++++++ backend/src/creators/dto/index.ts | 1 + .../subscriptions.service.spec.ts | 70 ++++++++++++++ .../subscriptions/subscriptions.service.ts | 95 +++++++++++++++++++ 9 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 backend/src/creators/dto/creator-payout-history-query.dto.ts diff --git a/backend/src/creators/creators.controller.spec.ts b/backend/src/creators/creators.controller.spec.ts index b551d712..43b24046 100644 --- a/backend/src/creators/creators.controller.spec.ts +++ b/backend/src/creators/creators.controller.spec.ts @@ -14,6 +14,7 @@ describe('CreatorsController', () => { createPlan: jest.fn(), findAllPlans: jest.fn(), findCreatorPlans: jest.fn(), + getPayoutHistory: jest.fn(), }; beforeEach(async () => { @@ -374,4 +375,34 @@ describe('CreatorsController', () => { }); }); }); + + describe('getPayoutHistory', () => { + it('calls service with creator address and query', () => { + const mockResponse = { + data: [ + { + checkoutId: 'chk_1', + creatorAddress: 'GCREATOR', + fanAddress: 'GFAN', + amount: '10', + assetCode: 'XLM', + txHash: 'tx-1', + payoutAt: new Date().toISOString(), + }, + ], + nextCursor: null, + hasMore: false, + }; + mockCreatorsService.getPayoutHistory.mockReturnValue(mockResponse); + + const query = { from: '2026-03-01T00:00:00.000Z', limit: 20 }; + const result = controller.getPayoutHistory('GCREATOR', query); + + expect(mockCreatorsService.getPayoutHistory).toHaveBeenCalledWith( + 'GCREATOR', + query, + ); + expect(result).toEqual(mockResponse); + }); + }); }); diff --git a/backend/src/creators/creators.controller.ts b/backend/src/creators/creators.controller.ts index 14694910..fe109d2c 100644 --- a/backend/src/creators/creators.controller.ts +++ b/backend/src/creators/creators.controller.ts @@ -5,6 +5,8 @@ import { PaginationDto, PaginatedResponseDto } from '../common/dto'; import { PlanDto } from './dto/plan.dto'; import { SearchCreatorsDto } from './dto/search-creators.dto'; import { PublicCreatorDto } from './dto/public-creator.dto'; +import { CreatorPayoutHistoryQueryDto } from './dto'; +import { CreatorPayoutHistoryResult } from '../subscriptions/subscriptions.service'; @ApiTags('creators') @Controller({ path: 'creators', version: '1' }) @@ -66,4 +68,19 @@ export class CreatorsController { ): PaginatedResponseDto { return this.creatorsService.findCreatorPlans(address, pagination); } + + @Get(':address/payout-history') + @ApiOperation({ + summary: 'List creator payout history with date filters and cursor pagination', + }) + @ApiResponse({ + status: 200, + description: 'Creator payout history page', + }) + getPayoutHistory( + @Param('address') address: string, + @Query() query: CreatorPayoutHistoryQueryDto, + ): CreatorPayoutHistoryResult { + return this.creatorsService.getPayoutHistory(address, query); + } } diff --git a/backend/src/creators/creators.module.ts b/backend/src/creators/creators.module.ts index 8e675961..2bc9fa27 100644 --- a/backend/src/creators/creators.module.ts +++ b/backend/src/creators/creators.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { CreatorsController } from './creators.controller'; import { CreatorsService } from './creators.service'; import { User } from '../users/entities/user.entity'; +import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; @Module({ - imports: [TypeOrmModule.forFeature([User])], + imports: [TypeOrmModule.forFeature([User]), SubscriptionsModule], controllers: [CreatorsController], providers: [CreatorsService], exports: [CreatorsService], diff --git a/backend/src/creators/creators.service.spec.ts b/backend/src/creators/creators.service.spec.ts index 524163d5..e0ab5284 100644 --- a/backend/src/creators/creators.service.spec.ts +++ b/backend/src/creators/creators.service.spec.ts @@ -5,10 +5,12 @@ import { CreatorsService } from './creators.service'; import { User, UserRole } from '../users/entities/user.entity'; import { EventBus } from '../events/event-bus'; import { SearchCreatorsDto } from './dto/search-creators.dto'; +import { SubscriptionsService } from '../subscriptions/subscriptions.service'; describe('CreatorsService', () => { let service: CreatorsService; let mockQueryBuilder: Partial>; + let subscriptionsService: { getCreatorPayoutHistory: jest.Mock }; beforeEach(async () => { // Create mock query builder @@ -25,6 +27,8 @@ describe('CreatorsService', () => { getRawAndEntities: jest.fn(), }; + subscriptionsService = { getCreatorPayoutHistory: jest.fn() }; + const module: TestingModule = await Test.createTestingModule({ providers: [ CreatorsService, @@ -39,6 +43,10 @@ describe('CreatorsService', () => { provide: EventBus, useValue: { publish: jest.fn() }, }, + { + provide: SubscriptionsService, + useValue: subscriptionsService, + }, ], }).compile(); @@ -445,6 +453,28 @@ describe('CreatorsService', () => { }); }); }); + + describe('getPayoutHistory', () => { + it('delegates payout history query to subscriptions service', () => { + const response = { data: [], nextCursor: null, hasMore: false }; + subscriptionsService.getCreatorPayoutHistory.mockReturnValue(response); + + const result = service.getPayoutHistory('GCREATOR', { + from: '2026-01-01T00:00:00.000Z', + to: '2026-01-31T23:59:59.999Z', + limit: 20, + }); + + expect(subscriptionsService.getCreatorPayoutHistory).toHaveBeenCalledWith({ + creatorAddress: 'GCREATOR', + from: '2026-01-01T00:00:00.000Z', + to: '2026-01-31T23:59:59.999Z', + cursor: undefined, + limit: 20, + }); + expect(result).toEqual(response); + }); + }); }); // Helper function to create mock users diff --git a/backend/src/creators/creators.service.ts b/backend/src/creators/creators.service.ts index 5cfdf586..dfd8dee1 100644 --- a/backend/src/creators/creators.service.ts +++ b/backend/src/creators/creators.service.ts @@ -5,6 +5,11 @@ import { PaginationDto, PaginatedResponseDto } from '../common/dto'; import { SearchCreatorsDto } from './dto/search-creators.dto'; import { PublicCreatorDto } from './dto/public-creator.dto'; import { User } from '../users/entities/user.entity'; +import { + CreatorPayoutHistoryResult, + SubscriptionsService, +} from '../subscriptions/subscriptions.service'; +import { CreatorPayoutHistoryQueryDto } from './dto'; export interface Plan { id: number; @@ -22,6 +27,7 @@ export class CreatorsService { constructor( @InjectRepository(User) private readonly userRepository: Repository, + private readonly subscriptionsService: SubscriptionsService, ) {} createPlan(creator: string, asset: string, amount: string, intervalDays: number): Plan { @@ -83,4 +89,17 @@ export class CreatorsService { return new PaginatedResponseDto(data, total, page, limit); } + + getPayoutHistory( + creatorAddress: string, + query: CreatorPayoutHistoryQueryDto, + ): CreatorPayoutHistoryResult { + return this.subscriptionsService.getCreatorPayoutHistory({ + creatorAddress, + from: query.from, + to: query.to, + cursor: query.cursor, + limit: query.limit, + }); + } } diff --git a/backend/src/creators/dto/creator-payout-history-query.dto.ts b/backend/src/creators/dto/creator-payout-history-query.dto.ts new file mode 100644 index 00000000..82654ad0 --- /dev/null +++ b/backend/src/creators/dto/creator-payout-history-query.dto.ts @@ -0,0 +1,41 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsISO8601, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class CreatorPayoutHistoryQueryDto { + @ApiPropertyOptional({ + description: 'Inclusive start of payout window (ISO-8601)', + example: '2026-03-01T00:00:00.000Z', + }) + @IsOptional() + @IsISO8601() + from?: string; + + @ApiPropertyOptional({ + description: 'Inclusive end of payout window (ISO-8601)', + example: '2026-03-31T23:59:59.999Z', + }) + @IsOptional() + @IsISO8601() + to?: string; + + @ApiPropertyOptional({ + description: 'Opaque cursor returned by previous response', + }) + @IsOptional() + @IsString() + cursor?: string; + + @ApiPropertyOptional({ + description: 'Number of payouts per page', + default: 20, + minimum: 1, + maximum: 100, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; +} diff --git a/backend/src/creators/dto/index.ts b/backend/src/creators/dto/index.ts index ef3d7f0b..5dac791a 100644 --- a/backend/src/creators/dto/index.ts +++ b/backend/src/creators/dto/index.ts @@ -1,3 +1,4 @@ export * from './plan.dto'; export * from './search-creators.dto'; export * from './public-creator.dto'; +export * from './creator-payout-history-query.dto'; diff --git a/backend/src/subscriptions/subscriptions.service.spec.ts b/backend/src/subscriptions/subscriptions.service.spec.ts index 91408007..31441497 100644 --- a/backend/src/subscriptions/subscriptions.service.spec.ts +++ b/backend/src/subscriptions/subscriptions.service.spec.ts @@ -259,4 +259,74 @@ describe('SubscriptionsService', () => { expect(r.indexed?.planId).toBe(1); }); }); + + describe('getCreatorPayoutHistory', () => { + const creatorAddress = 'GAAAAAAAAAAAAAAA'; + const fanAddress = `G${'C'.repeat(55)}`; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-03-27T12:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns completed payouts including txHash references', () => { + const first = service.createCheckout(fanAddress, creatorAddress, 1); + service.confirmSubscription(first.id, 'tx-first'); + jest.advanceTimersByTime(1000); + const second = service.createCheckout(fanAddress, creatorAddress, 1); + service.confirmSubscription(second.id, 'tx-second'); + + const result = service.getCreatorPayoutHistory({ creatorAddress, limit: 10 }); + + expect(result.data).toHaveLength(2); + expect(result.data[0].txHash).toBe('tx-second'); + expect(result.data[1].txHash).toBe('tx-first'); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeNull(); + }); + + it('applies date-range filters', () => { + const older = service.createCheckout(fanAddress, creatorAddress, 1); + service.confirmSubscription(older.id, 'tx-old'); + jest.setSystemTime(new Date('2026-03-28T12:00:00.000Z')); + const newer = service.createCheckout(fanAddress, creatorAddress, 1); + service.confirmSubscription(newer.id, 'tx-new'); + + const result = service.getCreatorPayoutHistory({ + creatorAddress, + from: '2026-03-28T00:00:00.000Z', + to: '2026-03-29T00:00:00.000Z', + limit: 10, + }); + + expect(result.data).toHaveLength(1); + expect(result.data[0].txHash).toBe('tx-new'); + }); + + it('supports cursor pagination', () => { + const first = service.createCheckout(fanAddress, creatorAddress, 1); + service.confirmSubscription(first.id, 'tx-first'); + jest.advanceTimersByTime(1000); + const second = service.createCheckout(fanAddress, creatorAddress, 1); + service.confirmSubscription(second.id, 'tx-second'); + + const pageOne = service.getCreatorPayoutHistory({ creatorAddress, limit: 1 }); + expect(pageOne.data).toHaveLength(1); + expect(pageOne.hasMore).toBe(true); + expect(pageOne.nextCursor).toBeTruthy(); + + const pageTwo = service.getCreatorPayoutHistory({ + creatorAddress, + limit: 1, + cursor: pageOne.nextCursor as string, + }); + + expect(pageTwo.data).toHaveLength(1); + expect(pageTwo.data[0].txHash).toBe('tx-first'); + }); + }); }); diff --git a/backend/src/subscriptions/subscriptions.service.ts b/backend/src/subscriptions/subscriptions.service.ts index c58c458b..09c46807 100644 --- a/backend/src/subscriptions/subscriptions.service.ts +++ b/backend/src/subscriptions/subscriptions.service.ts @@ -61,6 +61,23 @@ interface Checkout { updatedAt: Date; } +export interface CreatorPayoutHistoryItem { + checkoutId: string; + creatorAddress: string; + fanAddress: string; + amount: string; + assetCode: string; + assetIssuer?: string; + txHash: string; + payoutAt: string; +} + +export interface CreatorPayoutHistoryResult { + data: CreatorPayoutHistoryItem[]; + nextCursor: string | null; + hasMore: boolean; +} + interface Plan { id: number; creator: string; @@ -453,6 +470,66 @@ export class SubscriptionsService { }; } + getCreatorPayoutHistory(params: { + creatorAddress: string; + from?: string; + to?: string; + cursor?: string; + limit?: number; + }): CreatorPayoutHistoryResult { + const limit = params.limit ?? 20; + const fromDate = params.from ? new Date(params.from) : undefined; + const toDate = params.to ? new Date(params.to) : undefined; + + const filtered = Array.from(this.checkouts.values()) + .filter((checkout) => checkout.creatorAddress === params.creatorAddress) + .filter((checkout) => checkout.status === CheckoutStatus.COMPLETED) + .filter((checkout) => Boolean(checkout.txHash)) + .filter((checkout) => { + if (!fromDate) return true; + return checkout.updatedAt >= fromDate; + }) + .filter((checkout) => { + if (!toDate) return true; + return checkout.updatedAt <= toDate; + }) + .sort((a, b) => { + const byDate = b.updatedAt.getTime() - a.updatedAt.getTime(); + if (byDate !== 0) return byDate; + return b.id.localeCompare(a.id); + }); + + const cursorData = params.cursor ? this.decodeCursor(params.cursor) : null; + const paged = cursorData + ? filtered.filter((checkout) => { + const ts = checkout.updatedAt.getTime(); + if (ts < cursorData.timestamp) return true; + if (ts > cursorData.timestamp) return false; + return checkout.id < cursorData.checkoutId; + }) + : filtered; + + const selected = paged.slice(0, limit); + const hasMore = paged.length > limit; + const last = selected[selected.length - 1]; + const nextCursor = hasMore && last ? this.encodeCursor(last.updatedAt, last.id) : null; + + return { + data: selected.map((checkout) => ({ + checkoutId: checkout.id, + creatorAddress: checkout.creatorAddress, + fanAddress: checkout.fanAddress, + amount: checkout.amount, + assetCode: checkout.assetCode, + assetIssuer: checkout.assetIssuer, + txHash: checkout.txHash as string, + payoutAt: checkout.updatedAt.toISOString(), + })), + nextCursor, + hasMore, + }; + } + confirmSubscription(checkoutId: string, txHash?: string) { const checkout = this.getCheckout(checkoutId); @@ -565,4 +642,22 @@ export class SubscriptionsService { this.logger.error(`Failed to emit renewal failure event: ${message}`); }); } + + private encodeCursor(payoutDate: Date, checkoutId: string): string { + return Buffer.from(`${payoutDate.getTime()}:${checkoutId}`).toString('base64url'); + } + + private decodeCursor(cursor: string): { timestamp: number; checkoutId: string } { + try { + const decoded = Buffer.from(cursor, 'base64url').toString('utf8'); + const [timestampRaw, checkoutId] = decoded.split(':'); + const timestamp = Number.parseInt(timestampRaw, 10); + if (!Number.isFinite(timestamp) || !checkoutId) { + throw new Error('Invalid cursor'); + } + return { timestamp, checkoutId }; + } catch { + throw new BadRequestException('Invalid payout history cursor'); + } + } } From 86a9afb525849f247cbd750fdf003a7b6402f722 Mon Sep 17 00:00:00 2001 From: austinesamuel Date: Fri, 27 Mar 2026 19:46:05 +0100 Subject: [PATCH 4/4] added audit log for hight impact --- backend/.env.example | 10 +++ backend/src/app.module.ts | 17 +++- backend/src/audit/audit-admin.guard.spec.ts | 38 +++++++++ backend/src/audit/audit-admin.guard.ts | 29 +++++++ .../audit/audit-metadata.sanitizer.spec.ts | 20 +++++ backend/src/audit/audit-metadata.sanitizer.ts | 41 +++++++++ backend/src/audit/audit.admin.controller.ts | 22 +++++ backend/src/audit/audit.module.ts | 15 ++++ backend/src/audit/audit.service.spec.ts | 67 +++++++++++++++ backend/src/audit/audit.service.ts | 83 +++++++++++++++++++ backend/src/audit/auditable-action.ts | 16 ++++ backend/src/audit/dto/audit-query.dto.ts | 40 +++++++++ .../src/audit/entities/audit-log.entity.ts | 36 ++++++++ backend/src/auth/auth.controller.ts | 6 +- backend/src/auth/auth.module.ts | 3 +- backend/src/auth/auth.service.ts | 16 +++- backend/src/creators/creators.controller.ts | 2 +- .../subscriptions/subscriptions.controller.ts | 3 +- .../src/subscriptions/subscriptions.module.ts | 3 +- .../subscriptions.service.spec.ts | 28 ++++++- .../subscriptions/subscriptions.service.ts | 30 ++++++- backend/src/webhook/webhook.controller.ts | 20 ++++- backend/src/webhook/webhook.module.ts | 2 + backend/test/wallet.e2e-spec.ts | 22 ++++- 24 files changed, 556 insertions(+), 13 deletions(-) create mode 100644 backend/src/audit/audit-admin.guard.spec.ts create mode 100644 backend/src/audit/audit-admin.guard.ts create mode 100644 backend/src/audit/audit-metadata.sanitizer.spec.ts create mode 100644 backend/src/audit/audit-metadata.sanitizer.ts create mode 100644 backend/src/audit/audit.admin.controller.ts create mode 100644 backend/src/audit/audit.module.ts create mode 100644 backend/src/audit/audit.service.spec.ts create mode 100644 backend/src/audit/audit.service.ts create mode 100644 backend/src/audit/auditable-action.ts create mode 100644 backend/src/audit/dto/audit-query.dto.ts create mode 100644 backend/src/audit/entities/audit-log.entity.ts diff --git a/backend/.env.example b/backend/.env.example index d643c701..927c8ed3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,6 +22,16 @@ DB_USER=myfans DB_PASSWORD= # REQUIRED — use a strong random password DB_NAME=myfans +# Set to `true` locally so TypeORM can create/update tables (e.g. audit_logs). +# Production should use migrations; leave unset or false in prod. +TYPEORM_SYNC=false + +# ----------------------------------------------------------------------------- +# Audit log (optional; required for GET /v1/admin/audit) +# ----------------------------------------------------------------------------- +# Shared secret for admin audit queries. Sent as header: x-admin-audit-key +AUDIT_ADMIN_API_KEY= + # ----------------------------------------------------------------------------- # Authentication (REQUIRED) # Generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e67ff48b..cd042f08 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,8 +1,8 @@ import { Module, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; import { ThrottlerModule } from '@nestjs/throttler'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { ThrottlerGuard } from './auth/throttler.guard'; import { APP_GUARD } from '@nestjs/core'; -import { ThrottlerModule } from '@nestjs/throttler'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { HealthModule } from './health/health.module'; @@ -12,14 +12,29 @@ import { LoggingMiddleware } from './common/middleware/logging.middleware'; import { CreatorsModule } from './creators/creators.module'; import { SubscriptionsModule } from './subscriptions/subscriptions.module'; import { AuthModule } from './auth/auth.module'; +import { AuditModule } from './audit/audit.module'; +import { WebhookModule } from './webhook/webhook.module'; +import { ExampleController } from './common/examples/example.controller'; @Module({ imports: [ ThrottlerModule.forRoot([{ name: 'auth', ttl: 60000, limit: 5 }]), + TypeOrmModule.forRoot({ + type: 'postgres', + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT ?? '5432', 10), + username: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + autoLoadEntities: true, + synchronize: process.env.TYPEORM_SYNC === 'true', + }), LoggingModule, + AuditModule, AuthModule, CreatorsModule, SubscriptionsModule, + WebhookModule, HealthModule, ], controllers: [AppController, ExampleController], diff --git a/backend/src/audit/audit-admin.guard.spec.ts b/backend/src/audit/audit-admin.guard.spec.ts new file mode 100644 index 00000000..40614ecd --- /dev/null +++ b/backend/src/audit/audit-admin.guard.spec.ts @@ -0,0 +1,38 @@ +import { ExecutionContext, ServiceUnavailableException, UnauthorizedException } from '@nestjs/common'; +import { AuditAdminGuard } from './audit-admin.guard'; + +describe('AuditAdminGuard', () => { + const originalEnv = process.env.AUDIT_ADMIN_API_KEY; + + afterEach(() => { + process.env.AUDIT_ADMIN_API_KEY = originalEnv; + }); + + function ctx(headers: Record): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => ({ headers }), + }), + } as ExecutionContext; + } + + it('allows when key matches', () => { + process.env.AUDIT_ADMIN_API_KEY = 'secret-key'; + const guard = new AuditAdminGuard(); + expect(guard.canActivate(ctx({ 'x-admin-audit-key': 'secret-key' }))).toBe(true); + }); + + it('rejects when key mismatches', () => { + process.env.AUDIT_ADMIN_API_KEY = 'secret-key'; + const guard = new AuditAdminGuard(); + expect(() => + guard.canActivate(ctx({ 'x-admin-audit-key': 'wrong' })), + ).toThrow(UnauthorizedException); + }); + + it('503 when env not set', () => { + delete process.env.AUDIT_ADMIN_API_KEY; + const guard = new AuditAdminGuard(); + expect(() => guard.canActivate(ctx({}))).toThrow(ServiceUnavailableException); + }); +}); diff --git a/backend/src/audit/audit-admin.guard.ts b/backend/src/audit/audit-admin.guard.ts new file mode 100644 index 00000000..b50644d6 --- /dev/null +++ b/backend/src/audit/audit-admin.guard.ts @@ -0,0 +1,29 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common'; +import { Request } from 'express'; + +/** + * Protects audit query endpoints. Set `AUDIT_ADMIN_API_KEY` in the environment. + */ +@Injectable() +export class AuditAdminGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const expected = process.env.AUDIT_ADMIN_API_KEY?.trim(); + if (!expected) { + throw new ServiceUnavailableException( + 'Audit query is disabled (AUDIT_ADMIN_API_KEY not configured)', + ); + } + const req = context.switchToHttp().getRequest(); + const provided = req.headers['x-admin-audit-key']; + if (typeof provided !== 'string' || provided !== expected) { + throw new UnauthorizedException('Invalid audit admin API key'); + } + return true; + } +} diff --git a/backend/src/audit/audit-metadata.sanitizer.spec.ts b/backend/src/audit/audit-metadata.sanitizer.spec.ts new file mode 100644 index 00000000..5fc1e601 --- /dev/null +++ b/backend/src/audit/audit-metadata.sanitizer.spec.ts @@ -0,0 +1,20 @@ +import { sanitizeAuditMetadata } from './audit-metadata.sanitizer'; + +describe('sanitizeAuditMetadata', () => { + it('redacts sensitive keys', () => { + const out = sanitizeAuditMetadata({ + user: 'ok', + password: 'super-secret', + nested: { apiKey: 'x', safe: 1 }, + }); + expect(out?.password).toBe('[REDACTED]'); + expect(out?.nested).toEqual({ apiKey: '[REDACTED]', safe: 1 }); + }); + + it('redacts JWT-shaped strings', () => { + const jwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U'; + const out = sanitizeAuditMetadata({ token: jwt }); + expect(out?.token).toBe('[REDACTED_JWT]'); + }); +}); diff --git a/backend/src/audit/audit-metadata.sanitizer.ts b/backend/src/audit/audit-metadata.sanitizer.ts new file mode 100644 index 00000000..a1fdbd2d --- /dev/null +++ b/backend/src/audit/audit-metadata.sanitizer.ts @@ -0,0 +1,41 @@ +const SENSITIVE_KEY = /password|secret|token|authorization|cookie|apikey|api[_-]?key|bearer|refresh|access|credential|newsecret|new_secret/i; + +function looksLikeJwt(value: string): boolean { + const parts = value.split('.'); + return parts.length === 3 && parts.every((p) => p.length > 10); +} + +/** + * Strips or redacts values that must never appear in audit metadata (secrets, raw tokens). + */ +export function sanitizeAuditMetadata( + input: Record | undefined | null, +): Record | null { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return null; + } + const out: Record = {}; + for (const [k, v] of Object.entries(input)) { + if (SENSITIVE_KEY.test(k)) { + out[k] = '[REDACTED]'; + continue; + } + if (typeof v === 'string') { + if (looksLikeJwt(v)) { + out[k] = '[REDACTED_JWT]'; + } else { + out[k] = v; + } + continue; + } + if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)) { + const nested = sanitizeAuditMetadata(v as Record); + if (nested && Object.keys(nested).length > 0) { + out[k] = nested; + } + } else { + out[k] = v as unknown; + } + } + return Object.keys(out).length ? out : null; +} diff --git a/backend/src/audit/audit.admin.controller.ts b/backend/src/audit/audit.admin.controller.ts new file mode 100644 index 00000000..721f29ea --- /dev/null +++ b/backend/src/audit/audit.admin.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiHeader, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { AuditAdminGuard } from './audit-admin.guard'; +import { AuditService } from './audit.service'; +import { AuditQueryDto } from './dto/audit-query.dto'; +import { PaginatedResponseDto } from '../common/dto/paginated-response.dto'; +import { AuditLog } from './entities/audit-log.entity'; + +@ApiTags('admin') +@ApiHeader({ name: 'x-admin-audit-key', required: true }) +@Controller({ path: 'admin/audit', version: '1' }) +@UseGuards(AuditAdminGuard) +export class AuditAdminController { + constructor(private readonly auditService: AuditService) {} + + @Get() + @ApiOperation({ summary: 'Query audit log (requires AUDIT_ADMIN_API_KEY)' }) + @ApiResponse({ status: 200, description: 'Paginated audit entries' }) + query(@Query() query: AuditQueryDto): Promise> { + return this.auditService.query(query); + } +} diff --git a/backend/src/audit/audit.module.ts b/backend/src/audit/audit.module.ts new file mode 100644 index 00000000..70648b58 --- /dev/null +++ b/backend/src/audit/audit.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditAdminController } from './audit.admin.controller'; +import { AuditAdminGuard } from './audit-admin.guard'; +import { AuditService } from './audit.service'; +import { AuditLog } from './entities/audit-log.entity'; +import { LoggingModule } from '../common/logging.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([AuditLog]), LoggingModule], + controllers: [AuditAdminController], + providers: [AuditService, AuditAdminGuard], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/backend/src/audit/audit.service.spec.ts b/backend/src/audit/audit.service.spec.ts new file mode 100644 index 00000000..85bb7812 --- /dev/null +++ b/backend/src/audit/audit.service.spec.ts @@ -0,0 +1,67 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { RequestContextService } from '../common/services/request-context.service'; +import { AuditableAction } from './auditable-action'; +import { AuditService } from './audit.service'; +import { AuditLog } from './entities/audit-log.entity'; + +describe('AuditService', () => { + let service: AuditService; + let repo: jest.Mocked, 'create' | 'save' | 'findAndCount'>>; + + beforeEach(async () => { + repo = { + create: jest.fn((x) => x as AuditLog), + save: jest.fn().mockResolvedValue({}), + findAndCount: jest.fn().mockResolvedValue([[], 0]), + } as unknown as jest.Mocked, 'create' | 'save' | 'findAndCount'>>; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuditService, + { + provide: getRepositoryToken(AuditLog), + useValue: repo, + }, + { + provide: RequestContextService, + useValue: { getCorrelationId: jest.fn().mockReturnValue('corr-1') }, + }, + ], + }).compile(); + + service = module.get(AuditService); + }); + + it('persists sanitized metadata', async () => { + await service.record({ + action: AuditableAction.AUTH_SESSION_CREATED, + actorType: 'user', + actorId: 'GADDR', + metadata: { password: 'secret', ok: true }, + }); + const saved = repo.save.mock.calls[0][0] as AuditLog; + expect(saved.metadata?.password).toBe('[REDACTED]'); + expect(saved.metadata?.ok).toBe(true); + }); + + it('does not throw when persistence fails', async () => { + repo.save.mockRejectedValueOnce(new Error('db down')); + await expect( + service.record({ + action: AuditableAction.AUTH_SESSION_CREATED, + actorType: 'user', + actorId: 'GADDR', + }), + ).resolves.toBeUndefined(); + }); + + it('query returns paginated rows', async () => { + const row = { id: '1' } as AuditLog; + repo.findAndCount.mockResolvedValue([[row], 1]); + const result = await service.query({ page: 1, limit: 10 }); + expect(result.data).toEqual([row]); + expect(result.total).toBe(1); + }); +}); diff --git a/backend/src/audit/audit.service.ts b/backend/src/audit/audit.service.ts new file mode 100644 index 00000000..c674f153 --- /dev/null +++ b/backend/src/audit/audit.service.ts @@ -0,0 +1,83 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Between, FindOptionsWhere, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import { RequestContextService } from '../common/services/request-context.service'; +import { AuditableActionName, AuditActorType } from './auditable-action'; +import { sanitizeAuditMetadata } from './audit-metadata.sanitizer'; +import { AuditQueryDto } from './dto/audit-query.dto'; +import { AuditLog } from './entities/audit-log.entity'; +import { PaginatedResponseDto } from '../common/dto/paginated-response.dto'; + +export interface AuditRecordInput { + action: AuditableActionName; + actorType: AuditActorType; + actorId?: string | null; + metadata?: Record; + ip?: string | null; +} + +@Injectable() +export class AuditService { + private readonly logger = new Logger(AuditService.name); + + constructor( + @InjectRepository(AuditLog) + private readonly repo: Repository, + private readonly requestContext: RequestContextService, + ) {} + + /** + * Persists an audit row. Never throws to callers — failures are logged only. + */ + async record(input: AuditRecordInput): Promise { + try { + const metadata = sanitizeAuditMetadata(input.metadata ?? undefined); + const correlationId = this.requestContext.getCorrelationId(); + const row = this.repo.create({ + action: input.action, + actorType: input.actorType, + actorId: input.actorId ?? null, + metadata, + correlationId, + ip: input.ip ?? null, + }); + await this.repo.save(row); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn(`Audit record failed (${input.action}): ${message}`); + } + } + + async query(dto: AuditQueryDto): Promise> { + const page = dto.page ?? 1; + const limit = dto.limit ?? 50; + const where: FindOptionsWhere = {}; + + if (dto.action) { + where.action = dto.action; + } + if (dto.actorId) { + where.actorId = dto.actorId; + } + + const fromDate = dto.from ? new Date(dto.from) : undefined; + const toDate = dto.to ? new Date(dto.to) : undefined; + + if (fromDate && toDate) { + where.createdAt = Between(fromDate, toDate); + } else if (fromDate) { + where.createdAt = MoreThanOrEqual(fromDate); + } else if (toDate) { + where.createdAt = LessThanOrEqual(toDate); + } + + const [rows, total] = await this.repo.findAndCount({ + where, + order: { createdAt: 'DESC', id: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return new PaginatedResponseDto(rows, total, page, limit); + } +} diff --git a/backend/src/audit/auditable-action.ts b/backend/src/audit/auditable-action.ts new file mode 100644 index 00000000..97a628c3 --- /dev/null +++ b/backend/src/audit/auditable-action.ts @@ -0,0 +1,16 @@ +/** + * Stable string identifiers for persisted audit rows. + * Use dotted namespaces so filters and dashboards stay consistent. + */ +export const AuditableAction = { + AUTH_SESSION_CREATED: 'auth.session_created', + WEBHOOK_SECRET_ROTATED: 'webhook.secret_rotated', + WEBHOOK_SECRET_EXPIRED_PREVIOUS: 'webhook.secret_expired_previous', + SUBSCRIPTION_CHECKOUT_CONFIRMED: 'subscription.checkout_confirmed', + SUBSCRIPTION_CHECKOUT_FAILED: 'subscription.checkout_failed', +} as const; + +export type AuditableActionName = + (typeof AuditableAction)[keyof typeof AuditableAction]; + +export type AuditActorType = 'user' | 'system' | 'service' | 'anonymous'; diff --git a/backend/src/audit/dto/audit-query.dto.ts b/backend/src/audit/dto/audit-query.dto.ts new file mode 100644 index 00000000..7d19571d --- /dev/null +++ b/backend/src/audit/dto/audit-query.dto.ts @@ -0,0 +1,40 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsISO8601, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class AuditQueryDto { + @ApiPropertyOptional({ description: 'Page number', default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ description: 'Page size', default: 50, minimum: 1, maximum: 100 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 50; + + @ApiPropertyOptional({ description: 'Filter by auditable action key' }) + @IsOptional() + @IsString() + action?: string; + + @ApiPropertyOptional({ description: 'Filter by actor id (e.g. Stellar address)' }) + @IsOptional() + @IsString() + actorId?: string; + + @ApiPropertyOptional({ description: 'Inclusive start (ISO-8601)' }) + @IsOptional() + @IsISO8601() + from?: string; + + @ApiPropertyOptional({ description: 'Inclusive end (ISO-8601)' }) + @IsOptional() + @IsISO8601() + to?: string; +} diff --git a/backend/src/audit/entities/audit-log.entity.ts b/backend/src/audit/entities/audit-log.entity.ts new file mode 100644 index 00000000..94d5ff4a --- /dev/null +++ b/backend/src/audit/entities/audit-log.entity.ts @@ -0,0 +1,36 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity('audit_logs') +@Index(['action', 'createdAt']) +@Index(['actorId', 'createdAt']) +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 96 }) + action: string; + + @Column({ type: 'varchar', length: 24 }) + actorType: string; + + @Column({ type: 'varchar', length: 128, nullable: true }) + actorId: string | null; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record | null; + + @Column({ type: 'varchar', length: 64, nullable: true }) + correlationId: string | null; + + @Column({ type: 'varchar', length: 45, nullable: true }) + ip: string | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; +} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 703498ca..1e7d071c 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Body, BadRequestException } from '@nestjs/common'; +import { Controller, Post, Body, BadRequestException } from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; import { AuthService } from './auth.service'; @@ -12,7 +12,7 @@ export class AuthController { if (!this.authService.validateStellarAddress(body?.address ?? '')) { throw new BadRequestException('Invalid Stellar address'); } - return this.authService.createSession(address); + return this.authService.createSession(body.address as string); } @Post('register') @@ -21,6 +21,6 @@ export class AuthController { if (!this.authService.validateStellarAddress(body?.address ?? '')) { throw new BadRequestException('Invalid Stellar address'); } - return this.authService.createSession(address); + return this.authService.createSession(body.address as string); } } diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 4536900c..06b6a0de 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { EventsModule } from '../events/events.module'; +import { AuditModule } from '../audit/audit.module'; @Module({ - imports: [EventsModule], + imports: [EventsModule, AuditModule], controllers: [AuthController], providers: [AuthService], exports: [AuthService], diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 62114c92..356ab433 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,10 +1,15 @@ import { Injectable } from '@nestjs/common'; import { EventBus } from '../events/event-bus'; import { UserLoggedInEvent } from '../events/domain-events'; +import { AuditService } from '../audit/audit.service'; +import { AuditableAction } from '../audit/auditable-action'; @Injectable() export class AuthService { - constructor(private readonly eventBus: EventBus) {} + constructor( + private readonly eventBus: EventBus, + private readonly auditService: AuditService, + ) {} validateStellarAddress(address: string): boolean { return address.startsWith('G') && address.length === 56; @@ -20,6 +25,15 @@ export class AuthService { new UserLoggedInEvent(session.userId, stellarAddress), ); + void this.auditService.record({ + action: AuditableAction.AUTH_SESSION_CREATED, + actorType: 'user', + actorId: stellarAddress, + metadata: { + addressPrefix: `${stellarAddress.slice(0, 8)}…`, + }, + }); + return session; } } diff --git a/backend/src/creators/creators.controller.ts b/backend/src/creators/creators.controller.ts index fe109d2c..fd0d88ad 100644 --- a/backend/src/creators/creators.controller.ts +++ b/backend/src/creators/creators.controller.ts @@ -6,7 +6,7 @@ import { PlanDto } from './dto/plan.dto'; import { SearchCreatorsDto } from './dto/search-creators.dto'; import { PublicCreatorDto } from './dto/public-creator.dto'; import { CreatorPayoutHistoryQueryDto } from './dto'; -import { CreatorPayoutHistoryResult } from '../subscriptions/subscriptions.service'; +import type { CreatorPayoutHistoryResult } from '../subscriptions/subscriptions.service'; @ApiTags('creators') @Controller({ path: 'creators', version: '1' }) diff --git a/backend/src/subscriptions/subscriptions.controller.ts b/backend/src/subscriptions/subscriptions.controller.ts index 0a5b883a..a7dcbee9 100644 --- a/backend/src/subscriptions/subscriptions.controller.ts +++ b/backend/src/subscriptions/subscriptions.controller.ts @@ -11,7 +11,8 @@ import { } from '@nestjs/common'; import { SubscriptionsService } from './subscriptions.service'; import { ListSubscriptionsQueryDto } from './dto/list-subscriptions-query.dto'; -import { FanBearerGuard, RequestWithFan } from './guards/fan-bearer.guard'; +import { FanBearerGuard } from './guards/fan-bearer.guard'; +import type { RequestWithFan } from './guards/fan-bearer.guard'; import { SubscriptionStateQueryDto } from './dto/subscription-state-query.dto'; @Controller({ path: 'subscriptions', version: '1' }) diff --git a/backend/src/subscriptions/subscriptions.module.ts b/backend/src/subscriptions/subscriptions.module.ts index 1c0714d7..99b3c97f 100644 --- a/backend/src/subscriptions/subscriptions.module.ts +++ b/backend/src/subscriptions/subscriptions.module.ts @@ -6,9 +6,10 @@ import { EventsModule } from '../events/events.module'; import { LoggingModule } from '../common/logging.module'; import { FanBearerGuard } from './guards/fan-bearer.guard'; import { SubscriptionChainReaderService } from './subscription-chain-reader.service'; +import { AuditModule } from '../audit/audit.module'; @Module({ - imports: [EventsModule, LoggingModule], + imports: [EventsModule, LoggingModule, AuditModule], controllers: [SubscriptionsController], providers: [ SubscriptionsService, diff --git a/backend/src/subscriptions/subscriptions.service.spec.ts b/backend/src/subscriptions/subscriptions.service.spec.ts index 31441497..e8c347ca 100644 --- a/backend/src/subscriptions/subscriptions.service.spec.ts +++ b/backend/src/subscriptions/subscriptions.service.spec.ts @@ -1,3 +1,4 @@ +import { Provider } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { SUBSCRIPTION_EVENT_PUBLISHER, @@ -7,6 +8,8 @@ import { import { SubscriptionsService, SERVER_NETWORK } from './subscriptions.service'; import { EventBus } from '../events/event-bus'; import { SubscriptionChainReaderService } from './subscription-chain-reader.service'; +import { AuditService } from '../audit/audit.service'; +import { AuditableAction } from '../audit/auditable-action'; function makeEventBus(): EventBus { return { publish: jest.fn() } as unknown as EventBus; @@ -19,13 +22,19 @@ function makeChainReader(): SubscriptionChainReaderService { } as unknown as SubscriptionChainReaderService; } +function makeAuditService(): AuditService { + return { record: jest.fn().mockResolvedValue(undefined) } as unknown as AuditService; +} + async function buildService( eventPublisher?: jest.Mocked, + audit?: AuditService, ): Promise { - const providers: object[] = [ + const providers: Provider[] = [ SubscriptionsService, { provide: EventBus, useValue: makeEventBus() }, { provide: SubscriptionChainReaderService, useValue: makeChainReader() }, + { provide: AuditService, useValue: audit ?? makeAuditService() }, ]; if (eventPublisher) { providers.push({ @@ -48,6 +57,23 @@ describe('SubscriptionsService', () => { service = await buildService(eventPublisher); }); + it('records audit when checkout is confirmed', async () => { + const audit = { record: jest.fn().mockResolvedValue(undefined) } as unknown as AuditService; + const svc = await buildService(undefined, audit); + const checkout = svc.createCheckout( + 'GFANADDRESS111111111111111111111111111111111111111111111111', + 'GAAAAAAAAAAAAAAA', + 1, + ); + svc.confirmSubscription(checkout.id, 'tx-abc'); + expect(audit.record).toHaveBeenCalledWith( + expect.objectContaining({ + action: AuditableAction.SUBSCRIPTION_CHECKOUT_CONFIRMED, + metadata: expect.objectContaining({ txHash: 'tx-abc' }), + }), + ); + }); + it('emits renewal_failed event when checkout failure is recorded', async () => { const checkout = service.createCheckout( 'GFANADDRESS111111111111111111111111111111111111111111111111', diff --git a/backend/src/subscriptions/subscriptions.service.ts b/backend/src/subscriptions/subscriptions.service.ts index 09c46807..10f2daad 100644 --- a/backend/src/subscriptions/subscriptions.service.ts +++ b/backend/src/subscriptions/subscriptions.service.ts @@ -22,6 +22,8 @@ import { import { PaginatedResponseDto } from '../common/dto/paginated-response.dto'; import { isStellarAccountAddress } from '../common/utils/stellar-address'; import { SubscriptionChainReaderService } from './subscription-chain-reader.service'; +import { AuditService } from '../audit/audit.service'; +import { AuditableAction } from '../audit/auditable-action'; export enum CheckoutStatus { PENDING = 'pending', @@ -123,10 +125,11 @@ export class SubscriptionsService { constructor( private readonly eventBus: EventBus, + private readonly chainReader: SubscriptionChainReaderService, + private readonly auditService: AuditService, @Optional() @Inject(SUBSCRIPTION_EVENT_PUBLISHER) private readonly subscriptionEventPublisher?: SubscriptionEventPublisher, - private readonly chainReader: SubscriptionChainReaderService, ) { this.creatorProfiles.set('GAAAAAAAAAAAAAAA', { name: 'Creator 1', @@ -546,6 +549,18 @@ export class SubscriptionsService { Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, ); + void this.auditService.record({ + action: AuditableAction.SUBSCRIPTION_CHECKOUT_CONFIRMED, + actorType: 'user', + actorId: checkout.fanAddress, + metadata: { + checkoutId: checkout.id, + creatorAddress: checkout.creatorAddress, + planId: checkout.planId, + txHash: checkout.txHash, + }, + }); + return { success: true, checkoutId: checkout.id, @@ -569,6 +584,19 @@ export class SubscriptionsService { checkout.error = error; checkout.updatedAt = new Date(); this.emitRenewalFailureEvent(checkout, error); + + void this.auditService.record({ + action: AuditableAction.SUBSCRIPTION_CHECKOUT_FAILED, + actorType: 'user', + actorId: checkout.fanAddress, + metadata: { + checkoutId: checkout.id, + creatorAddress: checkout.creatorAddress, + rejected: isRejected, + errorSummary: error.slice(0, 240), + }, + }); + return { success: false, checkoutId: checkout.id, diff --git a/backend/src/webhook/webhook.controller.ts b/backend/src/webhook/webhook.controller.ts index 2304b5bf..adebf312 100644 --- a/backend/src/webhook/webhook.controller.ts +++ b/backend/src/webhook/webhook.controller.ts @@ -7,10 +7,15 @@ import { } from '@nestjs/common'; import { WebhookGuard } from './webhook.guard'; import { WebhookService } from './webhook.service'; +import { AuditService } from '../audit/audit.service'; +import { AuditableAction } from '../audit/auditable-action'; @Controller({ path: 'webhook', version: '1' }) export class WebhookController { - constructor(private readonly webhookService: WebhookService) {} + constructor( + private readonly webhookService: WebhookService, + private readonly auditService: AuditService, + ) {} /** Receive an incoming signed webhook event. */ @Post() @@ -29,6 +34,14 @@ export class WebhookController { @HttpCode(200) rotate(@Body() body: { newSecret: string; cutoffMs?: number }) { this.webhookService.rotate(body.newSecret, body.cutoffMs); + void this.auditService.record({ + action: AuditableAction.WEBHOOK_SECRET_ROTATED, + actorType: 'system', + metadata: { + cutoffMs: body.cutoffMs ?? 24 * 60 * 60 * 1000, + rotated: true, + }, + }); const state = this.webhookService.getState(); return { rotated: true, @@ -42,6 +55,11 @@ export class WebhookController { @HttpCode(200) expirePrevious() { this.webhookService.expirePrevious(); + void this.auditService.record({ + action: AuditableAction.WEBHOOK_SECRET_EXPIRED_PREVIOUS, + actorType: 'system', + metadata: { expiredPrevious: true }, + }); return { expired: true }; } } diff --git a/backend/src/webhook/webhook.module.ts b/backend/src/webhook/webhook.module.ts index 6a24231b..3145a37b 100644 --- a/backend/src/webhook/webhook.module.ts +++ b/backend/src/webhook/webhook.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { WebhookController } from './webhook.controller'; import { WebhookGuard } from './webhook.guard'; import { WebhookService } from './webhook.service'; +import { AuditModule } from '../audit/audit.module'; @Module({ + imports: [AuditModule], controllers: [WebhookController], providers: [WebhookService, WebhookGuard], exports: [WebhookService], diff --git a/backend/test/wallet.e2e-spec.ts b/backend/test/wallet.e2e-spec.ts index c97f42d7..9e4a7554 100644 --- a/backend/test/wallet.e2e-spec.ts +++ b/backend/test/wallet.e2e-spec.ts @@ -1,10 +1,27 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */ +import { Module } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import { App } from 'supertest/types'; import { AuthModule } from './../src/auth/auth.module'; import { SubscriptionsModule } from './../src/subscriptions/subscriptions.module'; +import { AuditService } from './../src/audit/audit.service'; +import { AuditModule } from './../src/audit/audit.module'; + +@Module({ + providers: [ + { + provide: AuditService, + useValue: { + record: jest.fn().mockResolvedValue(undefined), + query: jest.fn(), + }, + }, + ], + exports: [AuditService], +}) +class AuditModuleStub {} describe('Wallet Endpoints (e2e)', () => { let app: INestApplication; @@ -12,7 +29,10 @@ describe('Wallet Endpoints (e2e)', () => { beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AuthModule, SubscriptionsModule], - }).compile(); + }) + .overrideModule(AuditModule) + .useModule(AuditModuleStub) + .compile(); app = moduleFixture.createNestApplication(); await app.init();