diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01378997..6716f60d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,11 @@ jobs: path: node_modules key: node-modules-${{ hashFiles('package-lock.json') }} - run: npx playwright install --with-deps chromium - - run: npm run e2e + - name: Install cloudflared + run: | + curl -L --fail --output cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 + chmod +x cloudflared + - run: CLOUDFLARED_BIN=./cloudflared npm run e2e:cloud -- --runtime node - uses: actions/upload-artifact@v4 if: failure() with: @@ -82,9 +86,11 @@ jobs: path: node_modules key: node-modules-${{ hashFiles('package-lock.json') }} - run: npx playwright install --with-deps chromium - - run: echo "BETTER_AUTH_SECRET=ci-test-secret-that-is-at-least-32-chars" > .dev.vars - - run: npx wrangler d1 migrations apply DB --local - - run: E2E_RUNTIME=cf npm run e2e + - name: Install cloudflared + run: | + curl -L --fail --output cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 + chmod +x cloudflared + - run: CLOUDFLARED_BIN=./cloudflared npm run e2e:cloud:cf - uses: actions/upload-artifact@v4 if: failure() with: diff --git a/e2e/cloud-store.spec.ts b/e2e/cloud-store.spec.ts new file mode 100644 index 00000000..5b9b38ce --- /dev/null +++ b/e2e/cloud-store.spec.ts @@ -0,0 +1,406 @@ +import { + type APIRequestContext, + type Browser, + expect, + type Page, + request as playwrightRequest, + test, +} from '@playwright/test' +import { signInAsAdmin, signUpAndGoToFiles } from './helpers' + +const CLOUD_PRO_EMAIL = process.env.E2E_CLOUD_PRO_EMAIL ?? 'zpan-e2e-pro@zpan.test' +const CLOUD_PRO_PASSWORD = process.env.E2E_CLOUD_PRO_PASSWORD ?? 'ZPanStagingE2E!2026' +const LOCALHOST_RE = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/ + +type BindingState = { + bound: boolean + active?: boolean + account_email?: string +} + +type PairingInfo = { + code: string + pairingUrl: string +} + +type CloudProduct = { + id: string + name: string + prices: Array<{ id: string; currency: string; amount: number }> +} + +type CloudGiftCard = { + code: string +} + +type CloudOrder = { + id: string + paymentStatus: string + fulfillmentStatus: string +} + +type CloudLicense = { + id: string +} + +test.describe + .serial('ZPan Cloud store integration', () => { + test('@desktop covers pairing, admin store setup, gift-card wallet redemption, and wallet checkout', async ({ + page, + baseURL, + }) => { + test.setTimeout(180_000) + + await signInAsAdmin(page) + await ensureCloudBinding(page) + + const testId = Date.now() + const packageName = `E2E Cloud Pack ${testId}` + const product = await createOneTimePackage(page, packageName) + const giftCard = await createGiftCard(page) + await expectAdminProductVisibleInApi(page, packageName) + await expectAdminGiftCardVisibleInApi(page, giftCard.code) + + await page.goto('/storage') + await expect(page.getByRole('heading', { name: 'Storage' })).toBeVisible() + await expectStorefrontProductVisibleInApi(page, packageName) + + const walletBefore = await getWalletBalance(page) + await redeemGiftCard(page, giftCard.code) + await expect.poll(() => getWalletBalance(page), { timeout: 20_000 }).toBeGreaterThanOrEqual(walletBefore + 200) + + const hasPublicCallbackUrl = Boolean(baseURL && !LOCALHOST_RE.test(new URL(baseURL).origin)) + if (!hasPublicCallbackUrl) { + test.info().annotations.push({ + type: 'checkout-delivery-skipped', + description: 'Cloud staging cannot call back to a localhost ZPan instance.', + }) + return + } + + await postJson<{ orderId: string; url: string }>(page, '/api/store/checkouts', { + packageId: product.id, + currency: 'usd', + }) + + const orders = await expectOrderCreated(page, product.id) + await expectOrderFulfilled(page, orders.items[0].id) + await expectUserQuotaIncludesPackage(page, packageName) + }) + + test('@desktop creates Cloud store products and gift cards through admin UI forms', async ({ page }) => { + test.setTimeout(120_000) + + await signInAsAdmin(page) + await ensureCloudBinding(page) + + const testId = Date.now() + const packageName = `E2E UI Pack ${testId}` + await createOneTimePackageThroughUi(page, packageName) + + await expectAdminProductVisibleInApi(page, packageName) + + const giftCardCode = await createGiftCardThroughUi(page) + await expectAdminGiftCardVisibleInApi(page, giftCardCode) + }) + + test('@desktop lets a regular user list Cloud packages and redeem a gift card', async ({ + page, + browser, + baseURL, + }) => { + test.setTimeout(120_000) + + await signInAsAdmin(page) + await ensureCloudBinding(page) + + const testId = Date.now() + const packageName = `E2E User Pack ${testId}` + await createOneTimePackage(page, packageName) + const giftCard = await createGiftCard(page) + await expectStorefrontProductVisibleInApi(page, packageName) + + const userContext = await newBrowserContext(browser, baseURL) + try { + const userPage = await userContext.newPage() + await signUpAndGoToFiles(userPage) + await userPage.goto('/storage') + await expect(userPage.getByRole('heading', { name: 'Storage' })).toBeVisible() + await expectStorefrontProductVisibleInApi(userPage, packageName) + + const walletBefore = await getWalletBalance(userPage) + await redeemGiftCard(userPage, giftCard.code) + await expect + .poll(() => getWalletBalance(userPage), { timeout: 20_000 }) + .toBeGreaterThanOrEqual(walletBefore + 200) + } finally { + await userContext.close() + } + }) + }) + +async function ensureCloudBinding(page: Page) { + const current = await getJson(page, '/api/licensing/status') + if (current.bound && current.active) { + await enableCloudStore(page) + return + } + + const pairing = await postJson(page, '/api/licensing/pair') + await approvePairingInCloud(pairing) + + await expect + .poll(async () => (await getJson<{ status: string }>(page, `/api/licensing/pair/${pairing.code}/poll`)).status, { + timeout: 30_000, + }) + .toBe('approved') + + await expect + .poll(async () => { + const state = await getJson(page, '/api/licensing/status') + return state.bound && state.active + }) + .toBe(true) + await enableCloudStore(page) +} + +async function enableCloudStore(page: Page) { + await putJson(page, '/api/admin/store/settings', { enabled: true }) +} + +async function approvePairingInCloud(pairing: PairingInfo) { + const cloudOrigin = new URL(pairing.pairingUrl).origin + const cloudRequest = await playwrightRequest.newContext({ baseURL: cloudOrigin }) + try { + const signIn = await cloudRequest.post('/api/auth/sign-in/email', { + data: { email: CLOUD_PRO_EMAIL, password: CLOUD_PRO_PASSWORD }, + }) + expect(signIn.status()).toBe(200) + + await unbindCloudLicenses(cloudRequest) + + const approve = await cloudRequest.patch(`/api/pairings/${encodeURIComponent(pairing.code)}`, { + data: { action: 'approve' }, + }) + expect(approve.status()).toBe(200) + } finally { + await cloudRequest.dispose() + } +} + +async function unbindCloudLicenses(cloudRequest: APIRequestContext) { + const licenses = await cloudRequest.get('/api/accounts/me/licenses') + expect(licenses.status()).toBe(200) + const body = (await licenses.json()) as { data: CloudLicense[] } + for (const license of body.data) { + const response = await cloudRequest.delete(`/api/accounts/me/licenses/${encodeURIComponent(license.id)}`) + expect(response.status()).toBe(204) + } +} + +async function createOneTimePackage(page: Page, name: string) { + return postJson(page, '/api/admin/store/packages', { + type: 'zpan_quota', + name, + description: 'Playwright staging Cloud store package', + metadata: { + storageBytes: 1024 * 1024, + trafficBytes: 1024 * 1024, + validityDays: 7, + }, + prices: [{ currency: 'usd', amount: 100 }], + active: true, + sortOrder: 100_000, + }) +} + +async function createGiftCard(page: Page) { + const cards = await postJson(page, '/api/admin/store/gift-cards', { + amount: 200, + currency: 'usd', + count: 1, + }) + expect(cards.length).toBe(1) + return cards[0] +} + +async function createOneTimePackageThroughUi(page: Page, packageName: string) { + await page.goto('/admin/cloud-store') + await expect(page.getByRole('heading', { name: 'Storage Plans' })).toBeVisible() + await page.getByRole('button', { name: 'New plan' }).click() + const dialog = page.getByRole('dialog', { name: 'New plan' }) + await dialog.getByLabel('Plan name').fill(packageName) + await dialog.getByLabel('Description').fill('Created by Playwright through the admin form') + await dialog.getByRole('combobox', { name: 'Billing' }).click() + await page.getByRole('option', { name: 'Fixed-duration package' }).click() + await dialog.getByLabel('Valid days').fill('7') + await dialog.getByRole('spinbutton', { name: 'Storage quota' }).fill('1') + await dialog.getByRole('spinbutton', { name: 'Download traffic quota' }).fill('1') + await dialog.getByLabel('USD amount').fill('1') + + const response = page.waitForResponse( + (item) => item.url().includes('/api/admin/store/packages') && item.request().method() === 'POST', + ) + await dialog.getByRole('button', { name: 'Save' }).click() + expect((await response).status()).toBe(201) + await expect(dialog).not.toBeVisible({ timeout: 20_000 }) +} + +async function createGiftCardThroughUi(page: Page) { + await page.goto('/admin/cloud-store') + await expect(page.getByRole('heading', { name: 'Storage Plans' })).toBeVisible() + await page.getByRole('tab', { name: 'Gift Cards' }).click() + await page.getByRole('button', { name: 'Generate gift cards' }).click() + const dialog = page.getByRole('dialog', { name: 'Generate gift cards' }) + await dialog.getByLabel('Amount').fill('3') + + const response = page.waitForResponse( + (item) => item.url().includes('/api/admin/store/gift-cards') && item.request().method() === 'POST', + ) + await dialog.getByRole('button', { name: 'Generate' }).click() + const result = await response + expect(result.status()).toBe(201) + const cards = (await result.json()) as CloudGiftCard[] + expect(cards.length).toBe(1) + return cards[0].code +} + +async function expectAdminProductVisibleInApi(page: Page, packageName: string) { + await expect + .poll( + async () => { + const products = await getJson<{ items: CloudProduct[] }>(page, '/api/admin/store/packages') + return products.items.map((item) => item.name) + }, + { timeout: 60_000 }, + ) + .toContain(packageName) +} + +async function expectAdminGiftCardVisibleInApi(page: Page, code: string) { + await expect + .poll( + async () => { + const giftCards = await getJson<{ items: CloudGiftCard[] }>(page, '/api/admin/store/gift-cards') + return giftCards.items.map((item) => item.code) + }, + { timeout: 60_000 }, + ) + .toContain(code) +} + +async function redeemGiftCard(page: Page, code: string) { + await page.getByRole('button', { name: 'Wallet' }).click() + const walletDialog = page.getByRole('dialog', { name: 'Wallet' }) + await walletDialog.getByRole('button', { name: 'Redeem gift card' }).click() + const redeemDialog = page.getByRole('dialog', { name: 'Redeem gift card' }) + await redeemDialog.getByLabel('Gift card code').fill(code) + const redeemResponse = page.waitForResponse( + (response) => response.url().includes('/api/store/gift-cards/redeem') && response.request().method() === 'POST', + ) + await redeemDialog.getByRole('button', { name: 'Redeem' }).click() + expect((await redeemResponse).status()).toBe(200) + await expect(page.getByText(/Redeemed successfully/)).toBeVisible({ timeout: 20_000 }) + await page.keyboard.press('Escape') +} + +async function getWalletBalance(page: Page) { + const wallet = await getJson<{ balances: Array<{ availableAmount: number; currency: string }> }>( + page, + '/api/store/wallet', + ) + return wallet.balances.find((balance) => balance.currency === 'usd')?.availableAmount ?? 0 +} + +async function expectStorefrontProductVisibleInApi(page: Page, packageName: string) { + await expect + .poll( + async () => { + const products = await getJson<{ items: CloudProduct[] }>(page, '/api/store/packages') + return products.items.map((item) => item.name) + }, + { timeout: 60_000 }, + ) + .toContain(packageName) +} + +async function expectOrderCreated(page: Page, productId: string) { + const orders = await getJson<{ items: CloudOrder[] }>(page, '/api/store/orders') + expect(orders.items[0]).toEqual( + expect.objectContaining({ + paymentStatus: expect.stringMatching(/paid|pending|unpaid/), + }), + ) + + const adminOrders = await getJson<{ + items: Array }> + }>(page, '/api/admin/store/orders') + expect(adminOrders.items.some((order) => order.items.some((item) => item.productId === productId))).toBe(true) + return orders +} + +async function expectOrderFulfilled(page: Page, orderId: string) { + await expect + .poll( + async () => { + const orders = await getJson<{ items: CloudOrder[] }>(page, '/api/store/orders') + return orders.items.find((order) => order.id === orderId)?.fulfillmentStatus + }, + { timeout: 60_000 }, + ) + .toBe('fulfilled') +} + +async function expectUserQuotaIncludesPackage(page: Page, packageName: string) { + await expect + .poll( + async () => { + const quota = await getJson<{ + storagePlanName: string | null + storageExtraNames: string[] + trafficPlanName: string | null + trafficExtraNames: string[] + }>(page, '/api/quotas/me') + return [ + quota.storagePlanName, + quota.trafficPlanName, + ...quota.storageExtraNames, + ...quota.trafficExtraNames, + ].filter(Boolean) + }, + { timeout: 60_000 }, + ) + .toContain(packageName) +} + +async function newBrowserContext(browser: Browser, baseURL: string | undefined) { + return browser.newContext({ baseURL, locale: 'en-US' }) +} + +async function getJson(page: Page, url: string): Promise { + return browserJson(page, 'GET', url) +} + +async function postJson(page: Page, url: string, data?: unknown): Promise { + return browserJson(page, 'POST', url, data) +} + +async function putJson(page: Page, url: string, data?: unknown): Promise { + return browserJson(page, 'PUT', url, data) +} + +async function browserJson(page: Page, method: 'GET' | 'POST' | 'PUT', url: string, data?: unknown): Promise { + return page.evaluate( + async ({ method, url, data }) => { + const response = await fetch(url, { + method, + headers: data === undefined ? undefined : { 'Content-Type': 'application/json' }, + body: data === undefined ? undefined : JSON.stringify(data), + }) + const text = await response.text() + if (!response.ok) throw new Error(`${method} ${url} failed with ${response.status}: ${text}`) + return text ? JSON.parse(text) : null + }, + { method, url, data }, + ) as Promise +} diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 0cf3d9ce..27eebd03 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -3,7 +3,7 @@ * The webServer is already running when this executes. * Ensures an admin user and a storage backend exist. */ -import { test as setup } from '@playwright/test' +import { request as playwrightRequest, test as setup } from '@playwright/test' import Database from 'better-sqlite3' import { hashPassword } from '../server/lib/password' import { ADMIN_EMAIL, ADMIN_PASSWORD } from './helpers' @@ -77,7 +77,7 @@ function prepareNodeDatabase() { .prepare( ` UPDATE user - SET role = 'admin', updated_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER) + SET role = 'admin', email_verified = 1, updated_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER) WHERE email = ? `, ) @@ -89,7 +89,7 @@ function prepareNodeDatabase() { UPDATE account SET password = ?, updated_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER) WHERE provider_id = 'credential' - AND user_id = (SELECT id FROM user WHERE email = ?) + AND user_id IN (SELECT id FROM user WHERE email = ?) `, ) .run(passwordHash, ADMIN_EMAIL) @@ -97,56 +97,131 @@ function prepareNodeDatabase() { sqlite.close() } -setup('seed admin and storage', async ({ request }) => { +function ensureNodeStorage() { + if (process.env.E2E_RUNTIME === 'cf') return false + + const dbPath = process.env.DATABASE_URL || './zpan.db' + const sqlite = new Database(dbPath) + const storage = sqlite + .prepare( + ` + SELECT id, mode, capacity, used, status + FROM storages + ORDER BY created_at ASC + LIMIT 1 + `, + ) + .get() as StorageItem | undefined + + if (storage) { + sqlite + .prepare( + ` + UPDATE storages + SET title = ?, mode = ?, bucket = ?, endpoint = ?, region = ?, access_key = ?, secret_key = ?, + capacity = ?, status = ?, updated_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER) + WHERE id = ? + `, + ) + .run( + storageConfig.title, + storageConfig.mode, + storageConfig.bucket, + storageConfig.endpoint, + storageConfig.region, + storageConfig.accessKey, + storageConfig.secretKey, + storageConfig.capacity, + storageConfig.status, + storage.id, + ) + sqlite.close() + return true + } + + sqlite + .prepare( + ` + INSERT INTO storages ( + id, title, mode, bucket, endpoint, region, access_key, secret_key, + file_path, custom_host, capacity, used, status, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', '', ?, 0, ?, CAST(unixepoch('subsecond') * 1000 AS INTEGER), CAST(unixepoch('subsecond') * 1000 AS INTEGER)) + `, + ) + .run( + crypto.randomUUID(), + storageConfig.title, + storageConfig.mode, + storageConfig.bucket, + storageConfig.endpoint, + storageConfig.region, + storageConfig.accessKey, + storageConfig.secretKey, + storageConfig.capacity, + storageConfig.status, + ) + sqlite.close() + return true +} + +setup('seed admin and storage', async () => { + const request = await playwrightRequest.newContext({ baseURL: 'http://localhost:5173' }) const headers = { Origin: 'http://localhost:5173' } - prepareNodeDatabase() - - let authResp = await request.post('/api/auth/sign-in/email', { - headers, - data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD }, - }) - - if (!authResp.ok()) { - await request.post('/api/auth/sign-up/email', { - headers, - data: { name: 'E2E Admin', email: ADMIN_EMAIL, username: 'e2eadmin', password: ADMIN_PASSWORD }, - }) + try { prepareNodeDatabase() - authResp = await request.post('/api/auth/sign-in/email', { + + let authResp = await request.post('/api/auth/sign-in/email', { headers, data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD }, }) + if (!authResp.ok()) { - console.warn('[setup] could not authenticate admin') - return - } - } - - // E2E specs rely on self-service sign-up to create isolated users. Force the - // local test environment into OPEN mode so existing dev DB settings do not - // make the suite depend on invite codes. - // Check if storage already exists - const list = await request.get('/api/admin/storages', { headers }) - if (list.ok()) { - const data = (await list.json()) as { items?: StorageItem[] } - const storages = data.items ?? [] - if (storages.some(isAvailablePrivateStorage)) return - - const existing = storages[0] - if (existing) { - const resp = await request.put(`/api/admin/storages/${existing.id}`, { + await request.post('/api/auth/sign-up/email', { headers, - data: storageConfig, + data: { name: 'E2E Admin', email: ADMIN_EMAIL, username: 'e2eadmin', password: ADMIN_PASSWORD }, }) - if (!resp.ok()) throw new Error(`could not update E2E storage: ${resp.status()}`) - return + prepareNodeDatabase() + authResp = await request.post('/api/auth/sign-in/email', { + headers, + data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD }, + }) + if (!authResp.ok()) { + console.warn('[setup] could not authenticate admin') + return + } } - } - // Seed storage - const storageResp = await request.post('/api/admin/storages', { - headers, - data: storageConfig, - }) - if (!storageResp.ok()) throw new Error(`could not create E2E storage: ${storageResp.status()}`) + if (ensureNodeStorage()) return + + // E2E specs rely on self-service sign-up to create isolated users. Force the + // local test environment into OPEN mode so existing dev DB settings do not + // make the suite depend on invite codes. + // Check if storage already exists + const list = await request.get('/api/admin/storages', { headers }) + if (list.ok()) { + const data = (await list.json()) as { items?: StorageItem[] } + const storages = data.items ?? [] + if (storages.some(isAvailablePrivateStorage)) return + + const existing = storages[0] + if (existing) { + const resp = await request.put(`/api/admin/storages/${existing.id}`, { + headers, + data: storageConfig, + }) + if (!resp.ok()) throw new Error(`could not update E2E storage: ${resp.status()}`) + return + } + } + + // Seed storage + const storageResp = await request.post('/api/admin/storages', { + headers, + data: storageConfig, + }) + if (!storageResp.ok()) throw new Error(`could not create E2E storage: ${storageResp.status()}`) + } finally { + await request.dispose() + } }) diff --git a/package.json b/package.json index 1b023ba4..f96723de 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,9 @@ "lint:fix": "biome check --write .", "prepare": "husky", "format": "biome format --write .", - "e2e": "playwright test" + "e2e": "playwright test", + "e2e:cloud": "node scripts/run-cloud-e2e.mjs", + "e2e:cloud:cf": "node scripts/run-cloud-e2e.mjs --runtime cf" }, "engines": { "node": ">=24" diff --git a/playwright.config.ts b/playwright.config.ts index 78596535..93701dd5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test' const isCF = process.env.E2E_RUNTIME === 'cf' const envFile = process.env.CI ? '' : '--env-file=.dev.vars' +const chromeHostResolverRules = process.env.E2E_CHROME_HOST_RESOLVER_RULES const nodeServers = [ { @@ -33,8 +34,11 @@ export default defineConfig({ retries: process.env.CI ? 1 : 0, reporter: process.env.CI ? 'github' : 'list', use: { - baseURL: 'http://localhost:5173', + baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:5173', headless: true, + launchOptions: chromeHostResolverRules + ? { args: [`--host-resolver-rules=${chromeHostResolverRules}`] } + : undefined, trace: 'on-first-retry', }, projects: [ diff --git a/scripts/run-cloud-e2e.mjs b/scripts/run-cloud-e2e.mjs new file mode 100644 index 00000000..3e70aa92 --- /dev/null +++ b/scripts/run-cloud-e2e.mjs @@ -0,0 +1,131 @@ +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { spawn } from 'node:child_process' +import { Resolver } from 'node:dns/promises' + +const args = process.argv.slice(2) +const runtime = valueAfter('--runtime') ?? process.env.E2E_RUNTIME ?? 'node' +const project = valueAfter('--project') ?? 'desktop' +const cloudflared = process.env.CLOUDFLARED_BIN ?? 'cloudflared' +const tunnelUrlPattern = /https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/ +const publicDns = new Resolver() +publicDns.setServers(['1.1.1.1', '1.0.0.1']) + +const cloudEnv = { + ZPAN_CLOUD_URL: process.env.ZPAN_CLOUD_URL ?? 'https://zpan-cloud-staging.saltbo.workers.dev', + VITE_ZPAN_CLOUD_URL: process.env.VITE_ZPAN_CLOUD_URL ?? 'https://zpan-cloud-staging.saltbo.workers.dev', +} + +const tunnel = await startTunnel('http://localhost:5173') +const tunnelHost = new URL(tunnel.url).hostname +const tunnelIp = await waitForPublicTunnelIp(tunnelHost) +const tunnelEnv = { + E2E_BASE_URL: tunnel.url, + BETTER_AUTH_URL: tunnel.url, + TRUSTED_ORIGINS: `${tunnel.url},http://localhost:5173`, + E2E_CHROME_HOST_RESOLVER_RULES: `MAP ${tunnelHost} ${tunnelIp}`, +} +const e2eEnv = { + ...cloudEnv, + ...tunnelEnv, + ...(runtime === 'cf' ? { E2E_RUNTIME: 'cf' } : {}), +} + +if (runtime === 'cf') { + writeDevVars(e2eEnv) + await run('npx', ['wrangler', 'd1', 'migrations', 'apply', 'DB', '--local'], e2eEnv) +} + +try { + await run('npx', ['playwright', 'test', 'cloud-store.spec.ts', `--project=${project}`], e2eEnv) +} finally { + try { + tunnel.process.kill() + } catch {} + if (existsSync('.cloudflared.pid')) { + const pid = Number(readFileSync('.cloudflared.pid', 'utf8')) + if (Number.isInteger(pid)) { + try { + process.kill(pid) + } catch {} + } + rmSync('.cloudflared.pid', { force: true }) + } +} + +function valueAfter(flag) { + const index = args.indexOf(flag) + return index === -1 ? null : args[index + 1] +} + +function startTunnel(target) { + const child = spawn(cloudflared, ['tunnel', '--url', target, '--no-autoupdate'], { + stdio: ['ignore', 'pipe', 'pipe'], + }) + writeFileSync('.cloudflared.pid', String(child.pid)) + + return new Promise((resolve, reject) => { + let tunnelUrl = null + let registered = false + const timeout = setTimeout(() => { + child.kill() + reject(new Error('Timed out waiting for cloudflared tunnel registration')) + }, 30_000) + + function handleOutput(chunk) { + const text = chunk.toString() + process.stdout.write(text) + const match = text.match(tunnelUrlPattern) + if (match) tunnelUrl = match[0] + if (text.includes('Registered tunnel connection')) registered = true + if (!tunnelUrl || !registered) return + clearTimeout(timeout) + resolve({ process: child, url: tunnelUrl }) + } + + child.stdout.on('data', handleOutput) + child.stderr.on('data', handleOutput) + child.on('exit', (code) => { + clearTimeout(timeout) + reject(new Error(`cloudflared exited before tunnel URL was available: ${code}`)) + }) + }) +} + +function writeDevVars(env) { + const lines = [ + `BETTER_AUTH_SECRET=${process.env.BETTER_AUTH_SECRET ?? 'ci-test-secret-that-is-at-least-32-chars'}`, + `ZPAN_CLOUD_URL=${env.ZPAN_CLOUD_URL}`, + `VITE_ZPAN_CLOUD_URL=${env.VITE_ZPAN_CLOUD_URL}`, + `BETTER_AUTH_URL=${env.BETTER_AUTH_URL}`, + `TRUSTED_ORIGINS=${env.TRUSTED_ORIGINS}`, + '', + ] + writeFileSync('.dev.vars', lines.join('\n')) +} + +async function waitForPublicTunnelIp(hostname) { + const deadline = Date.now() + 120_000 + while (Date.now() < deadline) { + try { + const addresses = await publicDns.resolve4(hostname) + return addresses[0] + } catch { + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + } + throw new Error(`Timed out waiting for public tunnel DNS: ${hostname}`) +} + +function run(command, commandArgs, env = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, commandArgs, { + stdio: 'inherit', + env: { ...process.env, ...env }, + shell: process.platform === 'win32', + }) + child.on('exit', (code) => { + if (code === 0) resolve() + else reject(new Error(`${command} ${commandArgs.join(' ')} exited with ${code}`)) + }) + }) +} diff --git a/server/licensing/e2e-cloud-integration.test.ts b/server/licensing/e2e-cloud-integration.test.ts index 8feaa95a..6195a4e0 100644 --- a/server/licensing/e2e-cloud-integration.test.ts +++ b/server/licensing/e2e-cloud-integration.test.ts @@ -2,7 +2,7 @@ /** * E2E Integration Test: zpan ↔ zpan-cloud licensing flow. * - * This test exercises the REAL cloud API at cloud.zpan.space (or workers.dev) + * This test exercises the REAL staging cloud API * to verify the full licensing lifecycle: * 1. Pairing creation → cloud returns device code * 2. Pairing poll → pending status @@ -29,7 +29,8 @@ import { getOrCreateInstanceId } from './instance-id' import { createLicenseBinding, loadLicenseState } from './license-state' import { PUBLIC_KEYS } from './public-keys' -const CLOUD_BASE_URL = process.env.ZPAN_CLOUD_URL ?? 'https://zpan-cloud.saltbo.workers.dev' +const CLOUD_BASE_URL = process.env.ZPAN_CLOUD_URL ?? 'https://zpan-cloud-staging.saltbo.workers.dev' +const CLOUD_BASE_ORIGIN = new URL(CLOUD_BASE_URL).origin const { secretKey: E2E_SECRET, publicKey: E2E_PUBLIC } = generateKeys('public') function nowSec(): number { @@ -70,6 +71,7 @@ describe('E2E: zpan-cloud API contract', () => { pairingUrl: expect.stringContaining('/pair?code='), expiresAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), }) + expect(new URL(result.pairingUrl).origin).toBe(CLOUD_BASE_ORIGIN) }) it('GET /api/pairings/:code returns pending for a fresh code', async () => { diff --git a/server/middleware/require-feature.ts b/server/middleware/require-feature.ts index 5ceca8e7..80e39ca9 100644 --- a/server/middleware/require-feature.ts +++ b/server/middleware/require-feature.ts @@ -1,15 +1,26 @@ import type { ProFeature } from '@shared/types' +import type { Context } from 'hono' import { createMiddleware } from 'hono/factory' import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' import { hasFeature, loadBindingState } from '../licensing/has-feature' import { normalizeHost } from '../licensing/verify' import type { Env } from './platform' +function configuredPublicHost(c: Context): string | null { + const value = c.get('platform').getEnv('ZPAN_PUBLIC_ORIGIN') ?? c.get('platform').getEnv('BETTER_AUTH_URL') + if (!value) return null + try { + return new URL(value).host + } catch { + return null + } +} + export function requireFeature(name: ProFeature) { return createMiddleware(async (c, next) => { const db = c.get('platform').db const cloudBaseUrl = c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT - const currentHost = normalizeHost(c.req.header('host')) ?? new URL(c.req.url).host + const currentHost = configuredPublicHost(c) ?? normalizeHost(c.req.header('host')) ?? new URL(c.req.url).host const state = await loadBindingState(db, { currentHost, cloudBaseUrl }) if (!hasFeature(name, state)) { return c.json({ error: 'feature_not_available', feature: name, upgrade_url: '/settings/billing' }, 402) diff --git a/server/routes/licensing-admin.ts b/server/routes/licensing-admin.ts index e72a1f68..307384d5 100644 --- a/server/routes/licensing-admin.ts +++ b/server/routes/licensing-admin.ts @@ -16,7 +16,22 @@ function getCloudBaseUrl(c: { get(key: 'platform'): { getEnv(k: string): string return c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT } -function getInstanceOrigin(c: { req: { url: string; header(name: string): string | undefined } }): string { +function configuredPublicOrigin(c: { get(key: 'platform'): { getEnv(k: string): string | undefined } }): string | null { + const value = c.get('platform').getEnv('ZPAN_PUBLIC_ORIGIN') ?? c.get('platform').getEnv('BETTER_AUTH_URL') + if (!value) return null + try { + return new URL(value).origin + } catch { + return null + } +} + +function getInstanceOrigin(c: { + get(key: 'platform'): { getEnv(k: string): string | undefined } + req: { url: string; header(name: string): string | undefined } +}): string { + const configured = configuredPublicOrigin(c) + if (configured) return configured const requestUrl = new URL(c.req.url) const forwardedProto = c.req.header('x-forwarded-proto') const forwardedHost = c.req.header('x-forwarded-host') ?? c.req.header('host') @@ -28,7 +43,12 @@ function getInstanceOrigin(c: { req: { url: string; header(name: string): string return requestUrl.origin } -function getRequestHost(c: { req: { url: string; header(name: string): string | undefined } }): string { +function getRequestHost(c: { + get(key: 'platform'): { getEnv(k: string): string | undefined } + req: { url: string; header(name: string): string | undefined } +}): string { + const configured = configuredPublicOrigin(c) + if (configured) return new URL(configured).host const forwardedHost = c.req.header('x-forwarded-host') ?? c.req.header('host') return normalizeHost(forwardedHost) ?? new URL(c.req.url).host } diff --git a/server/routes/licensing.ts b/server/routes/licensing.ts index 3e78c832..914f3f43 100644 --- a/server/routes/licensing.ts +++ b/server/routes/licensing.ts @@ -9,6 +9,16 @@ import type { Env } from '../middleware/platform' import { syncPendingCloudTrafficReports } from '../services/cloud-traffic-metering' import { runLicensingRefresh } from '../services/licensing-refresh-runner' +function configuredPublicHost(c: Context): string | null { + const value = c.get('platform').getEnv('ZPAN_PUBLIC_ORIGIN') ?? c.get('platform').getEnv('BETTER_AUTH_URL') + if (!value) return null + try { + return new URL(value).host + } catch { + return null + } +} + function secretsMatch(provided: string, expected: string): boolean { if (provided.length !== expected.length) return false const enc = new TextEncoder() @@ -20,7 +30,9 @@ const app = new Hono() const db = c.get('platform').db const cloudBaseUrl = c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT const currentHost = - normalizeHost(c.req.header('x-forwarded-host') ?? c.req.header('host')) ?? new URL(c.req.url).host + configuredPublicHost(c) ?? + normalizeHost(c.req.header('x-forwarded-host') ?? c.req.header('host')) ?? + new URL(c.req.url).host const state = await loadBindingState(db, { currentHost, cloudBaseUrl }) return c.json(state satisfies BindingState) }) diff --git a/vite.config.ts b/vite.config.ts index b72a3e66..d7746eb6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -29,6 +29,7 @@ export default defineConfig(({ mode }) => ({ }, server: { port: 5173, + allowedHosts: process.env.E2E_BASE_URL ? true : undefined, ...(mode === 'node' ? { proxy: {