Files
universe/e2e/40-lux-financial.spec.ts
zeekayandHanzo Dev a32988281e lux-bank runs bankd v1.0.18, and Lux ID is the one sign-in the suite proves
bankd v1.0.18 takes only a lux.id access token issued to lux-financial for
the lux org, maps only a member of the admin org to _superusers, and listens
on HTTP alone. Its pod reads none of the bank-secrets keys, so the five env
references to them are gone.

bank-docs states the amd64 node selector its pod already runs with.

e2e/40-lux-financial proves the one way in: neither console serves a password
input or ships a credential in its JS; every sign-in screen hands off to lux.id
with client lux-financial, PKCE S256 and the console's /auth/callback; bankd
answers 404 on every password and Base sign-in route, the code exchange takes
only lux-financial by code or refresh, and a token lux.id did not issue to
lux-financial reads as anonymous. The password-form sign-in and the seeded
ledger totals it asserted are deleted: neither exists.

k8s/lux-k8s/{bank-api,bank-dash,bank-docs,bank-sandbox,bankd,financial} and
scripts/seed-bank-admin.ts are deleted. deploy/lux-bank declares the bank,
lux.financial is a static site, and no host those files name resolves.


Co-authored-by: Hanzo Dev <[email protected]>
2026-09-23 05:39:03 -07:00

225 lines
13 KiB
TypeScript

import { test, expect, Page, APIRequestContext } from '@playwright/test'
import { httpStatus, snap } from './helpers'
// ═══════════════════════════════════════════════════════════════════════════
// Lux Financial — one way in, and the surfaces around it
//
// Customer : https://app.lux.financial (Vite SPA → bankd @ api.lux.financial)
// Admin : https://admin.lux.financial (Vite SPA → bankd)
// Docs : https://docs.lux.financial (Docusaurus)
// Backend : api.lux.financial (Hanzo Base app "bankd", /v1/*)
//
// Both consoles sign in only with Lux ID: the public client lux-financial, PKCE
// S256, back to https://<console>/auth/callback. No console serves a password
// input or ships a credential in its JS, and bankd serves no password route and
// takes no token but a lux.id access token issued to lux-financial.
//
// Resilient by design: unreachable surfaces (HTTP 0 / ≥500) skip-with-note
// rather than hard-failing the run.
// ═══════════════════════════════════════════════════════════════════════════
const APP = 'https://app.lux.financial'
const ADMIN = 'https://admin.lux.financial'
const DOCS = 'https://docs.lux.financial'
const API = 'https://api.lux.financial'
const IDP_HOST = 'lux.id'
const CLIENT = 'lux-financial'
// ---------------------------------------------------------------------------
// Reachability cache
// ---------------------------------------------------------------------------
const _reach: Record<string, boolean> = {}
async function reachable(request: APIRequestContext, key: string, url: string): Promise<boolean> {
if (key in _reach) return _reach[key]
const status = await httpStatus(request, url, 15_000)
const ok = status > 0 && status < 500
_reach[key] = ok
if (!ok) console.log(` WARN: ${url} unreachable (HTTP ${status}) — skipping its tests`)
return ok
}
// ---------------------------------------------------------------------------
// Background lightness (0 = black … 1 = white) — parses rgb() and oklch().
// Customer uses rgb() (dark: rgb(0,0,0) / acm: rgb(245,247,250)).
// Admin uses oklch() (dark: oklch(0 0 0) / acm: oklch(1 0 0)).
// ---------------------------------------------------------------------------
async function bodyLightness(page: Page): Promise<number> {
return page.evaluate(() => {
const parse = (s: string): number | null => {
s = (s || '').trim()
let m = s.match(/oklch\(\s*([\d.]+)/)
if (m) return parseFloat(m[1]) // L already normalized 0..1
m = s.match(/rgba?\(([^)]+)\)/)
if (m) {
const [r, g, b] = m[1].split(',').map((x) => parseFloat(x))
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
}
return null
}
return parse(getComputedStyle(document.body).backgroundColor) ?? 0
})
}
// ═══════════════════════════════════════════════════════════════════════════
// Bank Customer — acm white-label (unauthenticated login screen)
// ═══════════════════════════════════════════════════════════════════════════
test.describe('Lux Financial — Bank Customer white-label', () => {
test('?brand=acm renders LIGHT theme with "acm" wordmark', async ({ page, request }) => {
test.skip(!(await reachable(request, 'app', APP)), 'app.lux.financial unreachable')
await page.goto(`${APP}/login?brand=acm`, { waitUntil: 'domcontentloaded', timeout: 45_000 })
await page.waitForTimeout(1500)
const L = await bodyLightness(page)
console.log(` customer acm background lightness: ${L.toFixed(3)}`)
expect(L).toBeGreaterThan(0.6) // near-white
await expect(page.locator('header')).toContainText('acm')
await expect(page.locator('body')).not.toContainText('Lux Financial')
await snap(page, 'luxfin-customer-acm-light')
})
test('default brand renders DARK theme with Lux Financial wordmark', async ({ page, request }) => {
test.skip(!(await reachable(request, 'app', APP)), 'app.lux.financial unreachable')
await page.goto(`${APP}/login`, { waitUntil: 'domcontentloaded', timeout: 45_000 })
await page.waitForTimeout(1500)
const L = await bodyLightness(page)
console.log(` customer default background lightness: ${L.toFixed(3)}`)
expect(L).toBeLessThan(0.4) // near-black
await expect(page.locator('header')).toContainText('Lux Financial')
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Bank Admin — acm white-label (unauthenticated login screen)
// ═══════════════════════════════════════════════════════════════════════════
test.describe('Lux Financial — Bank Admin white-label', () => {
test('?brand=acm renders LIGHT theme with "acm" wordmark (admin login)', async ({ page, request }) => {
test.skip(!(await reachable(request, 'admin', ADMIN)), 'admin.lux.financial unreachable')
await page.goto(`${ADMIN}/login?brand=acm`, { waitUntil: 'domcontentloaded', timeout: 45_000 })
await page.waitForTimeout(1500)
const L = await bodyLightness(page)
console.log(` admin acm background lightness: ${L.toFixed(3)}`)
expect(L).toBeGreaterThan(0.6)
await expect(page.locator('body')).toContainText('acm')
await snap(page, 'luxfin-admin-acm-light')
})
})
// ═══════════════════════════════════════════════════════════════════════════
// One way in — Lux ID, from every sign-in screen
// ═══════════════════════════════════════════════════════════════════════════
test.describe('Lux Financial — one way in', () => {
const screens: Array<[string, string, RegExp]> = [
[APP, '/login', /sign in with lux id/i],
[APP, '/signup', /continue with lux id/i],
[ADMIN, '/login', /sign in with lux id/i],
]
for (const [origin, path, button] of screens) {
test(`${new URL(origin).host}${path}: no password, Lux ID with PKCE`, async ({ page, request }) => {
test.skip(!(await reachable(request, origin, origin)), `${origin} unreachable`)
const bundles: string[] = []
page.on('response', async (r) => {
if (/\.js(\?|$)/.test(r.url())) bundles.push(await r.text().catch(() => ''))
})
await page.goto(`${origin}${path}`, { waitUntil: 'networkidle', timeout: 45_000 })
expect(await page.locator('input[type=password]').count(), 'a console never asks for a password').toBe(0)
expect(bundles.length).toBeGreaterThan(0)
for (const js of bundles) expect(js, 'a credential shipped in the JS').not.toMatch(/Ilove|DEMO_PASSWORD|demoLogin/)
await Promise.all([
page.waitForURL((u) => u.host === IDP_HOST, { timeout: 30_000 }),
page.getByRole('button', { name: button }).click(),
])
const q = new URL(page.url()).searchParams
expect(q.get('client_id')).toBe(CLIENT)
expect(q.get('response_type')).toBe('code')
expect(q.get('code_challenge_method')).toBe('S256')
expect(q.get('code_challenge') ?? '').toMatch(/^[A-Za-z0-9_-]{43,128}$/)
expect(q.get('client_secret'), 'a browser client carries no secret').toBeNull()
expect(q.get('redirect_uri')).toBe(`${origin}/auth/callback`)
})
}
})
// ═══════════════════════════════════════════════════════════════════════════
// Docs — docs.lux.financial
// ═══════════════════════════════════════════════════════════════════════════
test.describe('Lux Financial — Docs', () => {
test('compliance-full page loads with regulatory content', async ({ page, request }) => {
test.skip(!(await reachable(request, 'docs', DOCS)), 'docs.lux.financial unreachable')
const res = await page.goto(`${DOCS}/docs/compliance-full`, { waitUntil: 'domcontentloaded', timeout: 45_000 })
expect(res?.status()).toBeLessThan(400)
const body = page.locator('body')
await expect(body).toContainText(/Compliance/i)
// Substantive regulatory content, not an empty shell.
await expect(body).toContainText(/AML|KYC|BSA|regulatory/i)
await snap(page, 'luxfin-docs-compliance')
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Backend — api.lux.financial (bankd)
// ═══════════════════════════════════════════════════════════════════════════
test.describe('Lux Financial — Backend (bankd)', () => {
test('api.lux.financial/v1/health is healthy', async ({ request }) => {
test.skip(!(await reachable(request, 'api', `${'https://api.lux.financial'}/v1/health`)), 'api.lux.financial unreachable')
const res = await request.get('https://api.lux.financial/v1/health', { ignoreHTTPSErrors: true, timeout: 15_000 })
expect(res.status()).toBe(200)
const json = (await res.json()) as { code?: number }
expect(json.code).toBe(200)
})
test('bankd serves no password route and mints no token of its own', async ({ request }) => {
test.skip(!(await reachable(request, 'api', `${API}/v1/health`)), 'api.lux.financial unreachable')
for (const path of [
'/v1/bank/login',
'/v1/platform/auth/login',
'/v1/collections/users/auth-with-password',
'/v1/collections/_superusers/auth-with-password',
'/v1/collections/users/auth-refresh',
]) {
const res = await request.post(`${API}${path}`, { data: {}, failOnStatusCode: false })
expect(res.status(), path).toBe(404)
}
})
test('the code exchange takes only lux-financial, by code or refresh', async ({ request }) => {
test.skip(!(await reachable(request, 'api', `${API}/v1/health`)), 'api.lux.financial unreachable')
for (const form of [
{ grant_type: 'password', client_id: CLIENT, username: 'x', password: 'x' },
{ grant_type: 'authorization_code', client_id: 'hanzo-cli', code: 'x' },
]) {
const res = await request.post(`${API}/v1/iam/oauth/token`, { form, failOnStatusCode: false })
expect(res.status(), form.grant_type).toBe(400)
expect((await res.json()).error).toBe('unauthorized_client')
}
})
test('a token lux.id did not issue to lux-financial is anonymous', async ({ request }) => {
test.skip(!(await reachable(request, 'api', `${API}/v1/health`)), 'api.lux.financial unreachable')
// Unsigned, and naming another issuer, client and org: the shapes bankd refuses.
const b64 = (o: object) => Buffer.from(JSON.stringify(o)).toString('base64url')
const exp = Math.floor(Date.now() / 1000) + 3600
const forged = `${b64({ alg: 'none', typ: 'JWT' })}.${b64({
iss: 'https://hanzo.id', aud: ['hanzo-cli'], owner: 'admin', sub: 'x', exp,
tokenType: 'access-token', isAdmin: true, orgs: [{ org: 'admin' }],
})}.`
for (const auth of [undefined, `Bearer ${forged}`]) {
const headers = auth ? { Authorization: auth } : undefined
const res = await request.get(`${API}/v1/bank/overview`, { headers, failOnStatusCode: false })
expect(res.status()).toBe(401)
const su = await request.get(`${API}/v1/collections/_superusers/records`, { headers, failOnStatusCode: false })
expect(su.status()).toBe(403)
}
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Planned surfaces — skip-with-note until DNS/deploy lands
// ═══════════════════════════════════════════════════════════════════════════
test.describe('Lux Financial — Planned surfaces', () => {
test('issuer.lux.financial (planned — not yet live)', async ({ request }) => {
const status = await httpStatus(request, 'https://issuer.lux.financial', 12_000)
test.skip(status === 0 || status >= 500, `issuer.lux.financial not live (HTTP ${status})`)
expect(status).toBeLessThan(400)
})
})