1/// <reference types="node" />
2
3import * as NodeCrypto from "node:crypto";
4
5import { beforeEach, vi } from "vite-plus/test";
6import { describe, expect, it } from "@effect/vitest";
7import Constants from "expo-constants";
8import * as Cause from "effect/Cause";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as Layer from "effect/Layer";
12import { Cookies, FetchHttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
13import { ManagedRelay } from "@t3tools/client-runtime/relay";
14
15import type { EnvironmentId } from "@t3tools/contracts";
16import { verifyDpopProof } from "@t3tools/shared/dpop";
17import type { SavedRemoteConnection } from "../../lib/connection";
18import * as Dpop from "../cloud/dpop";
19import * as ManagedRelayLayer from "../cloud/managedRelayLayer";
20import {
21 clearAgentAwarenessRegistrationRecord,
22 loadAgentAwarenessRegistrationRecord,
23 loadOrCreateAgentAwarenessDeviceId,
24 loadPreferences,
25 saveAgentAwarenessRegistrationRecord,
26} from "../../persistence/imperative";
27import type { Preferences } from "../../persistence/mobile-preferences";
28import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
29import {
30 AgentAwarenessOperationError,
31 __resetAgentAwarenessRemoteRegistrationForTest,
32 armAgentAwarenessLiveActivityForLocalWork,
33 getAgentAwarenessRegistrationStatus,
34 mergeAgentAwarenessRegistrationPreferences,
35 refreshActiveLiveActivityRemoteRegistration,
36 refreshAgentAwarenessRegistration,
37 registerAgentAwarenessConnection,
38 registerLiveActivityPushToken,
39 releaseAgentAwarenessRelayTokenProvider,
40 setAgentAwarenessRelayTokenProvider,
41 shouldRegisterAgentAwarenessDeviceForProvider,
42 unregisterAgentAwarenessConnection,
43 updateAgentAwarenessRegistrationPreferences,
44} from "./remoteRegistration";
45import * as Notifications from "expo-notifications";
46import { Platform } from "react-native";
47import {
48 configureAndroidAgentNotifications,
49 clearAndroidAgentNotifications,
50} from "./androidNotifications";
51
52vi.mock("./androidNotifications", () => ({
53 supportsAndroidAgentNotifications: vi.fn(() => true),
54 configureAndroidAgentNotifications: vi.fn(),
55 clearAndroidAgentNotifications: vi.fn(),
56}));
57
58const secureStore = vi.hoisted(() => new Map<string, string>());
59const widgetMocks = vi.hoisted(() => ({
60 getInstances: vi.fn(() => []),
61 start: vi.fn(() => ({})),
62}));
63const environmentConfigsMock = vi.hoisted(() => ({
64 configs: new Map<
65 string,
66 { environment: { capabilities: { agentActivityPublishing?: boolean } } }
67 >(),
68}));
69const backgroundRuntime = vi.hoisted(() => ({
70 pending: [] as Array<{
71 readonly operation: unknown;
72 readonly resolve: (exit: Exit.Exit<unknown, unknown>) => void;
73 }>,
74}));
75const appStateMock = vi.hoisted(() => ({
76 listeners: [] as Array<(state: string) => void>,
77}));
78const registrationRecordStore = vi.hoisted(() => ({
79 current: null as {
80 readonly identity: string;
81 readonly signature: string;
82 readonly pushToStartToken?: string;
83 } | null,
84}));
85
86vi.mock("expo-constants", () => ({
87 default: {
88 expoConfig: {
89 version: "1.0.0",
90 extra: {},
91 },
92 },
93}));
94
95vi.mock("expo-widgets", () => ({
96 addPushToStartTokenListener: vi.fn(() => ({ remove: vi.fn() })),
97}));
98
99vi.mock("./agentLiveActivity", () => ({
100 getAgentLiveActivities: widgetMocks.getInstances,
101 startAgentLiveActivity: widgetMocks.start,
102}));
103
104// The state modules pull the whole connection stack (and native expo modules)
105// into the import graph; the arming gate only needs the configs map.
106vi.mock("../../state/atom-registry", () => ({
107 appAtomRegistry: {
108 get: () => environmentConfigsMock.configs,
109 },
110}));
111
112vi.mock("../../state/server", () => ({
113 environmentServerConfigsAtom: Symbol("environmentServerConfigsAtom"),
114}));
115
116vi.mock("expo-notifications", () => ({
117 addPushTokenListener: vi.fn(() => ({ remove: vi.fn() })),
118 getDevicePushTokenAsync: vi.fn(() => Promise.resolve({ type: "ios", data: "apns-token" })),
119 getPermissionsAsync: vi.fn(() => Promise.resolve({ granted: true })),
120}));
121
122vi.mock("expo-crypto", () => ({
123 CryptoDigestAlgorithm: {
124 SHA1: "SHA-1",
125 SHA256: "SHA-256",
126 SHA384: "SHA-384",
127 SHA512: "SHA-512",
128 },
129 getRandomBytes: (byteCount: number) => new Uint8Array(NodeCrypto.randomBytes(byteCount)),
130 getRandomBytesAsync: (byteCount: number) =>
131 Promise.resolve(new Uint8Array(NodeCrypto.randomBytes(byteCount))),
132 digest: (algorithm: string, data: unknown) => {
133 if (!(data instanceof Uint8Array)) {
134 return Promise.reject(new TypeError("expo-crypto digest data must be a typed array."));
135 }
136 return Promise.resolve(
137 new Uint8Array(NodeCrypto.createHash(algorithm).update(data).digest()).buffer,
138 );
139 },
140}));
141
142vi.mock("expo-secure-store", () => ({
143 getItemAsync: (key: string) => Promise.resolve(secureStore.get(key) ?? null),
144 setItemAsync: (key: string, value: string) => {
145 secureStore.set(key, value);
146 return Promise.resolve();
147 },
148}));
149
150vi.mock("react-native", () => ({
151 Platform: {
152 get OS() {
153 return "ios";
154 },
155 get Version() {
156 return "18.0";
157 },
158 },
159 AppState: {
160 addEventListener: (_event: string, listener: (state: string) => void) => {
161 appStateMock.listeners.push(listener);
162 return {
163 remove: () => {
164 const index = appStateMock.listeners.indexOf(listener);
165 if (index >= 0) {
166 appStateMock.listeners.splice(index, 1);
167 }
168 },
169 };
170 },
171 },
172}));
173
174vi.mock("../../lib/runtime", () => ({
175 runtime: {
176 runPromiseExit: (operation: unknown) =>
177 new Promise((resolve) => {
178 backgroundRuntime.pending.push({ operation, resolve });
179 }),
180 },
181}));
182
183vi.mock("../../persistence/imperative", () => ({
184 loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
185 loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
186 loadPreferences: vi.fn(() => Promise.resolve({ liveActivitiesEnabled: false })),
187 loadAgentAwarenessRegistrationRecord: vi.fn(() =>
188 Promise.resolve(registrationRecordStore.current),
189 ),
190 saveAgentAwarenessRegistrationRecord: vi.fn((record: { identity: string; signature: string }) => {
191 registrationRecordStore.current = record;
192 return Promise.resolve();
193 }),
194 clearAgentAwarenessRegistrationRecord: vi.fn(() => {
195 registrationRecordStore.current = null;
196 return Promise.resolve();
197 }),
198}));
199
200function proofIat(proof: string): number {
201 const payload = proof.split(".")[1];
202 if (!payload) {
203 throw new Error("Missing DPoP payload.");
204 }
205 const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
206 readonly iat: number;
207 };
208 return decoded.iat;
209}
210
211function savedConnection(): SavedRemoteConnection {
212 return {
213 environmentId: "env-1" as EnvironmentId,
214 environmentLabel: "Desktop",
215 pairingUrl: "https://desktop.example/pair",
216 displayUrl: "https://desktop.example",
217 httpBaseUrl: "https://desktop.example",
218 wsBaseUrl: "wss://desktop.example/ws",
219 bearerToken: "bearer-token",
220 };
221}
222
223const layerRelayTest = ManagedRelayLayer.layer("https://relay.example.test").pipe(
224 Layer.provide(Layer.mergeAll(FetchHttpClient.layer, Dpop.layer)),
225);
226
227const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundOperations")(
228 function* () {
229 let idlePasses = 0;
230 for (;;) {
231 yield* Effect.promise(() => Promise.resolve());
232 const pending = backgroundRuntime.pending.shift();
233 if (!pending) {
234 idlePasses++;
235 if (idlePasses >= 3) {
236 return;
237 }
238 continue;
239 }
240 idlePasses = 0;
241 const exit = yield* Effect.exit(
242 pending.operation as Effect.Effect<unknown, unknown, ManagedRelay.ManagedRelayClient>,
243 );
244 yield* Effect.sync(() => {
245 pending.resolve(exit);
246 });
247 }
248 },
249);
250
251describe("makeRelayDeviceRegistrationRequest", () => {
252 beforeEach(() => {
253 vi.restoreAllMocks();
254 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
255 type: "ios",
256 data: "apns-token",
257 });
258 vi.unstubAllGlobals();
259 vi.stubGlobal("__DEV__", false);
260 secureStore.clear();
261 backgroundRuntime.pending.length = 0;
262 Constants.expoConfig!.extra = {};
263 __resetAgentAwarenessRemoteRegistrationForTest();
264 appStateMock.listeners.length = 0;
265 registrationRecordStore.current = null;
266 vi.mocked(saveAgentAwarenessRegistrationRecord).mockClear();
267 vi.mocked(loadAgentAwarenessRegistrationRecord).mockClear();
268 vi.mocked(clearAgentAwarenessRegistrationRecord).mockClear();
269 vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1");
270 widgetMocks.getInstances.mockReset();
271 widgetMocks.getInstances.mockReturnValue([]);
272 widgetMocks.start.mockClear();
273 environmentConfigsMock.configs.clear();
274 });
275
276 it("preserves disabled Live Activity preferences in relay registrations", () => {
277 expect(
278 makeRelayDeviceRegistrationRequest({
279 deviceId: "device-1",
280 label: "Julius's iPhone",
281 iosMajorVersion: 18,
282 appVersion: "1.0.0",
283 pushToken: "apns-token",
284 pushToStartToken: "push-to-start-token",
285 notificationsEnabled: true,
286 preferences: {
287 liveActivitiesEnabled: false,
288 },
289 }),
290 ).toEqual({
291 deviceId: "device-1",
292 label: "Julius's iPhone",
293 platform: "ios",
294 iosMajorVersion: 18,
295 appVersion: "1.0.0",
296 pushToken: "apns-token",
297 pushToStartToken: "push-to-start-token",
298 preferences: {
299 liveActivitiesEnabled: false,
300 notificationsEnabled: true,
301 notifyOnApproval: true,
302 notifyOnInput: true,
303 notifyOnCompletion: true,
304 notifyOnFailure: true,
305 },
306 });
307 });
308
309 it("registers the app's APNs routing so the relay targets the right bundle", () => {
310 expect(
311 makeRelayDeviceRegistrationRequest({
312 deviceId: "device-1",
313 label: "Julius's iPhone",
314 iosMajorVersion: 18,
315 appVersion: "1.0.0",
316 bundleId: "com.t3tools.t3code.preview",
317 apsEnvironment: resolveApsEnvironment("preview"),
318 notificationsEnabled: true,
319 preferences: {},
320 }),
321 ).toMatchObject({
322 bundleId: "com.t3tools.t3code.preview",
323 apsEnvironment: "production",
324 });
325 });
326
327 it("routes development builds to the APNs sandbox", () => {
328 expect(resolveApsEnvironment("development")).toBe("sandbox");
329 expect(resolveApsEnvironment("preview")).toBe("production");
330 expect(resolveApsEnvironment("production")).toBe("production");
331 expect(resolveApsEnvironment(undefined)).toBe("production");
332 });
333
334 it("disables push features in Personal Team relay registrations", () => {
335 Constants.expoConfig!.extra = { iosPersonalTeamBuild: true };
336
337 expect(
338 makeRelayDeviceRegistrationRequest({
339 deviceId: "device-1",
340 label: "Julius's iPhone",
341 iosMajorVersion: 18,
342 appVersion: "1.0.0",
343 pushToken: "apns-token",
344 pushToStartToken: "push-to-start-token",
345 notificationsEnabled: true,
346 preferences: {},
347 }).preferences,
348 ).toMatchObject({
349 liveActivitiesEnabled: false,
350 notificationsEnabled: false,
351 });
352 });
353
354 it("marks notification delivery disabled when APNs permission is unavailable", () => {
355 expect(
356 makeRelayDeviceRegistrationRequest({
357 deviceId: "device-1",
358 label: "Julius's iPhone",
359 iosMajorVersion: 18,
360 appVersion: "1.0.0",
361 pushToStartToken: "push-to-start-token",
362 notificationsEnabled: false,
363 preferences: {
364 liveActivitiesEnabled: true,
365 },
366 }),
367 ).toEqual({
368 deviceId: "device-1",
369 label: "Julius's iPhone",
370 platform: "ios",
371 iosMajorVersion: 18,
372 appVersion: "1.0.0",
373 pushToStartToken: "push-to-start-token",
374 preferences: {
375 liveActivitiesEnabled: true,
376 notificationsEnabled: false,
377 notifyOnApproval: true,
378 notifyOnInput: true,
379 notifyOnCompletion: true,
380 notifyOnFailure: true,
381 },
382 });
383 });
384
385 it("overrides persisted preferences for an in-flight registration", () => {
386 expect(
387 mergeAgentAwarenessRegistrationPreferences(
388 { liveActivitiesEnabled: false, baseFontSize: 18 },
389 { liveActivitiesEnabled: true },
390 ),
391 ).toEqual({ liveActivitiesEnabled: true, baseFontSize: 18 });
392 });
393
394 it.effect("registers at most one listener while a Live Activity push token is pending", () => {
395 registerAgentAwarenessConnection(savedConnection());
396 const addPushTokenListener = vi.fn();
397 const activity = {
398 getPushToken: vi.fn(() => Promise.resolve(null)),
399 addPushTokenListener,
400 };
401
402 return Effect.gen(function* () {
403 expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false);
404 expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false);
405
406 expect(activity.getPushToken).toHaveBeenCalledTimes(2);
407 expect(addPushTokenListener).toHaveBeenCalledTimes(1);
408 }).pipe(Effect.provide(layerRelayTest));
409 });
410
411 it.effect("preserves Live Activity push-token lookup failures", () => {
412 const cause = new Error("native token lookup failed");
413 const activity = {
414 getPushToken: vi.fn(() => Promise.reject(cause)),
415 addPushTokenListener: vi.fn(),
416 };
417
418 return Effect.gen(function* () {
419 const error = yield* Effect.flip(
420 registerLiveActivityPushToken({ activity: activity as never }),
421 );
422
423 expect(error).toBeInstanceOf(AgentAwarenessOperationError);
424 expect(error).toMatchObject({
425 _tag: "AgentAwarenessOperationError",
426 operation: "read-live-activity-push-token",
427 cause,
428 message: "Agent awareness operation read-live-activity-push-token failed.",
429 });
430 }).pipe(Effect.provide(layerRelayTest));
431 });
432
433 it.effect(
434 "reports Live Activity token registration as skipped when relay auth is unavailable",
435 () => {
436 registerAgentAwarenessConnection(savedConnection());
437 const activity = {
438 getPushToken: vi.fn(() => Promise.resolve("activity-token")),
439 addPushTokenListener: vi.fn(),
440 };
441
442 return Effect.gen(function* () {
443 expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false);
444 }).pipe(Effect.provide(layerRelayTest));
445 },
446 );
447
448 it.effect(
449 "registers APNS-started Live Activities for relay updates without mutating them locally",
450 () => {
451 const activity = {
452 getPushToken: vi.fn(() => Promise.resolve("activity-token")),
453 addPushTokenListener: vi.fn(),
454 start: vi.fn(),
455 update: vi.fn(),
456 end: vi.fn(),
457 };
458 widgetMocks.getInstances.mockReturnValue([activity] as never);
459 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
460
461 return Effect.gen(function* () {
462 yield* refreshActiveLiveActivityRemoteRegistration();
463
464 expect(activity.getPushToken).toHaveBeenCalled();
465 expect(activity.start).not.toHaveBeenCalled();
466 expect(activity.update).not.toHaveBeenCalled();
467 expect(activity.end).not.toHaveBeenCalled();
468 }).pipe(Effect.provide(layerRelayTest));
469 },
470 );
471
472 it.effect(
473 "re-registers active Live Activity tokens when the app returns to the foreground",
474 () => {
475 const activity = {
476 getPushToken: vi.fn(() => Promise.resolve("activity-token")),
477 addPushTokenListener: vi.fn(),
478 };
479 widgetMocks.getInstances.mockReturnValue([activity] as never);
480 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
481
482 return Effect.gen(function* () {
483 yield* runBackgroundOperations();
484 activity.getPushToken.mockClear();
485
486 expect(appStateMock.listeners).toHaveLength(1);
487 for (const listener of appStateMock.listeners) {
488 listener("background");
489 }
490 yield* runBackgroundOperations();
491 expect(activity.getPushToken).not.toHaveBeenCalled();
492
493 for (const listener of appStateMock.listeners) {
494 listener("active");
495 }
496 yield* runBackgroundOperations();
497 expect(activity.getPushToken).toHaveBeenCalled();
498 }).pipe(Effect.provide(layerRelayTest));
499 },
500 );
501
502 it("ends local Live Activities and stops foreground reconciliation on cloud sign-out", () => {
503 const end = vi.fn(() => Promise.resolve());
504 const activity = {
505 getPushToken: vi.fn(() => Promise.resolve("activity-token")),
506 addPushTokenListener: vi.fn(),
507 end,
508 };
509 widgetMocks.getInstances.mockReturnValue([activity] as never);
510 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
511 expect(appStateMock.listeners).toHaveLength(1);
512
513 setAgentAwarenessRelayTokenProvider(null);
514
515 expect(end).toHaveBeenCalledWith("immediate");
516 expect(appStateMock.listeners).toHaveLength(0);
517 });
518
519 it.effect("refreshes APNs registration for connected environments after settings changes", () => {
520 registerAgentAwarenessConnection(savedConnection());
521 return Effect.gen(function* () {
522 yield* runBackgroundOperations();
523 vi.mocked(Notifications.getDevicePushTokenAsync).mockClear();
524
525 yield* refreshAgentAwarenessRegistration();
526
527 expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1);
528 }).pipe(Effect.provide(layerRelayTest));
529 });
530
531 it.effect("registers the APNs device when cloud auth becomes available", () => {
532 const fetchMock = vi.fn((request: RequestInfo | URL) => {
533 const url = request instanceof Request ? request.url : String(request);
534 return Promise.resolve(
535 Response.json(
536 url.endsWith("/v1/client/dpop-token")
537 ? {
538 access_token: "relay-dpop-token",
539 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
540 token_type: "DPoP",
541 expires_in: 300,
542 scope: "mobile:registration",
543 }
544 : { ok: true },
545 ),
546 );
547 });
548 vi.stubGlobal("fetch", fetchMock);
549 Constants.expoConfig!.extra = {
550 relay: {
551 url: "https://relay.example.test/",
552 },
553 };
554
555 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
556
557 return Effect.gen(function* () {
558 yield* runBackgroundOperations();
559
560 expect(fetchMock).toHaveBeenCalledTimes(2);
561 const [request, init] = fetchMock.mock.calls[1] as unknown as [
562 unknown,
563 RequestInit | undefined,
564 ];
565 const url = request instanceof Request ? request.url : String(request);
566 const method = request instanceof Request ? request.method : init?.method;
567 const headers = request instanceof Request ? request.headers : new Headers(init?.headers);
568 const dpop = headers.get("dpop");
569 expect(url).toBe("https://relay.example.test/v1/mobile/devices");
570 expect(method).toBe("POST");
571 expect(headers.get("authorization")).toBe("DPoP relay-dpop-token");
572 expect(dpop).toEqual(expect.any(String));
573 if (!dpop) {
574 throw new Error("Missing DPoP header.");
575 }
576 expect(
577 verifyDpopProof({
578 proof: dpop,
579 method: "POST",
580 url: "https://relay.example.test/v1/mobile/devices",
581 expectedAccessToken: "relay-dpop-token",
582 nowEpochSeconds: proofIat(dpop),
583 }),
584 ).toMatchObject({ ok: true });
585 expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
586 }).pipe(Effect.provide(layerRelayTest));
587 });
588
589 it.effect("marks registration failed when device registration cannot complete", () => {
590 Constants.expoConfig!.extra = {
591 relay: {
592 url: "https://relay.example.test/",
593 },
594 };
595 vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce(
596 new Error("registration failed"),
597 );
598 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
599
600 return Effect.gen(function* () {
601 // Drive the registration directly so the assertion does not depend on the
602 // background queue draining; refreshAgentAwarenessRegistration swallows the
603 // error but must record the failed status so the settings toggles cannot
604 // read as enabled.
605 yield* refreshAgentAwarenessRegistration();
606 expect(getAgentAwarenessRegistrationStatus()).toBe("failed");
607 }).pipe(Effect.provide(layerRelayTest));
608 });
609
610 it("clears registration status on cloud sign-out", () => {
611 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
612 setAgentAwarenessRelayTokenProvider(null);
613 expect(getAgentAwarenessRegistrationStatus()).toBe("unknown");
614 expect(clearAgentAwarenessRegistrationRecord).toHaveBeenCalled();
615 });
616
617 it("releases the provider without ending activities or clearing the registration", () => {
618 const end = vi.fn(() => Promise.resolve());
619 const activity = {
620 getPushToken: vi.fn(() => Promise.resolve("activity-token")),
621 addPushTokenListener: vi.fn(),
622 end,
623 };
624 widgetMocks.getInstances.mockReturnValue([activity] as never);
625 registrationRecordStore.current = { identity: "", signature: "sig" };
626 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
627 expect(appStateMock.listeners).toHaveLength(1);
628
629 releaseAgentAwarenessRelayTokenProvider();
630
631 expect(appStateMock.listeners).toHaveLength(0);
632 expect(end).not.toHaveBeenCalled();
633 expect(clearAgentAwarenessRegistrationRecord).not.toHaveBeenCalled();
634 expect(registrationRecordStore.current).not.toBeNull();
635 });
636
637 it.effect("resets a pending status to unknown when relay config is missing", () => {
638 // No relay url configured: registration can neither run nor ever succeed,
639 // so the status must not stick at "pending".
640 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
641
642 return Effect.gen(function* () {
643 yield* runBackgroundOperations();
644 expect(getAgentAwarenessRegistrationStatus()).toBe("unknown");
645 }).pipe(Effect.provide(layerRelayTest));
646 });
647
648 it.effect("keeps a registered status when a later refresh fails", () => {
649 Constants.expoConfig!.extra = {
650 relay: {
651 url: "https://relay.example.test/",
652 },
653 };
654 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
655
656 return Effect.gen(function* () {
657 yield* runBackgroundOperations();
658 expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
659
660 // The relay still holds the accepted registration; a transient refresh
661 // failure must not flip the settings toggles off.
662 vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce(
663 new Error("transient failure"),
664 );
665 yield* refreshAgentAwarenessRegistration();
666 expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
667 }).pipe(Effect.provide(layerRelayTest));
668 });
669
670 it.effect("does not re-register the same account when nothing has changed", () => {
671 Constants.expoConfig!.extra = {
672 relay: {
673 url: "https://relay.example.test/",
674 },
675 };
676 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
677
678 return Effect.gen(function* () {
679 yield* refreshAgentAwarenessRegistration();
680 expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
681 expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1);
682 expect(registrationRecordStore.current).not.toBeNull();
683
684 // Second attempt with an identical payload must skip the relay entirely,
685 // so no new registration record is written.
686 vi.mocked(saveAgentAwarenessRegistrationRecord).mockClear();
687 yield* refreshAgentAwarenessRegistration();
688 expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
689 expect(saveAgentAwarenessRegistrationRecord).not.toHaveBeenCalled();
690 }).pipe(Effect.provide(layerRelayTest));
691 });
692
693 it.effect("dedupes rapid activity-token re-registrations within the replay window", () => {
694 // Fetch counts are unreliable here (the module-level relay layer captures
695 // the first test's fetch), so assert on the flow's own seams: a real
696 // registration attempt loads the device id, a deduped one short-circuits
697 // before it.
698 const fetchMock = vi.fn((request: RequestInfo | URL) => {
699 const url = request instanceof Request ? request.url : String(request);
700 return Promise.resolve(
701 Response.json(
702 url.endsWith("/v1/client/dpop-token")
703 ? {
704 access_token: "relay-dpop-token",
705 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
706 token_type: "DPoP",
707 expires_in: 300,
708 scope: "mobile:registration",
709 }
710 : { ok: true },
711 ),
712 );
713 });
714 vi.stubGlobal("fetch", fetchMock);
715 Constants.expoConfig!.extra = {
716 relay: {
717 url: "https://relay.example.test/",
718 },
719 };
720 const activity = {
721 getPushToken: vi.fn(() => Promise.resolve("activity-token")),
722 addPushTokenListener: vi.fn(),
723 };
724 widgetMocks.getInstances.mockReturnValue([activity] as never);
725 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
726
727 return Effect.gen(function* () {
728 // Drains the sign-in refresh, which registers the activity token.
729 yield* runBackgroundOperations();
730 expect(activity.getPushToken).toHaveBeenCalled();
731
732 // A burst refresh (foreground / connection update seconds later) must
733 // dedupe: it reads the token but never proceeds to a registration
734 // attempt (which would load the device id first).
735 vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockClear();
736 yield* refreshActiveLiveActivityRemoteRegistration();
737 expect(loadOrCreateAgentAwarenessDeviceId).not.toHaveBeenCalled();
738 }).pipe(Effect.provide(layerRelayTest));
739 });
740
741 it.effect("re-registers when the stored account identity differs", () => {
742 Constants.expoConfig!.extra = {
743 relay: {
744 url: "https://relay.example.test/",
745 },
746 };
747 registrationRecordStore.current = { identity: "someone-else", signature: "stale" };
748 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
749
750 return Effect.gen(function* () {
751 yield* refreshAgentAwarenessRegistration();
752 expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1);
753 }).pipe(Effect.provide(layerRelayTest));
754 });
755
756 it.effect("coalesces simultaneous sign-in and environment connection registrations", () => {
757 const fetchMock = vi.fn((request: RequestInfo | URL) => {
758 const url = request instanceof Request ? request.url : String(request);
759 return Promise.resolve(
760 Response.json(
761 url.endsWith("/v1/client/dpop-token")
762 ? {
763 access_token: "relay-dpop-token",
764 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
765 token_type: "DPoP",
766 expires_in: 300,
767 scope: "mobile:registration",
768 }
769 : { ok: true },
770 ),
771 );
772 });
773 vi.stubGlobal("fetch", fetchMock);
774 Constants.expoConfig!.extra = {
775 relay: {
776 url: "https://relay.example.test/",
777 },
778 };
779
780 vi.mocked(Notifications.getPermissionsAsync).mockClear();
781 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
782 registerAgentAwarenessConnection(savedConnection());
783
784 return Effect.gen(function* () {
785 yield* runBackgroundOperations();
786 expect(Notifications.getPermissionsAsync).toHaveBeenCalledTimes(1);
787 }).pipe(Effect.provide(layerRelayTest));
788 });
789
790 it.effect("continues queued device registration after a failed auth lookup", () => {
791 Constants.expoConfig!.extra = {
792 relay: {
793 url: "https://relay.example.test/",
794 },
795 };
796
797 const tokenProvider = vi
798 .fn<() => Promise<string | null>>()
799 .mockRejectedValueOnce(new Error("auth unavailable"))
800 .mockResolvedValue("clerk-token-user-a");
801 setAgentAwarenessRelayTokenProvider(tokenProvider);
802 const tokenListener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)?.[0];
803 expect(tokenListener).toBeDefined();
804 tokenListener?.({ type: "ios", data: "rotated-apns-token" } as never);
805
806 return Effect.gen(function* () {
807 yield* runBackgroundOperations();
808
809 expect(backgroundRuntime.pending).toHaveLength(0);
810 expect(tokenProvider).toHaveBeenCalledTimes(2);
811 }).pipe(Effect.provide(layerRelayTest));
812 });
813
814 it("only registers again when the authenticated identity changes", () => {
815 expect(shouldRegisterAgentAwarenessDeviceForProvider(null, "user-a")).toBe(true);
816 expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", "user-a")).toBe(false);
817 expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", "user-b")).toBe(true);
818 expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", undefined)).toBe(true);
819 });
820
821 it.effect("registers rotated APNs tokens without rereading the native token", () => {
822 const fetchMock = vi.fn((request: RequestInfo | URL) => {
823 const url = request instanceof Request ? request.url : String(request);
824 return Promise.resolve(
825 Response.json(
826 url.endsWith("/v1/client/dpop-token")
827 ? {
828 access_token: "relay-dpop-token",
829 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
830 token_type: "DPoP",
831 expires_in: 300,
832 scope: "mobile:registration",
833 }
834 : { ok: true },
835 ),
836 );
837 });
838 vi.stubGlobal("fetch", fetchMock);
839 Constants.expoConfig!.extra = {
840 relay: {
841 url: "https://relay.example.test/",
842 },
843 };
844
845 vi.mocked(Notifications.getDevicePushTokenAsync).mockClear();
846 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
847
848 const tokenListener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)?.[0];
849 expect(tokenListener).toBeDefined();
850 tokenListener?.({ type: "ios", data: "rotated-apns-token" } as never);
851
852 return Effect.gen(function* () {
853 yield* runBackgroundOperations();
854 expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1);
855 }).pipe(Effect.provide(layerRelayTest));
856 });
857
858 it.effect(
859 "keeps the user-scoped relay APNs device when an environment connection is removed",
860 () => {
861 const fetchMock = vi.fn((request: RequestInfo | URL) => {
862 const url = request instanceof Request ? request.url : String(request);
863 return Promise.resolve(
864 Response.json(
865 url.endsWith("/v1/client/dpop-token")
866 ? {
867 access_token: "relay-dpop-token",
868 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
869 token_type: "DPoP",
870 expires_in: 300,
871 scope: "mobile:registration",
872 }
873 : { ok: true },
874 ),
875 );
876 });
877 vi.stubGlobal("fetch", fetchMock);
878 Constants.expoConfig!.extra = {
879 relay: {
880 url: "https://relay.example.test/",
881 },
882 };
883
884 registerAgentAwarenessConnection(savedConnection());
885 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
886 return Effect.gen(function* () {
887 yield* runBackgroundOperations();
888 fetchMock.mockClear();
889
890 unregisterAgentAwarenessConnection(savedConnection().environmentId);
891
892 expect(fetchMock).not.toHaveBeenCalled();
893 }).pipe(Effect.provide(layerRelayTest));
894 },
895 );
896
897 it("skips the Live Activity seed when the environment reports publishing disabled", async () => {
898 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
899 vi.mocked(loadPreferences).mockResolvedValueOnce({
900 liveActivitiesEnabled: true,
901 } as Preferences);
902 environmentConfigsMock.configs.set("env-1", {
903 environment: { capabilities: { agentActivityPublishing: false } },
904 });
905
906 armAgentAwarenessLiveActivityForLocalWork({
907 environmentId: "env-1" as EnvironmentId,
908 threadTitle: "Fix the flaky test",
909 projectTitle: "t3code",
910 });
911 await new Promise((resolve) => setTimeout(resolve, 0));
912
913 expect(widgetMocks.start).not.toHaveBeenCalled();
914 });
915
916 it("seeds the Live Activity for publishing and pre-capability environments", async () => {
917 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
918 environmentConfigsMock.configs.set("env-publishing", {
919 environment: { capabilities: { agentActivityPublishing: true } },
920 });
921
922 vi.mocked(loadPreferences).mockResolvedValueOnce({
923 liveActivitiesEnabled: true,
924 } as Preferences);
925 armAgentAwarenessLiveActivityForLocalWork({
926 environmentId: "env-publishing" as EnvironmentId,
927 threadTitle: "Fix the flaky test",
928 projectTitle: "t3code",
929 });
930 await new Promise((resolve) => setTimeout(resolve, 0));
931 expect(widgetMocks.start).toHaveBeenCalledTimes(1);
932
933 // An environment without the capability may run an older server that
934 // still publishes; only an explicit false skips the seed.
935 widgetMocks.start.mockClear();
936 vi.mocked(loadPreferences).mockResolvedValueOnce({
937 liveActivitiesEnabled: true,
938 } as Preferences);
939 armAgentAwarenessLiveActivityForLocalWork({
940 environmentId: "env-pre-capability" as EnvironmentId,
941 threadTitle: "Fix the flaky test",
942 projectTitle: "t3code",
943 });
944 await new Promise((resolve) => setTimeout(resolve, 0));
945 expect(widgetMocks.start).toHaveBeenCalledTimes(1);
946 });
947 it.effect.each(["ios", "android"] as const)(
948 "does not enable %s notifications when a token rotates after permission is revoked",
949 (os) => {
950 vi.spyOn(Platform, "OS", "get").mockReturnValue(os);
951 vi.spyOn(Platform, "Version", "get").mockReturnValue(os === "ios" ? 18 : 36);
952 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
953 type: os,
954 data: "initial",
955 });
956 const registrations: unknown[] = [];
957 vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
958 const request = new Request(input, init);
959 if (request.url.endsWith("/v1/client/dpop-token")) {
960 return Response.json({
961 access_token: "dpop",
962 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
963 token_type: "DPoP",
964 expires_in: 300,
965 scope: "mobile:registration",
966 });
967 }
968 registrations.push(await request.json());
969 return Response.json({ ok: true });
970 });
971 Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } };
972 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a");
973 return Effect.gen(function* () {
974 yield* runBackgroundOperations();
975 expect(registrations.at(-1)).toMatchObject({
976 preferences: { notificationsEnabled: true },
977 });
978 vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({
979 granted: false,
980 } as Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>);
981 const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0];
982 listener({ type: os, data: "rotated" });
983 yield* runBackgroundOperations();
984 expect(registrations.at(-1)).toMatchObject({
985 preferences: { notificationsEnabled: false },
986 });
987 expect(registrations.at(-1)).not.toHaveProperty("pushToken");
988 }).pipe(
989 Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
990 Effect.provide(
991 ManagedRelayLayer.layer("https://permission-relay.example.test").pipe(
992 Layer.provide(Layer.mergeAll(FetchHttpClient.layer, Dpop.layer)),
993 ),
994 ),
995 );
996 },
997 );
998 it.effect("preserves relay rejection errors with React Native response headers", () => {
999 vi.spyOn(Platform, "OS", "get").mockReturnValue("android");
1000 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
1001 type: "android",
1002 data: "fcm-token",
1003 });
1004 const rejectedResponse = new Response("Unsupported device platform", { status: 400 });
1005 Object.defineProperty(rejectedResponse.headers, "getSetCookie", { value: undefined });
1006 vi.stubGlobal("fetch", (request: RequestInfo | URL) => {
1007 const url = request instanceof Request ? request.url : String(request);
1008 const response = url.endsWith("/v1/client/dpop-token")
1009 ? Response.json({
1010 access_token: "relay-dpop-token",
1011 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
1012 token_type: "DPoP",
1013 expires_in: 300,
1014 scope: "mobile:registration",
1015 })
1016 : rejectedResponse;
1017 Object.defineProperty(response.headers, "getSetCookie", { value: undefined });
1018 return Promise.resolve(response);
1019 });
1020 Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } };
1021 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a");
1022
1023 return Effect.gen(function* () {
1024 // Hermes' compiled error hashing reads the response's cookie getter.
1025 const httpResponse = HttpClientResponse.fromWeb(
1026 HttpClientRequest.post("https://relay.example.test/v1/mobile/devices"),
1027 rejectedResponse,
1028 );
1029 expect(httpResponse.cookies).toEqual(Cookies.empty);
1030 const result = yield* Effect.exit(
1031 updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }),
1032 );
1033 expect(Exit.isFailure(result)).toBe(true);
1034 if (Exit.isFailure(result)) {
1035 expect(Cause.hasDies(result.cause)).toBe(false);
1036 expect(Cause.squash(result.cause)).toBeInstanceOf(
1037 ManagedRelay.ManagedRelayRequestFailedError,
1038 );
1039 }
1040 expect(getAgentAwarenessRegistrationStatus()).toBe("failed");
1041 expect(saveAgentAwarenessRegistrationRecord).not.toHaveBeenCalled();
1042 }).pipe(Effect.provide(layerRelayTest));
1043 });
1044
1045 it.effect("registers an Android FCM token without invoking Apple Live Activities", () => {
1046 vi.spyOn(Platform, "OS", "get").mockReturnValue("android");
1047 vi.spyOn(Platform, "Version", "get").mockReturnValue(36);
1048 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
1049 type: "android",
1050 data: "fcm-token",
1051 });
1052 vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: true } as Preferences);
1053 vi.stubGlobal(
1054 "fetch",
1055 vi.fn((request: RequestInfo | URL) => {
1056 const url = request instanceof Request ? request.url : String(request);
1057 return Promise.resolve(
1058 Response.json(
1059 url.endsWith("/v1/client/dpop-token")
1060 ? {
1061 access_token: "relay-dpop-token",
1062 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
1063 token_type: "DPoP",
1064 expires_in: 300,
1065 scope: "mobile:registration",
1066 }
1067 : { ok: true },
1068 ),
1069 );
1070 }),
1071 );
1072 Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } };
1073 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a");
1074 return Effect.gen(function* () {
1075 yield* refreshAgentAwarenessRegistration();
1076 expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalled();
1077 expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalled();
1078 expect(registrationRecordStore.current?.signature).toContain("fcm-token");
1079 expect(registrationRecordStore.current?.signature).toContain("android");
1080 expect(widgetMocks.getInstances).not.toHaveBeenCalled();
1081 expect(configureAndroidAgentNotifications).toHaveBeenCalledWith("device-1", "user-a", true);
1082 vi.mocked(clearAndroidAgentNotifications).mockClear();
1083 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-b"), "user-b");
1084 expect(clearAndroidAgentNotifications).toHaveBeenCalled();
1085 }).pipe(Effect.provide(layerRelayTest));
1086 });
1087 it.effect(
1088 "preserves same-account Android notifications and replays on remount and later foreground",
1089 () => {
1090 vi.spyOn(Platform, "OS", "get").mockReturnValue("android");
1091 vi.spyOn(Platform, "Version", "get").mockReturnValue(36);
1092 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
1093 type: "android",
1094 data: "fcm-token",
1095 });
1096 Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } };
1097 const now = vi.spyOn(Date, "now").mockReturnValue(1000000);
1098 vi.mocked(clearAndroidAgentNotifications).mockClear();
1099 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a");
1100 return Effect.gen(function* () {
1101 yield* runBackgroundOperations();
1102 expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
1103 expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1);
1104 yield* refreshAgentAwarenessRegistration();
1105 expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1);
1106 releaseAgentAwarenessRelayTokenProvider();
1107 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a");
1108 yield* runBackgroundOperations();
1109 expect(clearAndroidAgentNotifications).not.toHaveBeenCalled();
1110 expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(2);
1111 for (const listener of appStateMock.listeners) listener("active");
1112 yield* runBackgroundOperations();
1113 expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(2);
1114 now.mockReturnValue(1060001);
1115 for (const listener of appStateMock.listeners) listener("active");
1116 yield* runBackgroundOperations();
1117 expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(3);
1118 expect(clearAndroidAgentNotifications).not.toHaveBeenCalled();
1119 expect(widgetMocks.getInstances).not.toHaveBeenCalled();
1120 setAgentAwarenessRelayTokenProvider(null);
1121 expect(clearAndroidAgentNotifications).toHaveBeenCalled();
1122 expect(clearAgentAwarenessRegistrationRecord).toHaveBeenCalled();
1123 }).pipe(Effect.provide(layerRelayTest));
1124 },
1125 );
1126
1127 it.effect(
1128 "does not reconfigure or register a detached Android account from an in-flight operation",
1129 () => {
1130 vi.spyOn(Platform, "OS", "get").mockReturnValue("android");
1131 vi.spyOn(Platform, "Version", "get").mockReturnValue(36);
1132 Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } };
1133 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a");
1134 releaseAgentAwarenessRelayTokenProvider();
1135 vi.mocked(configureAndroidAgentNotifications).mockClear();
1136 return Effect.gen(function* () {
1137 yield* runBackgroundOperations();
1138 expect(configureAndroidAgentNotifications).not.toHaveBeenCalled();
1139 expect(saveAgentAwarenessRegistrationRecord).not.toHaveBeenCalled();
1140 }).pipe(Effect.provide(layerRelayTest));
1141 },
1142 );
1143});