diff --git a/src/infra/infra-store.test.ts b/src/infra/infra-store.test.ts index 3faefcf7041..eb503473680 100644 --- a/src/infra/infra-store.test.ts +++ b/src/infra/infra-store.test.ts @@ -9,11 +9,6 @@ import { resetDiagnosticEventsForTest, } from "./diagnostic-events.js"; import { readSessionStoreJson5 } from "./state-migrations.fs.js"; -import { - defaultVoiceWakeTriggers, - loadVoiceWakeConfig, - setVoiceWakeTriggers, -} from "./voicewake.js"; describe("infra store", () => { describe("state migrations fs", () => { @@ -45,53 +40,6 @@ describe("infra store", () => { }); }); - describe("voicewake store", () => { - it("returns defaults when missing", async () => { - await withTempDir("openclaw-voicewake-", async (baseDir) => { - const cfg = await loadVoiceWakeConfig(baseDir); - expect(cfg.triggers).toEqual(defaultVoiceWakeTriggers()); - expect(cfg.updatedAtMs).toBe(0); - }); - }); - - it("sanitizes and persists triggers", async () => { - await withTempDir("openclaw-voicewake-", async (baseDir) => { - const saved = await setVoiceWakeTriggers([" hi ", "", " there "], baseDir); - expect(saved.triggers).toEqual(["hi", "there"]); - expect(saved.updatedAtMs).toBeGreaterThan(0); - - const loaded = await loadVoiceWakeConfig(baseDir); - expect(loaded.triggers).toEqual(["hi", "there"]); - expect(loaded.updatedAtMs).toBeGreaterThan(0); - }); - }); - - it("falls back to defaults when triggers empty", async () => { - await withTempDir("openclaw-voicewake-", async (baseDir) => { - const saved = await setVoiceWakeTriggers(["", " "], baseDir); - expect(saved.triggers).toEqual(defaultVoiceWakeTriggers()); - }); - }); - - it("sanitizes malformed persisted config values", async () => { - await withTempDir("openclaw-voicewake-", async (baseDir) => { - await fs.mkdir(path.join(baseDir, "settings"), { recursive: true }); - await fs.writeFile( - path.join(baseDir, "settings", "voicewake.json"), - JSON.stringify({ - triggers: [" wake ", "", 42, null], - updatedAtMs: -1, - }), - "utf-8", - ); - - const loaded = await loadVoiceWakeConfig(baseDir); - expect(loaded.triggers).toEqual(["wake"]); - expect(loaded.updatedAtMs).toBe(0); - }); - }); - }); - describe("diagnostic-events", () => { it("emits monotonic seq", async () => { resetDiagnosticEventsForTest(); diff --git a/src/infra/outbound/channel-target.test.ts b/src/infra/outbound/channel-target.test.ts new file mode 100644 index 00000000000..5d1f290d8f5 --- /dev/null +++ b/src/infra/outbound/channel-target.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { applyTargetToParams } from "./channel-target.js"; + +describe("applyTargetToParams", () => { + it("maps trimmed target values into the configured target field", () => { + const toParams = { + action: "send", + args: { target: " channel:C1 " } as Record, + }; + applyTargetToParams(toParams); + expect(toParams.args.to).toBe("channel:C1"); + + const channelIdParams = { + action: "channel-info", + args: { target: " C123 " } as Record, + }; + applyTargetToParams(channelIdParams); + expect(channelIdParams.args.channelId).toBe("C123"); + }); + + it("throws on legacy destination fields when the action has canonical target support", () => { + expect(() => + applyTargetToParams({ + action: "send", + args: { + target: "channel:C1", + to: "legacy", + }, + }), + ).toThrow("Use `target` instead of `to`/`channelId`."); + }); + + it("throws when a no-target action receives target or legacy destination fields", () => { + expect(() => + applyTargetToParams({ + action: "broadcast", + args: { + to: "legacy", + }, + }), + ).toThrow("Use `target` for actions that accept a destination."); + + expect(() => + applyTargetToParams({ + action: "broadcast", + args: { + target: "channel:C1", + }, + }), + ).toThrow("Action broadcast does not accept a target."); + }); + + it("does nothing when target is blank", () => { + const params = { + action: "send", + args: { target: " " } as Record, + }; + + applyTargetToParams(params); + + expect(params.args).toEqual({ target: " " }); + }); +}); diff --git a/src/infra/outbound/target-errors.test.ts b/src/infra/outbound/target-errors.test.ts new file mode 100644 index 00000000000..fb43f5279bf --- /dev/null +++ b/src/infra/outbound/target-errors.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + ambiguousTargetError, + ambiguousTargetMessage, + missingTargetError, + missingTargetMessage, + unknownTargetError, + unknownTargetMessage, +} from "./target-errors.js"; + +describe("target error helpers", () => { + it("formats missing-target messages with and without hints", () => { + expect(missingTargetMessage("Slack")).toBe("Delivering to Slack requires target"); + expect(missingTargetMessage("Slack", "Use channel:C123")).toBe( + "Delivering to Slack requires target Use channel:C123", + ); + expect(missingTargetError("Slack", "Use channel:C123").message).toBe( + "Delivering to Slack requires target Use channel:C123", + ); + }); + + it("formats ambiguous and unknown target messages with labeled hints", () => { + expect(ambiguousTargetMessage("Discord", "general")).toBe( + 'Ambiguous target "general" for Discord. Provide a unique name or an explicit id.', + ); + expect(ambiguousTargetMessage("Discord", "general", "Use channel:123")).toBe( + 'Ambiguous target "general" for Discord. Provide a unique name or an explicit id. Hint: Use channel:123', + ); + expect(unknownTargetMessage("Discord", "general", "Use channel:123")).toBe( + 'Unknown target "general" for Discord. Hint: Use channel:123', + ); + expect(ambiguousTargetError("Discord", "general", "Use channel:123").message).toContain( + "Hint: Use channel:123", + ); + expect(unknownTargetError("Discord", "general").message).toBe( + 'Unknown target "general" for Discord.', + ); + }); +}); diff --git a/src/infra/pairing-token.test.ts b/src/infra/pairing-token.test.ts new file mode 100644 index 00000000000..2d6a5964396 --- /dev/null +++ b/src/infra/pairing-token.test.ts @@ -0,0 +1,30 @@ +import { Buffer } from "node:buffer"; +import { describe, expect, it, vi } from "vitest"; + +const randomBytesMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:crypto", async () => { + const actual = await vi.importActual("node:crypto"); + return { + ...actual, + randomBytes: (...args: unknown[]) => randomBytesMock(...args), + }; +}); + +import { generatePairingToken, PAIRING_TOKEN_BYTES, verifyPairingToken } from "./pairing-token.js"; + +describe("generatePairingToken", () => { + it("uses the configured byte count and returns a base64url token", () => { + randomBytesMock.mockReturnValueOnce(Buffer.from([0xfb, 0xff, 0x00])); + + expect(generatePairingToken()).toBe("-_8A"); + expect(randomBytesMock).toHaveBeenCalledWith(PAIRING_TOKEN_BYTES); + }); +}); + +describe("verifyPairingToken", () => { + it("uses constant-time comparison semantics", () => { + expect(verifyPairingToken("secret-token", "secret-token")).toBe(true); + expect(verifyPairingToken("secret-token", "secret-tokEn")).toBe(false); + }); +}); diff --git a/src/infra/voicewake.test.ts b/src/infra/voicewake.test.ts new file mode 100644 index 00000000000..d719a496e81 --- /dev/null +++ b/src/infra/voicewake.test.ts @@ -0,0 +1,55 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { withTempDir } from "../test-utils/temp-dir.js"; +import { + defaultVoiceWakeTriggers, + loadVoiceWakeConfig, + setVoiceWakeTriggers, +} from "./voicewake.js"; + +describe("voicewake config", () => { + it("returns defaults when missing", async () => { + await withTempDir("openclaw-voicewake-", async (baseDir) => { + await expect(loadVoiceWakeConfig(baseDir)).resolves.toEqual({ + triggers: defaultVoiceWakeTriggers(), + updatedAtMs: 0, + }); + }); + }); + + it("sanitizes and persists triggers", async () => { + await withTempDir("openclaw-voicewake-", async (baseDir) => { + const saved = await setVoiceWakeTriggers([" hi ", "", " there "], baseDir); + expect(saved.triggers).toEqual(["hi", "there"]); + expect(saved.updatedAtMs).toBeGreaterThan(0); + + await expect(loadVoiceWakeConfig(baseDir)).resolves.toEqual({ + triggers: ["hi", "there"], + updatedAtMs: saved.updatedAtMs, + }); + }); + }); + + it("falls back to defaults for empty or malformed persisted values", async () => { + await withTempDir("openclaw-voicewake-", async (baseDir) => { + const emptySaved = await setVoiceWakeTriggers(["", " "], baseDir); + expect(emptySaved.triggers).toEqual(defaultVoiceWakeTriggers()); + + await fs.mkdir(path.join(baseDir, "settings"), { recursive: true }); + await fs.writeFile( + path.join(baseDir, "settings", "voicewake.json"), + JSON.stringify({ + triggers: [" wake ", "", 42, null], + updatedAtMs: -1, + }), + "utf8", + ); + + await expect(loadVoiceWakeConfig(baseDir)).resolves.toEqual({ + triggers: ["wake"], + updatedAtMs: 0, + }); + }); + }); +});