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/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/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..9724b54 100644 --- a/playwright/tests/full-flow.spec.ts +++ b/playwright/tests/full-flow.spec.ts @@ -21,9 +21,13 @@ * local-dev default). See helpers/keys.ts for the derivation. */ import { expect, type Page, test } from "@playwright/test"; -import { execSync } from "child_process"; import { getTarget, getUrls, type ServiceUrls } from "../helpers/urls"; import { deriveAllProfiles, type DerivedProfiles } from "../helpers/keys"; +import { + cleanupPayPlatformDb, + fetchActivePpPublicKey, + fetchCompletedInboundStroops, +} from "../helpers/db"; import { closeAllContexts, createUserContext, @@ -78,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; @@ -89,6 +97,11 @@ 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; + // ─── Test ─────────────────────────────────────────────────────────── test.describe("Full UC Flow", () => { @@ -98,18 +111,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) + }`, + ); } } @@ -150,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 () => { @@ -159,6 +183,7 @@ test.describe("Full UC Flow", () => { admin: adminCtx, merchant: merchantCtx, pos: posCtx, + payService: payServiceCtx, }); }); @@ -434,6 +459,92 @@ 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 via the KYC UI", async () => { + ppPublicKey = await fetchActivePpPublicKey(); + console.log(`Discovered PP pubkey: ${ppPublicKey}`); + + // 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 ${profiles.payService.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 +590,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 +805,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 +837,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,12 +846,21 @@ 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); } });