| 1 | // @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync. |
| 2 | import * as NodeCrypto from "node:crypto"; |
| 3 | |
| 4 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 5 | import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; |
| 6 | import { describe, expect, it } from "@effect/vitest"; |
| 7 | import * as DateTime from "effect/DateTime"; |
| 8 | import * as Deferred from "effect/Deferred"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Fiber from "effect/Fiber"; |
| 11 | import * as Layer from "effect/Layer"; |
| 12 | import * as Redacted from "effect/Redacted"; |
| 13 | import * as Schema from "effect/Schema"; |
| 14 | import * as TestClock from "effect/testing/TestClock"; |
| 15 | import * as HttpClient from "effect/http/HttpClient"; |
| 16 | import * as HttpClientError from "effect/http/HttpClientError"; |
| 17 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 18 | |
| 19 | import type { ApnsCredentials } from "../Config.ts"; |
| 20 | import * as ApnsClient from "./ApnsClient.ts"; |
| 21 | import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; |
| 22 | |
| 23 | const isApnsJwtSigningError = Schema.is(ApnsClient.ApnsJwtSigningError); |
| 24 | const isApnsHttpRequestError = Schema.is(ApnsClient.ApnsHttpRequestError); |
| 25 | |
| 26 | const layerTest = ApnsClient.layer.pipe( |
| 27 | Layer.provide(ApnsProviderTokens.layer), |
| 28 | Layer.provide( |
| 29 | Layer.succeed( |
| 30 | HttpClient.HttpClient, |
| 31 | HttpClient.make(() => Effect.die("unexpected APNs HTTP request")), |
| 32 | ), |
| 33 | ), |
| 34 | ); |
| 35 | |
| 36 | describe("ApnsClient", () => { |
| 37 | const now = DateTime.makeUnsafe(0); |
| 38 | const state: RelayAgentActivityAggregateState = { |
| 39 | title: "T3 Code", |
| 40 | subtitle: "Agent work in progress", |
| 41 | activeCount: 1, |
| 42 | updatedAt: DateTime.formatIso(now), |
| 43 | activities: [ |
| 44 | { |
| 45 | environmentId: EnvironmentId.make("env"), |
| 46 | threadId: ThreadId.make("thread"), |
| 47 | projectTitle: "Project", |
| 48 | threadTitle: "Thread", |
| 49 | modelTitle: "gpt-5.4", |
| 50 | phase: "running" as const, |
| 51 | status: "Working", |
| 52 | updatedAt: DateTime.formatIso(now), |
| 53 | deepLink: "/", |
| 54 | }, |
| 55 | ], |
| 56 | }; |
| 57 | |
| 58 | it.effect("requests an update push token when remotely starting a Live Activity", () => |
| 59 | Effect.gen(function* () { |
| 60 | const apns = yield* ApnsClient.ApnsClient; |
| 61 | const request = apns.makeLiveActivityRequest({ |
| 62 | event: "start", |
| 63 | token: "token", |
| 64 | state, |
| 65 | nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), |
| 66 | nowIso: DateTime.formatIso(now), |
| 67 | }); |
| 68 | |
| 69 | expect(request.priority).toBe("10"); |
| 70 | expect(request.payload).toMatchObject({ |
| 71 | aps: { |
| 72 | event: "start", |
| 73 | "attributes-type": "LiveActivityAttributes", |
| 74 | "input-push-token": 1, |
| 75 | "content-state": { |
| 76 | name: "AgentActivity", |
| 77 | }, |
| 78 | }, |
| 79 | }); |
| 80 | }).pipe(Effect.provide(layerTest)), |
| 81 | ); |
| 82 | |
| 83 | it.effect("builds a low-priority update payload", () => |
| 84 | Effect.gen(function* () { |
| 85 | const apns = yield* ApnsClient.ApnsClient; |
| 86 | const request = apns.makeLiveActivityRequest({ |
| 87 | event: "update", |
| 88 | token: "token", |
| 89 | state, |
| 90 | nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), |
| 91 | nowIso: DateTime.formatIso(now), |
| 92 | }); |
| 93 | |
| 94 | expect(request.priority).toBe("5"); |
| 95 | expect(request.payload).toMatchObject({ |
| 96 | aps: { |
| 97 | event: "update", |
| 98 | "content-state": { |
| 99 | name: "AgentActivity", |
| 100 | }, |
| 101 | }, |
| 102 | }); |
| 103 | }).pipe(Effect.provide(layerTest)), |
| 104 | ); |
| 105 | |
| 106 | it.effect("builds a high-priority alerting update payload when an alert is attached", () => |
| 107 | Effect.gen(function* () { |
| 108 | const apns = yield* ApnsClient.ApnsClient; |
| 109 | const request = apns.makeLiveActivityRequest({ |
| 110 | event: "update", |
| 111 | token: "token", |
| 112 | state, |
| 113 | alert: { title: "Thread", body: "Approval: Project" }, |
| 114 | nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), |
| 115 | nowIso: DateTime.formatIso(now), |
| 116 | }); |
| 117 | |
| 118 | expect(request.priority).toBe("10"); |
| 119 | expect(request.payload).toMatchObject({ |
| 120 | aps: { |
| 121 | event: "update", |
| 122 | alert: { |
| 123 | title: "Thread", |
| 124 | body: "Approval: Project", |
| 125 | sound: "default", |
| 126 | }, |
| 127 | }, |
| 128 | }); |
| 129 | }).pipe(Effect.provide(layerTest)), |
| 130 | ); |
| 131 | |
| 132 | it.effect("builds an end payload with a dismissal date", () => |
| 133 | Effect.gen(function* () { |
| 134 | const apns = yield* ApnsClient.ApnsClient; |
| 135 | const request = apns.makeLiveActivityRequest({ |
| 136 | event: "end", |
| 137 | token: "token", |
| 138 | state, |
| 139 | nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), |
| 140 | nowIso: DateTime.formatIso(now), |
| 141 | }); |
| 142 | |
| 143 | expect(request.priority).toBe("10"); |
| 144 | expect(request.payload).toMatchObject({ |
| 145 | aps: { |
| 146 | event: "end", |
| 147 | "dismissal-date": 300, |
| 148 | }, |
| 149 | }); |
| 150 | |
| 151 | // Without final content the card would freeze on its previous state; |
| 152 | // contentless ends dismiss quickly instead. |
| 153 | const contentless = apns.makeLiveActivityRequest({ |
| 154 | event: "end", |
| 155 | token: "token", |
| 156 | state: null, |
| 157 | nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), |
| 158 | nowIso: DateTime.formatIso(now), |
| 159 | }); |
| 160 | expect(contentless.payload).toMatchObject({ |
| 161 | aps: { |
| 162 | event: "end", |
| 163 | "dismissal-date": 15, |
| 164 | }, |
| 165 | }); |
| 166 | }).pipe(Effect.provide(layerTest)), |
| 167 | ); |
| 168 | |
| 169 | it.effect("builds a standard APNs alert payload with routing metadata", () => |
| 170 | Effect.gen(function* () { |
| 171 | const apns = yield* ApnsClient.ApnsClient; |
| 172 | const request = apns.makePushNotificationRequest({ |
| 173 | token: "push-token", |
| 174 | notification: { |
| 175 | title: "Thread", |
| 176 | body: "Input: Project", |
| 177 | environmentId: "env", |
| 178 | threadId: "thread", |
| 179 | deepLink: "/threads/env/thread", |
| 180 | }, |
| 181 | }); |
| 182 | |
| 183 | expect(request.priority).toBe("10"); |
| 184 | expect(request.payload).toMatchObject({ |
| 185 | aps: { |
| 186 | alert: { |
| 187 | title: "Thread", |
| 188 | body: "Input: Project", |
| 189 | }, |
| 190 | sound: "default", |
| 191 | "thread-id": "env/thread", |
| 192 | }, |
| 193 | environmentId: "env", |
| 194 | threadId: "thread", |
| 195 | deepLink: "/threads/env/thread", |
| 196 | }); |
| 197 | }).pipe(Effect.provide(layerTest)), |
| 198 | ); |
| 199 | |
| 200 | it.effect("preserves JWT signing context and the crypto cause", () => |
| 201 | Effect.gen(function* () { |
| 202 | const apns = yield* ApnsClient.ApnsClient; |
| 203 | const request = apns.makePushNotificationRequest({ |
| 204 | token: "push-token", |
| 205 | notification: { |
| 206 | title: "Thread", |
| 207 | body: "Input: Project", |
| 208 | environmentId: "env", |
| 209 | threadId: "thread", |
| 210 | deepLink: "/threads/env/thread", |
| 211 | }, |
| 212 | }); |
| 213 | const error = yield* Effect.flip( |
| 214 | apns.sendPushNotificationRequest({ |
| 215 | credentials: { |
| 216 | teamId: "team-1", |
| 217 | keyId: "key-1", |
| 218 | privateKey: Redacted.make("not-a-private-key"), |
| 219 | bundleId: "com.t3tools.test", |
| 220 | environment: "sandbox", |
| 221 | }, |
| 222 | request, |
| 223 | issuedAtUnixSeconds: 123, |
| 224 | }), |
| 225 | ); |
| 226 | |
| 227 | expect(isApnsJwtSigningError(error)).toBe(true); |
| 228 | if (!isApnsJwtSigningError(error)) { |
| 229 | return yield* Effect.die("expected APNs JWT signing error"); |
| 230 | } |
| 231 | expect(error).toMatchObject({ |
| 232 | teamId: "team-1", |
| 233 | keyId: "key-1", |
| 234 | // The provider-token service quantizes iat to the reuse window, so |
| 235 | // the signing context carries the window start rather than the raw |
| 236 | // request time. |
| 237 | issuedAtUnixSeconds: 0, |
| 238 | cause: expect.any(Error), |
| 239 | message: "Failed to sign APNs JWT for key key-1.", |
| 240 | }); |
| 241 | }).pipe(Effect.provide(layerTest)), |
| 242 | ); |
| 243 | |
| 244 | it.effect("preserves APNs request context and the HTTP cause", () => { |
| 245 | const httpCause = new Error("network unavailable"); |
| 246 | const { privateKey } = NodeCrypto.generateKeyPairSync("ec", { |
| 247 | namedCurve: "prime256v1", |
| 248 | privateKeyEncoding: { type: "pkcs8", format: "pem" }, |
| 249 | publicKeyEncoding: { type: "spki", format: "pem" }, |
| 250 | }); |
| 251 | const credentials = { |
| 252 | teamId: "team-1", |
| 253 | keyId: "key-1", |
| 254 | privateKey: Redacted.make(privateKey), |
| 255 | bundleId: "com.t3tools.test", |
| 256 | environment: "sandbox", |
| 257 | } satisfies ApnsCredentials; |
| 258 | const failingHttpClient = HttpClient.make((request) => |
| 259 | Effect.fail( |
| 260 | new HttpClientError.HttpClientError({ |
| 261 | reason: new HttpClientError.TransportError({ request, cause: httpCause }), |
| 262 | }), |
| 263 | ), |
| 264 | ); |
| 265 | const layer = ApnsClient.layer.pipe( |
| 266 | Layer.provide(ApnsProviderTokens.layer), |
| 267 | Layer.provide(Layer.succeed(HttpClient.HttpClient, failingHttpClient)), |
| 268 | ); |
| 269 | |
| 270 | return Effect.gen(function* () { |
| 271 | const apns = yield* ApnsClient.ApnsClient; |
| 272 | const request = apns.makePushNotificationRequest({ |
| 273 | token: "long-push-token", |
| 274 | notification: { |
| 275 | title: "Thread", |
| 276 | body: "Input: Project", |
| 277 | environmentId: "env", |
| 278 | threadId: "thread", |
| 279 | deepLink: "/threads/env/thread", |
| 280 | }, |
| 281 | }); |
| 282 | const error = yield* Effect.flip( |
| 283 | apns.sendPushNotificationRequest({ |
| 284 | credentials, |
| 285 | request, |
| 286 | issuedAtUnixSeconds: 123, |
| 287 | }), |
| 288 | ); |
| 289 | |
| 290 | expect(isApnsHttpRequestError(error)).toBe(true); |
| 291 | if (!isApnsHttpRequestError(error)) { |
| 292 | return yield* Effect.die("expected APNs HTTP request error"); |
| 293 | } |
| 294 | expect(error).toMatchObject({ |
| 295 | requestKind: "push-notification", |
| 296 | event: null, |
| 297 | environment: "sandbox", |
| 298 | bundleId: "com.t3tools.test", |
| 299 | tokenSuffix: "sh-token", |
| 300 | stage: "send", |
| 301 | status: null, |
| 302 | message: "APNs push-notification request failed during send in sandbox.", |
| 303 | }); |
| 304 | expect(error.cause).toBeInstanceOf(HttpClientError.HttpClientError); |
| 305 | expect((error.cause as HttpClientError.HttpClientError).reason).toMatchObject({ |
| 306 | _tag: "TransportError", |
| 307 | cause: httpCause, |
| 308 | }); |
| 309 | }).pipe(Effect.provide(layer)); |
| 310 | }); |
| 311 | |
| 312 | it.effect("reuses the signed provider JWT across pushes within the reuse window", () => { |
| 313 | ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); |
| 314 | const { privateKey } = NodeCrypto.generateKeyPairSync("ec", { |
| 315 | namedCurve: "prime256v1", |
| 316 | privateKeyEncoding: { type: "pkcs8", format: "pem" }, |
| 317 | publicKeyEncoding: { type: "spki", format: "pem" }, |
| 318 | }); |
| 319 | const credentials = { |
| 320 | teamId: "team-jwt-cache", |
| 321 | keyId: "key-jwt-cache", |
| 322 | privateKey: Redacted.make(privateKey), |
| 323 | bundleId: "com.t3tools.test", |
| 324 | environment: "sandbox", |
| 325 | } satisfies ApnsCredentials; |
| 326 | const authorizations: Array<string> = []; |
| 327 | const capturingHttpClient = HttpClient.make((request) => { |
| 328 | authorizations.push(request.headers.authorization ?? ""); |
| 329 | return Effect.succeed( |
| 330 | HttpClientResponse.fromWeb( |
| 331 | request, |
| 332 | new Response("", { status: 200, headers: { "apns-id": "apns-id-1" } }), |
| 333 | ), |
| 334 | ); |
| 335 | }); |
| 336 | const layer = ApnsClient.layer.pipe( |
| 337 | Layer.provide(ApnsProviderTokens.layer), |
| 338 | Layer.provide(Layer.succeed(HttpClient.HttpClient, capturingHttpClient)), |
| 339 | ); |
| 340 | |
| 341 | return Effect.gen(function* () { |
| 342 | const apns = yield* ApnsClient.ApnsClient; |
| 343 | const request = apns.makePushNotificationRequest({ |
| 344 | token: "push-token", |
| 345 | notification: { |
| 346 | title: "Thread", |
| 347 | body: "Input: Project", |
| 348 | environmentId: "env", |
| 349 | threadId: "thread", |
| 350 | deepLink: "/threads/env/thread", |
| 351 | }, |
| 352 | }); |
| 353 | const send = (issuedAtUnixSeconds: number) => |
| 354 | apns.sendPushNotificationRequest({ credentials, request, issuedAtUnixSeconds }); |
| 355 | |
| 356 | const window = ApnsProviderTokens.APNS_JWT_REUSE_SECONDS; |
| 357 | yield* send(window + 10); |
| 358 | yield* send(window * 2 - 1); |
| 359 | yield* send(window * 2); |
| 360 | |
| 361 | expect(authorizations).toHaveLength(3); |
| 362 | // Within the 45-minute window APNs must see the byte-identical token; |
| 363 | // refreshing it per push trips TooManyProviderTokenUpdates. |
| 364 | expect(authorizations[1]).toBe(authorizations[0]); |
| 365 | expect(authorizations[2]).not.toBe(authorizations[0]); |
| 366 | ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); |
| 367 | }).pipe(Effect.provide(layer)); |
| 368 | }); |
| 369 | |
| 370 | it.effect.each( |
| 371 | (["live-activity", "push-notification"] as const).flatMap((requestKind) => |
| 372 | (["send", "read-response"] as const).map((stage) => ({ requestKind, stage })), |
| 373 | ), |
| 374 | )("aborts a stalled $requestKind $stage after ten seconds", ({ requestKind, stage }) => |
| 375 | Effect.gen(function* () { |
| 376 | const started = yield* Deferred.make<void>(); |
| 377 | const signals: AbortSignal[] = []; |
| 378 | const stalledHttpClient = HttpClient.make((request, _url, signal) => { |
| 379 | signals.push(signal); |
| 380 | const stall = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)); |
| 381 | if (stage === "send") return stall; |
| 382 | const response = HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); |
| 383 | Object.defineProperty(response, "text", { value: stall }); |
| 384 | return Effect.succeed(response); |
| 385 | }); |
| 386 | const layer = ApnsClient.layer.pipe( |
| 387 | Layer.provide(Layer.succeed(HttpClient.HttpClient, stalledHttpClient)), |
| 388 | Layer.provide( |
| 389 | Layer.succeed(ApnsProviderTokens.ApnsProviderTokens, { |
| 390 | getJwt: () => Effect.succeed("test-jwt"), |
| 391 | }), |
| 392 | ), |
| 393 | ); |
| 394 | const apns = yield* ApnsClient.ApnsClient.pipe(Effect.provide(layer)); |
| 395 | const credentials = { |
| 396 | teamId: "team-timeout", |
| 397 | keyId: "key-timeout", |
| 398 | privateKey: Redacted.make("unused-test-key"), |
| 399 | bundleId: "com.t3tools.test", |
| 400 | environment: "sandbox", |
| 401 | } satisfies ApnsCredentials; |
| 402 | const send = |
| 403 | requestKind === "live-activity" |
| 404 | ? apns.sendLiveActivityRequest({ |
| 405 | credentials, |
| 406 | issuedAtUnixSeconds: 123, |
| 407 | request: apns.makeLiveActivityRequest({ |
| 408 | event: "update", |
| 409 | token: "long-push-token", |
| 410 | state, |
| 411 | nowEpochSeconds: 123, |
| 412 | nowIso: DateTime.formatIso(now), |
| 413 | }), |
| 414 | }) |
| 415 | : apns.sendPushNotificationRequest({ |
| 416 | credentials, |
| 417 | issuedAtUnixSeconds: 123, |
| 418 | request: apns.makePushNotificationRequest({ |
| 419 | token: "long-push-token", |
| 420 | notification: { |
| 421 | title: "Thread", |
| 422 | body: "Done", |
| 423 | environmentId: "env", |
| 424 | threadId: "thread", |
| 425 | deepLink: "/", |
| 426 | }, |
| 427 | }), |
| 428 | }); |
| 429 | const fiber = yield* send.pipe(Effect.flip, Effect.forkChild); |
| 430 | yield* Deferred.await(started); |
| 431 | yield* TestClock.adjust("10 seconds"); |
| 432 | expect(signals[0]?.aborted).toBe(true); |
| 433 | const error = yield* Fiber.join(fiber); |
| 434 | expect(error).toMatchObject({ |
| 435 | _tag: "ApnsHttpRequestError", |
| 436 | requestKind, |
| 437 | event: requestKind === "live-activity" ? "update" : null, |
| 438 | stage, |
| 439 | status: stage === "read-response" ? 200 : null, |
| 440 | tokenSuffix: "sh-token", |
| 441 | cause: { _tag: "TimeoutError" }, |
| 442 | }); |
| 443 | }), |
| 444 | ); |
| 445 | }); |