diff --git a/apps/server/worker/auth/otp.ts b/apps/server/worker/auth/otp.ts index 192ee63d..77921515 100644 --- a/apps/server/worker/auth/otp.ts +++ b/apps/server/worker/auth/otp.ts @@ -1,5 +1,5 @@ // otp.ts:Email OTP(6 位,10min)+ WhatsApp/SMS OTP(6 位,5min)passwordless 认证 handler。 -// OTP 存 HMAC-SHA256 哈希(verificationTokens.codeHash),验证后立即删(01 章 4)。 +// OTP 存 HMAC-SHA256 哈希(verificationTokens.codeHash),验证后以 consumedAt CAS 标记消费。 // 限流:同一邮箱/手机 1/min + 5/hour(RateLimitStore DO,anti-abuse rule)。 // 最多 5 次错误后 token 作废(01 章 4:Email OTP 5 次错误后作废)。 // Phone OTP 国家白名单默认 US/CA,租户可扩展(01 章 4)。 diff --git a/docs/api-contracts.md b/docs/api-contracts.md index 102ed16a..825291af 100644 --- a/docs/api-contracts.md +++ b/docs/api-contracts.md @@ -43,8 +43,8 @@ Authentication: public auth endpoints or a cookie session. The public product se | POST | `/auth/verify-email` | PASS | `{ ok: true }` | | POST | `/auth/resend-verification` | PASS | enumeration-resistant success response | | POST | `/auth/magic-link/send` | PASS | enumeration-resistant success response | -| GET | `/auth/magic-link/verify` | PASS | Mutation-free compatibility redirect from a legacy query-string token to the Hosted UI `/magic-link#token=...` confirmation page; a missing or unresolvable credential redirects to the tokenless branded error state instead of rendering API JSON; never consumes the token or writes a session | -| POST | `/auth/magic-link/verify` | PASS | Explicit user-confirmed token consumption; on success writes the session cookie and returns `{ redirectUrl }` | +| GET | `/auth/magic-link/verify` | PASS | Mutation-free compatibility redirect from a legacy query-string token to the Hosted UI `/magic-link#token=...` confirmation page; a missing or unresolvable credential redirects to the tokenless branded error state instead of rendering API JSON; never consumes the token or writes a session | +| POST | `/auth/magic-link/verify` | PASS | Explicit user-confirmed token consumption; on success writes the session cookie and returns `{ redirectUrl }` | | POST | `/auth/otp/email/send` | PASS | enumeration-resistant success response | | POST | `/auth/otp/email/verify` | PASS | `{ redirectUrl? }`; on success writes the session cookie | | POST | `/auth/otp/whatsapp/send` | PASS | When the provider is ready, writes the OTP token and enqueues it to `WHATSAPP_QUEUE`; when the provider is not configured, returns the enumeration-resistant response and writes a policy denial audit event | @@ -75,7 +75,7 @@ Hosted Auth policy rules: - The Magic Link send endpoint distinguishes `login` from `user_creation` by whether the email already exists. An existing user MUST satisfy `magicLink.allowLogin`, a new user MUST satisfy `magicLink.allowUserCreation`; a policy denial still returns the enumeration-resistant success response and sends no mail. - The Magic Link JWT carries `action = login | user_creation`; after consuming the token the verify endpoint re-validates policy against that action and marks the primary email as verified. - The Email OTP send endpoint distinguishes `login` from `user_creation` by whether the email already exists. An existing user MUST satisfy `emailOtp.allowLogin`, a new user MUST satisfy `emailOtp.allowUserCreation`; a policy denial still returns the enumeration-resistant success response and sends no code. -- On successful Email OTP verification the one-time token is deleted, the primary email is marked verified, and a session is issued. +- On successful Email OTP verification the one-time token is atomically marked consumed, the primary email is marked verified, and a session is issued. - When `forceSso = true` and enterprise SSO has `enabled + allowLogin + domainDiscovery`, the Hosted UI shows only the enterprise SSO email discovery form. - `POST /sso/hrd` queries a verified org domain only when enterprise SSO has `enabled + allowLogin + domainDiscovery`, and it applies both the Hosted Auth global `allowedEmailDomains` / `blockedEmailDomains` and the enterprise SSO `allowedEmailDomains` / `blockedEmailDomains`; when enterprise SSO or domain discovery is denied by policy it returns `{ connectionId: null }` and writes `auth.policy_denied`. - OIDC RP JIT and SAML JIT apply both the Hosted Auth global domain policy and the enterprise SSO domain policy. When either policy denies, no user is created, no existing user is synced, and no membership is written. diff --git a/docs/deployment.md b/docs/deployment.md index cc3f6a7c..d36bcd22 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -837,19 +837,19 @@ work. Those need the per-feature smokes below. Smokes that involve a real provider need real credentials or a code that was really received. Always pass that kind of input through a **file variable** rather than a plain environment variable, so it never lands in shell history, the process environment or command logs. -| Command | Covers | Required input | -| ----------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| `pnpm smoke:production:auth` | Email OTP real cookie plus `/v1/me` | `XID_PRODUCTION_EMAIL` | -| `pnpm smoke:production:browser` | Headless Chrome checks on DOM, console and navigation | `XID_PRODUCTION_EMAIL` | -| `pnpm smoke:production:magic-link-send` | Magic Link send and audit path | `XID_PRODUCTION_EMAIL` | -| `pnpm smoke:production:magic-link` | Magic Link sign-in from a real click | `XID_PRODUCTION_MAGIC_LINK_URL_FILE` | -| `pnpm smoke:production:whatsapp-otp-send` | WhatsApp OTP send side | `XID_PRODUCTION_PHONE_OTP_PHONE_FILE` (SKIP when no provider is configured) | -| `pnpm smoke:production:sms-otp-send` | SMS OTP send side | Same as above | -| `pnpm smoke:production:whatsapp-otp` | Full WhatsApp OTP verification | `XID_PRODUCTION_PHONE_OTP_ORGANIZATION_ID` plus phone file plus code file | -| `pnpm smoke:production:sms-otp` | Full SMS OTP verification | Same as above | -| `pnpm smoke:production:social-oauth` | Social OAuth real callback | `XID_PRODUCTION_SOCIAL_OAUTH_CALLBACK_URL_FILE` | -| `pnpm smoke:production:enterprise-sso` | Enterprise SSO real IdP callback | `..._CALLBACK_URL_FILE` for OIDC; `..._SAML_RESPONSE_FILE` plus `..._CONNECTION_ID` for SAML | -| `pnpm smoke:production:mfa-sms` | MFA SMS step-up | `XID_PRODUCTION_MFA_SMS_COOKIE_FILE` plus `XID_PRODUCTION_MFA_SMS_CODE_FILE` | +| Command | Covers | Required input | +| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `pnpm smoke:production:auth` | Email OTP real cookie plus `/v1/me` | `XID_PRODUCTION_EMAIL` | +| `pnpm smoke:production:browser` | Chrome checks on DOM, console and navigation; set `XID_PRODUCTION_BROWSER_HEADED=1` when production Turnstile rejects headless automation. On macOS, headed Chrome launches in the background and closes through CDP. | `XID_PRODUCTION_EMAIL` | +| `pnpm smoke:production:magic-link-send` | Magic Link send and audit path | `XID_PRODUCTION_EMAIL` | +| `pnpm smoke:production:magic-link` | Magic Link sign-in from a real click | `XID_PRODUCTION_MAGIC_LINK_URL_FILE` | +| `pnpm smoke:production:whatsapp-otp-send` | WhatsApp OTP send side | `XID_PRODUCTION_PHONE_OTP_PHONE_FILE` (SKIP when no provider is configured) | +| `pnpm smoke:production:sms-otp-send` | SMS OTP send side | Same as above | +| `pnpm smoke:production:whatsapp-otp` | Full WhatsApp OTP verification | `XID_PRODUCTION_PHONE_OTP_ORGANIZATION_ID` plus phone file plus code file | +| `pnpm smoke:production:sms-otp` | Full SMS OTP verification | Same as above | +| `pnpm smoke:production:social-oauth` | Social OAuth real callback | `XID_PRODUCTION_SOCIAL_OAUTH_CALLBACK_URL_FILE` | +| `pnpm smoke:production:enterprise-sso` | Enterprise SSO real IdP callback | `..._CALLBACK_URL_FILE` for OIDC; `..._SAML_RESPONSE_FILE` plus `..._CONNECTION_ID` for SAML | +| `pnpm smoke:production:mfa-sms` | MFA SMS step-up | `XID_PRODUCTION_MFA_SMS_COOKIE_FILE` plus `XID_PRODUCTION_MFA_SMS_CODE_FILE` | The correct order for a full Magic Link smoke is: run `magic-link-send` first, take the link from **this** run out of the real inbox into a temporary file, then run `magic-link`. Otherwise you end up with mismatched evidence: a new email was sent, but an older link was consumed. diff --git a/docs/zh-Hans/deployment.md b/docs/zh-Hans/deployment.md index 31c433a3..7cf65a2e 100644 --- a/docs/zh-Hans/deployment.md +++ b/docs/zh-Hans/deployment.md @@ -1,4 +1,4 @@ - + > Translation of `docs/deployment.md` at commit `5d55b0c`. The English version is authoritative. > 本文是 [`docs/deployment.md`](../deployment.md) 的中文翻译,英文版为准。两版不一致时以英文版为准。 @@ -807,19 +807,19 @@ smoke。 涉及真实 provider 的 smoke 需要真实凭证或真实收到的验证码。这类输入一律用 **file 变量**传入,不用直接环境变量,避免出现在 shell history、进程环境或命令日志中。 -| 命令 | 覆盖 | 必需输入 | -| ----------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------- | -| `pnpm smoke:production:auth` | Email OTP 真实 cookie + `/v1/me` | `XID_PRODUCTION_EMAIL` | -| `pnpm smoke:production:browser` | headless Chrome 验证 DOM、console、导航 | `XID_PRODUCTION_EMAIL` | -| `pnpm smoke:production:magic-link-send` | Magic Link 发送与审计链路 | `XID_PRODUCTION_EMAIL` | -| `pnpm smoke:production:magic-link` | Magic Link 真实点击登录 | `XID_PRODUCTION_MAGIC_LINK_URL_FILE` | -| `pnpm smoke:production:whatsapp-otp-send` | WhatsApp OTP 发送侧 | `XID_PRODUCTION_PHONE_OTP_PHONE_FILE`(provider 未配置时 SKIP) | -| `pnpm smoke:production:sms-otp-send` | SMS OTP 发送侧 | 同上 | -| `pnpm smoke:production:whatsapp-otp` | WhatsApp OTP 完整验证 | `XID_PRODUCTION_PHONE_OTP_ORGANIZATION_ID` + phone file + code file | -| `pnpm smoke:production:sms-otp` | SMS OTP 完整验证 | 同上 | -| `pnpm smoke:production:social-oauth` | Social OAuth 真实 callback | `XID_PRODUCTION_SOCIAL_OAUTH_CALLBACK_URL_FILE` | -| `pnpm smoke:production:enterprise-sso` | 企业 SSO 真实 IdP callback | OIDC 用 `..._CALLBACK_URL_FILE`;SAML 用 `..._SAML_RESPONSE_FILE` + `..._CONNECTION_ID` | -| `pnpm smoke:production:mfa-sms` | MFA SMS step-up | `XID_PRODUCTION_MFA_SMS_COOKIE_FILE` + `XID_PRODUCTION_MFA_SMS_CODE_FILE` | +| 命令 | 覆盖 | 必需输入 | +| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `pnpm smoke:production:auth` | Email OTP 真实 cookie + `/v1/me` | `XID_PRODUCTION_EMAIL` | +| `pnpm smoke:production:browser` | Chrome 验证 DOM、console、导航;生产 Turnstile 拒绝 headless 自动化时设置 `XID_PRODUCTION_BROWSER_HEADED=1`。macOS 会在后台启动 headed Chrome,并通过 CDP 关闭。 | `XID_PRODUCTION_EMAIL` | +| `pnpm smoke:production:magic-link-send` | Magic Link 发送与审计链路 | `XID_PRODUCTION_EMAIL` | +| `pnpm smoke:production:magic-link` | Magic Link 真实点击登录 | `XID_PRODUCTION_MAGIC_LINK_URL_FILE` | +| `pnpm smoke:production:whatsapp-otp-send` | WhatsApp OTP 发送侧 | `XID_PRODUCTION_PHONE_OTP_PHONE_FILE`(provider 未配置时 SKIP) | +| `pnpm smoke:production:sms-otp-send` | SMS OTP 发送侧 | 同上 | +| `pnpm smoke:production:whatsapp-otp` | WhatsApp OTP 完整验证 | `XID_PRODUCTION_PHONE_OTP_ORGANIZATION_ID` + phone file + code file | +| `pnpm smoke:production:sms-otp` | SMS OTP 完整验证 | 同上 | +| `pnpm smoke:production:social-oauth` | Social OAuth 真实 callback | `XID_PRODUCTION_SOCIAL_OAUTH_CALLBACK_URL_FILE` | +| `pnpm smoke:production:enterprise-sso` | 企业 SSO 真实 IdP callback | OIDC 用 `..._CALLBACK_URL_FILE`;SAML 用 `..._SAML_RESPONSE_FILE` + `..._CONNECTION_ID` | +| `pnpm smoke:production:mfa-sms` | MFA SMS step-up | `XID_PRODUCTION_MFA_SMS_COOKIE_FILE` + `XID_PRODUCTION_MFA_SMS_CODE_FILE` | Magic Link 完整 smoke 的正确顺序是:先跑 `magic-link-send`,从真实邮件里取**这次**最新链接写入临时文件,再跑 `magic-link`。否则会出现"发了一封新邮件却消费了另一封旧链接"的证据错位。 diff --git a/tests/production/harness/console-route-checks.mjs b/tests/production/harness/console-route-checks.mjs new file mode 100644 index 00000000..934bddf0 --- /dev/null +++ b/tests/production/harness/console-route-checks.mjs @@ -0,0 +1,74 @@ +export const INSTANCE_CONSOLE_ROUTE_CHECKS = [ + { + path: '/console', + expectedPathPrefix: '/console/org', + expectedText: 'Key metrics', + }, + { + path: '/console/managed-projects', + expectedPathPrefix: '/console/managed-projects', + expectedText: 'Managed projects', + }, + { + path: '/console/users', + expectedPathPrefix: '/console/org/members', + expectedText: 'Members', + }, + { + path: '/console/organizations', + expectedPathPrefix: '/console/org', + expectedText: 'Key metrics', + }, + { + path: '/console/settings', + expectedPathPrefix: '/console/settings', + expectedText: 'Settings', + }, +] + +export const ORGANIZATION_CONSOLE_ROUTE_CHECKS = [ + { path: '/console/org', expectedText: 'Key metrics' }, + { path: '/console/org/members', expectedText: 'Members' }, + { path: '/console/org/projects', expectedText: 'Projects and access' }, + { path: '/console/org/roles', expectedText: 'Roles and permissions' }, + { path: '/console/org/auth-policy', expectedText: 'Authentication policy' }, + { path: '/console/org/delivery-channels', expectedText: 'Delivery channels' }, + { path: '/console/org/social-providers', expectedText: 'Social providers' }, + { path: '/console/org/sso', expectedText: 'Inbound SSO connections' }, + { path: '/console/org/outbound-sso', expectedText: 'Outbound enterprise SSO' }, + { path: '/console/org/scim', expectedText: 'Directory sync (SCIM)' }, + { path: '/console/org/scim-targets', expectedText: 'SCIM targets' }, + { path: '/console/org/domains', expectedText: 'Domains' }, + { path: '/console/org/branding', expectedText: 'Brand customization' }, + { path: '/console/org/applications', expectedText: 'OAuth applications' }, + { path: '/console/org/webhooks', expectedText: 'Webhooks' }, + { path: '/console/org/api-keys', expectedText: 'API keys' }, + { path: '/console/org/audit-events', expectedText: 'Audit events' }, + { path: '/console/org/compliance', expectedText: 'Compliance center' }, +].map((route) => ({ ...route, expectedPathPrefix: route.path })) + +export const PLATFORM_CONSOLE_ROUTE_CHECKS = [ + { path: '/console/platform', expectedText: 'Platform overview' }, + { path: '/console/platform/organizations', expectedText: 'Organizations' }, + { path: '/console/platform/users', expectedText: 'Global user search' }, + { path: '/console/platform/managers', expectedText: 'Instance managers' }, + { path: '/console/platform/events', expectedText: 'Global event stream' }, + { path: '/console/platform/flags', expectedText: 'Feature flags' }, + { path: '/console/platform/billing', expectedText: 'Billing overview' }, + { + path: '/console/platform/plans', + expectedText: 'Plans and quotas', + organizationQuery: true, + }, + { path: '/console/platform/announcements', expectedText: 'Announcements' }, + { path: '/console/platform/status', expectedText: 'Status incidents' }, + { path: '/console/platform/compliance', expectedText: 'Compliance center' }, + { path: '/console/platform/dead-letters', expectedText: 'Dead letters' }, + { path: '/console/platform/settings', expectedText: 'Platform settings' }, +].map((route) => ({ ...route, expectedPathPrefix: route.path })) + +export const CONSOLE_SPA_ROUTE_CHECKS = [ + ...INSTANCE_CONSOLE_ROUTE_CHECKS, + ...ORGANIZATION_CONSOLE_ROUTE_CHECKS, + ...PLATFORM_CONSOLE_ROUTE_CHECKS, +] diff --git a/tests/production/harness/production-auth.mjs b/tests/production/harness/production-auth.mjs index f7f2c46e..c9b77f28 100644 --- a/tests/production/harness/production-auth.mjs +++ b/tests/production/harness/production-auth.mjs @@ -187,12 +187,24 @@ export function productionD1Args(command) { } export async function d1(command, name, { runCommand = run } = {}) { - const stdout = await runCommand('pnpm', productionD1Args(command)) - const parsed = JSON.parse(stdout) - const failed = parsed.find((item) => !item?.success) - if (failed) throw new Error(`${name} failed`) - const first = parsed[0] - return first.results ?? [] + const maxAttempts = 3 + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const stdout = await runCommand('pnpm', productionD1Args(command)) + const parsed = JSON.parse(stdout) + const failed = parsed.find((item) => !item?.success) + if (failed) throw new Error(`${name} failed`) + const first = parsed[0] + return first.results ?? [] + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const retryable = /\bcode:\s*7403\b/.test(message) + if (!retryable || attempt === maxAttempts) throw error + console.warn(`RETRY ${name} after Cloudflare D1 control-plane error attempt=${attempt}`) + await delay(attempt * 500) + } + } + throw new Error(`${name} failed without a D1 result`) } export function collectSetCookie(res) { @@ -736,8 +748,11 @@ LIMIT 1; `, 'verify email otp consumed', ) - if (rows.length !== 0) throw new Error('email otp token still exists after verify') - printResult('PASS', 'email otp one time consume', 'deleted=true') + const row = rows[0] + if (!row || row.consumed_at === null) { + throw new Error('email otp token was not marked consumed after verify') + } + printResult('PASS', 'email otp one time consume', 'consumed=true') } export async function signInWithEmailOtp() { diff --git a/tests/production/harness/smoke-production-browser.mjs b/tests/production/harness/smoke-production-browser.mjs index 146afc35..4fa33e05 100644 --- a/tests/production/harness/smoke-production-browser.mjs +++ b/tests/production/harness/smoke-production-browser.mjs @@ -41,6 +41,11 @@ import { } from './production-evidence.mjs' import { docsAuthActionsOk, docsLocaleMetadataOk } from './public-doc-html.mjs' import { webRouteOwnerMatches } from './web-route-owner.mjs' +import { + INSTANCE_CONSOLE_ROUTE_CHECKS, + ORGANIZATION_CONSOLE_ROUTE_CHECKS, + PLATFORM_CONSOLE_ROUTE_CHECKS, +} from './console-route-checks.mjs' const CHROME_PATH = process.env['XID_CHROME_PATH'] ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' @@ -370,6 +375,9 @@ export async function buildSdkBrowserBundle() { const typecheckConfigPath = join(buildDir, 'tsconfig.json') const esbuildPath = await firstExistingPath( [ + join(reactPackageDir, 'node_modules/esbuild/bin/esbuild'), + join(repoRoot, 'node_modules/esbuild/bin/esbuild'), + join(repoRoot, 'node_modules/.pnpm/node_modules/esbuild/bin/esbuild'), join(reactPackageDir, 'node_modules/.bin/esbuild'), join(repoRoot, 'node_modules/.bin/esbuild'), join(repoRoot, 'node_modules/.pnpm/node_modules/.bin/esbuild'), @@ -377,6 +385,12 @@ export async function buildSdkBrowserBundle() { 'esbuild', ) try { + await runCommand( + 'pnpm', + ['--filter', '@xid-kit/core...', 'build'], + { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }, + 'build sdk browser smoke workspace dependencies', + ) await writeFile(entryPath, sdkBrowserEntrySource(), 'utf8') await writeFile( typecheckConfigPath, @@ -451,6 +465,7 @@ class CdpPage { this.pending = new Map() this.events = [] this.networkLog = [] + this.routeNetworkLog = [] } async connect() { @@ -463,6 +478,10 @@ class CdpPage { await this.send('Page.enable') await this.send('Runtime.enable') await this.send('Network.enable') + await this.send('Network.setExtraHTTPHeaders', { + headers: { 'Accept-Language': 'en-US,en;q=0.9' }, + }) + await this.send('Emulation.setLocaleOverride', { locale: 'en-US' }) await this.send('Log.enable') await this.send('WebAuthn.enable') } @@ -489,6 +508,13 @@ class CdpPage { } if (message.method === 'Network.responseReceived') { const url = String(message.params?.response?.url ?? '') + if (url.startsWith(`${baseUrl}/v1/`) || url.startsWith(`${baseUrl}/auth/`)) { + this.routeNetworkLog.push({ + url, + requestId: message.params.requestId, + status: message.params.response.status, + }) + } if ( url.includes('/v1/me') || url.includes('/auth/passkey') || @@ -511,6 +537,11 @@ class CdpPage { row.failed = true row.errorText = message.params.errorText } + const routeRow = this.routeNetworkLog.find((entry) => entry.requestId === requestId) + if (routeRow) { + routeRow.failed = true + routeRow.errorText = message.params.errorText + } } } @@ -562,7 +593,18 @@ class CdpPage { async navigate(path) { this.events = [] - await this.send('Page.navigate', { url: `${baseUrl}${path}` }) + this.routeNetworkLog = [] + const documentMarker = `xid-navigation-${process.pid}-${Date.now()}` + await this.evaluate(`globalThis.__xidNavigationMarker = ${JSON.stringify(documentMarker)}`) + const navigation = await this.send('Page.navigate', { url: `${baseUrl}${path}` }) + if (navigation.errorText) { + throw new Error(`navigate ${path} failed: ${navigation.errorText}`) + } + await this.waitFor( + Function(`return globalThis.__xidNavigationMarker !== ${JSON.stringify(documentMarker)}`), + 15_000, + `document navigation ${path}`, + ) await this.waitFor(() => document.readyState === 'complete', 15_000, `load ${path}`) await this.waitFor( () => !document.body.innerText.includes('Loading your session'), @@ -668,6 +710,32 @@ class CdpPage { if (clicked !== true) throw new Error(`visible submit button not found: ${formSelector}`) } + async waitForVisibleSubmitButton(formSelector = 'form', timeoutMs) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const ready = await this.evaluate(`(() => { + const formSelector = ${JSON.stringify(formSelector)}; + const isVisible = (node) => { + if (node.closest('[aria-hidden="true"],[inert]')) return false; + const style = getComputedStyle(node); + const rect = node.getBoundingClientRect(); + return style.display !== 'none' && + style.visibility !== 'hidden' && + Number(style.opacity || '1') > 0.1 && + rect.width > 0 && + rect.height > 0; + }; + const form = Array.from(document.querySelectorAll(formSelector)).find(isVisible); + if (!form) return false; + return Array.from(form.querySelectorAll('button[type="submit"], button:not([type])')) + .some((node) => isVisible(node) && !node.disabled); + })()`) + if (ready === true) return + await delay(250) + } + throw new Error(`visible submit button ${formSelector} timed out`) + } + async hasVisibleButton(label) { return await this.evaluate(`(() => { const label = ${JSON.stringify(label)}; @@ -869,16 +937,35 @@ function containsAny(text, values) { async function withChrome(fn) { const port = await freePort() const profileDir = await mkdtemp(join(tmpdir(), 'xid-chrome-')) - const chrome = spawn(CHROME_PATH, [ - '--headless=new', + const headed = process.env['XID_PRODUCTION_BROWSER_HEADED'] === '1' + const backgroundHeaded = headed && process.platform === 'darwin' + const chromeArgs = [ + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', '--disable-gpu', + '--disable-renderer-backgrounding', '--lang=en-US', '--no-first-run', '--no-default-browser-check', `--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, 'about:blank', - ]) + ] + let chrome + if (backgroundHeaded) { + const appPathMarker = '/Contents/MacOS/' + const markerIndex = CHROME_PATH.indexOf(appPathMarker) + const chromeAppPath = + process.env['XID_CHROME_APP_PATH'] ?? + (markerIndex >= 0 ? CHROME_PATH.slice(0, markerIndex) : null) + if (!chromeAppPath) { + throw new Error('XID_CHROME_APP_PATH is required for background headed Chrome') + } + chrome = spawn('/usr/bin/open', ['-gn', chromeAppPath, '--args', ...chromeArgs]) + } else { + if (!headed) chromeArgs.unshift('--headless=new') + chrome = spawn(CHROME_PATH, chromeArgs) + } let stderr = '' chrome.stderr.on('data', (chunk) => { @@ -888,28 +975,33 @@ async function withChrome(fn) { let result let mainError let cleanupError + let page try { await waitForVersion(port) const wsUrl = await createTab(port) - const page = new CdpPage(wsUrl) + page = new CdpPage(wsUrl) await page.connect() try { result = await fn(page) } finally { + if (backgroundHeaded) await page.send('Browser.close').catch(() => undefined) await page.close() } } catch (error) { if (stderr) process.stderr.write(stderr) mainError = error } finally { - chrome.kill('SIGTERM') - await new Promise((resolve) => { - chrome.once('exit', resolve) - setTimeout(resolve, 3000) - }) - for (let attempt = 1; attempt <= 3; attempt++) { + if (!backgroundHeaded) { + chrome.kill('SIGTERM') + await new Promise((resolve) => { + chrome.once('exit', resolve) + setTimeout(resolve, 3000) + }) + } + for (let attempt = 1; attempt <= 10; attempt++) { try { await rm(profileDir, { recursive: true, force: true }) + cleanupError = undefined break } catch (error) { cleanupError = error @@ -963,6 +1055,32 @@ function assertSignedInSnapshot(snapshot, name, expectedEmail) { throw new Error(`${name} has function class`) } +function assertNoRawJsonError(snapshot, name) { + if (/\{\s*"(?:code|error|message)"\s*:/.test(snapshot.text)) { + throw new Error(`${name} exposes a raw JSON error`) + } +} + +function assertNoVisibleLoadError(snapshot, name) { + if ( + /\bFailed to\b|\bcould not be loaded\b|\bUnable to load\b|\bSomething went wrong\b/i.test( + snapshot.text, + ) + ) { + throw new Error(`${name} exposes a visible load error`) + } +} + +function assertNoRouteApiErrors(page, name) { + const failures = page.routeNetworkLog.filter( + (entry) => entry.failed || !Number.isFinite(entry.status) || entry.status >= 400, + ) + if (failures.length > 0) { + const safeFailures = redactKnownText(JSON.stringify(failures), [smokeEmail]) + throw new Error(`${name} has API request failures: ${safeFailures.slice(0, 1200)}`) + } +} + async function checkWebManifestHttp() { const { res, text } = await fetchText('/site.webmanifest') if (res.status !== 200) throw new Error(`/site.webmanifest failed http=${res.status}`) @@ -1198,17 +1316,64 @@ async function checkSignInEmailOtpFlow(page) { 'email otp send button', ) await page.setVisibleInputValue('input[type="email"], input[autocomplete="email"]', smokeEmail) + const emailOtpSendLabel = (await page.hasVisibleText('Send code via email')) + ? 'Send code via email' + : '通过邮箱发送验证码' + try { + await page.waitForVisibleButton(emailOtpSendLabel, 45_000) + } catch (error) { + const failedSnapshot = await page.snapshot() + const turnstileState = await page.evaluate(`({ + scriptPresent: document.getElementById('xid-turnstile-script') !== null, + apiPresent: typeof globalThis.turnstile?.render === 'function', + iframeCount: document.querySelectorAll('iframe[src*="challenges.cloudflare.com"]').length, + buttons: Array.from(document.querySelectorAll('button')).map((node) => ({ + text: node.innerText.trim(), + disabled: node.disabled, + })).filter((entry) => entry.text.length > 0), + })`) + throw new Error( + `${error.message}; path=${failedSnapshot.pathname} turnstile=${JSON.stringify(turnstileState)} text=${redactKnownText(failedSnapshot.text, [smokeEmail]).slice(0, 1200)}`, + { cause: error }, + ) + } const afterMs = Date.now() - if ((await page.hasVisibleButton('Send code via email')) === true) { - await page.clickVisibleButton('Send code via email') - } else await page.clickVisibleButton('通过邮箱发送验证码') - await page.waitFor( - () => - document.body.innerText.includes('Verification code') || - document.body.innerText.includes('验证码'), - 15_000, - 'email otp code input', - ) + await page.clickVisibleButton(emailOtpSendLabel) + try { + await page.waitFor( + () => + document.body.innerText.includes('Verification code') || + document.body.innerText.includes('验证码'), + 15_000, + 'email otp code input', + ) + } catch (error) { + const failedSnapshot = await page.snapshot() + const failedRows = await d1( + ` +SELECT vt.consumed_at AS consumed_at +FROM verification_tokens vt +JOIN user_emails ue ON ue.user_id = vt.user_id +WHERE ue.email = ${sqlString(smokeEmail)} + AND vt.purpose = 'otp' + AND vt.channel = 'email' + AND vt.created_at >= ${afterMs} +ORDER BY vt.created_at DESC +LIMIT 1; +`, + 'diagnose email otp send timeout', + ) + const failedRouteLog = page.routeNetworkLog.slice(-20).map((entry) => ({ + url: entry.url, + status: entry.status, + failed: entry.failed ?? false, + errorText: entry.errorText ?? null, + })) + throw new Error( + `${error.message}; token_written=${failedRows.length === 1} token_consumed=${failedRows[0]?.consumed_at !== null && failedRows[0]?.consumed_at !== undefined} text=${redactKnownText(failedSnapshot.text, [smokeEmail]).slice(0, 1000)} route_log=${redactKnownText(JSON.stringify(failedRouteLog), [smokeEmail]).slice(0, 2400)}`, + { cause: error }, + ) + } const row = await waitForLatestBrowserOtpHash(afterMs) const code = await recoverOtpFromHash(String(row.code_hash)) await waitForLatestNotificationSent({ @@ -1230,11 +1395,30 @@ async function checkSignInEmailOtpFlow(page) { if ((await page.hasVisibleButton('Verify code')) === true) await page.clickVisibleButton('Verify code') else await page.clickVisibleButton('验证验证码') - await page.waitFor( - () => location.pathname.startsWith('/console'), - 15_000, - 'email otp default console redirect', - ) + try { + await page.waitFor( + () => location.pathname.startsWith('/console'), + 15_000, + 'email otp default console redirect', + ) + } catch (error) { + const failedSnapshot = await page.snapshot() + const failedNetwork = await page.authNetworkLog() + const failedTokenRows = await d1( + `SELECT consumed_at FROM verification_tokens WHERE id = ${sqlString(String(row.id))} LIMIT 1;`, + 'diagnose email otp redirect timeout', + ) + const failedRouteLog = page.routeNetworkLog.slice(-20).map((entry) => ({ + url: entry.url, + status: entry.status, + failed: entry.failed ?? false, + errorText: entry.errorText ?? null, + })) + throw new Error( + `${error.message}; path=${failedSnapshot.pathname} token_consumed=${failedTokenRows[0]?.consumed_at !== null && failedTokenRows[0]?.consumed_at !== undefined} text=${redactKnownText(failedSnapshot.text, [smokeEmail]).slice(0, 800)} auth_log=${redactKnownText(JSON.stringify(failedNetwork), [smokeEmail]).slice(0, 1600)} route_log=${redactKnownText(JSON.stringify(failedRouteLog), [smokeEmail]).slice(0, 2400)}`, + { cause: error }, + ) + } const cookie = await page.sessionCookieHeader() const me = await verifyMeForEmail(cookie, smokeEmail, { expectedInstanceManager: true }) const browserMe = await page.browserMe() @@ -1248,6 +1432,19 @@ async function checkSignInEmailOtpFlow(page) { assertSignedInSnapshot(snapshot, 'console after UI login', smokeEmail) assertNoConsoleErrors(page, 'sign-in email otp') await verifyTokenConsumed(String(row.id)) + const replay = await fetchText('/auth/otp/email/verify', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: smokeEmail, code }), + }) + const replayBody = parseJson(replay.text, 'email otp replay') + if (replay.res.status !== 400 || replayBody.code !== 'otp_invalid') { + throw new Error(`email otp replay was not rejected: http=${replay.res.status}`) + } + if ((replay.res.headers.get('set-cookie') ?? '').includes('__Host-xid.rt.')) { + throw new Error('email otp replay wrote a session cookie') + } + printResult('PASS', 'email otp replay invalid', `http=${replay.res.status}`) printResult('PASS', 'browser sign-in email otp default console', `url=${snapshot.pathname}`) return { cookie, me } } @@ -1280,7 +1477,7 @@ async function checkPasswordSmokeAuthConfig(organizationId) { printResult('PASS', 'production password smoke auth config', `org=${organizationId}`) } -async function submitPasswordSignIn(page, organizationId) { +async function submitPasswordSignIn(page, organizationId, allowTurnstileRetry = true) { await page.clearSessionCookies() await page.navigate( `/sign-in?organization_id=${encodeURIComponent(organizationId)}&continue=${encodeURIComponent('/console')}&locale=en`, @@ -1311,6 +1508,27 @@ async function submitPasswordSignIn(page, organizationId) { passwordSmokeEmail, ) await page.setVisibleInputValue('input[type="password"]', passwordSmokePassword) + try { + await page.waitForVisibleSubmitButton('form[aria-label]', 45_000) + } catch (error) { + const failedSnapshot = await page.snapshot() + const turnstileState = await page.evaluate(`({ + scriptPresent: document.getElementById('xid-turnstile-script') !== null, + apiPresent: typeof globalThis.turnstile?.render === 'function', + iframeCount: document.querySelectorAll('iframe[src*="challenges.cloudflare.com"]').length, + buttons: Array.from(document.querySelectorAll('button')).map((node) => ({ + text: node.innerText.trim(), + disabled: node.disabled, + })).filter((entry) => entry.text.length > 0), + })`) + if (allowTurnstileRetry) { + return await submitPasswordSignIn(page, organizationId, false) + } + throw new Error( + `${error.message}; path=${failedSnapshot.pathname} turnstile=${JSON.stringify(turnstileState)} text=${redactKnownText(failedSnapshot.text, [passwordSmokeEmail]).slice(0, 1200)}`, + { cause: error }, + ) + } await page.clickVisibleSubmitButton('form[aria-label]') } @@ -1319,9 +1537,9 @@ async function checkPasswordSignInFlow(page, organizationId) { await submitPasswordSignIn(page, organizationId) try { await page.waitFor( - () => location.pathname.startsWith('/console'), + () => location.pathname.startsWith('/account'), 30_000, - 'password smoke default console redirect', + 'password smoke default account redirect', ) } catch (error) { const snapshot = await page.snapshot() @@ -1399,16 +1617,21 @@ LIMIT 1; if (Number(row.verified) !== 0 || row.membership_status !== 'active' || !row.password_id) { throw new Error(`password smoke credential row mismatch: ${JSON.stringify(row)}`) } + await page.waitFor( + Function(`return document.body.innerText.includes(${JSON.stringify(passwordSmokeEmail)})`), + 15_000, + 'account after password signed in shell', + ) const snapshot = await page.snapshot() - if (!snapshot.pathname.startsWith('/console')) { + if (!snapshot.pathname.startsWith('/account')) { throw new Error(`password smoke default target mismatch: ${snapshot.href}`) } if (!snapshot.text.includes(passwordSmokeEmail)) { - throw new Error('console missing signed in password smoke email') + throw new Error('account missing signed in password smoke email') } - assertSignedInSnapshot(snapshot, 'console after password', passwordSmokeEmail) + assertSignedInSnapshot(snapshot, 'account after password', passwordSmokeEmail) assertNoConsoleErrors(page, 'sign-in password smoke') - printResult('PASS', 'browser password sign-in default console', `url=${snapshot.pathname}`) + printResult('PASS', 'browser password sign-in default account', `url=${snapshot.pathname}`) printResult('PASS', 'browser password cookie', cookie.split('; ')[0].split('=')[0]) printResult('PASS', 'browser password me active organization', `org=${organizationId}`) return { cookie, userId: row.user_id } @@ -1462,9 +1685,9 @@ async function checkMfaLoginChallengeFlow(page, organizationId, userId, secret) await page.clickVisibleButton('Verify') try { await page.waitFor( - () => location.pathname.startsWith('/console'), + () => location.pathname.startsWith('/account'), 45_000, - 'mfa challenge default console redirect', + 'mfa challenge default account redirect', ) } catch (error) { const snapshot = await page.snapshot() @@ -1499,8 +1722,13 @@ async function checkMfaLoginChallengeFlow(page, organizationId, userId, secret) if (meBody.user?.hasMfa !== true) { throw new Error(`/v1/me MFA challenge hasMfa mismatch: ${browserMe.body}`) } + await page.waitFor( + Function(`return document.body.innerText.includes(${JSON.stringify(passwordSmokeEmail)})`), + 15_000, + 'account after MFA signed in shell', + ) const snapshot = await page.snapshot() - assertSignedInSnapshot(snapshot, 'console after MFA', passwordSmokeEmail) + assertSignedInSnapshot(snapshot, 'account after MFA', passwordSmokeEmail) assertNoConsoleErrors(page, 'password MFA challenge smoke') printResult('PASS', 'browser password mfa challenge', `url=${snapshot.pathname}`) printResult('PASS', 'browser password mfa cookie', cookie.split('; ')[0].split('=')[0]) @@ -1563,17 +1791,61 @@ LIMIT 1; return { factors, backupCodes } } +async function waitForAccountSecurityMfaUi(page, allowReload = true) { + await page.navigate('/account/security') + try { + await page.waitFor( + () => + Array.from(document.querySelectorAll('select')).some((select) => + Array.from(select.options).some((option) => option.value === 'en'), + ), + 15_000, + 'production account locale selector', + ) + const locale = await page.evaluate(`(() => { + const select = Array.from(document.querySelectorAll('select')).find((item) => + Array.from(item.options).some((option) => option.value === 'en') + ); + if (!select) return { found: false, previous: null }; + const previous = select.value; + if (previous !== 'en') { + const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set; + setter?.call(select, 'en'); + select.dispatchEvent(new Event('change', { bubbles: true })); + } + return { found: true, previous }; + })()`) + if (!locale.found) throw new Error('production account locale selector missing') + if (locale.previous !== 'en') { + await page.waitFor( + () => document.body.innerText.includes('Two-factor authentication'), + 15_000, + 'production account English locale', + ) + printResult('PASS', 'production account locale switch', `${locale.previous}->en`) + } + await page.waitFor( + () => + document.body.innerText.toLowerCase().includes('two-factor authentication') && + document.body.innerText.includes('Add authenticator app'), + 30_000, + 'production account security mfa UI', + ) + } catch (error) { + if (allowReload) return await waitForAccountSecurityMfaUi(page, false) + const snapshot = await page.snapshot() + const browserMe = await page.browserMe() + throw new Error( + `${error instanceof Error ? error.message : String(error)}; path=${snapshot.pathname} text=${redactKnownText(snapshot.text, [passwordSmokeEmail]).slice(0, 1600)} me_http=${browserMe.status} route_log=${redactKnownText(JSON.stringify(page.routeNetworkLog.slice(-20)), [passwordSmokeEmail]).slice(0, 2400)}`, + { cause: error }, + ) + } +} + async function setupTotpSelfService(page, organizationId, userId) { const backupGateCopy = 'Add an authenticator app before generating backup codes.' await cleanupMfaSelfService(organizationId, userId) - await page.navigate('/account/security') - await page.waitFor( - () => - document.body.innerText.toLowerCase().includes('two-factor authentication') && - document.body.innerText.includes('Add authenticator app'), - 15_000, - 'production account security mfa UI', - ) + await waitForAccountSecurityMfaUi(page) await page.waitFor( Function(`return document.body.innerText.includes(${JSON.stringify(backupGateCopy)}) || Array.from(document.querySelectorAll('button')).some( @@ -1805,10 +2077,8 @@ async function checkPasskeyRegistrationAndSignInFlow(page, organizationId, userI 15_000, 'passkey sign-in UI', ) - const hasPasskeyButton = - (await page.hasVisibleButton('Sign in with passkey')) === true || - (await page.hasVisibleButton('使用通行密钥登录')) === true - if (hasPasskeyButton !== true) { + const hasPasskeyPanel = await page.hasVisibleText(['Sign in with passkey', '使用通行密钥登录']) + if (hasPasskeyPanel !== true) { await page.waitForVisibleButton('Passkey', 15_000) await page.clickVisibleButton('Passkey') } @@ -1816,10 +2086,29 @@ async function checkPasskeyRegistrationAndSignInFlow(page, organizationId, userI 'input[type="email"], input[autocomplete="email"], input[autocomplete="username"]', passwordSmokeEmail, ) - if ((await page.hasVisibleButton('Sign in with passkey')) === true) { - await page.clickVisibleButton('Sign in with passkey') - } else await page.clickVisibleButton('使用通行密钥登录') - await page.waitFor(() => location.pathname.startsWith('/console'), 15_000, 'console redirect') + const passkeySignInLabel = (await page.hasVisibleText('Sign in with passkey')) + ? 'Sign in with passkey' + : '使用通行密钥登录' + try { + await page.waitForVisibleButton(passkeySignInLabel, 45_000) + } catch (error) { + const failedSnapshot = await page.snapshot() + const turnstileState = await page.evaluate(`({ + scriptPresent: document.getElementById('xid-turnstile-script') !== null, + apiPresent: typeof globalThis.turnstile?.render === 'function', + iframeCount: document.querySelectorAll('iframe[src*="challenges.cloudflare.com"]').length, + buttons: Array.from(document.querySelectorAll('button')).map((node) => ({ + text: node.innerText.trim(), + disabled: node.disabled, + })).filter((entry) => entry.text.length > 0), + })`) + throw new Error( + `${error.message}; path=${failedSnapshot.pathname} turnstile=${JSON.stringify(turnstileState)} text=${redactKnownText(failedSnapshot.text, [passwordSmokeEmail]).slice(0, 1200)}`, + { cause: error }, + ) + } + await page.clickVisibleButton(passkeySignInLabel) + await page.waitFor(() => location.pathname.startsWith('/account'), 15_000, 'account redirect') const cookie = await page.sessionCookieHeader() const browserMe = await page.browserMe() @@ -1838,13 +2127,18 @@ async function checkPasskeyRegistrationAndSignInFlow(page, organizationId, userI if (meBody.activeOrg?.id !== organizationId) { throw new Error(`/v1/me passkey activeOrg mismatch: ${browserMe.body}`) } + await page.waitFor( + Function(`return document.body.innerText.includes(${JSON.stringify(passwordSmokeEmail)})`), + 15_000, + 'account after passkey signed in shell', + ) const snapshot = await page.snapshot() - if (!snapshot.pathname.startsWith('/console')) { + if (!snapshot.pathname.startsWith('/account')) { throw new Error(`passkey default target mismatch: ${snapshot.href}`) } - assertSignedInSnapshot(snapshot, 'console after passkey', passwordSmokeEmail) + assertSignedInSnapshot(snapshot, 'account after passkey', passwordSmokeEmail) assertNoConsoleErrors(page, 'sign-in passkey smoke') - printResult('PASS', 'browser passkey sign-in default console', `url=${snapshot.pathname}`) + printResult('PASS', 'browser passkey sign-in default account', `url=${snapshot.pathname}`) printResult('PASS', 'browser passkey cookie', cookie.split('; ')[0].split('=')[0]) printResult('PASS', 'browser passkey me active organization', `org=${organizationId}`) return { cookie } @@ -1859,6 +2153,7 @@ async function checkDocs( expectedMarkdown, expectedOgLocale, expectedLlmsIndex, + expectNimbusSidebar = true, }, ) { const ownerResponse = await fetch(`${baseUrl}${path}`, { @@ -1887,14 +2182,23 @@ async function checkDocs( } await page.navigate(path) - await page.waitFor( - () => - document.querySelector('[data-nb-sidebar]') !== null && - document.querySelector('[data-search-dialog]') !== null && - document.querySelector('[data-ai-agent-directive]') !== null, - 15_000, - `${path} Nimbus docs shell`, - ) + try { + await page.waitFor( + Function( + `return ${expectNimbusSidebar ? "document.querySelector('[data-nb-sidebar]') !== null" : 'true'} && + document.querySelector('[data-search-dialog]') !== null && + document.querySelector('[data-ai-agent-directive]') !== null`, + ), + 15_000, + `${path} Nimbus docs shell`, + ) + } catch (error) { + const snapshot = await page.snapshot() + throw new Error( + `${path} Nimbus docs shell timed out path=${snapshot.pathname} sidebar=${snapshot.hasNimbusSidebar} search=${snapshot.hasNimbusSearch} agent=${snapshot.hasAgentDirective}`, + { cause: error }, + ) + } const snapshot = await page.snapshot() if (snapshot.pathname !== path) throw new Error(`${path} pathname mismatch: ${snapshot.href}`) if (snapshot.text.includes('Sign in to XID')) throw new Error(`${path} rendered sign-in`) @@ -1908,16 +2212,24 @@ async function checkDocs( if (snapshot.markdownHref !== expectedMarkdown) { throw new Error(`${path} markdown alternate mismatch: ${snapshot.markdownHref}`) } - if (!snapshot.hasNimbusSidebar || !snapshot.hasNimbusSearch || !snapshot.hasAgentDirective) { + if ( + (expectNimbusSidebar && !snapshot.hasNimbusSidebar) || + !snapshot.hasNimbusSearch || + !snapshot.hasAgentDirective + ) { throw new Error(`${path} missing Nimbus docs shell`) } assertTextAbsent(snapshot, path, forbiddenPublicDocsText) if (snapshot.hasPlaceholderHref) throw new Error(`${path} has placeholder href`) assertNoConsoleErrors(page, path) - printResult('PASS', `browser ${path}`, `nimbus=true lang=${snapshot.lang}`) + printResult( + 'PASS', + `browser ${path}`, + `nimbus=true sidebar=${snapshot.hasNimbusSidebar} lang=${snapshot.lang}`, + ) } -async function checkConsoleRoute(page, path, expectedPathPrefix) { +async function checkConsoleRoute(page, path, expectedPathPrefix, expectedText) { const ownerResponse = await fetch(`${baseUrl}${path}`, { redirect: 'manual', headers: { accept: 'text/html' }, @@ -1945,6 +2257,33 @@ async function checkConsoleRoute(page, path, expectedPathPrefix) { { cause: error }, ) } + + if (expectedText) { + const foldedExpectedText = expectedText.toLowerCase() + await page.waitFor( + Function( + `return document.body.innerText.toLowerCase().includes(${JSON.stringify(foldedExpectedText)})`, + ), + 30_000, + `${path} expected content`, + ) + } + try { + await page.waitFor( + () => document.querySelector('main [role="status"] > [aria-hidden="true"]') === null, + 30_000, + `${path} settled content`, + ) + } catch (error) { + const snapshot = await page.snapshot() + const statusText = await page.evaluate( + `Array.from(document.querySelectorAll('main [role="status"]')).map((node) => node.innerText.trim()).filter(Boolean)`, + ) + throw new Error( + `${path} settled content timed out path=${snapshot.pathname} status=${redactKnownText(JSON.stringify(statusText), [smokeEmail]).slice(0, 1200)} route_log=${redactKnownText(JSON.stringify(page.routeNetworkLog.slice(-20)), [smokeEmail]).slice(0, 2400)}`, + { cause: error }, + ) + } const snapshot = await page.snapshot() if (!snapshot.pathname.startsWith(expectedPathPrefix)) { throw new Error(`${path} expected ${expectedPathPrefix}, got ${snapshot.href}`) @@ -1958,52 +2297,72 @@ async function checkConsoleRoute(page, path, expectedPathPrefix) { `${path} missing signed in email me_http=${browserMe.status} me_body=${safeBody.slice(0, 500)} text=${safeText.slice(0, 500)}`, ) } + if (expectedText && !snapshot.text.toLowerCase().includes(expectedText.toLowerCase())) { + throw new Error(`${path} missing expected content: ${expectedText}`) + } assertSignedInSnapshot(snapshot, path, smokeEmail) + assertNoRawJsonError(snapshot, path) + assertNoVisibleLoadError(snapshot, path) + assertNoRouteApiErrors(page, path) assertNoConsoleErrors(page, path) printResult('PASS', `browser ${path}`, `url=${snapshot.pathname}`) } -async function checkOrgConsoleRoutes(page) { - const routes = [ - { path: '/console/org', expectedText: 'Key metrics' }, - { path: '/console/org/members', expectedText: 'Invite member' }, - { path: '/console/org/roles', expectedText: 'Roles and permissions' }, - { path: '/console/org/auth-policy', expectedText: 'Authentication policy' }, - { path: '/console/org/delivery-channels', expectedText: 'Delivery channels' }, - { path: '/console/org/social-providers', expectedText: 'Social providers' }, - { path: '/console/org/sso', expectedText: 'SSO connections' }, - { path: '/console/org/scim', expectedText: 'Directory sync (SCIM)' }, - { path: '/console/org/domains', expectedText: 'Organization domains' }, - { path: '/console/org/branding', expectedText: 'Brand customization' }, - ] +async function checkAccountCompatibilityRoute(page, path, expectedPath, expectedText) { + const ownerResponse = await fetch(`${baseUrl}${path}`, { + redirect: 'manual', + headers: { accept: 'text/html' }, + }) + if (!webRouteOwnerMatches(ownerResponse.headers, 'console')) { + const actualOwner = ownerResponse.headers.get('x-xid-route-owner') ?? 'missing' + throw new Error(`${path} route owner mismatch: ${actualOwner}`) + } + await page.navigate(path) + await page.waitFor( + Function( + `return document.querySelector('nav[aria-label]') !== null && + document.querySelector('main')?.innerText.includes(${JSON.stringify(expectedText)}) === true`, + ), + 15_000, + `${path} account content`, + ) + const snapshot = await page.snapshot() + if (snapshot.pathname !== expectedPath) { + throw new Error(`${path} expected ${expectedPath}, got ${snapshot.href}`) + } + if (!snapshot.text.includes('Back to Console')) { + throw new Error(`${path} missing account navigation`) + } + assertSignedInSnapshot(snapshot, path, smokeEmail) + assertNoRawJsonError(snapshot, path) + assertNoVisibleLoadError(snapshot, path) + assertNoRouteApiErrors(page, path) + assertNoConsoleErrors(page, path) + printResult('PASS', `browser ${path}`, `url=${snapshot.pathname}`) +} + +async function checkConsoleRouteSet(page, routes, options = {}) { for (const route of routes) { - await checkConsoleRoute(page, route.path, route.path) - const expectedText = route.expectedText - const foldedExpectedText = expectedText.toLowerCase() - try { - await page.waitFor( - Function( - `return document.body.innerText.toLowerCase().includes(${JSON.stringify(foldedExpectedText)})`, - ), - 15_000, - `${route.path} expected content`, - ) - } catch (error) { - const snapshot = await page.snapshot() - const browserMe = await page.browserMe() - const fetchLog = await page.fetchLog() - const authLog = await page.authNetworkLog() - throw new Error( - `${route.path} expected content timed out expected=${expectedText} path=${snapshot.pathname} me_http=${browserMe.status} me_body=${redactKnownText(browserMe.body, [smokeEmail]).slice(0, 500)} text=${redactKnownText(snapshot.text, [smokeEmail]).slice(0, 800)} fetch_log=${redactKnownText(JSON.stringify(fetchLog), [smokeEmail]).slice(0, 1600)} auth_log=${redactKnownText(JSON.stringify(authLog), [smokeEmail]).slice(0, 1600)}`, - { cause: error }, - ) - } - const snapshot = await page.snapshot() - if (!snapshot.text.toLowerCase().includes(foldedExpectedText)) { - throw new Error(`${route.path} missing expected content: ${route.expectedText}`) - } + const path = route.organizationQuery + ? `${route.path}?tenantId=${encodeURIComponent(options.organizationId ?? '')}` + : route.path + await checkConsoleRoute(page, path, route.expectedPathPrefix, route.expectedText) } +} + +async function checkInstanceConsoleRoutes(page) { + await checkConsoleRouteSet(page, INSTANCE_CONSOLE_ROUTE_CHECKS) + await checkConsoleSettingsOverview(page) + printResult( + 'PASS', + 'browser instance console routes', + `count=${INSTANCE_CONSOLE_ROUTE_CHECKS.length}`, + ) +} + +async function checkOrgConsoleRoutes(page) { + await checkConsoleRouteSet(page, ORGANIZATION_CONSOLE_ROUTE_CHECKS) const links = await page.evaluate(`Array.from(document.querySelectorAll('aside nav a')).map((a) => ({ @@ -2017,7 +2376,21 @@ async function checkOrgConsoleRoutes(page) { throw new Error('org navigation missing Delivery channels') } - printResult('PASS', 'browser org console routes', `count=${routes.length}`) + printResult( + 'PASS', + 'browser org console routes', + `count=${ORGANIZATION_CONSOLE_ROUTE_CHECKS.length}`, + ) +} + +async function checkPlatformConsoleRoutes(page, organizationId) { + if (!organizationId) throw new Error('platform route smoke requires an organization id') + await checkConsoleRouteSet(page, PLATFORM_CONSOLE_ROUTE_CHECKS, { organizationId }) + printResult( + 'PASS', + 'browser platform console routes', + `count=${PLATFORM_CONSOLE_ROUTE_CHECKS.length}`, + ) } async function checkConsoleSettingsOverview(page) { @@ -2037,15 +2410,28 @@ async function checkConsoleSettingsOverview(page) { const requiredText = [ 'Auth policy', 'Social providers', - 'Enterprise SSO', + 'Inbound SSO', + 'Outbound SSO', 'Directory sync', + 'SCIM targets', + 'Delivery channels', + 'Applications', + 'Projects', + 'Roles and permissions', + 'API keys', + 'Webhooks', 'Domains', 'Branding', - 'Open auth policy', - 'Open social providers', + 'Members', + 'Audit events', + 'Compliance', ] for (const text of requiredText) { - if (!snapshot.text.includes(text)) throw new Error(`${path} missing ${text}`) + if (!snapshot.text.includes(text)) { + throw new Error( + `${path} missing ${text}; text=${redactKnownText(snapshot.text, [smokeEmail]).slice(0, 4000)}`, + ) + } } const forbiddenSettingsText = [ ...forbiddenText, @@ -2058,15 +2444,15 @@ async function checkConsoleSettingsOverview(page) { href: a.getAttribute('href'), text: a.textContent?.trim() ?? '', }))`) - const hasAuthPolicyLink = links.some( - (link) => link.href === '/console/org/auth-policy' && link.text === 'Open auth policy', + const expectedSettingsHrefs = ORGANIZATION_CONSOLE_ROUTE_CHECKS.map((route) => route.path).filter( + (href) => href !== '/console/org', ) - const hasSocialProvidersLink = links.some( - (link) => - link.href === '/console/org/social-providers' && link.text === 'Open social providers', + const missingSettingsHrefs = expectedSettingsHrefs.filter( + (href) => !links.some((link) => link.href === href && link.text === 'Open'), ) - if (!hasAuthPolicyLink) throw new Error(`${path} missing auth policy link`) - if (!hasSocialProvidersLink) throw new Error(`${path} missing social providers link`) + if (missingSettingsHrefs.length > 0) { + throw new Error(`${path} missing settings links: ${missingSettingsHrefs.join(',')}`) + } if (snapshot.hasPlaceholderHref) throw new Error(`${path} has placeholder href`) if (snapshot.badClass || snapshot.htmlHasFunctionClass) throw new Error(`${path} has function class`) @@ -2434,7 +2820,7 @@ async function checkActiveOrganization(page, cookie, originalMe) { await postActiveOrganization(cookie, null) const clearedMe = await getMe(cookie) if (clearedMe.activeOrg !== null) throw new Error('/v1/me activeOrg did not clear') - await checkConsoleRoute(page, '/console/organizations', '/console/platform/organizations') + await checkConsoleRoute(page, '/console/organizations', '/console/org', 'Key metrics') await postActiveOrganization(cookie, targetOrg.id) const updatedMe = await getMe(cookie) @@ -2629,6 +3015,7 @@ export async function runProductionBrowserSmoke() { me = result.me await checkDocs(page, { path: '/', + expectNimbusSidebar: false, expectedLanguage: 'en', expectedCanonical: 'https://xid.dev/', expectedMarkdown: 'https://xid.dev/index.md', @@ -2651,17 +3038,27 @@ export async function runProductionBrowserSmoke() { expectedOgLocale: 'zh_CN', expectedLlmsIndex: 'https://xid.dev/zh-hans/llms.txt', }) - await checkConsoleRoute(page, '/console', '/console') - await checkConsoleRoute(page, '/console/organizations', '/console/platform/organizations') - await checkConsoleRoute(page, '/console/users', '/console/platform/users') - await checkConsoleSettingsOverview(page) - await checkConsoleRoute(page, '/console/sessions', '/account/sessions') - await checkConsoleRoute(page, '/console/security', '/account/security') + await checkInstanceConsoleRoutes(page) + await checkAccountCompatibilityRoute( + page, + '/console/sessions', + '/account/sessions', + 'Active sessions', + ) + await checkAccountCompatibilityRoute( + page, + '/console/security', + '/account/security', + 'Security', + ) await checkActiveOrganization(page, state.cookie, me) await checkOrgConsoleRoutes(page) + await checkPlatformConsoleRoutes(page, me.organizations[0]?.id ?? me.activeOrg?.id ?? null) await checkSdkBrowserIntegration(page, state.cookie, me) await checkMfaProviderGate(page, state.cookie) - const organizationId = state.passwordOrganizationId + }) + const organizationId = state.passwordOrganizationId + await withChrome(async (page) => { const passwordResult = await checkPasswordSignInFlow(page, organizationId) await checkMfaSelfServiceFlow(page, organizationId, passwordResult.userId) const mfaLogin = await setupTotpSelfService(page, organizationId, passwordResult.userId) diff --git a/tests/production/production-auth.test.mjs b/tests/production/production-auth.test.mjs index 3866fae3..b6dd7fdc 100644 --- a/tests/production/production-auth.test.mjs +++ b/tests/production/production-auth.test.mjs @@ -139,4 +139,33 @@ describe('production auth D1 target', () => { expect(captured).toEqual({ command: 'pnpm', args: productionD1Args('SELECT 1') }) }) + + it('retries a transient Cloudflare D1 authorization response', async () => { + let attempts = 0 + await expect( + d1('SELECT 1', 'retry D1 command', { + runCommand: async () => { + attempts += 1 + if (attempts === 1) { + throw new Error('Cloudflare API failed: not authorized [code: 7403]') + } + return '[{"success":true,"results":[{"ok":1}]}]' + }, + }), + ).resolves.toEqual([{ ok: 1 }]) + expect(attempts).toBe(2) + }) + + it('does not retry an unrelated D1 failure', async () => { + let attempts = 0 + await expect( + d1('SELECT 1', 'failed D1 command', { + runCommand: async () => { + attempts += 1 + throw new Error('D1 query failed') + }, + }), + ).rejects.toThrow('D1 query failed') + expect(attempts).toBe(1) + }) }) diff --git a/tests/production/web-route-owner.test.mjs b/tests/production/web-route-owner.test.mjs index d5f06c1b..251f2d92 100644 --- a/tests/production/web-route-owner.test.mjs +++ b/tests/production/web-route-owner.test.mjs @@ -5,6 +5,12 @@ import { webRedirectLocationMatches, webRouteOwnerMatches, } from './harness/web-route-owner.mjs' +import { + CONSOLE_SPA_ROUTE_CHECKS, + INSTANCE_CONSOLE_ROUTE_CHECKS, + ORGANIZATION_CONSOLE_ROUTE_CHECKS, + PLATFORM_CONSOLE_ROUTE_CHECKS, +} from './harness/console-route-checks.mjs' describe('production web route owner contract', () => { it('requires explicit Site and Console owners', () => { @@ -93,10 +99,10 @@ describe('production web route owner contract', () => { expect(httpHarness).toContain('xid\\.dev\\/zh-hans"') expect(httpHarness).toContain('xid\\.dev\\/pt-br"') expect(browserHarness).toContain( - "checkConsoleRoute(page, '/console/sessions', '/account/sessions')", + "checkAccountCompatibilityRoute(\n page,\n '/console/sessions',\n '/account/sessions',", ) expect(browserHarness).toContain( - "checkConsoleRoute(page, '/console/security', '/account/security')", + "checkAccountCompatibilityRoute(\n page,\n '/console/security',\n '/account/security',", ) expect(browserHarness).toContain("webRouteOwnerMatches(ownerResponse.headers, 'console')") expect(readinessHarness).toContain("webRouteOwnerMatches(internalDocs.res.headers, 'site')") @@ -106,4 +112,23 @@ describe('production web route owner contract', () => { expect(wildcardHarness).toContain("webRouteOwnerMatches(res.headers, 'core')") expect(wildcardHarness).toContain("webRouteOwnerMatches(res.headers, 'console')") }) + + it('covers every Console SPA route in the production browser smoke', async () => { + const routerSource = await readFile( + new URL('../../apps/console/src/router.tsx', import.meta.url), + 'utf8', + ) + const routeList = routerSource.match( + /export const CONSOLE_SPA_ROUTE_PATHS = \[([\s\S]*?)\] as const/, + ) + expect(routeList).not.toBeNull() + const routerPaths = [...routeList[1].matchAll(/'([^']+)'/g)].map((match) => match[1]) + const smokePaths = CONSOLE_SPA_ROUTE_CHECKS.map((route) => route.path) + + expect(INSTANCE_CONSOLE_ROUTE_CHECKS).toHaveLength(5) + expect(ORGANIZATION_CONSOLE_ROUTE_CHECKS).toHaveLength(18) + expect(PLATFORM_CONSOLE_ROUTE_CHECKS).toHaveLength(13) + expect(new Set(smokePaths).size).toBe(36) + expect(smokePaths.toSorted()).toEqual(routerPaths.toSorted()) + }) })