From 80c1830d6413eda521dfbce3570eff4aa1549e4b Mon Sep 17 00:00:00 2001 From: Gorka Date: Tue, 2 Jun 2026 08:43:41 -0300 Subject: [PATCH 1/2] fix(playwright): exercise + assert the actual payment flow, not just UI scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-flow playwright suite was passing on a backend that returned errors: 1. Step 12 asserted only `#pos-status` visible + non-empty. moonlight-pay writes both success and failure text into that element, so "Payment processing failed: 500" was an unconditional green. 2. Step 13 asserted "merchant page has #balance-display OR #tx-list visible" — both are UI scaffolding present on a freshly-loaded page, regardless of whether any payment landed. 3. The `beforeAll` cleanup tried to TRUNCATE pay-platform tables via `execSync("psql …")` — psql isn't in the test-runner container, so the cleanup silently no-op'd with "DB cleanup skipped (psql not available)". Deterministic keys + uncleaned `council_pps` left stale PP rows that pay-platform randomly picked, 500'ing half the time. 4. Step 8 filled `#pp-pk` with `profiles.provider.publicKey` — the operator wallet pubkey, not the derived PP keypair the operator's provider-console flow actually registers. pay-platform stored the wrong key and instant-execute's bundle submission 500'd with "PP not found or inactive". 5. provider-platform's add-bundle gates on the submitter's entity row being APPROVED. pay-platform's pay-service identity auto-creates as UNVERIFIED via SEP-10 first-time auth and is never promoted — bundle submission would 403 if the right PP got picked. The loose Step 12 assertion hid this entirely. Fixes: * Replace the execSync(psql) cleanup with a node-pg client (helpers/db.ts:cleanupPayPlatformDb) that authoritatively truncates `councils`, `pay_accounts`, AND `council_pps`. Adds `pg` + `@types/pg` to playwright/package.json. * helpers/db.ts:fetchActivePpPublicKey() discovers the derived PP pubkey from provider-platform's DB after Step 4. New Step 6b fills it for later use, and Step 8 fills `#pp-pk` with it instead of the operator pubkey. * Step 6b also registers pay-service as APPROVED via a new helpers/register-entity.ts (the SEP-43/raw signed-challenge flow matching provider-platform's POST /providers/:pp/entities). Mirrors what a real deployment seeds at install time. * New Step 13b exercises the public KYC route at #/entities/register?provider= end-to-end (POS user wallet, Stellar Wallets Kit modal, signed-challenge submission, success card). Independent of the payment flow; smokes the new UI added in provider-console#37. * Step 12 now reads `#pos-status` text and fails on `failed|reject|error|expired|insufficient`. Step 13 queries pay-platform's `transactions` table directly for a COMPLETED IN transaction for the merchant — protocol-level proof that the bundle landed, not just that UI scaffolding rendered. Net result: the suite that previously passed against a backend silently 500'ing now actually verifies the payment settles end-to-end. --- playwright/helpers/db.ts | 91 +++++++++++ playwright/helpers/register-entity.ts | 69 +++++++++ playwright/package.json | 2 + playwright/tests/full-flow.spec.ts | 208 +++++++++++++++++++++++--- 4 files changed, 352 insertions(+), 18 deletions(-) create mode 100644 playwright/helpers/db.ts create mode 100644 playwright/helpers/register-entity.ts diff --git a/playwright/helpers/db.ts b/playwright/helpers/db.ts new file mode 100644 index 0000000..5e424a3 --- /dev/null +++ b/playwright/helpers/db.ts @@ -0,0 +1,91 @@ +/** + * Direct PostgreSQL helpers for the playwright full-flow setup. + * + * The test-runner container runs in Node — there's no psql binary inside it. + * The original `execSync("psql …")` cleanup silently failed with + * "DB cleanup skipped (psql not available)" and stale rows leaked across + * runs, which had been hidden by loose UI assertions. Direct PG over node-pg + * fixes that without infra changes to the container image. + * + * Both DBs (provider_platform_db, pay_platform_db) live on the same PG + * instance (host `db` inside the test compose network, `localhost:5442` from + * the dev host). The CLEANUP_DATABASE_URL / DISCOVERY_DATABASE_URL env vars + * default to the in-container DSN; override when running playwright off-host. + */ +import { Client } from "pg"; + +const DEFAULT_DSN_IN_CONTAINER = "postgresql://admin:devpass@db:5432"; + +function resolveDsn(dbName: string): string { + const explicit = process.env[`${dbName.toUpperCase()}_URL`]; + if (explicit) return explicit; + const base = process.env.PG_BASE_URL || DEFAULT_DSN_IN_CONTAINER; + return `${base}/${dbName}`; +} + +/** + * Truncate stale councils + pay_accounts + council_pps so deterministic + * playwright keys don't accumulate ghost PPs from prior runs. + */ +export async function cleanupPayPlatformDb(): Promise { + const client = new Client({ connectionString: resolveDsn("pay_platform_db") }); + await client.connect(); + try { + await client.query( + "TRUNCATE councils, pay_accounts, council_pps CASCADE", + ); + } finally { + await client.end(); + } +} + +/** + * Returns the active PP's `payment_providers.public_key` from + * provider-platform's DB. The playwright provider-creation flow registers + * exactly one PP per run; this lets later steps reference the actual + * derived PP keypair instead of the operator wallet's pubkey (which is what + * the original test mistakenly used). + */ +export async function fetchActivePpPublicKey(): Promise { + const client = new Client({ + connectionString: resolveDsn("provider_platform_db"), + }); + await client.connect(); + try { + const res = await client.query<{ public_key: string }>( + "SELECT public_key FROM payment_providers WHERE is_active = TRUE ORDER BY created_at DESC LIMIT 1", + ); + if (res.rows.length === 0) { + throw new Error("No active PP found in provider_platform_db"); + } + return res.rows[0].public_key; + } finally { + await client.end(); + } +} + +/** + * Returns the sum of completed inbound transaction amounts (stroops) for a + * pay-platform wallet. Used by Step 13 to assert the payment actually + * settled, not just that a UI element became visible. pay-platform's + * displayed balance = sum(completed IN) - sum(completed OUT); for a + * freshly-onboarded merchant in a single-payment test, sum-of-completed-IN + * is the right signal that the bundle landed end-to-end. + */ +export async function fetchCompletedInboundStroops( + walletPubkey: string, +): Promise { + const client = new Client({ + connectionString: resolveDsn("pay_platform_db"), + }); + await client.connect(); + try { + const res = await client.query<{ total: string | null }>( + "SELECT COALESCE(SUM(amount_stroops), 0)::text AS total FROM transactions WHERE wallet_public_key = $1 AND direction = 'IN' AND status = 'COMPLETED'", + [walletPubkey], + ); + return BigInt(res.rows[0]?.total ?? "0"); + } finally { + await client.end(); + } +} diff --git a/playwright/helpers/register-entity.ts b/playwright/helpers/register-entity.ts new file mode 100644 index 0000000..fc193ea --- /dev/null +++ b/playwright/helpers/register-entity.ts @@ -0,0 +1,69 @@ +/** + * Server-side helper to register a wallet as an APPROVED entity on a + * provider-platform PP. Matches the SEP-43/raw signed-challenge flow that + * `provider-platform`'s `POST /providers/:pp/entities` requires. + * + * Used as test setup in the playwright full-flow: pay-platform submits + * bundles on behalf of customers via the `pay-service` identity, and + * provider-platform's add-bundle gate rejects submitters whose entity row + * is not APPROVED. A real deployment seeds pay-service's entity once at + * deploy time; for the test we do the equivalent before the POS payment + * step. + * + * This is intentionally an API-direct path, not a UI flow — the test's + * Freighter setup doesn't include the pay-service identity. The new UI at + * `provider-console#/entities/register?provider=` is exercised + * separately in Step 10.5 using a user identity that IS in Freighter. + */ +import { Keypair } from "@stellar/stellar-sdk"; + +export async function registerEntityViaApi( + providerUrl: string, + ppPublicKey: string, + user: Keypair, + name: string, + jurisdictions: string[] = [], +): Promise { + const base = `${providerUrl}/api/v1/providers/${ + encodeURIComponent(ppPublicKey) + }/entities`; + + const challengeRes = await fetch(`${base}/challenge`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pubkey: user.publicKey() }), + }); + if (!challengeRes.ok) { + throw new Error( + `Entity challenge failed for ${user.publicKey()}: ${challengeRes.status} ${await challengeRes + .text()}`, + ); + } + const challengeBody = await challengeRes.json() as { + data: { nonce: string }; + }; + const nonce = challengeBody.data.nonce; + + // SEP-43 raw nonce-bytes signature — matches verify-stellar-signature.ts's + // raw fallback used by SDK / E2E flows. + const nonceBytes = Uint8Array.from(atob(nonce), (c) => c.charCodeAt(0)); + const sigBytes = user.sign(Buffer.from(nonceBytes)); + const signature = btoa(String.fromCharCode(...new Uint8Array(sigBytes))); + + const res = await fetch(base, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + pubkey: user.publicKey(), + name, + jurisdictions, + signedChallenge: { nonce, signature }, + }), + }); + if (!res.ok && res.status !== 409) { + throw new Error( + `Entity registration failed for ${user.publicKey()}: ${res.status} ${await res + .text()}`, + ); + } +} diff --git a/playwright/package.json b/playwright/package.json index 1fef298..397a757 100644 --- a/playwright/package.json +++ b/playwright/package.json @@ -12,6 +12,8 @@ "@playwright/test": "^1.49.0", "@stellar/stellar-sdk": "^15.0.1", "@types/node": "^25.6.0", + "@types/pg": "^8.11.10", + "pg": "^8.13.1", "typescript": "^5.7.0" } } diff --git a/playwright/tests/full-flow.spec.ts b/playwright/tests/full-flow.spec.ts index ba56c5f..5fc9883 100644 --- a/playwright/tests/full-flow.spec.ts +++ b/playwright/tests/full-flow.spec.ts @@ -21,9 +21,21 @@ * local-dev default). See helpers/keys.ts for the derivation. */ import { expect, type Page, test } from "@playwright/test"; -import { execSync } from "child_process"; +import { Keypair } from "@stellar/stellar-sdk"; import { getTarget, getUrls, type ServiceUrls } from "../helpers/urls"; -import { deriveAllProfiles, type DerivedProfiles } from "../helpers/keys"; +import { + deriveAllProfiles, + deriveKeypair, + type DerivedProfiles, + masterSeedFromSecret, + ROLES, +} from "../helpers/keys"; +import { + cleanupPayPlatformDb, + fetchActivePpPublicKey, + fetchCompletedInboundStroops, +} from "../helpers/db"; +import { registerEntityViaApi } from "../helpers/register-entity"; import { closeAllContexts, createUserContext, @@ -89,6 +101,24 @@ let posPage: Page; let posUrl: string; let testStartEpochS: number; +// Discovered after Step 4 — the actual PP public key registered in +// provider-platform (derived from the operator wallet's master seed, distinct +// from the operator wallet pubkey). +let ppPublicKey: string; + +// Derived alongside the user profiles; pay-platform submits bundles to +// provider-platform under this identity, so its entity row must be APPROVED +// before any POS payment will land. helpers/keys.ts exposes pay-service via +// the ROLES table but not on the DerivedProfiles return — derive it directly. +const PAY_SERVICE_KEYPAIR: Keypair = deriveKeypair( + masterSeedFromSecret( + process.env.MASTER_SECRET || + "SAQCGLJ2JISI67QGG457IBN2DY6YW5GGS2OMQU5KNLXB3TWVUIR2RD74", + ), + ROLES.PAY_SERVICE, + 0, +); + // ─── Test ─────────────────────────────────────────────────────────── test.describe("Full UC Flow", () => { @@ -98,18 +128,24 @@ test.describe("Full UC Flow", () => { testStartEpochS = Math.floor(Date.now() / 1000); // Clean up stale pay-platform data from previous test runs. - // Deterministic keys mean old accounts/councils accumulate and - // interfere with council selection (first council without a PP gets - // picked instead of the newly created one). + // Deterministic keys mean old accounts/councils/council_pps accumulate + // and (a) cause council selection to pick the wrong council, (b) leave + // multiple PP rows for the same council so the random PP selection in + // pay-platform/instant-execute hits a stale entry → 500. + // + // The previous psql-via-execSync attempt silently failed inside the + // test-runner container (no psql binary), masked by loose UI assertions. + // Use node-pg directly so the cleanup is actually authoritative. if (getTarget() === "local") { try { - execSync( - `PGPASSWORD=devpass psql -h localhost -p 5442 -U admin -d pay_platform_db -c "TRUNCATE councils CASCADE; TRUNCATE pay_accounts CASCADE;"`, - { stdio: "pipe" }, - ); + await cleanupPayPlatformDb(); console.log("Cleaned up stale pay-platform data"); - } catch { - console.log("DB cleanup skipped (psql not available)"); + } catch (err) { + console.log( + `DB cleanup failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); } } @@ -434,6 +470,34 @@ test.describe("Full UC Flow", () => { ).toBeVisible({ timeout: 120_000 }); }); + // ── Step 6b: Approve pay-service as a bundle submitter ──────────── + // + // pay-platform submits all POS-instant bundles to provider-platform under + // the pay-service identity. provider-platform's add-bundle pipeline + // requires the submitter's `entities.status` to be APPROVED; SEP-10 + // first-time auth (which pay-platform does when acquiring its provider + // JWT) only auto-creates an UNVERIFIED row. A real deployment seeds the + // pay-service entity once at deploy time; the test does the equivalent + // here so the bundle in Step 12 can settle. Until this PR landed the + // bundle would 403 and the UI showed an error, but the loose Step 12 + // assertion accepted any non-empty status text — so the flow silently + // skipped the only path it actually exists to validate. + + test("Step 6b: Discover PP pubkey + register pay-service as APPROVED entity", async () => { + ppPublicKey = await fetchActivePpPublicKey(); + console.log(`Discovered PP pubkey: ${ppPublicKey}`); + + await registerEntityViaApi( + urls.providerApi, + ppPublicKey, + PAY_SERVICE_KEYPAIR, + "Pay Service", + ); + console.log( + `Registered pay-service ${PAY_SERVICE_KEYPAIR.publicKey()} as APPROVED entity for PP ${ppPublicKey}`, + ); + }); + // ── Step 7: Admin signs in to moonlight-pay ─────────────────────── test("Step 7: Admin signs in to moonlight-pay/admin", async () => { @@ -479,7 +543,14 @@ test.describe("Full UC Flow", () => { await adminPage.waitForSelector("#pp-name", { timeout: 5_000 }); await adminPage.fill("#pp-name", PROVIDER_NAME); await adminPage.fill("#pp-url", urls.providerApi); - await adminPage.fill("#pp-pk", profiles.provider.publicKey); + // pp.publicKey is the *derived* PP keypair from the operator's wallet + // master seed (see provider-console/src/lib/wallet.ts), NOT the + // operator's own wallet pubkey. The original test mistakenly filled + // this field with profiles.provider.publicKey (the operator wallet); + // pay-platform then stored the wrong key and instant-execute later + // 500'd with "PP not found or inactive". ppPublicKey was discovered + // from provider-platform's DB in Step 6b. + await adminPage.fill("#pp-pk", ppPublicKey); await adminPage.click("#pp-save"); // Verify PP appears in the list @@ -687,10 +758,27 @@ test.describe("Full UC Flow", () => { await popup2.waitForEvent("close", { timeout: 10_000 }).catch(() => {}); } - // Verify: payment status shown + // Verify: payment status shown AND the message indicates success, not + // an error. The previous assertion (toBeVisible + not.toBeEmpty) passed + // for both success ("Payment received!") and failure ("Payment + // processing failed: 500 …") strings — masking the very bug this test + // is meant to catch. const statusEl = posPage.locator("#pos-status"); await expect(statusEl).toBeVisible({ timeout: 60_000 }); - await expect(statusEl).not.toBeEmpty({ timeout: 10_000 }); + const statusText = (await statusEl.textContent({ timeout: 10_000 })) + ?.trim() ?? ""; + expect(statusText.length, "POS status must not be empty").toBeGreaterThan( + 0, + ); + // moonlight-pay surfaces failures as "Payment processing failed: …" / + // "rejected the bundle" / "preparation failed" etc. Any of those means + // the bundle never settled — fail the test loudly instead of swallowing + // it as a UI smoke pass. + const errorPatterns = /failed|reject|error|expired|insufficient/i; + expect( + errorPatterns.test(statusText), + `POS status indicates failure: "${statusText}"`, + ).toBe(false); }); // ── Step 13: Merchant verifies received payment ─────────────────── @@ -702,6 +790,7 @@ test.describe("Full UC Flow", () => { await merchantPage.reload(); await merchantPage.waitForLoadState("networkidle"); + // UI smoke — merchant home renders. const hasBalance = await merchantPage .locator("#balance-display") .isVisible() @@ -710,13 +799,96 @@ test.describe("Full UC Flow", () => { .locator("#tx-list") .isVisible() .catch(() => false); - expect(hasBalance || hasTxList).toBeTruthy(); - if (hasTxList) { - const txContent = await merchantPage.locator("#tx-list").textContent(); - expect(txContent?.length).toBeGreaterThan(0); + // Protocol-level proof: pay-platform recorded at least one COMPLETED + // inbound transaction for the merchant. The previous assertion + // (txContent?.length > 0) accepted "No transactions yet" placeholder + // text, so a payment that never settled looked the same as one that + // did. The DB query is authoritative. + if (getTarget() === "local") { + const completed = await fetchCompletedInboundStroops( + profiles.merchant.publicKey, + ); + expect( + completed > 0n, + `Expected at least one COMPLETED inbound tx for merchant ${profiles.merchant.publicKey}, got total ${completed} stroops`, + ).toBe(true); + } + }); + + // ── Step 13b: Exercise the public KYC route ──────────────────────── + // + // Independent UI smoke for #/entities/register?provider=. The POS + // user's identity has no entity row in provider-platform yet (POS only + // auths to moonlight-pay, never to provider-platform), so registering + // here exercises the create-new-APPROVED-entity branch of the route. + // Independent of Step 12's payment — the bundle submitter is pay-service + // and that one was registered in Step 6b. + + test("Step 13b: POS user registers as entity via KYC route", async () => { + if (getTarget() !== "local") { + test.skip(true, "KYC route smoke is local-only"); } + const kycPage = await posCtx.context.newPage(); + await kycPage.goto( + `${urls.providerConsole}/#/entities/register?provider=${ppPublicKey}`, + ); + await kycPage.waitForLoadState("networkidle"); + + // Hard-error UI must NOT be showing. + await expect( + kycPage.locator("text=Cannot continue"), + "valid ?provider= should render the connect step, not the hard-error card", + ).toHaveCount(0); + + await expect(kycPage.locator("#kyc-connect-btn")).toBeVisible({ + timeout: 10_000, + }); + + // Connect button opens the Stellar Wallets Kit modal first. The user + // (or the test) must select "Freighter" inside that modal to trigger + // the Freighter popup. Mirrors the Step 12 modal-handling pattern. + await withWalletApproval(posCtx.context, kycPage, async () => { + await kycPage.click("#kyc-connect-btn"); + await kycPage.waitForTimeout(1000); + const freighterOption = kycPage.locator("text=Freighter").first(); + if ( + await freighterOption.isVisible({ timeout: 3_000 }).catch(() => false) + ) { + await freighterOption.click(); + } else { + await kycPage.evaluate(() => { + const modal = document.querySelector("stellar-wallets-modal"); + if (modal?.shadowRoot) { + const btn = + modal.shadowRoot.querySelector("[data-wallet-id]") as + | HTMLElement + | null ?? + modal.shadowRoot.querySelector("button") as HTMLElement | null; + btn?.click(); + } + }); + } + }); + + // After connect: the form shows. + await expect(kycPage.locator("#kyc-step-form")).toBeVisible({ + timeout: 15_000, + }); + + await kycPage.fill("#kyc-name", "POS User"); + + await withWalletApproval(posCtx.context, kycPage, async () => { + await kycPage.click("#kyc-submit-btn"); + }); + + // Success state — entity APPROVED. + await expect(kycPage.locator("#kyc-step-success")).toBeVisible({ + timeout: 30_000, + }); + + await kycPage.close(); }); // ── Step 14: OTEL trace verification ────────────────────────────── From de810fc27035633affb99db7f8c2ee7b83365a5d Mon Sep 17 00:00:00 2001 From: Gorka Date: Tue, 2 Jun 2026 09:17:55 -0300 Subject: [PATCH 2/2] test(playwright): drive Step 6b's pay-service registration through the KYC UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously Step 6b registered pay-service via a direct fetch(/providers/:pp/entities/challenge) → sign → POST /entities call — faster but it bypassed the very route this PR exists to validate. Move the registration onto the public KYC route at provider-console#/entities/register?provider= so Step 6b exercises the real flow: Stellar Wallets Kit modal → Freighter sign-challenge popup → name submission → success card. * helpers/keys.ts adds the pay-service identity to DerivedProfiles. The ROLES enum already exposed PAY_SERVICE; only the profile return was missing. * The full-flow test creates a payServiceCtx in beforeAll (its own Chromium + Freighter, Friendbot-funded by createUserContext) and closes it in afterAll. * Step 6b now opens a page in payServiceCtx, navigates to the KYC route, drives the modal-then-popup approval pattern Step 12 already uses, and asserts the success card renders. * Step 13b (the previous POS-user UI smoke) is removed — Step 6b now covers the same UI path with a more meaningful identity (the bundle submitter), so 13b became redundant. * helpers/register-entity.ts (API-direct SEP-43/raw flow) is removed — no remaining consumers. --- playwright/helpers/keys.ts | 10 ++ playwright/helpers/register-entity.ts | 69 ---------- playwright/tests/full-flow.spec.ts | 179 +++++++++++--------------- 3 files changed, 86 insertions(+), 172 deletions(-) delete mode 100644 playwright/helpers/register-entity.ts diff --git a/playwright/helpers/keys.ts b/playwright/helpers/keys.ts index b9ac87b..6ddea2f 100644 --- a/playwright/helpers/keys.ts +++ b/playwright/helpers/keys.ts @@ -62,6 +62,10 @@ export interface DerivedProfiles { admin: { name: string; publicKey: string; secretKey: string }; merchant: { name: string; publicKey: string; secretKey: string }; pos: { name: string; publicKey: string; secretKey: string }; + /** The pay-platform service identity. Submits bundles to provider-platform + * on behalf of customers in the POS instant-payment flow, so its entity + * row must be APPROVED for any payment to settle. */ + payService: { name: string; publicKey: string; secretKey: string }; } /** @@ -83,6 +87,7 @@ export function deriveAllProfiles(masterSecret?: string): DerivedProfiles { const admin = deriveKeypair(seed, ROLES.PAY_ADMIN, 0); const merchant = deriveKeypair(seed, ROLES.ALICE, 0); const pos = deriveKeypair(seed, ROLES.BOB, 0); + const payService = deriveKeypair(seed, ROLES.PAY_SERVICE, 0); return { council: { @@ -110,5 +115,10 @@ export function deriveAllProfiles(masterSecret?: string): DerivedProfiles { publicKey: pos.publicKey(), secretKey: pos.secret(), }, + payService: { + name: "Pay Service", + publicKey: payService.publicKey(), + secretKey: payService.secret(), + }, }; } diff --git a/playwright/helpers/register-entity.ts b/playwright/helpers/register-entity.ts deleted file mode 100644 index fc193ea..0000000 --- a/playwright/helpers/register-entity.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Server-side helper to register a wallet as an APPROVED entity on a - * provider-platform PP. Matches the SEP-43/raw signed-challenge flow that - * `provider-platform`'s `POST /providers/:pp/entities` requires. - * - * Used as test setup in the playwright full-flow: pay-platform submits - * bundles on behalf of customers via the `pay-service` identity, and - * provider-platform's add-bundle gate rejects submitters whose entity row - * is not APPROVED. A real deployment seeds pay-service's entity once at - * deploy time; for the test we do the equivalent before the POS payment - * step. - * - * This is intentionally an API-direct path, not a UI flow — the test's - * Freighter setup doesn't include the pay-service identity. The new UI at - * `provider-console#/entities/register?provider=` is exercised - * separately in Step 10.5 using a user identity that IS in Freighter. - */ -import { Keypair } from "@stellar/stellar-sdk"; - -export async function registerEntityViaApi( - providerUrl: string, - ppPublicKey: string, - user: Keypair, - name: string, - jurisdictions: string[] = [], -): Promise { - const base = `${providerUrl}/api/v1/providers/${ - encodeURIComponent(ppPublicKey) - }/entities`; - - const challengeRes = await fetch(`${base}/challenge`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ pubkey: user.publicKey() }), - }); - if (!challengeRes.ok) { - throw new Error( - `Entity challenge failed for ${user.publicKey()}: ${challengeRes.status} ${await challengeRes - .text()}`, - ); - } - const challengeBody = await challengeRes.json() as { - data: { nonce: string }; - }; - const nonce = challengeBody.data.nonce; - - // SEP-43 raw nonce-bytes signature — matches verify-stellar-signature.ts's - // raw fallback used by SDK / E2E flows. - const nonceBytes = Uint8Array.from(atob(nonce), (c) => c.charCodeAt(0)); - const sigBytes = user.sign(Buffer.from(nonceBytes)); - const signature = btoa(String.fromCharCode(...new Uint8Array(sigBytes))); - - const res = await fetch(base, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - pubkey: user.publicKey(), - name, - jurisdictions, - signedChallenge: { nonce, signature }, - }), - }); - if (!res.ok && res.status !== 409) { - throw new Error( - `Entity registration failed for ${user.publicKey()}: ${res.status} ${await res - .text()}`, - ); - } -} diff --git a/playwright/tests/full-flow.spec.ts b/playwright/tests/full-flow.spec.ts index 5fc9883..9724b54 100644 --- a/playwright/tests/full-flow.spec.ts +++ b/playwright/tests/full-flow.spec.ts @@ -21,21 +21,13 @@ * local-dev default). See helpers/keys.ts for the derivation. */ import { expect, type Page, test } from "@playwright/test"; -import { Keypair } from "@stellar/stellar-sdk"; import { getTarget, getUrls, type ServiceUrls } from "../helpers/urls"; -import { - deriveAllProfiles, - deriveKeypair, - type DerivedProfiles, - masterSeedFromSecret, - ROLES, -} from "../helpers/keys"; +import { deriveAllProfiles, type DerivedProfiles } from "../helpers/keys"; import { cleanupPayPlatformDb, fetchActivePpPublicKey, fetchCompletedInboundStroops, } from "../helpers/db"; -import { registerEntityViaApi } from "../helpers/register-entity"; import { closeAllContexts, createUserContext, @@ -90,6 +82,10 @@ let providerCtx: UserContext; let adminCtx: UserContext; let merchantCtx: UserContext; let posCtx: UserContext; +// pay-service has its own Freighter context so Step 6b can drive the public +// KYC route end-to-end as the bundle-submitting identity rather than +// seeding the entity DB via an API call. +let payServiceCtx: UserContext; let councilPage: Page; let providerPage: Page; @@ -106,19 +102,6 @@ let testStartEpochS: number; // from the operator wallet pubkey). let ppPublicKey: string; -// Derived alongside the user profiles; pay-platform submits bundles to -// provider-platform under this identity, so its entity row must be APPROVED -// before any POS payment will land. helpers/keys.ts exposes pay-service via -// the ROLES table but not on the DerivedProfiles return — derive it directly. -const PAY_SERVICE_KEYPAIR: Keypair = deriveKeypair( - masterSeedFromSecret( - process.env.MASTER_SECRET || - "SAQCGLJ2JISI67QGG457IBN2DY6YW5GGS2OMQU5KNLXB3TWVUIR2RD74", - ), - ROLES.PAY_SERVICE, - 0, -); - // ─── Test ─────────────────────────────────────────────────────────── test.describe("Full UC Flow", () => { @@ -186,6 +169,11 @@ test.describe("Full UC Flow", () => { publicKey: profiles.pos.publicKey, secretKey: profiles.pos.secretKey, }); + payServiceCtx = await createUserContext(null, { + name: profiles.payService.name, + publicKey: profiles.payService.publicKey, + secretKey: profiles.payService.secretKey, + }); }); test.afterAll(async () => { @@ -195,6 +183,7 @@ test.describe("Full UC Flow", () => { admin: adminCtx, merchant: merchantCtx, pos: posCtx, + payService: payServiceCtx, }); }); @@ -483,18 +472,76 @@ test.describe("Full UC Flow", () => { // assertion accepted any non-empty status text — so the flow silently // skipped the only path it actually exists to validate. - test("Step 6b: Discover PP pubkey + register pay-service as APPROVED entity", async () => { + test("Step 6b: Discover PP pubkey + register pay-service via the KYC UI", async () => { ppPublicKey = await fetchActivePpPublicKey(); console.log(`Discovered PP pubkey: ${ppPublicKey}`); - await registerEntityViaApi( - urls.providerApi, - ppPublicKey, - PAY_SERVICE_KEYPAIR, - "Pay Service", + // Drive the public KYC route end-to-end as the pay-service identity. + // Uses the same provider-console#/entities/register?provider=… page a + // real submitter would use, signing the SEP-43/raw challenge through + // Freighter rather than seeding the entity DB by direct API call. + const kycPage = await payServiceCtx.context.newPage(); + await kycPage.goto( + `${urls.providerConsole}/#/entities/register?provider=${ppPublicKey}`, ); + await kycPage.waitForLoadState("networkidle"); + + // The hard-error UI must NOT be showing. + await expect( + kycPage.locator("text=Cannot continue"), + "valid ?provider= should render the connect step, not the hard-error card", + ).toHaveCount(0); + + await expect(kycPage.locator("#kyc-connect-btn")).toBeVisible({ + timeout: 10_000, + }); + + // Connect button opens the Stellar Wallets Kit modal first. The user + // (or the test) must select "Freighter" inside that modal to trigger + // the Freighter approval popup. Mirrors the Step 12 modal-handling + // pattern. + await withWalletApproval(payServiceCtx.context, kycPage, async () => { + await kycPage.click("#kyc-connect-btn"); + await kycPage.waitForTimeout(1000); + const freighterOption = kycPage.locator("text=Freighter").first(); + if ( + await freighterOption.isVisible({ timeout: 3_000 }).catch(() => false) + ) { + await freighterOption.click(); + } else { + await kycPage.evaluate(() => { + const modal = document.querySelector("stellar-wallets-modal"); + if (modal?.shadowRoot) { + const btn = + modal.shadowRoot.querySelector("[data-wallet-id]") as + | HTMLElement + | null ?? + modal.shadowRoot.querySelector("button") as HTMLElement | null; + btn?.click(); + } + }); + } + }); + + // After connect: the name-input form shows. + await expect(kycPage.locator("#kyc-step-form")).toBeVisible({ + timeout: 15_000, + }); + + await kycPage.fill("#kyc-name", "Pay Service"); + + await withWalletApproval(payServiceCtx.context, kycPage, async () => { + await kycPage.click("#kyc-submit-btn"); + }); + + // Success card — entity APPROVED. + await expect(kycPage.locator("#kyc-step-success")).toBeVisible({ + timeout: 30_000, + }); + + await kycPage.close(); console.log( - `Registered pay-service ${PAY_SERVICE_KEYPAIR.publicKey()} as APPROVED entity for PP ${ppPublicKey}`, + `Registered pay-service ${profiles.payService.publicKey} as APPROVED entity for PP ${ppPublicKey}`, ); }); @@ -817,80 +864,6 @@ test.describe("Full UC Flow", () => { } }); - // ── Step 13b: Exercise the public KYC route ──────────────────────── - // - // Independent UI smoke for #/entities/register?provider=. The POS - // user's identity has no entity row in provider-platform yet (POS only - // auths to moonlight-pay, never to provider-platform), so registering - // here exercises the create-new-APPROVED-entity branch of the route. - // Independent of Step 12's payment — the bundle submitter is pay-service - // and that one was registered in Step 6b. - - test("Step 13b: POS user registers as entity via KYC route", async () => { - if (getTarget() !== "local") { - test.skip(true, "KYC route smoke is local-only"); - } - const kycPage = await posCtx.context.newPage(); - await kycPage.goto( - `${urls.providerConsole}/#/entities/register?provider=${ppPublicKey}`, - ); - await kycPage.waitForLoadState("networkidle"); - - // Hard-error UI must NOT be showing. - await expect( - kycPage.locator("text=Cannot continue"), - "valid ?provider= should render the connect step, not the hard-error card", - ).toHaveCount(0); - - await expect(kycPage.locator("#kyc-connect-btn")).toBeVisible({ - timeout: 10_000, - }); - - // Connect button opens the Stellar Wallets Kit modal first. The user - // (or the test) must select "Freighter" inside that modal to trigger - // the Freighter popup. Mirrors the Step 12 modal-handling pattern. - await withWalletApproval(posCtx.context, kycPage, async () => { - await kycPage.click("#kyc-connect-btn"); - await kycPage.waitForTimeout(1000); - const freighterOption = kycPage.locator("text=Freighter").first(); - if ( - await freighterOption.isVisible({ timeout: 3_000 }).catch(() => false) - ) { - await freighterOption.click(); - } else { - await kycPage.evaluate(() => { - const modal = document.querySelector("stellar-wallets-modal"); - if (modal?.shadowRoot) { - const btn = - modal.shadowRoot.querySelector("[data-wallet-id]") as - | HTMLElement - | null ?? - modal.shadowRoot.querySelector("button") as HTMLElement | null; - btn?.click(); - } - }); - } - }); - - // After connect: the form shows. - await expect(kycPage.locator("#kyc-step-form")).toBeVisible({ - timeout: 15_000, - }); - - await kycPage.fill("#kyc-name", "POS User"); - - await withWalletApproval(posCtx.context, kycPage, async () => { - await kycPage.click("#kyc-submit-btn"); - }); - - // Success state — entity APPROVED. - await expect(kycPage.locator("#kyc-step-success")).toBeVisible({ - timeout: 30_000, - }); - - await kycPage.close(); - }); - // ── Step 14: OTEL trace verification ────────────────────────────── test("Step 14: OTEL trace verification", async () => {