diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7941029 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,26 @@ +# Contributing + +## Bug fixes require a regression test + +A bug-fix PR is **not done** until a corresponding regression test exists. + +1. Add or extend a `*.regression.spec.ts` under `src/` (colocated with the feature). +2. Reference the originating issue and/or PR in the `describe`/`it` title or a header comment, e.g. `issue #52 / PR #54`. +3. The test must fail if the fixed bug is re-introduced locally. +4. Keep tests independent: no shared mutable state across files; mock Supabase and Trustless Work (no live network). + +### Convention + +| Rule | Detail | +| --- | --- | +| Naming | `src//*.regression.spec.ts` (e.g. `webhook-status-mapping.regression.spec.ts`) | +| Discovery | Jest `testRegex` already matches `*.spec.ts`, so regression specs run in `pnpm test` and CI | +| Run only regressions | `pnpm exec jest regression --runInBand` | + +See [docs/integration-tests.md](docs/integration-tests.md#regression-test-suite-issue-69) for the suite index (test → issue/PR). + +## Before opening a PR + +- Keep formatting and lint green (`pnpm run format:check`, `pnpm run lint:check`). +- Run `pnpm test` (or at least the specs you touched) and ensure CI stays green. +- Prefer atomic commits with Conventional Commit prefixes (`fix:`, `test:`, `docs:`, …). diff --git a/README.md b/README.md index 953eea2..72cb75d 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ The server listens on port **3001** by default with a global `v1` prefix. | `pnpm run start` | Start without watch | | `pnpm run build` | Compile to `dist/` | | `pnpm run start:prod` | Run the compiled build (`node dist/main`) | +| `pnpm test` | Run Jest unit + integration + regression specs | +| `pnpm exec jest regression --runInBand` | Run only `*.regression.spec.ts` | There is also a `smoke-test-backend.ps1` PowerShell script for a quick end-to-end check. @@ -192,5 +194,7 @@ left off, instead of losing in-flight work. ## Docs +- [`CONTRIBUTING.md`](CONTRIBUTING.md) — contribution guide; **bug fixes require a regression test**. +- [`docs/integration-tests.md`](docs/integration-tests.md) — integration fixtures, KYC/KYB suite, and [regression suite index](docs/integration-tests.md#regression-test-suite-issue-69). - [`docs/SCOPE.md`](docs/SCOPE.md) — closed scope decisions. - [`docs/EMAIL_NOTIFICATIONS_PLAN.md`](docs/EMAIL_NOTIFICATIONS_PLAN.md) — event-driven email notifications plan (epic + tickets). diff --git a/docs/integration-tests.md b/docs/integration-tests.md index 9bb4bcc..7b62aef 100644 --- a/docs/integration-tests.md +++ b/docs/integration-tests.md @@ -70,6 +70,41 @@ This suite should stay green before merging compliance-related changes. Adding a `IdentityProvider` implementation only requires a new `MockIdentityProvider({ name, ... })` binding in the existing cases — no rewrite of the HTTP assertions. +## Regression Test Suite (issue #69) + +Dedicated regression specs guard previously fixed production bugs so they cannot silently +reappear. Naming: `*.regression.spec.ts`, colocated under `src//`. Each `describe`/`it` +(or a header comment) must cite the originating issue and/or PR. + +**Policy:** a bug-fix PR is not done until a matching regression test exists. See +[CONTRIBUTING.md](../CONTRIBUTING.md). + +### Convention + +| Rule | Detail | +| --- | --- | +| File name | `src//.regression.spec.ts` | +| Traceability | Issue/PR in title or header comment | +| Independence | No shared mutable state; mock Supabase / Trustless Work | +| CI | Included automatically by Jest `testRegex: .*\.spec\.ts$` | + +Run only regression specs: + +```bash +pnpm exec jest regression --runInBand +``` + +### Index (test → issue/PR) + +| Regression file | Guards | Issue / PR | +| --- | --- | --- | +| `src/webhooks/webhook-status-mapping.regression.spec.ts` | `escrow.released` → `completed` (not stuck `funded`) | #52 / PR #54 | +| `src/disputes/dispute-percentages.regression.spec.ts` | Dispute resolve percentages must sum to 100 | #12 / PR #49 | +| `src/wallets/stellar-address.regression.spec.ts` | Invalid Stellar address rejected | #27 | +| `src/agreements/status-transitions.regression.spec.ts` | Illegal status transitions blocked | #59 / #67 · PR #110 / #76 | +| `src/agreements/agreement-activity.regression.spec.ts` | Dispute/status events land in `agreement_activity` with states | #58 / #61 · PR #100 / #104 | +| `src/integration/api-edge-cases.regression.spec.ts` | Invalid JWT, not-found IDs, unauthorized `by-wallet` | #15 / #51 · PR #57 | + ## Running Locally Install dependencies: @@ -90,6 +125,12 @@ Run only the migrated flow integration suite: pnpm run test:integration ``` +Run only regression specs: + +```bash +pnpm exec jest regression --runInBand +``` + ## CI `.github/workflows/ci.yml` installs with `pnpm install --frozen-lockfile`, checks formatting and @@ -98,3 +139,5 @@ linting, then runs: ```bash pnpm exec jest --runInBand ``` + +Regression specs are included in that Jest run (no separate CI job). diff --git a/src/agreements/agreement-activity.regression.spec.ts b/src/agreements/agreement-activity.regression.spec.ts new file mode 100644 index 0000000..af5fe78 --- /dev/null +++ b/src/agreements/agreement-activity.regression.spec.ts @@ -0,0 +1,248 @@ +/** + * Regression: issue #58 / #61 · PR #100 / #104 + * Bug: dispute open/resolve and status changes did not land in agreement_activity + * with previous_state / new_state columns populated. + */ +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { AgreementsService } from './agreements.service'; +import { AgreementActivityService } from './agreement-activity.service'; +import { DisputesService } from '../disputes/disputes.service'; + +type Row = Record; + +function buildDb(seed: { + agreements: Row[]; + auth_users?: Row[]; + agreement_participants?: Row[]; + disputes?: Row[]; + dispute_resolutions?: Row[]; + agreement_activity?: Row[]; +}) { + const tables: Record = { + agreements: seed.agreements.map((r) => ({ ...r })), + auth_users: (seed.auth_users ?? []).map((r) => ({ ...r })), + agreement_participants: (seed.agreement_participants ?? []).map((r) => ({ ...r })), + disputes: (seed.disputes ?? []).map((r) => ({ ...r })), + dispute_resolutions: (seed.dispute_resolutions ?? []).map((r) => ({ ...r })), + agreement_activity: (seed.agreement_activity ?? []).map((r) => ({ ...r })), + }; + + function chain(table: string) { + const rows = tables[table] ?? []; + const filters: Array<(r: Row) => boolean> = []; + let mode: 'select' | 'insert' | 'update' = 'select'; + let payload: Row | Row[] | null = null; + let wantSingle = false; + let wantMaybe = false; + + const api: Record = {}; + const self = () => api; + api.select = () => self(); + api.eq = (col: string, val: unknown) => { + filters.push((r) => r[col] === val); + return self(); + }; + api.in = (col: string, vals: unknown[]) => { + filters.push((r) => vals.includes(r[col])); + return self(); + }; + api.limit = () => self(); + api.order = () => self(); + api.insert = (data: Row | Row[]) => { + mode = 'insert'; + payload = data; + return self(); + }; + api.update = (data: Row) => { + mode = 'update'; + payload = data; + return self(); + }; + api.single = () => { + wantSingle = true; + return finalize(); + }; + api.maybeSingle = () => { + wantMaybe = true; + return finalize(); + }; + + const finalize = () => { + if (mode === 'insert') { + const items = Array.isArray(payload) ? payload : [payload as Row]; + const created = items.map((item, i) => ({ + id: (item.id as string) || `${table}-${tables[table].length + i + 1}`, + created_at: new Date().toISOString(), + ...item, + })); + tables[table].push(...created); + return Promise.resolve({ + data: created.length === 1 ? created[0] : created, + error: null, + }); + } + let matched = rows.filter((r) => filters.every((f) => f(r))); + if (mode === 'update') { + matched = matched.map((r) => Object.assign(r, payload as Row)); + } + if (wantSingle || wantMaybe) { + const data = matched[0] ?? null; + if (wantSingle && !data) { + return Promise.resolve({ data: null, error: { message: 'not found' } }); + } + return Promise.resolve({ data, error: null }); + } + return Promise.resolve({ data: matched, error: null }); + }; + + (api as { then?: unknown }).then = (resolve: (v: unknown) => unknown) => + finalize().then(resolve); + return api; + } + + return { tables, client: { from: (table: string) => chain(table) } }; +} + +const USER = 'user-1'; +const WALLET = 'GWALLET-PAYER'; +const RESOLVER = 'GWALLET-RESOLVER'; +const AGREEMENT_ID = 'agr-activity-1'; + +describe('regression: agreement activity logging (issue #58 / #61 · PR #100 / #104)', () => { + it('openDispute writes dispute_opened + status change with previous/new state', async () => { + const db = buildDb({ + agreements: [ + { + id: AGREEMENT_ID, + status: 'active', + title: 'Escrow job', + amount: '100', + asset: 'USDC', + created_by: WALLET, + milestones: [], + }, + ], + auth_users: [ + { id: USER, wallet_public_key: WALLET }, + { id: 'user-resolver', wallet_public_key: RESOLVER }, + ], + agreement_participants: [ + { agreement_id: AGREEMENT_ID, wallet_address: WALLET, role: 'payer' }, + { agreement_id: AGREEMENT_ID, wallet_address: 'GWALLET-PAYEE', role: 'payee' }, + ], + disputes: [], + dispute_resolutions: [], + agreement_activity: [], + }); + + const supabase = { getClient: () => db.client } as never; + const emitter = new EventEmitter2(); + const activity = new AgreementActivityService(supabase); + const logSpy = jest.spyOn(activity, 'logActivity'); + const agreements = new AgreementsService(supabase, emitter, activity); + const disputes = new DisputesService(supabase, agreements, emitter, activity); + + const result = await disputes.openDispute(USER, { + agreement_id: AGREEMENT_ID, + opened_by: WALLET, + reason: 'Work incomplete', + evidence_urls: [], + }); + + expect(result.error).toBeNull(); + + const actions = logSpy.mock.calls.map((c) => c[2]); + expect(actions).toContain('dispute_opened'); + expect(actions).toContain('status_changed_to_disputed'); + + const statusCall = logSpy.mock.calls.find((c) => c[2] === 'status_changed_to_disputed'); + expect(statusCall?.[4]).toEqual( + expect.objectContaining({ previousState: 'active', newState: 'disputed' }), + ); + + const persistedStatus = db.tables.agreement_activity.find( + (r) => r.action === 'status_changed_to_disputed', + ); + expect(persistedStatus).toEqual( + expect.objectContaining({ + previous_state: 'active', + new_state: 'disputed', + }), + ); + + const persistedDispute = db.tables.agreement_activity.find( + (r) => r.action === 'dispute_opened', + ); + expect(persistedDispute).toBeTruthy(); + }); + + it('resolveDispute writes dispute_resolved + status change with previous/new state', async () => { + const db = buildDb({ + agreements: [ + { + id: AGREEMENT_ID, + status: 'disputed', + title: 'Escrow job', + amount: '100', + asset: 'USDC', + created_by: WALLET, + milestones: [], + }, + ], + auth_users: [ + { id: USER, wallet_public_key: WALLET }, + { id: 'user-resolver', wallet_public_key: RESOLVER }, + ], + agreement_participants: [ + { agreement_id: AGREEMENT_ID, wallet_address: WALLET, role: 'payer' }, + ], + disputes: [ + { + id: 'disp-1', + agreement_id: AGREEMENT_ID, + opened_by: WALLET, + reason: 'x', + evidence_urls: [], + status: 'under_review', + resolver_wallet: RESOLVER, + }, + ], + dispute_resolutions: [], + agreement_activity: [], + }); + + const supabase = { getClient: () => db.client } as never; + const emitter = new EventEmitter2(); + const activity = new AgreementActivityService(supabase); + const logSpy = jest.spyOn(activity, 'logActivity'); + const agreements = new AgreementsService(supabase, emitter, activity); + const disputes = new DisputesService(supabase, agreements, emitter, activity); + + const result = await disputes.resolveDispute('user-resolver', 'disp-1', { + resolved_by: RESOLVER, + payer_percentage: 40, + payee_percentage: 60, + resolution_notes: 'Split', + }); + + expect(result.error).toBeNull(); + + const actions = logSpy.mock.calls.map((c) => c[2]); + expect(actions).toContain('dispute_resolved'); + expect(actions).toContain('status_changed_to_resolved'); + + const statusCall = logSpy.mock.calls.find((c) => c[2] === 'status_changed_to_resolved'); + expect(statusCall?.[4]).toEqual( + expect.objectContaining({ previousState: 'disputed', newState: 'resolved' }), + ); + + expect( + db.tables.agreement_activity.find((r) => r.action === 'status_changed_to_resolved'), + ).toEqual( + expect.objectContaining({ + previous_state: 'disputed', + new_state: 'resolved', + }), + ); + }); +}); diff --git a/src/agreements/status-transitions.regression.spec.ts b/src/agreements/status-transitions.regression.spec.ts new file mode 100644 index 0000000..be109a0 --- /dev/null +++ b/src/agreements/status-transitions.regression.spec.ts @@ -0,0 +1,120 @@ +/** + * Regression: issue #59 / #67 · PR #110 / #76 + * Bug: illegal agreement status transitions were accepted (or terminal states mutated). + */ +import { BadRequestException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { AgreementsService } from './agreements.service'; +import { AgreementActivityService } from './agreement-activity.service'; +import { canTransition, invalidTransitionMessage } from './agreement-lifecycle'; +import { validateTransition } from './agreement.validator'; + +type Row = Record; + +function buildDb(agreements: Row[], authUsers: Row[]) { + const tables: Record = { + agreements: agreements.map((r) => ({ ...r })), + auth_users: authUsers.map((r) => ({ ...r })), + agreement_participants: [], + agreement_activity: [], + }; + + function chain(table: string) { + const rows = tables[table] ?? []; + const filters: Array<(r: Row) => boolean> = []; + let mode: 'select' | 'insert' | 'update' = 'select'; + let payload: Row | null = null; + let wantSingle = false; + + const api: Record = {}; + const self = () => api; + api.select = () => self(); + api.eq = (col: string, val: unknown) => { + filters.push((r) => r[col] === val); + return self(); + }; + api.insert = (data: Row) => { + mode = 'insert'; + payload = data; + return self(); + }; + api.update = (data: Row) => { + mode = 'update'; + payload = data; + return self(); + }; + api.single = () => { + wantSingle = true; + return finalize(); + }; + api.maybeSingle = () => finalize(); + + const finalize = () => { + if (mode === 'insert') { + tables[table].push({ id: `${table}-${tables[table].length + 1}`, ...payload }); + return Promise.resolve({ data: payload, error: null }); + } + let matched = rows.filter((r) => filters.every((f) => f(r))); + if (mode === 'update') { + matched = matched.map((r) => Object.assign(r, payload)); + } + if (wantSingle) { + const data = matched[0] ?? null; + if (!data) return Promise.resolve({ data: null, error: { message: 'not found' } }); + return Promise.resolve({ data, error: null }); + } + return Promise.resolve({ data: matched[0] ?? null, error: null }); + }; + + (api as { then?: unknown }).then = (resolve: (v: unknown) => unknown) => + finalize().then(resolve); + return api; + } + + return { tables, client: { from: (t: string) => chain(t) } }; +} + +describe('regression: illegal status transitions blocked (issue #59 / #67 · PR #110 / #76)', () => { + it('blocks pending → completed at the lifecycle + validator layer', () => { + expect(canTransition('pending', 'completed')).toBe(false); + expect(invalidTransitionMessage('pending', 'completed')).toMatch(/Invalid status transition/); + + const result = validateTransition('pending', 'completed'); + expect(result.success).toBe(false); + expect(result.error?.details[0]?.code).toBe('INVALID_TRANSITION'); + }); + + it('blocks transitions out of terminal completed', () => { + expect(canTransition('completed', 'active')).toBe(false); + expect(invalidTransitionMessage('completed', 'active')).toMatch(/terminal/i); + + const result = validateTransition('completed', 'active'); + expect(result.success).toBe(false); + }); + + it('AgreementsService.updateStatus rejects pending → completed', async () => { + const db = buildDb( + [ + { + id: 'agr-1', + status: 'pending', + title: 'T', + amount: '100', + asset: 'USDC', + created_by: 'GWALLET', + milestones: [], + }, + ], + [{ id: 'user-1', wallet_public_key: 'GWALLET' }], + ); + const supabase = { getClient: () => db.client } as never; + const activity = new AgreementActivityService(supabase); + const svc = new AgreementsService(supabase, new EventEmitter2(), activity); + + await expect( + svc.updateStatus('user-1', 'agr-1', { actor_wallet: 'GWALLET', status: 'completed' }), + ).rejects.toThrow(BadRequestException); + + expect(db.tables.agreements[0].status).toBe('pending'); + }); +}); diff --git a/src/disputes/dispute-percentages.regression.spec.ts b/src/disputes/dispute-percentages.regression.spec.ts new file mode 100644 index 0000000..769d7dd --- /dev/null +++ b/src/disputes/dispute-percentages.regression.spec.ts @@ -0,0 +1,159 @@ +/** + * Regression: issue #12 / PR #49 + * Bug: dispute resolution accepted payer_percentage + payee_percentage ≠ 100. + */ +import { BadRequestException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { DisputesService } from './disputes.service'; +import { AgreementsService } from '../agreements/agreements.service'; +import { AgreementActivityService } from '../agreements/agreement-activity.service'; + +type Row = Record; + +function buildDb(seed: { + agreements: Row[]; + auth_users?: Row[]; + disputes?: Row[]; + dispute_resolutions?: Row[]; + agreement_activity?: Row[]; +}) { + const tables: Record = { + agreements: seed.agreements.map((r) => ({ ...r })), + auth_users: (seed.auth_users ?? []).map((r) => ({ ...r })), + disputes: (seed.disputes ?? []).map((r) => ({ ...r })), + dispute_resolutions: (seed.dispute_resolutions ?? []).map((r) => ({ ...r })), + agreement_activity: (seed.agreement_activity ?? []).map((r) => ({ ...r })), + }; + + function chain(table: string) { + const rows = tables[table] ?? []; + const filters: Array<(r: Row) => boolean> = []; + let mode: 'select' | 'insert' | 'update' = 'select'; + let payload: Row | Row[] | null = null; + let wantSingle = false; + let wantMaybe = false; + + const api: Record = {}; + const self = () => api; + + api.select = () => self(); + api.eq = (col: string, val: unknown) => { + filters.push((r) => r[col] === val); + return self(); + }; + api.insert = (data: Row | Row[]) => { + mode = 'insert'; + payload = data; + return self(); + }; + api.update = (data: Row) => { + mode = 'update'; + payload = data; + return self(); + }; + api.single = () => { + wantSingle = true; + return finalize(); + }; + api.maybeSingle = () => { + wantMaybe = true; + return finalize(); + }; + + const finalize = () => { + if (mode === 'insert') { + const items = Array.isArray(payload) ? payload : [payload as Row]; + const created = items.map((item, i) => ({ + id: (item.id as string) || `${table}-${tables[table].length + i + 1}`, + ...item, + })); + tables[table].push(...created); + return Promise.resolve({ + data: created.length === 1 ? created[0] : created, + error: null, + }); + } + + let matched = rows.filter((r) => filters.every((f) => f(r))); + if (mode === 'update') { + matched = matched.map((r) => Object.assign(r, payload as Row)); + } + if (wantSingle || wantMaybe) { + const data = matched[0] ?? null; + if (wantSingle && !data) { + return Promise.resolve({ data: null, error: { message: 'not found' } }); + } + return Promise.resolve({ data, error: null }); + } + return Promise.resolve({ data: matched, error: null }); + }; + + (api as { then?: unknown }).then = (resolve: (v: unknown) => unknown) => + finalize().then(resolve); + + return api; + } + + return { + tables, + client: { from: (table: string) => chain(table) }, + }; +} + +describe('regression: dispute percentage validation (issue #12 / PR #49)', () => { + const RESOLVER = 'GWALLET-RESOLVER'; + const USER_RESOLVER = 'user-resolver'; + + it('rejects resolve when payer_percentage + payee_percentage !== 100', async () => { + const db = buildDb({ + agreements: [ + { + id: 'agr-1', + status: 'disputed', + title: 'T', + amount: '100', + asset: 'USDC', + created_by: 'GWALLET-PAYER', + }, + ], + auth_users: [{ id: USER_RESOLVER, wallet_public_key: RESOLVER }], + disputes: [ + { + id: 'disp-1', + agreement_id: 'agr-1', + status: 'under_review', + resolver_wallet: RESOLVER, + }, + ], + dispute_resolutions: [], + agreement_activity: [], + }); + + const supabase = { getClient: () => db.client } as never; + const emitter = new EventEmitter2(); + const activity = new AgreementActivityService(supabase); + const agreements = new AgreementsService(supabase, emitter, activity); + const disputes = new DisputesService(supabase, agreements, emitter, activity); + + await expect( + disputes.resolveDispute(USER_RESOLVER, 'disp-1', { + resolved_by: RESOLVER, + payer_percentage: 45, + payee_percentage: 40, + resolution_notes: 'bad split', + }), + ).rejects.toThrow(BadRequestException); + + await expect( + disputes.resolveDispute(USER_RESOLVER, 'disp-1', { + resolved_by: RESOLVER, + payer_percentage: 45, + payee_percentage: 40, + resolution_notes: 'bad split', + }), + ).rejects.toThrow(/Percentages must sum to 100%/); + + expect(db.tables.dispute_resolutions).toHaveLength(0); + expect(db.tables.disputes[0].status).toBe('under_review'); + }); +}); diff --git a/src/integration/api-edge-cases.regression.spec.ts b/src/integration/api-edge-cases.regression.spec.ts new file mode 100644 index 0000000..7245556 --- /dev/null +++ b/src/integration/api-edge-cases.regression.spec.ts @@ -0,0 +1,226 @@ +/** + * Regression: issue #15 / #51 · PR #57 + * Bug: API edge cases — invalid JWT, missing agreement IDs, and unauthorized + * by-wallet access did not consistently return 401 / 404 / 403. + */ +import 'reflect-metadata'; +import { ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Test } from '@nestjs/testing'; +import type { INestApplication } from '@nestjs/common'; +import * as jwt from 'jsonwebtoken'; +import request from 'supertest'; +import { AuthModule } from '../auth/auth.module'; +import { SupabaseService } from '../supabase/supabase.service'; +import { AgreementsController } from '../agreements/agreements.controller'; +import { AgreementActivityService } from '../agreements/agreement-activity.service'; +import { AgreementsService } from '../agreements/agreements.service'; + +type Row = Record; + +const JWT_SECRET = 'dev-insecure-change-me'; +const USER_ID = 'staging-user-1'; +const OTHER_USER_ID = 'staging-user-2'; +const WALLET = 'GSTAGINGUSERWALLET000000000000000000000000000000000000000'; +const OTHER_WALLET = 'GSTAGINGOTHERWALLET000000000000000000000000000000000'; +const AGREEMENT_ID = '550e8400-e29b-41d4-a716-446655440000'; + +class QueryBuilder implements PromiseLike<{ data?: unknown; error: unknown }> { + private filters: Array<{ key: string; value: unknown }> = []; + private mode: 'select' | 'insert' | 'update' = 'select'; + private payload: Row | null = null; + private resultMode: 'many' | 'single' | 'maybeSingle' = 'many'; + + constructor( + private readonly db: FakeSupabase, + private readonly table: string, + ) {} + + select() { + return this; + } + eq(key: string, value: unknown) { + this.filters.push({ key, value }); + return this; + } + in(key: string, values: unknown[]) { + this.filters.push({ key, value: values }); + return this; + } + insert(data: Row) { + this.mode = 'insert'; + this.payload = data; + return this; + } + update(data: Row) { + this.mode = 'update'; + this.payload = data; + return this; + } + order() { + return this; + } + limit() { + return this; + } + single() { + this.resultMode = 'single'; + return this; + } + maybeSingle() { + this.resultMode = 'maybeSingle'; + return this; + } + + then( + onfulfilled?: + ((value: { data?: unknown; error: unknown }) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ) { + return this.execute().then(onfulfilled, onrejected); + } + + private execute(): Promise<{ data?: unknown; error: unknown }> { + const rows = this.db.tables[this.table] ?? []; + if (this.mode === 'insert') { + const inserted = { id: `${this.table}-${rows.length + 1}`, ...this.payload }; + this.db.tables[this.table] = [...rows, inserted]; + return Promise.resolve({ data: inserted, error: null }); + } + + const matched = rows.filter((row) => + this.filters.every((f) => { + if (Array.isArray(f.value)) return (f.value as unknown[]).includes(row[f.key]); + return row[f.key] === f.value; + }), + ); + + if (this.mode === 'update') { + for (const row of matched) Object.assign(row, this.payload); + } + + if (this.resultMode === 'single') { + if (!matched[0]) { + return Promise.resolve({ + data: null, + error: { message: 'not found', code: 'PGRST116' }, + }); + } + return Promise.resolve({ data: matched[0], error: null }); + } + if (this.resultMode === 'maybeSingle') { + return Promise.resolve({ data: matched[0] ?? null, error: null }); + } + return Promise.resolve({ data: matched, error: null }); + } +} + +class FakeSupabase { + tables: Record = {}; + + constructor() { + this.reset(); + } + + reset() { + this.tables = { + auth_users: [ + { id: USER_ID, wallet_public_key: WALLET }, + { id: OTHER_USER_ID, wallet_public_key: OTHER_WALLET }, + ], + agreements: [ + { + id: AGREEMENT_ID, + title: 'Staging escrow agreement', + amount: '100.00', + asset: 'USDC', + status: 'active', + created_by: WALLET, + milestones: [], + metadata: {}, + }, + ], + agreement_participants: [ + { id: 'p1', agreement_id: AGREEMENT_ID, wallet_address: WALLET, role: 'payer' }, + ], + agreement_activity: [], + }; + } + + getClient() { + return { from: (table: string) => new QueryBuilder(this, table) }; + } +} + +describe('regression: API edge cases (issue #15 / #51 · PR #57)', () => { + let app: INestApplication; + let supabase: FakeSupabase; + + beforeAll(async () => { + process.env.JWT_SECRET = JWT_SECRET; + supabase = new FakeSupabase(); + + const moduleRef = await Test.createTestingModule({ + imports: [AuthModule], + controllers: [AgreementsController], + providers: [ + AgreementsService, + AgreementActivityService, + { provide: SupabaseService, useValue: supabase }, + { provide: ConfigService, useValue: { get: jest.fn(() => JWT_SECRET) } }, + { provide: EventEmitter2, useValue: { emit: jest.fn() } }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + app.setGlobalPrefix('v1'); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + await app.init(); + }); + + beforeEach(() => { + supabase.reset(); + }); + + afterAll(async () => { + await app.close(); + }); + + const tokenFor = (sub = USER_ID) => + jwt.sign({ sub, email: `${sub}@example.com` }, JWT_SECRET, { + algorithm: 'HS256', + expiresIn: '7d', + }); + const auth = (sub = USER_ID) => ({ Authorization: `Bearer ${tokenFor(sub)}` }); + + it('rejects invalid JWT with 401', async () => { + await request(app.getHttpServer()) + .get(`/v1/agreements/by-wallet?wallet=${WALLET}`) + .set('Authorization', 'Bearer invalid-token') + .expect(401); + + await request(app.getHttpServer()) + .get(`/v1/agreements/${AGREEMENT_ID}`) + .set('Authorization', 'Bearer invalid-token') + .expect(401); + }); + + it('returns 404 for a missing agreement id', async () => { + const missingId = '550e8400-e29b-41d4-a716-446655440999'; + await request(app.getHttpServer()).get(`/v1/agreements/${missingId}`).set(auth()).expect(404); + }); + + it('returns 403 for by-wallet when the JWT user does not own the wallet', async () => { + await request(app.getHttpServer()) + .get(`/v1/agreements/by-wallet?wallet=${OTHER_WALLET}`) + .set(auth(USER_ID)) + .expect(403); + }); +}); diff --git a/src/wallets/stellar-address.regression.spec.ts b/src/wallets/stellar-address.regression.spec.ts new file mode 100644 index 0000000..327fa42 --- /dev/null +++ b/src/wallets/stellar-address.regression.spec.ts @@ -0,0 +1,37 @@ +/** + * Regression: issue #27 (SEP-0043 challenge) + * Bug: invalid Stellar addresses were accepted on verification-challenge input. + */ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { VerificationChallengeQueryDto } from './dto/verification-challenge.dto'; + +describe('regression: invalid Stellar address rejected (issue #27)', () => { + async function validateAddress(address: string) { + const dto = plainToInstance(VerificationChallengeQueryDto, { address }); + return validate(dto); + } + + it('rejects non-G addresses and wrong-length keys', async () => { + const cases = [ + 'not-a-stellar-key', + 'SINVALIDSECRETKEYSHOULDFAIL0000000000000000000000000000', + 'GSHORT', + 'G' + 'A'.repeat(54), // 55 chars total — one short + 'g' + 'A'.repeat(55), // lowercase + ]; + + for (const address of cases) { + const errors = await validateAddress(address); + expect(errors.length).toBeGreaterThan(0); + const messages = errors.flatMap((e) => Object.values(e.constraints ?? {})); + expect(messages.some((m) => /valid Stellar public key/i.test(m))).toBe(true); + } + }); + + it('accepts a well-formed G... 56-char Stellar public key', async () => { + const address = 'GA7QYNF7SOWQ3GLR2BGMZEHHHVSH3VK4UFR2QPYDQGPHK3WSALDQXJZN'; + const errors = await validateAddress(address); + expect(errors).toHaveLength(0); + }); +}); diff --git a/src/webhooks/webhook-status-mapping.regression.spec.ts b/src/webhooks/webhook-status-mapping.regression.spec.ts new file mode 100644 index 0000000..393fa4f --- /dev/null +++ b/src/webhooks/webhook-status-mapping.regression.spec.ts @@ -0,0 +1,115 @@ +/** + * Regression: issue #52 / PR #54 + * Bug: escrow.released webhook mapped incorrectly so the agreement stayed funded + * instead of completing after release. + */ +import { WebhooksService } from './webhooks.service'; +import { RetryJobType } from '../retry-queue/retry-queue.types'; + +const SECRET = 'test-webhook-secret-32chars-long!!'; + +type MockedWebhooksService = WebhooksService & { + _emit: jest.Mock; + _enqueue: jest.Mock; + _registerHandler: jest.Mock; +}; + +function buildService(getClientCalls: unknown[] = []): MockedWebhooksService { + let callIndex = 0; + const getClient = jest.fn().mockImplementation(() => getClientCalls[callIndex++]); + const emit = jest.fn(); + const registerHandler = jest.fn(); + let jobSeq = 0; + const enqueue = jest.fn().mockImplementation((jobType: string, payload: unknown) => ({ + id: `job-${++jobSeq}`, + job_type: jobType, + payload, + })); + + const svc = new (WebhooksService as unknown as new (...args: unknown[]) => WebhooksService)( + { getClient }, + { emit }, + { notifyDisputeOpened: jest.fn() }, + { + get: (key: string, def?: string) => (key === 'TRUSTLESS_WORK_WEBHOOK_SECRET' ? SECRET : def), + }, + { enqueue, registerHandler }, + { logActivity: jest.fn().mockResolvedValue(undefined) }, + ) as MockedWebhooksService; + + svc._emit = emit; + svc._enqueue = enqueue; + svc._registerHandler = registerHandler; + svc.onModuleInit(); + return svc; +} + +function selectClient(returnData: unknown) { + const chain: Record = {}; + ['from', 'select', 'eq'].forEach((m) => { + chain[m] = jest.fn().mockReturnValue(chain); + }); + chain['maybeSingle'] = jest.fn().mockResolvedValue({ data: returnData, error: null }); + return chain; +} + +function updateClient(returnData: unknown) { + const chain: Record = {}; + ['from', 'update', 'eq', 'neq', 'select'].forEach((m) => { + chain[m] = jest.fn().mockReturnValue(chain); + }); + chain['maybeSingle'] = jest.fn().mockResolvedValue({ data: returnData, error: null }); + return chain; +} + +async function runHandleEventAndProcess( + svc: MockedWebhooksService, + payload: Record, +): Promise { + const result = await svc.handleEvent(payload as never); + expect(result).toEqual({ handled: true }); + const enqueueCalls = svc._enqueue.mock.calls as Array<[unknown, unknown]>; + const [, jobPayload] = enqueueCalls[enqueueCalls.length - 1]; + const registerCalls = svc._registerHandler.mock.calls as Array< + [unknown, (jobPayload: unknown, attempt: number) => Promise] + >; + const [, handler] = registerCalls[0]; + await handler(jobPayload, 1); +} + +describe('regression: webhook status mapping (issue #52 / PR #54)', () => { + const row = { id: 'agr-1', title: 'Test', amount: '100', asset: 'USDC' }; + + it('maps escrow.released → completed (not funded) and emits agreement.completed', async () => { + const update = updateClient(row); + const svc = buildService([selectClient({ status: 'in_review', id: 'agr-1' }), update]); + + await runHandleEventAndProcess(svc, { + event: 'escrow.released', + contractId: 'c-released-1', + }); + + // Guard the TW_EVENT_MAP contract itself (re-introducing funded here must fail). + expect(svc._enqueue).toHaveBeenCalledWith( + RetryJobType.WEBHOOK_EVENT_PROCESSING, + expect.objectContaining({ + config: { action: 'status_update', targetStatus: 'completed' }, + }), + expect.any(String), + ); + + expect(update.update).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'completed', + completed_at: expect.any(String), + }), + ); + expect(update.update).not.toHaveBeenCalledWith(expect.objectContaining({ status: 'funded' })); + + expect(svc._emit).toHaveBeenCalledWith( + 'agreement.completed', + expect.objectContaining({ agreementId: 'agr-1' }), + ); + expect(svc._emit).not.toHaveBeenCalledWith('agreement.funded', expect.anything()); + }); +});