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