Files
hanzo.ai/e2e/gates/wallet.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

473 lines
23 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 { createHash } from 'node:crypto'
import { writeFileSync } from 'node:fs'
import { expect, test, type Page } from '@playwright/test'
import { secp256k1 } from '@noble/curves/secp256k1.js'
import { keccak_256 } from '@noble/hashes/sha3.js'
import { AUP_VERSION, TERMS_VERSION } from '@hanzo/app/lib/terms'
import { serveExport } from './export'
import { heard, stream } from './heard'
import { API, contract, cors, issuer, jwt, type Seen } from './issuer'
/**
* A WALLET SIGNS IN ON THIS SITE.
*
* /login and /signup offer "Continue with wallet", after the providers, where
* IAM says it signs a wallet in (`web3`, with evm among `web3Chains`) and the
* browser injects an EIP-1193 wallet (components/auth/panel, lib/auth/wallet).
* Pressed, the wallet is asked for its account and network, IAM at api.hanzo.ai
* for a challenge, the wallet signs the EIP-4361 text of it, and IAM's verify
* answers with a PKCE-bound code the callback exchanges as it does a password's
* (@hanzo/iam `loginWithWallet`).
*
* The wallet is a real one: an EIP-1193 provider injected before the page loads,
* whose `personal_sign` is a throwaway secp256k1 key held in this test (`Purse`).
* IAM is answered here (./issuer), and its verify does with the proof what IAM's
* does (hanzoai/iam internal/wallet/verify.go): the nonce in the signed text must
* be one it minted and not yet spent, the text must be that challenge rendered
* for the address, and the signature must recover to the address. Nothing leaves
* this machine, and no account is made 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 code IAM's verify mints here. */
const CODE = 'w4ll3t'
/** What IAM states it verifies a wallet signature for, as /v1/iam/auth/methods says it live. */
const CHAINS = ['evm', 'solana', 'bitcoin', 'ton', 'xrp', 'polkadot', 'cardano']
/** The challenge IAM mints, as IAM names its fields (GET /v1/iam/web3/nonce). */
type Challenge = { domain: string; uri: string; statement: string; nonce: string; issuedAt: string; expirationTime: string; version: string }
const hex = (b: Uint8Array) => Buffer.from(b).toString('hex')
const unhex = (s: string) => Uint8Array.from(Buffer.from(s.replace(/^0x/, ''), 'hex'))
/** An address as EIP-55 writes it. */
function checksum(address: string): string {
const low = address.toLowerCase().replace(/^0x/, '')
const hash = hex(keccak_256(new TextEncoder().encode(low)))
return `0x${[...low].map((c, i) => (parseInt(hash[i], 16) >= 8 ? c.toUpperCase() : c)).join('')}`
}
/** The address of a public key: the last 20 bytes of the keccak of its uncompressed point. */
const addressOf = (pub: Uint8Array) => checksum(hex(keccak_256(secp256k1.Point.fromBytes(pub).toBytes(false).slice(1)).slice(-20)))
/** What `personal_sign` signs: EIP-191's prefix, the length, the bytes. */
function digest(bytes: Uint8Array): Uint8Array {
const prefix = new TextEncoder().encode(`\x19Ethereum Signed Message:\n${bytes.length}`)
return keccak_256(Uint8Array.from([...prefix, ...bytes]))
}
/** Who signed `text`, from a 65-byte r‖s‖v signature. */
function signer(text: string, signature: string): string {
const b = unhex(signature)
if (b.length !== 65) throw new Error('not a 65-byte signature')
const recovered = Uint8Array.from([b[64] - 27, ...b.slice(0, 64)])
return addressOf(secp256k1.recoverPublicKey(recovered, digest(new TextEncoder().encode(text)), { prehash: false }))
}
/**
* The sign-in text for `address`, from a challenge: the contract IAM parses the
* signed text by (luxwallet/connect BuildSiwxMessage), line for line.
*/
function render(c: Challenge, address: string, chain: number): string {
return [
`${c.domain} wants you to sign in with your Ethereum account:`,
address,
'',
c.statement,
'',
`URI: ${c.uri}`,
`Version: ${c.version}`,
`Chain ID: ${chain}`,
`Nonce: ${c.nonce}`,
`Issued At: ${c.issuedAt}`,
`Expiration Time: ${c.expirationTime}`,
].join('\n')
}
/** A throwaway Ethereum wallet: its key lives in this test and nowhere else. */
class Purse {
readonly key = secp256k1.utils.randomSecretKey()
readonly address = addressOf(secp256k1.getPublicKey(this.key))
/** Each EIP-1193 method the page asked, in order. */
readonly asked: string[] = []
/** Signs with this other key while claiming its own address: a forged proof. */
forge: Uint8Array | null = null
answer(method: string, params: unknown[]): unknown {
this.asked.push(method)
if (method === 'eth_requestAccounts' || method === 'eth_accounts') return [this.address.toLowerCase()]
if (method === 'eth_chainId') return '0x1'
if (method === 'personal_sign') {
const [data, from] = params as [string, string]
if (from.toLowerCase() !== this.address.toLowerCase()) throw new Error(`asked to sign as ${from}`)
const sig = secp256k1.sign(digest(unhex(data)), this.forge ?? this.key, { prehash: false, format: 'recovered' })
return `0x${hex(sig.slice(1))}${(27 + sig[0]).toString(16)}`
}
throw new Error(`unsupported method ${method}`)
}
}
/** The wallet, injected as an EIP-1193 provider before the page loads. `decline` says no at the signature, as a person does (4001). */
async function inject(page: Page, purse: Purse, { decline = false } = {}): Promise<void> {
await page.exposeBinding('__purse', (_source, method: string, params: unknown[]) => purse.answer(method, params))
await page.addInitScript((no) => {
const w = window as unknown as { __purse: (m: string, p: unknown[]) => Promise<unknown>; ethereum: unknown }
w.ethereum = {
request: async ({ method, params }: { method: string; params?: unknown[] }) => {
if (no && method === 'personal_sign') throw Object.assign(new Error('User rejected the request.'), { code: 4001 })
return w.__purse(method, params ?? [])
},
}
}, decline)
}
/** The access token IAM mints each wallet's account, minted once. */
const issued = new WeakMap<Purse, string>()
/** The account a first-seen wallet is made, as IAM makes it: its own org, a wallet, no email. */
function account(purse: Purse): string {
const held = issued.get(purse)
if (held) return held
const id = createHash('sha256').update(`evm:${purse.address.toLowerCase()}`).digest('hex').slice(0, 16)
const token = jwt({
sub: `wallet-evm-${id}/wallet_evm_${id}`,
name: `wallet_evm_${id}`,
displayName: `evm:${purse.address.slice(0, 6)}...${purse.address.slice(-4)}`,
orgs: [{ org: `wallet-evm-${id}`, role: 'owner' }],
wallets: [{ chain: 'evm', address: purse.address.toLowerCase() }],
})
issued.set(purse, token)
return token
}
interface World extends Seen {
/** Each challenge IAM minted, and the query it was asked with. */
minted: { challenge: Challenge; query: URLSearchParams }[]
/** Each proof IAM's verify was sent, and what it answered. */
verified: { body: Record<string, unknown>; refused: string | null }[]
/** Each token request, as the form it was posted as. */
tokens: URLSearchParams[]
}
/**
* The world, answered: everything off this machine fails but IAM (./issuer), whose
* methods say a wallet signs in (`web3`), whose nonce and verify are a wallet
* sign-in's, and whose account row is a wallet's — no email; made long ago with
* the terms on record, or `fresh` and owing them.
*/
async function world(page: Page, purse: Purse, { web3 = true, fresh = false, verify }: { web3?: boolean; fresh?: boolean; verify?: Record<string, unknown> } = {}): 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' } } }),
)
const seen: World = Object.assign(await issuer(page, { site: base, token: account(purse) }), { minted: [], verified: [], 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: 'wallet', createdTime: fresh ? new Date().toISOString() : '2024-01-01T00:00:00Z' } }
: { status: 'ok', data: fresh ? {} : { terms: { terms: TERMS_VERSION, aup: AUP_VERSION } } },
}),
)
const spent = new Set<string>()
const refuse = (body: Record<string, unknown>): string | null => {
const text = String(body.message ?? '')
const nonce = /^Nonce: (.+)$/m.exec(text)?.[1] ?? ''
const minted = seen.minted.find((m) => m.challenge.nonce === nonce)
if (!minted || spent.has(nonce)) return 'web3: the nonce is unknown or spent'
spent.add(nonce)
if (body.chain !== 'evm' || body.scheme !== 'secp256k1-eip191') return 'web3: unsupported chain'
if (text !== render(minted.challenge, String(body.address), 1)) return 'web3: malformed login message'
if (signer(text, String(body.signature)) !== checksum(String(body.address))) return 'web3: verification failed: bad-signature'
return null
}
await page.route(
(u) => u.origin === API && ['/v1/iam/auth/methods', '/v1/iam/web3/nonce', '/v1/iam/web3/verify', '/v1/iam/oauth/token'].includes(u.pathname),
async (route) => {
const req = route.request()
const url = new URL(req.url())
if (req.method() === 'OPTIONS') return route.fulfill({ status: 204, headers: cors(route) })
const json = (body: unknown) => route.fulfill({ status: 200, headers: cors(route), contentType: 'application/json', body: JSON.stringify(body) })
if (url.pathname === '/v1/iam/auth/methods')
return json({
status: 'ok',
data: {
password: true,
code: true,
signup: true,
webauthn: true,
web3,
web3Chains: web3 ? CHAINS : [],
oauth: [
{ name: 'provider-github', type: 'GitHub' },
{ name: 'provider-google', type: 'Google' },
],
},
})
if (url.pathname === '/v1/iam/web3/nonce') {
const now = new Date()
const challenge: Challenge = {
domain: 'api.hanzo.ai',
uri: 'https://api.hanzo.ai/login',
statement: 'Sign in to Hanzo. This request will not trigger a blockchain transaction or cost any gas.',
nonce: hex(secp256k1.utils.randomSecretKey()).slice(0, 32),
issuedAt: now.toISOString().replace(/\.\d+Z$/, 'Z'),
expirationTime: new Date(now.getTime() + 600_000).toISOString().replace(/\.\d+Z$/, 'Z'),
version: '1',
}
seen.minted.push({ challenge, query: url.searchParams })
return json({ status: 'ok', msg: '', data: challenge })
}
if (url.pathname === '/v1/iam/web3/verify') {
const body = JSON.parse(req.postData() ?? '{}') as Record<string, unknown>
const refused = refuse(body)
seen.verified.push({ body, refused })
return json(refused ? { status: 'error', msg: refused } : (verify ?? { status: 'ok', msg: '', data: CODE }))
}
seen.tokens.push(new URLSearchParams(req.postData() ?? ''))
return route.fallback()
},
)
// Served from a loopback address the token and userinfo calls go to this origin
// (app/providers `proxyBaseUrl`), as the dev server's /v1 proxy takes them;
// answered as api.hanzo.ai answers them, and the exchange recorded either way.
const token = account(purse)
await page.route(
(u) => u.origin === base && ['/v1/iam/oauth/token', '/v1/iam/oauth/userinfo'].includes(u.pathname),
(route) => {
const req = route.request()
if (new URL(req.url()).pathname === '/v1/iam/oauth/userinfo') {
const c = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()) as Record<string, unknown>
return route.fulfill({ json: { sub: c.sub, name: c.name, preferred_username: c.name } })
}
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
}
/** The sign-in card, by the name its section carries. */
const card = (page: Page) => page.getByRole('region', { name: /^(Log in|Sign up)$/ })
/** The wallet's door. */
const door = (page: Page) => card(page).getByRole('button', { name: 'Continue with wallet' })
/** React owns the page (app/providers `data-hydrated`). */
const owned = (page: Page) => page.locator('html[data-hydrated]').waitFor({ state: 'attached' })
/** The next page a completed sign-in goes to, written down as an entry into the app does. */
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)
}
/** Base64url SHA-256: the S256 challenge of a PKCE verifier. */
const s256 = (verifier: string) => createHash('sha256').update(verifier).digest('base64url')
for (const route of ['/login', '/signup'] as const) {
for (const width of [1440, 390]) {
test(`${route} at ${width}px offers the wallet after Google and GitHub, drawn as they are`, async ({ page }, info) => {
const purse = new Purse()
await inject(page, purse)
const seen = await world(page, purse)
await page.setViewportSize({ width, height: 900 })
await page.goto(`${base}${route}`)
await expect(door(page)).toBeVisible({ timeout: 20_000 })
// One order: Google, GitHub, the wallet, the phone, and the email under them.
await expect(card(page).getByRole('button', { name: /Continue with (?!email)/ })).toHaveText([/Google$/, /GitHub$/, /wallet$/, /phone$/])
for (const control of [door(page), card(page).getByRole('button', { name: /Continue with GitHub$/ })])
await expect
.poll(() => control.evaluate((el) => ({ height: Math.round(el.getBoundingClientRect().height), font: getComputedStyle(el).fontSize })))
.toEqual({ height: 56, font: '16px' })
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'no sideways scroll').toBe(true)
expect(purse.asked, 'the wallet is not asked anything before the press').toEqual([])
expect(seen.minted, 'and IAM mints no challenge').toEqual([])
await page.screenshot({ path: info.outputPath(`wallet${route.replace('/', '-')}-${width}.png`), fullPage: false })
})
}
}
test('a wallet signs in on /login: one challenge, one signature, the code exchanged with its verifier, and on to where the sign-in was headed', async ({ page }, info) => {
const purse = new Purse()
await inject(page, purse)
const seen = await world(page, purse)
const events = await stream(page)
await headed(page, '/pricing')
await page.goto(`${base}/login`)
await owned(page)
await door(page).click()
await page.waitForURL(`${base}/pricing`, { timeout: 20_000 })
expect(purse.asked, 'the account, its network, then one signature').toEqual(['eth_requestAccounts', 'eth_chainId', 'personal_sign'])
expect(seen.minted, 'one challenge').toHaveLength(1)
const q = seen.minted[0].query
expect(q.get('chain'), 'for the evm family').toBe('evm')
expect(q.get('clientId'), 'for the client the code is for').toBe('hanzo-app')
expect(q.get('address'), 'naming the address, as EIP-55 spells it').toBe(purse.address)
expect(seen.verified, 'one proof, and IAM took it').toHaveLength(1)
const { body, refused } = seen.verified[0]
expect(refused).toBeNull()
expect(body).toMatchObject({ chain: 'evm', scheme: 'secp256k1-eip191', address: purse.address, type: 'code', clientId: 'hanzo-app', redirectUri: `${base}/auth/callback`, codeChallengeMethod: 'S256' })
expect(body.state, 'state rides the proof').toBeTruthy()
expect(signer(String(body.message), String(body.signature)), 'the signature is the wallet’s').toBe(purse.address)
// The code is redeemed with the verifier whose S256 challenge the proof carried.
expect(seen.tokens, 'one exchange').toHaveLength(1)
const form = seen.tokens[0]
expect(form.get('grant_type')).toBe('authorization_code')
expect(form.get('code')).toBe(CODE)
expect(form.get('redirect_uri')).toBe(`${base}/auth/callback`)
expect(s256(form.get('code_verifier') ?? ''), 'PKCE: the verifier answers the challenge verify was sent').toBe(body.codeChallenge)
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_access_token'))).toBe(account(purse))
contract(seen)
expect(seen.authorize, 'no authorize page: the wallet signs in where it is').toEqual([])
expect(seen.posted.map((p) => p.path), 'no credential call on this origin: both wallet legs go to the issuer').toEqual([])
expect(await heard(events, 'login_completed'), 'a login, by wallet').toMatchObject({ method: 'wallet' })
expect(events.filter((e) => e.event === 'signup_submitted'), 'a login asks for no account').toEqual([])
// The proof as IAM received it, for a check against IAM's own verifier.
writeFileSync(info.outputPath('proof.json'), JSON.stringify({ challenge: seen.minted[0].challenge, chain: body.chain, scheme: body.scheme, address: body.address, message: body.message, signature: body.signature }, null, 2))
})
test('/signup: a new wallet is the account asked for, with no email; the terms are asked once, and another account is the way out', async ({ page }, info) => {
const purse = new Purse()
await inject(page, purse)
const seen = await world(page, purse, { fresh: true })
const events = await stream(page)
await page.setViewportSize({ width: 390, height: 900 })
await page.goto(`${base}/signup`)
await owned(page)
await door(page).click()
expect(await heard(events, 'signup_submitted'), 'the account asked for, by wallet').toMatchObject({ method: 'wallet' })
await expect(page.getByRole('button', { name: 'Create account' })).toBeVisible({ timeout: 20_000 })
await expect(page.getByRole('button', { name: 'Use a different account' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Use a different email' }), 'there is no email to swap').toHaveCount(0)
expect(seen.verified.map((v) => v.refused)).toEqual([null])
contract(seen)
await page.screenshot({ path: info.outputPath('wallet-terms-390.png') })
})
for (const width of [1440, 390]) {
test(`Settings at ${width}px shows the wallet that signs the account in, and says it has no email`, async ({ page }, info) => {
const purse = new Purse()
await page.addInitScript((token) => localStorage.setItem('hanzo_iam_access_token', token), account(purse))
await world(page, purse)
await page.setViewportSize({ width, height: 900 })
await page.goto(`${base}/settings`)
const wallet = page.getByLabel('Sign-in wallet (Ethereum)')
await expect(wallet).toHaveValue(purse.address.toLowerCase(), { timeout: 20_000 })
const email = page.getByLabel('Email', { exact: true })
await expect(email).toHaveValue('')
await expect(email).toHaveAttribute('placeholder', 'None. This account signs in with its wallet.')
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'no sideways scroll').toBe(true)
await wallet.scrollIntoViewIfNeeded()
await page.screenshot({ path: info.outputPath(`wallet-settings-${width}.png`) })
})
}
test('no wallet in the browser, no wallet button: one that could not finish is worse than none', async ({ page }) => {
const purse = new Purse()
await world(page, purse)
await page.goto(`${base}/login`)
await expect(card(page).getByRole('button', { name: /Continue with GitHub$/ })).toBeVisible({ timeout: 20_000 })
await owned(page)
await expect(card(page).getByRole('button', { name: /Continue with (?!email)/ })).toHaveText([/Google$/, /GitHub$/, /phone$/])
await expect(door(page)).toHaveCount(0)
})
test('where IAM signs no wallet in, an injected wallet is not offered', async ({ page }) => {
const purse = new Purse()
await inject(page, purse)
await world(page, purse, { web3: false })
await page.goto(`${base}/login`)
await owned(page)
await expect(card(page).getByRole('button', { name: /Continue with (?!email)/ })).toHaveText([/Google$/, /GitHub$/, /phone$/])
await expect(door(page)).toHaveCount(0)
expect(purse.asked).toEqual([])
})
test('declined in the wallet: said in the page’s words, nothing sent to verify, and the door open again', async ({ page }) => {
const purse = new Purse()
await inject(page, purse, { decline: true })
const seen = await world(page, purse)
await page.goto(`${base}/login`)
await owned(page)
await door(page).click()
await expect(card(page).getByRole('alert')).toHaveText('Your wallet declined the sign-in.')
await expect(card(page).getByText('Confirm the sign-in in your wallet.')).toHaveCount(0)
expect(seen.verified, 'no proof, no verify').toEqual([])
await expect(door(page)).toBeEnabled()
expect(new URL(page.url()).pathname).toBe('/login')
})
test('a forged signature is refused, in IAM’s words, and nobody is signed in', async ({ page }) => {
const purse = new Purse()
purse.forge = secp256k1.utils.randomSecretKey()
await inject(page, purse)
const seen = await world(page, purse)
await page.goto(`${base}/login`)
await owned(page)
await door(page).click()
await expect(card(page).getByRole('alert')).toHaveText('web3: verification failed: bad-signature')
expect(seen.verified.map((v) => v.refused)).toEqual(['web3: verification failed: bad-signature'])
expect(seen.tokens, 'no code, no exchange').toEqual([])
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_access_token'))).toBeNull()
})
test('a second factor the account owes is asked here, for the wallet', async ({ page }) => {
const purse = new Purse()
await inject(page, purse)
await world(page, purse, { verify: { status: 'ok', msg: '', data: 'NextMfa', data2: [{ mfaType: 'app', isPreferred: true, mfaRememberInHours: 0 }] } })
await page.goto(`${base}/login`)
await owned(page)
await door(page).click()
await expect(card(page).getByText('Two-step verification')).toBeVisible({ timeout: 20_000 })
await expect(card(page).getByText('Your wallet', { exact: true })).toBeVisible()
})
/** Another app's authorize request, as the desktop app sends it. */
const DESKTOP = new URLSearchParams({
client_id: 'hanzo-app',
redirect_uri: 'hanzo://oauth/hanzo',
response_type: 'code',
scope: 'openid profile email',
state: 'st-desktop',
code_challenge: 'ch-desktop',
code_challenge_method: 'S256',
})
test('an app’s sign-in by wallet mints the app’s code, the app is handed it, and this site signs nobody in', async ({ page }) => {
const purse = new Purse()
await inject(page, purse)
const seen = await world(page, purse)
await page.goto(`${base}/login?${DESKTOP}`)
await owned(page)
await door(page).click()
await expect(page.getByText('Signed in. Return to the app; you can close this tab.')).toBeVisible({ timeout: 20_000 })
expect(seen.verified).toHaveLength(1)
expect(seen.verified[0].body).toMatchObject({ clientId: 'hanzo-app', redirectUri: 'hanzo://oauth/hanzo', state: 'st-desktop', codeChallenge: 'ch-desktop', codeChallengeMethod: 'S256' })
expect(seen.tokens, 'the app redeems its own code').toEqual([])
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_access_token'))).toBeNull()
})