From d4f36fe0be840e65e977488d1f1a946a98592d21 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 00:11:03 +0000 Subject: [PATCH] test: extract apns auth helper coverage --- src/infra/push-apns.auth.test.ts | 182 +++++++++++++++++++++++++++++++ src/infra/push-apns.test.ts | 181 ------------------------------ 2 files changed, 182 insertions(+), 181 deletions(-) create mode 100644 src/infra/push-apns.auth.test.ts diff --git a/src/infra/push-apns.auth.test.ts b/src/infra/push-apns.auth.test.ts new file mode 100644 index 00000000000..2dd832b4b28 --- /dev/null +++ b/src/infra/push-apns.auth.test.ts @@ -0,0 +1,182 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + normalizeApnsEnvironment, + resolveApnsAuthConfigFromEnv, + shouldClearStoredApnsRegistration, + shouldInvalidateApnsRegistration, +} from "./push-apns.js"; + +const tempDirs: string[] = []; + +async function makeTempDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-auth-test-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + await fs.rm(dir, { recursive: true, force: true }); + } + } +}); + +describe("push APNs auth and helper coverage", () => { + it("normalizes APNs environment values", () => { + expect(normalizeApnsEnvironment("sandbox")).toBe("sandbox"); + expect(normalizeApnsEnvironment(" PRODUCTION ")).toBe("production"); + expect(normalizeApnsEnvironment("staging")).toBeNull(); + expect(normalizeApnsEnvironment(null)).toBeNull(); + }); + + it("prefers inline APNs private key values and unescapes newlines", async () => { + const resolved = await resolveApnsAuthConfigFromEnv({ + OPENCLAW_APNS_TEAM_ID: "TEAM123", + OPENCLAW_APNS_KEY_ID: "KEY123", + OPENCLAW_APNS_PRIVATE_KEY_P8: + "-----BEGIN PRIVATE KEY-----\\nline-a\\nline-b\\n-----END PRIVATE KEY-----", // pragma: allowlist secret + OPENCLAW_APNS_PRIVATE_KEY: "ignored", + } as NodeJS.ProcessEnv); + + expect(resolved).toMatchObject({ + ok: true, + value: { + teamId: "TEAM123", + keyId: "KEY123", + }, + }); + if (resolved.ok) { + expect(resolved.value.privateKey).toContain("\nline-a\n"); + expect(resolved.value.privateKey).not.toBe("ignored"); + } + }); + + it("falls back to OPENCLAW_APNS_PRIVATE_KEY when OPENCLAW_APNS_PRIVATE_KEY_P8 is blank", async () => { + const resolved = await resolveApnsAuthConfigFromEnv({ + OPENCLAW_APNS_TEAM_ID: "TEAM123", + OPENCLAW_APNS_KEY_ID: "KEY123", + OPENCLAW_APNS_PRIVATE_KEY_P8: " ", + OPENCLAW_APNS_PRIVATE_KEY: + "-----BEGIN PRIVATE KEY-----\\nline-c\\nline-d\\n-----END PRIVATE KEY-----", // pragma: allowlist secret + } as NodeJS.ProcessEnv); + + expect(resolved).toMatchObject({ + ok: true, + value: { + teamId: "TEAM123", + keyId: "KEY123", + privateKey: "-----BEGIN PRIVATE KEY-----\nline-c\nline-d\n-----END PRIVATE KEY-----", + }, + }); + }); + + it("reads APNs private keys from OPENCLAW_APNS_PRIVATE_KEY_PATH", async () => { + const dir = await makeTempDir(); + const keyPath = path.join(dir, "apns-key.p8"); + await fs.writeFile( + keyPath, + "-----BEGIN PRIVATE KEY-----\\nline-e\\nline-f\\n-----END PRIVATE KEY-----\n", + "utf8", + ); + + const resolved = await resolveApnsAuthConfigFromEnv({ + OPENCLAW_APNS_TEAM_ID: "TEAM123", + OPENCLAW_APNS_KEY_ID: "KEY123", + OPENCLAW_APNS_PRIVATE_KEY_PATH: keyPath, + } as NodeJS.ProcessEnv); + + expect(resolved).toMatchObject({ + ok: true, + value: { + teamId: "TEAM123", + keyId: "KEY123", + privateKey: "-----BEGIN PRIVATE KEY-----\nline-e\nline-f\n-----END PRIVATE KEY-----", + }, + }); + }); + + it("reports missing auth fields and path read failures", async () => { + const dir = await makeTempDir(); + const missingPath = path.join(dir, "missing-key.p8"); + + await expect(resolveApnsAuthConfigFromEnv({} as NodeJS.ProcessEnv)).resolves.toEqual({ + ok: false, + error: "APNs auth missing: set OPENCLAW_APNS_TEAM_ID and OPENCLAW_APNS_KEY_ID", + }); + + const missingKey = await resolveApnsAuthConfigFromEnv({ + OPENCLAW_APNS_TEAM_ID: "TEAM123", + OPENCLAW_APNS_KEY_ID: "KEY123", + OPENCLAW_APNS_PRIVATE_KEY_PATH: missingPath, + } as NodeJS.ProcessEnv); + + expect(missingKey.ok).toBe(false); + if (!missingKey.ok) { + expect(missingKey.error).toContain( + `failed reading OPENCLAW_APNS_PRIVATE_KEY_PATH (${missingPath})`, + ); + } + }); + + it("invalidates only real bad-token APNs failures", () => { + expect(shouldInvalidateApnsRegistration({ status: 410, reason: "Unregistered" })).toBe(true); + expect(shouldInvalidateApnsRegistration({ status: 400, reason: " BadDeviceToken " })).toBe( + true, + ); + expect(shouldInvalidateApnsRegistration({ status: 400, reason: "BadTopic" })).toBe(false); + expect(shouldInvalidateApnsRegistration({ status: 429, reason: "BadDeviceToken" })).toBe(false); + }); + + it("clears only direct registrations without an environment override mismatch", () => { + expect( + shouldClearStoredApnsRegistration({ + registration: { + nodeId: "ios-node-direct", + transport: "direct", + token: "ABCD1234ABCD1234ABCD1234ABCD1234", + topic: "ai.openclaw.ios", + environment: "sandbox", + updatedAtMs: 1, + }, + result: { status: 400, reason: "BadDeviceToken" }, + }), + ).toBe(true); + + expect( + shouldClearStoredApnsRegistration({ + registration: { + nodeId: "ios-node-relay", + transport: "relay", + relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", + installationId: "install-123", + topic: "ai.openclaw.ios", + environment: "production", + distribution: "official", + updatedAtMs: 1, + }, + result: { status: 410, reason: "Unregistered" }, + }), + ).toBe(false); + + expect( + shouldClearStoredApnsRegistration({ + registration: { + nodeId: "ios-node-direct", + transport: "direct", + token: "ABCD1234ABCD1234ABCD1234ABCD1234", + topic: "ai.openclaw.ios", + environment: "sandbox", + updatedAtMs: 1, + }, + result: { status: 400, reason: "BadDeviceToken" }, + overrideEnvironment: "production", + }), + ).toBe(false); + }); +}); diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index 5abaa4556ea..89e8fb319f6 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -3,44 +3,20 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { - deriveDeviceIdFromPublicKey, - publicKeyRawBase64UrlFromPem, - verifyDeviceSignature, -} from "./device-identity.js"; import { clearApnsRegistration, clearApnsRegistrationIfCurrent, loadApnsRegistration, - normalizeApnsEnvironment, registerApnsRegistration, registerApnsToken, - resolveApnsAuthConfigFromEnv, sendApnsAlert, sendApnsBackgroundWake, - shouldClearStoredApnsRegistration, - shouldInvalidateApnsRegistration, } from "./push-apns.js"; const tempDirs: string[] = []; const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" }) .privateKey.export({ format: "pem", type: "pkcs8" }) .toString(); -const relayGatewayIdentity = (() => { - const { publicKey, privateKey } = generateKeyPairSync("ed25519"); - const publicKeyPem = publicKey.export({ format: "pem", type: "spki" }).toString(); - const publicKeyRaw = publicKeyRawBase64UrlFromPem(publicKeyPem); - const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw); - if (!deviceId) { - throw new Error("failed to derive test gateway device id"); - } - return { - deviceId, - publicKey: publicKeyRaw, - privateKeyPem: privateKey.export({ format: "pem", type: "pkcs8" }).toString(), - }; -})(); - async function makeTempDir(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-test-")); tempDirs.push(dir); @@ -297,40 +273,6 @@ describe("push APNs registration store", () => { }); }); -describe("push APNs env config", () => { - it("normalizes APNs environment values", () => { - expect(normalizeApnsEnvironment("sandbox")).toBe("sandbox"); - expect(normalizeApnsEnvironment("PRODUCTION")).toBe("production"); - expect(normalizeApnsEnvironment("staging")).toBeNull(); - }); - - it("resolves inline private key and unescapes newlines", async () => { - const env = { - OPENCLAW_APNS_TEAM_ID: "TEAM123", - OPENCLAW_APNS_KEY_ID: "KEY123", - OPENCLAW_APNS_PRIVATE_KEY_P8: - "-----BEGIN PRIVATE KEY-----\\nline-a\\nline-b\\n-----END PRIVATE KEY-----", // pragma: allowlist secret - } as NodeJS.ProcessEnv; - const resolved = await resolveApnsAuthConfigFromEnv(env); - expect(resolved.ok).toBe(true); - if (!resolved.ok) { - return; - } - expect(resolved.value.privateKey).toContain("\nline-a\n"); - expect(resolved.value.teamId).toBe("TEAM123"); - expect(resolved.value.keyId).toBe("KEY123"); - }); - - it("returns an error when required APNs auth vars are missing", async () => { - const resolved = await resolveApnsAuthConfigFromEnv({} as NodeJS.ProcessEnv); - expect(resolved.ok).toBe(false); - if (resolved.ok) { - return; - } - expect(resolved.error).toContain("OPENCLAW_APNS_TEAM_ID"); - }); -}); - describe("push APNs send semantics", () => { it("sends alert pushes with alert headers and payload", async () => { const { send, registration, auth } = createDirectApnsSendFixture({ @@ -440,127 +382,4 @@ describe("push APNs send semantics", () => { }, }); }); - - it("routes relay-backed alert pushes through the relay sender", async () => { - const send = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - apnsId: "relay-apns-id", - environment: "production", - tokenSuffix: "abcd1234", - }); - - const result = await sendApnsAlert({ - relayConfig: { - baseUrl: "https://relay.example.com", - timeoutMs: 1000, - }, - registration: { - nodeId: "ios-node-relay", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: "install-123", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "official", - updatedAtMs: 1, - tokenDebugSuffix: "abcd1234", - }, - nodeId: "ios-node-relay", - title: "Wake", - body: "Ping", - relayGatewayIdentity: relayGatewayIdentity, - relayRequestSender: send, - }); - - expect(send).toHaveBeenCalledTimes(1); - expect(send.mock.calls[0]?.[0]).toMatchObject({ - relayHandle: "relay-handle-123", - gatewayDeviceId: relayGatewayIdentity.deviceId, - pushType: "alert", - priority: "10", - payload: { - aps: { - alert: { title: "Wake", body: "Ping" }, - sound: "default", - }, - }, - }); - const sent = send.mock.calls[0]?.[0]; - expect(typeof sent?.signature).toBe("string"); - expect(typeof sent?.signedAtMs).toBe("number"); - const signedPayload = [ - "openclaw-relay-send-v1", - sent?.gatewayDeviceId, - String(sent?.signedAtMs), - sent?.bodyJson, - ].join("\n"); - expect( - verifyDeviceSignature(relayGatewayIdentity.publicKey, signedPayload, sent?.signature), - ).toBe(true); - expect(result).toMatchObject({ - ok: true, - status: 200, - transport: "relay", - environment: "production", - tokenSuffix: "abcd1234", - }); - }); - - it("flags invalid device responses for registration invalidation", () => { - expect(shouldInvalidateApnsRegistration({ status: 400, reason: "BadDeviceToken" })).toBe(true); - expect(shouldInvalidateApnsRegistration({ status: 410, reason: "Unregistered" })).toBe(true); - expect(shouldInvalidateApnsRegistration({ status: 429, reason: "TooManyRequests" })).toBe( - false, - ); - }); - - it("only clears stored registrations for direct APNs failures without an override mismatch", () => { - expect( - shouldClearStoredApnsRegistration({ - registration: { - nodeId: "ios-node-direct", - transport: "direct", - token: "ABCD1234ABCD1234ABCD1234ABCD1234", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }, - result: { status: 400, reason: "BadDeviceToken" }, - }), - ).toBe(true); - - expect( - shouldClearStoredApnsRegistration({ - registration: { - nodeId: "ios-node-relay", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: "install-123", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "official", - updatedAtMs: 1, - }, - result: { status: 410, reason: "Unregistered" }, - }), - ).toBe(false); - - expect( - shouldClearStoredApnsRegistration({ - registration: { - nodeId: "ios-node-direct", - transport: "direct", - token: "ABCD1234ABCD1234ABCD1234ABCD1234", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }, - result: { status: 400, reason: "BadDeviceToken" }, - overrideEnvironment: "production", - }), - ).toBe(false); - }); });