Files
zeekay efc7145708 sign-in: one place says which ways in are offered, and their gates read it
#93 held the wallet and the phone back with a constant inside the panel,
and the gates kept looking for both doors. lib/auth/offered is that
constant now: the panel and login.spec draw their providers from it, and
the tests that walk a held-back door skip with the reason until it is
offered again.
2026-10-30 01:39:36 -07:00

328 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page } from '@playwright/test'
import { AUP_VERSION, TERMS_VERSION } from '@hanzo/app/lib/terms'
import { serveExport } from './export'
import { OFFERED } from '../../lib/auth/offered'
import { heard, stream } from './heard'
import { API, contract, cors, issuer, jwt, type Seen } from './issuer'
/** The door this file walks is held back (#93); its tests run again when lib/auth/offered offers it. */
const BACK = 'the phone is held back (lib/auth/offered)'
/**
* A PHONE NUMBER SIGNS IN ON THIS SITE.
*
* /login and /signup offer "Continue with phone" after the providers
* (components/auth/panel, components/auth/phone). The number is typed in its
* country with the SMS consent ticked, and IAM's code flow takes it from there:
* POST /v1/iam/verification-codes and then /v1/iam/login with the code. The
* button is IAM's to turn on (`code` in its sign-in methods), and IAM is never
* asked whose number it is: its address lookup answers an email address only.
*
* IAM is answered here and does what IAM does with a number
* (hanzoai/iam internal/otp): one code per number per 30 seconds
* (`ErrTooSoon`), five wrong guesses spend a code, and a code signs in only the
* account the number is on; any other number is told the code is wrong, as IAM
* tells it. Nothing is texted anywhere.
*/
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
/** The number an account holds here, as IAM files it, and as it is typed in the US. */
const HELD = '+14155550134'
const TYPED = '(415) 555-0134'
/** The code IAM texts, and the authorization code its login mints for it. */
const CODE = '246810'
const MINTED = 'ph0ne'
/** IAM's own sentences (internal/oidc login.go, internal/otp). */
const REFUSED = 'the code is incorrect or has expired'
const TOO_SOON = 'a verification code was just sent; wait a moment before asking for another'
const TOKEN = jwt({ sub: 'hanzo/ada', name: 'ada', phone_number: HELD, orgs: [{ org: 'ada', role: 'owner' }] })
interface World extends Seen {
/** Each text IAM was asked to send, as the form it was posted as. */
texts: URLSearchParams[]
/** Each code sign-in IAM was posted, and its authorize query. */
logins: { body: Record<string, unknown>; url: URL }[]
tokens: URLSearchParams[]
}
/**
* The world: everything off this machine fails but IAM (./issuer), whose methods
* say it sends codes (`codes`), which signs in the numbers in `held`, spends a code at five wrong guesses and, `soon`, refuses
* every text after the first as too soon (another tab texted the number). The
* page's own 30 seconds are the page clock's.
*/
async function world(page: Page, { held = [HELD], soon = false, codes = true }: { held?: string[]; soon?: boolean; codes?: boolean } = {}): Promise<World> {
await page.route('**/*', (route) => (new URL(route.request().url()).origin === base ? route.continue() : route.abort()))
await page.route(`${base}/v1/billing/tier`, (route) =>
route.fulfill({ json: { user: 'ada', plan: 'dev', tier: { name: 'dev', displayName: 'Dev' }, balance: { currency: 'usd' } } }),
)
const issued = await issuer(page, { site: base, token: TOKEN })
if (!codes)
await page.route(
(u) => u.origin === API && u.pathname === '/v1/iam/auth/methods',
(route) =>
route.fulfill({
status: 200,
headers: cors(route),
contentType: 'application/json',
body: JSON.stringify({ status: 'ok', data: { password: true, code: false, signup: true, oauth: [{ name: 'provider-google', type: 'Google' }] } }),
}),
)
const seen: World = Object.assign(issued, { texts: [], logins: [], tokens: [] })
await page.route(
(u) => u.origin === base && ['/v1/iam/preferences', '/v1/iam/account'].includes(u.pathname),
(route) =>
route.fulfill({
json:
new URL(route.request().url()).pathname === '/v1/iam/account'
? { status: 'ok', data: { name: 'ada', phone: HELD, createdTime: '2024-01-01T00:00:00Z' } }
: { status: 'ok', data: { terms: { terms: TERMS_VERSION, aup: AUP_VERSION } } },
}),
)
// IAM's code record for the number: whether one was sent, and the wrong guesses against it.
let sent = false
let wrong = 0
await page.route(
(u) => u.origin === base && ['/v1/iam/verification-codes', '/v1/iam/login', '/v1/iam/oauth/token', '/v1/iam/oauth/userinfo'].includes(u.pathname),
(route) => {
const req = route.request()
const url = new URL(req.url())
const answer = (body: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
if (url.pathname === '/v1/iam/verification-codes') {
const form = new URLSearchParams(req.postData() ?? '')
seen.texts.push(form)
if (sent && soon) return answer({ status: 'error', msg: TOO_SOON })
sent = true
wrong = 0
return answer({ status: 'ok', msg: '', data: null })
}
if (url.pathname === '/v1/iam/login') {
const body = JSON.parse(req.postData() ?? '{}') as Record<string, unknown>
seen.logins.push({ body, url })
if (wrong >= 5 || body.code !== CODE || !held.includes(String(body.username))) {
wrong++
return answer({ status: 'error', msg: REFUSED })
}
return answer({ status: 'ok', msg: '', data: MINTED })
}
if (url.pathname === '/v1/iam/oauth/userinfo') return route.fulfill({ json: { sub: 'hanzo/ada', name: 'ada' } })
seen.tokens.push(new URLSearchParams(req.postData() ?? ''))
return route.fulfill({ json: { access_token: TOKEN, id_token: TOKEN, refresh_token: 'r1', expires_in: 3600, token_type: 'Bearer' } })
},
)
return seen
}
const card = (page: Page) => page.getByRole('region', { name: /^(Log in|Sign up)$/ })
const door = (page: Page) => card(page).getByRole('button', { name: 'Continue with phone' })
const owned = (page: Page) => page.locator('html[data-hydrated]').waitFor({ state: 'attached' })
const field = (page: Page) => card(page).getByRole('textbox', { name: 'Phone number' })
const consent = (page: Page) => card(page).getByRole('checkbox')
const textMe = (page: Page) => card(page).getByRole('button', { name: 'Text me a code' })
const codeField = (page: Page) => card(page).getByRole('textbox', { name: 'Verification code' })
const go = (page: Page) => card(page).getByRole('button', { name: 'Continue', exact: true })
async function headed(page: Page, to: string): Promise<void> {
await page.addInitScript((address) => {
if (!sessionStorage.getItem('gate.headed')) {
sessionStorage.setItem('gate.headed', '1')
sessionStorage.setItem('signin.destination', address)
}
}, to)
}
/** The number typed, consented, and sent for its code. */
async function dial(page: Page, typed = TYPED): Promise<void> {
await owned(page)
await door(page).click()
await field(page).fill(typed)
await consent(page).click()
await textMe(page).click()
}
for (const route of ['/login', '/signup'] as const) {
for (const [width, scheme] of [[1440, 'light'], [390, 'light'], [390, 'dark'], [1440, 'dark']] as const) {
test(`${route} at ${width}px, ${scheme}, offers the phone after the providers, and its two steps fit`, async ({ page }, info) => {
test.skip(!OFFERED.phone, BACK)
await page.emulateMedia({ colorScheme: scheme })
const seen = await world(page)
await page.setViewportSize({ width, height: 900 })
await page.goto(`${base}${route}`)
await expect(door(page)).toBeVisible({ timeout: 20_000 })
await expect(card(page).getByRole('button', { name: /Continue with (?!email)/ })).toHaveText([/Apple$/, /Google$/, /GitHub$/, /phone$/])
await expect
.poll(() => door(page).evaluate((el) => ({ height: Math.round(el.getBoundingClientRect().height), font: getComputedStyle(el).fontSize })))
.toEqual({ height: 56, font: '16px' })
const tag = `${route.slice(1)}-${width}-${scheme}`
await page.screenshot({ path: info.outputPath(`phone-first-${tag}.png`) })
await owned(page)
await door(page).click()
await expect(field(page)).toBeVisible()
await expect(card(page).getByText(/^I agree to receive text messages \(SMS\) from Hanzo AI/)).toBeVisible()
await expect(card(page).getByRole('button', { name: /Country calling code: United States \+1/ })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'no sideways scroll').toBe(true)
await page.screenshot({ path: info.outputPath(`phone-number-${tag}.png`) })
await field(page).fill(TYPED)
await consent(page).click()
await textMe(page).click()
await expect(codeField(page)).toBeVisible({ timeout: 15_000 })
await expect(card(page).getByText('+1 415 555 0134')).toBeVisible()
await expect(card(page).getByRole('button', { name: /^Send a new code in \d+s$/ })).toBeDisabled()
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'no sideways scroll').toBe(true)
await page.screenshot({ path: info.outputPath(`phone-code-${tag}.png`) })
expect(seen.texts).toHaveLength(1)
})
}
}
test('a number an account holds is texted, and its code signs in where the sign-in was headed', async ({ page }) => {
test.skip(!OFFERED.phone, BACK)
const seen = await world(page)
const events = await stream(page)
await headed(page, '/pricing')
await page.goto(`${base}/login`)
await dial(page)
await expect(codeField(page)).toBeVisible({ timeout: 15_000 })
expect(seen.identified, 'IAM is not asked whose number it is').toEqual([])
expect(seen.texts.map((f) => Object.fromEntries(f)), 'one text, to that number, as a phone').toEqual([{ applicationId: 'admin/hanzo-app', dest: HELD, type: 'phone' }])
await codeField(page).fill(CODE)
await go(page).click()
await page.waitForURL(`${base}/pricing`, { timeout: 20_000 })
expect(seen.logins).toHaveLength(1)
expect(seen.logins[0].body).toMatchObject({ username: HELD, phone: HELD, code: CODE, signinMethod: 'Verification code', application: 'hanzo-app', organization: 'hanzo' })
expect(seen.logins[0].body.email, 'a number is not sent as an address').toBeUndefined()
expect(seen.logins[0].url.searchParams.get('code_challenge_method')).toBe('S256')
expect(seen.tokens.map((f) => f.get('code')), 'the code IAM minted is exchanged').toEqual([MINTED])
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_access_token'))).toBe(TOKEN)
contract(seen)
expect(await heard(events, 'login_completed'), 'a login, by phone').toMatchObject({ method: 'phone' })
expect(events.filter((e) => e.event === 'signup_submitted'), 'a number asks for no account').toEqual([])
})
test('nothing is texted for a partial number or without the consent', async ({ page }) => {
test.skip(!OFFERED.phone, BACK)
const seen = await world(page)
await page.goto(`${base}/signup`)
await owned(page)
await door(page).click()
await field(page).fill('415 555')
await consent(page).click()
await textMe(page).click()
await expect(card(page).getByRole('alert')).toHaveText('Enter a full phone number, with its area code.')
await expect(field(page)).toHaveAttribute('aria-invalid', 'true')
await field(page).fill(TYPED)
await consent(page).click()
await textMe(page).click()
await expect(card(page).getByRole('alert')).toHaveText('Agree to receive the text to continue.')
expect(seen.texts, 'and nothing is texted').toEqual([])
expect(seen.identified).toEqual([])
})
test('a number no account holds is texted as any is, and its code is refused in IAM’s words', async ({ page }) => {
test.skip(!OFFERED.phone, BACK)
const seen = await world(page, { held: [] })
await page.goto(`${base}/login`)
await dial(page)
await expect(codeField(page)).toBeVisible({ timeout: 15_000 })
await codeField(page).fill(CODE)
await go(page).click()
await expect(card(page).getByRole('alert')).toHaveText(REFUSED)
expect(seen.texts).toHaveLength(1)
expect(seen.tokens, 'nobody signed in, and no account was made').toEqual([])
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_access_token'))).toBeNull()
})
test('where IAM sends no codes there is no phone button', async ({ page }) => {
await world(page, { codes: false })
await page.goto(`${base}/login`)
await expect(card(page).getByRole('button', { name: /Continue with Google$/ })).toBeVisible({ timeout: 20_000 })
await owned(page)
await expect(door(page)).toHaveCount(0)
})
test('a number is read in the country picked: a UK number keeps no trunk prefix', async ({ page }) => {
test.skip(!OFFERED.phone, BACK)
const seen = await world(page, { held: ['+447911123456'] })
await page.goto(`${base}/login`)
await owned(page)
await door(page).click()
await card(page).getByRole('button', { name: /Country calling code/ }).click()
await page.getByRole('textbox', { name: 'Search countries' }).fill('United Kingdom')
await page.locator('[data-slot="search-list"]').getByText('United Kingdom', { exact: true }).click()
await expect(card(page).getByRole('button', { name: /Country calling code: United Kingdom \+44/ })).toBeVisible()
await field(page).fill('07911 123456')
await consent(page).click()
await textMe(page).click()
await expect(codeField(page)).toBeVisible({ timeout: 15_000 })
expect(seen.texts.map((f) => f.get('dest'))).toEqual(['+447911123456'])
})
test('five wrong codes spend it; another waits out IAM’s 30 seconds, and IAM’s own refusal is said', async ({ page }) => {
test.skip(!OFFERED.phone, BACK)
await page.clock.install()
const seen = await world(page, { soon: true })
await page.goto(`${base}/login`)
await dial(page)
await expect(codeField(page)).toBeVisible({ timeout: 15_000 })
const again = card(page).getByRole('button', { name: /^Send a new code/ })
await expect(again).toBeDisabled()
for (let n = 1; n <= 5; n++) {
await codeField(page).fill(`00000${n}`)
await go(page).click()
await expect(card(page).getByRole('alert')).toHaveText(n < 5 ? REFUSED : 'That code was tried five times. Send a new code.')
}
await expect(go(page), 'a spent code is not offered again').toBeDisabled()
expect(seen.logins).toHaveLength(5)
// IAM's 30 seconds pass; IAM still refuses (another tab texted the number), in its words.
await page.clock.fastForward(31_000)
await expect(again).toHaveText('Send a new code')
await expect(again).toBeEnabled()
await again.click()
await expect(card(page).getByRole('alert')).toHaveText(TOO_SOON)
expect(seen.texts).toHaveLength(2)
expect(seen.tokens, 'nobody signed in').toEqual([])
})
test('a new code after IAM’s 30 seconds clears the five and signs in', async ({ page }) => {
test.skip(!OFFERED.phone, BACK)
await page.clock.install()
const seen = await world(page)
await page.goto(`${base}/login`)
await dial(page)
await expect(codeField(page)).toBeVisible({ timeout: 15_000 })
for (let n = 1; n <= 5; n++) {
await codeField(page).fill(`11111${n}`)
await go(page).click()
await expect(card(page).getByRole('alert')).toBeVisible()
}
await expect(go(page)).toBeDisabled()
await page.clock.fastForward(31_000)
await card(page).getByRole('button', { name: 'Send a new code' }).click()
await expect(card(page).getByText('A new code is on its way.')).toBeVisible()
await expect(go(page)).toBeEnabled()
expect(seen.texts.map((f) => f.get('dest'))).toEqual([HELD, HELD])
await codeField(page).fill(CODE)
await go(page).click()
await expect.poll(() => seen.tokens.length, { timeout: 20_000 }).toBe(1)
})