forked from koshikraj/ottopus
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwallets.test.ts
More file actions
181 lines (151 loc) · 7.07 KB
/
Copy pathwallets.test.ts
File metadata and controls
181 lines (151 loc) · 7.07 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
import { PGlite } from '@electric-sql/pglite'
import { drizzle } from 'drizzle-orm/pglite'
import type { MiddlewareHandler } from 'hono'
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
import type { PrivyWallet } from '../auth/privy.js'
import { userIdForDid } from '../auth/session.js'
import { migrationFiles, statementsIn } from '../db/migrate.js'
import * as schema from '../db/schema.js'
import { walletRoutes } from './wallets.js'
/**
* The routes are exercised with a stub session rather than a real Privy token:
* token verification has its own tests, and what matters here is what each
* route does once it knows who is calling and what was attested.
*/
let db: ReturnType<typeof drizzle<typeof schema>>
let pg: PGlite
let userId: string
const address = (n: number) => `0x${n.toString(16).padStart(40, '0')}`
const wallet = (n: number): PrivyWallet => ({
address: address(n),
walletClientType: 'metamask',
chainType: 'ethereum',
firstVerifiedAt: '2026-09-01T10:00:00.000Z',
})
/** Stands in for requireSession. `attested` undefined means no identity token. */
const signedIn = (attested: PrivyWallet[] | undefined, as = userId): MiddlewareHandler => {
return async (c, next) => {
c.set('userId', as)
c.set('privyWallets', attested)
await next()
}
}
const app = (attested?: PrivyWallet[] | undefined) => walletRoutes(db, signedIn(attested))
/** The same routes, as a different person. */
const appAs = (as: string) => walletRoutes(db, signedIn(undefined, as))
beforeAll(async () => {
pg = await PGlite.create()
await pg.exec(`create role anon; create role authenticated; create role service_role;`)
for (const file of await migrationFiles(new URL('../../drizzle', import.meta.url).pathname)) {
for (const stmt of await statementsIn(file)) await pg.exec(stmt)
}
db = drizzle(pg, { schema, casing: 'snake_case' })
userId = await userIdForDid(db, 'did:privy:routes')
}, 60_000)
beforeEach(async () => {
await pg.exec(`delete from linked_wallets`)
})
const post = (a: PrivyWallet[] | undefined, path: string, body?: unknown) =>
app(a).request(path, {
method: 'POST',
...(body === undefined
? {}
: { body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }),
})
describe('POST /sync', () => {
it('links what the identity token attests', async () => {
const res = await post([wallet(1)], '/sync')
expect(res.status).toBe(200)
const body = (await res.json()) as { wallets: { address: string }[] }
expect(body.wallets.map((w) => w.address)).toEqual([address(1)])
})
/**
* The one that could unlink someone's entire account. An absent identity
* token looks identical to "Privy attests nothing", and syncing on that
* would drop every proved arm. It has to be refused, not treated as empty.
*/
it('refuses a sync with no identity token instead of unlinking everything', async () => {
await post([wallet(1)], '/sync')
const res = await post(undefined, '/sync')
expect(res.status).toBe(400)
expect((await res.json()) as { error: string }).toMatchObject({
error: 'identity_token_required',
})
const after = await app([wallet(1)]).request('/')
expect(((await after.json()) as { wallets: unknown[] }).wallets).toHaveLength(1)
})
/** An attestation that genuinely lists nothing is a real unlink, though. */
/**
* The route-level half of the access-token substitution. `readIdentity` now
* reports undefined rather than [] for a token with no linked_accounts
* claim, and this is what that buys: the sync is refused instead of taken as
* "this user has no wallets".
*/
it('refuses a token that carried no linked_accounts claim', async () => {
await post([wallet(1)], '/sync')
// What the middleware sets when readIdentity found no readable claim.
const res = await post(undefined, '/sync')
expect(res.status).toBe(400)
const after = await app([wallet(1)]).request('/')
expect(((await after.json()) as { wallets: unknown[] }).wallets).toHaveLength(1)
})
it('does unlink when the token attests an empty list', async () => {
await post([wallet(1)], '/sync')
const res = await post([], '/sync')
expect(((await res.json()) as { wallets: unknown[] }).wallets).toEqual([])
})
})
describe('POST /watch', () => {
it('stores a pasted address as watch-only', async () => {
const res = await post(undefined, '/watch', { address: address(5), label: 'Treasury' })
expect(res.status).toBe(201)
const { wallet: arm } = (await res.json()) as { wallet: { isWatchOnly: boolean; label: string } }
expect(arm).toMatchObject({ isWatchOnly: true, label: 'Treasury' })
})
it('rejects a malformed address with 400', async () => {
expect((await post(undefined, '/watch', { address: '0x123' })).status).toBe(400)
})
it('rejects an empty body with 400', async () => {
expect((await post(undefined, '/watch')).status).toBe(400)
})
it('answers 409 for an address already linked', async () => {
await post(undefined, '/watch', { address: address(5) })
expect((await post(undefined, '/watch', { address: address(5) })).status).toBe(409)
})
it('answers 422 once the arms are full', async () => {
for (let i = 0; i < 8; i++) await post(undefined, '/watch', { address: address(i) })
expect((await post(undefined, '/watch', { address: address(99) })).status).toBe(422)
})
})
describe('DELETE /:id', () => {
it('unlinks an arm', async () => {
const created = await post(undefined, '/watch', { address: address(5) })
const { wallet: arm } = (await created.json()) as { wallet: { id: string } }
const res = await app().request(`/${arm.id}`, { method: 'DELETE' })
expect(res.status).toBe(204)
const list = await app().request('/')
expect(((await list.json()) as { wallets: unknown[] }).wallets).toEqual([])
})
it('answers 404 for an id that does not exist', async () => {
const missing = '11111111-2222-3333-4444-555555555555'
expect((await app().request(`/${missing}`, { method: 'DELETE' })).status).toBe(404)
})
/** A malformed uuid is a bad request, not a database error surfacing as 500. */
it('answers 404 for a malformed id', async () => {
expect((await app().request('/not-a-uuid', { method: 'DELETE' })).status).toBe(404)
})
/**
* Another person's wallet id must look exactly like a missing one, and the
* wallet must still be there for its owner afterwards.
*/
it('answers 404 for a wallet belonging to someone else, and leaves it linked', async () => {
const stranger = await userIdForDid(db, 'did:privy:someone-else')
const created = await post(undefined, '/watch', { address: address(7) })
const { wallet: arm } = (await created.json()) as { wallet: { id: string } }
expect((await appAs(stranger).request(`/${arm.id}`, { method: 'DELETE' })).status).toBe(404)
const list = await app().request('/')
const { wallets } = (await list.json()) as { wallets: { id: string }[] }
expect(wallets.map((w) => w.id)).toEqual([arm.id])
expect(((await appAs(stranger).request('/').then((r) => r.json())) as { wallets: unknown[] }).wallets).toEqual([])
})
})