Skip to content
Merged
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
91 changes: 91 additions & 0 deletions playwright/helpers/db.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string> {
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<bigint> {
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();
}
}
10 changes: 10 additions & 0 deletions playwright/helpers/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

/**
Expand All @@ -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: {
Expand Down Expand Up @@ -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(),
},
};
}
2 changes: 2 additions & 0 deletions playwright/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
179 changes: 162 additions & 17 deletions playwright/tests/full-flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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", () => {
Expand All @@ -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)
}`,
);
}
}

Expand Down Expand Up @@ -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 () => {
Expand All @@ -159,6 +183,7 @@ test.describe("Full UC Flow", () => {
admin: adminCtx,
merchant: merchantCtx,
pos: posCtx,
payService: payServiceCtx,
});
});

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ───────────────────
Expand All @@ -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()
Expand All @@ -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);
}
});

Expand Down
Loading