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]>
172 lines
7.5 KiB
TypeScript
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)
|
|
})
|
|
}
|
|
})
|