src/features/account/utils.test.ts
Rama main · réplica de lectura del repositorio original.
import { HttpResponse, http } from 'msw'
import { setupServer } from 'msw/node'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import {
asRecord,
base64UrlToBuffer,
bufferToBase64Url,
enrollPasskey,
enrollWallet,
formatDate,
formatSessionDevice,
readChainId,
readFirstString,
readRedirectUrl,
readRequiredString,
readString,
readStringArray,
readTotpEnrollment,
} from './utils'
const base = 'http://localhost:3000'
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
server.resetHandlers()
vi.restoreAllMocks()
})
afterAll(() => server.close())
describe('pure readers', () => {
it('round-trips base64url buffers', () => {
expect(bufferToBase64Url(new Uint8Array([251, 255]).buffer)).toBe('-_8')
expect(Array.from(new Uint8Array(base64UrlToBuffer('-_8')))).toEqual([251, 255])
})
it('normalizes records, strings, and arrays', () => {
expect(asRecord({ id: 'value' })).toEqual({ id: 'value' })
expect(asRecord(null)).toEqual({})
expect(asRecord('string')).toEqual({})
expect(readString('value')).toBe('value')
expect(readString('')).toBeNull()
expect(readString(5)).toBeNull()
expect(readFirstString(['first'])).toBe('first')
expect(readFirstString([1])).toBeNull()
expect(readFirstString('not-array')).toBeNull()
expect(readStringArray(['a', '', 'b', 2])).toEqual(['a', 'b'])
expect(readStringArray('nope')).toEqual([])
})
it('parses chain ids and required strings', () => {
expect(readChainId(1)).toBe(1)
expect(readChainId('0x2105')).toBe(8453)
expect(readChainId('10')).toBe(10)
expect(() => readChainId(null)).toThrow('Wallet did not return a chain ID.')
expect(readRequiredString('challenge', 'challenge')).toBe('challenge')
expect(() => readRequiredString('', 'challenge')).toThrow('Passkey registration option challenge is required.')
expect(() => readRequiredString(5, 'challenge')).toThrow('Passkey registration option challenge is required.')
})
it('reads redirect urls from varied response shapes', () => {
expect(readRedirectUrl({ url: '/profile' })).toBe('/profile')
expect(readRedirectUrl({ redirectTo: '/settings' })).toBe('/settings')
expect(readRedirectUrl({ callbackURL: '/callback' })).toBe('/callback')
expect(readRedirectUrl({ url: 1 })).toBeNull()
expect(readRedirectUrl(null)).toBeNull()
expect(readRedirectUrl('string')).toBeNull()
expect(readRedirectUrl({})).toBeNull()
})
it('formats session devices and dates', () => {
expect(formatSessionDevice(null)).toBe('Unknown device')
expect(formatSessionDevice('Custom Agent')).toBe('Custom Agent')
expect(formatSessionDevice('Mozilla/5.0 (Mac OS X) Chrome/120')).toBe('Chrome on macOS')
expect(formatSessionDevice('Mozilla/5.0 (Windows) Edg/120')).toBe('Edge on Windows')
expect(formatSessionDevice('Mozilla/5.0 (Android) Firefox/120')).toBe('Firefox on Android')
expect(formatSessionDevice('Mozilla/5.0 (iPhone) Safari/16')).toBe('Safari on iOS')
expect(formatSessionDevice('Mozilla/5.0 (iPad) Safari/16')).toBe('Safari on iOS')
expect(formatSessionDevice('SomethingElse/1.0')).toBe('Browser session')
expect(typeof formatDate('2026-01-01T00:00:00.000Z')).toBe('string')
expect(typeof formatDate(new Date('2026-01-01'))).toBe('string')
})
it('reads totp enrollment fields with fallbacks', () => {
expect(readTotpEnrollment({ qrCode: 'qr', otpAuthUri: 'uri', secret: 's', backupCodes: ['a'] })).toEqual({
qrCode: 'qr',
otpAuthUri: 'uri',
secret: 's',
backupCodes: ['a'],
})
expect(readTotpEnrollment({ qr: 'qr2', totpURI: 'uri2' })).toEqual({
qrCode: 'qr2',
otpAuthUri: 'uri2',
secret: null,
backupCodes: [],
})
expect(readTotpEnrollment({ totpURI: 'otpauth://totp/Realmroot:user?secret=ABC234' }).secret).toBe('ABC234')
expect(readTotpEnrollment(null)).toEqual({ qrCode: null, otpAuthUri: null, secret: null, backupCodes: [] })
})
})
describe('enrollPasskey', () => {
const optionChallenge = bufferToBase64Url(new Uint8Array([1, 2, 3]).buffer)
const userId = bufferToBase64Url(new Uint8Array([4, 5, 6]).buffer)
const excludeId = bufferToBase64Url(new Uint8Array([7, 8, 9]).buffer)
beforeEach(() => {
server.use(
http.get(`${base}/api/auth/passkey/generate-register-options`, () =>
HttpResponse.json({
publicKey: {
challenge: optionChallenge,
user: { id: userId, name: 'jane', displayName: 'Jane Stone' },
excludeCredentials: [{ id: excludeId, type: 'public-key' }],
},
}),
),
http.post(`${base}/api/auth/passkey/verify-registration`, async ({ request }) =>
HttpResponse.json({ verified: true, received: await request.json() }),
),
)
})
it('serializes the created credential and posts verification', async () => {
const createdCredential = {
id: 'cred-1',
type: 'public-key',
rawId: new Uint8Array([10, 11]).buffer,
response: {
attestationObject: new Uint8Array([12, 13]).buffer,
clientDataJSON: new Uint8Array([14, 15]).buffer,
getTransports: () => ['internal'],
},
getClientExtensionResults: () => ({ credProps: { rk: true } }),
}
const create = vi.fn().mockResolvedValue(createdCredential)
vi.stubGlobal('navigator', { credentials: { create } })
const result = (await enrollPasskey('My Key')) as { verified: boolean; received: Record<string, unknown> }
expect(result.verified).toBe(true)
expect(create).toHaveBeenCalledOnce()
const passed = create.mock.calls[0][0].publicKey
expect(Array.from(new Uint8Array(passed.challenge))).toEqual([1, 2, 3])
expect(passed.user.name).toBe('jane')
expect(Array.from(new Uint8Array(passed.excludeCredentials[0].id))).toEqual([7, 8, 9])
const sent = result.received as { name: string; response: { id: string } }
expect(sent.name).toBe('My Key')
expect((sent.response as unknown as { rawId: string }).rawId).toBe(bufferToBase64Url(createdCredential.rawId))
})
it('handles credentials without optional getters and empty name', async () => {
const create = vi.fn().mockResolvedValue({
id: 'cred-2',
type: 'public-key',
rawId: new Uint8Array([1]).buffer,
response: {
attestationObject: new Uint8Array([2]).buffer,
clientDataJSON: new Uint8Array([3]).buffer,
},
})
vi.stubGlobal('navigator', { credentials: { create } })
const result = (await enrollPasskey('')) as { verified: boolean }
expect(result.verified).toBe(true)
})
it('throws when credentials.create is unavailable', async () => {
vi.stubGlobal('navigator', { credentials: {} })
await expect(enrollPasskey('x')).rejects.toThrow('Passkey registration is not supported by this browser.')
})
it('throws when credential creation is cancelled', async () => {
vi.stubGlobal('navigator', { credentials: { create: vi.fn().mockResolvedValue(null) } })
await expect(enrollPasskey('x')).rejects.toThrow('Passkey registration was cancelled.')
})
})
describe('enrollWallet', () => {
function stubEthereum(handlers: Record<string, unknown>) {
const request = vi.fn(async ({ method }: { method: string; params?: unknown }) => {
const value = handlers[method]
if (typeof value === 'function') return (value as () => unknown)()
return value
})
vi.stubGlobal('window', Object.assign(window, { ethereum: { request } }))
return request
}
it('signs a SIWE message and links the wallet', async () => {
let linked: unknown = null
server.use(
http.post(`${base}/api/auth/siwe/nonce`, () => HttpResponse.json({ nonce: 'nonce12345' })),
http.post(`${base}/api/account/wallet-addresses`, async ({ request }) => {
linked = await request.json()
return HttpResponse.json({ id: 'wallet-1' })
}),
)
stubEthereum({
eth_requestAccounts: ['0x1111111111111111111111111111111111111111'],
eth_chainId: '0x1',
personal_sign: '0xsignature',
})
const result = (await enrollWallet([1])) as { id: string }
expect(result.id).toBe('wallet-1')
expect((linked as { walletAddress: string }).walletAddress).toBe('0x1111111111111111111111111111111111111111')
expect((linked as { signature: string }).signature).toBe('0xsignature')
})
it('throws when no wallet provider exists', async () => {
vi.stubGlobal('window', Object.assign(window, { ethereum: undefined }))
await expect(enrollWallet([1])).rejects.toThrow('No wallet provider was found in this browser.')
})
it('throws when no account is selected', async () => {
stubEthereum({ eth_requestAccounts: [] })
await expect(enrollWallet([1])).rejects.toThrow('No wallet account was selected.')
})
it('throws when the wallet network is not enabled', async () => {
stubEthereum({
eth_requestAccounts: ['0x1111111111111111111111111111111111111111'],
eth_chainId: '0xa',
})
await expect(enrollWallet([1])).rejects.toThrow('This wallet network is not enabled. Switch to chain 1.')
})
it('throws when the wallet returns no signature', async () => {
server.use(http.post(`${base}/api/auth/siwe/nonce`, () => HttpResponse.json({ nonce: 'nonce12345' })))
stubEthereum({
eth_requestAccounts: ['0x1111111111111111111111111111111111111111'],
eth_chainId: '0x1',
personal_sign: '',
})
await expect(enrollWallet([1])).rejects.toThrow('Wallet did not return a signature.')
})
})