diff --git a/src/infra/archive-helpers.test.ts b/src/infra/archive-helpers.test.ts new file mode 100644 index 00000000000..178a1989733 --- /dev/null +++ b/src/infra/archive-helpers.test.ts @@ -0,0 +1,125 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; +import { + createTarEntryPreflightChecker, + fileExists, + readJsonFile, + resolveArchiveKind, + resolvePackedRootDir, + withTimeout, +} from "./archive.js"; + +const tempDirs = createTrackedTempDirs(); +const createTempDir = () => tempDirs.make("openclaw-archive-helper-test-"); + +afterEach(async () => { + vi.useRealTimers(); + await tempDirs.cleanup(); +}); + +describe("archive helpers", () => { + it.each([ + { input: "/tmp/file.zip", expected: "zip" }, + { input: "/tmp/file.TAR.GZ", expected: "tar" }, + { input: "/tmp/file.tgz", expected: "tar" }, + { input: "/tmp/file.tar", expected: "tar" }, + { input: "/tmp/file.txt", expected: null }, + ])("detects archive kind for $input", ({ input, expected }) => { + expect(resolveArchiveKind(input)).toBe(expected); + }); + + it("resolves packed roots from package dir or single extracted root dir", async () => { + const directDir = await createTempDir(); + const fallbackDir = await createTempDir(); + await fs.mkdir(path.join(directDir, "package"), { recursive: true }); + await fs.mkdir(path.join(fallbackDir, "bundle-root"), { recursive: true }); + + await expect(resolvePackedRootDir(directDir)).resolves.toBe(path.join(directDir, "package")); + await expect(resolvePackedRootDir(fallbackDir)).resolves.toBe( + path.join(fallbackDir, "bundle-root"), + ); + }); + + it("rejects unexpected packed root layouts", async () => { + const multipleDir = await createTempDir(); + const emptyDir = await createTempDir(); + await fs.mkdir(path.join(multipleDir, "a"), { recursive: true }); + await fs.mkdir(path.join(multipleDir, "b"), { recursive: true }); + await fs.writeFile(path.join(emptyDir, "note.txt"), "hi", "utf8"); + + await expect(resolvePackedRootDir(multipleDir)).rejects.toThrow(/unexpected archive layout/i); + await expect(resolvePackedRootDir(emptyDir)).rejects.toThrow(/unexpected archive layout/i); + }); + + it("returns work results and propagates errors before timeout", async () => { + await expect(withTimeout(Promise.resolve("ok"), 100, "extract zip")).resolves.toBe("ok"); + await expect( + withTimeout(Promise.reject(new Error("boom")), 100, "extract zip"), + ).rejects.toThrow("boom"); + }); + + it("rejects when archive work exceeds the timeout", async () => { + vi.useFakeTimers(); + const late = new Promise((resolve) => setTimeout(() => resolve("ok"), 50)); + const result = withTimeout(late, 1, "extract tar"); + const pending = expect(result).rejects.toThrow("extract tar timed out after 1ms"); + await vi.advanceTimersByTimeAsync(1); + await pending; + }); + + it("preflights tar entries for blocked link types, path escapes, and size budgets", () => { + const checker = createTarEntryPreflightChecker({ + rootDir: "/tmp/dest", + limits: { + maxEntries: 1, + maxEntryBytes: 8, + maxExtractedBytes: 12, + }, + }); + + expect(() => checker({ path: "package/link", type: "SymbolicLink", size: 0 })).toThrow( + "tar entry is a link: package/link", + ); + expect(() => checker({ path: "../escape.txt", type: "File", size: 1 })).toThrow( + /escapes destination|absolute/i, + ); + + checker({ path: "package/ok.txt", type: "File", size: 8 }); + expect(() => checker({ path: "package/second.txt", type: "File", size: 1 })).toThrow( + "archive entry count exceeds limit", + ); + }); + + it("treats stripped-away tar entries as no-ops and enforces extracted byte budgets", () => { + const checker = createTarEntryPreflightChecker({ + rootDir: "/tmp/dest", + stripComponents: 1, + limits: { + maxEntries: 4, + maxEntryBytes: 16, + maxExtractedBytes: 10, + }, + }); + + expect(() => checker({ path: "package", type: "Directory", size: 0 })).not.toThrow(); + checker({ path: "package/a.txt", type: "File", size: 6 }); + expect(() => checker({ path: "package/b.txt", type: "File", size: 6 })).toThrow( + "archive extracted size exceeds limit", + ); + }); + + it("reads JSON files and reports file existence", async () => { + const dir = await createTempDir(); + const jsonPath = path.join(dir, "data.json"); + const badPath = path.join(dir, "bad.json"); + await fs.writeFile(jsonPath, '{"ok":true}', "utf8"); + await fs.writeFile(badPath, "{not json", "utf8"); + + await expect(readJsonFile<{ ok: boolean }>(jsonPath)).resolves.toEqual({ ok: true }); + await expect(readJsonFile(badPath)).rejects.toThrow(); + await expect(fileExists(jsonPath)).resolves.toBe(true); + await expect(fileExists(path.join(dir, "missing.json"))).resolves.toBe(false); + }); +}); diff --git a/src/infra/archive.test.ts b/src/infra/archive.test.ts index 14c546e7674..d77b1e0bdb4 100644 --- a/src/infra/archive.test.ts +++ b/src/infra/archive.test.ts @@ -6,7 +6,7 @@ import * as tar from "tar"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { withRealpathSymlinkRebindRace } from "../test-utils/symlink-rebind-race.js"; import type { ArchiveSecurityError } from "./archive.js"; -import { extractArchive, resolveArchiveKind, resolvePackedRootDir } from "./archive.js"; +import { extractArchive, resolvePackedRootDir } from "./archive.js"; let fixtureRoot = ""; let fixtureCount = 0; @@ -82,19 +82,6 @@ afterAll(async () => { }); describe("archive utils", () => { - it("detects archive kinds", () => { - const cases = [ - { input: "/tmp/file.zip", expected: "zip" }, - { input: "/tmp/file.tgz", expected: "tar" }, - { input: "/tmp/file.tar.gz", expected: "tar" }, - { input: "/tmp/file.tar", expected: "tar" }, - { input: "/tmp/file.txt", expected: null }, - ] as const; - for (const testCase of cases) { - expect(resolveArchiveKind(testCase.input), testCase.input).toBe(testCase.expected); - } - }); - it.each([{ ext: "zip" as const }, { ext: "tar" as const }])( "extracts $ext archives", async ({ ext }) => { @@ -329,14 +316,6 @@ describe("archive utils", () => { }, ); - it("fails resolvePackedRootDir when extract dir has multiple root dirs", async () => { - const workDir = await makeTempDir("packed-root"); - const extractDir = path.join(workDir, "extract"); - await fs.mkdir(path.join(extractDir, "a"), { recursive: true }); - await fs.mkdir(path.join(extractDir, "b"), { recursive: true }); - await expect(resolvePackedRootDir(extractDir)).rejects.toThrow(/unexpected archive layout/i); - }); - it("rejects tar entries with absolute extraction paths", async () => { await withArchiveCase("tar", async ({ workDir, archivePath, extractDir }) => { const inputDir = path.join(workDir, "input"); diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index a8206f30b02..f5e9f699e5c 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -1,130 +1,169 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import fs from "node:fs/promises"; +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; import { DEVICE_BOOTSTRAP_TOKEN_TTL_MS, issueDeviceBootstrapToken, verifyDeviceBootstrapToken, } from "./device-bootstrap.js"; -const tempRoots: string[] = []; +const tempDirs = createTrackedTempDirs(); +const createTempDir = () => tempDirs.make("openclaw-device-bootstrap-test-"); -async function createBaseDir(): Promise { - const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-bootstrap-")); - tempRoots.push(baseDir); - return baseDir; +function resolveBootstrapPath(baseDir: string): string { + return path.join(baseDir, "devices", "bootstrap.json"); } afterEach(async () => { vi.useRealTimers(); - await Promise.all( - tempRoots.splice(0).map(async (root) => await rm(root, { recursive: true, force: true })), - ); + await tempDirs.cleanup(); }); describe("device bootstrap tokens", () => { - it("accepts the first successful verification", async () => { - const baseDir = await createBaseDir(); - const issued = await issueDeviceBootstrapToken({ baseDir }); - - await expect( - verifyDeviceBootstrapToken({ - token: issued.token, - deviceId: "device-1", - publicKey: "pub-1", - role: "node", - scopes: ["node.invoke"], - baseDir, - }), - ).resolves.toEqual({ ok: true }); - }); - - it("rejects replay after the first successful verification", async () => { - const baseDir = await createBaseDir(); - const issued = await issueDeviceBootstrapToken({ baseDir }); - - await expect( - verifyDeviceBootstrapToken({ - token: issued.token, - deviceId: "device-1", - publicKey: "pub-1", - role: "node", - scopes: ["node.invoke"], - baseDir, - }), - ).resolves.toEqual({ ok: true }); - - await expect( - verifyDeviceBootstrapToken({ - token: issued.token, - deviceId: "device-1", - publicKey: "pub-1", - role: "operator", - scopes: ["operator.read"], - baseDir, - }), - ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); - }); - - it("rejects reuse from a different device after consumption", async () => { - const baseDir = await createBaseDir(); - const issued = await issueDeviceBootstrapToken({ baseDir }); - - await verifyDeviceBootstrapToken({ - token: issued.token, - deviceId: "device-1", - publicKey: "pub-1", - role: "node", - scopes: ["node.invoke"], - baseDir, - }); - - await expect( - verifyDeviceBootstrapToken({ - token: issued.token, - deviceId: "device-2", - publicKey: "pub-2", - role: "node", - scopes: ["node.invoke"], - baseDir, - }), - ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); - }); - - it("expires bootstrap tokens after the ttl window", async () => { + it("issues bootstrap tokens and persists them with an expiry", async () => { vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-12T10:00:00Z")); - const baseDir = await createBaseDir(); + vi.setSystemTime(new Date("2026-03-14T12:00:00Z")); + + const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); - vi.setSystemTime(new Date(Date.now() + DEVICE_BOOTSTRAP_TOKEN_TTL_MS + 1)); + expect(issued.token).toMatch(/^[A-Za-z0-9_-]+$/); + expect(issued.expiresAtMs).toBe(Date.now() + DEVICE_BOOTSTRAP_TOKEN_TTL_MS); + + const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8"); + const parsed = JSON.parse(raw) as Record< + string, + { token: string; ts: number; issuedAtMs: number } + >; + expect(parsed[issued.token]).toMatchObject({ + token: issued.token, + ts: Date.now(), + issuedAtMs: Date.now(), + }); + }); + + it("verifies valid bootstrap tokens once and deletes them after success", async () => { + const baseDir = await createTempDir(); + const issued = await issueDeviceBootstrapToken({ baseDir }); await expect( verifyDeviceBootstrapToken({ token: issued.token, - deviceId: "device-1", - publicKey: "pub-1", - role: "node", - scopes: ["node.invoke"], + deviceId: "device-123", + publicKey: "public-key-123", + role: "operator.admin", + scopes: ["operator.admin"], + baseDir, + }), + ).resolves.toEqual({ ok: true }); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: "device-123", + publicKey: "public-key-123", + role: "operator.admin", + scopes: ["operator.admin"], + baseDir, + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + + await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}"); + }); + + it("keeps the token when required verification fields are blank", async () => { + const baseDir = await createTempDir(); + const issued = await issueDeviceBootstrapToken({ baseDir }); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: "device-123", + publicKey: "public-key-123", + role: " ", + scopes: ["operator.admin"], + baseDir, + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + + const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8"); + expect(raw).toContain(issued.token); + }); + + it("rejects blank or unknown tokens", async () => { + const baseDir = await createTempDir(); + await issueDeviceBootstrapToken({ baseDir }); + + await expect( + verifyDeviceBootstrapToken({ + token: " ", + deviceId: "device-123", + publicKey: "public-key-123", + role: "operator.admin", + scopes: ["operator.admin"], + baseDir, + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + + await expect( + verifyDeviceBootstrapToken({ + token: "missing-token", + deviceId: "device-123", + publicKey: "public-key-123", + role: "operator.admin", + scopes: ["operator.admin"], baseDir, }), ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); }); - it("persists only token state that verification actually consumes", async () => { - const baseDir = await createBaseDir(); - const issued = await issueDeviceBootstrapToken({ baseDir }); - const raw = await readFile(join(baseDir, "devices", "bootstrap.json"), "utf8"); - const state = JSON.parse(raw) as Record>; - const record = state[issued.token]; + it("accepts legacy records that only stored issuedAtMs and prunes expired tokens", async () => { + vi.useFakeTimers(); + const baseDir = await createTempDir(); + const bootstrapPath = resolveBootstrapPath(baseDir); + await fs.mkdir(path.dirname(bootstrapPath), { recursive: true }); - expect(record).toMatchObject({ - token: issued.token, - }); - expect(record).not.toHaveProperty("channel"); - expect(record).not.toHaveProperty("senderId"); - expect(record).not.toHaveProperty("accountId"); - expect(record).not.toHaveProperty("threadId"); + vi.setSystemTime(new Date("2026-03-14T12:00:00Z")); + await fs.writeFile( + bootstrapPath, + `${JSON.stringify( + { + legacyToken: { + token: "legacyToken", + issuedAtMs: Date.now(), + }, + expiredToken: { + token: "expiredToken", + issuedAtMs: Date.now() - DEVICE_BOOTSTRAP_TOKEN_TTL_MS - 1, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + await expect( + verifyDeviceBootstrapToken({ + token: "legacyToken", + deviceId: "device-123", + publicKey: "public-key-123", + role: "operator.admin", + scopes: ["operator.admin"], + baseDir, + }), + ).resolves.toEqual({ ok: true }); + + await expect( + verifyDeviceBootstrapToken({ + token: "expiredToken", + deviceId: "device-123", + publicKey: "public-key-123", + role: "operator.admin", + scopes: ["operator.admin"], + baseDir, + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); }); }); 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.relay.test.ts b/src/infra/push-apns.relay.test.ts new file mode 100644 index 00000000000..4e8e8054311 --- /dev/null +++ b/src/infra/push-apns.relay.test.ts @@ -0,0 +1,284 @@ +import { generateKeyPairSync } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + deriveDeviceIdFromPublicKey, + publicKeyRawBase64UrlFromPem, + verifyDeviceSignature, +} from "./device-identity.js"; +import { resolveApnsRelayConfigFromEnv, sendApnsRelayPush } from "./push-apns.relay.js"; + +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(), + }; +})(); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("push-apns.relay", () => { + describe("resolveApnsRelayConfigFromEnv", () => { + it("returns a missing-config error when no relay base URL is configured", () => { + expect(resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv)).toEqual({ + ok: false, + error: + "APNs relay config missing: set gateway.push.apns.relay.baseUrl or OPENCLAW_APNS_RELAY_BASE_URL", + }); + }); + + it("lets env overrides win and clamps tiny timeout values", () => { + const resolved = resolveApnsRelayConfigFromEnv( + { + OPENCLAW_APNS_RELAY_BASE_URL: " https://relay-override.example.com/base/ ", + OPENCLAW_APNS_RELAY_TIMEOUT_MS: "999", + } as NodeJS.ProcessEnv, + { + push: { + apns: { + relay: { + baseUrl: "https://relay.example.com", + timeoutMs: 2500, + }, + }, + }, + }, + ); + + expect(resolved).toMatchObject({ + ok: true, + value: { + baseUrl: "https://relay-override.example.com/base", + timeoutMs: 1000, + }, + }); + }); + + it("allows loopback http URLs for alternate truthy env values", () => { + const resolved = resolveApnsRelayConfigFromEnv({ + OPENCLAW_APNS_RELAY_BASE_URL: "http://[::1]:8787", + OPENCLAW_APNS_RELAY_ALLOW_HTTP: "yes", + OPENCLAW_APNS_RELAY_TIMEOUT_MS: "nope", + } as NodeJS.ProcessEnv); + + expect(resolved).toMatchObject({ + ok: true, + value: { + baseUrl: "http://[::1]:8787", + timeoutMs: 10_000, + }, + }); + }); + + it.each([ + { + name: "unsupported protocol", + env: { OPENCLAW_APNS_RELAY_BASE_URL: "ftp://relay.example.com" }, + expected: "unsupported protocol", + }, + { + name: "http non-loopback host", + env: { + OPENCLAW_APNS_RELAY_BASE_URL: "http://relay.example.com", + OPENCLAW_APNS_RELAY_ALLOW_HTTP: "true", + }, + expected: "loopback hosts", + }, + { + name: "query string", + env: { OPENCLAW_APNS_RELAY_BASE_URL: "https://relay.example.com/path?debug=1" }, + expected: "query and fragment are not allowed", + }, + { + name: "userinfo", + env: { OPENCLAW_APNS_RELAY_BASE_URL: "https://user:pass@relay.example.com/path" }, + expected: "userinfo is not allowed", + }, + ])("rejects invalid relay URL: $name", ({ env, expected }) => { + const resolved = resolveApnsRelayConfigFromEnv(env as NodeJS.ProcessEnv); + expect(resolved.ok).toBe(false); + if (!resolved.ok) { + expect(resolved.error).toContain(expected); + } + }); + }); + + describe("sendApnsRelayPush", () => { + it("signs relay payloads and forwards the request through the injected sender", async () => { + vi.spyOn(Date, "now").mockReturnValue(123_456_789); + const sender = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + apnsId: "relay-apns-id", + environment: "production", + tokenSuffix: "abcd1234", + }); + + const result = await sendApnsRelayPush({ + relayConfig: { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, + }, + sendGrant: "send-grant-123", + relayHandle: "relay-handle-123", + payload: { aps: { alert: { title: "Wake", body: "Ping" } } }, + pushType: "alert", + priority: "10", + gatewayIdentity: relayGatewayIdentity, + requestSender: sender, + }); + + expect(sender).toHaveBeenCalledTimes(1); + const sent = sender.mock.calls[0]?.[0]; + expect(sent).toMatchObject({ + relayConfig: { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, + }, + sendGrant: "send-grant-123", + relayHandle: "relay-handle-123", + gatewayDeviceId: relayGatewayIdentity.deviceId, + signedAtMs: 123_456_789, + pushType: "alert", + priority: "10", + payload: { aps: { alert: { title: "Wake", body: "Ping" } } }, + }); + expect(sent?.bodyJson).toBe( + JSON.stringify({ + relayHandle: "relay-handle-123", + pushType: "alert", + priority: 10, + payload: { aps: { alert: { title: "Wake", body: "Ping" } } }, + }), + ); + expect( + verifyDeviceSignature( + relayGatewayIdentity.publicKey, + [ + "openclaw-relay-send-v1", + sent?.gatewayDeviceId, + String(sent?.signedAtMs), + sent?.bodyJson, + ].join("\n"), + sent?.signature ?? "", + ), + ).toBe(true); + expect(result).toMatchObject({ + ok: true, + status: 200, + apnsId: "relay-apns-id", + environment: "production", + tokenSuffix: "abcd1234", + }); + }); + + it("does not follow relay redirects", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 302, + json: vi.fn().mockRejectedValue(new Error("no body")), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const result = await sendApnsRelayPush({ + relayConfig: { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, + }, + sendGrant: "send-grant-123", + relayHandle: "relay-handle-123", + payload: { aps: { "content-available": 1 } }, + pushType: "background", + priority: "5", + gatewayIdentity: relayGatewayIdentity, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" }); + expect(result).toMatchObject({ + ok: false, + status: 302, + reason: "RelayRedirectNotAllowed", + environment: "production", + }); + }); + + it("falls back to fetch status when the relay body is not JSON", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 202, + json: vi.fn().mockRejectedValue(new Error("bad json")), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + await expect( + sendApnsRelayPush({ + relayConfig: { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, + }, + sendGrant: "send-grant-123", + relayHandle: "relay-handle-123", + payload: { aps: { "content-available": 1 } }, + pushType: "background", + priority: "5", + gatewayIdentity: relayGatewayIdentity, + }), + ).resolves.toEqual({ + ok: true, + status: 202, + apnsId: undefined, + reason: undefined, + environment: "production", + tokenSuffix: undefined, + }); + }); + + it("normalizes relay JSON response fields", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 202, + json: vi.fn().mockResolvedValue({ + ok: false, + status: 410, + apnsId: " relay-apns-id ", + reason: " Unregistered ", + tokenSuffix: " abcd1234 ", + }), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + await expect( + sendApnsRelayPush({ + relayConfig: { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, + }, + sendGrant: "send-grant-123", + relayHandle: "relay-handle-123", + payload: { aps: { "content-available": 1 } }, + pushType: "background", + priority: "5", + gatewayIdentity: relayGatewayIdentity, + }), + ).resolves.toEqual({ + ok: false, + status: 410, + apnsId: "relay-apns-id", + reason: "Unregistered", + environment: "production", + tokenSuffix: "abcd1234", + }); + }); + }); +}); 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 a2c616e81b4..949e4632282 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -1,53 +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 { - deriveDeviceIdFromPublicKey, - publicKeyRawBase64UrlFromPem, - verifyDeviceSignature, -} from "./device-identity.js"; -import { - clearApnsRegistration, - clearApnsRegistrationIfCurrent, - loadApnsRegistration, - normalizeApnsEnvironment, - registerApnsRegistration, - registerApnsToken, - resolveApnsAuthConfigFromEnv, - resolveApnsRelayConfigFromEnv, - sendApnsAlert, - sendApnsBackgroundWake, - shouldClearStoredApnsRegistration, - shouldInvalidateApnsRegistration, -} from "./push-apns.js"; -import { sendApnsRelayPush } from "./push-apns.relay.js"; +import { sendApnsAlert, sendApnsBackgroundWake } 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); - return dir; -} function createDirectApnsSendFixture(params: { nodeId: string; @@ -74,398 +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 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"); - }); - - it("resolves APNs relay config from env", () => { - const resolved = resolveApnsRelayConfigFromEnv({ - OPENCLAW_APNS_RELAY_BASE_URL: "https://relay.example.com", - OPENCLAW_APNS_RELAY_TIMEOUT_MS: "2500", - } as NodeJS.ProcessEnv); - expect(resolved).toMatchObject({ - ok: true, - value: { - baseUrl: "https://relay.example.com", - timeoutMs: 2500, - }, - }); - }); - - it("resolves APNs relay config from gateway config", () => { - const resolved = resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv, { - push: { - apns: { - relay: { - baseUrl: "https://relay.example.com/base/", - timeoutMs: 2500, - }, - }, - }, - }); - expect(resolved).toMatchObject({ - ok: true, - value: { - baseUrl: "https://relay.example.com/base", - timeoutMs: 2500, - }, - }); - }); - - it("lets relay env overrides win over gateway config", () => { - const resolved = resolveApnsRelayConfigFromEnv( - { - OPENCLAW_APNS_RELAY_BASE_URL: "https://relay-override.example.com", - OPENCLAW_APNS_RELAY_TIMEOUT_MS: "3000", - } as NodeJS.ProcessEnv, - { - push: { - apns: { - relay: { - baseUrl: "https://relay.example.com", - timeoutMs: 2500, - }, - }, - }, - }, - ); - expect(resolved).toMatchObject({ - ok: true, - value: { - baseUrl: "https://relay-override.example.com", - timeoutMs: 3000, - }, - }); - }); - - it("rejects insecure APNs relay http URLs by default", () => { - const resolved = resolveApnsRelayConfigFromEnv({ - OPENCLAW_APNS_RELAY_BASE_URL: "http://relay.example.com", - } as NodeJS.ProcessEnv); - expect(resolved).toMatchObject({ - ok: false, - }); - if (resolved.ok) { - return; - } - expect(resolved.error).toContain("OPENCLAW_APNS_RELAY_ALLOW_HTTP=true"); - }); - - it("allows APNs relay http URLs only when explicitly enabled", () => { - const resolved = resolveApnsRelayConfigFromEnv({ - OPENCLAW_APNS_RELAY_BASE_URL: "http://127.0.0.1:8787", - OPENCLAW_APNS_RELAY_ALLOW_HTTP: "true", - } as NodeJS.ProcessEnv); - expect(resolved).toMatchObject({ - ok: true, - value: { - baseUrl: "http://127.0.0.1:8787", - timeoutMs: 10_000, - }, - }); - }); - - it("rejects http relay URLs for non-loopback hosts even when explicitly enabled", () => { - const resolved = resolveApnsRelayConfigFromEnv({ - OPENCLAW_APNS_RELAY_BASE_URL: "http://relay.example.com", - OPENCLAW_APNS_RELAY_ALLOW_HTTP: "true", - } as NodeJS.ProcessEnv); - expect(resolved).toMatchObject({ - ok: false, - }); - if (resolved.ok) { - return; - } - expect(resolved.error).toContain("loopback hosts"); - }); - - it("rejects APNs relay URLs with query, fragment, or userinfo components", () => { - const withQuery = resolveApnsRelayConfigFromEnv({ - OPENCLAW_APNS_RELAY_BASE_URL: "https://relay.example.com/path?debug=1", - } as NodeJS.ProcessEnv); - expect(withQuery.ok).toBe(false); - if (!withQuery.ok) { - expect(withQuery.error).toContain("query and fragment are not allowed"); - } - - const withUserinfo = resolveApnsRelayConfigFromEnv({ - OPENCLAW_APNS_RELAY_BASE_URL: "https://user:pass@relay.example.com/path", - } as NodeJS.ProcessEnv); - expect(withUserinfo.ok).toBe(false); - if (!withUserinfo.ok) { - expect(withUserinfo.error).toContain("userinfo is not allowed"); - } - }); - - it("reports the config key name for invalid gateway relay URLs", () => { - const resolved = resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv, { - push: { - apns: { - relay: { - baseUrl: "https://relay.example.com/path?debug=1", - }, - }, - }, - }); - expect(resolved.ok).toBe(false); - if (!resolved.ok) { - expect(resolved.error).toContain("gateway.push.apns.relay.baseUrl"); - } - }); }); describe("push APNs send semantics", () => { @@ -577,158 +142,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("does not follow relay redirects", async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: false, - status: 302, - json: vi.fn().mockRejectedValue(new Error("no body")), - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const result = await sendApnsRelayPush({ - relayConfig: { - baseUrl: "https://relay.example.com", - timeoutMs: 1000, - }, - sendGrant: "send-grant-123", - relayHandle: "relay-handle-123", - payload: { aps: { "content-available": 1 } }, - pushType: "background", - priority: "5", - gatewayIdentity: relayGatewayIdentity, - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" }); - expect(result).toMatchObject({ - ok: false, - status: 302, - reason: "RelayRedirectNotAllowed", - environment: "production", - }); - }); - - 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); - }); }); diff --git a/src/infra/tls/gateway.test.ts b/src/infra/tls/gateway.test.ts new file mode 100644 index 00000000000..c55bb09201f --- /dev/null +++ b/src/infra/tls/gateway.test.ts @@ -0,0 +1,156 @@ +import { X509Certificate } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js"; +import { normalizeFingerprint } from "./fingerprint.js"; +import { loadGatewayTlsRuntime } from "./gateway.js"; + +const tempDirs = createTrackedTempDirs(); +const createTempDir = () => tempDirs.make("openclaw-gateway-tls-test-"); + +const KEY_PEM = [ + "-----BEGIN PRIVATE KEY-----", // pragma: allowlist secret + "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDrur5CWp4psMMb", + "DTPY1aN46HPDxRchGgh8XedNkrlc4z1KFiyLUsXpVIhuyoXq1fflpTDz7++pGEDJ", + "Q5pEdChn3fuWgi7gC+pvd5VQ1eAX/7qVE72fhx14NxhaiZU3hCzXjG2SflTEEExk", + "UkQTm0rdHSjgLVMhTM3Pqm6Kzfdgtm9ZyXwlAsorE/pvgbUxG3Q4xKNBGzbirZ+1", + "EzPDwsjf3fitNtakZJkymu6Kg5lsUihQVXOP0U7f989FmevoTMvJmkvJzsoTRd7s", + "XNSOjzOwJr8da8C4HkXi21md1yEccyW0iSh7tWvDrpWDAgW6RMuMHC0tW4bkpDGr", + "FpbQOgzVAgMBAAECggEAIMhwf8Ve9CDVTWyNXpU9fgnj2aDOCeg3MGaVzaO/XCPt", + "KOHDEaAyDnRXYgMP0zwtFNafo3klnSBWmDbq3CTEXseQHtsdfkKh+J0KmrqXxval", + "YeikKSyvBEIzRJoYMqeS3eo1bddcXgT/Pr9zIL/qzivpPJ4JDttBzyTeaTbiNaR9", + "KphGNueo+MTQMLreMqw5VAyJ44gy7Z/2TMiMEc/d95wfubcOSsrIfpOKnMvWd/rl", + "vxIS33s95L7CjREkixskj5Yo5Wpt3Yf5b0Zi70YiEsCfAZUDrPW7YzMlylzmhMzm", + "MARZKfN1Tmo74SGpxUrBury+iPwf1sYcRnsHR+zO8QKBgQD6ISQHRzPboZ3J/60+", + "fRLETtrBa9WkvaH9c+woF7l47D4DIlvlv9D3N1KGkUmhMnp2jNKLIlalBNDxBdB+", + "iwZP1kikGz4629Ch3/KF/VYscLTlAQNPE42jOo7Hj7VrdQx9zQrK9ZBLteXmSvOh", + "bB3aXwXPF3HoTMt9gQ9thhXZJQKBgQDxQxUnQSw43dRlqYOHzPUEwnJkGkuW/qxn", + "aRc8eopP5zUaebiDFmqhY36x2Wd+HnXrzufy2o4jkXkWTau8Ns+OLhnIG3PIU9L/", + "LYzJMckGb75QYiK1YKMUUSQzlNCS8+TFVCTAvG2u2zCCk7oTIe8aT516BQNjWDjK", + "gWo2f87N8QKBgHoVANO4kfwJxszXyMPuIeHEpwquyijNEap2EPaEldcKXz4CYB4j", + "4Cc5TkM12F0gGRuRohWcnfOPBTgOYXPSATOoX+4RCe+KaCsJ9gIl4xBvtirrsqS+", + "42ue4h9O6fpXt9AS6sii0FnTnzEmtgC8l1mE9X3dcJA0I0HPYytOvY0tAoGAAYJj", + "7Xzw4+IvY/ttgTn9BmyY/ptTgbxSI8t6g7xYhStzH5lHWDqZrCzNLBuqFBXosvL2", + "bISFgx9z3Hnb6y+EmOUc8C2LyeMMXOBSEygmk827KRGUGgJiwsvHKDN0Ipc4BSwD", + "ltkW7pMceJSoA1qg/k8lMxA49zQkFtA8c97U0mECgYEAk2DDN78sRQI8RpSECJWy", + "l1O1ikVUAYVeh5HdZkpt++ddfpo695Op9OeD2Eq27Y5EVj8Xl58GFxNk0egLUnYq", + "YzSbjcNkR2SbVvuLaV1zlQKm6M5rfvhj4//YrzrrPUQda7Q4eR0as/3q91uzAO2O", + "++pfnSCVCyp/TxSkhEDEawU=", + "-----END PRIVATE KEY-----", +].join("\n"); + +const CERT_PEM = `-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIUel0Lv05cjrViyI/H3tABBJxM7NgwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDEyMDEyMjEzMloXDTI2MDEy +MTEyMjEzMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA67q+QlqeKbDDGw0z2NWjeOhzw8UXIRoIfF3nTZK5XOM9 +ShYsi1LF6VSIbsqF6tX35aUw8+/vqRhAyUOaRHQoZ937loIu4Avqb3eVUNXgF/+6 +lRO9n4cdeDcYWomVN4Qs14xtkn5UxBBMZFJEE5tK3R0o4C1TIUzNz6puis33YLZv +Wcl8JQLKKxP6b4G1MRt0OMSjQRs24q2ftRMzw8LI3934rTbWpGSZMpruioOZbFIo +UFVzj9FO3/fPRZnr6EzLyZpLyc7KE0Xe7FzUjo8zsCa/HWvAuB5F4ttZndchHHMl +tIkoe7Vrw66VgwIFukTLjBwtLVuG5KQxqxaW0DoM1QIDAQABo1MwUTAdBgNVHQ4E +FgQUwNdNkEQtd0n/aofzN7/EeYPPPbIwHwYDVR0jBBgwFoAUwNdNkEQtd0n/aofz +N7/EeYPPPbIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAnOnw +o8Az/bL0A6bGHTYra3L9ArIIljMajT6KDHxylR4LhliuVNAznnhP3UkcZbUdjqjp +MNOM0lej2pNioondtQdXUskZtqWy6+dLbTm1RYQh1lbCCZQ26o7o/oENzjPksLAb +jRM47DYxRweTyRWQ5t9wvg/xL0Yi1tWq4u4FCNZlBMgdwAEnXNwVWTzRR9RHwy20 +lmUzM8uQ/p42bk4EvPEV4PI1h5G0khQ6x9CtkadCTDs/ZqoUaJMwZBIDSrdJJSLw +4Vh8Lqzia1CFB4um9J4S1Gm/VZMBjjeGGBJk7VSYn4ZmhPlbPM+6z39lpQGEG0x4 +r1USnb+wUdA7Zoj/mQ== +-----END CERTIFICATE-----`; + +afterEach(async () => { + await tempDirs.cleanup(); +}); + +describe("loadGatewayTlsRuntime", () => { + it("disables tls when config is absent or disabled", async () => { + await expect(loadGatewayTlsRuntime(undefined)).resolves.toEqual({ + enabled: false, + required: false, + }); + await expect(loadGatewayTlsRuntime({ enabled: false })).resolves.toEqual({ + enabled: false, + required: false, + }); + }); + + it("loads existing cert, key, and optional ca files", async () => { + const dir = await createTempDir(); + const certPath = path.join(dir, "gateway-cert.pem"); + const keyPath = path.join(dir, "gateway-key.pem"); + const caPath = path.join(dir, "gateway-ca.pem"); + await writeFile(certPath, CERT_PEM, "utf8"); + await writeFile(keyPath, KEY_PEM, "utf8"); + await writeFile(caPath, CERT_PEM, "utf8"); + + const result = await loadGatewayTlsRuntime({ + enabled: true, + certPath, + keyPath, + caPath, + autoGenerate: false, + }); + + expect(result).toMatchObject({ + enabled: true, + required: true, + certPath, + keyPath, + caPath, + fingerprintSha256: normalizeFingerprint(new X509Certificate(CERT_PEM).fingerprint256 ?? ""), + tlsOptions: { + cert: CERT_PEM, + key: KEY_PEM, + ca: CERT_PEM, + minVersion: "TLSv1.3", + }, + }); + expect(result.error).toBeUndefined(); + }); + + it("fails closed when cert/key are missing and auto generation is disabled", async () => { + const dir = await createTempDir(); + const certPath = path.join(dir, "missing-cert.pem"); + const keyPath = path.join(dir, "missing-key.pem"); + + await expect( + loadGatewayTlsRuntime({ + enabled: true, + certPath, + keyPath, + autoGenerate: false, + }), + ).resolves.toMatchObject({ + enabled: false, + required: true, + certPath, + keyPath, + error: "gateway tls: cert/key missing", + }); + }); + + it("reports load failures for invalid pem files", async () => { + const dir = await createTempDir(); + const certPath = path.join(dir, "gateway-cert.pem"); + const keyPath = path.join(dir, "gateway-key.pem"); + await writeFile(certPath, "not a certificate\n", "utf8"); + await writeFile(keyPath, KEY_PEM, "utf8"); + + const result = await loadGatewayTlsRuntime({ + enabled: true, + certPath, + keyPath, + autoGenerate: false, + }); + + expect(result).toMatchObject({ + enabled: false, + required: true, + certPath, + keyPath, + }); + expect(result.error).toContain("gateway tls: failed to load cert"); + }); +});