diff --git a/src/infra/push-apns.store.test.ts b/src/infra/push-apns.store.test.ts new file mode 100644 index 00000000000..210a9f791c6 --- /dev/null +++ b/src/infra/push-apns.store.test.ts @@ -0,0 +1,308 @@ +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 { + clearApnsRegistration, + clearApnsRegistrationIfCurrent, + loadApnsRegistration, + registerApnsRegistration, + registerApnsToken, +} from "./push-apns.js"; + +const tempDirs: string[] = []; + +async function makeTempDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-store-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 registration store", () => { + it("stores and reloads direct APNs registrations", async () => { + const baseDir = await makeTempDir(); + const saved = await registerApnsToken({ + nodeId: "ios-node-1", + token: "ABCD1234ABCD1234ABCD1234ABCD1234", + topic: "ai.openclaw.ios", + environment: "sandbox", + baseDir, + }); + + const loaded = await loadApnsRegistration("ios-node-1", baseDir); + expect(loaded).toMatchObject({ + nodeId: "ios-node-1", + transport: "direct", + topic: "ai.openclaw.ios", + environment: "sandbox", + updatedAtMs: saved.updatedAtMs, + }); + expect(loaded && loaded.transport === "direct" ? loaded.token : null).toBe( + "abcd1234abcd1234abcd1234abcd1234", + ); + }); + + it("stores relay-backed registrations without a raw token", async () => { + const baseDir = await makeTempDir(); + const saved = await registerApnsRegistration({ + 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", + tokenDebugSuffix: " abcd-1234 ", + baseDir, + }); + + const loaded = await loadApnsRegistration("ios-node-relay", baseDir); + expect(saved.transport).toBe("relay"); + expect(loaded).toMatchObject({ + 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", + tokenDebugSuffix: "abcd1234", + }); + expect(loaded && "token" in loaded).toBe(false); + }); + + it("normalizes legacy direct records from disk and ignores invalid entries", async () => { + const baseDir = await makeTempDir(); + const statePath = path.join(baseDir, "push", "apns-registrations.json"); + await fs.mkdir(path.dirname(statePath), { recursive: true }); + await fs.writeFile( + statePath, + `${JSON.stringify( + { + registrationsByNodeId: { + " ios-node-legacy ": { + nodeId: " ios-node-legacy ", + token: "", + topic: " ai.openclaw.ios ", + environment: " PRODUCTION ", + updatedAtMs: 3, + }, + " ": { + nodeId: " ios-node-fallback ", + token: "", + topic: " ai.openclaw.ios ", + updatedAtMs: 2, + }, + "ios-node-bad-relay": { + transport: "relay", + nodeId: "ios-node-bad-relay", + relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", + installationId: "install-123", + topic: "ai.openclaw.ios", + environment: "production", + distribution: "beta", + updatedAtMs: 1, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + await expect(loadApnsRegistration("ios-node-legacy", baseDir)).resolves.toMatchObject({ + nodeId: "ios-node-legacy", + transport: "direct", + token: "abcd1234abcd1234abcd1234abcd1234", + topic: "ai.openclaw.ios", + environment: "production", + updatedAtMs: 3, + }); + await expect(loadApnsRegistration("ios-node-fallback", baseDir)).resolves.toMatchObject({ + nodeId: "ios-node-fallback", + transport: "direct", + topic: "ai.openclaw.ios", + environment: "sandbox", + updatedAtMs: 2, + }); + await expect(loadApnsRegistration("ios-node-bad-relay", baseDir)).resolves.toBeNull(); + }); + + it("falls back cleanly for malformed or missing registration state", async () => { + const baseDir = await makeTempDir(); + const statePath = path.join(baseDir, "push", "apns-registrations.json"); + await fs.mkdir(path.dirname(statePath), { recursive: true }); + await fs.writeFile(statePath, "[]", "utf8"); + + await expect(loadApnsRegistration("ios-node-missing", baseDir)).resolves.toBeNull(); + await expect(loadApnsRegistration(" ", baseDir)).resolves.toBeNull(); + await expect(clearApnsRegistration(" ", baseDir)).resolves.toBe(false); + await expect(clearApnsRegistration("ios-node-missing", baseDir)).resolves.toBe(false); + }); + + it("rejects invalid direct and relay registration inputs", async () => { + const baseDir = await makeTempDir(); + const oversized = "x".repeat(257); + + await expect( + registerApnsToken({ + nodeId: "ios-node-1", + token: "not-a-token", + topic: "ai.openclaw.ios", + baseDir, + }), + ).rejects.toThrow("invalid APNs token"); + await expect( + registerApnsToken({ + nodeId: "n".repeat(257), + token: "ABCD1234ABCD1234ABCD1234ABCD1234", + topic: "ai.openclaw.ios", + baseDir, + }), + ).rejects.toThrow("nodeId required"); + await expect( + registerApnsToken({ + nodeId: "ios-node-1", + token: "A".repeat(513), + topic: "ai.openclaw.ios", + baseDir, + }), + ).rejects.toThrow("invalid APNs token"); + await expect( + registerApnsToken({ + nodeId: "ios-node-1", + token: "ABCD1234ABCD1234ABCD1234ABCD1234", + topic: "a".repeat(256), + baseDir, + }), + ).rejects.toThrow("topic required"); + await expect( + registerApnsRegistration({ + nodeId: "ios-node-relay", + transport: "relay", + relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", + installationId: "install-123", + topic: "ai.openclaw.ios", + environment: "staging", + distribution: "official", + baseDir, + }), + ).rejects.toThrow("relay registrations must use production environment"); + await expect( + registerApnsRegistration({ + nodeId: "ios-node-relay", + transport: "relay", + relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", + installationId: "install-123", + topic: "ai.openclaw.ios", + environment: "production", + distribution: "beta", + baseDir, + }), + ).rejects.toThrow("relay registrations must use official distribution"); + await expect( + registerApnsRegistration({ + nodeId: "ios-node-relay", + transport: "relay", + relayHandle: oversized, + sendGrant: "send-grant-123", + installationId: "install-123", + topic: "ai.openclaw.ios", + environment: "production", + distribution: "official", + baseDir, + }), + ).rejects.toThrow("relayHandle too long"); + await expect( + registerApnsRegistration({ + nodeId: "ios-node-relay", + transport: "relay", + relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", + installationId: oversized, + topic: "ai.openclaw.ios", + environment: "production", + distribution: "official", + baseDir, + }), + ).rejects.toThrow("installationId too long"); + await expect( + registerApnsRegistration({ + nodeId: "ios-node-relay", + transport: "relay", + relayHandle: "relay-handle-123", + sendGrant: "x".repeat(1025), + installationId: "install-123", + topic: "ai.openclaw.ios", + environment: "production", + distribution: "official", + baseDir, + }), + ).rejects.toThrow("sendGrant too long"); + }); + + it("persists with a trailing newline and clears registrations", async () => { + const baseDir = await makeTempDir(); + await registerApnsToken({ + nodeId: "ios-node-1", + token: "ABCD1234ABCD1234ABCD1234ABCD1234", + topic: "ai.openclaw.ios", + baseDir, + }); + + const statePath = path.join(baseDir, "push", "apns-registrations.json"); + await expect(fs.readFile(statePath, "utf8")).resolves.toMatch(/\n$/); + await expect(clearApnsRegistration("ios-node-1", baseDir)).resolves.toBe(true); + await expect(loadApnsRegistration("ios-node-1", baseDir)).resolves.toBeNull(); + }); + + it("only clears a registration when the stored entry still matches", async () => { + vi.useFakeTimers(); + try { + const baseDir = await makeTempDir(); + vi.setSystemTime(new Date("2026-03-11T00:00:00Z")); + const stale = await registerApnsToken({ + nodeId: "ios-node-1", + token: "ABCD1234ABCD1234ABCD1234ABCD1234", + topic: "ai.openclaw.ios", + environment: "sandbox", + baseDir, + }); + + vi.setSystemTime(new Date("2026-03-11T00:00:01Z")); + const fresh = await registerApnsToken({ + nodeId: "ios-node-1", + token: "ABCD1234ABCD1234ABCD1234ABCD1234", + topic: "ai.openclaw.ios", + environment: "sandbox", + baseDir, + }); + + await expect( + clearApnsRegistrationIfCurrent({ + nodeId: "ios-node-1", + registration: stale, + baseDir, + }), + ).resolves.toBe(false); + await expect(loadApnsRegistration("ios-node-1", baseDir)).resolves.toEqual(fresh); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index 89e8fb319f6..949e4632282 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -1,27 +1,10 @@ import { generateKeyPairSync } from "node:crypto"; -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 { - clearApnsRegistration, - clearApnsRegistrationIfCurrent, - loadApnsRegistration, - registerApnsRegistration, - registerApnsToken, - sendApnsAlert, - sendApnsBackgroundWake, -} from "./push-apns.js"; +import { sendApnsAlert, sendApnsBackgroundWake } from "./push-apns.js"; -const tempDirs: string[] = []; const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" }) .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); - return dir; -} function createDirectApnsSendFixture(params: { nodeId: string; @@ -48,229 +31,6 @@ function createDirectApnsSendFixture(params: { afterEach(async () => { vi.unstubAllGlobals(); - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (dir) { - await fs.rm(dir, { recursive: true, force: true }); - } - } -}); - -describe("push APNs registration store", () => { - it("stores and reloads node APNs registration", async () => { - const baseDir = await makeTempDir(); - const saved = await registerApnsToken({ - nodeId: "ios-node-1", - token: "ABCD1234ABCD1234ABCD1234ABCD1234", - topic: "ai.openclaw.ios", - environment: "sandbox", - baseDir, - }); - - const loaded = await loadApnsRegistration("ios-node-1", baseDir); - expect(loaded).not.toBeNull(); - expect(loaded?.nodeId).toBe("ios-node-1"); - expect(loaded?.transport).toBe("direct"); - expect(loaded && loaded.transport === "direct" ? loaded.token : null).toBe( - "abcd1234abcd1234abcd1234abcd1234", - ); - expect(loaded?.topic).toBe("ai.openclaw.ios"); - expect(loaded?.environment).toBe("sandbox"); - expect(loaded?.updatedAtMs).toBe(saved.updatedAtMs); - }); - - it("stores and reloads relay-backed APNs registrations without a raw token", async () => { - const baseDir = await makeTempDir(); - const saved = await registerApnsRegistration({ - 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", - tokenDebugSuffix: "abcd1234", - baseDir, - }); - - const loaded = await loadApnsRegistration("ios-node-relay", baseDir); - expect(saved.transport).toBe("relay"); - expect(loaded).toMatchObject({ - 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", - tokenDebugSuffix: "abcd1234", - }); - expect(loaded && "token" in loaded).toBe(false); - }); - - it("rejects invalid APNs tokens", async () => { - const baseDir = await makeTempDir(); - await expect( - registerApnsToken({ - nodeId: "ios-node-1", - token: "not-a-token", - topic: "ai.openclaw.ios", - baseDir, - }), - ).rejects.toThrow("invalid APNs token"); - }); - - it("rejects oversized direct APNs registration fields", async () => { - const baseDir = await makeTempDir(); - await expect( - registerApnsToken({ - nodeId: "n".repeat(257), - token: "ABCD1234ABCD1234ABCD1234ABCD1234", - topic: "ai.openclaw.ios", - baseDir, - }), - ).rejects.toThrow("nodeId required"); - await expect( - registerApnsToken({ - nodeId: "ios-node-1", - token: "A".repeat(513), - topic: "ai.openclaw.ios", - baseDir, - }), - ).rejects.toThrow("invalid APNs token"); - await expect( - registerApnsToken({ - nodeId: "ios-node-1", - token: "ABCD1234ABCD1234ABCD1234ABCD1234", - topic: "a".repeat(256), - baseDir, - }), - ).rejects.toThrow("topic required"); - }); - - it("rejects relay registrations that do not use production/official values", async () => { - const baseDir = await makeTempDir(); - await expect( - registerApnsRegistration({ - nodeId: "ios-node-relay", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: "install-123", - topic: "ai.openclaw.ios", - environment: "staging", - distribution: "official", - baseDir, - }), - ).rejects.toThrow("relay registrations must use production environment"); - await expect( - registerApnsRegistration({ - nodeId: "ios-node-relay", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: "install-123", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "beta", - baseDir, - }), - ).rejects.toThrow("relay registrations must use official distribution"); - }); - - it("rejects oversized relay registration identifiers", async () => { - const baseDir = await makeTempDir(); - const oversized = "x".repeat(257); - await expect( - registerApnsRegistration({ - nodeId: "ios-node-relay", - transport: "relay", - relayHandle: oversized, - sendGrant: "send-grant-123", - installationId: "install-123", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "official", - baseDir, - }), - ).rejects.toThrow("relayHandle too long"); - await expect( - registerApnsRegistration({ - nodeId: "ios-node-relay", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: oversized, - topic: "ai.openclaw.ios", - environment: "production", - distribution: "official", - baseDir, - }), - ).rejects.toThrow("installationId too long"); - await expect( - registerApnsRegistration({ - nodeId: "ios-node-relay", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "x".repeat(1025), - installationId: "install-123", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "official", - baseDir, - }), - ).rejects.toThrow("sendGrant too long"); - }); - - it("clears registrations", async () => { - const baseDir = await makeTempDir(); - await registerApnsToken({ - nodeId: "ios-node-1", - token: "ABCD1234ABCD1234ABCD1234ABCD1234", - topic: "ai.openclaw.ios", - baseDir, - }); - - await expect(clearApnsRegistration("ios-node-1", baseDir)).resolves.toBe(true); - await expect(loadApnsRegistration("ios-node-1", baseDir)).resolves.toBeNull(); - }); - - it("only clears a registration when the stored entry still matches", async () => { - vi.useFakeTimers(); - try { - const baseDir = await makeTempDir(); - vi.setSystemTime(new Date("2026-03-11T00:00:00Z")); - const stale = await registerApnsToken({ - nodeId: "ios-node-1", - token: "ABCD1234ABCD1234ABCD1234ABCD1234", - topic: "ai.openclaw.ios", - environment: "sandbox", - baseDir, - }); - - vi.setSystemTime(new Date("2026-03-11T00:00:01Z")); - const fresh = await registerApnsToken({ - nodeId: "ios-node-1", - token: "ABCD1234ABCD1234ABCD1234ABCD1234", - topic: "ai.openclaw.ios", - environment: "sandbox", - baseDir, - }); - - await expect( - clearApnsRegistrationIfCurrent({ - nodeId: "ios-node-1", - registration: stale, - baseDir, - }), - ).resolves.toBe(false); - await expect(loadApnsRegistration("ios-node-1", baseDir)).resolves.toEqual(fresh); - } finally { - vi.useRealTimers(); - } - }); }); describe("push APNs send semantics", () => {