test: extract apns store coverage

This commit is contained in:
Peter Steinberger 2026-03-14 00:13:07 +00:00
parent d4f36fe0be
commit e268e7a726
2 changed files with 309 additions and 241 deletions

View 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();
}
});
});

View File

@ -1,27 +1,10 @@
import { generateKeyPairSync } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearApnsRegistration,
clearApnsRegistrationIfCurrent,
loadApnsRegistration,
registerApnsRegistration,
registerApnsToken,
sendApnsAlert,
sendApnsBackgroundWake,
} from "./push-apns.js";
import { sendApnsAlert, sendApnsBackgroundWake } from "./push-apns.js";
const tempDirs: string[] = [];
const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" })
.privateKey.export({ format: "pem", type: "pkcs8" })
.toString();
async function makeTempDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-test-"));
tempDirs.push(dir);
return dir;
}
function createDirectApnsSendFixture(params: {
nodeId: string;
@ -48,229 +31,6 @@ function createDirectApnsSendFixture(params: {
afterEach(async () => {
vi.unstubAllGlobals();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
await fs.rm(dir, { recursive: true, force: true });
}
}
});
describe("push APNs registration store", () => {
it("stores and reloads node APNs registration", async () => {
const baseDir = await makeTempDir();
const saved = await registerApnsToken({
nodeId: "ios-node-1",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
baseDir,
});
const loaded = await loadApnsRegistration("ios-node-1", baseDir);
expect(loaded).not.toBeNull();
expect(loaded?.nodeId).toBe("ios-node-1");
expect(loaded?.transport).toBe("direct");
expect(loaded && loaded.transport === "direct" ? loaded.token : null).toBe(
"abcd1234abcd1234abcd1234abcd1234",
);
expect(loaded?.topic).toBe("ai.openclaw.ios");
expect(loaded?.environment).toBe("sandbox");
expect(loaded?.updatedAtMs).toBe(saved.updatedAtMs);
});
it("stores and reloads relay-backed APNs registrations without a raw token", async () => {
const baseDir = await makeTempDir();
const saved = await registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
tokenDebugSuffix: "abcd1234",
baseDir,
});
const loaded = await loadApnsRegistration("ios-node-relay", baseDir);
expect(saved.transport).toBe("relay");
expect(loaded).toMatchObject({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
tokenDebugSuffix: "abcd1234",
});
expect(loaded && "token" in loaded).toBe(false);
});
it("rejects invalid APNs tokens", async () => {
const baseDir = await makeTempDir();
await expect(
registerApnsToken({
nodeId: "ios-node-1",
token: "not-a-token",
topic: "ai.openclaw.ios",
baseDir,
}),
).rejects.toThrow("invalid APNs token");
});
it("rejects oversized direct APNs registration fields", async () => {
const baseDir = await makeTempDir();
await expect(
registerApnsToken({
nodeId: "n".repeat(257),
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
baseDir,
}),
).rejects.toThrow("nodeId required");
await expect(
registerApnsToken({
nodeId: "ios-node-1",
token: "A".repeat(513),
topic: "ai.openclaw.ios",
baseDir,
}),
).rejects.toThrow("invalid APNs token");
await expect(
registerApnsToken({
nodeId: "ios-node-1",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "a".repeat(256),
baseDir,
}),
).rejects.toThrow("topic required");
});
it("rejects relay registrations that do not use production/official values", async () => {
const baseDir = await makeTempDir();
await expect(
registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "staging",
distribution: "official",
baseDir,
}),
).rejects.toThrow("relay registrations must use production environment");
await expect(
registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "beta",
baseDir,
}),
).rejects.toThrow("relay registrations must use official distribution");
});
it("rejects oversized relay registration identifiers", async () => {
const baseDir = await makeTempDir();
const oversized = "x".repeat(257);
await expect(
registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: oversized,
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
baseDir,
}),
).rejects.toThrow("relayHandle too long");
await expect(
registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: oversized,
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
baseDir,
}),
).rejects.toThrow("installationId too long");
await expect(
registerApnsRegistration({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "x".repeat(1025),
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
baseDir,
}),
).rejects.toThrow("sendGrant too long");
});
it("clears registrations", async () => {
const baseDir = await makeTempDir();
await registerApnsToken({
nodeId: "ios-node-1",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
baseDir,
});
await expect(clearApnsRegistration("ios-node-1", baseDir)).resolves.toBe(true);
await expect(loadApnsRegistration("ios-node-1", baseDir)).resolves.toBeNull();
});
it("only clears a registration when the stored entry still matches", async () => {
vi.useFakeTimers();
try {
const baseDir = await makeTempDir();
vi.setSystemTime(new Date("2026-03-11T00:00:00Z"));
const stale = await registerApnsToken({
nodeId: "ios-node-1",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
baseDir,
});
vi.setSystemTime(new Date("2026-03-11T00:00:01Z"));
const fresh = await registerApnsToken({
nodeId: "ios-node-1",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
baseDir,
});
await expect(
clearApnsRegistrationIfCurrent({
nodeId: "ios-node-1",
registration: stale,
baseDir,
}),
).resolves.toBe(false);
await expect(loadApnsRegistration("ios-node-1", baseDir)).resolves.toEqual(fresh);
} finally {
vi.useRealTimers();
}
});
});
describe("push APNs send semantics", () => {