Files
hanzo.ai/e2e/gates/organization.spec.ts
zeekay 5fd8163efb Keep API keys on the platform alone
hanzo.ai had its own keys page beside platform.hanzo.ai/api-keys.
Settings' API keys row and the dashboard's keys tab now open the
platform's page, and the old address sends the reader there until the
account menu (@hanzo/rooms 0.1.38) is replaced by 0.1.43, which links
to the platform itself.
2026-10-12 13:35:01 -07:00

181 lines
8.2 KiB
TypeScript

import { expect, test, type Page, type Request } from '@playwright/test'
import { serveExport } from './export'
import { signIn } from '../session'
/**
* THE SETTINGS AREA, AS EACH ROLE READS IT.
*
* /settings and /settings/organization/* draw what IAM's role for the acting org
* allows — an owner or admin gets the controls, a member reads — and an org of
* one is offered a team instead of a roster. Every write goes to the API that
* owns it with the body that API reads. Measured on the shipped bytes with IAM,
* cloud and billing answered here.
*/
const TEAM = 'acme'
const HOME = 'sam'
const json = (body: unknown, status = 200) => ({ status, contentType: 'application/json', body: JSON.stringify(body) })
interface Heard {
profile: unknown[]
settings: unknown[]
minted: unknown[]
}
/** IAM, cloud and billing as they answer for `org`. */
async function platform(page: Page, org: string, personal: boolean): Promise<Heard> {
const heard: Heard = { profile: [], settings: [], minted: [] }
await page.route('**/v1/**', (route) => route.fulfill(json({})))
await page.route(
(url) => url.pathname === `/v1/iam/organizations/admin/${org}`,
(route) => route.fulfill(json({ owner: 'admin', name: org, displayName: personal ? 'Sam' : 'Acme Inc', isPersonal: personal })),
)
await page.route(
(url) => url.pathname === '/v1/iam/organizations/profile',
(route) => {
heard.profile.push(route.request().postDataJSON())
return route.fulfill(json({ owner: 'admin', name: org, displayName: 'Acme Labs' }))
},
)
let config: Record<string, unknown> = { note: 'kept' }
await page.route(
(url) => url.pathname === '/v1/settings/organization',
(route) => {
const req: Request = route.request()
if (req.method() === 'PUT') {
const body = req.postDataJSON() as { config: Record<string, unknown> }
heard.settings.push(body)
config = body.config
}
return route.fulfill(json({ product: 'organization', config, secretKeys: [] }))
},
)
await page.route(
(url) => url.pathname === '/v1/iam/memberships',
(route) =>
route.fulfill(
json({
status: 'ok',
data: [
{ user: `${HOME}/${HOME}`, org, role: 'owner' },
{ user: 'kim/kim', org, role: 'member' },
],
}),
),
)
await page.route((url) => url.pathname === '/v1/iam/users', (route) => route.fulfill(json({ users: [] })))
await page.route((url) => url.pathname === '/v1/iam/invitations', (route) => route.fulfill(json({ invitations: [] })))
// Cloud lists the keys the reader may see: for an admin, every key filed in the org.
await page.route(
(url) => url.pathname === '/v1/account/keys',
(route) => {
if (route.request().method() === 'POST') {
heard.minted.push(route.request().postDataJSON())
return route.fulfill(json({ id: `${HOME}-secret-1`, name: 'key 3', type: 'secret', status: 'active', key: 'sk-live-0123456789abcdef' }))
}
return route.fulfill(
json({
keys: [
{ id: 'default', name: 'Default', type: 'secret', status: 'active', creator: `${HOME}/${HOME}`, created: '2026-08-01T00:00:00Z' },
{ id: 'ci', name: 'CI', type: 'secret', status: 'active', creator: 'kim/kim', created: '2026-09-01T00:00:00Z' },
],
}),
)
},
)
return heard
}
/**
* Sam, who lives in his personal org and acts in `org` with `role` — the token's
* `orgs` claim lists the home org first — with `org` chosen in this browser.
*/
async function as(page: Page, role: string, org: string, personal = false): Promise<Heard> {
const heard = await platform(page, org, personal)
const orgs = org === HOME ? [{ org: HOME, role }] : [{ org: HOME, role: 'admin' }, { org, role }]
await signIn(page, { sub: 'u-sam', name: HOME, email: '[email protected]', orgs })
await page.addInitScript((o) => localStorage.setItem('hanzo_iam_current_org', o), org)
return heard
}
test.describe('settings', () => {
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
test('an admin renames the org through IAM and keeps its address in cloud settings', async ({ page }) => {
const heard = await as(page, 'admin', TEAM)
await page.goto(`${base}/settings/organization`)
const name = page.locator('#org-name')
await expect(name).toHaveValue('Acme Inc', { timeout: 30_000 })
await expect(page.getByRole('button', { name: 'Delete organization' })).toBeVisible()
await expect(page.getByText(TEAM, { exact: true })).toBeVisible()
await name.fill('Acme Labs')
await page.getByLabel('Address line 1').fill('1 Market St')
await page.locator('#org-city').fill('San Francisco')
await page.getByRole('button', { name: 'Save' }).click()
await expect.poll(() => heard.profile.length).toBe(1)
expect(heard.profile[0]).toEqual({ owner: 'admin', name: TEAM, displayName: 'Acme Labs' })
await expect.poll(() => heard.settings.length).toBe(1)
const stored = (heard.settings[0] as { config: { note: string; address: Record<string, string> } }).config
expect(stored.note, 'the rest of the document is carried').toBe('kept')
expect(stored.address).toMatchObject({ line1: '1 Market St', city: 'San Francisco' })
})
test('a member reads the org and is offered none of its controls', async ({ page }) => {
await as(page, 'member', TEAM)
await page.goto(`${base}/settings/organization`)
await expect(page.locator('#org-name')).toHaveValue('Acme Inc', { timeout: 30_000 })
await expect(page.locator('#org-name')).toBeDisabled()
await expect(page.getByRole('button', { name: 'Save' })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Delete organization' })).toHaveCount(0)
await page.goto(`${base}/settings/organization/members`)
await expect(page.getByText('kim', { exact: true })).toBeVisible({ timeout: 30_000 })
await expect(page.getByRole('button', { name: 'Send invite' })).toHaveCount(0)
await expect(page.getByRole('button', { name: /^Actions for / })).toHaveCount(0)
await page.goto(`${base}/settings/organization/billing`)
await expect(page.getByRole('heading', { name: 'Billing', level: 1 })).toBeVisible({ timeout: 30_000 })
await expect(page.getByRole('link', { name: 'Change plan' })).toHaveCount(0)
})
test('an admin manages people who joined by membership', async ({ page }) => {
await as(page, 'owner', TEAM)
await page.goto(`${base}/settings/organization/members`)
await expect(page.getByRole('button', { name: 'Send invite' })).toBeVisible({ timeout: 30_000 })
await expect(page.getByRole('button', { name: 'Actions for kim' })).toBeVisible()
await expect(page.getByRole('button', { name: `Actions for ${HOME}` }), 'no menu on your own row').toHaveCount(0)
})
test('an org of one is offered a team instead of a roster', async ({ page }) => {
await as(page, 'admin', HOME, true)
await page.goto(`${base}/settings/organization`)
await expect(page.getByRole('button', { name: 'Convert to team' })).toBeVisible({ timeout: 30_000 })
await expect(page.getByRole('button', { name: 'Delete organization' })).toHaveCount(0)
const nav = page.getByRole('navigation', { name: 'Settings' })
await expect(nav.getByRole('link', { name: 'Organization', exact: true })).toBeVisible()
await expect(nav.getByRole('link', { name: 'Members' })).toHaveCount(0)
})
test('on a phone the column is a sheet and nothing runs off the side', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await as(page, 'admin', TEAM)
await page.goto(`${base}/settings/organization/members`)
await expect(page.getByRole('heading', { name: 'Members', level: 1 })).toBeVisible({ timeout: 30_000 })
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0)
await page.getByRole('button', { name: 'Settings menu' }).click()
const sheet = page.getByRole('dialog', { name: 'Settings' })
await sheet.getByRole('link', { name: 'Billing' }).click()
await expect(page).toHaveURL(/\/settings\/organization\/billing$/)
await expect(sheet).toHaveCount(0)
})
})