| 1 | /// <reference types="node" /> |
| 2 | |
| 3 | import * as NodeCrypto from "node:crypto"; |
| 4 | |
| 5 | import { beforeEach, vi } from "vite-plus/test"; |
| 6 | import { describe, expect, it } from "@effect/vitest"; |
| 7 | import Constants from "expo-constants"; |
| 8 | import * as Cause from "effect/Cause"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Exit from "effect/Exit"; |
| 11 | import * as Layer from "effect/Layer"; |
| 12 | import { Cookies, FetchHttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; |
| 13 | import { ManagedRelay } from "@t3tools/client-runtime/relay"; |
| 14 | |
| 15 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 16 | import { verifyDpopProof } from "@t3tools/shared/dpop"; |
| 17 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 18 | import * as Dpop from "../cloud/dpop"; |
| 19 | import * as ManagedRelayLayer from "../cloud/managedRelayLayer"; |
| 20 | import { |
| 21 | clearAgentAwarenessRegistrationRecord, |
| 22 | loadAgentAwarenessRegistrationRecord, |
| 23 | loadOrCreateAgentAwarenessDeviceId, |
| 24 | loadPreferences, |
| 25 | saveAgentAwarenessRegistrationRecord, |
| 26 | } from "../../persistence/imperative"; |
| 27 | import type { Preferences } from "../../persistence/mobile-preferences"; |
| 28 | import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; |
| 29 | import { |
| 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"; |
| 45 | import * as Notifications from "expo-notifications"; |
| 46 | import { Platform } from "react-native"; |
| 47 | import { |
| 48 | configureAndroidAgentNotifications, |
| 49 | clearAndroidAgentNotifications, |
| 50 | } from "./androidNotifications"; |
| 51 | |
| 52 | vi.mock("./androidNotifications", () => ({ |
| 53 | supportsAndroidAgentNotifications: vi.fn(() => true), |
| 54 | configureAndroidAgentNotifications: vi.fn(), |
| 55 | clearAndroidAgentNotifications: vi.fn(), |
| 56 | })); |
| 57 | |
| 58 | const secureStore = vi.hoisted(() => new Map<string, string>()); |
| 59 | const widgetMocks = vi.hoisted(() => ({ |
| 60 | getInstances: vi.fn(() => []), |
| 61 | start: vi.fn(() => ({})), |
| 62 | })); |
| 63 | const environmentConfigsMock = vi.hoisted(() => ({ |
| 64 | configs: new Map< |
| 65 | string, |
| 66 | { environment: { capabilities: { agentActivityPublishing?: boolean } } } |
| 67 | >(), |
| 68 | })); |
| 69 | const backgroundRuntime = vi.hoisted(() => ({ |
| 70 | pending: [] as Array<{ |
| 71 | readonly operation: unknown; |
| 72 | readonly resolve: (exit: Exit.Exit<unknown, unknown>) => void; |
| 73 | }>, |
| 74 | })); |
| 75 | const appStateMock = vi.hoisted(() => ({ |
| 76 | listeners: [] as Array<(state: string) => void>, |
| 77 | })); |
| 78 | const registrationRecordStore = vi.hoisted(() => ({ |
| 79 | current: null as { |
| 80 | readonly identity: string; |
| 81 | readonly signature: string; |
| 82 | readonly pushToStartToken?: string; |
| 83 | } | null, |
| 84 | })); |
| 85 | |
| 86 | vi.mock("expo-constants", () => ({ |
| 87 | default: { |
| 88 | expoConfig: { |
| 89 | version: "1.0.0", |
| 90 | extra: {}, |
| 91 | }, |
| 92 | }, |
| 93 | })); |
| 94 | |
| 95 | vi.mock("expo-widgets", () => ({ |
| 96 | addPushToStartTokenListener: vi.fn(() => ({ remove: vi.fn() })), |
| 97 | })); |
| 98 | |
| 99 | vi.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. |
| 106 | vi.mock("../../state/atom-registry", () => ({ |
| 107 | appAtomRegistry: { |
| 108 | get: () => environmentConfigsMock.configs, |
| 109 | }, |
| 110 | })); |
| 111 | |
| 112 | vi.mock("../../state/server", () => ({ |
| 113 | environmentServerConfigsAtom: Symbol("environmentServerConfigsAtom"), |
| 114 | })); |
| 115 | |
| 116 | vi.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 | |
| 122 | vi.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 | |
| 142 | vi.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 | |
| 150 | vi.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 | |
| 174 | vi.mock("../../lib/runtime", () => ({ |
| 175 | runtime: { |
| 176 | runPromiseExit: (operation: unknown) => |
| 177 | new Promise((resolve) => { |
| 178 | backgroundRuntime.pending.push({ operation, resolve }); |
| 179 | }), |
| 180 | }, |
| 181 | })); |
| 182 | |
| 183 | vi.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 | |
| 200 | function 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 | |
| 211 | function 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 | |
| 223 | const layerRelayTest = ManagedRelayLayer.layer("https://relay.example.test").pipe( |
| 224 | Layer.provide(Layer.mergeAll(FetchHttpClient.layer, Dpop.layer)), |
| 225 | ); |
| 226 | |
| 227 | const 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 | |
| 251 | describe("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 | }); |