diff options
| author | Adam Soltys <[email protected]> | 2026-02-11 10:45:39 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-11 15:34:39 -0800 |
| commit | e8e3fe413f02b9520bd3539f351c344134148c58 (patch) | |
| tree | bf2508e6228c060ffc6df6efc09a39e1a3732234 | |
| parent | 2cc4969678f4d073ebddec0e7e9f2aac707fb761 (diff) | |
| download | coinos-ui-e8e3fe413f02b9520bd3539f351c344134148c58.tar.gz coinos-ui-e8e3fe413f02b9520bd3539f351c344134148c58.zip | |
ark payment test
| -rw-r--r-- | .env.sample | 6 | ||||
| -rw-r--r-- | package.json | 4 | ||||
| -rw-r--r-- | playwright.config.ts | 2 | ||||
| -rw-r--r-- | src/components/Account.svelte | 4 | ||||
| -rw-r--r-- | src/components/InvoiceData.svelte | 1 | ||||
| -rw-r--r-- | src/components/WalletPass.svelte | 4 | ||||
| -rw-r--r-- | src/routes/test/ark/send/+server.ts | 76 | ||||
| -rw-r--r-- | test-results/.last-run.json | 4 | ||||
| -rw-r--r-- | test/e2e/ark-receive-from-server-wallet.spec.ts | 149 |
9 files changed, 249 insertions, 1 deletions
diff --git a/.env.sample b/.env.sample index 72dfb652..d4c24f77 100644 --- a/.env.sample +++ b/.env.sample @@ -9,3 +9,9 @@ PUBLIC_COINOS_PUBKEY=ba80990666ef0b6f4ba5059347beb13242921e54669e680064ca755256a PUBLIC_COINOS_RELAY=wss://relay.coinos.io PUBLIC_COINOS_NETWORK=regtest PUBLIC_RECAPTCHA_SITE_KEY= + +# Test-only endpoints (never enable in production) +ENABLE_E2E_TEST_ENDPOINTS=false +E2E_TEST_SECRET= +ARK_SERVER_WALLET_PRIVATE_KEY= +ARK_SERVER_URL=http://localhost:7070 diff --git a/package.json b/package.json index 49da557a..c7d3999c 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,10 @@ "docker": "docker build . -t coinos-ui", "preview": "vite preview", "test:e2e": "playwright test", + "test:e2e:ark": "ENABLE_E2E_TEST_ENDPOINTS=true E2E_TEST_SECRET=local-e2e-secret ARK_SERVER_WALLET_PRIVATE_KEY=0ebe9a690651e9e08d7c2f4db106d8ec264725241fdb34c57aedb26155b0e027 playwright test test/e2e/ark-receive-from-server-wallet.spec.ts --project=chromium --workers=1", + "test:e2e:ark:headed": "ENABLE_E2E_TEST_ENDPOINTS=true E2E_TEST_SECRET=local-e2e-secret ARK_SERVER_WALLET_PRIVATE_KEY=0ebe9a690651e9e08d7c2f4db106d8ec264725241fdb34c57aedb26155b0e027 playwright test test/e2e/ark-receive-from-server-wallet.spec.ts --project=chromium --headed --workers=1", + "test:e2e:ark:watch": "ENABLE_E2E_TEST_ENDPOINTS=true E2E_TEST_SECRET=local-e2e-secret ARK_SERVER_WALLET_PRIVATE_KEY=0ebe9a690651e9e08d7c2f4db106d8ec264725241fdb34c57aedb26155b0e027 E2E_SLOWMO=350 E2E_PAUSE_AT_END=true playwright test test/e2e/ark-receive-from-server-wallet.spec.ts --project=chromium --headed --workers=1 --retries=0 --timeout=120000 --global-timeout=180000", + "test:e2e:ark:debug": "ENABLE_E2E_TEST_ENDPOINTS=true E2E_TEST_SECRET=local-e2e-secret ARK_SERVER_WALLET_PRIVATE_KEY=0ebe9a690651e9e08d7c2f4db106d8ec264725241fdb34c57aedb26155b0e027 PWDEBUG=1 playwright test test/e2e/ark-receive-from-server-wallet.spec.ts --project=chromium --headed --workers=1", "lint": "prettier --check .", "format": "prettier --write ." }, diff --git a/playwright.config.ts b/playwright.config.ts index f245b4e4..3b1be109 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,6 +3,7 @@ import { defineConfig, devices } from "@playwright/test"; const port = Number(process.env.E2E_PORT || 4173); const host = process.env.E2E_HOST || "127.0.0.1"; const baseURL = process.env.E2E_BASE_URL || `http://${host}:${port}`; +const slowMo = Number(process.env.E2E_SLOWMO || 0); export default defineConfig({ testDir: "./test/e2e", @@ -18,6 +19,7 @@ export default defineConfig({ use: { baseURL, trace: "on-first-retry", + launchOptions: slowMo > 0 ? { slowMo } : undefined, }, projects: [ { diff --git a/src/components/Account.svelte b/src/components/Account.svelte index 711c5ba7..bb9aec22 100644 --- a/src/components/Account.svelte +++ b/src/components/Account.svelte @@ -142,6 +142,8 @@ <div class="shadow-lg space-y-4 p-4 cursor-pointer hover:bg-base-200" aria-label="Payments" + data-testid="account-card" + data-account-type={accountType} role="button" tabindex="0" onclick={goPayments} @@ -180,7 +182,7 @@ class="contents" onclick={(e) => setAccount(e, "/invoice")} > - <button class="btn !w-auto flex-1 !bg-base-300"> + <button class="btn !w-auto flex-1 !bg-base-300" data-testid="account-receive"> <iconify-icon noobserver icon="ph:hand-coins-bold" diff --git a/src/components/InvoiceData.svelte b/src/components/InvoiceData.svelte index e0ec0f3e..5ba950d5 100644 --- a/src/components/InvoiceData.svelte +++ b/src/components/InvoiceData.svelte @@ -56,6 +56,7 @@ {#if !showQr || txt?.length <= 120} <div + data-testid="invoice-text" class="break-all text-center text-secondary text-xl flex gap-1 items-center justify-center" > {txt} diff --git a/src/components/WalletPass.svelte b/src/components/WalletPass.svelte index e0dcf961..11f6978a 100644 --- a/src/components/WalletPass.svelte +++ b/src/components/WalletPass.svelte @@ -37,6 +37,7 @@ {#if revealPassword} <input name="password" + data-testid="walletpass-input" type="text" required bind:value={password} @@ -46,6 +47,7 @@ {:else} <input name="password" + data-testid="walletpass-input" type="password" required bind:value={password} @@ -83,6 +85,7 @@ <div class="w-full flex justify-center gap-2"> <button type="button" + data-testid="walletpass-cancel" class="border rounded-2xl font-semibold mx-auto py-5 px-6 hover:opacity-80 mx-auto grow" onclick={cancel} onkeydown={cancel} @@ -91,6 +94,7 @@ </button> <button type="submit" + data-testid="walletpass-submit" class="border rounded-2xl font-semibold mx-auto py-5 px-6 hover:opacity-80 mx-auto grow" > <div class="my-auto">Submit</div> diff --git a/src/routes/test/ark/send/+server.ts b/src/routes/test/ark/send/+server.ts new file mode 100644 index 00000000..b077c793 --- /dev/null +++ b/src/routes/test/ark/send/+server.ts @@ -0,0 +1,76 @@ +import { env } from "$env/dynamic/private"; +import { PUBLIC_COINOS_URL } from "$env/static/public"; +import { error, json } from "@sveltejs/kit"; + +let walletPromise: Promise<any> | undefined; + +const getWallet = async () => { + if (!walletPromise) { + const privateKey = env.ARK_SERVER_WALLET_PRIVATE_KEY; + const arkServerUrl = env.ARK_SERVER_URL || "http://localhost:7070"; + + if (!privateKey) { + throw new Error("ARK_SERVER_WALLET_PRIVATE_KEY is not configured"); + } + + const { SingleKey, Wallet } = await import("@arkade-os/sdk"); + const identity = SingleKey.fromHex(privateKey); + walletPromise = Wallet.create({ identity, arkServerUrl }); + } + + return walletPromise; +}; + +export async function POST({ request, cookies, fetch }) { + // Never expose this in production. + if (env.NODE_ENV === "production") error(404, "Not found"); + + // Require explicit opt-in for local/integration test environments. + if (env.ENABLE_E2E_TEST_ENDPOINTS !== "true") { + error(404, "Not found"); + } + + const expectedSecret = env.E2E_TEST_SECRET; + const providedSecret = request.headers.get("x-test-secret"); + if (!expectedSecret || providedSecret !== expectedSecret) { + error(401, "Unauthorized"); + } + + const body = await request.json(); + const address = String(body?.address || "").trim(); + const amount = Number(body?.amount || 1000); + const iid = typeof body?.iid === "string" ? body.iid : undefined; + + if (!/^t?ark1[a-z0-9]+$/i.test(address)) { + error(400, "Invalid ark address"); + } + + if (!Number.isInteger(amount) || amount <= 0 || amount > 1_000_000) { + error(400, "Invalid amount"); + } + + const wallet = await getWallet(); + const txid = await wallet.sendBitcoin({ address, amount }); + + // Optional: mark an Ark invoice as received right after send. + if (iid) { + const token = cookies.get("token"); + if (!token) error(401, "Missing auth token cookie"); + + const response = await fetch(`${PUBLIC_COINOS_URL}/ark/receive`, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ iid, amount, hash: txid }), + }); + + if (!response.ok) { + error(response.status, await response.text()); + } + } + + return json({ ok: true, txid, amount, address, iid: iid || null }); +} diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 00000000..cbcc1fba --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +}
\ No newline at end of file diff --git a/test/e2e/ark-receive-from-server-wallet.spec.ts b/test/e2e/ark-receive-from-server-wallet.spec.ts new file mode 100644 index 00000000..80e28fd8 --- /dev/null +++ b/test/e2e/ark-receive-from-server-wallet.spec.ts @@ -0,0 +1,149 @@ +import { expect, test } from "@playwright/test"; + +const username = process.env.E2E_USERNAME || "bob"; +const password = process.env.E2E_PASSWORD || "pw"; +const testSecret = process.env.E2E_TEST_SECRET; +const apiBaseUrl = process.env.E2E_API_BASE_URL || "http://localhost:3119"; +const pauseAtEnd = process.env.E2E_PAUSE_AT_END === "true"; + +test("bob receives 1000 sats in ark vault after server wallet send", async ({ + page, +}) => { + test.setTimeout(90_000); + test.skip(!testSecret, "E2E_TEST_SECRET is required for /test/ark/send"); + + await page.goto("/login"); + await page.getByTestId("login-username").fill(username); + await page.locator('input[name="password"]').first().fill(password); + await page.getByTestId("login-submit").click(); + + await expect(page).toHaveURL(new RegExp(`/${username}(?:[/?#]|$)`)); + + const arkAccountCard = page + .locator('[data-testid="account-card"][data-account-type="ark"]'); + const arkAccountCount = await arkAccountCard.count(); + expect(arkAccountCount).toBeGreaterThan(0); + + let invoiceOpened = false; + + const walletPassInput = page.getByTestId("walletpass-input"); + + for (let i = 0; i < arkAccountCount; i++) { + const card = arkAccountCard.nth(i); + await card.scrollIntoViewIfNeeded(); + const accountHref = await card + .locator('a[href^="/account/"]') + .first() + .getAttribute("href"); + const accountId = accountHref?.split("/account/")[1]; + + await card.locator('a[href="/invoice"]').first().click(); + + // Ark vault actions can require wallet password to derive/unlock key. + if ( + await walletPassInput + .first() + .isVisible({ timeout: 2000 }) + .catch(() => false) + ) { + await walletPassInput.fill(password); + await page.getByTestId("walletpass-submit").click(); + } + + invoiceOpened = await page + .waitForURL(/\/invoice\/[^/?#]+/, { timeout: 5000 }) + .then(() => true) + .catch(() => false); + + // Fallback for brittle click bubbling in account cards: + // set the selected aid cookie and navigate to invoice route directly. + if (!invoiceOpened && accountId) { + await page.evaluate((aid) => { + document.cookie = `aid=${aid}; path=/; max-age=86400`; + }, accountId); + await page.goto("/invoice"); + invoiceOpened = await page + .waitForURL(/\/invoice\/[^/?#]+/, { timeout: 5000 }) + .then(() => true) + .catch(() => false); + } + + if (invoiceOpened) break; + + if (await page.getByTestId("walletpass-cancel").isVisible().catch(() => false)) { + await page.getByTestId("walletpass-cancel").click(); + } + } + + expect(invoiceOpened).toBeTruthy(); + await expect(page).toHaveURL(/\/invoice\/[^/?#]+/); + + const invoiceText = (await page.getByTestId("invoice-text").first().innerText()).trim(); + const addressMatch = invoiceText.match(/t?ark1[a-z0-9]+/i); + expect(addressMatch).toBeTruthy(); + const address = addressMatch![0]; + + const invoiceId = page.url().split("/invoice/")[1]?.split(/[/?#]/)[0]; + expect(invoiceId).toBeTruthy(); + + const sendResult = await page.evaluate( + async ({ address, testSecret, invoiceId }) => { + const res = await fetch("/test/ark/send", { + method: "POST", + headers: { + "content-type": "application/json", + "x-test-secret": testSecret, + }, + body: JSON.stringify({ address, amount: 1000, iid: invoiceId }), + }); + const text = await res.text(); + return { ok: res.ok, status: res.status, text }; + }, + { address, testSecret: testSecret!, invoiceId }, + ); + expect( + sendResult.ok, + `/test/ark/send failed (${sendResult.status}): ${sendResult.text}`, + ).toBeTruthy(); + + let paid = false; + let status: any; + + for (let i = 0; i < 30; i++) { + const statusResponse = await page.request.get(`${apiBaseUrl}/invoice/${invoiceId}`); + if (statusResponse.ok()) { + status = await statusResponse.json(); + if ((status?.received || 0) >= 1000 || (status?.pending || 0) >= 1000) { + paid = true; + break; + } + } + await page.waitForTimeout(1000); + } + + expect( + paid, + `ark payment did not appear in invoice status: ${JSON.stringify(status)}`, + ).toBeTruthy(); + + const paidRouteReached = await page + .waitForURL(new RegExp(`/invoice/${invoiceId}/paid(?:[/?#]|$)`), { + timeout: 20_000, + }) + .then(() => true) + .catch(() => false); + + if (pauseAtEnd) { + // Keep browser open for manual inspection at end of flow. + await page.pause(); + } + + expect( + paidRouteReached, + `expected redirect to /invoice/${invoiceId}/paid, current URL: ${page.url()}`, + ).toBeTruthy(); + + console.log( + `[e2e] ark invoice ${invoiceId} funded with 1000 sats (received=${status?.received || 0}, pending=${status?.pending || 0})`, + ); +}); |
