Skip to content
Draft
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
14 changes: 14 additions & 0 deletions docs/test-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,20 @@ 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 auth merge specs SQL-write `account_merge.expiration`.**
`e2e-stack/specs/auth.spec.ts` (expired otp) updates expiration directly. A green run does **not**
prove that merge links expire through the product path or that the API sets expiration on create.
- **Full-stack auth merge specs SQL-write `user.walletId` (and may insert a partner `wallet` row).**
`e2e-stack/specs/auth.spec.ts` (RealUnit / Denario merge-mail cases) assigns the partner wallet on
the master address so the mail URL can be asserted. If the named wallet row is missing, the spec
inserts it. A green run does **not** prove that a customer registered through that partner app,
or that partner wallet rows are provisioned by the regular seed or migration path.
Those three cases are `test.skip` until DFXswiss/backend#5270 is on develop: the e2e-stack API
image is built from backend@develop, which still brands merge from the default wallet.
- **Full-stack auth merge specs SQL-write `user.userDataId` to attach a second address.**
`e2e-stack/specs/auth.spec.ts` (mixed RealUnit+DFX) re-parents an extra user onto the master
account. A green run does **not** prove that a dual-account is created through the product path.
Skipped with the other host-matrix cases until DFXswiss/backend#5270 is on develop.
- **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
260 changes: 236 additions & 24 deletions e2e-stack/specs/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ import {
testEmail,
testWallet,
waitForRow,
withDb,
} from './fixtures';
import { cleanupCreatedData, createUser, e2eMail } from './fixtures/factories';
import { cleanupCreatedData, createUser, e2eMail, type CreateUserResult } from './fixtures/factories';

/** Parse a 6-digit verification code from a notification row (VerificationMail / EmailVerification). */
function codeFromNotificationData(data: string): string {
Expand Down Expand Up @@ -62,6 +63,91 @@ async function waitForVerificationCode(
return codeFromNotificationData(row.data);
}

function assertMergeMailUrl(url: string | undefined, code: string, host: 'dfx' | 'realunit'): void {
expect(url, 'merge mail must include a confirmation URL').toBeTruthy();
const parsed = new URL(url as string);
expect(parsed.pathname).toBe('/account-merge');
expect(parsed.searchParams.get('otp')).toBe(code);
if (host === 'realunit') {
expect(['https://realunit.app', 'https://dev.realunit.app']).toContain(parsed.origin);
} else {
const dfxOrigin = new URL(process.env.E2E_FRONTEND_URL ?? 'http://frontend').origin;
expect(parsed.origin).toBe(dfxOrigin);
}
}

function mergeMailFromNotification(data: string): { url?: string; walletName?: string } {
let parsed: { wallet?: { name?: string }; texts?: Array<{ params?: { url?: string } }> };
try {
parsed = JSON.parse(data) as typeof parsed;
} catch (e) {
throw new Error(`mergeMailFromNotification: failed to JSON.parse notification.data: ${e}`);
}
const url = parsed.texts?.find((t) => typeof t?.params?.url === 'string')?.params?.url;
return { url, walletName: parsed.wallet?.name };
}

async function waitForMergeMail(userDataId: number): Promise<{ url?: string; walletName?: string }> {
const row = await waitForRow<{ data: string }>(
`SELECT n.data FROM notification n
WHERE n."userDataId" = $1 AND n.context = 'AccountMergeRequest'
ORDER BY n.id DESC LIMIT 1`,
[userDataId],
20000,
);
return mergeMailFromNotification(row.data);
}

async function walletIdNamed(name: string): Promise<number> {
const existing = await queryOne<{ id: number }>(`SELECT id FROM wallet WHERE name = $1 ORDER BY id ASC LIMIT 1`, [
name,
]);
if (existing?.id) return existing.id;
const inserted = await withDb(async (client) => {
const result = await client.query<{ id: number }>(
`INSERT INTO wallet (name, "displayName", created, updated)
VALUES ($1, $1, NOW(), NOW())
RETURNING id`,
[name],
);
return result.rows[0];
});
if (!inserted?.id) throw new Error(`walletIdNamed: failed to insert wallet ${name}`);
return inserted.id;
}

async function setUserWallet(userId: number, walletId: number): Promise<void> {
await withDb(async (client) => {
await client.query(`UPDATE "user" SET "walletId" = $1 WHERE id = $2`, [walletId, userId]);
});
}

/** B requests A's mail after 2FA — the product path that sends the merge mail to A. */
async function triggerMergeViaMailChange(
page: Page,
master: CreateUserResult,
slave: CreateUserResult,
): Promise<string> {
await openScreen(page, '/2fa', slave.jwt);
await completeMail2faOnPage(page, slave.userDataId);
await page.goto('/account/mail');
await page.waitForLoadState('networkidle');
await expect(page.getByRole('textbox', { name: 'Email address' })).toBeVisible({ timeout: 20000 });
await page.getByRole('textbox', { name: 'Email address' }).fill(required(master.mail, 'master.mail'));
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('It looks like you already have an account with DFX.')).toBeVisible({
timeout: 20000,
});
const mergeRow = await waitForRow<{ code: string }>(
`SELECT code FROM account_merge
WHERE "masterId" = $1 AND "slaveId" = $2
ORDER BY id DESC LIMIT 1`,
[master.userDataId, slave.userDataId],
20000,
);
return mergeRow.code;
}

/** Complete the mail-based /2fa screen for a customer account (same browser IP for later check2fa). */
async function completeMail2faOnPage(page: Page, userDataId: number): Promise<void> {
await expect(
Expand Down Expand Up @@ -346,25 +432,116 @@ test.describe('Auth area e2e', () => {
test('/account-merge?otp=… adds wallet address (UI + Postgres)', async ({ page }) => {
test.setTimeout(120000);

const mailA = e2eMail('merge-master');
const mailB = e2eMail('merge-slave');
const userA = await createUser({ tag: 'merge-a', mail: mailA, language: 'EN' });
const userB = await createUser({ tag: 'merge-b', mail: mailB, language: 'EN' });
const userA = await createUser({ tag: 'merge-a', mail: e2eMail('merge-master'), language: 'EN' });
const userB = await createUser({ tag: 'merge-b', mail: e2eMail('merge-slave'), language: 'EN' });
const code = await triggerMergeViaMailChange(page, userA, userB);
expect(code).toBeTruthy();

const mail = await waitForMergeMail(userA.userDataId);
assertMergeMailUrl(mail.url, code, 'dfx');

// Confirm merge unauthenticated (OptionalJwtAuthGuard).
await page.goto(`/account-merge?otp=${encodeURIComponent(code)}`);
await expect(page.getByText('Wallet address added', { exact: true })).toBeVisible({ timeout: 30000 });
await expect(page.getByText('You can now access your account.', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'My account' })).toBeVisible();

const completed = await waitForRow<{ isCompleted: boolean }>(
`SELECT "isCompleted" AS "isCompleted" FROM account_merge
WHERE "masterId" = $1 AND "slaveId" = $2
ORDER BY id DESC LIMIT 1`,
[userA.userDataId, userB.userDataId],
20000,
);
expect(completed.isCompleted).toBe(true);

const slave = await waitForRow<{ status: string }>(
`SELECT status FROM user_data WHERE id = $1 AND status = 'Merged'`,
[userB.userDataId],
20000,
);
expect(slave.status).toBe('Merged');
});

// Host-matrix (RealUnit / Denario / mixed) needs DFXswiss/backend#5270 on the API image.
// e2e-stack.yml checks out backend@develop, which still brands merge from the default wallet.
test('unanimous RealUnit merge mail links to realunit.app and still confirms on DFX', async ({ page }) => {
test.skip(true, 'Needs DFXswiss/backend#5270; e2e-stack builds the API from develop');
test.setTimeout(120000);
const realunitId = await walletIdNamed('RealUnit');
const userA = await createUser({ tag: 'merge-ru-a', mail: e2eMail('merge-ru-master'), language: 'EN' });
const userB = await createUser({ tag: 'merge-ru-b', mail: e2eMail('merge-ru-slave'), language: 'EN' });
await setUserWallet(userA.userId, realunitId);

const code = await triggerMergeViaMailChange(page, userA, userB);
const mail = await waitForMergeMail(userA.userDataId);
expect(mail.walletName).toBe('RealUnit');
assertMergeMailUrl(mail.url, code, 'realunit');

await page.goto(`/account-merge?otp=${encodeURIComponent(code)}`);
await expect(page.getByText('Wallet address added', { exact: true })).toBeVisible({ timeout: 30000 });
});

test('unanimous Denario merge mail stays on the DFX confirmation host', async ({ page }) => {
test.skip(true, 'Needs DFXswiss/backend#5270; e2e-stack builds the API from develop');
test.setTimeout(120000);
const denarioId = await walletIdNamed('Denario');
const userA = await createUser({ tag: 'merge-de-a', mail: e2eMail('merge-de-master'), language: 'EN' });
const userB = await createUser({ tag: 'merge-de-b', mail: e2eMail('merge-de-slave'), language: 'EN' });
await setUserWallet(userA.userId, denarioId);

const code = await triggerMergeViaMailChange(page, userA, userB);
const mail = await waitForMergeMail(userA.userDataId);
expect(mail.walletName).toBeUndefined();
assertMergeMailUrl(mail.url, code, 'dfx');
});

test('mixed RealUnit+DFX addresses on the master stay on the DFX confirmation host', async ({ page }) => {
test.skip(true, 'Needs DFXswiss/backend#5270; e2e-stack builds the API from develop');
test.setTimeout(120000);
const realunitId = await walletIdNamed('RealUnit');
const userA = await createUser({ tag: 'merge-mx-a', mail: e2eMail('merge-mx-master'), language: 'EN' });
const extra = await createUser({ tag: 'merge-mx-extra', mail: e2eMail('merge-mx-extra'), language: 'EN' });
const userB = await createUser({ tag: 'merge-mx-b', mail: e2eMail('merge-mx-slave'), language: 'EN' });
await setUserWallet(userA.userId, realunitId);
await withDb(async (client) => {
await client.query(`UPDATE "user" SET "userDataId" = $1 WHERE id = $2`, [userA.userDataId, extra.userId]);
});

const code = await triggerMergeViaMailChange(page, userA, userB);
const mail = await waitForMergeMail(userA.userDataId);
expect(mail.walletName).toBeUndefined();
assertMergeMailUrl(mail.url, code, 'dfx');
});

test('/account-merge without otp ends on /login when there is no session', async ({ page }) => {
await page.goto('/account-merge');
await page.waitForLoadState('networkidle');
await expect
.poll(() => normPath(new URL(page.url()).pathname), {
message: 'missing merge otp without a session should end on /login',
timeout: 20000,
})
.toBe('/login');
});

test('/account-merge already-completed otp lands on /error', async ({ page }) => {
test.setTimeout(120000);

const mailA = e2eMail('merge-done-master');
const mailB = e2eMail('merge-done-slave');
const userA = await createUser({ tag: 'merge-done-a', mail: mailA, language: 'EN' });
const userB = await createUser({ tag: 'merge-done-b', mail: mailB, language: 'EN' });

// Trigger merge the same way the product does: B requests A's mail after 2FA.
await openScreen(page, '/2fa', userB.jwt);
await completeMail2faOnPage(page, userB.userDataId);

await page.goto('/account/mail');
await page.waitForLoadState('networkidle');
await expect(page.getByRole('textbox', { name: 'Email address' })).toBeVisible({
timeout: 20000,
});

const mailInput = page.getByRole('textbox', { name: 'Email address' });
await mailInput.fill(mailA);
await page.getByRole('textbox', { name: 'Email address' }).fill(mailA);
await page.getByRole('button', { name: 'Save' }).click();

await expect(page.getByText('It looks like you already have an account with DFX.')).toBeVisible({
timeout: 20000,
});
Expand All @@ -376,28 +553,63 @@ test.describe('Auth area e2e', () => {
[userA.userDataId, userB.userDataId],
20000,
);
expect(mergeRow.code).toBeTruthy();

// Confirm merge unauthenticated (OptionalJwtAuthGuard).
await page.goto(`/account-merge?otp=${encodeURIComponent(mergeRow.code)}`);
await expect(page.getByText('Wallet address added', { exact: true })).toBeVisible({ timeout: 30000 });
await expect(page.getByText('You can now access your account.', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'My account' })).toBeVisible();

const completed = await waitForRow<{ isCompleted: boolean }>(
`SELECT "isCompleted" AS "isCompleted" FROM account_merge
await page.goto(`/account-merge?otp=${encodeURIComponent(mergeRow.code)}`);
await expect
.poll(() => normPath(new URL(page.url()).pathname), {
message: 'already-merged otp should navigate to /error',
timeout: 20000,
})
.toBe('/error');
await expect(page.getByText(/already been added/i)).toBeVisible();
});

test('/account-merge expired otp lands on /error', async ({ page }) => {
test.setTimeout(120000);

const mailA = e2eMail('merge-exp-master');
const mailB = e2eMail('merge-exp-slave');
const userA = await createUser({ tag: 'merge-exp-a', mail: mailA, language: 'EN' });
const userB = await createUser({ tag: 'merge-exp-b', mail: mailB, language: 'EN' });

await openScreen(page, '/2fa', userB.jwt);
await completeMail2faOnPage(page, userB.userDataId);
await page.goto('/account/mail');
await page.waitForLoadState('networkidle');
await expect(page.getByRole('textbox', { name: 'Email address' })).toBeVisible({
timeout: 20000,
});
await page.getByRole('textbox', { name: 'Email address' }).fill(mailA);
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('It looks like you already have an account with DFX.')).toBeVisible({
timeout: 20000,
});

const mergeRow = await waitForRow<{ code: string }>(
`SELECT code FROM account_merge
WHERE "masterId" = $1 AND "slaveId" = $2
ORDER BY id DESC LIMIT 1`,
[userA.userDataId, userB.userDataId],
20000,
);
expect(completed.isCompleted).toBe(true);

const slave = await waitForRow<{ status: string }>(
`SELECT status FROM user_data WHERE id = $1 AND status = 'Merged'`,
[userB.userDataId],
20000,
);
expect(slave.status).toBe('Merged');
await withDb(async (client) => {
await client.query(`UPDATE account_merge SET expiration = $1 WHERE code = $2`, [
new Date('2000-01-01T00:00:00Z'),
mergeRow.code,
]);
});

await page.goto(`/account-merge?otp=${encodeURIComponent(mergeRow.code)}`);
await expect
.poll(() => normPath(new URL(page.url()).pathname), {
message: 'expired merge otp should navigate to /error',
timeout: 20000,
})
.toBe('/error');
await expect(page.getByText(/expired|Invalid link/i)).toBeVisible();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function CallQueueUserInfo({ userData, users, kycSteps, highlightCheckDat
{ label: 'Status', value: userData.status },
{ label: 'KYC Level', value: userData.kycLevel == null ? undefined : String(userData.kycLevel) },
{ label: 'KYC Status', value: userData.kycStatus },
{ label: 'Wallet', value: primaryUser?.walletName ?? userData.wallet?.name },
{ label: 'Wallet', value: primaryUser?.walletName },
{ label: 'User Ref', value: primaryUser?.ref },
{ label: 'Used Ref', value: primaryUser?.usedRef },
{ label: 'Referrer (Ref Werber)', value: refUserName },
Expand Down
1 change: 0 additions & 1 deletion src/components/compliance/user-data-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ function userDataRows(d: UserDataDetail, depositLimitNode: ReactNode, idNode: Re
{ key: 'kycStatus', value: display(d.kycStatus) },
{ key: 'kycLevel', value: display(d.kycLevel) },
{ key: 'depositLimit', value: depositLimitNode },
{ key: 'wallet', value: refName(d.wallet) },
];
}

Expand Down
5 changes: 0 additions & 5 deletions src/hooks/compliance.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,6 @@ export interface LanguageRef {
symbol?: string;
}

export interface WalletRef {
name?: string;
}

export interface OrganizationDetail {
id?: number;
name?: string;
Expand Down Expand Up @@ -307,7 +303,6 @@ export interface UserDataDetail {
kycStatus?: string;
kycLevel?: number;
depositLimit?: number;
wallet?: WalletRef;

// Personal Data
accountType?: string;
Expand Down
Loading