Files
universe/e2e/05-cloud.spec.ts
Hanzo Dev a7e59013fe e2e(cloud): the authorize request was missing its PKCE challenge
'OAuth authorize URL works with lux-cloud client' failed on all three
viewports. The endpoint was fine — the request was not. lux-cloud is a PUBLIC
client, and IAM refuses an authorize without a code_challenge:

    /auth/callback?error=invalid_request
      &error_description=PKCE+is+required+for+public+clients

That bounce is a redirect, so isRealAuthorizeResponse called it real and the
test went on to die waiting for a login form a rejected request never reaches.
The failure therefore read as 'authorize is broken' while authorize was
enforcing its contract. env.ts already documented the challenge in the URL it
had measured, and 50-login already asserts a public client must send one; this
test was the only place that did not.

Adds pkce() beside OIDC — one builder, where the OIDC contract already lives.
The verifier is fixed, not random: nothing exchanges it, and a fresh secret per
run makes a re-run non-reproducible.

The response check had to change with it. Once the request is valid the flow
lands on the real login page, which is 200 text/html — and measured today, that
page and a control request to a path that does not exist return BYTE-IDENTICAL
654-byte SPA shells. No server-side signal separates them, so this test now
asserts the one thing that does: an OAuth error never comes back to our own
callback, and the password field renders. isRealAuthorizeResponse is left alone
for 13-iam-billing, which reads it from a non-navigating request.

  cloud suite: 54 passed / 3 failed  ->  57 passed / 0 failed

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-28 12:38:41 -07:00

246 lines
12 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { getUrls, httpStatus, httpGetJson, rpcCall, getCurrentValidators, snap } from './helpers'
import { OIDC, authorizeUrl, pkce } from './env'
const urls = getUrls()
const CLOUD_URL = urls.cloud
const CLOUD_API = 'https://api.cloud.lux.network'
// ── Cloud Frontend — White Label Verification ───────────────────────
test.describe('Cloud — White Label Branding', () => {
test('cloud.lux.network loads with HTTP 200', async ({ page }) => {
const res = await page.goto(CLOUD_URL, { timeout: 30_000, waitUntil: 'domcontentloaded' })
expect(res?.status()).toBe(200)
await snap(page, 'cloud-home')
})
test('cloud page renders content', async ({ page }) => {
await page.goto(CLOUD_URL, { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const bodyLen = (await page.textContent('body'))?.length || 0
expect(bodyLen).toBeGreaterThan(100)
await snap(page, 'cloud-brand')
})
test('cloud has visible navigation', async ({ page }) => {
await page.goto(CLOUD_URL, { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const nav = page.locator('header, nav, [class*="header"], [class*="nav"], [class*="sidebar"]').first()
await expect(nav).toBeVisible({ timeout: 10_000 })
await snap(page, 'cloud-nav')
})
})
// ── Cloud API — Health & Endpoints ──────────────────────────────────
test.describe('Cloud — API Health', () => {
test('bootnode API is healthy', async ({ request }) => {
const data = await httpGetJson(request, `${CLOUD_API}/health`) as { status: string; version: string }
console.log(` API: ${data.status} v${data.version}`)
expect(data.status).toBe('healthy')
})
test('API requires authentication for fleets', async ({ request }) => {
const res = await request.get(`${CLOUD_API}/v1/fleets`, {
ignoreHTTPSErrors: true,
timeout: 10_000,
})
// 401/403 = auth required, 404 = route requires auth before matching
expect([401, 403, 404]).toContain(res.status())
const json = await res.json()
expect(json.detail).toBeTruthy()
})
test('API WebSocket endpoint reachable', async ({ request }) => {
const status = await httpStatus(request, 'https://ws.cloud.lux.network/', 10_000)
console.log(` WS endpoint: HTTP ${status}`)
expect(status).toBeGreaterThan(0)
})
})
// ── Cloud — Login Flow (OAuth via lux.id -> hanzo.id) ───────────────
test.describe('Cloud — Login Flow', () => {
test('login page loads', async ({ page }) => {
const res = await page.goto(`${CLOUD_URL}/login`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
expect(res?.status()).toBe(200)
await page.waitForTimeout(2000)
await snap(page, 'cloud-login')
})
test('login redirects to IAM (lux.id / hanzo.id)', async ({ page }) => {
await page.goto(`${CLOUD_URL}/login`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(5000)
const currentUrl = page.url()
// In production mode, login page auto-redirects to IAM OAuth
const isOnIam = currentUrl.includes('hanzo.id') ||
currentUrl.includes('lux.id') ||
currentUrl.includes('oauth/authorize')
const isOnLogin = currentUrl.includes('/login')
// Should either redirect to IAM or show login form
expect(isOnIam || isOnLogin).toBe(true)
console.log(` Login redirected to: ${currentUrl}`)
await snap(page, 'cloud-login-redirect')
})
test('IAM OAuth endpoint is accessible', async ({ request }) => {
// Verify lux.id (backed by hanzo.id) OAuth is live
const data = await httpGetJson(request, 'https://lux.id/.well-known/openid-configuration') as Record<string, unknown>
expect(data.authorization_endpoint).toBeTruthy()
expect(data.token_endpoint).toBeTruthy()
console.log(` Issuer: ${data.issuer}`)
console.log(` Auth endpoint: ${data.authorization_endpoint}`)
})
test('OAuth authorize URL works with lux-cloud client', async ({ page }) => {
// Built through authorizeUrl() so the path can only ever be IAM's one
// routed authorize path. The old version hand-built /oauth/authorize and
// asserted status 200 + "url contains lux.id" — both of which the IdP's
// SPA catch-all satisfies for ANY dead path, so it passed on a 404 in
// disguise. Measured 2026-07-27: lux.id/oauth/authorize -> 200 text/html
// (shell), same as the control lux.id/zzz-control.
// WITH a PKCE challenge, because lux-cloud is a public client and IAM
// refuses one without it. Omitting it does not fail at the endpoint — the
// request is bounced back to /auth/callback carrying
// `error=invalid_request&error_description=PKCE+is+required+for+public+clients`,
// so the test died on a login form that a rejected request never reaches.
// That reads as "authorize is broken" when authorize was enforcing its
// contract and the REQUEST was the thing that was wrong.
const { challenge, method } = pkce()
const authUrl = authorizeUrl('https://lux.id', {
client_id: OIDC.clientId,
response_type: 'code',
redirect_uri: `${CLOUD_URL}${OIDC.callbackPath}`,
scope: 'openid profile email',
code_challenge: challenge,
code_challenge_method: method,
})
const res = await page.goto(authUrl, { timeout: 30_000, waitUntil: 'domcontentloaded' })
const status = res?.status() || 0
const ctype = res?.headers()['content-type'] || ''
const landed = page.url()
console.log(` OAuth authorize: ${status} ${ctype} -> ${landed}`)
await snap(page, 'cloud-oauth')
// A REJECTED request is the one that comes back to our own callback with an
// OAuth error. That is the shape a missing PKCE challenge produces, and it
// is the only server-side signal available here.
expect(
/[?&]error=/.test(landed) ? landed : '',
`authorize refused the request and bounced to ${landed}`,
).toBe('')
// Everything else has to be judged on the DOM, because the server cannot
// tell us. Measured 2026-08-08: the login page IAM lands on and a control
// request to a path that does not exist return BYTE-IDENTICAL 654-byte SPA
// shells — same status, same content-type, same body. isRealAuthorizeResponse
// cannot separate them and is deliberately not used here; the form only
// exists after the SPA runs, so rendering it is the proof the route is real.
await expect(page.locator('input[type="password"], form[action*="login"]').first())
.toBeVisible({ timeout: 15_000 })
})
test('auth callback page exists', async ({ page }) => {
const res = await page.goto(`${CLOUD_URL}/auth/callback`, { timeout: 15_000, waitUntil: 'domcontentloaded' })
// Will show error (no code param) but page should load
expect(res?.status()).toBe(200)
await page.waitForTimeout(2000)
await snap(page, 'cloud-auth-callback')
})
})
// ── Cloud — Dashboard (requires auth, verify page structure) ────────
test.describe('Cloud — Dashboard Pages', () => {
test('dashboard redirects to login when unauthenticated', async ({ page }) => {
await page.goto(`${CLOUD_URL}/dashboard`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(5000)
const currentUrl = page.url()
// Should redirect to login or IAM
const redirectedToAuth = currentUrl.includes('/login') ||
currentUrl.includes('hanzo.id') ||
currentUrl.includes('lux.id') ||
currentUrl.includes('oauth')
// Or it may show dashboard with login prompt
console.log(` Dashboard URL: ${currentUrl}`)
await snap(page, 'cloud-dashboard-unauth')
// Page should still return 200 (SPA handles auth client-side)
})
test('infrastructure/nodes page exists', async ({ page }) => {
await page.goto(`${CLOUD_URL}/dashboard/infrastructure/nodes`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
await snap(page, 'cloud-nodes-page')
// SPA should render something
const bodyLen = (await page.textContent('body'))?.length || 0
expect(bodyLen).toBeGreaterThan(0)
})
test('returns calculator page exists', async ({ page }) => {
await page.goto(`${CLOUD_URL}/dashboard/calculator`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
await snap(page, 'cloud-calculator-page')
const bodyLen = (await page.textContent('body'))?.length || 0
expect(bodyLen).toBeGreaterThan(0)
})
})
// ── Cloud — Node Launch UI (unauthenticated structure check) ────────
test.describe('Cloud — Node Launch Interface', () => {
test('shows blockchain selection or launch UI', async ({ page }) => {
await page.goto(CLOUD_URL, { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(5000)
// Look for any node/blockchain/launch related UI on the landing page
const launchUI = page.locator(
'[class*="node"], [class*="Node"], [class*="chain"], [class*="Chain"], ' +
'[class*="launch"], [class*="Launch"], [class*="deploy"], [class*="Deploy"], ' +
'[class*="validator"], [class*="Validator"], [class*="rpc"], [class*="RPC"], ' +
'text=/launch/i, text=/deploy/i, text=/node/i, text=/validator/i, text=/RPC/i, text=/Get Started/i'
).first()
const visible = await launchUI.isVisible({ timeout: 10_000 }).catch(() => false)
if (visible) {
await snap(page, 'cloud-launch-ui')
}
expect(await page.title()).toBeTruthy()
})
test('products/node page accessible', async ({ page }) => {
const res = await page.goto(`${CLOUD_URL}/products/node`, { timeout: 15_000, waitUntil: 'domcontentloaded' })
// May 404 if route doesn't exist in this build, or 200
const status = res?.status() || 0
console.log(` /products/node: HTTP ${status}`)
await snap(page, 'cloud-products-node')
})
test('chains page accessible', async ({ page }) => {
const res = await page.goto(`${CLOUD_URL}/chains`, { timeout: 15_000, waitUntil: 'domcontentloaded' })
const status = res?.status() || 0
console.log(` /chains: HTTP ${status}`)
await snap(page, 'cloud-chains')
})
})
// ── Cloud — Validator Network Status ────────────────────────────────
test.describe('Cloud — Validator Network', () => {
test('P-Chain has >= 5 validators (scaling to 8)', async ({ request }) => {
const validators = await getCurrentValidators(request, urls.rpcPlatform) as { nodeID: string; weight: string }[]
console.log(` Validators: ${validators.length} (target: 8)`)
for (const v of validators) {
console.log(` ${v.nodeID} weight=${v.weight}`)
}
// 5 genesis + up to 3 new via operator scale-up (KMS-backed keys)
expect(validators.length).toBeGreaterThanOrEqual(5)
if (validators.length < 8) {
console.log(` NOTE: ${8 - validators.length} more validators pending (operator scaling)`)
}
})
test('all validators have 100% uptime', async ({ request }) => {
// Public P-Chain RPC does not attest peer uptime ("0.0000"); assert the
// public signal instead — every validator carries positive stake weight.
const validators = await getCurrentValidators(request, urls.rpcPlatform) as { nodeID: string; uptime: string; weight: string }[]
for (const v of validators) {
console.log(` ${v.nodeID} uptime=${v.uptime} weight=${v.weight}`)
expect(BigInt(v.weight)).toBeGreaterThan(0n)
}
console.log(` All ${validators.length} validators carry stake weight`)
})
})