Compare commits

...
4 Commits
Author SHA1 Message Date
Hanzo AI ce37139741 feat(config): resolve IAM issuer per ENV from request host (devnet/testnet)
Build Docker Image / docker (push) Successful in 2m44s
console2 already resolves brand from the hostname, but mapped host->brand
only, so a non-prod host (console.devnet.hanzo.ai) resolved brand=hanzo and
fell back to the PROD issuer hanzo.id -> sign-in bounced to prod IAM.

Add envFromHost() (env label in the host: console.devnet.hanzo.ai -> devnet,
console.testnet.hanzo.ai -> testnet, console.hanzo.ai -> mainnet) and
iamUrlFor(brand, env): mainnet keeps the brand vanity apex (hanzo.id),
non-prod uses the env-scoped subdomain id.<env>.hanzo.ai. /v1 stays
same-origin so the cloud base is already env-correct.

ONE brand-agnostic image now serves mainnet/testnet/devnet with no baked
NEXT_PUBLIC_* — the documented build-time issuer override still wins if set.
+7 unit tests (envFromHost + per-env resolveConfig); full suite 155 green.
2026-06-28 15:28:48 -07:00
Hanzo AI e3e42c279f fix(security): /paas gate keys on GLOBAL admin (C1), pin session authority, block path traversal, drop x-powered-by
Red NO-GO on the v0.4.0 token re-add: the /paas gate architecture is right
(server verifies the IAM session via cloud /v1/get-account, trusts no
client-decoded token, deny-by-default) but it gated on the WRONG flag —
org-scoped `isAdmin`. The platform service token is unscoped (every tenant,
every cluster) and the proxy forwards no user identity, so the gate is the
SOLE authz: any tenant ORG admin (e.g. owner=maxpower, isAdmin=true) would
reach the GLOBAL control plane. v0.4.1, TDD (failing test -> fix -> green).

1. [C1] /paas now requires a GLOBAL platform admin, mirroring the IAM
   backend's own rule object.User.IsGlobalAdmin() == (Owner == conf.AdminOrg,
   default "admin"): owner in {admin, built-in} OR an explicit isGlobalAdmin
   verdict. org-scoped isAdmin is NO LONGER sufficient. isAdminAccount ->
   isGlobalAdminAccount (its only caller was /paas). org-admin -> 403,
   global-admin -> forward, unauth -> 401, anon -> 401.
2. [HIGH] getServerAccount authority is PINNED to server-only CLOUD_URL
   (in-cluster cloud-api), never the request origin — a Host/X-Forwarded-Host
   spoof can no longer move the session-authority endpoint (which could forge
   an admin account). No pinned authority -> fail secure (null, no network).
   getServerAccount(cookie) drops the origin arg (callers: /paas, wallet).
3. [LOW] /paas rejects `.`/`..`/empty/separator path segments (400) so a
   `..` cannot escape the platform /v1 prefix via URL normalization.
4. [LOW] next.config poweredByHeader:false — drop the X-Powered-By fingerprint.

DO NOT re-add PAAS_SERVICE_TOKEN here — the CTO re-adds it to the CR after
Red confirms a non-global org-admin gets 403.

148 unit green; tsc --noEmit clean; next build green. CLOUD_URL must be set
on the console2 deployment (in-cluster cloud-api) for the gate to function.
2026-06-28 08:43:41 -07:00
Hanzo AI 345015b745 fix(security): gate /paas proxy, server-side authz, billing IDOR/idempotency, OIDC state+PKCE, strict CSP, drop client X-Org-Id, clear prefs on logout
Build Docker Image / docker (push) Successful in 2m38s
Seven hardening fixes, TDD (failing test → fix → green); +33 unit, +13 e2e.

1. [C1] app/paas/[...path] verifies the IAM session (same-origin /v1/get-account
   via lib/auth/server) AND requires admin BEFORE attaching PAAS_SERVICE_TOKEN —
   deny-by-default: unauth 401, non-admin 403, admin reaches upstream, honest 501
   when the token is unset. Re-add PAAS_SERVICE_TOKEN to the CR after re-review.
2. Function-level authz: catch-all [...slug] denies admin-only modules to
   non-admins (Access required, module never mounts). ADMIN_PRODUCT_IDS is the
   GUI-free source of truth, drift-guarded against the catalog admin:true entries.
3. Billing top-up: userId derived from the verified session (was body.userId =
   IDOR); txHash sent as Idempotency-Key so a replay never double-credits.
4. OIDC: callback validates state against the stored value before exchange (was
   unchecked) and surfaces ?error; PKCE S256 machinery added, gated off
   (NEXT_PUBLIC_IAM_PKCE) until casibase /v1/signin forwards code_verifier.
5. Strict security headers in next.config: CSP (frame-ancestors none, object-src
   none, base-uri, scoped form-action/connect-src), HSTS, nosniff, no-referrer,
   X-Frame-Options DENY, Permissions-Policy.
6. client.ts no longer sends X-Org-Id (spoofable, not a trust boundary); org is
   derived server-side from the session.
7. signOut clears the localStorage preferences cache (no cross-user leak).

136 unit + 111 e2e green; tsc --noEmit clean; production build green.
2026-06-28 07:54:33 -07:00
Hanzo AI a318a23065 test: add E2E + unit suite (200 tests) and fix 3 bugs found via TDD
Greenfield test infrastructure for console2 (had zero tests):

- vitest 3 unit layer (test/unit, 102 tests): routing, catalog/registry
  integrity, brand config, the /v1 client envelope + REST layer, account
  anonymous handling, honest-state mapping, and the provider/model/store/app
  domain logic. Heavy GUI deps aliased to hermetic stubs (test/stubs) so the
  registry graph imports without rendering Tamagui in Node.
- Playwright E2E layer (test/e2e, 98 tests): real clicks/forms/navigation over
  auth (IAM OIDC), the catalog home, the sidebar, every enabled module + honest
  empty/403/404/503 states, ALL admin flows (IAM tabs, Audit, Secrets/KMS,
  Clusters, Kubernetes, Settings, API Keys), negative authz, forms, mobile +
  desktop viewports, and clean-console checks. Hermetic: /v1 + /paas mocked via
  route interception — never touches real prod data.
- One-command CI: `npm run test:e2e` (webServer builds + serves), `test:unit`,
  `test:all`.

Bugs fixed (TDD red -> green, each with a regression test):
- favorites: DEFAULT_PINNED named a non-existent catalog id ('billing'); the
  shell silently drops unknown pins so new users lost a pin. -> ['chat','cost'].
- least privilege: the sidebar + home rendered admin-only surfaces (IAM/KMS/
  Secrets/Audit/Clusters/Kubernetes) to every user. Added visibleCatalog(isAdmin)
  and wired it into the shell + home; server-side 403 remains the authority.
- config: brandFromHost mapped every brand's .id host except pars.id. Added it.

Behavior-neutral: nav-sidebar/page-content/pinned-section testIDs on the shell
to scope E2E assertions; DEFAULT_PINNED exported for its regression test.
2026-06-28 07:06:43 -07:00
65 changed files with 6026 additions and 135 deletions
+7
View File
@@ -7,6 +7,13 @@ dist/
*.tsbuildinfo
next-env.d.ts
# test artifacts (the suites under test/ ARE committed; only outputs are ignored)
/test-results/
/playwright-report/
/blob-report/
/coverage/
/.playwright/
# env
.env*.local
+12
View File
@@ -21,3 +21,15 @@ agents working here:
- **Boundaries:** frontend only. No DB. No Docker builds locally (CI/CD builds
images). No secrets in the repo — config is `NEXT_PUBLIC_*` only.
- **Verify:** `npm run typecheck` and `npm run build` must pass. Show output.
- **Tests (real, committed under `test/`):**
- `npm run test:unit` — vitest, pure client logic + catalog/data-integrity
(routing, registry, config, the `/v1` client envelope, domain `logic.ts`).
Heavy GUI deps are aliased to hermetic stubs (`test/stubs/`) so the registry
graph imports without rendering Tamagui in Node.
- `npm run test:e2e` — Playwright against the real Next server (builds + serves
via `webServer`). HERMETIC: the `/v1` + `/paas` backend is mocked with route
interception (`test/e2e/fixtures.ts`) — tests NEVER touch real prod data.
Fixtures: `ACCOUNTS.admin/member/anonymous`, `backend.account()/envelope()/
rest()/error()/paas()`, `baseline()`, `landAs()`, `trackConsoleErrors()`.
- `npm run test:all` — both. E2E scopes assertions with the `nav-sidebar`,
`page-content`, and `pinned-section` testIDs on the shell.
+44
View File
@@ -153,6 +153,50 @@ npm run dev # http://localhost:4000
Data layer is the unified `/v1` backend — this repo is frontend only. Do NOT
add Postgres/Mongo/etc. Do NOT build Docker images locally (CI/CD does that).
## Testing (committed under `test/`)
Two layers, orthogonal: vitest for pure logic + data-integrity, Playwright for
real rendered UI + interaction. Run:
```bash
npm run test:unit # vitest (jsdom) — fast, hermetic, no server
npm run test:e2e # playwright — builds + serves the real app, mocks /v1
npm run test:all # both
```
- **Unit (`test/unit/`, vitest 3, `vitest.config.ts`)** — 102 tests over the pure
surface: `matchRoute`, the catalog/registry invariants (unique ids, no dead
routes, no empty category, admin-visibility), `brandFromHost`/`resolveConfig`,
the `/v1` client (envelope unwrap, `ApiError`, REST layer), `AccountApi`
(anonymous == logged-out), honest-state mapping, and the provider/model/store/
app domain `logic.ts`. The heavy GUI deps (`@hanzo/gui`, `@hanzogui/*`, icons,
`ethers`, `@zap-proto/*`, the IAM SDK) are aliased to hermetic stubs in
`test/stubs/` so the registry module graph imports without rendering Tamagui in
Node. Real rendering is the E2E layer's job.
- **E2E (`test/e2e/`, `@playwright/test`, `playwright.config.ts`)** — 98 tests
exercising real clicks/forms/navigation across auth, the catalog home, the
sidebar, every enabled module (+ honest empty/403/404/503 states), all admin
flows (IAM tabs, Audit, Secrets/KMS, Clusters, Kubernetes, Settings, API Keys),
negative authz, forms, mobile+desktop viewports, and clean-console checks.
HERMETIC + SAFE: the `/v1` envelope API, the plain-REST provisioning kinds, and
the `/paas` proxy are all mocked with route interception in
`test/e2e/fixtures.ts` (`backend.account()/envelope()/rest()/error()/paas()`,
`baseline()`, `landAs()`, `trackConsoleErrors()`) — the suite NEVER touches
real prod data. The shell exposes `nav-sidebar` / `page-content` /
`pinned-section` testIDs purely to scope E2E assertions.
Bugs found + fixed via TDD while writing the suite:
- **Dead default pin** — `favorites.tsx` `DEFAULT_PINNED` named `billing`, which
is not a catalog id (the billing surface id is `cost`); the shell silently
drops unknown pins, so new users lost a pin. Fixed to `['chat','cost']`.
- **Admin nav leak (least privilege)** — the sidebar and catalog home rendered
admin-only entries (IAM/KMS/Secrets/Audit/Clusters/Kubernetes) to every user.
Added `visibleCatalog(isAdmin)` (registry), wired into the shell + home, so
non-admins never see admin surfaces. Server-side 403 remains the authority
(defense in depth).
- **Host→brand gap** — `brandFromHost` mapped every brand's `.id` host except
`pars.id` (asymmetric). Added it.
## Cloud console — 10-category CLOUD AXIS + embedded PaaS (feat/cloud-taxonomy-10cat)
The catalog (`src/lib/products/registry.tsx`) is reorganized from 6 ad-hoc
+18
View File
@@ -4,17 +4,35 @@ import { use } from 'react'
import { notFound } from 'next/navigation'
import { matchRoute } from '~/lib/products/match'
import { isAdminProductId } from '~/lib/auth/admin'
import { useSession } from '~/lib/auth/session'
import { ApiError } from '~/lib/api'
import { ErrorState } from '~/components/ui/States'
import { Loader } from '~/components/ui/Loader'
/**
* Catch-all product route. Resolves the module + route from the registry and
* renders its component. Adding a product anywhere in the registry makes its
* routes live here — no per-product page files.
*
* Function-level authz: an admin-only product (IAM/KMS/Secrets/Audit/Clusters/
* Kubernetes) NEVER renders for a non-admin, however the URL was reached — nav
* hiding is cosmetic, this is the gate. The backend `/v1` endpoints remain the
* server-side authority (defense in depth); this stops the admin UI from ever
* mounting (and firing those calls) for a non-admin.
*/
export default function ProductPage({ params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = use(params)
const { account, loading } = useSession()
const matched = matchRoute(slug)
if (!matched) notFound()
if (isAdminProductId(matched.module.id) && !account?.isAdmin) {
if (loading) return <Loader />
return <ErrorState err={new ApiError('Admin access is required for this surface.', 403)} />
}
const Component = matched.route.component
return <Component params={matched.params} />
}
+5 -2
View File
@@ -13,9 +13,10 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { Star, Lock, ExternalLink, ArrowRight, Info } from '@hanzogui/lucide-icons-2'
import { branding, config } from '~/config'
import { catalogByCategory, type CatalogEntry } from '~/lib/products/registry'
import { catalogByCategory, visibleCatalog, type CatalogEntry } from '~/lib/products/registry'
import { openProduct } from '~/lib/products/open'
import { useFavorites } from '~/lib/products/favorites'
import { useSession } from '~/lib/auth/session'
import { PageHeader } from '~/components/ui/PageHeader'
const STATUS_LABEL = { enabled: 'Enabled', external: 'External', soon: 'Soon' } as const
@@ -106,9 +107,11 @@ function ProductCard({
export default function DashboardHome() {
const router = useRouter()
const { account } = useSession()
const { toggle, isPinned } = useFavorites()
const push = (path: string) => router.push(path)
const groups = catalogByCategory()
// Least privilege: non-admins don't see admin-only product cards on the home.
const groups = catalogByCategory(visibleCatalog(Boolean(account?.isAdmin)))
return (
<>
+30 -4
View File
@@ -1,31 +1,57 @@
'use client'
/**
* IAM OAuth callback. IAM redirects here with `?code&state`; we exchange them
* for a backend session (`/v1/signin`) and land on the dashboard. On failure we
* surface the error and offer a retry.
* IAM OAuth callback. IAM redirects here with `?code&state` (or `?error`). We:
* 1. surface any IdP `error`,
* 2. require `code` + `state`,
* 3. validate `state` against the value we stored at sign-in start (CSRF /
* authorization-code-injection defense) BEFORE exchanging the code,
* 4. exchange code (+ PKCE verifier) for a backend session, and land on `/`.
*
* The exchange runs exactly once (a ref guard) so React's dev double-effect can't
* consume the one-time state twice and false-flag a mismatch.
*/
import { Suspense, useEffect, useState } from 'react'
import { Suspense, useEffect, useRef, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button, Text, YStack } from '@hanzo/gui'
import { ApiError } from '~/lib/api'
import { Loader } from '~/components/ui/Loader'
import { useSession } from '~/lib/auth/session'
import { consumeState, describeAuthError } from '~/lib/auth/iam'
function Callback() {
const params = useSearchParams()
const router = useRouter()
const { completeSignIn } = useSession()
const [error, setError] = useState<string | null>(null)
const ran = useRef(false)
useEffect(() => {
if (ran.current) return
ran.current = true
const idpError = params.get('error')
if (idpError) {
setError(describeAuthError(idpError, params.get('error_description')))
return
}
const code = params.get('code')
const state = params.get('state')
if (!code || !state) {
setError('Missing authorization code.')
return
}
// CSRF / code-injection defense: the returned state MUST match the one we
// stored when starting sign-in. Consume (clear) it either way.
const expected = consumeState()
if (!expected || state !== expected) {
setError('This sign-in could not be verified (state mismatch). Please sign in again.')
return
}
completeSignIn(code, state)
.then(() => router.replace('/'))
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : 'Sign-in failed.'))
+36 -22
View File
@@ -9,34 +9,31 @@
* and config comes from server-only env (sourced via KMS, never `NEXT_PUBLIC`).
*
* Flow: the client sends an HUSD ERC-20 transfer to the treasury and posts the
* tx hash here. We read the receipt from the Hanzo EVM, confirm it is a mined,
* successful HUSD `Transfer(from → treasury, value)`, derive USD cents from the
* (18-decimal, USD-pegged) value, then record it to commerce as a `husd` crypto
* payment and return the credited amount + the new balance. The on-chain amount
* — never a client-supplied number — is what gets credited.
* tx hash here. We require a valid IAM session and derive the credited USER from
* it (NEVER the request body — that would be an IDOR). We read the receipt from
* the Hanzo EVM, confirm a mined, successful HUSD `Transfer(from → treasury,
* value)`, derive USD cents from the (18-decimal, USD-pegged) value, then record
* it to commerce as a `husd` crypto payment keyed by the tx hash as an
* idempotency key (replay-safe — the same tx never credits twice). The on-chain
* amount — never a client-supplied number — is what gets credited.
*
* Honest failure: if HUSD/treasury are unconfigured (greenfield — HUSD not yet
* deployed) we return 501 so the UI shows a truthful "coming" state; if the tx
* is missing/failed/not an HUSD-to-treasury transfer we return 400; if the chain
* or commerce is unreachable we return 502. Never a fabricated credit.
* Honest failure: if HUSD/treasury are unconfigured (greenfield) we return 501;
* if there is no session we return 401; if the tx is missing/failed/not an
* HUSD-to-treasury transfer we return 400; chain/commerce unreachable → 502.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { ethers } from 'ethers'
export const runtime = 'nodejs'
import { getServerAccount } from '~/lib/auth/server'
const RPC_URL = (process.env.HANZO_RPC_URL ?? 'https://rpc.hanzo.network').replace(/\/+$/, '')
const HUSD_ADDRESS = (process.env.HANZO_HUSD_ADDRESS ?? '').trim()
const TREASURY = (process.env.HANZO_HUSD_TREASURY ?? '').trim()
const COMMERCE_URL = (process.env.COMMERCE_URL ?? 'https://api.hanzo.ai').replace(/\/+$/, '')
const CHAIN_ID = Number(process.env.HANZO_CHAIN_ID ?? '36900')
export const runtime = 'nodejs'
const ERC20_TRANSFER_ABI = ['event Transfer(address indexed from, address indexed to, uint256 value)']
const isAddr = (a: string): boolean => /^0x[0-9a-fA-F]{40}$/.test(a)
/** Forward the caller's identity (session cookie / bearer) to commerce. */
function authHeaders(req: NextRequest): Record<string, string> {
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' }
function authHeaders(req: NextRequest, extra: Record<string, string> = {}): Record<string, string> {
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json', ...extra }
const cookie = req.headers.get('cookie')
if (cookie) h.Cookie = cookie
const auth = req.headers.get('authorization')
@@ -45,6 +42,12 @@ function authHeaders(req: NextRequest): Record<string, string> {
}
export async function POST(req: NextRequest): Promise<NextResponse> {
const HUSD_ADDRESS = (process.env.HANZO_HUSD_ADDRESS ?? '').trim()
const TREASURY = (process.env.HANZO_HUSD_TREASURY ?? '').trim()
const RPC_URL = (process.env.HANZO_RPC_URL ?? 'https://rpc.hanzo.network').replace(/\/+$/, '')
const COMMERCE_URL = (process.env.COMMERCE_URL ?? 'https://api.hanzo.ai').replace(/\/+$/, '')
const CHAIN_ID = Number(process.env.HANZO_CHAIN_ID ?? '36900')
// Greenfield gate: no HUSD contract / treasury ⇒ honest "not configured".
if (!isAddr(HUSD_ADDRESS) || !isAddr(TREASURY)) {
return NextResponse.json(
@@ -53,7 +56,14 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
)
}
let body: { txHash?: string; fromAddress?: string; userId?: string }
// Authn: the credited user is the SESSION user — never the request body (IDOR).
const account = await getServerAccount(req.headers.get('cookie'))
if (!account) {
return NextResponse.json({ error: 'Sign in to top up your balance.' }, { status: 401 })
}
const userId = account.name
let body: { txHash?: string; fromAddress?: string }
try {
body = await req.json()
} catch {
@@ -119,10 +129,13 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
}
// ── 2. Record to commerce as an HUSD crypto payment ─────────────────────────
// The tx hash is the idempotency key: a replay of the same hash MUST NOT credit
// twice. The hash is globally unique on-chain, so the ledger (commerce) dedupes
// on it — `Idempotency-Key` is the standard request for that guarantee.
try {
const recordRes = await fetch(`${COMMERCE_URL}/v1/billing/payment`, {
method: 'POST',
headers: authHeaders(req),
headers: authHeaders(req, { 'Idempotency-Key': txHash }),
cache: 'no-store',
body: JSON.stringify({
method: 'crypto',
@@ -133,7 +146,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
txHash,
fromAddress: verifiedFrom!,
toAddress: TREASURY,
userId: body.userId,
userId,
}),
})
if (!recordRes.ok) {
@@ -145,11 +158,12 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
}
const payment = (await recordRes.json().catch(() => ({}))) as { status?: string }
// New balance (USD ledger) — best-effort; the credit already landed.
// New balance (USD ledger) — best-effort; the credit already landed. The user
// is the SESSION user, never a client-supplied id.
let balance = 0
try {
const balRes = await fetch(
`${COMMERCE_URL}/v1/billing/balance?user=${encodeURIComponent(body.userId ?? '')}&currency=usd`,
`${COMMERCE_URL}/v1/billing/balance?user=${encodeURIComponent(userId)}&currency=usd`,
{ headers: authHeaders(req), cache: 'no-store' },
)
if (balRes.ok) balance = ((await balRes.json()) as { balance?: number }).balance ?? 0
+49 -13
View File
@@ -1,31 +1,67 @@
/**
* Same-origin proxy to the platform.hanzo.ai control plane (Job 3 — embedded
* PaaS). The browser calls console2's OWN origin (`/paas/...`); this server-side
* handler forwards to `platform.hanzo.ai/v1/...`, injecting the service token
* from server-only env (sourced via KMS — never `NEXT_PUBLIC_`, never in the
* browser bundle). This is the real control-plane API, not an iframe stub.
* Same-origin proxy to the platform.hanzo.ai control plane (embedded PaaS). The
* browser calls console2's OWN origin (`/paas/...`); this server-side handler
* forwards to `platform.hanzo.ai/v1/...`, injecting the service token from
* server-only env (sourced via KMS — never `NEXT_PUBLIC_`, never in the browser
* bundle). This is the real control-plane API, not an iframe stub.
*
* When `PAAS_SERVICE_TOKEN` is unset the proxy returns an honest 501 so the UI
* can show a truthful "not configured" state — it never fabricates apps/deploys.
* SECURITY (deny-by-default): the proxy attaches a powerful, UNSCOPED platform
* service token (every tenant, every cluster), so EVERY request must first
* present a valid IAM session AND be a GLOBAL platform admin — both verified
* BEFORE the token is attached. The token carries no user scope, so the gate is
* the sole authz: a tenant ORG admin (`isAdmin`) is NOT enough; only a global
* admin (member of the admin org) passes. Unauthenticated → 401, non-global-admin
* → 403. The forwarded path is constrained to `/v1/...` (no `..` traversal).
* (Re-add `PAAS_SERVICE_TOKEN` to the deployment once this gate is confirmed.)
*
* When `PAAS_SERVICE_TOKEN` is unset the proxy returns an honest 501 (to global
* admins) so the UI shows a truthful "not configured" state — it never fabricates.
*/
import { type NextRequest, NextResponse } from 'next/server'
const PLATFORM_URL = (process.env.PLATFORM_URL ?? 'https://platform.hanzo.ai').replace(/\/+$/, '')
const TOKEN = process.env.PAAS_SERVICE_TOKEN ?? ''
import { getServerAccount, isGlobalAdminAccount } from '~/lib/auth/server'
/**
* A path segment is safe iff it cannot alter the resolved path: non-empty, not a
* relative `.`/`..`, and free of separators / NUL. This keeps the forwarded URL
* inside the platform's `/v1/` prefix (URL normalization would let `..` escape it).
*/
const isSafeSegment = (s: string): boolean =>
s.length > 0 && s !== '.' && s !== '..' && !s.includes('/') && !s.includes('\\') && !s.includes('\0')
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
if (!TOKEN) {
// Deny-by-default authz — verify the session + require GLOBAL admin BEFORE the
// token (uniform 401 for anyone unauthenticated, regardless of the path shape).
const account = await getServerAccount(req.headers.get('cookie'))
if (!account) {
return NextResponse.json({ error: 'Sign in to use the control plane.' }, { status: 401 })
}
if (!isGlobalAdminAccount(account)) {
return NextResponse.json(
{ error: 'Global platform admin access is required for the control plane.' },
{ status: 403 },
)
}
// Constrain the forwarded path to /v1/... — reject any traversal/odd segment.
if (!path.every(isSafeSegment)) {
return NextResponse.json({ error: 'Invalid control-plane path.' }, { status: 400 })
}
const token = process.env.PAAS_SERVICE_TOKEN ?? ''
if (!token) {
return NextResponse.json(
{ error: 'PaaS control plane is not configured (PAAS_SERVICE_TOKEN missing).' },
{ status: 501 },
)
}
const search = req.nextUrl.search
const url = `${PLATFORM_URL}/v1/${path.join('/')}${search}`
const platformUrl = (process.env.PLATFORM_URL ?? 'https://platform.hanzo.ai').replace(/\/+$/, '')
const url = `${platformUrl}/v1/${path.join('/')}${req.nextUrl.search}`
const init: RequestInit = {
method: req.method,
headers: {
Authorization: `Bearer ${TOKEN}`,
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
+48
View File
@@ -32,13 +32,61 @@ function guiPackages() {
return ['@hanzo/gui', '@hanzo/iam-js-sdk', 'react-native-web', ...scoped]
}
/**
* Strict security headers for every response (the ONE place they are set).
*
* CSP closes the high-value vectors: `frame-ancestors 'none'` kills clickjacking
* (+ X-Frame-Options: DENY for legacy UAs), `object-src 'none'` plugins,
* `base-uri 'self'` base-tag hijack, scoped `form-action`/`connect-src` limit
* credential/data exfil to the Hanzo/Lux/Zoo/Pars brand backends, HSTS forces TLS,
* nosniff stops MIME confusion, and Referrer-Policy stops URL leakage.
*
* `script-src` uses `'unsafe-inline'` (NOT a nonce): console2's pages are
* statically prerendered, so Next cannot inject a per-request nonce into the
* static HTML — a nonce+`strict-dynamic` policy blocks every script and
* white-screens the app. Nonce-strict CSP would require forcing dynamic rendering
* app-wide; tracked as a follow-up. console2 renders all data as escaped React
* text (no HTML-injection sink it introduces), so the residual XSS surface is low
* and the remaining directives still contain any exploit.
*/
const CSP = [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self' https://hanzo.id https://lux.id https://zoolabs.id https://pars.id",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
"connect-src 'self' https://*.hanzo.ai https://*.hanzo.network https://hanzo.id https://*.lux.cloud https://*.lux.network https://lux.id https://*.zoo.cloud https://*.zoo.ngo https://*.zoo.network https://zoolabs.id https://*.pars.cloud https://*.pars.network https://pars.id",
"frame-src 'self'",
"worker-src 'self' blob:",
"manifest-src 'self'",
].join('; ')
const SECURITY_HEADERS = [
{ key: 'Content-Security-Policy', value: CSP },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'no-referrer' },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'X-DNS-Prefetch-Control', value: 'off' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), browsing-topics=()' },
]
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// Drop the `X-Powered-By: Next.js` fingerprint (no framework disclosure).
poweredByHeader: false,
transpilePackages: guiPackages(),
experimental: {
esmExternals: true,
},
async headers() {
return [{ source: '/(.*)', headers: SECURITY_HEADERS }]
},
webpack(config) {
config.resolve.alias = {
...config.resolve.alias,
+2665 -11
View File
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/console2",
"version": "0.2.0",
"version": "0.4.2",
"private": true,
"license": "BSD-3-Clause",
"author": "Hanzo AI <dev@hanzo.ai>",
@@ -9,7 +9,12 @@
"dev": "next dev -p 4000",
"build": "next build",
"start": "next start -p 4000",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:unit": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:all": "vitest run && playwright test"
},
"dependencies": {
"@hanzo/gui": "7.3.0",
@@ -28,10 +33,14 @@
"react-native-web": "0.21.2"
},
"devDependencies": {
"@playwright/test": "1.61.1",
"@types/node": "22.20.0",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitest/coverage-v8": "3.2.6",
"jsdom": "29.1.1",
"react-native": "0.83.9",
"typescript": "5.9.3"
"typescript": "5.9.3",
"vitest": "3.2.6"
}
}
+40
View File
@@ -0,0 +1,40 @@
import { defineConfig, devices } from '@playwright/test'
/**
* E2E config for console2.
*
* Tests are HERMETIC: the cloud `/v1` backend and the `/paas` proxy are mocked
* with Playwright route interception (see test/e2e/fixtures.ts), so the suite is
* deterministic and never touches real prod data. The webServer builds and
* serves the REAL Next app, so every assertion is against real rendered UI and
* real client logic.
*
* Run: `npm run test:e2e` (one command; builds + serves + tests).
*/
const PORT = 4000
const BASE_URL = `http://localhost:${PORT}`
export default defineConfig({
testDir: './test/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
timeout: 45_000,
expect: { timeout: 10_000 },
use: {
baseURL: BASE_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
locale: 'en-US',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'npm run build && npm run start',
url: BASE_URL,
timeout: 300_000,
reuseExistingServer: !process.env.CI,
env: { NODE_OPTIONS: '--max-old-space-size=6144' },
},
})
+10 -5
View File
@@ -16,7 +16,7 @@ import { Button, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
import { LogOut, Star, Lock, ExternalLink, LayoutGrid, SlidersHorizontal } from '@hanzogui/lucide-icons-2'
import { branding } from '~/config'
import { catalogByCategory, findEntry, type CatalogEntry } from '~/lib/products/registry'
import { catalogByCategory, findEntry, visibleCatalog, type CatalogEntry } from '~/lib/products/registry'
import { openProduct } from '~/lib/products/open'
import { useFavorites } from '~/lib/products/favorites'
import { useSession } from '~/lib/auth/session'
@@ -81,15 +81,20 @@ export function DashboardShell({ children }: { children: ReactNode }) {
const push = (path: string) => router.push(path)
const isActive = (id: string) => pathname === `/${id}` || pathname.startsWith(`/${id}/`)
// Least privilege: a non-admin never sees admin-only surfaces in the nav.
const isAdmin = Boolean(account?.isAdmin)
const visible = visibleCatalog(isAdmin)
const pinnedEntries = pinned
.map((id) => findEntry(id))
.filter((e): e is CatalogEntry => Boolean(e))
.filter((e): e is CatalogEntry => Boolean(e) && (isAdmin || !e!.admin))
const groups = catalogByCategory()
const groups = catalogByCategory(visible)
return (
<XStack flex={1} minH="100vh" bg="$background">
<YStack
testID="nav-sidebar"
width={264}
p="$3"
gap="$2"
@@ -113,7 +118,7 @@ export function DashboardShell({ children }: { children: ReactNode }) {
<ScrollView flex={1}>
<YStack gap="$3">
{pinnedEntries.length > 0 ? (
<YStack gap="$1">
<YStack testID="pinned-section" gap="$1">
<Text px="$2" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
Pinned
</Text>
@@ -177,7 +182,7 @@ export function DashboardShell({ children }: { children: ReactNode }) {
</XStack>
<ScrollView flex={1}>
<YStack flex={1} p="$4" gap="$4">
<YStack testID="page-content" flex={1} p="$4" gap="$4">
{children}
</YStack>
</ScrollView>
+64 -7
View File
@@ -10,6 +10,13 @@
* proxy resolved from `window.location.hostname`, so the /v1 client + IAM SDK are
* per-host with no other wiring. `NEXT_PUBLIC_*` still OVERRIDES per field.
*
* The env TIER is ALSO resolved from the host's env label, so the SAME image
* serves mainnet/testnet/devnet without baking a prod issuer into a non-prod
* console: `console.hanzo.ai` → mainnet → issuer `hanzo.id`;
* `console.devnet.hanzo.ai` → devnet → issuer `id.devnet.hanzo.ai`;
* `console.testnet.hanzo.ai` → testnet → issuer `id.testnet.hanzo.ai`. /v1 stays
* same-origin per host, so the cloud base is inherently env-correct already.
*
* NOTE: hanzo's issuer is `hanzo.id` (NOT iam.hanzo.ai — legacy zone, iss mismatch
* drops sign-in). zoo's IAM is `zoolabs.id` (NOT zoo.id). client_id is `<org>-cloud`
* (HIP-0111). The cloud backend must accept all brand issuers/auds (multi-brand);
@@ -20,9 +27,14 @@ const trimSlash = (s: string) => s.replace(/\/+$/, '')
export type BrandId = 'hanzo' | 'lux' | 'zoo' | 'pars'
/** Deployment env tier, derived from the env label in the request host. */
export type EnvTier = 'mainnet' | 'testnet' | 'devnet'
export type ConsoleConfig = {
/** Resolved brand id (from hostname). */
brand: BrandId
/** Resolved env tier (from the host's env label; mainnet has no label). */
env: EnvTier
/** Wordmark shown in the shell, e.g. "Lux Cloud". */
brandName: string
/** Unified cloud backend base URL (hanzoai/cloud /v1) — shared across brands. */
@@ -37,6 +49,12 @@ export type ConsoleConfig = {
iamOrgName: string
/** IAM OAuth client id (= app) — shared. */
iamClientId: string
/**
* Emit PKCE (S256) on the authorize URL. Gated OFF until the cloud `/v1/signin`
* forwards the `code_verifier` to IAM (the deployed casibase exchanges the code
* without one); enable per-deploy with `NEXT_PUBLIC_IAM_PKCE=1`.
*/
iamPkce: boolean
/** Billing/account portal — the console LINKS here, never reimplements it. */
billingUrl: string
}
@@ -65,9 +83,18 @@ function cloudUrl(): string {
return 'https://api.hanzo.ai'
}
/** Per-brand IAM (each org's own issuer/app) + wordmark. Cloud backend is shared. */
const BRANDS: Record<BrandId, { brandName: string; iamUrl: string; iamOrgName: string; iamApp: string }> = {
hanzo: { brandName: 'Hanzo Cloud', iamUrl: 'https://hanzo.id', iamOrgName: 'hanzo', iamApp: 'hanzo-cloud' },
/**
* Per-brand IAM (each org's own issuer/app) + wordmark. Cloud backend is shared.
* `envApex` is the apex the env label attaches to for NON-prod IAM
* (`id.<env>.<apex>`). Only hanzo runs non-prod envs today (devnet/testnet under
* hanzo.ai); the other brands are mainnet-only here, so their non-prod issuer is
* intentionally left undefined (a deliberate, separate addition when needed).
*/
const BRANDS: Record<
BrandId,
{ brandName: string; iamUrl: string; iamOrgName: string; iamApp: string; envApex?: string }
> = {
hanzo: { brandName: 'Hanzo Cloud', iamUrl: 'https://hanzo.id', iamOrgName: 'hanzo', iamApp: 'hanzo-cloud', envApex: 'hanzo.ai' },
lux: { brandName: 'Lux Cloud', iamUrl: 'https://lux.id', iamOrgName: 'lux', iamApp: 'lux-cloud' },
zoo: { brandName: 'Zoo Cloud', iamUrl: 'https://zoolabs.id', iamOrgName: 'zoo', iamApp: 'zoo-cloud' },
pars: { brandName: 'Pars Cloud', iamUrl: 'https://pars.id', iamOrgName: 'pars', iamApp: 'pars-cloud' },
@@ -86,6 +113,7 @@ const HOST_BRANDS: ReadonlyArray<{ suffix: string; brand: BrandId }> = [
{ suffix: 'hanzo.id', brand: 'hanzo' },
{ suffix: 'pars.cloud', brand: 'pars' },
{ suffix: 'pars.network', brand: 'pars' },
{ suffix: 'pars.id', brand: 'pars' },
]
/** Resolve the brand id from a hostname (port/case-insensitive). Defaults to hanzo. */
@@ -99,31 +127,60 @@ export function brandFromHost(host?: string | null): BrandId {
return 'hanzo'
}
/**
* Resolve the env tier from a hostname. The env label sits left of the brand
* apex as its own DNS label: `console.devnet.hanzo.ai` → devnet,
* `id.testnet.hanzo.ai` → testnet, `console.hanzo.ai` → mainnet (no label).
* Port/case-insensitive. `devnet`/`testnet` only ever appear as the env label,
* so a label-membership test is unambiguous.
*/
export function envFromHost(host?: string | null): EnvTier {
const labels = (host ?? '').toLowerCase().replace(/:\d+$/, '').trim().split('.')
if (labels.includes('devnet')) return 'devnet'
if (labels.includes('testnet')) return 'testnet'
return 'mainnet'
}
/**
* IAM issuer for a brand at an env tier. mainnet uses the brand's vanity apex
* (`hanzo.id`); non-prod uses the env-scoped subdomain `id.<env>.<apex>`
* (`id.devnet.hanzo.ai`). Brands without a non-prod env keep the mainnet issuer.
*/
function iamUrlFor(brand: BrandId, env: EnvTier): string {
const b = BRANDS[brand]
if (env !== 'mainnet' && b.envApex) return `https://id.${env}.${b.envApex}`
return b.iamUrl
}
/** Current hostname: window in the browser, NEXT_PUBLIC_DEFAULT_HOST for SSR/build. */
function currentHost(): string {
if (typeof window !== 'undefined') return window.location.hostname
return process.env.NEXT_PUBLIC_DEFAULT_HOST ?? 'console.hanzo.ai'
}
const cache = new Map<BrandId, ConsoleConfig>()
const cache = new Map<string, ConsoleConfig>()
/** Resolve the full config for the current host. iamOrgName overridable via env. */
export function resolveConfig(host: string = currentHost()): ConsoleConfig {
const brand = brandFromHost(host)
const cached = cache.get(brand)
const env = envFromHost(host)
const key = `${brand}:${env}`
const cached = cache.get(key)
if (cached) return cached
const b = BRANDS[brand]
const resolved: ConsoleConfig = {
brand,
env,
brandName: b.brandName,
cloudUrl: cloudUrl(),
iamUrl: trimSlash(process.env.NEXT_PUBLIC_IAM_URL ?? b.iamUrl),
iamUrl: trimSlash(process.env.NEXT_PUBLIC_IAM_URL ?? iamUrlFor(brand, env)),
iamOrgName: process.env.NEXT_PUBLIC_IAM_ORG_NAME ?? b.iamOrgName,
iamAppName: process.env.NEXT_PUBLIC_IAM_APP_NAME ?? b.iamApp,
iamClientId: process.env.NEXT_PUBLIC_IAM_CLIENT_ID ?? b.iamApp,
iamPkce: process.env.NEXT_PUBLIC_IAM_PKCE === '1',
...SHARED,
}
cache.set(brand, resolved)
cache.set(key, resolved)
return resolved
}
+13 -2
View File
@@ -20,8 +20,19 @@ export const AccountApi = {
}
},
/** Exchange the IAM OAuth code+state for a backend session cookie. */
signin: (code: string, state: string) => post<Account>('signin', undefined, { code, state }),
/**
* Exchange the IAM OAuth code+state for a backend session cookie.
*
* `codeVerifier` is the PKCE verifier (sent only when PKCE is enabled); the
* cloud `/v1/signin` forwards it to IAM to complete the S256 exchange. State is
* validated on the client BEFORE this call (see `app/auth/callback`).
*/
signin: (code: string, state: string, codeVerifier?: string) =>
post<Account>('signin', undefined, {
code,
state,
...(codeVerifier ? { code_verifier: codeVerifier } : {}),
}),
signout: () => post('signout'),
+11 -13
View File
@@ -34,19 +34,17 @@ const acceptLanguage = (): string => {
}
/**
* Headers sent on every cloud call. Besides locale, we stamp `X-Org-Id` with the
* brand org (`config.iamOrgName`, hostname-derived — the user's OWN org, never a
* spoofable input). The casibase endpoints scope by the session's org claim, but
* the sub-services mounted on the same backend that speak plain REST (the
* provisioning service) require an explicit tenant header and reject the request
* with 403 "X-Org-Id required" without it — this is what lets the data-product
* modules resolve their tenant on the direct cloud-api path. When a gateway sits
* in front and re-injects the header from the JWT, the stamped value is simply
* overwritten, so sending it is correct in both topologies.
* Headers sent on every cloud call: locale + JSON content type for bodies.
*
* Tenancy is NOT a client concern. The org is derived server-side from the
* validated session (the cloud backend reads the JWT `owner` claim and strips any
* client-supplied identity header), so the browser sends credentials only and
* NEVER an `X-Org-Id`. A client-set org header is spoofable by definition, so it
* is not a trust boundary and is not sent — the server is the sole authority for
* which org a request scopes to.
*/
const baseHeaders = (hasBody: boolean): Record<string, string> => ({
'Accept-Language': acceptLanguage(),
'X-Org-Id': config.iamOrgName,
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
})
@@ -128,9 +126,9 @@ export const idOf = (owner: string, name: string): string => `${owner}/${encodeU
// control plane. Same cookie credentials and `ApiError` as the envelope path;
// only the body shape and verbs differ, so the transport stays in this one file.
//
// Tenancy is server-side: the gateway validates the session cookie and injects
// `X-Org-Id` from the JWT (and strips any client-supplied identity header), so
// the browser sends credentials only — never an org header.
// Tenancy is server-side: the cloud backend validates the session cookie and
// derives `X-Org-Id` from the JWT (stripping any client-supplied value), so the
// browser sends credentials only — never an org header.
/** Build a `/v1/<path>` URL on an arbitrary base (cloud backend by default). */
export const v1Url = (path: string, base: string = config.cloudUrl): string =>
+20
View File
@@ -0,0 +1,20 @@
/**
* Admin-only product ids — the single, GUI-free source of truth for which
* console surfaces require an admin session.
*
* The catalog (`registry.tsx`) marks the same entries `admin: true` for the
* nav/home hiding, but the registry pulls in the whole GUI module graph, so it
* can't be imported by lean server code or a route guard. This list is the lean
* mirror; `test/unit/admin-authz.test.ts` asserts the two never drift.
*/
export const ADMIN_PRODUCT_IDS: ReadonlySet<string> = new Set([
'iam',
'kms',
'secrets',
'audit',
'clusters',
'kubernetes',
])
/** True when a product id is an admin-only surface. */
export const isAdminProductId = (id: string): boolean => ADMIN_PRODUCT_IDS.has(id)
+113 -26
View File
@@ -1,10 +1,24 @@
/**
* Hanzo IAM (OIDC) client.
* Hanzo IAM (OIDC) client — sign-in start + callback helpers.
*
* Wraps `@hanzo/iam-js-sdk`. The SDK touches `window`/`sessionStorage`, so it is
* constructed lazily and only in the browser. Sign-in is the standard authorize
* redirect: `getSigninUrl()` -> IAM login -> our `/auth/callback?code&state` ->
* the backend `/v1/signin` exchanges code+state for a session cookie.
* Authentication is a standard authorize-code redirect to IAM (hanzo.id):
* `startSignIn()` -> IAM login -> our `/auth/callback?code&state` -> the cloud
* `/v1/signin` exchanges code+state for the first-party session cookie.
*
* Two anti-CSRF / anti-interception controls live here:
* - **state** (always): a fresh, cryptographically-random value is stamped on the
* authorize URL and persisted; the callback MUST match it before exchanging the
* code, defeating login-CSRF / authorization-code injection. (The previous flow
* posted code+state to `/v1/signin` and never checked state.)
* - **PKCE S256** (gated on `config.iamPkce`): a per-attempt `code_verifier` is
* stored and its S256 challenge is sent on the authorize URL; the verifier is
* handed to `/v1/signin` so the backend completes the PKCE exchange. It is
* gated because the deployed casibase `/v1/signin` exchanges the code WITHOUT a
* verifier today (`casdoorsdk.GetOAuthToken(code, state)`) — emitting a
* challenge before the backend forwards the verifier would break the exchange.
* Flip `NEXT_PUBLIC_IAM_PKCE=1` once the backend forwards `code_verifier`.
*
* The SDK is retained only for the signup URL.
*/
import Sdk from '@hanzo/iam-js-sdk'
@@ -13,12 +27,12 @@ import { config } from '~/config'
/** Path IAM redirects back to after authorize. */
export const CALLBACK_PATH = '/auth/callback'
let sdk: Sdk | null = null
const STATE_KEY = 'console2.oauth.state'
const VERIFIER_KEY = 'console2.oauth.verifier'
let sdk: Sdk | null = null
function iam(): Sdk {
if (typeof window === 'undefined') {
throw new Error('IAM SDK is browser-only')
}
if (typeof window === 'undefined') throw new Error('IAM SDK is browser-only')
if (!sdk) {
sdk = new Sdk({
serverUrl: config.iamUrl,
@@ -32,22 +46,95 @@ function iam(): Sdk {
return sdk
}
/** Full IAM authorize URL to begin sign-in. */
export const getSigninUrl = (): string => iam().getSigninUrl()
/**
* Authorize URL that hints a specific social provider (IAM provider names, e.g.
* `provider-github`, `provider-google`).
*
* The hint rides on the standard authorize redirect as `provider_hint`: IAM
* (hanzo.id) owns each provider's OAuth — client id, scope, callback — so the
* console never reconstructs github.com/accounts.google.com URLs. IAM advances
* straight to the provider when it recognises the hint, and otherwise renders
* its login page with the same providers; either way the console stays one
* authorize call with no duplicated IdP config.
*/
export const getProviderSigninUrl = (provider: string): string =>
`${getSigninUrl()}&provider_hint=${encodeURIComponent(provider)}`
/** Full IAM signup URL. */
export const getSignupUrl = (): string => iam().getSignupUrl()
// ── OAuth helpers (state + PKCE) ─────────────────────────────────────────────
/** URL-safe base64 (no padding) of raw bytes. */
function base64url(bytes: Uint8Array): string {
let s = ''
for (const b of bytes) s += String.fromCharCode(b)
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/** A cryptographically-random URL-safe token (default 256 bits of entropy). */
function randomToken(bytes = 32): string {
const a = new Uint8Array(bytes)
crypto.getRandomValues(a)
return base64url(a)
}
/** The PKCE S256 challenge for a verifier: BASE64URL(SHA-256(verifier)). */
export async function pkceChallenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
return base64url(new Uint8Array(digest))
}
const redirectUri = (): string => `${window.location.origin}${CALLBACK_PATH}`
/**
* Build the IAM authorize URL with a fresh CSRF `state` (always persisted) and a
* PKCE S256 challenge when enabled (verifier persisted). `provider` hints a
* social IdP (`provider-github`, `provider-google`); IAM owns each provider's
* OAuth, so the console never reconstructs the IdP URLs.
*/
export async function buildAuthorizeUrl(
opts: { provider?: string; pkce?: boolean } = {},
): Promise<string> {
const usePkce = opts.pkce ?? config.iamPkce
const state = randomToken()
sessionStorage.setItem(STATE_KEY, state)
const u = new URL(`${config.iamUrl}/login/oauth/authorize`)
u.searchParams.set('client_id', config.iamClientId)
u.searchParams.set('response_type', 'code')
u.searchParams.set('redirect_uri', redirectUri())
u.searchParams.set('scope', 'openid profile email')
u.searchParams.set('state', state)
if (usePkce) {
const verifier = randomToken()
sessionStorage.setItem(VERIFIER_KEY, verifier)
u.searchParams.set('code_challenge', await pkceChallenge(verifier))
u.searchParams.set('code_challenge_method', 'S256')
} else {
sessionStorage.removeItem(VERIFIER_KEY)
}
if (opts.provider) u.searchParams.set('provider_hint', opts.provider)
return u.toString()
}
/** Begin sign-in by redirecting to IAM (optionally hinting a social provider). */
export async function startSignIn(provider?: string): Promise<void> {
window.location.assign(await buildAuthorizeUrl({ provider }))
}
/** Read + clear the persisted CSRF state (one-time use). */
export function consumeState(): string | null {
const s = sessionStorage.getItem(STATE_KEY)
sessionStorage.removeItem(STATE_KEY)
return s
}
/** Read + clear the persisted PKCE verifier (one-time; null when PKCE was off). */
export function consumeCodeVerifier(): string | null {
const v = sessionStorage.getItem(VERIFIER_KEY)
sessionStorage.removeItem(VERIFIER_KEY)
return v
}
/** A human-readable explanation for an IdP `?error` returned on the callback. */
export function describeAuthError(code: string, description?: string | null): string {
if (description) return description
const map: Record<string, string> = {
access_denied: 'Sign-in was cancelled.',
invalid_request: 'The sign-in request was invalid. Please try again.',
invalid_scope: 'The sign-in request asked for an invalid scope.',
unauthorized_client: 'This application is not authorized to sign you in.',
server_error: 'The identity provider hit an error. Please try again.',
temporarily_unavailable: 'The identity provider is temporarily unavailable. Please try again.',
}
return map[code] ?? `Sign-in failed (${code}).`
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Server-side session reader — the ONE place a privileged server route verifies
* the caller's IAM session and authority.
*
* console2 keeps no session state of its own: the unified cloud `/v1` backend is
* the session authority (it mints the first-party session cookie at `/v1/signin`
* and answers `/v1/get-account`). So a server handler verifies the caller by
* FORWARDING the request's own cookies to `/v1/get-account` and reading the
* account back. Deny-by-default: no cookie, an anonymous session, a non-2xx, or
* an unparseable body all resolve to "no account" (the caller returns 401).
*
* AUTHORITY IS PINNED (never request-derived). The `/v1/get-account` URL comes
* from server-only `CLOUD_URL` (the in-cluster cloud-api), NOT from the request
* origin/Host. A request-derived authority would let a `Host`/`X-Forwarded-Host`
* spoof point the session check at an attacker host that forges an admin account
* and defeats the gate. When no authority is pinned we FAIL SECURE (treat the
* caller as unauthenticated) rather than trust the request.
*/
export type ServerAccount = {
owner: string
name: string
isAdmin: boolean
/** Backend's explicit global-admin verdict, if it ever serializes one. */
isGlobalAdmin: boolean
organization?: string
type?: string
}
/**
* Orgs whose members are GLOBAL platform admins (Hanzo staff). This mirrors the
* IAM backend's own rule — `object.User.IsGlobalAdmin() == (Owner == conf.AdminOrg)`,
* default admin org `"admin"`. `built-in` is casdoor's reserved system org (never
* a tenant) kept as defense-in-depth. A tenant org (hanzo, lux, zoo, maxpower, …)
* is NEVER global admin here — even when its member holds `isAdmin` (org-scoped
* admin). The global platform control plane (`/paas`) requires THIS, not `isAdmin`.
*/
const GLOBAL_ADMIN_ORGS: ReadonlySet<string> = new Set(['admin', 'built-in'])
/**
* The pinned cloud `/v1` base for a server-side session check. Server-only env
* ONLY — never the request origin. `CLOUD_URL` is the canonical in-cluster
* cloud-api (e.g. `http://cloud-api.hanzo.svc:8000`); `NEXT_PUBLIC_CLOUD_URL` is
* a fixed fallback for dev/split-origin. Returns `''` when neither is set, which
* makes `getServerAccount` fail secure.
*/
function backendBase(): string {
return (process.env.CLOUD_URL ?? process.env.NEXT_PUBLIC_CLOUD_URL ?? '').replace(/\/+$/, '')
}
/**
* The signed-in account for this request, or `null`. Never throws — every failure
* (missing cookie, no pinned authority, network error, non-2xx, bad body,
* anonymous casibase session) is treated as unauthenticated (fail secure). Makes
* NO network call when there is no cookie or no pinned authority.
*/
export async function getServerAccount(cookie: string | null): Promise<ServerAccount | null> {
if (!cookie) return null
const base = backendBase()
if (!base) return null // fail secure: no pinned authority → never trust the request
let res: Response
try {
res = await fetch(`${base}/v1/get-account`, {
headers: { cookie, accept: 'application/json' },
cache: 'no-store',
})
} catch {
return null
}
if (!res.ok) return null
let env: unknown
try {
env = await res.json()
} catch {
return null
}
const data = (env as { data?: Record<string, unknown> } | null)?.data
// casibase auto-creates an "anonymous-user" session — NOT a real sign-in.
if (!data || typeof data.name !== 'string' || data.type === 'anonymous-user') return null
return {
owner: typeof data.owner === 'string' ? data.owner : '',
name: data.name,
isAdmin: data.isAdmin === true,
isGlobalAdmin: data.isGlobalAdmin === true,
organization: typeof data.organization === 'string' ? data.organization : undefined,
type: typeof data.type === 'string' ? data.type : undefined,
}
}
/**
* Deny-by-default GLOBAL-admin predicate (also narrows the type for callers).
* GLOBAL admin ⟺ the backend's own rule: member of the admin org, or an explicit
* `isGlobalAdmin` verdict. NOT satisfied by org-scoped `isAdmin` — that is the C1
* fix: a tenant org admin must never reach the global control plane.
*/
export const isGlobalAdminAccount = (a: ServerAccount | null): a is ServerAccount =>
a != null && (a.isGlobalAdmin === true || GLOBAL_ADMIN_ORGS.has(a.owner))
+24 -15
View File
@@ -3,14 +3,18 @@
/**
* Session context — the one source of auth truth for the console.
*
* On mount it asks the backend `/v1/get-account`. `signIn()` redirects to IAM;
* `completeSignIn(code, state)` (used by the callback route) posts to
* `/v1/signin` to mint the session cookie, then reloads the account.
* On mount it asks the backend `/v1/get-account`. `signIn()` redirects to IAM
* (with CSRF state + optional PKCE — see `./iam`); `completeSignIn(code, state)`
* (used by the callback route, AFTER it validates state) posts to `/v1/signin` to
* mint the session cookie, then reloads the account. `signOut()` revokes the
* session AND wipes the local preferences cache so a shared browser never leaks
* the previous user's pins/layout.
*/
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
import { AccountApi, type Account } from '~/lib/api'
import { getProviderSigninUrl, getSigninUrl } from './iam'
import { clearPreferencesCache } from '~/lib/products/preferences-cache'
import { consumeCodeVerifier, startSignIn } from './iam'
type SessionState = {
account: Account | null
@@ -42,24 +46,29 @@ export function SessionProvider({ children }: { children: ReactNode }) {
}, [reload])
const signIn = useCallback(() => {
window.location.assign(getSigninUrl())
void startSignIn()
}, [])
const signInWith = useCallback((provider: string) => {
window.location.assign(getProviderSigninUrl(provider))
void startSignIn(provider)
}, [])
const completeSignIn = useCallback(
async (code: string, state: string) => {
const res = await AccountApi.signin(code, state)
setAccount(res.data ?? (await AccountApi.current()))
},
[],
)
const completeSignIn = useCallback(async (code: string, state: string) => {
// The callback validated `state` before calling us; pair the code with its
// one-time PKCE verifier (null when PKCE is off) for the backend exchange.
const codeVerifier = consumeCodeVerifier()
const res = await AccountApi.signin(code, state, codeVerifier ?? undefined)
setAccount(res.data ?? (await AccountApi.current()))
}, [])
const signOut = useCallback(async () => {
await AccountApi.signout()
setAccount(null)
try {
await AccountApi.signout()
} finally {
// Always clear local state, even if the revoke call failed.
clearPreferencesCache()
setAccount(null)
}
}, [])
return (
+7 -2
View File
@@ -11,8 +11,13 @@ import { useCallback } from 'react'
import { usePreferences } from './preferences'
/** Sensible first-run pins so the Pinned section isn't empty for new users. */
const DEFAULT_PINNED = ['chat', 'billing']
/**
* Sensible first-run pins so the Pinned section isn't empty for new users.
* Every id MUST be a real catalog id — the shell drops unknown ids, so a
* non-existent id (e.g. the old `billing`; the billing surface is `cost`) would
* silently vanish. Guarded by test/unit/favorites.test.ts.
*/
export const DEFAULT_PINNED = ['chat', 'cost']
export type Favorites = {
/** Pinned product ids, in pin order. */
+34
View File
@@ -0,0 +1,34 @@
/**
* Preferences fast-paint cache — the localStorage key scheme, in ONE place so
* both the provider (which writes it) and sign-out (which must clear it) agree.
*
* The cache is only a flash-of-pins optimization; the IAM account is the source
* of truth. On sign-out it MUST be wiped so a shared browser never paints the
* previous user's pins/layout to the next person.
*/
/** localStorage key prefix for the per-user preferences cache. */
export const PREFS_CACHE_PREFIX = 'hanzo.console2.prefs.'
/** Per-user (or anonymous) cache key. */
export const prefsCacheKey = (name: string | undefined): string => `${PREFS_CACHE_PREFIX}${name ?? 'anon'}`
/** localStorage if present (absent in SSR / some privacy modes), else undefined. */
function storage(): Storage | undefined {
return typeof window !== 'undefined' ? window.localStorage : undefined
}
/**
* Remove EVERY cached preferences entry (all users + anon) from localStorage.
* Called on sign-out. No-op when localStorage is unavailable.
*/
export function clearPreferencesCache(): void {
const ls = storage()
if (!ls) return
const stale: string[] = []
for (let i = 0; i < ls.length; i++) {
const key = ls.key(i)
if (key && key.startsWith(PREFS_CACHE_PREFIX)) stale.push(key)
}
for (const key of stale) ls.removeItem(key)
}
+6 -6
View File
@@ -7,7 +7,8 @@
* Source of truth is the signed-in user's IAM account (`properties` →
* `hanzo.preferences`), so customizations follow the user across every product
* and every device/login. localStorage is used ONLY as a fast-paint cache to
* avoid a flash before the account loads — it is never authoritative.
* avoid a flash before the account loads — it is never authoritative, and it is
* wiped on sign-out (see `preferences-cache`).
*
* Writes are optimistic + write-through: the local view updates immediately and
* `update-preferences` persists to the account (self-scoped, shallow-merged
@@ -25,6 +26,7 @@ import {
import { AccountApi } from '~/lib/api'
import { useSession } from '~/lib/auth/session'
import { prefsCacheKey } from './preferences-cache'
/** Must match the backend `preferencesKey` (controllers/account.go). */
const PREFS_PROPERTY = 'hanzo.preferences'
@@ -43,8 +45,6 @@ type PreferencesState = {
const PreferencesContext = createContext<PreferencesState | null>(null)
const cacheKey = (name: string | undefined) => `hanzo.console2.prefs.${name ?? 'anon'}`
function parsePrefs(raw: string | undefined | null): Preferences {
if (!raw) return {}
try {
@@ -64,14 +64,14 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
useEffect(() => {
// Fast-paint from the local cache so pins don't flash on a cold load…
if (typeof window !== 'undefined' && !account) {
setPrefs(parsePrefs(window.localStorage.getItem(cacheKey(name))))
setPrefs(parsePrefs(window.localStorage.getItem(prefsCacheKey(name))))
return
}
// …then the account becomes the source of truth once it arrives.
const fromAccount = parsePrefs(account?.properties?.[PREFS_PROPERTY])
setPrefs(fromAccount)
if (typeof window !== 'undefined') {
window.localStorage.setItem(cacheKey(name), JSON.stringify(fromAccount))
window.localStorage.setItem(prefsCacheKey(name), JSON.stringify(fromAccount))
}
setReady(Boolean(account))
}, [account, name])
@@ -81,7 +81,7 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
setPrefs((prev) => {
const next = { ...prev, [key]: value }
if (typeof window !== 'undefined') {
window.localStorage.setItem(cacheKey(name), JSON.stringify(next))
window.localStorage.setItem(prefsCacheKey(name), JSON.stringify(next))
}
// Write-through to the account (self-scoped server-side). Optimistic:
// a failure leaves the local + cache view; the next load reconciles.
+20 -3
View File
@@ -1174,8 +1174,25 @@ export const findModule = (id: string): ProductModule | undefined =>
export const findEntry = (id: string): CatalogEntry | undefined =>
catalog.find((e) => e.id === id)
/** The catalog grouped by category, in display order, skipping empty groups. */
export const catalogByCategory = (): { category: ProductCategory; entries: CatalogEntry[] }[] =>
/**
* The catalog entries a viewer may SEE in nav/home. Admin-only entries (IAM,
* KMS, Secrets, Audit, Clusters, Kubernetes) are hidden from non-admins — least
* privilege, applied as a defense-in-depth UX layer. The AUTHORITATIVE gate
* remains server-side: each admin `/v1` endpoint 403s an unauthorized session
* and the module renders an honest "Access required". This just stops a
* non-admin from seeing the door (or reaching it from the nav).
*/
export const visibleCatalog = (isAdmin: boolean): CatalogEntry[] =>
isAdmin ? catalog : catalog.filter((e) => !e.admin)
/**
* The catalog grouped by category, in display order, skipping empty groups.
* Pass a filtered list (e.g. `visibleCatalog(isAdmin)`) to scope what renders;
* defaults to the full catalog.
*/
export const catalogByCategory = (
entries: CatalogEntry[] = catalog,
): { category: ProductCategory; entries: CatalogEntry[] }[] =>
categoryOrder
.map((category) => ({ category, entries: catalog.filter((e) => e.category === category) }))
.map((category) => ({ category, entries: entries.filter((e) => e.category === category) }))
.filter((g) => g.entries.length > 0)
+157
View File
@@ -0,0 +1,157 @@
import { test, expect, ACCOUNTS, baseline, trackConsoleErrors } from './fixtures'
/**
* Admin surfaces — Identity & Access (orgs/users/roles), Audit, Secrets (KMS),
* Clusters, Kubernetes, Settings, and API Keys. These are the highest-bar pages:
* real tabbed navigation, real CRUD-shaped reads, and honest states — all driven
* by an admin session. Every read is mocked, so nothing touches real prod data.
*/
test.describe('IAM (Identity & Access)', () => {
const ORGS = [
{ owner: 'admin', name: 'hanzo', displayName: 'Hanzo', websiteUrl: 'https://hanzo.ai', createdTime: '2026-01-02T00:00:00Z' },
]
const USERS = [
{ owner: 'hanzo', name: 'ada-admin', displayName: 'Ada Admin', email: 'ada@example.test', isAdmin: true, type: 'normal-user' },
{ owner: 'hanzo', name: 'mo-member', displayName: 'Mo Member', email: 'mo@example.test', isAdmin: false, type: 'normal-user' },
]
const ROLES = [
{ owner: 'hanzo', name: 'cloud-admins', displayName: 'Cloud Admins', isEnabled: true, users: ['hanzo/ada-admin'] },
]
test('organizations tab lists orgs and exposes the tab bar', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin).envelope('iam/get-organizations', ORGS, 1)
await page.goto('/iam')
const content = page.getByTestId('page-content')
await expect(content.getByText('Identity & Access')).toBeVisible()
await expect(content.getByText('Hanzo', { exact: true })).toBeVisible()
for (const tab of ['Organizations', 'Users', 'Roles']) {
await expect(content.getByText(tab, { exact: true })).toBeVisible()
}
})
test('users tab lists users with admin/member role badges', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin).envelope('iam/get-organizations', ORGS, 1).envelope('iam/get-users', USERS, 2)
await page.goto('/iam')
await page.getByTestId('page-content').getByText('Users', { exact: true }).click()
await page.waitForURL('**/iam/users')
const content = page.getByTestId('page-content')
await expect(content.getByText('Ada Admin')).toBeVisible()
await expect(content.getByText('Mo Member')).toBeVisible()
await expect(content.getByText('admin', { exact: true })).toBeVisible()
})
test('roles tab lists roles', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin).envelope('iam/get-roles', ROLES, 1)
await page.goto('/iam/roles')
await expect(page.getByTestId('page-content').getByText('cloud-admins')).toBeVisible()
})
test('shows an honest "needs admin" state on 403', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin).error('iam/get-organizations', 403)
await page.goto('/iam')
await expect(page.getByText('Access required')).toBeVisible()
await expect(page.getByText(/requires an admin session/)).toBeVisible()
})
test('shows an honest "not routed" state on 404', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin).error('iam/get-organizations', 404)
await page.goto('/iam')
await expect(page.getByText(/The IAM admin API .* is not routed/)).toBeVisible()
})
})
test.describe('Audit', () => {
test('lists identity & access events', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin).envelope(
'iam/get-records',
[{ id: 1, createdTime: '2026-06-20T10:00:00Z', user: 'ada-admin', action: 'login', method: 'POST', requestUri: '/v1/signin', clientIp: '203.0.113.7' }],
1,
)
await page.goto('/audit')
const content = page.getByTestId('page-content')
await expect(content.getByText('Audit')).toBeVisible()
await expect(content.getByText('ada-admin')).toBeVisible()
await expect(content.getByText('203.0.113.7')).toBeVisible()
})
})
test.describe('Secrets (KMS)', () => {
test('states the zero-knowledge model and probes reachability', async ({ page, backend }) => {
const errors = trackConsoleErrors(page)
backend.account(ACCOUNTS.admin) // default 200 -> reachable
await page.goto('/secrets')
const content = page.getByTestId('page-content')
await expect(content.getByText('Zero-knowledge by design')).toBeVisible()
await expect(content.getByText('KMS reachable')).toBeVisible()
await expect(content.getByText('KMS console').first()).toBeVisible()
expect(errors).toEqual([])
})
})
test.describe('Clusters', () => {
test('renders the dedicated-cluster surface with an honest empty state', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin).paas('org/hanzo/cluster', { clusters: [] })
await page.goto('/clusters')
const content = page.getByTestId('page-content')
await expect(content.getByText('Clusters')).toBeVisible()
await expect(content.getByText(/No dedicated clusters yet/)).toBeVisible()
})
})
test.describe('Kubernetes', () => {
test('renders the workloads surface with an honest empty state', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin).paas('apps', { apps: [] })
await page.goto('/kubernetes')
const content = page.getByTestId('page-content')
await expect(content.getByText('Kubernetes')).toBeVisible()
await expect(content.getByText(/No workloads/)).toBeVisible()
})
})
test.describe('Settings', () => {
test('General tab shows the real signed-in account', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin)
await page.goto('/settings')
const content = page.getByTestId('page-content')
await expect(content.getByText('Settings')).toBeVisible()
await expect(content.getByText('ada@example.test')).toBeVisible()
})
test('Branding tab shows the resolved per-host runtime config', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin)
await page.goto('/settings')
await page.getByTestId('page-content').getByText('Branding', { exact: true }).click()
await page.waitForURL('**/settings/branding')
const content = page.getByTestId('page-content')
await expect(content.getByText('https://hanzo.id')).toBeVisible()
await expect(content.getByText('Hanzo Cloud', { exact: true })).toBeVisible()
})
test('Members tab lists organization members', async ({ page, backend }) => {
backend
.account(ACCOUNTS.admin)
.envelope('iam/get-users', [{ owner: 'hanzo', name: 'mo-member', displayName: 'Mo Member', email: 'mo@example.test', isAdmin: false, type: 'normal-user' }], 1)
await page.goto('/settings/members')
await expect(page.getByTestId('page-content').getByText('Mo Member')).toBeVisible()
})
})
test.describe('API Keys', () => {
test('explains where keys live when the account exposes none', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin) // no accessKey/accessSecret
await page.goto('/api-keys')
const content = page.getByTestId('page-content')
await expect(content.getByText('No API key on this account')).toBeVisible()
await expect(content.getByText('API docs')).toBeVisible()
})
test('masks the credential by default and reveals it on click', async ({ page, backend }) => {
backend.account({ ...ACCOUNTS.admin, accessKey: 'hk-abc123def456ghi', accessSecret: 'sk-topsecretvalue99' })
await page.goto('/api-keys')
const content = page.getByTestId('page-content')
// Masked: the full secret is NOT visible initially.
await expect(content.getByText('hk-abc123def456ghi')).toHaveCount(0)
await content.getByText('Reveal').first().click()
await expect(content.getByText('hk-abc123def456ghi')).toBeVisible()
})
})
+86
View File
@@ -0,0 +1,86 @@
import { test, expect, ACCOUNTS, trackConsoleErrors } from './fixtures'
/**
* Auth is delegated to Hanzo IAM (OIDC). The console gates every dashboard route
* behind a real session: an anonymous/absent session is logged-out and must be
* bounced to /signin; a real session lands on the dashboard; sign-out clears it.
*/
test.describe('authentication', () => {
test('an unauthenticated visitor is redirected to /signin', async ({ page, backend }) => {
backend.account(ACCOUNTS.anonymous) // casibase auto-anon == logged-out
await page.goto('/')
await page.waitForURL('**/signin')
await expect(page.getByText('Sign in to manage your cloud.')).toBeVisible()
})
test('the sign-in page offers IAM, GitHub, and Google (no credential fields)', async ({ page, backend }) => {
backend.account(ACCOUNTS.anonymous)
await page.goto('/signin')
await expect(page.getByText('Continue with Hanzo ID')).toBeVisible()
await expect(page.getByText('Continue with GitHub')).toBeVisible()
await expect(page.getByText('Continue with Google')).toBeVisible()
// Delegated auth: the console never collects a password.
await expect(page.locator('input[type="password"]')).toHaveCount(0)
})
test('"Continue with Hanzo ID" starts the IAM authorize redirect with the right client', async ({ page, backend }) => {
backend.account(ACCOUNTS.anonymous)
// Stop the cross-origin navigation at the IAM host and assert the URL.
await page.route(/hanzo\.id/, (route) =>
route.fulfill({ contentType: 'text/html', body: '<html><body>IAM</body></html>' }),
)
await page.goto('/signin')
await page.getByText('Continue with Hanzo ID').click()
await page.waitForURL(/hanzo\.id\/login\/oauth\/authorize/)
const url = page.url()
expect(url).toContain('client_id=hanzo-cloud')
expect(url).toContain('response_type=code')
expect(url).toContain(encodeURIComponent('/auth/callback'))
})
test('GitHub button hints the github provider on the authorize redirect', async ({ page, backend }) => {
backend.account(ACCOUNTS.anonymous)
await page.route(/hanzo\.id/, (route) =>
route.fulfill({ contentType: 'text/html', body: '<html><body>IAM</body></html>' }),
)
await page.goto('/signin')
await page.getByText('Continue with GitHub').click()
await page.waitForURL(/hanzo\.id/)
expect(page.url()).toContain('provider_hint=provider-github')
})
test('a real session lands on the dashboard home', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin)
await page.goto('/')
await expect(page).toHaveURL(/\/$/)
await expect(page.getByText(/See, enable, and manage every .* product from one place/)).toBeVisible()
})
test('the session persists across a reload (no bounce to signin)', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin)
await page.goto('/')
await expect(page.getByText(/See, enable, and manage every/)).toBeVisible()
await page.reload()
await expect(page).not.toHaveURL(/signin/)
await expect(page.getByText(/See, enable, and manage every/)).toBeVisible()
})
test('signing out clears the session and returns to /signin', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin).envelope('signout', 'ok')
await page.goto('/')
await expect(page.getByText(/See, enable, and manage every/)).toBeVisible()
// After signout the client clears the account; AuthGate bounces to /signin.
await page.getByText('Sign out').click()
await page.waitForURL('**/signin')
await expect(page.getByText('Sign in to manage your cloud.')).toBeVisible()
})
test('the OAuth callback shows an honest error when the code is missing', async ({ page, backend }) => {
backend.account(ACCOUNTS.anonymous)
const errors = trackConsoleErrors(page)
await page.goto('/auth/callback')
await expect(page.getByText('Missing authorization code.')).toBeVisible()
await expect(page.getByText('Back to sign in')).toBeVisible()
expect(errors).toEqual([])
})
})
+28
View File
@@ -0,0 +1,28 @@
import { test, expect, ACCOUNTS, landAs, baseline } from './fixtures'
/**
* Function-level authz at the catch-all route. A non-admin who reaches an
* admin-only URL directly (deep link, typed URL, stale tab) must get an honest
* "Access required" — the admin module must NEVER mount (and so never fire its
* privileged `/v1` calls). Nav hiding is cosmetic; this is the gate.
*/
const ADMIN_ROUTES = ['iam', 'kms', 'secrets', 'audit', 'clusters', 'kubernetes']
test.describe('catch-all admin guard', () => {
for (const id of ADMIN_ROUTES) {
test(`non-admin is denied /${id} (Access required)`, async ({ page, backend }) => {
baseline(backend)
await landAs(page, backend, ACCOUNTS.member)
await page.goto(`/${id}`)
await expect(page.getByTestId('page-content').getByText('Access required')).toBeVisible()
})
}
test('admin CAN load an admin route (no guard card)', async ({ page, backend }) => {
baseline(backend)
await landAs(page, backend, ACCOUNTS.admin)
await page.goto('/clusters')
await expect(page.getByTestId('page-content')).toBeVisible()
await expect(page.getByText('Access required')).toHaveCount(0)
})
})
+54
View File
@@ -0,0 +1,54 @@
import { test, expect, ACCOUNTS, landAs } from './fixtures'
/**
* Negative authorization (least privilege). Admin-only surfaces (IAM, KMS,
* Secrets, Audit, Clusters, Kubernetes) must not be presented to a non-admin in
* the nav or on the home, AND the data behind them is gated server-side — so even
* reaching the URL yields an honest "Access required", never real org data.
*/
const ADMIN_LABELS = ['IAM', 'KMS', 'Secrets', 'Audit', 'Clusters', 'Kubernetes']
test.describe('non-admin (member)', () => {
test('does NOT see admin-only products on the home', async ({ page, backend }) => {
await landAs(page, backend, ACCOUNTS.member)
const content = page.getByTestId('page-content')
await expect(content.getByText('Models', { exact: true }).first()).toBeVisible() // ordinary product OK
for (const label of ADMIN_LABELS) {
await expect(content.getByText(label, { exact: true }), `${label} card hidden`).toHaveCount(0)
}
})
test('does NOT see admin-only items in the sidebar', async ({ page, backend }) => {
await landAs(page, backend, ACCOUNTS.member)
const nav = page.getByTestId('nav-sidebar')
for (const label of ADMIN_LABELS) {
await expect(nav.getByText(label, { exact: true }), `${label} nav hidden`).toHaveCount(0)
}
// Ordinary surfaces remain (Settings is not admin-gated).
await expect(nav.getByText('Settings', { exact: true })).toBeVisible()
})
test('is gated server-side even on direct navigation to an admin route', async ({ page, backend }) => {
backend.account(ACCOUNTS.member).error('iam/get-organizations', 403)
await page.goto('/iam')
await expect(page.getByText('Access required')).toBeVisible()
// No real org data leaked into the table.
await expect(page.getByText('Hanzo', { exact: true })).toHaveCount(0)
})
})
test.describe('admin (positive control)', () => {
test('DOES see admin-only products on the home', async ({ page, backend }) => {
await landAs(page, backend, ACCOUNTS.admin)
const content = page.getByTestId('page-content')
await expect(content.getByText('IAM', { exact: true })).toBeVisible()
await expect(content.getByText('Audit', { exact: true })).toBeVisible()
})
test('DOES see admin-only items in the sidebar', async ({ page, backend }) => {
await landAs(page, backend, ACCOUNTS.admin)
const nav = page.getByTestId('nav-sidebar')
await expect(nav.getByText('Clusters', { exact: true })).toBeVisible()
await expect(nav.getByText('Kubernetes', { exact: true })).toBeVisible()
})
})
+41
View File
@@ -0,0 +1,41 @@
import { test, expect, ACCOUNTS, baseline, trackConsoleErrors } from './fixtures'
/**
* Every key route must render with a CLEAN browser console — no uncaught errors,
* no React crashes. Honest-empty backend, admin session (so admin routes load).
*/
const ROUTES = [
'/',
'/providers',
'/models',
'/model-catalog',
'/chat',
'/plans',
'/status',
'/o11y',
'/sessions',
'/scores',
'/settings',
'/iam',
'/audit',
'/secrets',
'/clusters',
'/kubernetes',
'/vector',
'/playground',
'/api-keys',
'/agents', // a soon module
]
test.describe('clean browser console', () => {
for (const route of ROUTES) {
test(`no console errors on ${route}`, async ({ page, backend }) => {
baseline(backend).account(ACCOUNTS.admin)
const errors = trackConsoleErrors(page)
await page.goto(route)
await expect(page.getByTestId('page-content')).toBeVisible()
await page.waitForTimeout(400) // let async loads settle so late errors surface
expect(errors, `console errors on ${route}`).toEqual([])
})
}
})
+202
View File
@@ -0,0 +1,202 @@
/**
* Hermetic E2E backend — Playwright route interception for the cloud `/v1`
* envelope API, the plain-REST provisioning kinds, and the `/paas` proxy.
*
* Why: console2 is a pure client of the unified backend. Mocking the backend at
* the network boundary makes every test deterministic and SAFE — it never
* touches real prod data (no real org/user/secret is read or mutated). The REAL
* Next app + real client logic render against these fixtures, so assertions are
* on real UI behavior.
*
* Usage:
* test('…', async ({ page, backend }) => {
* backend.account(ACCOUNTS.admin).envelope('get-providers', [provider])
* await page.goto('/providers')
* await expect(page.getByText('OpenAI')).toBeVisible()
* })
*
* Unset endpoints return honest-empty defaults so nothing errors; `get-account`
* defaults to the anonymous (logged-out) session unless `account()` is called.
*/
import { test as base, expect, type Page, type Route } from '@playwright/test'
export { expect }
/** Fixture accounts — NEVER real customers. Pure test identities. */
export const ACCOUNTS = {
admin: {
owner: 'hanzo',
name: 'ada-admin',
displayName: 'Ada Admin',
email: 'ada@example.test',
type: 'normal-user',
organization: 'hanzo',
isAdmin: true,
},
member: {
owner: 'hanzo',
name: 'mo-member',
displayName: 'Mo Member',
email: 'mo@example.test',
type: 'normal-user',
organization: 'hanzo',
isAdmin: false,
},
anonymous: { owner: 'hanzo', name: 'anonymous', type: 'anonymous-user' },
} as const
/** Plain-REST provisioning kinds (return raw JSON, not the casibase envelope). */
const PROVISIONING_KINDS = ['sql', 'vector', 'datastore', 'kv', 'search', 's3', 'docdb']
type FulfillSpec = { status: number; contentType: string; body: string }
const json = (body: string, status = 200): FulfillSpec => ({
status,
contentType: 'application/json',
body,
})
/** A casibase `{status,msg,data,data2}` envelope body. */
const envelopeBody = (data: unknown, data2?: unknown): string =>
JSON.stringify({
status: 'ok',
msg: '',
data,
data2: data2 ?? (Array.isArray(data) ? data.length : undefined),
})
export type Backend = {
/** Set the signed-in account (`/v1/get-account`). */
account: (a: unknown) => Backend
/** Register a casibase-envelope endpoint (path after `/v1/`). */
envelope: (path: string, data: unknown, data2?: number) => Backend
/** Register a plain-REST endpoint (raw JSON body) under `/v1/`. */
rest: (path: string, body: unknown, status?: number) => Backend
/** Register an error response under `/v1/` (honest 401/403/404/503 states). */
error: (path: string, status: number, msg?: string) => Backend
/** Register a method-specific REST response (e.g. POST create on a shared path). */
createdAt: (method: string, path: string, body: unknown, status?: number) => Backend
/** Register a `/paas/*` proxy endpoint (raw JSON). */
paas: (path: string, body: unknown, status?: number) => Backend
/** Register a `/paas/*` error (e.g. 501 not-configured). */
paasError: (path: string, status: number, msg?: string) => Backend
}
function createBackend(page: Page): Backend {
const map = new Map<string, FulfillSpec>()
const handler = async (route: Route) => {
const u = new URL(route.request().url())
// Method-qualified match first (e.g. provisioning POST vs GET share a path),
// then the path-only match.
const exact = map.get(`${route.request().method()} ${u.pathname}`) ?? map.get(u.pathname)
if (exact) return route.fulfill(exact)
// Defaults — honest, never error.
if (u.pathname === '/v1/get-account') {
return route.fulfill(json(envelopeBody(ACCOUNTS.anonymous)))
}
if (u.pathname.startsWith('/paas/')) {
return route.fulfill(json('{}')) // {apps:[],clusters:[]} both resolve to []
}
const seg = u.pathname.replace('/v1/', '').split('/')[0]
if (PROVISIONING_KINDS.includes(seg)) {
const isDetail = u.pathname.replace('/v1/', '').includes('/')
return route.fulfill(json(isDetail ? '{}' : '[]'))
}
return route.fulfill(json(envelopeBody([], 0)))
}
// Intercept both the cloud `/v1` surface and the `/paas` proxy.
void page.route(/\/(v1|paas)\//, handler)
const api: Backend = {
account(a) {
map.set('/v1/get-account', json(envelopeBody(a)))
return api
},
envelope(path, data, data2) {
map.set('/v1/' + path, json(envelopeBody(data, data2)))
return api
},
rest(path, body, status = 200) {
map.set('/v1/' + path, json(JSON.stringify(body), status))
return api
},
error(path, status, msg = 'error') {
map.set('/v1/' + path, json(JSON.stringify({ status: 'error', msg, data: null }), status))
return api
},
createdAt(method, path, body, status = 201) {
map.set(`${method} /v1/${path}`, json(JSON.stringify(body), status))
return api
},
paas(path, body, status = 200) {
map.set('/paas/' + path, json(JSON.stringify(body), status))
return api
},
paasError(path, status, msg = 'not configured') {
map.set('/paas/' + path, json(JSON.stringify({ error: msg }), status))
return api
},
}
return api
}
/** Test with a `backend` fixture pre-installed (route interception is live). */
export const test = base.extend<{ backend: Backend }>({
backend: async ({ page }, use) => {
const backend = createBackend(page)
await use(backend)
},
})
/**
* Collect browser console errors + uncaught page errors for "clean console"
* assertions. Call BEFORE navigation. Benign, environment-level noise (favicon,
* intercepted-request abort logs) is filtered so only REAL app errors fail.
*/
export function trackConsoleErrors(page: Page): string[] {
const errors: string[] = []
const benign = [
/favicon/i,
/Failed to load resource/i, // intercepted/aborted sub-resources
/Download the React DevTools/i,
/\[Fast Refresh\]/i,
]
page.on('console', (msg) => {
if (msg.type() !== 'error') return
const text = msg.text()
if (benign.some((re) => re.test(text))) return
errors.push(text)
})
page.on('pageerror', (err) => errors.push(`pageerror: ${err.message}`))
return errors
}
/**
* Register honest-empty responses for the plain-REST surfaces (o11y, evals,
* gateway models, pricing, paas) so any module renders its empty state cleanly.
* Envelope list endpoints are already empty by default.
*/
export function baseline(backend: Backend): Backend {
const page0 = { page: 1, limit: 50, totalItems: 0, totalPages: 0 }
return backend
.rest('o11y/traces', { data: [], meta: page0 })
.rest('o11y/sessions', { data: [], meta: page0 })
.rest('o11y/scores', { data: [], meta: page0 })
.rest('evals/scores', { data: [], meta: page0 })
.rest('evals/datasets', { data: [] })
.rest('models', { object: 'list', data: [] })
.rest('pricing/models', { models: [] })
.rest('pricing', { cloud: { plans: [] } })
.paas('apps', { apps: [] })
}
/** Sign in as a fixture account and land on the dashboard home. */
export async function landAs(page: Page, backend: Backend, account: unknown): Promise<void> {
backend.account(account)
await page.goto('/')
// The home subtitle is unique to the authenticated dashboard home.
await expect(page.getByText(/See, enable, and manage every .* product from one place/)).toBeVisible()
}
+65
View File
@@ -0,0 +1,65 @@
import { test, expect, ACCOUNTS } from './fixtures'
/**
* Forms — real input, real validation, real success. The managed-resource create
* form shares one slug rule with every create surface; the provider Add flow
* mints a record and opens its editor.
*/
test.describe('managed resource create (Vector)', () => {
test('rejects an invalid name with honest inline guidance', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin).rest('vector', [])
await page.goto('/vector')
const content = page.getByTestId('page-content')
await expect(content.getByText('Hanzo Vector', { exact: true })).toBeVisible()
await page.getByPlaceholder('my-resource').fill('Bad_Name')
await expect(
content.getByText(/Lowercase letters, numbers, hyphens; start with a letter, end alphanumeric/),
).toBeVisible()
})
test('creates a resource and reveals one-time credentials', async ({ page, backend }) => {
backend
.account(ACCOUNTS.admin)
.rest('vector', [])
.createdAt('POST', 'vector', {
id: 'v-1',
name: 'my-index',
kind: 'vector',
status: 'creating',
host: 'vec.hanzo.internal',
port: 6333,
connectionString: 'https://vec.hanzo.internal:6333?key=onetime',
password: 'one-time-secret',
})
await page.goto('/vector')
const content = page.getByTestId('page-content')
await page.getByPlaceholder('my-resource').fill('my-index')
await content.getByText('Create', { exact: true }).click()
await expect(content.getByText(/my-index created — save your credentials now/)).toBeVisible()
await expect(content.getByText('Connection string', { exact: true })).toBeVisible()
})
})
test.describe('provider add', () => {
test('Add mints a provider and opens its editor', async ({ page, backend }) => {
backend
.account(ACCOUNTS.admin)
.envelope('get-providers', [], 0)
.envelope('add-provider', 'ok')
.envelope('get-provider', {
owner: 'admin',
name: 'provider_new',
displayName: 'New Provider',
category: 'Model',
type: 'OpenAI',
subType: 'gpt-4',
state: 'Active',
})
await page.goto('/providers')
const content = page.getByTestId('page-content')
await content.getByText('Add', { exact: true }).click()
await page.waitForURL(/\/providers\/provider_/)
// The editor rendered real inputs (we navigated off the list).
await expect(content.locator('input').first()).toBeVisible()
})
})
+48
View File
@@ -0,0 +1,48 @@
import { test, expect, ACCOUNTS, landAs, trackConsoleErrors } from './fixtures'
/**
* The dashboard home is the unified catalog: every Hanzo product grouped by the
* ten canonical categories, with honest enablement badges. It renders entirely
* from the catalog registry, so this guards the rendered surface end-to-end.
*/
test.describe('catalog home', () => {
test('renders all ten category sections', async ({ page, backend }) => {
await landAs(page, backend, ACCOUNTS.admin)
const content = page.getByTestId('page-content')
for (const c of ['AI', 'Compute', 'Data', 'Network', 'Security', 'Dev', 'Deploy', 'Observe', 'Web3', 'Apps']) {
await expect(content.getByText(c, { exact: true }).first()).toBeVisible()
}
})
test('renders product cards with their Google-Cloud equivalents', async ({ page, backend }) => {
await landAs(page, backend, ACCOUNTS.admin)
const content = page.getByTestId('page-content')
await expect(content.getByText('Model Garden')).toBeVisible() // Models gcp subtitle
await expect(content.getByText('Secret Manager')).toBeVisible() // Secrets gcp subtitle
await expect(content.getByText('Memorystore')).toBeVisible() // KV gcp subtitle
})
test('shows the three honest enablement badges', async ({ page, backend }) => {
await landAs(page, backend, ACCOUNTS.admin)
const content = page.getByTestId('page-content')
await expect(content.getByText('Enabled').first()).toBeVisible()
await expect(content.getByText('External').first()).toBeVisible()
await expect(content.getByText('Soon').first()).toBeVisible()
})
test('learn-more opens the product discover interstitial', async ({ page, backend }) => {
await landAs(page, backend, ACCOUNTS.admin)
await page.getByLabel('Learn about Models').click()
await page.waitForURL('**/discover/models')
const content = page.getByTestId('page-content')
await expect(content.getByText('Docs & guides')).toBeVisible()
await expect(content.getByText('Open source gets paid')).toBeVisible()
})
test('renders with a clean browser console', async ({ page, backend }) => {
const errors = trackConsoleErrors(page)
await landAs(page, backend, ACCOUNTS.admin)
await expect(page.getByTestId('page-content').getByText('Compute', { exact: true }).first()).toBeVisible()
expect(errors).toEqual([])
})
})
+110
View File
@@ -0,0 +1,110 @@
import { test, expect, ACCOUNTS, baseline, trackConsoleErrors } from './fixtures'
/**
* Every enabled in-console module must render its surface (PageHeader) cleanly
* on a default (honest-empty) backend — no crash, no fabricated data, no console
* error. This is the broad coverage net across the whole product surface.
*/
const ENABLED_MODULES: { path: string; title: string | RegExp }[] = [
{ path: '/models', title: 'Models' },
{ path: '/providers', title: 'Providers' },
{ path: '/embeddings', title: 'Stores' },
{ path: '/applications', title: 'Applications' },
{ path: '/vector', title: 'Hanzo Vector' },
{ path: '/sql', title: 'Hanzo SQL' },
{ path: '/kv', title: 'Hanzo KV' },
{ path: '/s3', title: 'Hanzo Object Storage' },
{ path: '/datastore', title: 'Hanzo Datastore' },
{ path: '/docdb', title: 'Hanzo DocDB' },
{ path: '/search', title: 'Hanzo Search' },
{ path: '/playground', title: 'Playground' },
{ path: '/o11y', title: 'Traces' },
{ path: '/sessions', title: 'Sessions' },
{ path: '/scores', title: 'Scores' },
{ path: '/evals', title: 'Evals' },
{ path: '/datasets', title: 'Datasets' },
{ path: '/prompts', title: 'Prompts' },
{ path: '/status', title: 'Status' },
{ path: '/plans', title: 'Plans & Pricing' },
{ path: '/wallet', title: /Wallet/ },
{ path: '/chat', title: 'Chat' },
{ path: '/bot', title: 'Bot' },
{ path: '/model-catalog', title: 'Model Catalog' },
{ path: '/api-keys', title: 'API Keys' },
{ path: '/settings', title: 'Settings' },
]
test.describe('module render smoke (enabled, non-admin)', () => {
for (const m of ENABLED_MODULES) {
test(`renders ${m.path} cleanly`, async ({ page, backend }) => {
baseline(backend).account(ACCOUNTS.admin)
const errors = trackConsoleErrors(page)
await page.goto(m.path)
await expect(page.getByTestId('page-content').getByText(m.title).first()).toBeVisible()
expect(errors, `console errors on ${m.path}`).toEqual([])
})
}
})
test.describe('soon modules (honest coming-soon, never a 404)', () => {
for (const id of ['agents', 'gpus', 'vpc', 'settlement']) {
test(`/${id} shows an honest coming-soon page`, async ({ page, backend }) => {
backend.account(ACCOUNTS.admin)
await page.goto(`/${id}`)
await expect(page.getByText(/is coming soon/)).toBeVisible()
await expect(page.getByText('Request early access')).toBeVisible()
})
}
})
test.describe('data rendering', () => {
test('Providers lists real rows from the backend', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin).envelope(
'get-providers',
[
{ owner: 'hanzo', name: 'openai-prod', displayName: 'OpenAI Prod', category: 'Model', type: 'OpenAI', subType: 'gpt-4', state: 'Active' },
],
1,
)
await page.goto('/providers')
const content = page.getByTestId('page-content')
await expect(content.getByText('openai-prod')).toBeVisible()
await expect(content.getByText('OpenAI Prod')).toBeVisible()
})
test('Plans renders priced plan cards from the rate card', async ({ page, backend }) => {
baseline(backend).account(ACCOUNTS.admin).rest('pricing', {
cloud: {
plans: [
{ id: 'starter', name: 'Starter', category: 'Compute', description: 'Kick the tires.', cpuType: 'shared', vcpus: 1, memoryGB: 1, diskGB: 25, maxVMs: 1, priceMonthly: 5, freeTier: true },
{ id: 'pro', name: 'Pro', category: 'Compute', description: 'Production workloads.', cpuType: 'dedicated', vcpus: 4, memoryGB: 8, diskGB: 160, maxVMs: 10, priceMonthly: 48, popular: true },
],
},
})
await page.goto('/plans')
const content = page.getByTestId('page-content')
await expect(content.getByText('Starter', { exact: true })).toBeVisible()
await expect(content.getByText('Pro', { exact: true })).toBeVisible()
await expect(content.getByText('$48')).toBeVisible()
})
})
test.describe('honest async states', () => {
test('observability traces show the runtime-initializing notice on 503', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin).error('o11y/traces', 503)
await page.goto('/o11y')
await expect(page.getByText('Observability runtime initializing')).toBeVisible()
})
test('observability traces show "not routed" on 404', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin).error('o11y/traces', 404)
await page.goto('/o11y')
await expect(page.getByText('Not routed on this host')).toBeVisible()
})
test('KMS reports "not routed" when /v1/kms 404s', async ({ page, backend }) => {
backend.account(ACCOUNTS.admin).error('kms/orgs/hanzo/secrets/', 404)
await page.goto('/secrets')
await expect(page.getByText('Not routed on this host')).toBeVisible()
})
})
+56
View File
@@ -0,0 +1,56 @@
import { test, expect, ACCOUNTS, landAs } from './fixtures'
/**
* The sidebar renders from the catalog: a curated Pinned section, then every
* product by category. Each row opens the product and carries a pin toggle.
*/
test.describe('sidebar navigation', () => {
test('renders the brand wordmark and category sections', async ({ page, backend }) => {
await landAs(page, backend, ACCOUNTS.admin)
const nav = page.getByTestId('nav-sidebar')
await expect(nav.getByText('Hanzo Cloud Console')).toBeVisible()
await expect(nav.getByText('AI', { exact: true })).toBeVisible()
await expect(nav.getByText('Security', { exact: true })).toBeVisible()
})
test('first-run pins resolve to REAL products (regression: dead "billing" id)', async ({ page, backend }) => {
// DEFAULT_PINNED was ['chat','billing'] — 'billing' is not a catalog id, so it
// silently vanished and only Chat pinned. The fix pins ['chat','cost'].
await landAs(page, backend, ACCOUNTS.admin)
const pinned = page.getByTestId('pinned-section')
await expect(pinned.getByText('Chat')).toBeVisible()
await expect(pinned.getByText('Cost')).toBeVisible()
})
test('clicking a nav row opens the in-console module', async ({ page, backend }) => {
await landAs(page, backend, ACCOUNTS.admin)
await page.getByTestId('nav-sidebar').getByText('Providers', { exact: true }).click()
await page.waitForURL('**/providers')
await expect(page.getByTestId('page-content').getByText('Providers', { exact: true })).toBeVisible()
await expect(page.getByText('No providers yet. Click Add to create one.')).toBeVisible()
})
test('pinning a product adds it to the Pinned section', async ({ page, backend }) => {
await landAs(page, backend, ACCOUNTS.admin)
const nav = page.getByTestId('nav-sidebar')
// Models starts unpinned -> its star says "Pin Models".
await nav.getByLabel('Pin Models').click()
// Optimistic update: the same row now offers to unpin, and a Pinned row exists.
await expect(nav.getByLabel('Unpin Models').first()).toBeVisible()
await expect(page.getByTestId('pinned-section').getByText('Models', { exact: true })).toBeVisible()
})
test('an external product opens in a new tab (no in-app navigation)', async ({ page, context, backend }) => {
await landAs(page, backend, ACCOUNTS.admin)
// Keep it hermetic: stub the external origin so no real request leaves.
await context.route(/api\.hanzo\.ai/, (route) =>
route.fulfill({ contentType: 'text/html', body: '<html><body>gateway</body></html>' }),
)
const popupPromise = context.waitForEvent('page')
await page.getByTestId('nav-sidebar').getByText('Gateway', { exact: true }).click()
const popup = await popupPromise
await popup.waitForLoadState('domcontentloaded').catch(() => {})
expect(popup.url()).toContain('api.hanzo.ai')
await expect(page).toHaveURL(/\/$/) // the main page did not navigate
})
})
+23
View File
@@ -0,0 +1,23 @@
import { test, expect, ACCOUNTS } from './fixtures'
/**
* OIDC callback hardening (client-side). The callback must surface an IdP error,
* and — critically — reject a `state` it never issued (login-CSRF / code injection)
* BEFORE exchanging the code. Both are pure client behavior, so they're hermetic.
*/
test.describe('OIDC callback', () => {
test('surfaces an IdP error and offers a retry', async ({ page, backend }) => {
backend.account(ACCOUNTS.anonymous)
await page.goto('/auth/callback?error=access_denied&error_description=You%20declined')
await expect(page.getByText('You declined')).toBeVisible()
await expect(page.getByText('Back to sign in')).toBeVisible()
})
test('rejects a forged state (none was stored) before any code exchange', async ({ page, backend }) => {
backend.account(ACCOUNTS.anonymous)
await page.goto('/auth/callback?code=attacker_code&state=forged_state')
await expect(page.getByText(/could not be verified|state mismatch/i)).toBeVisible()
// It never lands on the dashboard (no session minted from a forged callback).
await expect(page).not.toHaveURL(/\/$/)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { test, expect } from '@playwright/test'
/**
* The `/paas` proxy attaches a powerful platform service token, so it is gated
* deny-by-default IN the real server route. These tests deliberately do NOT use
* the `backend` fixture (which mocks `/paas` in the browser) — they hit the REAL
* Next route with no session, which must refuse with 401 before any token/config
* is touched.
*/
test.describe('/paas proxy — server-side deny-by-default', () => {
test('unauthenticated requests are 401 for every verb', async ({ request }) => {
const methods = ['get', 'post', 'patch', 'delete'] as const
for (const method of methods) {
const res = await request[method]('/paas/apps')
expect(res.status(), `${method.toUpperCase()} /paas/apps → 401`).toBe(401)
}
})
test('auth precedes the not-configured state (no 501 leak to anon)', async ({ request }) => {
const res = await request.get('/paas/apps')
expect(res.status()).toBe(401)
const body = await res.json().catch(() => ({}))
expect(JSON.stringify(body)).not.toContain('PAAS_SERVICE_TOKEN')
})
})
+25
View File
@@ -0,0 +1,25 @@
import { test, expect, ACCOUNTS, landAs } from './fixtures'
/** The console must render on desktop and mobile viewports. */
test.describe('responsive layout', () => {
test('desktop renders the sidebar + content', async ({ page, backend }) => {
await page.setViewportSize({ width: 1280, height: 800 })
await landAs(page, backend, ACCOUNTS.admin)
await expect(page.getByTestId('nav-sidebar')).toBeVisible()
await expect(page.getByTestId('page-content')).toBeVisible()
})
test('mobile renders the catalog home', async ({ page, backend }) => {
await page.setViewportSize({ width: 390, height: 844 }) // iPhone-ish
await landAs(page, backend, ACCOUNTS.admin)
await expect(page.getByTestId('page-content').getByText('Compute', { exact: true }).first()).toBeVisible()
})
test('mobile renders the sign-in card', async ({ page, backend }) => {
await page.setViewportSize({ width: 390, height: 844 })
backend.account(ACCOUNTS.anonymous)
await page.goto('/signin')
await expect(page.getByText('Continue with Hanzo ID')).toBeVisible()
await expect(page.getByText('Sign in to manage your cloud.')).toBeVisible()
})
})
+41
View File
@@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test'
/**
* Security headers (next.config). Asserts the strict header set is present on
* real responses AND that the app still renders under the CSP with zero CSP
* violations — a broken CSP would white-screen the white-label app, so this is
* both the policy check and the render check.
*/
test.describe('security headers', () => {
test('every response carries the strict header set', async ({ request }) => {
const res = await request.get('/signin')
expect(res.status()).toBeLessThan(400)
const h = res.headers()
const csp = h['content-security-policy'] ?? ''
expect(csp).toContain("default-src 'self'")
expect(csp).toContain("frame-ancestors 'none'")
expect(csp).toContain("object-src 'none'")
expect(csp).toContain("base-uri 'self'")
expect(csp).toContain('form-action')
expect(csp).toContain('connect-src')
expect(h['x-frame-options']).toBe('DENY')
expect(h['x-content-type-options']).toBe('nosniff')
expect(h['referrer-policy']).toBe('no-referrer')
expect(h['strict-transport-security']).toContain('max-age=')
expect(h['permissions-policy']).toContain('geolocation=()')
// No framework fingerprinting (poweredByHeader: false).
expect(h['x-powered-by']).toBeUndefined()
})
test('the app renders under CSP with no CSP violations', async ({ page }) => {
const violations: string[] = []
page.on('console', (m) => {
if (m.type() === 'error' && /content security policy|refused to (?:execute|load|apply)/i.test(m.text())) {
violations.push(m.text())
}
})
await page.goto('/signin')
await expect(page.getByText('Sign in to manage your cloud.')).toBeVisible()
expect(violations, violations.join('\n')).toEqual([])
})
})
+14
View File
@@ -0,0 +1,14 @@
/**
* Hermetic unit-test stub for `ethers`. The wallet module references
* `ethers.*` only at call time (never at import), so a universal callable
* Proxy satisfies the binding without pulling the real library into unit tests.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const universal: any = new Proxy(function () {}, {
get: () => universal,
apply: () => universal,
construct: () => universal,
})
export const ethers = universal
export default universal
+28
View File
@@ -0,0 +1,28 @@
/**
* Hermetic unit-test stub for `@hanzo/gui`.
*
* Unit tests import the real module graph (registry → product modules) to
* exercise PURE logic and data-integrity, but never render it — so every gui
* export is a no-op placeholder. Real rendering + interaction is covered by the
* Playwright E2E suite against the live Next server. Exports mirror exactly the
* named imports used across `src/` (see test/stubs/README intent).
*/
const Dummy = (_props?: unknown): null => null
export const Button = Dummy
export const Card = Dummy
export const GuiProvider = Dummy
export const Input = Dummy
export const Label = Dummy
export const ScrollView = Dummy
export const Select = Dummy
export const Slider = Dummy
export const Spinner = Dummy
export const Switch = Dummy
export const Text = Dummy
export const TextArea = Dummy
export const XStack = Dummy
export const YStack = Dummy
export const useTheme = (): Record<string, unknown> => ({})
export default Dummy
+14
View File
@@ -0,0 +1,14 @@
/**
* Hermetic unit-test stub for `@hanzo/iam-js-sdk`. The real SDK touches
* `window`/`sessionStorage`; unit tests only need the constructable shape and
* the two URL builders. Live OIDC behavior is covered by the E2E suite.
*/
export default class Sdk {
constructor(_opts?: unknown) {}
getSigninUrl(): string {
return 'https://stub.iam/login/oauth/authorize?client_id=stub'
}
getSignupUrl(): string {
return 'https://stub.iam/signup'
}
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Hermetic unit-test stub for `@hanzogui/lucide-icons-2`. Every icon used across
* `src/` is a no-op placeholder so the module graph imports without rendering.
* Auto-generated from the exact named-import set; regenerate if a new icon is
* imported into the unit graph (registry/match). Real icons render in E2E.
*/
const Icon = (_props?: unknown): null => null
export const Activity = Icon
export const AlertCircle = Icon
export const ArrowLeft = Icon
export const ArrowLeftRight = Icon
export const ArrowRight = Icon
export const ArrowUpRight = Icon
export const BarChart3 = Icon
export const Bell = Icon
export const BookOpen = Icon
export const Bot = Icon
export const Box = Icon
export const Boxes = Icon
export const Brain = Icon
export const Cable = Icon
export const Check = Icon
export const CheckCircle2 = Icon
export const ChevronDown = Icon
export const ChevronLeft = Icon
export const ChevronRight = Icon
export const Code = Icon
export const Code2 = Icon
export const Coins = Icon
export const Container = Icon
export const Copy = Icon
export const Cpu = Icon
export const CreditCard = Icon
export const Database = Icon
export const ExternalLink = Icon
export const Eye = Icon
export const EyeOff = Icon
export const FileText = Icon
export const Fingerprint = Icon
export const FolderGit2 = Icon
export const FunctionSquare = Icon
export const Gauge = Icon
export const GitBranch = Icon
export const Github = Icon
export const Globe = Icon
export const Hammer = Icon
export const HardDrive = Icon
export const Info = Icon
export const Key = Icon
export const KeyRound = Icon
export const KeySquare = Icon
export const Layers = Icon
export const LayoutGrid = Icon
export const Library = Icon
export const LineChart = Icon
export const ListChecks = Icon
export const Lock = Icon
export const LogOut = Icon
export const MessageSquare = Icon
export const Monitor = Icon
export const Network = Icon
export const Package = Icon
export const Play = Icon
export const Plus = Icon
export const Radio = Icon
export const RefreshCw = Icon
export const Repeat = Icon
export const Rocket = Icon
export const ScrollText = Icon
export const Search = Icon
export const Server = Icon
export const Shield = Icon
export const ShieldCheck = Icon
export const SlidersHorizontal = Icon
export const Sparkles = Icon
export const Spline = Icon
export const Star = Icon
export const Tag = Icon
export const Terminal = Icon
export const Trash = Icon
export const TriangleAlert = Icon
export const Wallet = Icon
export const Waypoints = Icon
export const Zap = Icon
export default Icon
+3
View File
@@ -0,0 +1,3 @@
/** Hermetic unit-test stub for `@zap-proto/web`. */
export class Conn {}
export default Conn
+5
View File
@@ -0,0 +1,5 @@
/** Hermetic unit-test stub for `@zap-proto/zap`. */
export class Builder {}
export class Message {}
export class StructView {}
export default Builder
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["node", "vitest/globals"],
"noEmit": true
},
"include": ["**/*.ts", "**/*.tsx"]
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { AccountApi } from '~/lib/api/account'
/**
* This is an ADMIN console: casibase auto-creates an "anonymous-user" session,
* which is NOT a real sign-in. `current()` MUST treat it (and any failure) as
* logged-out, or the AuthGate would let an unauthenticated visitor in.
*/
function jsonRes(body: unknown, status = 200): Response {
return {
ok: status < 400,
status,
json: async () => body,
text: async () => JSON.stringify(body),
} as unknown as Response
}
describe('AccountApi.current', () => {
beforeEach(() => vi.restoreAllMocks())
it('returns the account for a real sign-in', async () => {
vi.stubGlobal('fetch', vi.fn(async () =>
jsonRes({ status: 'ok', msg: '', data: { owner: 'hanzo', name: 'a', type: 'normal-user', isAdmin: true } }),
))
const acct = await AccountApi.current()
expect(acct).toMatchObject({ name: 'a', isAdmin: true })
})
it('treats an anonymous-user session as logged-out (null)', async () => {
vi.stubGlobal('fetch', vi.fn(async () =>
jsonRes({ status: 'ok', msg: '', data: { owner: 'hanzo', name: 'anon', type: 'anonymous-user' } }),
))
expect(await AccountApi.current()).toBeNull()
})
it('returns null on a 401 (no valid session)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonRes({}, 401)))
expect(await AccountApi.current()).toBeNull()
})
it('returns null when get-account errors out (never throws)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('network') }))
expect(await AccountApi.current()).toBeNull()
})
it('returns null for an ok envelope with empty data', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonRes({ status: 'ok', msg: '', data: null })))
expect(await AccountApi.current()).toBeNull()
})
})
describe('AccountApi.updatePreferences', () => {
beforeEach(() => vi.restoreAllMocks())
it('posts the partial and returns the merged map', async () => {
const fetchMock = vi.fn(async () =>
jsonRes({ status: 'ok', msg: '', data: { favorites: ['chat'], theme: 'dark' } }),
)
vi.stubGlobal('fetch', fetchMock)
const merged = await AccountApi.updatePreferences({ theme: 'dark' })
expect(merged).toEqual({ favorites: ['chat'], theme: 'dark' })
expect(fetchMock.mock.calls[0][0]).toBe('https://console.hanzo.ai/v1/update-preferences')
expect(fetchMock.mock.calls[0][1].method).toBe('POST')
})
it('returns {} when the backend omits data', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonRes({ status: 'ok', msg: '', data: undefined })))
expect(await AccountApi.updatePreferences({ x: 1 })).toEqual({})
})
})
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest'
import { ADMIN_PRODUCT_IDS, isAdminProductId } from '~/lib/auth/admin'
import { catalog } from '~/lib/products/registry'
describe('admin product ids — single source of truth', () => {
it('exactly mirrors the catalog `admin: true` entries (drift guard)', () => {
const fromCatalog = new Set(catalog.filter((e) => e.admin).map((e) => e.id))
expect(fromCatalog).toEqual(new Set(ADMIN_PRODUCT_IDS))
})
it('every admin id is a real catalog entry', () => {
const ids = new Set(catalog.map((e) => e.id))
for (const id of ADMIN_PRODUCT_IDS) expect(ids.has(id), `${id} exists`).toBe(true)
})
it('covers the sensitive surfaces', () => {
for (const id of ['iam', 'kms', 'secrets', 'audit', 'clusters', 'kubernetes']) {
expect(isAdminProductId(id), `${id} is admin-gated`).toBe(true)
}
})
it('ordinary products are not admin-gated', () => {
for (const id of ['models', 'chat', 'settings', 'playground', 'vector']) {
expect(isAdminProductId(id), `${id} is open`).toBe(false)
}
})
})
+174
View File
@@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
get,
getList,
post,
idOf,
v1Url,
restGet,
restDelete,
ApiError,
} from '~/lib/api/client'
/** Build a Fetch Response double from an envelope/body. */
function res(opts: {
ok?: boolean
status?: number
json?: unknown
text?: string
throwJson?: boolean
}): Response {
const status = opts.status ?? (opts.ok === false ? 500 : 200)
return {
ok: opts.ok ?? status < 400,
status,
json: async () => {
if (opts.throwJson) throw new Error('not json')
return opts.json
},
text: async () => opts.text ?? '',
} as unknown as Response
}
const okEnvelope = <T>(data: T, data2?: unknown) => ({ status: 'ok', msg: '', data, data2 })
describe('client.request (casibase envelope)', () => {
beforeEach(() => vi.restoreAllMocks())
it('GET unwraps `data`, sends credentials + Accept-Language, and NO client X-Org-Id', async () => {
const fetchMock = vi.fn(async () => res({ json: okEnvelope({ name: 'z' }) }))
vi.stubGlobal('fetch', fetchMock)
const out = await get<{ name: string }>('get-account')
expect(out).toEqual({ name: 'z' })
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('https://console.hanzo.ai/v1/get-account')
expect(init.credentials).toBe('include')
expect(init.headers['Accept-Language']).toBeDefined()
// Tenancy is derived server-side from the session; a client-set tenant header
// is spoofable and is NOT a trust boundary, so it must not be sent.
expect(init.headers['X-Org-Id']).toBeUndefined()
})
it('does not send X-Org-Id on the plain-REST path either', async () => {
const fetchMock = vi.fn(async () => res({ status: 200, text: JSON.stringify({ ok: true }) }))
vi.stubGlobal('fetch', fetchMock)
await restGet(v1Url('vector'))
const init = fetchMock.mock.calls[0][1]
expect(init.headers['X-Org-Id']).toBeUndefined()
})
it('GET appends query params, skipping null/undefined', async () => {
const fetchMock = vi.fn(async () => res({ json: okEnvelope([]) }))
vi.stubGlobal('fetch', fetchMock)
await get('get-providers', { owner: 'hanzo', page: 2, skip: undefined, none: null })
const url = new URL(fetchMock.mock.calls[0][0] as string)
expect(url.searchParams.get('owner')).toBe('hanzo')
expect(url.searchParams.get('page')).toBe('2')
expect(url.searchParams.has('skip')).toBe(false)
expect(url.searchParams.has('none')).toBe(false)
})
it('POST sets Content-Type and JSON-encodes the body', async () => {
const fetchMock = vi.fn(async () => res({ json: okEnvelope('done') }))
vi.stubGlobal('fetch', fetchMock)
const r = await post('add-provider', { name: 'p' })
expect(r.status).toBe('ok')
const init = fetchMock.mock.calls[0][1]
expect(init.method).toBe('POST')
expect(init.headers['Content-Type']).toBe('application/json')
expect(init.body).toBe(JSON.stringify({ name: 'p' }))
})
it('throws ApiError(401) on unauthorized BEFORE reading the body', async () => {
vi.stubGlobal('fetch', vi.fn(async () => res({ ok: false, status: 401 })))
await expect(get('get-account')).rejects.toMatchObject({ name: 'ApiError', status: 401 })
})
it('throws ApiError(403) on forbidden', async () => {
vi.stubGlobal('fetch', vi.fn(async () => res({ ok: false, status: 403 })))
await expect(get('iam/get-organizations')).rejects.toMatchObject({ status: 403 })
})
it('throws ApiError on a non-ok envelope (status:"error")', async () => {
vi.stubGlobal('fetch', vi.fn(async () => res({ json: { status: 'error', msg: 'boom', data: null } })))
await expect(get('x')).rejects.toThrow('boom')
})
it('wraps a network failure as ApiError(0)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => {
throw new TypeError('Failed to fetch')
}))
await expect(get('x')).rejects.toMatchObject({ name: 'ApiError', status: 0 })
})
it('reports an invalid JSON body honestly', async () => {
vi.stubGlobal('fetch', vi.fn(async () => res({ ok: false, status: 500, throwJson: true })))
await expect(get('x')).rejects.toThrow(/Invalid response from server/)
})
})
describe('getList total', () => {
beforeEach(() => vi.restoreAllMocks())
it('uses data2 when it is a number', async () => {
vi.stubGlobal('fetch', vi.fn(async () => res({ json: okEnvelope([{ a: 1 }], 42) })))
const { rows, total } = await getList<{ a: number }[]>('get-providers')
expect(rows).toHaveLength(1)
expect(total).toBe(42)
})
it('falls back to array length when data2 is absent', async () => {
vi.stubGlobal('fetch', vi.fn(async () => res({ json: okEnvelope([1, 2, 3]) })))
expect((await getList('x')).total).toBe(3)
})
it('is 0 for a non-array payload with no data2', async () => {
vi.stubGlobal('fetch', vi.fn(async () => res({ json: okEnvelope({ not: 'array' }) })))
expect((await getList('x')).total).toBe(0)
})
})
describe('plain-REST layer', () => {
beforeEach(() => vi.restoreAllMocks())
it('restGet returns parsed JSON on 2xx', async () => {
vi.stubGlobal('fetch', vi.fn(async () => res({ status: 200, text: JSON.stringify({ ok: true }) })))
expect(await restGet(v1Url('vector'))).toEqual({ ok: true })
})
it('restDelete resolves to undefined on 204', async () => {
vi.stubGlobal('fetch', vi.fn(async () => res({ status: 204, text: '' })))
await expect(restDelete(v1Url('vector/foo'))).resolves.toBeUndefined()
})
it('restRequest surfaces a {msg|error} body on non-2xx', async () => {
vi.stubGlobal('fetch', vi.fn(async () => res({ ok: false, status: 400, text: JSON.stringify({ error: 'bad name' }) })))
await expect(restGet(v1Url('vector'))).rejects.toThrow('bad name')
})
it('restRequest throws ApiError(403)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => res({ ok: false, status: 403, text: '' })))
await expect(restGet(v1Url('vector'))).rejects.toMatchObject({ status: 403 })
})
})
describe('url + id helpers', () => {
it('v1Url builds /v1/<path> on the cloud base by default', () => {
expect(v1Url('vector/foo')).toBe('https://console.hanzo.ai/v1/vector/foo')
expect(v1Url('apps', 'https://platform.hanzo.ai')).toBe('https://platform.hanzo.ai/v1/apps')
})
it('idOf joins owner/name and URL-encodes the name', () => {
expect(idOf('hanzo', 'gpt-4')).toBe('hanzo/gpt-4')
expect(idOf('hanzo', 'a b/c')).toBe('hanzo/a%20b%2Fc')
})
it('ApiError carries a numeric status (default 0)', () => {
expect(new ApiError('x').status).toBe(0)
expect(new ApiError('x', 404).status).toBe(404)
expect(new ApiError('x') instanceof Error).toBe(true)
})
})
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect } from 'vitest'
import { brandFromHost, envFromHost, resolveConfig, branding } from '~/config'
/**
* ONE console image serves every brand; the brand is resolved at runtime from
* the request hostname. Misrouting a host to the wrong brand means the wrong IAM
* issuer / org scope — an auth failure — so the host→brand map is pinned here.
*/
describe('brandFromHost', () => {
it('defaults to hanzo for empty/unknown hosts', () => {
expect(brandFromHost('')).toBe('hanzo')
expect(brandFromHost(null)).toBe('hanzo')
expect(brandFromHost(undefined)).toBe('hanzo')
expect(brandFromHost('random.example.com')).toBe('hanzo')
})
it('maps hanzo hosts', () => {
expect(brandFromHost('console.hanzo.ai')).toBe('hanzo')
expect(brandFromHost('hanzo.ai')).toBe('hanzo')
expect(brandFromHost('hanzo.id')).toBe('hanzo')
})
it('maps lux hosts (cloud, network, id)', () => {
expect(brandFromHost('console.lux.cloud')).toBe('lux')
expect(brandFromHost('lux.network')).toBe('lux')
expect(brandFromHost('os.lux.network')).toBe('lux')
expect(brandFromHost('lux.id')).toBe('lux')
})
it('maps zoo hosts (cloud, ngo, network, zoolabs.id)', () => {
expect(brandFromHost('console.zoo.cloud')).toBe('zoo')
expect(brandFromHost('zoo.ngo')).toBe('zoo')
expect(brandFromHost('zoo.network')).toBe('zoo')
expect(brandFromHost('zoolabs.id')).toBe('zoo')
})
it('maps pars hosts (cloud, network, AND id — symmetric with the other brands)', () => {
expect(brandFromHost('pars.cloud')).toBe('pars')
expect(brandFromHost('console.pars.cloud')).toBe('pars')
expect(brandFromHost('pars.network')).toBe('pars')
// Regression: every other brand maps its `.id` host; pars must too.
expect(brandFromHost('pars.id')).toBe('pars')
})
it('is case- and port-insensitive', () => {
expect(brandFromHost('CONSOLE.LUX.CLOUD')).toBe('lux')
expect(brandFromHost('console.hanzo.ai:4000')).toBe('hanzo')
expect(brandFromHost(' console.zoo.cloud ')).toBe('zoo')
})
it('does not match a brand merely contained in a different TLD', () => {
// endsWith('.hanzo.ai') guard, not a substring match.
expect(brandFromHost('nothanzo.ai')).toBe('hanzo') // falls through to default
expect(brandFromHost('hanzo.ai.evil.com')).toBe('hanzo') // default, not lux/zoo
})
})
/**
* ONE image serves mainnet/testnet/devnet; the env tier is resolved from the
* host's env label. A non-prod console MUST NOT bounce sign-in to the prod
* issuer, so the host→env map (and the per-env issuer it implies) is pinned here.
*/
describe('envFromHost', () => {
it('defaults to mainnet when there is no env label', () => {
expect(envFromHost('console.hanzo.ai')).toBe('mainnet')
expect(envFromHost('hanzo.ai')).toBe('mainnet')
expect(envFromHost('')).toBe('mainnet')
expect(envFromHost(null)).toBe('mainnet')
expect(envFromHost(undefined)).toBe('mainnet')
})
it('detects devnet / testnet from the env label (any service host)', () => {
expect(envFromHost('console.devnet.hanzo.ai')).toBe('devnet')
expect(envFromHost('id.devnet.hanzo.ai')).toBe('devnet')
expect(envFromHost('api.devnet.hanzo.ai')).toBe('devnet')
expect(envFromHost('console.testnet.hanzo.ai')).toBe('testnet')
expect(envFromHost('api.testnet.hanzo.ai')).toBe('testnet')
})
it('is case- and port-insensitive', () => {
expect(envFromHost('CONSOLE.DEVNET.HANZO.AI:4000')).toBe('devnet')
expect(envFromHost(' console.testnet.hanzo.ai ')).toBe('testnet')
})
it('matches the env LABEL, not a substring (devnetwork ≠ devnet)', () => {
expect(envFromHost('devnetwork.hanzo.ai')).toBe('mainnet')
expect(envFromHost('testnetic.hanzo.ai')).toBe('mainnet')
})
})
describe('resolveConfig', () => {
it('resolves per-brand IAM identity, shared cloud/billing', () => {
const lux = resolveConfig('console.lux.cloud')
expect(lux.brand).toBe('lux')
expect(lux.brandName).toBe('Lux Cloud')
expect(lux.iamUrl).toBe('https://lux.id')
expect(lux.iamOrgName).toBe('lux')
expect(lux.iamAppName).toBe('lux-cloud')
expect(lux.iamClientId).toBe('lux-cloud')
expect(lux.platformUrl).toMatch(/^https:\/\//)
expect(lux.billingUrl).toMatch(/^https:\/\//)
})
it('zoo uses zoolabs.id issuer (NOT zoo.id)', () => {
const zoo = resolveConfig('console.zoo.cloud')
expect(zoo.iamUrl).toBe('https://zoolabs.id')
expect(zoo.iamOrgName).toBe('zoo')
})
it('mainnet hanzo keeps the prod vanity issuer', () => {
const m = resolveConfig('console.hanzo.ai')
expect(m.brand).toBe('hanzo')
expect(m.env).toBe('mainnet')
expect(m.iamUrl).toBe('https://hanzo.id')
})
it('devnet hanzo resolves the per-env issuer (NOT prod hanzo.id) — the sign-in bounce fix', () => {
const dev = resolveConfig('console.devnet.hanzo.ai')
expect(dev.brand).toBe('hanzo')
expect(dev.env).toBe('devnet')
expect(dev.iamUrl).toBe('https://id.devnet.hanzo.ai')
// org/app/client are unchanged across envs — only the issuer host moves.
expect(dev.iamOrgName).toBe('hanzo')
expect(dev.iamAppName).toBe('hanzo-cloud')
expect(dev.iamClientId).toBe('hanzo-cloud')
})
it('testnet hanzo resolves its own per-env issuer (one image serves testnet too)', () => {
const t = resolveConfig('console.testnet.hanzo.ai')
expect(t.env).toBe('testnet')
expect(t.iamUrl).toBe('https://id.testnet.hanzo.ai')
})
})
describe('branding', () => {
it('builds a "<Brand> Console" wordmark for the current host', () => {
// jsdom url is console.hanzo.ai -> hanzo brand.
expect(branding.name).toBe('Hanzo Cloud Console')
expect(branding.short).toBe('Cloud Console')
})
})
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import { newModelRoute } from '~/components/products/models/logic'
import { newApplication, isUndeployed, STATUS_OPTIONS } from '~/components/products/applications/logic'
import { newStore } from '~/components/products/stores/logic'
/** New-record templates + lifecycle predicates ported from casibase. */
describe('newModelRoute', () => {
it('starts with an empty modelName (filled in the create form) and enabled=true', () => {
const r = newModelRoute('hanzo')
expect(r.owner).toBe('hanzo')
expect(r.modelName).toBe('')
expect(r.enabled).toBe(true)
expect(r.premium).toBe(false)
expect(r.name).toMatch(/^route_/)
})
})
describe('newApplication', () => {
it('is an undeployed app in its own namespace', () => {
const a = newApplication('hanzo')
expect(a.owner).toBe('hanzo')
expect(a.status).toBe('Not Deployed')
expect(a.namespace).toMatch(/^hanzo-cloud-app-/)
expect(a.name).toMatch(/^application_/)
})
})
describe('isUndeployed', () => {
it('is true when status is missing or "Not Deployed"', () => {
expect(isUndeployed({ owner: 'o', name: 'a' })).toBe(true)
expect(isUndeployed({ owner: 'o', name: 'a', status: 'Not Deployed' })).toBe(true)
})
it('is false once the app is Pending/Running/Failed', () => {
expect(isUndeployed({ owner: 'o', name: 'a', status: 'Running' })).toBe(false)
expect(isUndeployed({ owner: 'o', name: 'a', status: 'Pending' })).toBe(false)
})
it('STATUS_OPTIONS covers the lifecycle', () => {
expect(STATUS_OPTIONS).toEqual(['Not Deployed', 'Pending', 'Running', 'Failed'])
})
})
describe('newStore', () => {
it('wires the built-in storage provider and browser speech defaults', () => {
const s = newStore('hanzo')
expect(s.owner).toBe('hanzo')
expect(s.storageProvider).toBe('provider-storage-built-in')
expect(s.textToSpeechProvider).toBe('Browser Built-In')
expect(s.state).toBe('Active')
expect(s.name).toMatch(/^store_/)
})
})
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import { DEFAULT_PINNED } from '~/lib/products/favorites'
import { findEntry } from '~/lib/products/registry'
/**
* First-run pins are shown in the sidebar before the user customizes them. The
* shell renders pins by resolving each id against the catalog and dropping
* unknown ids — so a default that names a NON-EXISTENT product silently vanishes
* (the user sees fewer pins than intended). Every default MUST resolve.
*/
describe('DEFAULT_PINNED', () => {
it('is non-empty', () => {
expect(DEFAULT_PINNED.length).toBeGreaterThan(0)
})
it('every default pin resolves to a real catalog entry (no dead pins)', () => {
for (const id of DEFAULT_PINNED) {
expect(findEntry(id), `default pin "${id}" must exist in the catalog`).toBeDefined()
}
})
})
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
pkceChallenge,
buildAuthorizeUrl,
consumeState,
consumeCodeVerifier,
describeAuthError,
} from '~/lib/auth/iam'
describe('PKCE S256', () => {
it('matches the RFC 7636 Appendix B test vector', async () => {
expect(await pkceChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')).toBe(
'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
)
})
})
describe('authorize URL — state always, PKCE gated', () => {
beforeEach(() => sessionStorage.clear())
it('stamps a random state, persists it, and emits NO PKCE by default', async () => {
const url = new URL(await buildAuthorizeUrl())
const state = url.searchParams.get('state')
expect(state).toBeTruthy()
expect(state!.length).toBeGreaterThanOrEqual(20) // not the old fixed appName
expect(url.searchParams.get('response_type')).toBe('code')
expect(url.searchParams.get('redirect_uri')).toContain('/auth/callback')
expect(url.searchParams.get('client_id')).toBeTruthy()
expect(url.searchParams.has('code_challenge')).toBe(false)
expect(sessionStorage.getItem('console2.oauth.state')).toBe(state)
})
it('produces a distinct state on each start (no fixed/guessable state)', async () => {
const a = new URL(await buildAuthorizeUrl()).searchParams.get('state')
const b = new URL(await buildAuthorizeUrl()).searchParams.get('state')
expect(a).not.toBe(b)
})
it('emits an S256 challenge bound to the stored verifier when PKCE is on', async () => {
const url = new URL(await buildAuthorizeUrl({ pkce: true }))
expect(url.searchParams.get('code_challenge_method')).toBe('S256')
const challenge = url.searchParams.get('code_challenge')!
const verifier = sessionStorage.getItem('console2.oauth.verifier')!
expect(verifier).toBeTruthy()
expect(await pkceChallenge(verifier)).toBe(challenge)
})
it('hints a social provider when asked', async () => {
const url = new URL(await buildAuthorizeUrl({ provider: 'provider-github' }))
expect(url.searchParams.get('provider_hint')).toBe('provider-github')
})
})
describe('one-time consumption', () => {
beforeEach(() => sessionStorage.clear())
it('consumeState returns then clears', async () => {
await buildAuthorizeUrl()
expect(consumeState()).toBeTruthy()
expect(consumeState()).toBeNull()
})
it('consumeCodeVerifier returns then clears (PKCE on)', async () => {
await buildAuthorizeUrl({ pkce: true })
expect(consumeCodeVerifier()).toBeTruthy()
expect(consumeCodeVerifier()).toBeNull()
})
})
describe('IdP error messages', () => {
it('prefers the provided description', () => {
expect(describeAuthError('access_denied', 'You said no')).toBe('You said no')
})
it('maps known codes and falls back for unknown', () => {
expect(describeAuthError('access_denied')).toMatch(/cancel/i)
expect(describeAuthError('weird_code')).toContain('weird_code')
})
})
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest'
import { matchRoute } from '~/lib/products/match'
import { findModule } from '~/lib/products/registry'
/**
* The catch-all router resolves a URL slug to one module + route + params. This
* is the ONLY routing logic in the console, so its contract is pinned here.
*/
describe('matchRoute', () => {
it('returns null for an empty slug', () => {
expect(matchRoute([])).toBeNull()
expect(matchRoute([''])).toBeNull()
})
it('returns null for an unknown module', () => {
expect(matchRoute(['does-not-exist'])).toBeNull()
expect(matchRoute(['nope', 'x', 'y'])).toBeNull()
})
it('matches a module index route ("")', () => {
const m = matchRoute(['providers'])
expect(m).not.toBeNull()
expect(m!.module.id).toBe('providers')
expect(m!.route.path).toBe('')
expect(m!.params).toEqual({})
})
it('captures a :name param', () => {
const m = matchRoute(['providers', 'openai'])
expect(m!.module.id).toBe('providers')
expect(m!.route.path).toBe(':name')
expect(m!.params).toEqual({ name: 'openai' })
})
it('captures the special "new" name for model create', () => {
const m = matchRoute(['models', 'new'])
expect(m!.module.id).toBe('models')
expect(m!.params).toEqual({ name: 'new' })
})
it('captures a :tab param for tabbed modules (iam, settings, evals)', () => {
expect(matchRoute(['iam', 'users'])!.params).toEqual({ tab: 'users' })
expect(matchRoute(['settings', 'org'])!.params).toEqual({ tab: 'org' })
expect(matchRoute(['evals', 'scores'])!.params).toEqual({ tab: 'scores' })
})
it('returns null when there are more segments than any route accepts', () => {
// providers has '' and ':name' (1 extra segment max).
expect(matchRoute(['providers', 'a', 'b'])).toBeNull()
})
it('does not resolve external (non-module) catalog ids as routes', () => {
// `cost`/`gateway` are external surfaces, not in-console modules.
expect(findModule('cost')).toBeUndefined()
expect(matchRoute(['cost'])).toBeNull()
expect(matchRoute(['gateway'])).toBeNull()
})
it('resolves every enabled in-console module index route', () => {
// Smoke: each module is reachable at its index.
for (const id of ['models', 'providers', 'chat', 'vector', 'sql', 'status']) {
const m = matchRoute([id])
expect(m, `module ${id} should resolve`).not.toBeNull()
expect(m!.module.id).toBe(id)
}
})
})
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { openProduct } from '~/lib/products/open'
import { findEntry } from '~/lib/products/registry'
/**
* `openProduct` is the ONE place that knows how each catalog kind opens:
* in-console modules navigate via the router; external surfaces open a new tab.
*/
describe('openProduct', () => {
beforeEach(() => vi.restoreAllMocks())
it('navigates in-console for a module entry (no new tab)', () => {
const push = vi.fn()
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
openProduct(findEntry('models')!, push)
expect(push).toHaveBeenCalledWith('/models')
expect(openSpy).not.toHaveBeenCalled()
})
it('opens an external entry in a new noopener tab (no router push)', () => {
const push = vi.fn()
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const gateway = findEntry('gateway')!
expect(gateway.kind).toBe('external')
openProduct(gateway, push)
expect(openSpy).toHaveBeenCalledWith((gateway as { href: string }).href, '_blank', 'noopener')
expect(push).not.toHaveBeenCalled()
})
})
+21
View File
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
import { OSS_PROGRAM, githubUrl, DOCS_URL } from '~/lib/oss-program'
/** ONE source of truth for the OSS revenue-share program; stated identically everywhere. */
describe('OSS_PROGRAM', () => {
it('states the share, asset, and a live dividends URL', () => {
expect(OSS_PROGRAM.revenueSharePct).toBe(25)
expect(OSS_PROGRAM.payoutAsset).toBe('HUSD')
expect(OSS_PROGRAM.dividendsUrl).toMatch(/^https:\/\//)
expect(OSS_PROGRAM.basis).toContain('SBOM')
})
it('githubUrl builds an org/repo link', () => {
expect(githubUrl('hanzoai/vector')).toBe('https://github.com/hanzoai/vector')
})
it('DOCS_URL is the canonical docs site', () => {
expect(DOCS_URL).toBe('https://docs.hanzo.ai')
})
})
+148
View File
@@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { NextRequest } from 'next/server'
import { GET, POST, PATCH, DELETE } from '../../app/paas/[...path]/route'
const ORIGIN = 'https://console.hanzo.ai'
const CLOUD = 'https://cloud-api.test'
const ctx = (path: string[]) => ({ params: Promise.resolve({ path }) })
const reqWith = (method: string, cookie?: string, pathSuffix = 'apps?cluster=hanzo'): NextRequest =>
new NextRequest(`${ORIGIN}/paas/${pathSuffix}`, {
method,
headers: cookie ? { cookie } : {},
body: method === 'GET' || method === 'HEAD' ? undefined : '{}',
})
// GLOBAL admin: owner is the admin org → reaches the global control plane.
const GLOBAL_ADMIN = { owner: 'admin', name: 'root', isAdmin: true, isGlobalAdmin: false, type: 'normal-user' }
// ORG admin (the C1 case): admin OF a tenant org, NOT global → must be refused.
const ORG_ADMIN = { owner: 'maxpower', name: 'davelorenzini', isAdmin: true, isGlobalAdmin: false, type: 'normal-user' }
const MEMBER = { owner: 'hanzo', name: 'mo', isAdmin: false, isGlobalAdmin: false, type: 'normal-user' }
const ANON = { owner: 'hanzo', name: 'anonymous', type: 'anonymous-user' }
const accountRes = (account: unknown) =>
new Response(JSON.stringify({ status: 'ok', msg: '', data: account }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
function fetchFor(account: unknown, platform?: { status?: number; body?: string }) {
return vi.fn(async (url: string) => {
if (String(url).includes('/v1/get-account')) return accountRes(account)
return new Response(platform?.body ?? '{"apps":[]}', {
status: platform?.status ?? 200,
headers: { 'content-type': 'application/json' },
})
})
}
const VERBS = [
['GET', GET],
['POST', POST],
['PATCH', PATCH],
['DELETE', DELETE],
] as const
describe('/paas proxy — deny-by-default GLOBAL-admin authz', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
vi.stubEnv('CLOUD_URL', CLOUD) // pin the session authority
})
afterEach(() => vi.unstubAllEnvs())
it('401 for every verb when there is no session (and no backend call)', async () => {
vi.stubEnv('PAAS_SERVICE_TOKEN', 'secret-token')
const f = fetchFor(ANON)
vi.stubGlobal('fetch', f)
for (const [method, handler] of VERBS) {
const r = await handler(reqWith(method), ctx(['apps']))
expect(r.status, `${method} unauth → 401`).toBe(401)
}
expect(f).not.toHaveBeenCalled() // short-circuits with no cookie
})
it('401 for an anonymous casibase session', async () => {
vi.stubEnv('PAAS_SERVICE_TOKEN', 'secret-token')
vi.stubGlobal('fetch', fetchFor(ANON))
const r = await GET(reqWith('GET', 'sid=anon'), ctx(['apps']))
expect(r.status).toBe(401)
})
it('403 for every verb for an authenticated NON-admin', async () => {
vi.stubEnv('PAAS_SERVICE_TOKEN', 'secret-token')
vi.stubGlobal('fetch', fetchFor(MEMBER))
for (const [method, handler] of VERBS) {
const r = await handler(reqWith(method, 'sid=mo'), ctx(['apps']))
expect(r.status, `${method} non-admin → 403`).toBe(403)
}
})
// THE C1 FIX: a tenant ORG admin (isAdmin=true, owner=maxpower) must NOT reach
// the global platform — the token forwards no user scope, so org-admin ≠ global.
it('403 for every verb for a tenant ORG admin (isAdmin=true but not global)', async () => {
vi.stubEnv('PAAS_SERVICE_TOKEN', 'secret-token')
const f = fetchFor(ORG_ADMIN, { status: 200, body: '{"apps":[{"id":"leak"}]}' })
vi.stubGlobal('fetch', f)
for (const [method, handler] of VERBS) {
const r = await handler(reqWith(method, 'sid=dave'), ctx(['apps']))
expect(r.status, `${method} org-admin → 403`).toBe(403)
}
// never forwarded to the platform → no token ever attached for an org admin
expect(f.mock.calls.some(([u]) => String(u).includes('platform'))).toBe(false)
})
it('a GLOBAL admin reaches upstream; the service token is attached server-side only', async () => {
vi.stubEnv('PAAS_SERVICE_TOKEN', 'secret-token')
vi.stubEnv('PLATFORM_URL', 'https://platform.test')
const f = fetchFor(GLOBAL_ADMIN, { status: 200, body: '{"apps":[{"id":"x"}]}' })
vi.stubGlobal('fetch', f)
const r = await GET(reqWith('GET', 'sid=root'), ctx(['apps']))
expect(r.status).toBe(200)
expect(await r.text()).toContain('apps')
const forwarded = f.mock.calls.find(([u]) => String(u).startsWith('https://platform.test'))
expect(forwarded, 'forwarded to platform').toBeTruthy()
expect(String(forwarded![0])).toBe('https://platform.test/v1/apps?cluster=hanzo')
expect((forwarded![1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer secret-token' })
})
it('a GLOBAL admin gets an honest 501 when the token is unset (no token leak to anyone)', async () => {
// PAAS_SERVICE_TOKEN intentionally unset
vi.stubGlobal('fetch', fetchFor(GLOBAL_ADMIN))
const r = await GET(reqWith('GET', 'sid=root'), ctx(['apps']))
expect(r.status).toBe(501)
})
// Path-traversal: even a global admin cannot escape /v1 on the platform.
it('400 on a `..` traversal segment (cannot escape /v1), for a global admin', async () => {
vi.stubEnv('PAAS_SERVICE_TOKEN', 'secret-token')
vi.stubEnv('PLATFORM_URL', 'https://platform.test')
const f = fetchFor(GLOBAL_ADMIN, { status: 200, body: '{}' })
vi.stubGlobal('fetch', f)
const r = await GET(reqWith('GET', 'sid=root', 'x'), ctx(['..', '..', 'admin']))
expect(r.status).toBe(400)
// never forwarded to the platform
expect(f.mock.calls.some(([u]) => String(u).startsWith('https://platform.test'))).toBe(false)
})
it('400 on a `.` segment and on an empty segment', async () => {
vi.stubEnv('PAAS_SERVICE_TOKEN', 'secret-token')
vi.stubGlobal('fetch', fetchFor(GLOBAL_ADMIN))
expect((await GET(reqWith('GET', 'sid=root', 'x'), ctx(['.', 'apps']))).status).toBe(400)
expect((await GET(reqWith('GET', 'sid=root', 'x'), ctx(['apps', '']))).status).toBe(400)
})
it('allows legit multi-segment platform paths (org/{org}/cluster)', async () => {
vi.stubEnv('PAAS_SERVICE_TOKEN', 'secret-token')
vi.stubEnv('PLATFORM_URL', 'https://platform.test')
const f = fetchFor(GLOBAL_ADMIN, { status: 200, body: '{"clusters":[]}' })
vi.stubGlobal('fetch', f)
const r = await GET(reqWith('GET', 'sid=root', 'org/maxpower/cluster'), ctx(['org', 'maxpower', 'cluster']))
expect(r.status).toBe(200)
const forwarded = f.mock.calls.find(([u]) => String(u).startsWith('https://platform.test'))
expect(String(forwarded![0])).toBe('https://platform.test/v1/org/maxpower/cluster')
})
})
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { PREFS_CACHE_PREFIX, prefsCacheKey, clearPreferencesCache } from '~/lib/products/preferences-cache'
/** Map-backed localStorage double (Node 26's jsdom doesn't wire a usable one). */
function installLocalStorage(): Storage {
const store = new Map<string, string>()
const mock: Storage = {
get length() {
return store.size
},
key: (i: number) => Array.from(store.keys())[i] ?? null,
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
setItem: (k: string, v: string) => {
store.set(k, String(v))
},
removeItem: (k: string) => {
store.delete(k)
},
clear: () => store.clear(),
}
Object.defineProperty(window, 'localStorage', { value: mock, configurable: true })
return mock
}
describe('preferences cache', () => {
let ls: Storage
beforeEach(() => {
ls = installLocalStorage()
})
it('prefsCacheKey is per-user with an anon fallback', () => {
expect(prefsCacheKey('ada')).toBe(`${PREFS_CACHE_PREFIX}ada`)
expect(prefsCacheKey(undefined)).toBe(`${PREFS_CACHE_PREFIX}anon`)
})
it('clearPreferencesCache removes ONLY prefs.* keys (leaves the rest)', () => {
ls.setItem(prefsCacheKey('ada'), '{"favorites":["chat"]}')
ls.setItem(prefsCacheKey(undefined), '{}')
ls.setItem('unrelated.key', 'keep-me')
clearPreferencesCache()
expect(ls.getItem(prefsCacheKey('ada'))).toBeNull()
expect(ls.getItem(prefsCacheKey(undefined))).toBeNull()
expect(ls.getItem('unrelated.key')).toBe('keep-me')
})
it('is a no-op when there is nothing cached', () => {
expect(() => clearPreferencesCache()).not.toThrow()
})
})
+136
View File
@@ -0,0 +1,136 @@
import { describe, it, expect } from 'vitest'
import {
newProvider,
applyCategory,
applyType,
showSubType,
showRegion,
showClientSecret,
temperatureEnabled,
topPEnabled,
showSampling,
} from '~/components/products/providers/logic'
/**
* The provider editor is a thin render layer over these pure rules (the
* category→type→subType cascade + conditional field visibility). Porting from
* the casibase ProviderEditPage, this is the most intricate domain logic in the
* console, so each branch is pinned.
*/
describe('newProvider', () => {
it('is a Model/OpenAI/gpt-4 template owned by the caller', () => {
const p = newProvider('hanzo')
expect(p.owner).toBe('hanzo')
expect(p.category).toBe('Model')
expect(p.type).toBe('OpenAI')
expect(p.subType).toBe('gpt-4')
expect(p.state).toBe('Active')
expect(p.name).toMatch(/^provider_/)
})
it('mints a fresh name each call', () => {
expect(newProvider('hanzo').name).not.toBe(newProvider('hanzo').name)
})
})
describe('applyCategory', () => {
it('applies the category defaults (Storage → Local File System)', () => {
const p = applyCategory(newProvider('o'), 'Storage')
expect(p.category).toBe('Storage')
expect(p.type).toBe('Local File System')
})
it('applies Embedding defaults (OpenAI / AdaSimilarity)', () => {
const p = applyCategory(newProvider('o'), 'Embedding')
expect(p).toMatchObject({ category: 'Embedding', type: 'OpenAI', subType: 'AdaSimilarity' })
})
it('sets category without defaults for an unknown category', () => {
const base = newProvider('o')
const p = applyCategory(base, 'Public Cloud')
expect(p.category).toBe('Public Cloud')
expect(p.type).toBe(base.type) // unchanged
})
it('does not mutate the input', () => {
const base = newProvider('o')
applyCategory(base, 'Storage')
expect(base.category).toBe('Model')
})
})
describe('applyType', () => {
it('applies the subType default for the current category', () => {
const p = applyType({ ...newProvider('o'), category: 'Model' }, 'Claude')
expect(p.type).toBe('Claude')
expect(p.subType).toBe('claude-opus-4-0')
})
it('leaves subType when the type has no mapped default', () => {
const base = { ...newProvider('o'), category: 'Model', subType: 'keep-me' }
const p = applyType(base, 'SomeUnknownType')
expect(p.type).toBe('SomeUnknownType')
expect(p.subType).toBe('keep-me')
})
})
describe('field visibility', () => {
const m = (over: Record<string, unknown>) => ({ ...newProvider('o'), ...over }) as never
it('showSubType: model-family categories only', () => {
expect(showSubType(m({ category: 'Model' }))).toBe(true)
expect(showSubType(m({ category: 'Embedding' }))).toBe(true)
expect(showSubType(m({ category: 'Storage' }))).toBe(false)
})
it('showRegion: hidden for model/storage/agent kinds and for k8s/ethereum', () => {
expect(showRegion(m({ category: 'Storage' }))).toBe(false)
expect(showRegion(m({ category: 'Model' }))).toBe(false)
expect(showRegion(m({ category: 'Public Cloud' }))).toBe(true)
expect(showRegion(m({ category: 'Blockchain', type: 'Ethereum' }))).toBe(false)
expect(showRegion(m({ category: 'Blockchain', type: 'Solana' }))).toBe(true)
expect(showRegion(m({ category: 'Private Cloud', type: 'Kubernetes' }))).toBe(false)
})
it('showClientSecret: hidden for MCP agents, scans, dummy/ollama, non-OpenAI storage', () => {
expect(showClientSecret(m({ category: 'Model', type: 'OpenAI' }))).toBe(true)
expect(showClientSecret(m({ category: 'Agent', type: 'MCP' }))).toBe(false)
expect(showClientSecret(m({ category: 'Scan', type: 'Nmap' }))).toBe(false)
expect(showClientSecret(m({ category: 'Model', type: 'Ollama' }))).toBe(false)
expect(showClientSecret(m({ category: 'Model', type: 'Dummy' }))).toBe(false)
expect(showClientSecret(m({ category: 'Storage', type: 'Local File System' }))).toBe(false)
})
it('showSampling: only for Model providers', () => {
expect(showSampling(m({ category: 'Model' }))).toBe(true)
expect(showSampling(m({ category: 'Embedding' }))).toBe(false)
})
})
describe('temperatureEnabled / topPEnabled', () => {
const m = (over: Record<string, unknown>) => ({ ...newProvider('o'), ...over }) as never
it('is off for non-Model categories', () => {
expect(temperatureEnabled(m({ category: 'Embedding' }))).toBe(false)
})
it('is on for sampling-capable types (Gemini, DeepSeek, Ollama, …)', () => {
expect(temperatureEnabled(m({ category: 'Model', type: 'Gemini' }))).toBe(true)
expect(temperatureEnabled(m({ category: 'Model', type: 'DeepSeek' }))).toBe(true)
})
it('is on for OpenAI chat models but OFF for o1/o3/o4 reasoning models', () => {
expect(temperatureEnabled(m({ category: 'Model', type: 'OpenAI', subType: 'gpt-4' }))).toBe(true)
expect(temperatureEnabled(m({ category: 'Model', type: 'OpenAI', subType: 'o1-preview' }))).toBe(false)
expect(temperatureEnabled(m({ category: 'Model', type: 'OpenAI', subType: 'o3-mini' }))).toBe(false)
})
it('is off for types with no sampling support (e.g. Claude is not in the list)', () => {
expect(temperatureEnabled(m({ category: 'Model', type: 'Claude' }))).toBe(false)
})
it('topPEnabled mirrors temperatureEnabled', () => {
expect(topPEnabled).toBe(temperatureEnabled)
})
})
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest'
import {
catalog,
categoryOrder,
productModules,
findModule,
findEntry,
catalogByCategory,
visibleCatalog,
type CatalogEntry,
} from '~/lib/products/registry'
/**
* The catalog is the single source of truth for nav + routing. If it drifts
* (duplicate id, dead route, empty category, admin entry that leaks), the whole
* console drifts. These invariants are the guard rails.
*/
describe('catalog integrity', () => {
it('is non-empty', () => {
expect(catalog.length).toBeGreaterThan(50)
})
it('has unique ids', () => {
const ids = catalog.map((e) => e.id)
expect(new Set(ids).size).toBe(ids.length)
})
it('only uses declared categories', () => {
for (const e of catalog) {
expect(categoryOrder, `${e.id} category`).toContain(e.category)
}
})
it('only uses the three honest statuses', () => {
for (const e of catalog) {
expect(['enabled', 'external', 'soon']).toContain(e.status)
}
})
it('every module entry has routes, an index route, and real components', () => {
const modules = catalog.filter((e): e is Extract<CatalogEntry, { kind: 'module' }> => e.kind === 'module')
for (const m of modules) {
expect(m.routes.length, `${m.id} routes`).toBeGreaterThan(0)
expect(m.routes.some((r) => r.path === ''), `${m.id} has index route`).toBe(true)
for (const r of m.routes) {
expect(typeof r.component, `${m.id}#${r.path} component`).toBe('function')
// Route patterns are simple segment lists, never absolute paths.
expect(r.path.startsWith('/'), `${m.id}#${r.path} not absolute`).toBe(false)
}
}
})
it('every external entry has a real https href', () => {
const externals = catalog.filter((e): e is Extract<CatalogEntry, { kind: 'external' }> => e.kind === 'external')
expect(externals.length).toBeGreaterThan(0)
for (const e of externals) {
expect(e.href, `${e.id} href`).toMatch(/^https:\/\//)
}
})
it('soon entries are honest modules (single index route)', () => {
for (const e of catalog) {
if (e.status === 'soon') {
expect(e.kind, `${e.id} soon kind`).toBe('module')
}
}
})
it('derives productModules as exactly the module subset, in order', () => {
const moduleIds = catalog.filter((e) => e.kind === 'module').map((e) => e.id)
expect(productModules.map((m) => m.id)).toEqual(moduleIds)
})
it('findModule resolves every module and rejects external ids', () => {
for (const m of productModules) expect(findModule(m.id)?.id).toBe(m.id)
expect(findModule('gateway')).toBeUndefined() // external
expect(findModule('nope')).toBeUndefined()
})
it('findEntry resolves any catalog id', () => {
expect(findEntry('models')?.kind).toBe('module')
expect(findEntry('gateway')?.kind).toBe('external')
expect(findEntry('nope')).toBeUndefined()
})
})
describe('catalogByCategory', () => {
it('returns all ten categories in declared order with no empty groups', () => {
const groups = catalogByCategory()
expect(groups.map((g) => g.category)).toEqual(categoryOrder)
for (const g of groups) expect(g.entries.length, `${g.category}`).toBeGreaterThan(0)
})
it('partitions the catalog exactly (no entry lost or duplicated)', () => {
const flat = catalogByCategory().flatMap((g) => g.entries)
expect(flat.length).toBe(catalog.length)
})
})
describe('admin visibility (least privilege)', () => {
it('marks the sensitive surfaces admin-only', () => {
const adminIds = catalog.filter((e) => e.admin).map((e) => e.id).sort()
// IAM/KMS/Secrets/Audit/Clusters/Kubernetes are admin-gated.
expect(adminIds).toEqual(
['audit', 'clusters', 'iam', 'kms', 'kubernetes', 'secrets'].sort(),
)
})
it('hides admin entries from non-admins, shows everything to admins', () => {
const asAdmin = visibleCatalog(true)
const asMember = visibleCatalog(false)
expect(asAdmin.length).toBe(catalog.length)
expect(asMember.length).toBeLessThan(catalog.length)
// No admin entry survives for a non-admin.
expect(asMember.some((e) => e.admin)).toBe(false)
// A non-admin still sees ordinary products.
expect(asMember.some((e) => e.id === 'models')).toBe(true)
})
})
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { getServerAccount, isGlobalAdminAccount } from '~/lib/auth/server'
const CLOUD = 'https://cloud-api.test'
const envRes = (account: unknown) =>
new Response(JSON.stringify({ status: 'ok', msg: '', data: account }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
describe('getServerAccount (fail-secure, pinned authority)', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
vi.stubEnv('CLOUD_URL', CLOUD)
})
afterEach(() => vi.unstubAllEnvs())
it('returns null with no cookie and makes NO network call', async () => {
const f = vi.fn()
vi.stubGlobal('fetch', f)
expect(await getServerAccount(null)).toBeNull()
expect(f).not.toHaveBeenCalled()
})
it('returns the account for a real session', async () => {
vi.stubGlobal('fetch', vi.fn(async () => envRes({ owner: 'hanzo', name: 'ada', isAdmin: true, type: 'normal-user' })))
expect(await getServerAccount('sid=1')).toMatchObject({ owner: 'hanzo', name: 'ada', isAdmin: true })
})
it('treats an anonymous casibase session as logged-out', async () => {
vi.stubGlobal('fetch', vi.fn(async () => envRes({ owner: 'hanzo', name: 'anonymous', type: 'anonymous-user' })))
expect(await getServerAccount('sid=1')).toBeNull()
})
it('returns null on a non-2xx', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 401 })))
expect(await getServerAccount('sid=1')).toBeNull()
})
it('returns null on a network error', async () => {
vi.stubGlobal('fetch', vi.fn(async () => {
throw new Error('down')
}))
expect(await getServerAccount('sid=1')).toBeNull()
})
it('returns null on an unparseable (HTML) body', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('<html>', { status: 200, headers: { 'content-type': 'text/html' } })))
expect(await getServerAccount('sid=1')).toBeNull()
})
it('calls the PINNED CLOUD_URL authority — never a request-derived origin', async () => {
const f = vi.fn(async () => envRes({ owner: 'hanzo', name: 'ada', isAdmin: true, type: 'normal-user' }))
vi.stubGlobal('fetch', f)
await getServerAccount('sid=abc')
const [url, init] = f.mock.calls[0] as [string, RequestInit]
expect(String(url)).toBe(`${CLOUD}/v1/get-account`)
expect(init.headers).toMatchObject({ cookie: 'sid=abc' })
})
it('FAILS SECURE when no authority is pinned (CLOUD_URL unset): null, no network call', async () => {
vi.unstubAllEnvs() // CLOUD_URL + NEXT_PUBLIC_CLOUD_URL both unset
const f = vi.fn()
vi.stubGlobal('fetch', f)
expect(await getServerAccount('sid=1')).toBeNull()
expect(f).not.toHaveBeenCalled()
})
})
describe('isGlobalAdminAccount (deny-by-default, GLOBAL admin only)', () => {
it('null is false', () => {
expect(isGlobalAdminAccount(null)).toBe(false)
})
it('a GLOBAL admin (owner == admin org) is true', () => {
expect(isGlobalAdminAccount({ owner: 'admin', name: 'root', isAdmin: true, isGlobalAdmin: false })).toBe(true)
})
it("casdoor's reserved built-in org is global admin", () => {
expect(isGlobalAdminAccount({ owner: 'built-in', name: 'admin', isAdmin: true, isGlobalAdmin: false })).toBe(true)
})
it('an explicit isGlobalAdmin flag (any owner) is true (future-proof)', () => {
expect(isGlobalAdminAccount({ owner: 'hanzo', name: 'x', isAdmin: false, isGlobalAdmin: true })).toBe(true)
})
// THE C1 FIX: a tenant ORG admin must NOT be a global admin.
it('an ORG admin (isAdmin=true) of a customer org is NOT a global admin', () => {
expect(isGlobalAdminAccount({ owner: 'maxpower', name: 'davelorenzini', isAdmin: true, isGlobalAdmin: false })).toBe(false)
})
it('the hanzo tenant org admin is NOT a global admin', () => {
expect(isGlobalAdminAccount({ owner: 'hanzo', name: 'z', isAdmin: true, isGlobalAdmin: false })).toBe(false)
})
// Mirror the backend exactly: IsGlobalAdmin() == (Owner == conf.AdminOrg),
// owner-only — membership of the admin org IS global, no org-admin flag needed.
it('a member of the admin org is global admin even without the org-admin flag', () => {
expect(isGlobalAdminAccount({ owner: 'admin', name: 'x', isAdmin: false, isGlobalAdmin: false })).toBe(true)
})
it('a normal customer user is not a global admin', () => {
expect(isGlobalAdminAccount({ owner: 'maxpower', name: 'mo', isAdmin: false, isGlobalAdmin: false })).toBe(false)
})
})
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest'
import { slugError } from '~/lib/slug'
/**
* Resource-name validation is shared by every create form (managed resources +
* clusters). One rule, one place — so its edge cases are pinned here.
*/
describe('slugError', () => {
it('accepts a typical DNS-ish name', () => {
expect(slugError('my-resource')).toBeNull()
expect(slugError('db1')).toBeNull()
expect(slugError('a1')).toBeNull()
})
it('rejects too short / too long', () => {
expect(slugError('a')).toBe('Use 240 characters.')
expect(slugError('')).toBe('Use 240 characters.')
expect(slugError('a'.repeat(41))).toBe('Use 240 characters.')
})
it('accepts exactly 40 chars and rejects 41', () => {
expect(slugError('a' + 'b'.repeat(38) + 'c')).toBeNull() // 40
expect(slugError('a' + 'b'.repeat(39) + 'c')).not.toBeNull() // 41
})
it('rejects consecutive hyphens', () => {
expect(slugError('foo--bar')).toBe('No consecutive hyphens.')
})
it('rejects uppercase, leading digit/hyphen, trailing hyphen, symbols', () => {
const msg =
'Lowercase letters, numbers, hyphens; start with a letter, end alphanumeric.'
expect(slugError('MyResource')).toBe(msg)
expect(slugError('1abc')).toBe(msg)
expect(slugError('-abc')).toBe(msg)
expect(slugError('abc-')).toBe(msg)
expect(slugError('a_b')).toBe(msg)
expect(slugError('a b')).toBe(msg)
expect(slugError('café')).toBe(msg)
})
})
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import { honestError, asApiError } from '~/components/ui/States'
import { ApiError } from '~/lib/api'
/**
* Honest async states are the ONE way the console explains a failed load — never
* a fabricated success, never a generic crash. The status→message mapping is the
* contract every admin module relies on, so it is pinned here.
*/
describe('honestError', () => {
it('maps 404 to "Not available on this deployment"', () => {
expect(honestError(new ApiError('x', 404)).title).toBe('Not available on this deployment')
})
it('maps 503 to "Service unavailable"', () => {
expect(honestError(new ApiError('x', 503)).title).toBe('Service unavailable')
})
it('maps 401/403 to "Access required"', () => {
expect(honestError(new ApiError('x', 401)).title).toBe('Access required')
expect(honestError(new ApiError('x', 403)).title).toBe('Access required')
})
it('treats sign-in/unauthorized messages as access-required even without a status', () => {
expect(honestError(new ApiError('Please sign in first')).title).toBe('Access required')
expect(honestError(new ApiError('unauthorized operation')).title).toBe('Access required')
})
it('falls back to the raw message for anything else', () => {
const e = honestError(new ApiError('disk on fire', 500))
expect(e.title).toBe('Could not load')
expect(e.body).toBe('disk on fire')
})
it('honors per-surface copy overrides for 404 / unauthorized', () => {
expect(honestError(new ApiError('x', 404), { notFound: 'IAM not routed here' }).body).toBe('IAM not routed here')
expect(honestError(new ApiError('x', 403), { unauthorized: 'admins only' }).body).toBe('admins only')
})
})
describe('asApiError', () => {
it('passes an ApiError through unchanged', () => {
const e = new ApiError('x', 404)
expect(asApiError(e)).toBe(e)
})
it('wraps a plain Error preserving the message', () => {
const out = asApiError(new Error('boom'))
expect(out).toBeInstanceOf(ApiError)
expect(out.message).toBe('boom')
})
it('stringifies a non-Error throw', () => {
expect(asApiError('weird').message).toBe('weird')
})
})
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { NextRequest } from 'next/server'
// Deterministic on-chain receipt: a successful HUSD Transfer(from → treasury) of
// 5e18 base units = 500 cents. Mocked so the route's verify path is hermetic.
const HUSD = '0x' + 'a'.repeat(40)
const TREASURY = '0x' + 'b'.repeat(40)
const FROM = '0x' + 'c'.repeat(40)
vi.mock('ethers', () => {
class JsonRpcProvider {
constructor(_url: string, _cid: number) {}
async getTransactionReceipt(_hash: string) {
return { status: 1, logs: [{ address: HUSD, topics: ['0xddf2'], data: '0x' }] }
}
}
class Interface {
constructor(_abi: unknown) {}
parseLog(_log: unknown) {
return { name: 'Transfer', args: { from: FROM, to: TREASURY, value: 5000000000000000000n } }
}
}
return { ethers: { JsonRpcProvider, Interface, getAddress: (a: string) => a } }
})
import { POST } from '../../app/billing/topup/wallet/route'
const ORIGIN = 'https://console.hanzo.ai'
const TXHASH = '0x' + '1'.repeat(64)
const reqWith = (body: unknown, cookie?: string): NextRequest =>
new NextRequest(`${ORIGIN}/billing/topup/wallet`, {
method: 'POST',
headers: cookie ? { cookie, 'content-type': 'application/json' } : { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
const accountRes = (account: unknown) =>
new Response(JSON.stringify({ status: 'ok', msg: '', data: account }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
const SESSION_USER = { owner: 'hanzo', name: 'realuser', isAdmin: false, type: 'normal-user' }
/** Mock get-account + commerce; capture the commerce payment request. */
function mockBackend(account: unknown) {
const calls: { url: string; init: RequestInit }[] = []
const f = vi.fn(async (url: string, init?: RequestInit) => {
calls.push({ url: String(url), init: init ?? {} })
if (String(url).includes('/v1/get-account')) return accountRes(account)
if (String(url).includes('/v1/billing/payment'))
return new Response(JSON.stringify({ status: 'recorded' }), { status: 200 })
if (String(url).includes('/v1/billing/balance'))
return new Response(JSON.stringify({ balance: 500 }), { status: 200 })
return new Response('{}', { status: 200 })
})
vi.stubGlobal('fetch', f)
return calls
}
const configured = () => {
vi.stubEnv('HANZO_HUSD_ADDRESS', HUSD)
vi.stubEnv('HANZO_HUSD_TREASURY', TREASURY)
vi.stubEnv('COMMERCE_URL', 'https://commerce.test')
vi.stubEnv('CLOUD_URL', 'https://cloud-api.test') // pinned session authority
}
describe('wallet top-up — IDOR + idempotency', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
afterEach(() => vi.unstubAllEnvs())
it('501 when HUSD/treasury is not configured (greenfield)', async () => {
const r = await POST(reqWith({ txHash: TXHASH }, 'sid=1'))
expect(r.status).toBe(501)
})
it('401 when configured but there is no session', async () => {
configured()
mockBackend(null)
const r = await POST(reqWith({ txHash: TXHASH })) // no cookie
expect(r.status).toBe(401)
})
it('credits the SESSION user (ignores a spoofed body.userId) and keys idempotency on txHash', async () => {
configured()
const calls = mockBackend(SESSION_USER)
const r = await POST(reqWith({ txHash: TXHASH, userId: 'victim-account' }, 'sid=1'))
expect(r.status).toBe(200)
expect(await r.json()).toMatchObject({ creditedCents: 500, txHash: TXHASH })
const payment = calls.find((c) => c.url.includes('/v1/billing/payment'))!
expect(payment, 'recorded to commerce').toBeTruthy()
// IDOR fix: the recorded user is the SESSION user, NOT the spoofed body value.
const sent = JSON.parse(String(payment.init.body))
expect(sent.userId).toBe('realuser')
expect(sent.userId).not.toBe('victim-account')
// Idempotency: the tx hash is the replay key.
expect((payment.init.headers as Record<string, string>)['Idempotency-Key']).toBe(TXHASH)
// Balance is read for the SESSION user too, never a client value.
const balance = calls.find((c) => c.url.includes('/v1/billing/balance'))!
expect(balance.url).toContain('user=realuser')
})
it('rejects a malformed tx hash with 400', async () => {
configured()
mockBackend(SESSION_USER)
const r = await POST(reqWith({ txHash: 'not-a-hash' }, 'sid=1'))
expect(r.status).toBe(400)
})
})
+10 -1
View File
@@ -29,5 +29,14 @@
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": ["node_modules", ".next", "out"]
"exclude": [
"node_modules",
".next",
"out",
"test",
"vitest.config.ts",
"playwright.config.ts",
"playwright-report",
"test-results"
]
}
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig } from 'vitest/config'
import { resolve } from 'node:path'
/**
* Unit-test runner config for console2.
*
* Scope: PURE client logic + data-integrity (routing, registry/catalog, config,
* the /v1 client envelope, domain `logic.ts` files). Real rendering and user
* interaction are covered by Playwright (test/e2e) against the live Next server,
* so the heavy GUI deps are aliased to hermetic no-op stubs: the registry module
* graph imports cleanly without pulling Tamagui/react-native into Node.
*/
const stub = (file: string) => resolve(__dirname, 'test/stubs', file)
export default defineConfig({
resolve: {
alias: [
// App path alias (`~/x` -> `src/x`), mirrors tsconfig paths.
{ find: /^~\//, replacement: resolve(__dirname, 'src') + '/' },
// Heavy UI deps -> hermetic stubs (imported, never rendered in unit tests).
{ find: '@hanzo/gui', replacement: stub('gui.ts') },
{ find: '@hanzogui/lucide-icons-2', replacement: stub('icons.ts') },
{ find: '@hanzo/iam-js-sdk', replacement: stub('iam-sdk.ts') },
{ find: '@zap-proto/web', replacement: stub('zap-web.ts') },
{ find: '@zap-proto/zap', replacement: stub('zap-zap.ts') },
{ find: 'ethers', replacement: stub('ethers.ts') },
],
},
esbuild: { jsx: 'automatic' },
test: {
environment: 'jsdom',
environmentOptions: { jsdom: { url: 'https://console.hanzo.ai' } },
include: ['test/unit/**/*.test.ts'],
globals: true,
clearMocks: true,
restoreMocks: true,
unstubGlobals: true,
},
})