Files
hanzo.ai/e2e/gates/address.spec.ts
zeekay ac2da21e5f sign-in: wallet and phone, and a person claims a wallet's account
/login and /signup draw "Continue with wallet" and "Continue with phone"
after Google and GitHub, each only where IAM's methods say it is on.

Wallet: where IAM signs a wallet in (@hanzo/iam 0.21.37 `wallet`) and the
browser injects an EIP-1193 wallet; the press is loginWithWallet (IAM's
challenge, personal_sign of its EIP-4361 text, IAM's verify) and ends as a
password's sign-in ends. A wallet IAM has not seen is made an account in an
org of its own, with no email.

Phone: where IAM says it sends codes (`code`) and has answered. The number
is read in its country with the SMS consent ticked, then IAM's code flow.
IAM's address lookup answers an email address only, so a number is texted as
any is and the account that holds it is signed in by the code.

Settings -> General -> Sign in another way adds an email or a phone to the
signed-in account through IAM's own proof (addAddress, PUT /v1/iam/address),
drawn only where IAM's methods say `address`. Same account, org, keys and
balance; nothing is created, so nothing earns twice.

Gates: wallet.spec (a throwaway secp256k1 key behind an injected provider),
phone.spec, address.spec. login.spec expects the phone after the providers.
2026-10-26 16:47:32 -07:00

188 lines
10 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 { API, cors, issuer, jwt } from './issuer'
/**
* A PERSON CLAIMS A WALLET'S ACCOUNT BY GIVING IT AN ADDRESS.
*
* An account an agent made with a wallet has no email and no phone. Settings →
* General offers "Sign in another way": an email address or a phone number, each
* proved by a code sent to it (@hanzo/iam `sendLoginCode`, then `addAddress`,
* PUT /v1/iam/address with the person's own bearer). The form is IAM's to turn
* on (`address` in its sign-in methods), and what it adds lands on the SAME
* account: the page signs nobody in, creates nothing, and asks IAM for no second
* account.
*
* IAM is answered here and does what IAM does (hanzoai/iam internal/oidc
* address.go): the caller is whoever holds the bearer, a wrong code is refused in
* the words a wrong code gets, an address is added and never replaced.
*/
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
const ID = 'ab12cd34ef56ab78'
const WALLET = '0x00000000000000000000000000000000000000a1'
const CODE = '135790'
const REFUSED = 'the code is incorrect or has expired'
/** The token IAM minted a wallet's account: its own org, a wallet, no address. */
const TOKEN = jwt({
sub: `wallet-evm-${ID}/wallet_evm_${ID}`,
name: `wallet_evm_${ID}`,
orgs: [{ org: `wallet-evm-${ID}`, role: 'owner' }],
wallets: [{ chain: 'evm', address: WALLET }],
})
interface World {
/** Each code IAM was asked to send, as the form it was posted as. */
texts: URLSearchParams[]
/** Each address IAM was asked to add, with the bearer it came with. */
added: { auth: string; body: Record<string, string> }[]
/** The addresses the account holds, as IAM keeps them. */
row: { email: string; phone: string }
}
/**
* The world: everything off this machine fails but IAM (./issuer), whose methods
* say whether an address can be added (`address`), whose account row is the
* wallet's with the addresses it has been given, and whose send and add are
* answered as IAM answers them.
*/
async function world(page: Page, { address = true, email = '', phone = '' }: { address?: boolean; email?: string; phone?: string } = {}): 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: 'wallet', plan: 'dev', tier: { name: 'dev', displayName: 'Dev' }, balance: { currency: 'usd' } } }),
)
await page.addInitScript((token) => {
localStorage.setItem('hanzo_iam_access_token', token)
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000))
}, TOKEN)
await issuer(page, { site: base, token: TOKEN })
const seen: World = { texts: [], added: [], row: { email, phone } }
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: true, signup: true, web3: true, web3Chains: ['evm'], address, oauth: [{ name: 'provider-google', type: 'Google' }] } }),
}),
)
await page.route(
(u) => u.origin === base && ['/v1/iam/preferences', '/v1/iam/account', '/v1/iam/verification-codes', '/v1/iam/address'].includes(u.pathname),
(route) => {
const req = route.request()
const path = new URL(req.url()).pathname
const answer = (body: unknown, status = 200) => route.fulfill({ status, headers: cors(route), contentType: 'application/json', body: JSON.stringify(body) })
if (req.method() === 'OPTIONS') return route.fulfill({ status: 204, headers: cors(route) })
if (path === '/v1/iam/preferences') return answer({ status: 'ok', data: { terms: { terms: TERMS_VERSION, aup: AUP_VERSION } } })
if (path === '/v1/iam/account') return answer({ status: 'ok', data: { name: `wallet_evm_${ID}`, createdTime: '2024-01-01T00:00:00Z', ...seen.row } })
if (path === '/v1/iam/verification-codes') {
seen.texts.push(new URLSearchParams(req.postData() ?? ''))
return answer({ status: 'ok', msg: '', data: null })
}
const body = JSON.parse(req.postData() ?? '{}') as Record<string, string>
seen.added.push({ auth: req.headers().authorization ?? '', body })
if (body.code !== CODE) return answer({ status: 'error', msg: REFUSED }, 400)
if (body.address.includes('@')) seen.row.email = body.address
else seen.row.phone = body.address.replace(/\D/g, '')
return answer({ status: 'ok', data: { owner: `wallet-evm-${ID}`, name: `wallet_evm_${ID}`, ...seen.row } })
},
)
return seen
}
const section = (page: Page) => page.getByRole('heading', { name: 'Sign in another way' })
const emailField = (page: Page) => page.getByRole('textbox', { name: 'Email', exact: true }).last()
const codeField = (page: Page) => page.getByRole('textbox', { name: 'Verification code' })
for (const width of [1440, 390]) {
for (const scheme of ['light', 'dark'] as const) {
test(`Settings at ${width}px, ${scheme}: a wallet's account adds an email, proved by a code, to the same account`, async ({ page }, info) => {
await page.emulateMedia({ colorScheme: scheme })
const seen = await world(page)
await page.setViewportSize({ width, height: 900 })
await page.goto(`${base}/settings`)
await expect(section(page)).toBeVisible({ timeout: 20_000 })
await expect(page.getByLabel('Sign-in wallet (Ethereum)')).toHaveValue(WALLET)
await expect(page.getByRole('button', { name: 'Send code' })).toBeVisible()
await expect(page.getByText('Phone number', { exact: true })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'no sideways scroll').toBe(true)
await section(page).evaluate((el) => el.scrollIntoView({ block: 'start' }))
await page.screenshot({ path: info.outputPath(`address-${width}-${scheme}.png`) })
await page.getByRole('textbox', { name: 'Email', exact: true }).last().fill('[email protected]')
await page.getByRole('button', { name: 'Send code' }).click()
await expect(codeField(page)).toBeVisible()
expect(seen.texts.map((f) => Object.fromEntries(f)), 'one code, to that address, as an email').toEqual([{ applicationId: 'admin/hanzo-app', dest: '[email protected]', type: 'email' }])
await codeField(page).fill(CODE)
await page.getByRole('button', { name: 'Add email' }).click()
await expect(page.getByText('Email added. You can sign in with it.')).toBeVisible()
expect(seen.added, 'one add, as the signed-in person').toEqual([{ auth: `Bearer ${TOKEN}`, body: { clientId: 'hanzo-app', address: '[email protected]', code: CODE } }])
// The same account, now with the address: the profile reads it, and the form for it is gone.
await expect(page.getByLabel('Email', { exact: true })).toHaveValue('[email protected]')
await expect(page.getByRole('button', { name: 'Send code' })).toHaveCount(0)
await expect(page.getByLabel('Sign-in wallet (Ethereum)'), 'the wallet still signs it in').toHaveValue(WALLET)
})
}
}
test('a phone number is added as the sign-in takes one: the country, the consent, a text, and the code', async ({ page }, info) => {
const seen = await world(page, { email: '[email protected]' })
await page.goto(`${base}/settings`)
await expect(section(page)).toBeVisible({ timeout: 20_000 })
await expect(page.getByRole('button', { name: 'Send code' }), 'the account has an email: only the phone is asked for').toHaveCount(0)
const card = page.locator('form', { has: page.getByRole('textbox', { name: 'Phone number' }) })
await card.getByRole('textbox', { name: 'Phone number' }).fill('(415) 555-0134')
await card.getByRole('button', { name: 'Text me a code' }).click()
await expect(card.getByRole('alert')).toHaveText('Agree to receive the text to continue.')
expect(seen.texts, 'nothing is texted without the consent').toEqual([])
await card.getByRole('checkbox').click()
await card.getByRole('button', { name: 'Text me a code' }).click()
await expect(codeField(page)).toBeVisible()
expect(seen.texts.map((f) => Object.fromEntries(f))).toEqual([{ applicationId: 'admin/hanzo-app', dest: '+14155550134', type: 'phone' }])
await page.screenshot({ path: info.outputPath('address-phone-code.png') })
await codeField(page).fill(CODE)
await page.getByRole('button', { name: 'Add phone' }).click()
await expect(page.getByText('Phone added. You can sign in with it.')).toBeVisible()
expect(seen.added).toEqual([{ auth: `Bearer ${TOKEN}`, body: { clientId: 'hanzo-app', address: '+14155550134', code: CODE } }])
await expect(section(page), 'both addresses held: nothing left to add').toHaveCount(0)
})
test('a wrong code is IAM’s sentence, the address is not added, and another address can be tried', async ({ page }) => {
const seen = await world(page)
await page.goto(`${base}/settings`)
await expect(section(page)).toBeVisible({ timeout: 20_000 })
await emailField(page).fill('[email protected]')
await page.getByRole('button', { name: 'Send code' }).click()
await codeField(page).fill('000000')
await page.getByRole('button', { name: 'Add email' }).click()
await expect(page.getByRole('alert').filter({ hasText: REFUSED })).toHaveText(REFUSED)
expect(seen.row, 'nothing was written').toEqual({ email: '', phone: '' })
await page.getByRole('button', { name: 'Use a different email' }).click()
await expect(page.getByRole('button', { name: 'Send code' })).toBeVisible()
})
test('where IAM does not take an address, Settings does not draw the form', async ({ page }) => {
await world(page, { address: false })
await page.goto(`${base}/settings`)
await expect(page.getByLabel('Sign-in wallet (Ethereum)')).toHaveValue(WALLET, { timeout: 20_000 })
await page.locator('html[data-hydrated]').waitFor({ state: 'attached' })
await expect(section(page)).toHaveCount(0)
})