Files
universe/e2e/13-iam-billing-universes.spec.ts
zeekayandHanzo Dev 0ad6ac563d e2e: one way to build an IAM authorize URL; kill the silent-pass
IAM routes exactly ONE authorize path (iam internal/oidc/oidc.go
PathAuthorize) = /v1/iam/oauth/authorize. Two specs hand-built
`${iam}/oauth/authorize` instead, and the IdP SPA answers ANY unrouted
path with 200 text/html — so both specs asserted a 2xx/3xx and PASSED
on a dead endpoint.

Measured on lux.id 2026-07-27, same query string on each:
  /v1/iam/oauth/authorize  302                              <- REAL (redirect to login)
  /v1/iam/oauth/authorize  400 text/plain  "invalid redirect_uri" (unregistered uri)
  /oauth/authorize         200 text/html   (SPA shell)
  /oauth2/authorize        200 text/html   (SPA shell)
  /zzz-control             200 text/html   (SPA shell)      <- CONTROL: 200 proves nothing
  /v1/iam/.well-known/jwks 200 application/json (real keys)

env.ts now owns the path (OIDC.authorizePath/tokenPath/jwksPath) and
exports authorizeUrl() — the same "one way" that orgRpc() already gives
RPC URLs. isRealAuthorizeResponse() rejects the shell: a 200 text/html
can no longer pass.

Proof both directions, playwright 13-iam '-g OAuth authorize endpoint':
  canonical path  -> 6 passed, 3 skipped (Zoo DNS); logs "400 text/plain"
  NEGATIVE CONTROL, authorizePath flipped back to /oauth/authorize
                  -> 2 failed (Hanzo, Lux)
The old assertion passed on that identical response.

05-cloud also gains a positive DOM assertion (a visible password input)
instead of "current URL contains lux.id", which the shell satisfies.

Also: cm/exchange-config's gateway URL. The live ConfigMap in ns
lux-mainnet still carries /mainnet/ext/bc/C/rpc and this source had been
half-converted to /mainnet/v1/bc/C/rpc — still dead. Measured:
  api.lux.network/mainnet/ext/bc/C/rpc 404
  api.lux.network/mainnet/v1/bc/C/rpc  404   <- the /mainnet/ segment is dead too
  api.lux.network/v1/bc/C/rpc          200 eth_chainId 0x17871 (96369)

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 18:08:38 -07:00

172 lines
7.5 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { httpStatus } from './helpers'
import { authorizeUrl, isRealAuthorizeResponse } from './env'
// Per-universe IAM + commerce config — Hanzo / Lux / Zoo only.
interface Universe {
name: string
iamUrl: string
iamClientId: string
iamOrg: string
commerceApi?: string
landingUrl?: string
}
const UNIVERSES: Universe[] = [
{
name: 'Hanzo',
iamUrl: 'https://hanzo.id',
iamClientId: 'app-hanzo',
iamOrg: 'hanzo',
commerceApi: 'https://commerce-api.hanzo.ai',
landingUrl: 'https://hanzo.ai',
},
{
name: 'Lux',
iamUrl: 'https://lux.id',
iamClientId: 'app-lux',
iamOrg: 'lux',
commerceApi: 'https://commerce-api.hanzo.ai', // shared multi-tenant backend
landingUrl: 'https://lux.network',
},
{
name: 'Zoo',
iamUrl: 'https://zoo.id',
iamClientId: 'app-zoo',
iamOrg: 'zoo',
commerceApi: 'https://commerce-api.hanzo.ai',
landingUrl: 'https://zoo.network',
},
]
// ── IAM Reachability ────────────────────────────────────────────────
test.describe('Per-Universe IAM — Reach', () => {
for (const u of UNIVERSES) {
test(`${u.name}: ${u.iamUrl}`, async ({ request }) => {
const s = await httpStatus(request, u.iamUrl, 15_000)
console.log(` ${u.name}: ${s}`)
if (u.name === 'Zoo') {
test.skip(true, 'zoo.id DNS not provisioned yet (reserved at registry)')
}
expect([200, 301, 302, 307, 308]).toContain(s)
})
}
})
// ── IAM OIDC Discovery ──────────────────────────────────────────────
test.describe('Per-Universe IAM — OIDC Discovery', () => {
// The actual OIDC backend is shared at iam.hanzo.ai (multi-tenant by org/clientId)
test('shared IAM backend serves OIDC well-known', async ({ request }) => {
const res = await request.get('https://iam.hanzo.ai/.well-known/openid-configuration', { timeout: 15_000 })
expect(res.status()).toBe(200)
const json = await res.json() as {
issuer: string
jwks_uri: string
authorization_endpoint: string
token_endpoint: string
userinfo_endpoint: string
}
console.log(` issuer: ${json.issuer}`)
// Shared Hanzo IAM issues under its canonical login host (hanzo.id).
expect(json.issuer).toContain('hanzo')
expect(json.jwks_uri).toBeTruthy()
expect(json.authorization_endpoint).toBeTruthy()
expect(json.token_endpoint).toBeTruthy()
expect(json.userinfo_endpoint).toBeTruthy()
})
test('JWKS endpoint returns valid keys', async ({ request }) => {
// FOLLOW THE DISCOVERY DOCUMENT — never a hand-built path. This test used
// to GET `https://iam.hanzo.ai/.well-known/jwks`, which returns 200 with
// `text/html`: it is the SPA catch-all, and `JSON.parse` threw. Two bugs in
// one line — a guessed path, and 200-as-evidence, the same trap that made a
// deleted KMS route look alive.
//
// MEASURED 2026-07-26: discovery declares
// `https://hanzo.id/v1/iam/.well-known/jwks`, which serves JSON with
// kid=cert-adnexus. /v1, as the house rule says — never /api, and never a
// bare /.well-known/jwks that the IdP does not publish.
const disco = await request.get('https://iam.hanzo.ai/.well-known/openid-configuration', { timeout: 15_000 })
const { jwks_uri } = await disco.json() as { jwks_uri: string }
expect(jwks_uri, 'discovery declares no jwks_uri').toBeTruthy()
expect(jwks_uri, 'jwks_uri must be under /v1, never /api').not.toContain('/api/')
console.log(` jwks_uri (declared): ${jwks_uri}`)
const res = await request.get(jwks_uri, { timeout: 10_000 })
expect(res.status()).toBe(200)
expect(res.headers()['content-type'],
`${jwks_uri} served ${res.headers()['content-type']} — that is the SPA catch-all, not JWKS`)
.toContain('json')
const json = await res.json() as { keys: Array<{ kid: string; kty: string; alg: string }> }
expect(json.keys).toBeDefined()
expect(json.keys.length).toBeGreaterThan(0)
expect(json.keys[0].kty).toBeTruthy()
console.log(` ${json.keys.length} keys, kids: ${json.keys.map(k => k.kid).join(', ')}`)
})
})
// ── IAM Login Page UI (per universe brand) ──────────────────────────
test.describe('Per-Universe IAM — Login UI', () => {
for (const u of UNIVERSES) {
test(`${u.name} /login renders form`, async ({ page }) => {
if (u.name === 'Zoo') test.skip(true, 'zoo.id DNS not provisioned')
await page.goto(`${u.iamUrl}/login`, { waitUntil: 'networkidle', timeout: 30_000 })
await page.waitForTimeout(2000)
const userInput = page.locator('input[type="text"], input[type="email"], input[name*="user" i], input[name*="email" i]').first()
await expect(userInput).toBeVisible({ timeout: 10_000 })
const pwd = page.locator('input[type="password"]').first()
await expect(pwd).toBeVisible({ timeout: 10_000 })
})
}
})
// ── Commerce / Billing Endpoints ────────────────────────────────────
test.describe('Commerce (Billing) — Health + Plans', () => {
test('commerce-api healthz', async ({ request }) => {
const res = await request.get('https://commerce-api.hanzo.ai/health', { timeout: 10_000 })
console.log(` commerce /health: ${res.status()}`)
expect([200, 301, 302]).toContain(res.status())
})
test('commerce /v1/commerce namespace reachable', async ({ request }) => {
const res = await request.get('https://commerce-api.hanzo.ai/v1/commerce/plans', { timeout: 10_000 })
console.log(` /v1/commerce/plans: ${res.status()}`)
// 200 if plans public, 401/403 if auth required — both mean service is up
expect([200, 401, 403, 404]).toContain(res.status())
})
})
// ── Sign up + Sign in dry-run (no real account creation) ────────────
// This exercises the form handlers + OAuth2 authorize flow without persisting users.
test.describe('Per-Universe IAM — Auth Flow Smoke', () => {
for (const u of UNIVERSES) {
test(`${u.name} OAuth authorize endpoint accepts client_id=${u.iamClientId}`, async ({ request }) => {
if (u.name === 'Zoo') test.skip(true, 'zoo.id DNS not provisioned')
const params = new URLSearchParams({
client_id: u.iamClientId,
response_type: 'code',
scope: 'openid profile email',
redirect_uri: `${u.iamUrl}/callback`,
state: 'e2e-smoke',
})
const res = await request.get(authorizeUrl(u.iamUrl, Object.fromEntries(params)), {
maxRedirects: 0,
timeout: 15_000,
})
// A 200 is NOT evidence: the IdP SPA answers any unrouted path with 200
// text/html, so the previous [200,302,303,307,308] assertion passed on a
// dead endpoint. Measured 2026-07-27 on lux.id — /oauth/authorize,
// /oauth2/authorize and the control /zzz-control all return 200
// text/html, while the routed /v1/iam/oauth/authorize returns 400
// text/plain "authorization error: invalid redirect_uri".
const ctype = res.headers()['content-type'] || ''
console.log(` ${u.name}: ${res.status()} ${ctype}`)
expect(
isRealAuthorizeResponse(res.status(), ctype),
`${u.name} authorize answered the SPA shell (${res.status()} ${ctype})`,
).toBe(true)
expect(res.status()).toBeLessThan(500)
})
}
})