Merge remote-tracking branch 'origin/main' into fix/dpop-verification-challenge

This commit is contained in:
saltbo
2026-07-31 11:54:32 -04:00
5 changed files with 98 additions and 25 deletions
+5 -5
View File
@@ -18,6 +18,9 @@ import {
const LOCALHOST_RE = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/
const CLOUD_BASE_ORIGIN = new URL(process.env.ZPAN_CLOUD_URL ?? 'https://zpan-cloud-staging.saltbo.workers.dev').origin
const PUBLIC_BASE_ORIGIN = new URL(
process.env.E2E_PUBLIC_BASE_URL ?? process.env.E2E_BASE_URL ?? 'http://localhost:5185',
).origin
type CloudProduct = {
id: string
@@ -57,10 +60,7 @@ test.describe
await unbindCurrentCloudBinding()
})
test('@desktop covers pairing, Cloud store setup, gift-card credit redemption, and checkout', async ({
page,
baseURL,
}) => {
test('@desktop covers pairing, Cloud store setup, gift-card credit redemption, and checkout', async ({ page }) => {
test.setTimeout(420_000)
await signInAsAdmin(page)
@@ -85,7 +85,7 @@ test.describe
await redeemGiftCard(page, giftCard.code)
await expect.poll(() => getCreditBalance(page), { timeout: 20_000 }).toBeGreaterThanOrEqual(creditsBefore + 200)
const hasPublicCallbackUrl = Boolean(baseURL && !LOCALHOST_RE.test(new URL(baseURL).origin))
const hasPublicCallbackUrl = !LOCALHOST_RE.test(PUBLIC_BASE_ORIGIN)
if (!hasPublicCallbackUrl) {
test.info().annotations.push({
type: 'checkout-delivery-skipped',
+21 -2
View File
@@ -3,13 +3,13 @@
* The webServer is already running when this executes.
* Ensures an admin user and a storage backend exist.
*/
import { request as playwrightRequest, test as setup } from '@playwright/test'
import { expect, 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'
const localBaseUrl = process.env.E2E_LOCAL_BASE_URL ?? 'http://localhost:5185'
const publicBaseUrl = process.env.E2E_BASE_URL ?? localBaseUrl
const publicBaseUrl = process.env.E2E_PUBLIC_BASE_URL ?? process.env.E2E_BASE_URL ?? localBaseUrl
const defaultOrgQuota = process.env.E2E_DEFAULT_ORG_QUOTA ?? String(1024 * 1024 * 1024)
const storageConfig = {
@@ -170,6 +170,7 @@ setup('seed admin and storage', async () => {
const request = await playwrightRequest.newContext({ baseURL: localBaseUrl })
const headers = { Origin: localBaseUrl }
try {
await expectPublicCallbackReady()
prepareNodeDatabase()
let authResp = await request.post('/api/auth/sign-in/email', {
@@ -246,3 +247,21 @@ setup('seed admin and storage', async () => {
await request.dispose()
}
})
async function expectPublicCallbackReady() {
if (publicBaseUrl === localBaseUrl) return
const request = await playwrightRequest.newContext({ baseURL: publicBaseUrl })
try {
await expect
.poll(
async () => {
const response = await request.get('/api/health')
return response.ok() ? ((await response.json()) as { status?: string }).status : await response.text()
},
{ message: `public callback URL did not become ready: ${publicBaseUrl}`, timeout: 60_000 },
)
.toBe('ok')
} finally {
await request.dispose()
}
}
+18
View File
@@ -3,6 +3,24 @@ const CLIENT_TRANSPORT_FAILURE = /(?:\b502\b|ERR_(?:FAILED|TUNNEL_CONNECTION_FAI
const TUNNEL_CONTEXT_CANCELED = /(?:Incoming request ended abruptly|Request failed)[^\n]*context canceled/i
const QUICK_TUNNEL_REQUEST = /trycloudflare\.com/i
export class CloudE2eCommandError extends Error {
constructor(command, commandArgs, code, output) {
super(`${command} ${commandArgs.join(' ')} exited with ${code}`)
this.output = output
}
}
export function cloudE2eEndpoints(localBaseUrl, tunnelUrl) {
return {
browserBaseUrl: localBaseUrl,
publicBaseUrl: tunnelUrl ?? localBaseUrl,
}
}
export function cloudflaredQuickTunnelArgs(target) {
return ['tunnel', '--url', target, '--protocol', 'http2', '--no-autoupdate']
}
export function isRetryableQuickTunnelFailure({ commandOutput, tunnelOutput }) {
if (QUICK_TUNNEL_502.test(commandOutput)) return true
return (
+38 -1
View File
@@ -1,7 +1,44 @@
import { describe, expect, it } from 'vitest'
import { cloudE2eAttemptCount, isRetryableQuickTunnelFailure } from './cloud-e2e-resilience.mjs'
import {
CloudE2eCommandError,
cloudE2eAttemptCount,
cloudE2eEndpoints,
cloudflaredQuickTunnelArgs,
isRetryableQuickTunnelFailure,
} from './cloud-e2e-resilience.mjs'
describe('cloud E2E resilience', () => {
it('provides the command error type before the runner executes', () => {
const error = new CloudE2eCommandError('node', ['playwright', 'test'], 1, 'gateway response')
expect(error).toBeInstanceOf(Error)
expect(error).toBeInstanceOf(CloudE2eCommandError)
expect(error.message).toBe('node playwright test exited with 1')
expect(error.output).toBe('gateway response')
})
it('keeps browser traffic local and reserves the tunnel for public callbacks', () => {
expect(cloudE2eEndpoints('http://localhost:5185', 'https://callback.trycloudflare.com')).toEqual({
browserBaseUrl: 'http://localhost:5185',
publicBaseUrl: 'https://callback.trycloudflare.com',
})
expect(cloudE2eEndpoints('http://localhost:5185', null)).toEqual({
browserBaseUrl: 'http://localhost:5185',
publicBaseUrl: 'http://localhost:5185',
})
})
it('uses HTTP/2 instead of QUIC for the Quick Tunnel transport', () => {
expect(cloudflaredQuickTunnelArgs('http://localhost:5185')).toEqual([
'tunnel',
'--url',
'http://localhost:5185',
'--protocol',
'http2',
'--no-autoupdate',
])
})
it('retries a Cloudflare Quick Tunnel gateway page', () => {
expect(
isRetryableQuickTunnelFailure({
+16 -17
View File
@@ -2,7 +2,13 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { spawn } from 'node:child_process'
import { Resolver } from 'node:dns/promises'
import { createRequire } from 'node:module'
import { cloudE2eAttemptCount, isRetryableQuickTunnelFailure } from './cloud-e2e-resilience.mjs'
import {
CloudE2eCommandError,
cloudE2eAttemptCount,
cloudE2eEndpoints,
cloudflaredQuickTunnelArgs,
isRetryableQuickTunnelFailure,
} from './cloud-e2e-resilience.mjs'
const args = process.argv.slice(2)
const require = createRequire(import.meta.url)
@@ -49,7 +55,7 @@ for (let attempt = 1; attempt <= maxRunAttempts; attempt += 1) {
break
} catch (error) {
const retryable =
error instanceof CommandError &&
error instanceof CloudE2eCommandError &&
tunnel &&
isRetryableQuickTunnelFailure({
commandOutput: error.output,
@@ -67,17 +73,17 @@ for (let attempt = 1; attempt <= maxRunAttempts; attempt += 1) {
async function buildE2eEnv(tunnel) {
const tunnelHost = tunnel ? new URL(tunnel.url).hostname : ''
const tunnelIp = tunnel ? await waitForPublicTunnelIp(tunnelHost) : ''
const baseUrl = tunnel?.url ?? localBaseUrl
if (tunnel) await waitForPublicTunnelIp(tunnelHost)
const { browserBaseUrl, publicBaseUrl } = cloudE2eEndpoints(localBaseUrl, tunnel?.url)
return {
...cloudEnv,
E2E_BASE_URL: baseUrl,
E2E_BASE_URL: browserBaseUrl,
E2E_LOCAL_BASE_URL: localBaseUrl,
E2E_PUBLIC_BASE_URL: publicBaseUrl,
E2E_APP_PORT: String(appPort),
E2E_API_PORT: String(apiPort),
BETTER_AUTH_URL: baseUrl,
TRUSTED_ORIGINS: `${baseUrl},${localBaseUrl}`,
...(tunnel ? { E2E_CHROME_HOST_RESOLVER_RULES: `MAP ${tunnelHost} ${tunnelIp}` } : {}),
BETTER_AUTH_URL: publicBaseUrl,
TRUSTED_ORIGINS: `${publicBaseUrl},${localBaseUrl}`,
...s3MockEnv(),
...credentialsEnv,
...(runtime === 'cf' ? { E2E_RUNTIME: 'cf' } : {}),
@@ -141,7 +147,7 @@ async function startTunnel(target) {
}
function startTunnelOnce(target) {
const child = spawn(cloudflared, ['tunnel', '--url', target, '--no-autoupdate'], {
const child = spawn(cloudflared, cloudflaredQuickTunnelArgs(target), {
stdio: ['ignore', 'pipe', 'pipe'],
})
writeFileSync(pidFile, String(child.pid))
@@ -227,13 +233,6 @@ async function waitForPublicTunnelIp(hostname) {
throw new Error(`Timed out waiting for public tunnel DNS: ${hostname}`)
}
class CommandError extends Error {
constructor(command, commandArgs, code, output) {
super(`${command} ${commandArgs.join(' ')} exited with ${code}`)
this.output = output
}
}
function run(command, commandArgs, env = {}, captureOutput = false) {
return new Promise((resolve, reject) => {
const child = spawn(command, commandArgs, {
@@ -253,7 +252,7 @@ function run(command, commandArgs, env = {}, captureOutput = false) {
}
child.on('exit', (code) => {
if (code === 0) resolve()
else reject(new CommandError(command, commandArgs, code, output))
else reject(new CloudE2eCommandError(command, commandArgs, code, output))
})
})
}