also looking at this
feat: native trail maps and complete native mobile screens
#1017
60 files
+2505
-823
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -288,3 +288,95 @@ describe('signed-out native mutation security', () => { | ||
| 288 | 288 | } finally {globalThis.fetch=previous} |
| 289 | 289 | }) |
| 290 | 290 | }) |
| 291 | ||
| 292 | describe('native account-bound operations', () => { | |
| 293 | it('requires a warm screen to reload after its account changes before an operation begins', async () => { | |
| 294 | let account: unknown = {user:{id:7},version:1} | |
| 295 | let token = 'account-seven' | |
| 296 | const fetched = recorder() | |
| 297 | try { | |
| 298 | const api = createNativeApi({snapshots:{get:()=>account},secureStorage:{get:()=>token}}) | |
| 299 | await api.send('/api/me') | |
| 300 | account = {user:{id:8},version:2} | |
| 301 | token = 'account-eight' | |
| 302 | await expect(api.bindSession()).rejects.toThrow('Your account changed') | |
| 303 | expect(fetched.calls).toHaveLength(1) | |
| 304 | } | |
| 305 | finally { fetched.restore() } | |
| 306 | }) | |
| 307 | ||
| 308 | it('refuses a warm screen after the bearer changes before the account snapshot arrives', async () => { | |
| 309 | let token = 'account-seven' | |
| 310 | const fetched = recorder() | |
| 311 | try { | |
| 312 | const api = createNativeApi({snapshots:{get:()=>({user:{id:7},version:1})},secureStorage:{get:()=>token}}) | |
| 313 | await api.send('/api/me') | |
| 314 | token = 'account-eight' | |
| 315 | await expect(api.bindSession()).rejects.toThrow('Your account changed') | |
| 316 | expect(fetched.calls).toHaveLength(1) | |
| 317 | } | |
| 318 | finally { fetched.restore() } | |
| 319 | }) | |
| 320 | ||
| 321 | it('keeps an identity check and its upload in the same account session', async () => { | |
| 322 | let account: unknown = {user:{id:7},version:1} | |
| 323 | let token = 'account-seven' | |
| 324 | const fetched = recorder({body:'{"user":{"id":7}}'}) | |
| 325 | try { | |
| 326 | const api = await createNativeApi({snapshots:{get:()=>account},secureStorage:{get:()=>token}}).bindSession() | |
| 327 | await api.send('/api/me') | |
| 328 | account = {user:{id:8},version:2} | |
| 329 | token = 'account-eight' | |
| 330 | await expect(api.send('/api/activities','POST',{upload_id:'run:owned'})).rejects.toThrow('Your account changed') | |
| 331 | expect(fetched.calls).toHaveLength(1) | |
| 332 | expect(fetched.calls[0].headers.Authorization).toBe('Bearer account-seven') | |
| 333 | } | |
| 334 | finally { fetched.restore() } | |
| 335 | }) | |
| 336 | ||
| 337 | it('works on a cold start without an account snapshot and detects a changed keychain', async () => { | |
| 338 | let token = 'cold-start-token' | |
| 339 | const fetched = recorder() | |
| 340 | try { | |
| 341 | const api = await createNativeApi({secureStorage:{get:()=>token}}).bindSession() | |
| 342 | await api.send('/api/me') | |
| 343 | token = 'replacement-token' | |
| 344 | await expect(api.send('/api/activities','POST',{})).rejects.toThrow('Your account changed') | |
| 345 | expect(fetched.calls).toHaveLength(1) | |
| 346 | } | |
| 347 | finally { fetched.restore() } | |
| 348 | }) | |
| 349 | ||
| 350 | it('does not bind a signed-out session or silently retarget after a keychain read', async () => { | |
| 351 | let account: unknown = {user:{id:7},version:1} | |
| 352 | const fetched = recorder() | |
| 353 | try { | |
| 354 | await expect(createNativeApi({secureStorage:{get:()=>null}}).bindSession()).rejects.toThrow('Sign in') | |
| 355 | const api = createNativeApi({snapshots:{get:()=>account},secureStorage:{get:async()=>{ | |
| 356 | account={user:{id:8},version:2} | |
| 357 | return 'previous-token' | |
| 358 | }}}) | |
| 359 | await expect(api.bindSession()).rejects.toThrow('Your account changed') | |
| 360 | expect(fetched.calls).toHaveLength(0) | |
| 361 | } | |
| 362 | finally { fetched.restore() } | |
| 363 | }) | |
| 364 | ||
| 365 | it('does not expose an old response after a cold-start bearer changes in flight', async () => { | |
| 366 | let token = 'account-seven' | |
| 367 | const previous = globalThis.fetch | |
| 368 | const bearers: string[] = [] | |
| 369 | globalThis.fetch = (async (url: any, init: any) => { | |
| 370 | expect(String(url)).toBe(`${WILDLOOP_ORIGIN}/api/me`) | |
| 371 | bearers.push(init.headers.Authorization) | |
| 372 | token = 'account-eight' | |
| 373 | return new Response('{"user":{"id":7}}') | |
| 374 | }) as typeof fetch | |
| 375 | try { | |
| 376 | const api = await createNativeApi({secureStorage:{get:()=>token}}).bindSession() | |
| 377 | await expect(api.send('/api/me')).rejects.toThrow('Your account changed') | |
| 378 | expect(bearers).toEqual(['Bearer account-seven']) | |
| 379 | } | |
| 380 | finally { globalThis.fetch = previous } | |
| 381 | }) | |
| 382 | }) | |