mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
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.
181 lines
8.2 KiB
TypeScript
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)
|
|
})
|
|
})
|