diff --git a/tests/production/harness/smoke-production.mjs b/tests/production/harness/smoke-production.mjs index 96c82950..7748f203 100644 --- a/tests/production/harness/smoke-production.mjs +++ b/tests/production/harness/smoke-production.mjs @@ -48,16 +48,9 @@ const forgotPasswordEmail = // 含已下线内部文档 slug:公开页面永远不得出现这些路径,防止复用同名 slug 时静默泄露。 const forbiddenPublicDocsPatterns = [ - 'docs/design', - 'docs/goal', - 'docs/verification', - 'docs/deployment', - 'docs/api-contracts', - 'docs/current-gap-audit', - 'docs/implementation-status', - 'docs/soft-delete', - '完整功能设计', - '设计真相源', + /href=["']\/docs\/(?:design|goal|verification|deployment|api-contracts|current-gap-audit|implementation-status|soft-delete)(?:[/#?]|["'])/u, + /完整功能设计/u, + /设计真相源/u, ] export function publicDocsBodyOk(body) { @@ -66,7 +59,7 @@ export function publicDocsBodyOk(body) { body.includes('data-ai-agent-directive') && body.includes('data-nb-sidebar') && body.includes('data-search-dialog') && - forbiddenPublicDocsPatterns.every((item) => !body.includes(item)) + forbiddenPublicDocsPatterns.every((pattern) => !pattern.test(body)) ) } @@ -136,6 +129,12 @@ function canonicalJson(value) { return JSON.stringify(value) } +function canonicalAuthConfig(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return canonicalJson(value) + const { guest: _flowCapability, ...stableConfig } = value + return canonicalJson(stableConfig) +} + const docsHomeTextRequired = [ 'Build identity at the edge, without giving up control | XID', 'Identity infrastructure for Cloudflare', @@ -155,17 +154,17 @@ const docsHomeSeoPatternRequired = [ /]*name="description"[^>]*content="XID brings Hosted Auth, OIDC, organizations, enterprise federation, directory sync, and SDKs/u, /]*rel="canonical"[^>]*href="https:\/\/xid\.dev\/"[^>]*>/u, /]*rel="alternate"[^>]*hreflang="en"[^>]*href="https:\/\/xid\.dev\/"[^>]*>/u, - /]*rel="alternate"[^>]*hreflang="zh-Hans"[^>]*href="https:\/\/xid\.dev\/zh-hans\/"[^>]*>/u, - /]*rel="alternate"[^>]*hreflang="ja"[^>]*href="https:\/\/xid\.dev\/ja\/"[^>]*>/u, - /]*rel="alternate"[^>]*hreflang="ko"[^>]*href="https:\/\/xid\.dev\/ko\/"[^>]*>/u, - /]*rel="alternate"[^>]*hreflang="fr"[^>]*href="https:\/\/xid\.dev\/fr\/"[^>]*>/u, - /]*rel="alternate"[^>]*hreflang="de"[^>]*href="https:\/\/xid\.dev\/de\/"[^>]*>/u, - /]*rel="alternate"[^>]*hreflang="es"[^>]*href="https:\/\/xid\.dev\/es\/"[^>]*>/u, - /]*rel="alternate"[^>]*hreflang="pt-BR"[^>]*href="https:\/\/xid\.dev\/pt-br\/"[^>]*>/u, + /]*rel="alternate"[^>]*hreflang="zh-Hans"[^>]*href="https:\/\/xid\.dev\/zh-hans"[^>]*>/u, + /]*rel="alternate"[^>]*hreflang="ja"[^>]*href="https:\/\/xid\.dev\/ja"[^>]*>/u, + /]*rel="alternate"[^>]*hreflang="ko"[^>]*href="https:\/\/xid\.dev\/ko"[^>]*>/u, + /]*rel="alternate"[^>]*hreflang="fr"[^>]*href="https:\/\/xid\.dev\/fr"[^>]*>/u, + /]*rel="alternate"[^>]*hreflang="de"[^>]*href="https:\/\/xid\.dev\/de"[^>]*>/u, + /]*rel="alternate"[^>]*hreflang="es"[^>]*href="https:\/\/xid\.dev\/es"[^>]*>/u, + /]*rel="alternate"[^>]*hreflang="pt-BR"[^>]*href="https:\/\/xid\.dev\/pt-br"[^>]*>/u, /]*rel="alternate"[^>]*hreflang="x-default"[^>]*href="https:\/\/xid\.dev\/"[^>]*>/u, /]*property="og:type"[^>]*content="website"[^>]*>/u, /]*property="og:title"[^>]*content="Build identity at the edge, without giving up control \| XID"[^>]*>/u, - /]*property="og:image"[^>]*content="https:\/\/xid\.dev\/og\/[^"]+\.png"[^>]*>/u, + /]*property="og:image"[^>]*content="https:\/\/xid\.dev\/og\.png"[^>]*>/u, /]*name="twitter:card"[^>]*content="summary_large_image"[^>]*>/u, /]*rel="alternate"[^>]*type="text\/plain"[^>]*href="https:\/\/xid\.dev\/en\/llms\.txt"[^>]*>/u, /]*rel="alternate"[^>]*type="text\/markdown"[^>]*href="https:\/\/xid\.dev\/index\.md"[^>]*>/u, @@ -199,7 +198,7 @@ export function docsHomeOk(body) { return ( body.includes('data-pagefind-body') && body.includes('data-search-dialog') && - forbiddenPublicDocsPatterns.every((item) => !body.includes(item)) && + forbiddenPublicDocsPatterns.every((pattern) => !pattern.test(body)) && docsHomeTextRequired.every((item) => body.includes(item)) && docsHomeSeoPatternRequired.every((pattern) => pattern.test(body)) && docsHomeJsonLdOk(body) @@ -462,21 +461,21 @@ const checks = [ { name: 'console-shell', surface: 'console', - path: '/console', + path: '/console/', expectStatus: 200, expectBody: (body) => body.includes('
'), }, { name: 'console-shell-query-fallback', surface: 'console', - path: '/console?source=production-smoke', + path: '/console/?source=production-smoke', expectStatus: 200, expectBody: (body) => body.includes('
'), }, { name: 'tenant-console-shell-query-fallback', surface: 'console', - url: `${tenantBaseUrl}/console?source=production-smoke`, + url: `${tenantBaseUrl}/console/?source=production-smoke`, expectStatus: 200, expectBody: (body) => body.includes('
'), }, @@ -550,6 +549,7 @@ const checks = [ const gateChecks = [ { name: 'password-disabled-gate', + turnstileProtected: true, path: '/auth/password/sign-in', method: 'POST', body: { identifier: defaultEmail, password: 'xid-production-smoke-not-a-password' }, @@ -568,6 +568,7 @@ const gateChecks = [ }, { name: 'social-google-not-configured-gate', + turnstileProtected: true, path: '/auth/google/authorize', method: 'GET', expectStatus: 400, @@ -580,6 +581,7 @@ const gateChecks = [ }, { name: 'enterprise-sso-disabled-hrd', + turnstileProtected: true, path: '/sso/hrd', method: 'POST', body: { email: defaultEmail }, @@ -736,6 +738,25 @@ export async function runProductionSmoke() { results.push(result) } + let turnstileConfigured = false + try { + const authConfigRes = await fetch(`${coreBaseUrl}/auth/config`, { redirect: 'manual' }) + const authConfig = await authConfigRes.json() + turnstileConfigured = + authConfigRes.status === 200 && + typeof authConfig?.turnstileSiteKey === 'string' && + authConfig.turnstileSiteKey.trim().length > 0 + } catch (error) { + failed = true + results.push({ + name: 'turnstile-config-detection', + status: 'FAIL', + httpStatus: 'ERROR', + url: `${coreBaseUrl}/auth/config`, + error: error instanceof Error ? error.message : String(error), + }) + } + for (const check of gateChecks) { const url = `${coreBaseUrl}${check.path}` try { @@ -746,7 +767,14 @@ export async function runProductionSmoke() { body: check.body ? JSON.stringify(check.body) : undefined, }) const body = await res.text() - const statusOk = res.status === check.expectStatus + let json = null + try { + json = JSON.parse(body) + } catch { + json = null + } + const blockedByTurnstile = turnstileConfigured && check.turnstileProtected === true + const statusOk = res.status === (blockedByTurnstile ? 401 : check.expectStatus) const location = res.headers.get('location') const setCookie = res.headers.get('set-cookie') ?? '' const contentType = res.headers.get('content-type') ?? '' @@ -760,20 +788,16 @@ export async function runProductionSmoke() { !body.includes('id="root"') && !body.includes(' 0 && + (blockedByTurnstile || policyDeniedCount > 0) && policyDeniedLeakCount === 0 if (!ok) failed = true results.push({ name: 'forgot-password-disabled-gate', - status: ok ? 'PASS' : 'FAIL', + status: ok ? (blockedByTurnstile ? 'SKIP' : 'PASS') : 'FAIL', httpStatus: res.status, routeOwner: res.headers.get('x-xid-route-owner') ?? 'implicit-core', + note: blockedByTurnstile ? 'configured Turnstile blocks the underlying policy probe' : '', url, }) } catch (error) { @@ -1044,7 +1072,7 @@ WHERE ${policyDeniedWhere(policyDeniedInput, afterMs)} } catch { baseline = null } - const baselineTextCanonical = canonicalJson(baseline) + const baselineTextCanonical = canonicalAuthConfig(baseline) for (const variant of variants) { const url = `${coreBaseUrl}${variant.path}` try { @@ -1063,7 +1091,7 @@ WHERE ${policyDeniedWhere(policyDeniedInput, afterMs)} webRouteOwnerMatches(res.headers, 'core') && defaultAuthConfigOk(baseline) && defaultAuthConfigOk(json) && - canonicalJson(json) === baselineTextCanonical + canonicalAuthConfig(json) === baselineTextCanonical if (!ok) failed = true results.push({ name: variant.name, @@ -1183,12 +1211,13 @@ LIMIT 1; }) const body = await res.text() const setCookie = res.headers.get('set-cookie') ?? '' - let jsonOk = false + let json = null try { - jsonOk = JSON.parse(body)?.ok === true + json = JSON.parse(body) } catch { - jsonOk = false + json = null } + const blockedByTurnstile = turnstileConfigured const tokenCount = await countRows( ` SELECT count(*) AS count @@ -1222,31 +1251,34 @@ WHERE phone = ${sqlString(check.phone)} `, `load ${check.channel} gate user phone count`, ) - const policyDeniedCount = await waitForPolicyDenied( - { - name: check.channel, - method: check.method, - action: 'availability', - identifierType: 'phone', - path: check.path, - }, - afterMs, - ) + const policyDeniedCount = blockedByTurnstile + ? 0 + : await waitForPolicyDenied( + { + name: check.channel, + method: check.method, + action: 'availability', + identifierType: 'phone', + path: check.path, + }, + afterMs, + ) const ok = - res.status === 200 && + res.status === (blockedByTurnstile ? 401 : 200) && webRouteOwnerMatches(res.headers, 'core') && - jsonOk && + (blockedByTurnstile ? json?.code === 'captcha_required' : json?.ok === true) && !setCookie.includes('__Host-xid.rt.') && tokenCount === 0 && phoneRowCount === 0 && sentAuditCount === 0 && - policyDeniedCount > 0 + (blockedByTurnstile || policyDeniedCount > 0) if (!ok) failed = true results.push({ name: check.name, - status: ok ? 'PASS' : 'FAIL', + status: ok ? (blockedByTurnstile ? 'SKIP' : 'PASS') : 'FAIL', httpStatus: res.status, routeOwner: res.headers.get('x-xid-route-owner') ?? 'implicit-core', + note: blockedByTurnstile ? 'configured Turnstile blocks the underlying policy probe' : '', url, }) } catch (error) { @@ -1264,10 +1296,11 @@ WHERE phone = ${sqlString(check.phone)} for (const result of results) { const error = result.error ? ` error=${result.error}` : '' const owner = result.routeOwner ? ` owner=${result.routeOwner}` : '' + const note = result.note ? ` note=${result.note}` : '' printResult( result.status, result.name, - `http=${result.httpStatus}${owner} url=${result.url}${error}`, + `http=${result.httpStatus}${owner} url=${result.url}${error}${note}`, ) } diff --git a/tests/production/web-route-owner.test.mjs b/tests/production/web-route-owner.test.mjs index f8b23f63..d5f06c1b 100644 --- a/tests/production/web-route-owner.test.mjs +++ b/tests/production/web-route-owner.test.mjs @@ -82,16 +82,16 @@ describe('production web route owner contract', () => { expect(httpHarness).toContain('/?source=production-smoke') expect(httpHarness).toContain('/getting-started?source=production-smoke') expect(httpHarness).toContain('/llms.txt?source=production-smoke') - expect(httpHarness).toContain('/console?source=production-smoke') - expect(httpHarness).toContain('`${tenantBaseUrl}/console?source=production-smoke`') + expect(httpHarness).toContain('/console/?source=production-smoke') + expect(httpHarness).toContain('`${tenantBaseUrl}/console/?source=production-smoke`') expect(httpHarness).toContain('`${tenantBaseUrl}/auth/config?source=production-smoke`') expect(httpHarness).toContain('runWildcardRouteProbe()') expect(httpHarness).toContain('webRouteOwnerMatches(res.headers, check.surface)') expect(httpHarness).not.toContain('x-xid-docs-route-status') expect(httpHarness).not.toContain('?locale=zh-Hans') expect(httpHarness).not.toContain('
') - expect(httpHarness).toContain('xid\\.dev\\/zh-hans\\/') - expect(httpHarness).toContain('xid\\.dev\\/pt-br\\/') + expect(httpHarness).toContain('xid\\.dev\\/zh-hans"') + expect(httpHarness).toContain('xid\\.dev\\/pt-br"') expect(browserHarness).toContain( "checkConsoleRoute(page, '/console/sessions', '/account/sessions')", )