mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
/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.
188 lines
10 KiB
TypeScript
188 lines
10 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 { 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)
|
||
})
|