mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 16:47:27 +00:00
Merge branch 'main' into ui/dashboard-v2.1.3
This commit is contained in:
125
src/infra/archive-helpers.test.ts
Normal file
125
src/infra/archive-helpers.test.ts
Normal file
@@ -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<string>((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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string> {
|
||||
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<string, Record<string, unknown>>;
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
||||
182
src/infra/push-apns.auth.test.ts
Normal file
182
src/infra/push-apns.auth.test.ts
Normal file
@@ -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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
284
src/infra/push-apns.relay.test.ts
Normal file
284
src/infra/push-apns.relay.test.ts
Normal file
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
308
src/infra/push-apns.store.test.ts
Normal file
308
src/infra/push-apns.store.test.ts
Normal file
@@ -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<string> {
|
||||
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: "<ABCD1234ABCD1234ABCD1234ABCD1234>",
|
||||
topic: " ai.openclaw.ios ",
|
||||
environment: " PRODUCTION ",
|
||||
updatedAtMs: 3,
|
||||
},
|
||||
" ": {
|
||||
nodeId: " ios-node-fallback ",
|
||||
token: "<ABCD1234ABCD1234ABCD1234ABCD1234>",
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
156
src/infra/tls/gateway.test.ts
Normal file
156
src/infra/tls/gateway.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user