mirror of https://github.com/openclaw/openclaw.git
test: extract apns auth helper coverage
This commit is contained in:
parent
60f2aba40d
commit
d4f36fe0be
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,44 +3,20 @@ import fs from "node:fs/promises";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
deriveDeviceIdFromPublicKey,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
verifyDeviceSignature,
|
||||
} from "./device-identity.js";
|
||||
import {
|
||||
clearApnsRegistration,
|
||||
clearApnsRegistrationIfCurrent,
|
||||
loadApnsRegistration,
|
||||
normalizeApnsEnvironment,
|
||||
registerApnsRegistration,
|
||||
registerApnsToken,
|
||||
resolveApnsAuthConfigFromEnv,
|
||||
sendApnsAlert,
|
||||
sendApnsBackgroundWake,
|
||||
shouldClearStoredApnsRegistration,
|
||||
shouldInvalidateApnsRegistration,
|
||||
} from "./push-apns.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" })
|
||||
.privateKey.export({ format: "pem", type: "pkcs8" })
|
||||
.toString();
|
||||
const relayGatewayIdentity = (() => {
|
||||
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
||||
const publicKeyPem = publicKey.export({ format: "pem", type: "spki" }).toString();
|
||||
const publicKeyRaw = publicKeyRawBase64UrlFromPem(publicKeyPem);
|
||||
const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw);
|
||||
if (!deviceId) {
|
||||
throw new Error("failed to derive test gateway device id");
|
||||
}
|
||||
return {
|
||||
deviceId,
|
||||
publicKey: publicKeyRaw,
|
||||
privateKeyPem: privateKey.export({ format: "pem", type: "pkcs8" }).toString(),
|
||||
};
|
||||
})();
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-test-"));
|
||||
tempDirs.push(dir);
|
||||
|
|
@ -297,40 +273,6 @@ describe("push APNs registration store", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("push APNs env config", () => {
|
||||
it("normalizes APNs environment values", () => {
|
||||
expect(normalizeApnsEnvironment("sandbox")).toBe("sandbox");
|
||||
expect(normalizeApnsEnvironment("PRODUCTION")).toBe("production");
|
||||
expect(normalizeApnsEnvironment("staging")).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves inline private key and unescapes newlines", async () => {
|
||||
const env = {
|
||||
OPENCLAW_APNS_TEAM_ID: "TEAM123",
|
||||
OPENCLAW_APNS_KEY_ID: "KEY123",
|
||||
OPENCLAW_APNS_PRIVATE_KEY_P8:
|
||||
"-----BEGIN PRIVATE KEY-----\\nline-a\\nline-b\\n-----END PRIVATE KEY-----", // pragma: allowlist secret
|
||||
} as NodeJS.ProcessEnv;
|
||||
const resolved = await resolveApnsAuthConfigFromEnv(env);
|
||||
expect(resolved.ok).toBe(true);
|
||||
if (!resolved.ok) {
|
||||
return;
|
||||
}
|
||||
expect(resolved.value.privateKey).toContain("\nline-a\n");
|
||||
expect(resolved.value.teamId).toBe("TEAM123");
|
||||
expect(resolved.value.keyId).toBe("KEY123");
|
||||
});
|
||||
|
||||
it("returns an error when required APNs auth vars are missing", async () => {
|
||||
const resolved = await resolveApnsAuthConfigFromEnv({} as NodeJS.ProcessEnv);
|
||||
expect(resolved.ok).toBe(false);
|
||||
if (resolved.ok) {
|
||||
return;
|
||||
}
|
||||
expect(resolved.error).toContain("OPENCLAW_APNS_TEAM_ID");
|
||||
});
|
||||
});
|
||||
|
||||
describe("push APNs send semantics", () => {
|
||||
it("sends alert pushes with alert headers and payload", async () => {
|
||||
const { send, registration, auth } = createDirectApnsSendFixture({
|
||||
|
|
@ -440,127 +382,4 @@ describe("push APNs send semantics", () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("routes relay-backed alert pushes through the relay sender", async () => {
|
||||
const send = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
apnsId: "relay-apns-id",
|
||||
environment: "production",
|
||||
tokenSuffix: "abcd1234",
|
||||
});
|
||||
|
||||
const result = await sendApnsAlert({
|
||||
relayConfig: {
|
||||
baseUrl: "https://relay.example.com",
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
registration: {
|
||||
nodeId: "ios-node-relay",
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
updatedAtMs: 1,
|
||||
tokenDebugSuffix: "abcd1234",
|
||||
},
|
||||
nodeId: "ios-node-relay",
|
||||
title: "Wake",
|
||||
body: "Ping",
|
||||
relayGatewayIdentity: relayGatewayIdentity,
|
||||
relayRequestSender: send,
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send.mock.calls[0]?.[0]).toMatchObject({
|
||||
relayHandle: "relay-handle-123",
|
||||
gatewayDeviceId: relayGatewayIdentity.deviceId,
|
||||
pushType: "alert",
|
||||
priority: "10",
|
||||
payload: {
|
||||
aps: {
|
||||
alert: { title: "Wake", body: "Ping" },
|
||||
sound: "default",
|
||||
},
|
||||
},
|
||||
});
|
||||
const sent = send.mock.calls[0]?.[0];
|
||||
expect(typeof sent?.signature).toBe("string");
|
||||
expect(typeof sent?.signedAtMs).toBe("number");
|
||||
const signedPayload = [
|
||||
"openclaw-relay-send-v1",
|
||||
sent?.gatewayDeviceId,
|
||||
String(sent?.signedAtMs),
|
||||
sent?.bodyJson,
|
||||
].join("\n");
|
||||
expect(
|
||||
verifyDeviceSignature(relayGatewayIdentity.publicKey, signedPayload, sent?.signature),
|
||||
).toBe(true);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
status: 200,
|
||||
transport: "relay",
|
||||
environment: "production",
|
||||
tokenSuffix: "abcd1234",
|
||||
});
|
||||
});
|
||||
|
||||
it("flags invalid device responses for registration invalidation", () => {
|
||||
expect(shouldInvalidateApnsRegistration({ status: 400, reason: "BadDeviceToken" })).toBe(true);
|
||||
expect(shouldInvalidateApnsRegistration({ status: 410, reason: "Unregistered" })).toBe(true);
|
||||
expect(shouldInvalidateApnsRegistration({ status: 429, reason: "TooManyRequests" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("only clears stored registrations for direct APNs failures without an override mismatch", () => {
|
||||
expect(
|
||||
shouldClearStoredApnsRegistration({
|
||||
registration: {
|
||||
nodeId: "ios-node-direct",
|
||||
transport: "direct",
|
||||
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
updatedAtMs: 1,
|
||||
},
|
||||
result: { status: 400, reason: "BadDeviceToken" },
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldClearStoredApnsRegistration({
|
||||
registration: {
|
||||
nodeId: "ios-node-relay",
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
updatedAtMs: 1,
|
||||
},
|
||||
result: { status: 410, reason: "Unregistered" },
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldClearStoredApnsRegistration({
|
||||
registration: {
|
||||
nodeId: "ios-node-direct",
|
||||
transport: "direct",
|
||||
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
updatedAtMs: 1,
|
||||
},
|
||||
result: { status: 400, reason: "BadDeviceToken" },
|
||||
overrideEnvironment: "production",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue