diff --git a/docs/test-architecture.md b/docs/test-architecture.md index 5ff1b1bbd..774ea3748 100644 --- a/docs/test-architecture.md +++ b/docs/test-architecture.md @@ -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 diff --git a/e2e-stack/specs/auth.spec.ts b/e2e-stack/specs/auth.spec.ts index a550ee8b7..05f254c76 100644 --- a/e2e-stack/specs/auth.spec.ts +++ b/e2e-stack/specs/auth.spec.ts @@ -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 { @@ -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 { + 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 { + 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 { + 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 { await expect( @@ -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, }); @@ -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(); }); }); diff --git a/src/components/compliance/call-queue/call-queue-user-info.tsx b/src/components/compliance/call-queue/call-queue-user-info.tsx index e8fcd3258..9f5cc5f37 100644 --- a/src/components/compliance/call-queue/call-queue-user-info.tsx +++ b/src/components/compliance/call-queue/call-queue-user-info.tsx @@ -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 }, diff --git a/src/components/compliance/user-data-panel.tsx b/src/components/compliance/user-data-panel.tsx index 9bb1e5f1c..e29bf9cab 100644 --- a/src/components/compliance/user-data-panel.tsx +++ b/src/components/compliance/user-data-panel.tsx @@ -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) }, ]; } diff --git a/src/hooks/compliance.hook.ts b/src/hooks/compliance.hook.ts index ad4bb25a9..79c03c6a7 100644 --- a/src/hooks/compliance.hook.ts +++ b/src/hooks/compliance.hook.ts @@ -276,10 +276,6 @@ export interface LanguageRef { symbol?: string; } -export interface WalletRef { - name?: string; -} - export interface OrganizationDetail { id?: number; name?: string; @@ -307,7 +303,6 @@ export interface UserDataDetail { kycStatus?: string; kycLevel?: number; depositLimit?: number; - wallet?: WalletRef; // Personal Data accountType?: string;