Coves frontend - a photon fork
1import { describe, it, expect, vi } from 'vitest'
2import {
3 asDID,
4 asHandle,
5 asInstanceURL,
6 asSealedToken,
7 isValidDID,
8 isValidHandle,
9 isValidInstanceURL,
10 tryAsDID,
11 tryAsHandle,
12 tryAsInstanceURL,
13 toClientAccount,
14 toClientSession,
15 parseApiMeResponse,
16 type AccountSession,
17} from './session'
18
19// ============================================================================
20// Branded Types Tests
21// ============================================================================
22
23describe('DID validation', () => {
24 it('validates correct DID format', () => {
25 expect(isValidDID('did:plc:abc123')).toBe(true)
26 expect(isValidDID('did:web:example.com')).toBe(true)
27 expect(
28 isValidDID('did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK'),
29 ).toBe(true)
30 })
31
32 it('rejects invalid DID format', () => {
33 expect(isValidDID('not-a-did')).toBe(false)
34 expect(isValidDID('did:')).toBe(false)
35 expect(isValidDID('did:plc:')).toBe(false)
36 expect(isValidDID('')).toBe(false)
37 })
38
39 it('asDID throws for invalid DID', () => {
40 expect(() => asDID('invalid')).toThrow('Invalid DID format')
41 })
42
43 it('asDID returns branded type for valid DID', () => {
44 const did = asDID('did:plc:test123')
45 expect(did).toBe('did:plc:test123')
46 })
47
48 it('tryAsDID returns null for invalid DID', () => {
49 expect(tryAsDID('invalid')).toBeNull()
50 })
51
52 it('tryAsDID returns branded type for valid DID', () => {
53 const did = tryAsDID('did:plc:test123')
54 expect(did).toBe('did:plc:test123')
55 })
56})
57
58describe('Handle validation', () => {
59 it('validates correct Handle format', () => {
60 expect(isValidHandle('alice.bsky.social')).toBe(true)
61 expect(isValidHandle('user.example.com')).toBe(true)
62 expect(isValidHandle('test-user.my-domain.org')).toBe(true)
63 })
64
65 it('rejects invalid Handle format', () => {
66 expect(isValidHandle('notsingle')).toBe(false)
67 expect(isValidHandle('')).toBe(false)
68 expect(isValidHandle('.starts.with.dot')).toBe(false)
69 })
70
71 it('asHandle throws for invalid Handle', () => {
72 expect(() => asHandle('invalid')).toThrow('Invalid Handle format')
73 })
74
75 it('asHandle returns branded type for valid Handle', () => {
76 const handle = asHandle('user.example.com')
77 expect(handle).toBe('user.example.com')
78 })
79
80 it('tryAsHandle returns null for invalid Handle', () => {
81 expect(tryAsHandle('invalid')).toBeNull()
82 })
83
84 it('tryAsHandle returns branded type for valid Handle', () => {
85 const handle = tryAsHandle('user.example.com')
86 expect(handle).toBe('user.example.com')
87 })
88})
89
90describe('InstanceURL validation', () => {
91 it('validates correct InstanceURL format', () => {
92 expect(isValidInstanceURL('https://example.com')).toBe(true)
93 expect(isValidInstanceURL('https://bsky.social')).toBe(true)
94 expect(isValidInstanceURL('http://localhost:3000')).toBe(true)
95 })
96
97 it('rejects invalid InstanceURL format', () => {
98 expect(isValidInstanceURL('not-a-url')).toBe(false)
99 expect(isValidInstanceURL('')).toBe(false)
100 expect(isValidInstanceURL('ftp://example.com')).toBe(false)
101 })
102
103 it('asInstanceURL throws for invalid URL', () => {
104 expect(() => asInstanceURL('invalid')).toThrow(
105 'Invalid Instance URL format',
106 )
107 })
108
109 it('asInstanceURL returns branded type for valid URL', () => {
110 const url = asInstanceURL('https://example.com')
111 expect(url).toBe('https://example.com')
112 })
113
114 it('tryAsInstanceURL returns null for invalid URL', () => {
115 expect(tryAsInstanceURL('invalid')).toBeNull()
116 })
117
118 it('tryAsInstanceURL returns branded type for valid URL', () => {
119 const url = tryAsInstanceURL('https://example.com')
120 expect(url).toBe('https://example.com')
121 })
122})
123
124// ============================================================================
125// parseApiMeResponse Tests
126// ============================================================================
127
128describe('parseApiMeResponse', () => {
129 const testInstance = asInstanceURL('https://coves.example.com')
130 const testToken = asSealedToken('sealed-token-123')
131
132 it('returns AccountSession for valid response', () => {
133 const data = {
134 did: 'did:plc:user1',
135 handle: 'user1.example.com',
136 }
137
138 const result = parseApiMeResponse(data, testInstance, testToken)
139
140 expect(result).not.toBeNull()
141 expect(result!.did).toBe('did:plc:user1')
142 expect(result!.handle).toBe('user1.example.com')
143 expect(result!.instance).toBe('https://coves.example.com')
144 expect(result!.sealedToken).toBe('sealed-token-123')
145 expect(result!.avatar).toBeUndefined()
146 })
147
148 it('returns null when did is missing', () => {
149 const data = { handle: 'user1.example.com' }
150 const result = parseApiMeResponse(data, testInstance, testToken)
151 expect(result).toBeNull()
152 })
153
154 it('returns null when DID format is invalid', () => {
155 const data = { did: 'not-a-did', handle: 'user1.example.com' }
156 const result = parseApiMeResponse(data, testInstance, testToken)
157 expect(result).toBeNull()
158 })
159
160 it('returns null when handle is missing', () => {
161 const data = { did: 'did:plc:user1' }
162 const result = parseApiMeResponse(data, testInstance, testToken)
163 expect(result).toBeNull()
164 })
165
166 it('returns null when handle format is invalid', () => {
167 const data = { did: 'did:plc:user1', handle: 'invalid' }
168 const result = parseApiMeResponse(data, testInstance, testToken)
169 expect(result).toBeNull()
170 })
171
172 it('includes avatar when present', () => {
173 const data = {
174 did: 'did:plc:user1',
175 handle: 'user1.example.com',
176 avatar: 'https://example.com/avatar.png',
177 }
178
179 const result = parseApiMeResponse(data, testInstance, testToken)
180
181 expect(result).not.toBeNull()
182 expect(result!.avatar).toBe('https://example.com/avatar.png')
183 })
184
185 it('sets avatar to undefined when not present', () => {
186 const data = {
187 did: 'did:plc:user1',
188 handle: 'user1.example.com',
189 }
190
191 const result = parseApiMeResponse(data, testInstance, testToken)
192
193 expect(result).not.toBeNull()
194 expect(result!.avatar).toBeUndefined()
195 })
196
197 it('returns null for null input', () => {
198 const result = parseApiMeResponse(null, testInstance, testToken)
199 expect(result).toBeNull()
200 })
201
202 it('returns null for non-object input', () => {
203 expect(parseApiMeResponse('string', testInstance, testToken)).toBeNull()
204 expect(parseApiMeResponse(42, testInstance, testToken)).toBeNull()
205 expect(parseApiMeResponse(true, testInstance, testToken)).toBeNull()
206 expect(parseApiMeResponse(undefined, testInstance, testToken)).toBeNull()
207 })
208
209 it('logs warning when did is missing', () => {
210 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
211
212 parseApiMeResponse({ handle: 'user1.example.com' }, testInstance, testToken)
213
214 expect(warnSpy).toHaveBeenCalledWith(
215 expect.stringContaining('Missing or non-string "did"'),
216 )
217 warnSpy.mockRestore()
218 })
219
220 it('logs warning when DID format is invalid', () => {
221 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
222
223 parseApiMeResponse(
224 { did: 'not-a-did', handle: 'user1.example.com' },
225 testInstance,
226 testToken,
227 )
228
229 expect(warnSpy).toHaveBeenCalledWith(
230 expect.stringContaining('Invalid DID format'),
231 'not-a-did',
232 )
233 warnSpy.mockRestore()
234 })
235
236 it('logs warning when handle is missing', () => {
237 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
238
239 parseApiMeResponse({ did: 'did:plc:user1' }, testInstance, testToken)
240
241 expect(warnSpy).toHaveBeenCalledWith(
242 expect.stringContaining('Missing or non-string "handle"'),
243 )
244 warnSpy.mockRestore()
245 })
246
247 it('logs warning when handle format is invalid', () => {
248 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
249
250 parseApiMeResponse(
251 { did: 'did:plc:user1', handle: 'invalid' },
252 testInstance,
253 testToken,
254 )
255
256 expect(warnSpy).toHaveBeenCalledWith(
257 expect.stringContaining('Invalid handle format'),
258 'invalid',
259 )
260 warnSpy.mockRestore()
261 })
262
263 it('logs warning for non-object input', () => {
264 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
265
266 parseApiMeResponse('string', testInstance, testToken)
267
268 expect(warnSpy).toHaveBeenCalledWith(
269 expect.stringContaining('Invalid input: expected object'),
270 'string',
271 )
272 warnSpy.mockRestore()
273 })
274
275 it('rejects javascript: avatar URLs and sets avatar to undefined', () => {
276 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
277
278 const data = {
279 did: 'did:plc:user1',
280 handle: 'user1.example.com',
281 avatar: 'javascript:alert(1)',
282 }
283
284 const result = parseApiMeResponse(data, testInstance, testToken)
285
286 expect(result).not.toBeNull()
287 expect(result!.avatar).toBeUndefined()
288 expect(warnSpy).toHaveBeenCalledWith(
289 expect.stringContaining('Avatar URL rejected'),
290 'javascript:alert(1)',
291 )
292 warnSpy.mockRestore()
293 })
294
295 it('rejects data: avatar URLs and sets avatar to undefined', () => {
296 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
297
298 const data = {
299 did: 'did:plc:user1',
300 handle: 'user1.example.com',
301 avatar: 'data:text/html,<script>alert(1)</script>',
302 }
303
304 const result = parseApiMeResponse(data, testInstance, testToken)
305
306 expect(result).not.toBeNull()
307 expect(result!.avatar).toBeUndefined()
308 expect(warnSpy).toHaveBeenCalledWith(
309 expect.stringContaining('Avatar URL rejected'),
310 'data:text/html,<script>alert(1)</script>',
311 )
312 warnSpy.mockRestore()
313 })
314
315 it('accepts https avatar URLs', () => {
316 const data = {
317 did: 'did:plc:user1',
318 handle: 'user1.example.com',
319 avatar: 'https://cdn.example.com/avatar.png',
320 }
321
322 const result = parseApiMeResponse(data, testInstance, testToken)
323
324 expect(result).not.toBeNull()
325 expect(result!.avatar).toBe('https://cdn.example.com/avatar.png')
326 })
327
328 it('accepts http avatar URLs', () => {
329 const data = {
330 did: 'did:plc:user1',
331 handle: 'user1.example.com',
332 avatar: 'http://localhost:3000/avatar.png',
333 }
334
335 const result = parseApiMeResponse(data, testInstance, testToken)
336
337 expect(result).not.toBeNull()
338 expect(result!.avatar).toBe('http://localhost:3000/avatar.png')
339 })
340})
341
342// ============================================================================
343// toClientAccount / toClientSession Tests
344// ============================================================================
345
346describe('toClientAccount', () => {
347 it('removes sealedToken and uses DID as id', () => {
348 const account: AccountSession = {
349 did: asDID('did:plc:test'),
350 handle: asHandle('test.example.com'),
351 instance: asInstanceURL('https://example.com'),
352 sealedToken: asSealedToken('secret-token'),
353 avatar: 'https://example.com/avatar.png',
354 }
355
356 const clientAccount = toClientAccount(account)
357
358 expect(clientAccount.id).toBe('did:plc:test')
359 expect(clientAccount.did).toBe('did:plc:test')
360 expect(clientAccount.handle).toBe('test.example.com')
361 expect(clientAccount.instance).toBe('https://example.com')
362 expect(clientAccount.avatar).toBe('https://example.com/avatar.png')
363 expect('sealedToken' in clientAccount).toBe(false)
364 })
365})
366
367describe('toClientSession', () => {
368 it('returns unauthenticated session for null account', () => {
369 const clientSession = toClientSession(null)
370
371 expect(clientSession.authenticated).toBe(false)
372 expect(clientSession.activeAccountId).toBeNull()
373 expect(clientSession.account).toBeNull()
374 })
375
376 it('returns authenticated session with account for valid AccountSession', () => {
377 const account: AccountSession = {
378 did: asDID('did:plc:test'),
379 handle: asHandle('test.example.com'),
380 instance: asInstanceURL('https://example.com'),
381 sealedToken: asSealedToken('secret-token'),
382 }
383
384 const clientSession = toClientSession(account)
385
386 expect(clientSession.authenticated).toBe(true)
387 expect(clientSession.activeAccountId).toBe('did:plc:test')
388 expect(clientSession.account).not.toBeNull()
389 if (clientSession.authenticated) {
390 expect(clientSession.account.did).toBe('did:plc:test')
391 expect('sealedToken' in clientSession.account).toBe(false)
392 }
393 })
394})