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]>
225 lines
13 KiB
TypeScript
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)
|
|
})
|
|
})
|