Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/test-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ run does not prove for each one; the taxonomy and cross-repository entries live
A green run proves the quote list, pending-table and stats-chart fixtures render. It does not
prove that the API returns those payloads, that login or token verification works, or that those
staff/settings or stats endpoints return real data.
- **The RealUnit compliance visual spec answers the customer list and dossier itself.**
`e2e/realunit-compliance.spec.ts` fulfils `GET /v1/realunit/compliance/customers` and
`GET /v1/realunit/compliance/customers/:id` with synthetic fixtures (including `addresses`).
A green run proves those fixtures render. It does not prove that the API returns that payload
or that the server filters to RealUnit wallets.
- **Two specs force KYC completeness.** Both collection-invoice cases — the refused QR and the
stored-detail error — override `**/v2/user` so that `kyc.dataComplete` is read as `true`, because
the invoice button is gated on that value. A green run therefore proves nothing about the gate for
Expand Down Expand Up @@ -168,6 +173,22 @@ run does not prove for each one; the taxonomy and cross-repository entries live
`e2e-stack/specs/buy.spec.ts` (`openQuoteCapableBuy` and older quote cases) updates the limit
directly so `LIMIT_EXCEEDED` does not hide payment info. A green run does **not** prove that a
customer reaches that limit through the product path.
- **Full-stack continue-race specs SQL-write `user_data.tradeApprovalDate`.**
`e2e-stack/specs/kyc-continue-race.spec.ts` sets the date so recommendation is skipped. A green
run does **not** prove that a customer obtains trade approval through the product path.
- **Full-stack continue-race specs SQL-insert STRICT `TfaLog` rows.**
`e2e-stack/specs/kyc-continue-race.spec.ts` inserts `kyc_log` type `TfaLog` with comment
`Strict (App)` so `continue()` does not 403 after FinancialData starts. A green run does
**not** prove the mail/app 2FA enrolment or verification path.
- **Full-stack continue-race specs recreate `kyc_step` unique index `NULLS NOT DISTINCT`.**
`e2e-stack/specs/kyc-continue-race.spec.ts` drops the synchronize unique index on
`(userDataId, name, type, sequenceNumber)` and creates `IDX_3a1150791476264753a67212a1`
with `NULLS NOT DISTINCT`, matching production. A green run does **not** prove the
migration chain applied that index.
- **Full-stack continue-race specs SQL-complete KYC steps.**
`e2e-stack/specs/kyc-continue-race.spec.ts` upserts ContactData, PersonalData, NationalityData
and Ident (`SumsubAuto`) to `Completed`. A green run does **not** prove those steps complete
through the product path, including live ident.
- **The settings verification-call visual spec answers GET /v2/user itself.**
`e2e/settings-verification-call.spec.ts` fulfils `/v2/user` with three synthetic kyc payloads
(`phoneCallAccepted` unset / true / false) and fulfils the Settings bootstrap GETs
Expand Down
236 changes: 236 additions & 0 deletions e2e-stack/specs/kyc-continue-race.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
/**
* Proves the ident-complete continue race against the real API and Postgres.
*
* Production failure: after Sumsub ident finished, the client fired many overlapping
* PUT /v2/kyc calls; one FinancialData insert won and the rest hit the unique index.
*
* Lowest layer that can express that: 13 parallel continues against a live API and
* Postgres. FinancialData has a NULL type, so the unique index only conflicts when it is
* NULLS NOT DISTINCT (production). The harness schema comes from synchronize, so this file
* recreates that index before the burst. All HTTP 200 plus COUNT=1 would also pass on the
* old duplicate-key retry path; xact_rollback must stay flat because a unique-violation
* aborts the initiateStep transaction.
*/

import {
cleanupCreatedData,
createKycStep,
createUser,
expect,
queryOne,
queryRows,
test,
withDb,
} from './fixtures';

test.describe.configure({ mode: 'serial' });

test.beforeAll(async () => {
await ensureKycStepUniqueNullsNotDistinct();
});

test.afterAll(async () => {
await cleanupCreatedData();
});

const PARALLEL_CONTINUES = 13;
/** Trusted client IP for loc realIp middleware (`cf-connecting-ip`). */
const TFA_IP = '203.0.113.7';
/** Production unique index on kyc_step (userDataId, name, type, sequenceNumber). */
const KYC_STEP_UNIQUE_INDEX = 'IDX_3a1150791476264753a67212a1';

function apiBase(): string {
return process.env.E2E_API_URL ?? 'http://api:3000';
}

async function kycHashOf(userDataId: number): Promise<string> {
const row = await queryOne<{ kycHash: string }>(`SELECT "kycHash" FROM user_data WHERE id = $1`, [userDataId]);
if (!row?.kycHash) throw new Error(`user_data.kycHash missing for userDataId ${userDataId}`);
return row.kycHash;
}

async function countSteps(userDataId: number, name: string): Promise<number> {
const row = await queryOne<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM kyc_step WHERE "userDataId" = $1 AND name = $2`,
[userDataId, name],
);
return Number(row?.n ?? 0);
}

async function rollbackCount(): Promise<number> {
const row = await queryOne<{ n: string }>(
`SELECT xact_rollback::text AS n FROM pg_stat_database WHERE datname = current_database()`,
);
return Number(row?.n ?? 0);
}

/**
* Synchronize does not apply FixNullableUniqueIndexes. Recreate the production unique index
* so NULL `type` on FinancialData actually conflicts (otherwise COUNT=1 is the only signal
* and xact_rollback never moves).
*/
async function ensureKycStepUniqueNullsNotDistinct(): Promise<void> {
await withDb(async (client) => {
const { rows } = await client.query<{ indexname: string; indexdef: string }>(
`SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'kyc_step'`,
);
for (const idx of rows) {
const def = idx.indexdef.toLowerCase();
if (
def.includes('unique') &&
def.includes('userdataid') &&
def.includes('sequencenumber') &&
(def.includes('"type"') || def.includes(', type,') || def.includes('(type'))
) {
await client.query(`DROP INDEX IF EXISTS "${idx.indexname}"`);
}
}
await client.query(
`CREATE UNIQUE INDEX "${KYC_STEP_UNIQUE_INDEX}" ON "kyc_step" ("userDataId", "name", "type", "sequenceNumber") NULLS NOT DISTINCT`,
);
});
const row = await queryOne<{ indexdef: string }>(`SELECT indexdef FROM pg_indexes WHERE indexname = $1`, [
KYC_STEP_UNIQUE_INDEX,
]);
expect(row?.indexdef ?? '').toMatch(/NULLS NOT DISTINCT/i);
}

async function ensureCompletedStep(
userDataId: number,
name: string,
extra: { type?: string | null; result?: string | null } = {},
): Promise<void> {
const existing = await queryOne<{ id: number }>(
`SELECT id FROM kyc_step WHERE "userDataId" = $1 AND name = $2 ORDER BY id LIMIT 1`,
[userDataId, name],
);
if (existing) {
await withDb(async (client) => {
await client.query(
`UPDATE kyc_step SET status = 'Completed', result = COALESCE($2, result), updated = NOW() WHERE id = $1`,
[existing.id, extra.result ?? null],
);
});
return;
}
await createKycStep(userDataId, {
name,
status: 'Completed',
sequenceNumber: 0,
type: extra.type,
result: extra.result,
});
}

async function seedPriorSteps(userDataId: number): Promise<void> {
// Signup/mail already inserts ContactData. Re-inserting the same NULL-type row only
// succeeds on the synchronize unique index; production NULLS NOT DISTINCT rejects it.
await ensureCompletedStep(userDataId, 'ContactData');
await ensureCompletedStep(userDataId, 'PersonalData');
await ensureCompletedStep(userDataId, 'NationalityData', {
result: JSON.stringify({ nationality: { symbol: 'CH' } }),
});
await withDb(async (client) => {
await client.query(`UPDATE user_data SET "tradeApprovalDate" = NOW() WHERE id = $1`, [userDataId]);
});
}

async function seedIdentCompleted(tag: string): Promise<{ userDataId: number; kycHash: string }> {
const user = await createUser({
tag,
language: 'EN',
country: 'CH',
kycLevel: 30,
completePersonalData: true,
});
await seedPriorSteps(user.userDataId);
await ensureCompletedStep(user.userDataId, 'Ident', { type: 'SumsubAuto' });
return { userDataId: user.userDataId, kycHash: await kycHashOf(user.userDataId) };
}

async function markStrictTfa(userDataId: number): Promise<void> {
// continue() requires a STRICT TfaLog for the request IP once FinancialData/Ident is in progress.
await withDb(async (client) => {
for (const ip of [TFA_IP, '127.0.0.1', '::1', '::ffff:127.0.0.1', 'unknown']) {
await client.query(
`INSERT INTO kyc_log (type, comment, "userDataId", "ipAddress", created, updated)
VALUES ('TfaLog', 'Strict (App)', $1, $2, NOW(), NOW())`,
[userDataId, ip],
);
}
});
}

async function putContinue(kycHash: string): Promise<{ status: number }> {
const res = await fetch(`${apiBase()}/v2/kyc`, {
method: 'PUT',
headers: {
Accept: 'application/json',
'x-kyc-code': kycHash,
'cf-connecting-ip': TFA_IP,
},
});
return { status: res.status };
}

test('13 parallel PUT /v2/kyc after ident create exactly one FinancialData step', async () => {
const user = await seedIdentCompleted('continue-race-api');
await markStrictTfa(user.userDataId);
expect(await countSteps(user.userDataId, 'FinancialData')).toBe(0);

const rollbacksBefore = await rollbackCount();
const results = await Promise.all(Array.from({ length: PARALLEL_CONTINUES }, () => putContinue(user.kycHash)));

const statuses = results.map((r) => r.status);
expect(
statuses.every((s) => s === 200),
`continue statuses: ${statuses.join(',')}`,
).toBe(true);
expect(await countSteps(user.userDataId, 'FinancialData')).toBe(1);

const rows = await queryRows<{ id: number; status: string }>(
`SELECT id, status FROM kyc_step WHERE "userDataId" = $1 AND name = 'FinancialData' ORDER BY id`,
[user.userDataId],
);
expect(rows).toHaveLength(1);

const rollbacksAfter = await rollbackCount();
expect(rollbacksAfter - rollbacksBefore).toBe(0);
});

test('a fourteenth continue after the burst still leaves one FinancialData step', async () => {
const user = await seedIdentCompleted('continue-race-fourteenth');
await markStrictTfa(user.userDataId);

const burst = await Promise.all(Array.from({ length: PARALLEL_CONTINUES }, () => putContinue(user.kycHash)));
const fourteenth = await putContinue(user.kycHash);
const statuses = [...burst.map((r) => r.status), fourteenth.status];

expect(
statuses.every((s) => s === 200),
`continue statuses: ${statuses.join(',')}`,
).toBe(true);
expect(await countSteps(user.userDataId, 'FinancialData')).toBe(1);
});

test('two users racing 13 continues each still get one FinancialData step apiece', async () => {
// Still one FinancialData per user under load. Does not distinguish a per-user advisory
// lock from a process-wide mutex; the production incident is one user, 13 continues.
const userA = await seedIdentCompleted('continue-race-two-a');
const userB = await seedIdentCompleted('continue-race-two-b');
await markStrictTfa(userA.userDataId);
await markStrictTfa(userB.userDataId);

const results = await Promise.all([
...Array.from({ length: PARALLEL_CONTINUES }, () => putContinue(userA.kycHash)),
...Array.from({ length: PARALLEL_CONTINUES }, () => putContinue(userB.kycHash)),
]);

const statuses = results.map((r) => r.status);
expect(
statuses.every((s) => s === 200),
`continue statuses: ${statuses.join(',')}`,
).toBe(true);
expect(await countSteps(userA.userDataId, 'FinancialData')).toBe(1);
expect(await countSteps(userB.userDataId, 'FinancialData')).toBe(1);
});
4 changes: 3 additions & 1 deletion e2e-stack/specs/support-dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ test.describe('Support dashboard (staff)', () => {
]);
const issue = required(issueRow, 'createLimitRequest must leave a support_issue row');

const { jwt } = await loginAs('Support');
// Customer LimitRequests are filed under Department.Compliance. Support's issue list is
// restricted to Department.Support, so this listing uses Compliance (who can open the dashboard).
const { jwt } = await loginAs('Compliance');
await openScreen(page, '/support/dashboard/all', jwt);

await page.getByRole('button', { name: /^Limit Requests \(/ }).click();
Expand Down
10 changes: 10 additions & 0 deletions e2e/realunit-compliance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,14 @@ const DOSSIER = {
created: '2024-01-02T00:00:00.000Z',
},
],
addresses: [
{
id: 7901,
address: '0xabc0000000000000000000000000000000000001',
status: 'Active',
created: '2024-01-02T00:00:00.000Z',
},
],
buyRoutes: [
{
id: 7501,
Expand Down Expand Up @@ -393,6 +401,8 @@ test.describe('RealUnit Compliance dashboards - Visual Regression Tests', () =>
// title AND the identity "Account Type" value).
await expect(page.getByText('Identity')).toBeVisible();
await expect(page.getByText('Account Opener Authorization', { exact: false })).toBeVisible();
// Wallet address from the Addresses table (Buy Routes keep the same hex as targetAddress but do not render it).
await expect(page.getByText('0xabc0000000000000000000000000000000000001')).toBeVisible();
await expect(page.getByText('Support Issues', { exact: false })).toBeVisible();
await expect(page.getByText('Missing incoming transfer')).toBeVisible();

Expand Down
14 changes: 7 additions & 7 deletions e2e/safe-accounts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import { getCachedAuth } from './helpers/auth-cache';
* repeated here — the switcher is not rendered at all in those.
*
* Requires a local dataset with three reachable accounts: one owned (write), one shared
* read-only, and one shared with a write mandate. The last one matters: a mandate over
* someone else's Safe cannot transact either, because orders carry no account and would be
* booked against the caller's own. Not a CI regression gate — see CONTRIBUTING.md.
* read-only, and one shared with a write mandate. The last one matters: a write mandate
* can transact, because orders now hit the account resource. Not a CI regression gate —
* see CONTRIBUTING.md.
*/

test.describe('DFX Safe - Account switcher', () => {
Expand Down Expand Up @@ -78,11 +78,11 @@ test.describe('DFX Safe - Account switcher', () => {
await page.getByText('Example Mandate AG').click();
await page.waitForLoadState('networkidle');

// The mandate grants write, yet acting is still refused — and the entry says so rather
// than looking fully usable and dropping the section on selection.
await expect(page.getByRole('button', { name: 'Deposit', exact: true })).toHaveCount(0);
await expect(page.getByText('View only').first()).toBeVisible();
// Write mandate can transact: orders hit the account resource.
await expect(page.getByRole('button', { name: 'Deposit', exact: true })).toBeVisible();
await expect(page.getByText('View only')).toHaveCount(0);

// Baseline follows; visuals are not a CI gate.
await expect(page).toHaveScreenshot('04-account-shared-write-mandate.png', screenshotOpts);
});
});
1 change: 1 addition & 0 deletions src/__tests__/realunit-compliance-kyc-file-date.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const DOSSIER: RealUnitCustomerDetailDto = {
kycSteps: [],
transactions: [],
bankDatas: [],
addresses: [],
buyRoutes: [],
sellRoutes: [],
swapRoutes: [],
Expand Down
Loading
Loading