// Phase 68 (GUARD-03, GUARD-04, GUARD-10): Kill system tests. // Tests: passcode verification (static + TOTP), brute-force lockout, TOTP auto-generation, // challenge/response flow, session stop/restart, allowList enforcement. // Added 2026-03-29. DO NOT REMOVE. import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock callWahaApi to avoid real HTTP calls vi.mock("../../http-client.js", () => ({ callWahaApi: vi.fn().mockResolvedValue({ ok: true }), })); // Mock guardian state functions vi.mock("./guardian.js", () => ({ markGuardianStopped: vi.fn(), clearGuardianStopped: vi.fn(), isGuardianStopped: vi.fn().mockReturnValue(false), sendGuardianAlert: vi.fn(), })); // Mock sendWahaText to avoid real HTTP calls vi.mock("../../send.js", () => ({ sendWahaText: vi.fn().mockResolvedValue(undefined), })); // Mock logger to suppress output vi.mock("../../logger.js", () => ({ createLogger: vi.fn().mockReturnValue({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: vi.fn().mockReturnThis(), }), })); import { KILL_COMMANDS_RE, isKillAllowed, verifyKillPasscode, generateTotpSecret, hasPendingKillChallenge, handleKillCommand, handleKillPendingResponse, clearExpiredChallenges, type KillSystemConfig, } from "./kill-system.js"; import { callWahaApi } from "../../http-client.js"; import { markGuardianStopped, clearGuardianStopped, sendGuardianAlert } from "./guardian.js"; import { sendWahaText } from "../../send.js"; import * as OTPAuth from "otpauth"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeStaticConfig(overrides: Partial = {}): KillSystemConfig { return { enabled: true, passcodeMode: "static", staticPasscode: "123456", totpSecret: undefined, allowList: ["972544329000"], ...KILL_DEFAULTS, ...overrides, }; } function makeTotpConfig(overrides: Partial = {}): KillSystemConfig { const secret = new OTPAuth.Secret({ size: 20 }); return { enabled: true, passcodeMode: "totp", totpSecret: secret.base32, allowList: ["972544329000"], ...KILL_DEFAULTS, ...overrides, }; } const SENDER = "972544329000@c.us"; const SENDER_WITH_DEVICE = "972544329000:26@s.whatsapp.net"; const CHAT_ID = "120363421825201386@g.us"; // Each test group uses a unique sender to avoid cross-test state pollution. // clearExpiredChallenges() only removes truly expired challenges (based on expiryMs), // so using the same sender across tests causes lockout state to leak. // DO NOT CHANGE — this isolation pattern prevents cascading failures. let senderCounter = 0; function uniqueSender(): string { senderCounter++; return `97254432${String(senderCounter).padStart(4, "0")}@c.us`; } function phoneFromSender(sender: string): string { return sender.replace(/@.*$/, ""); } const KILL_DEFAULTS = { maxAttempts: 3, cooldownSeconds: 300, challengeExpirySeconds: 120 }; const MOCK_ACCOUNT = { session: "test-session", baseUrl: "http://localhost:3004", apiKey: "test-key", }; function makeConfig(killCfg: Partial = {}) { return { baseUrl: "http://localhost:3004", apiKey: "test-key", guardianConfig: { enabled: true, killSystem: { enabled: true, passcodeMode: "static", staticPasscode: "123456", allowList: ["972544329000"], ...KILL_DEFAULTS, ...killCfg, }, }, }; } // --------------------------------------------------------------------------- // KILL_COMMANDS_RE // --------------------------------------------------------------------------- describe("KILL_COMMANDS_RE", () => { it("matches /kill", () => { expect(KILL_COMMANDS_RE.test("/kill")).toBe(true); }); it("matches /unkill", () => { expect(KILL_COMMANDS_RE.test("/unkill")).toBe(true); }); it("matches case-insensitive /KILL", () => { expect(KILL_COMMANDS_RE.test("/KILL")).toBe(true); }); it("does not match /killall", () => { expect(KILL_COMMANDS_RE.test("/killall")).toBe(false); }); it("does not match random text", () => { expect(KILL_COMMANDS_RE.test("hello")).toBe(false); }); }); // --------------------------------------------------------------------------- // isKillAllowed // --------------------------------------------------------------------------- describe("isKillAllowed", () => { it("returns false for empty allowList", () => { expect(isKillAllowed(SENDER, [])).toBe(false); }); it("returns true when sender phone in allowList", () => { expect(isKillAllowed(SENDER, ["972544329000"])).toBe(true); }); it("strips device suffix :N before matching", () => { expect(isKillAllowed(SENDER_WITH_DEVICE, ["972544329000"])).toBe(true); }); it("returns true for wildcard *", () => { expect(isKillAllowed(SENDER, ["*"])).toBe(true); }); it("returns false when phone not in allowList", () => { expect(isKillAllowed("999999@c.us", ["972544329000"])).toBe(false); }); it("handles null allowList gracefully", () => { expect(isKillAllowed(SENDER, null as any)).toBe(false); }); }); // --------------------------------------------------------------------------- // verifyKillPasscode — static mode // --------------------------------------------------------------------------- describe("verifyKillPasscode (static mode)", () => { const cfg = makeStaticConfig(); beforeEach(() => { // Reset challenges by clearing expired — seed a fresh challenge clearExpiredChallenges(); // expire everything }); it("returns no_challenge when no challenge exists", () => { const result = verifyKillPasscode("unknown@c.us", "123456", cfg); expect(result.success).toBe(false); if (!result.success) expect(result.reason).toBe("no_challenge"); }); // DO NOT CHANGE — each test uses uniqueSender() to avoid cross-test state pollution. // The module-level pendingChallenges map persists between tests. clearExpiredChallenges() // only removes challenges past their expiryMs, NOT all challenges. it("returns success for correct passcode", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: makeConfig({ allowList: [phone] }), }); const result = verifyKillPasscode(sender, "123456", cfg); expect(result.success).toBe(true); }); it("returns wrong and increments attempts for wrong passcode", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: makeConfig({ allowList: [phone] }), }); const result = verifyKillPasscode(sender, "999999", cfg); expect(result.success).toBe(false); if (!result.success) expect(result.reason).toBe("wrong"); }); it("locks after maxAttempts wrong passcodes", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: makeConfig({ allowList: [phone] }), }); // 3 wrong attempts verifyKillPasscode(sender, "000000", cfg); verifyKillPasscode(sender, "111111", cfg); const result = verifyKillPasscode(sender, "222222", cfg); expect(result.success).toBe(false); // After 3 wrong, should be locked const locked = verifyKillPasscode(sender, "123456", cfg); expect(locked.success).toBe(false); if (!locked.success) expect(locked.reason).toBe("locked"); }); it("returns locked during cooldown even with correct passcode", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: makeConfig({ allowList: [phone] }), }); // Manually trigger lockout verifyKillPasscode(sender, "000000", cfg); verifyKillPasscode(sender, "111111", cfg); verifyKillPasscode(sender, "222222", cfg); // 3rd wrong — triggers lock // Correct passcode during lockout const result = verifyKillPasscode(sender, "123456", cfg); expect(result.success).toBe(false); if (!result.success) expect(result.reason).toBe("locked"); }); it("returns expired when challenge is past challengeExpirySeconds", async () => { // Use challengeExpirySeconds: 0 in BOTH the config for handleKillCommand AND for verifyKillPasscode. // The challenge is created with expiryMs from the config at creation time, // but verifyKillPasscode checks expiry using the config passed to it. // Both must be 0 for the expired path to trigger. DO NOT CHANGE. const sender = uniqueSender(); const phone = phoneFromSender(sender); const shortConfig = makeConfig({ allowList: [phone], challengeExpirySeconds: 0 }); await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: shortConfig, }); const shortCfg = makeStaticConfig({ challengeExpirySeconds: 0 }); const result = verifyKillPasscode(sender, "123456", shortCfg); expect(result.success).toBe(false); if (!result.success) expect(result.reason).toBe("expired"); }); }); // --------------------------------------------------------------------------- // verifyKillPasscode — TOTP mode // --------------------------------------------------------------------------- describe("verifyKillPasscode (TOTP mode)", () => { beforeEach(() => { clearExpiredChallenges(); }); // DO NOT CHANGE — each test uses uniqueSender() to avoid cross-test state pollution. it("returns success for valid TOTP token", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); const totpCfg = makeTotpConfig({ allowList: [phone] }); const config = makeConfig({ passcodeMode: "totp", totpSecret: totpCfg.totpSecret, allowList: [phone], }); await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config, }); // Generate the current TOTP token const totp = new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(totpCfg.totpSecret!), algorithm: "SHA1", digits: 6, period: 30, }); const validToken = totp.generate(); const result = verifyKillPasscode(sender, validToken, totpCfg); expect(result.success).toBe(true); }); it("returns wrong for invalid TOTP token", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); const totpCfg = makeTotpConfig({ allowList: [phone] }); const config = makeConfig({ passcodeMode: "totp", totpSecret: totpCfg.totpSecret, allowList: [phone], }); await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config, }); const result = verifyKillPasscode(sender, "000000", totpCfg); expect(result.success).toBe(false); if (!result.success) expect(result.reason).toBe("wrong"); }); }); // --------------------------------------------------------------------------- // generateTotpSecret // --------------------------------------------------------------------------- describe("generateTotpSecret", () => { it("returns base32 string and otpauth:// URI", () => { const result = generateTotpSecret("test-account"); expect(result.base32).toMatch(/^[A-Z2-7]+=*$/); expect(result.uri).toMatch(/^otpauth:\/\/totp\//); }); it("URI contains issuer Chatlytics Guardian", () => { const result = generateTotpSecret("test-account"); expect(result.uri).toContain("Chatlytics%20Guardian"); }); it("generated secret is valid for TOTP generation", () => { const result = generateTotpSecret("test-account"); const totp = new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(result.base32), algorithm: "SHA1", digits: 6, period: 30, }); const token = totp.generate(); expect(token).toMatch(/^\d{6}$/); }); }); // --------------------------------------------------------------------------- // hasPendingKillChallenge // --------------------------------------------------------------------------- describe("hasPendingKillChallenge", () => { beforeEach(() => { clearExpiredChallenges(); }); it("returns false when no challenge exists", () => { expect(hasPendingKillChallenge("nobody@c.us")).toBe(false); }); it("returns true when challenge exists", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: makeConfig({ allowList: [phone] }), }); expect(hasPendingKillChallenge(sender)).toBe(true); }); }); // --------------------------------------------------------------------------- // handleKillCommand // --------------------------------------------------------------------------- describe("handleKillCommand", () => { beforeEach(() => { clearExpiredChallenges(); vi.mocked(callWahaApi).mockClear(); vi.mocked(sendWahaText).mockClear(); }); it("returns null for sender not in allowList (silent ignore)", async () => { const sender = uniqueSender(); const config = makeConfig({ allowList: ["000000000"] }); const result = await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config, }); expect(result).toBeNull(); }); it("returns reply containing 'passcode' for /kill with valid sender", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); const result = await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: makeConfig({ allowList: [phone] }), }); expect(result).not.toBeNull(); expect(result?.reply).toMatch(/passcode/i); }); it("returns reply containing 'passcode' for /unkill with valid sender", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); const result = await handleKillCommand({ command: "unkill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: makeConfig({ allowList: [phone] }), }); expect(result).not.toBeNull(); expect(result?.reply).toMatch(/passcode/i); }); it("reuses existing non-expired challenge (does not reset attempts)", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); const cfg = makeConfig({ allowList: [phone] }); // Create initial challenge await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); // Make a wrong attempt to increment attempts verifyKillPasscode(sender, "999999", makeStaticConfig()); // Send /kill again — should reuse challenge, not reset attempts const result = await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); expect(result).not.toBeNull(); // Challenge should still exist with incremented attempts expect(hasPendingKillChallenge(sender)).toBe(true); }); // DO NOT CHANGE — tests TOTP auto-generation when totpSecret is unset. // Config must have passcodeMode: "totp" and totpSecret must be explicitly deleted // (not just undefined in overrides) because makeConfig sets a default. it("auto-generates TOTP secret when totpSecret is unset", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); const config = makeConfig({ passcodeMode: "totp", allowList: [phone] }); // Explicitly remove totpSecret — makeConfig doesn't set it by default, // but resolveKillConfig reads from guardianConfig.killSystem.totpSecret delete (config.guardianConfig.killSystem as Record).totpSecret; const result = await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config, }); expect(result?.configUpdate?.totpSecret).toBeTruthy(); expect(result?.reply).toContain("otpauth://"); }); }); // --------------------------------------------------------------------------- // handleKillPendingResponse // --------------------------------------------------------------------------- describe("handleKillPendingResponse", () => { beforeEach(() => { clearExpiredChallenges(); vi.mocked(callWahaApi).mockClear(); vi.mocked(sendWahaText).mockClear(); vi.mocked(markGuardianStopped).mockClear(); vi.mocked(clearGuardianStopped).mockClear(); }); it("returns handled:false when no pending challenge", async () => { const result = await handleKillPendingResponse({ senderJid: "nobody@c.us", text: "123456", chatId: CHAT_ID, account: MOCK_ACCOUNT, config: makeConfig(), }); expect(result.handled).toBe(false); }); // DO NOT CHANGE — each test uses uniqueSender() to avoid cross-test state pollution. // The pendingChallenges map is module-level and persists between tests. it("handles incorrect passcode with remaining attempts message", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); const cfg = makeConfig({ allowList: [phone] }); await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); const result = await handleKillPendingResponse({ senderJid: sender, text: "000000", chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); expect(result.handled).toBe(true); expect(result.reply).toContain("Incorrect"); }); it("stops single session with correct passcode + calls markGuardianStopped", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); const cfg = makeConfig({ allowList: [phone] }); // Mock single active session vi.mocked(callWahaApi).mockImplementation(async (params) => { if (params.path === "/api/sessions") { return [{ name: "test-session", status: "WORKING" }]; } return { ok: true }; }); await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); const result = await handleKillPendingResponse({ senderJid: sender, text: "123456", chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); expect(result.handled).toBe(true); expect(result.reply).toContain("stopped"); expect(markGuardianStopped).toHaveBeenCalledWith("test-session"); }); it("returns numbered session list for multiple sessions", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); const cfg = makeConfig({ allowList: [phone] }); vi.mocked(callWahaApi).mockImplementation(async (params) => { if (params.path === "/api/sessions") { return [ { name: "session-1", status: "WORKING" }, { name: "session-2", status: "WORKING" }, ]; } return { ok: true }; }); await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); const result = await handleKillPendingResponse({ senderJid: sender, text: "123456", chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); expect(result.handled).toBe(true); expect(result.reply).toContain("1."); expect(result.reply).toContain("2."); }); it("stops selected session from numbered list", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); const cfg = makeConfig({ allowList: [phone] }); vi.mocked(callWahaApi).mockImplementation(async (params) => { if (params.path === "/api/sessions") { return [ { name: "session-1", status: "WORKING" }, { name: "session-2", status: "WORKING" }, ]; } return { ok: true }; }); await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); // Authenticate await handleKillPendingResponse({ senderJid: sender, text: "123456", chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); // Select session 2 vi.mocked(callWahaApi).mockResolvedValue({ ok: true }); const result = await handleKillPendingResponse({ senderJid: sender, text: "2", chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); expect(result.handled).toBe(true); expect(result.reply).toContain("session-2"); expect(markGuardianStopped).toHaveBeenCalledWith("session-2"); }); it("restarts session with /unkill + correct passcode + calls clearGuardianStopped", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); const cfg = makeConfig({ allowList: [phone] }); vi.mocked(callWahaApi).mockImplementation(async (params) => { if (params.path === "/api/sessions") { return [{ name: "test-session", status: "STOPPED" }]; } return { ok: true }; }); await handleKillCommand({ command: "unkill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); const result = await handleKillPendingResponse({ senderJid: sender, text: "123456", chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); expect(result.handled).toBe(true); expect(result.reply).toContain("restarted"); expect(clearGuardianStopped).toHaveBeenCalledWith("test-session"); }); it("auto-selects single stopped session for unkill", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); const cfg = makeConfig({ allowList: [phone] }); vi.mocked(callWahaApi).mockImplementation(async (params) => { if (params.path === "/api/sessions") { return [{ name: "stopped-session", status: "STOPPED" }]; } return { ok: true }; }); await handleKillCommand({ command: "unkill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); const result = await handleKillPendingResponse({ senderJid: sender, text: "123456", chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); expect(result.handled).toBe(true); expect(result.reply).toContain("stopped-session"); expect(result.reply).toContain("restarted"); expect(clearGuardianStopped).toHaveBeenCalledWith("stopped-session"); }); it("handles no sessions gracefully", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); const cfg = makeConfig({ allowList: [phone] }); vi.mocked(callWahaApi).mockImplementation(async (params) => { if (params.path === "/api/sessions") { return []; } return { ok: true }; }); await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); const result = await handleKillPendingResponse({ senderJid: sender, text: "123456", chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); expect(result.handled).toBe(true); expect(result.reply).toContain("No active sessions"); }); it("uses bypassPolicy:true on sendWahaText replies", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); const cfg = makeConfig({ allowList: [phone] }); vi.mocked(callWahaApi).mockImplementation(async (params) => { if (params.path === "/api/sessions") return []; return { ok: true }; }); await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); await handleKillPendingResponse({ senderJid: sender, text: "123456", chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); const calls = vi.mocked(sendWahaText).mock.calls; expect(calls.length).toBeGreaterThan(0); for (const [params] of calls) { expect(params.bypassPolicy).toBe(true); // Verify sendWahaText is called with the correct target chatId expect(params.to).toBe(CHAT_ID); } }); }); // --------------------------------------------------------------------------- // clearExpiredChallenges // --------------------------------------------------------------------------- describe("clearExpiredChallenges", () => { beforeEach(() => { clearExpiredChallenges(); }); // DO NOT CHANGE — creates challenge with 0 expiryMs so clearExpiredChallenges() actually removes it. // clearExpiredChallenges() only removes challenges where now - createdAt >= expiryMs. it("removes expired challenges", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); // Use challengeExpirySeconds: 0 so the challenge is created with expiryMs: 0 (immediately expired) const cfg = makeConfig({ allowList: [phone], challengeExpirySeconds: 0 }); await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config: cfg, }); // Challenge exists but is immediately expired (expiryMs: 0) // hasPendingKillChallenge checks expiry inline — with expiryMs: 0 it's already expired // So we need to verify clearExpiredChallenges cleans it up. // After clearExpiredChallenges(), the challenge map entry should be gone. clearExpiredChallenges(); expect(hasPendingKillChallenge(sender)).toBe(false); }); }); // --------------------------------------------------------------------------- // Phase 69 (GUARD-08): executeKillAction fires sendGuardianAlert tests // --------------------------------------------------------------------------- describe("executeKillAction fires sendGuardianAlert (GUARD-08)", () => { const mockCallWahaApi = vi.mocked(callWahaApi); const mockSendGuardianAlert = vi.mocked(sendGuardianAlert); beforeEach(() => { mockCallWahaApi.mockClear(); mockCallWahaApi.mockResolvedValue({ ok: true }); mockSendGuardianAlert.mockClear(); // Clear any pending challenges from previous tests clearExpiredChallenges(); }); // DO NOT CHANGE — each test uses uniqueSender() to avoid cross-test state pollution. it("calls sendGuardianAlert with event 'manual-kill' after successful /kill (manual-kill alert)", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); const config = { baseUrl: "http://localhost:3004", apiKey: "test-key", guardianConfig: { enabled: true, alertWebhookUrl: "http://alerts.example.com/hook", killSystem: { enabled: true, passcodeMode: "static" as const, staticPasscode: "123456", allowList: [phone], maxAttempts: 3, cooldownSeconds: 300, challengeExpirySeconds: 120, }, }, }; // Mock: first call returns sessions list (for getAccountSessions), second call is the stop API mockCallWahaApi .mockResolvedValueOnce([{ name: MOCK_ACCOUNT.session, status: "WORKING" }]) .mockResolvedValueOnce({ ok: true }); // Issue kill command and verify passcode to trigger executeKillAction await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config, }); // Now submit the correct passcode await handleKillPendingResponse({ senderJid: sender, text: "123456", chatId: CHAT_ID, account: MOCK_ACCOUNT, config, }); expect(mockSendGuardianAlert).toHaveBeenCalledWith( expect.objectContaining({ alertWebhookUrl: "http://alerts.example.com/hook", event: "manual-kill", session: MOCK_ACCOUNT.session, }) ); }); it("calls sendGuardianAlert with event 'manual-unkill' after successful /unkill (manual-unkill alert)", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); const config = { baseUrl: "http://localhost:3004", apiKey: "test-key", guardianConfig: { enabled: true, alertWebhookUrl: "http://alerts.example.com/hook", killSystem: { enabled: true, passcodeMode: "static" as const, staticPasscode: "123456", allowList: [phone], maxAttempts: 3, cooldownSeconds: 300, challengeExpirySeconds: 120, }, }, }; // Mock: first call returns sessions list (for getAccountSessions — unkill looks for STOPPED), second is restart API mockCallWahaApi .mockResolvedValueOnce([{ name: MOCK_ACCOUNT.session, status: "STOPPED" }]) .mockResolvedValueOnce({ ok: true }); await handleKillCommand({ command: "unkill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config, }); await handleKillPendingResponse({ senderJid: sender, text: "123456", chatId: CHAT_ID, account: MOCK_ACCOUNT, config, }); expect(mockSendGuardianAlert).toHaveBeenCalledWith( expect.objectContaining({ alertWebhookUrl: "http://alerts.example.com/hook", event: "manual-unkill", session: MOCK_ACCOUNT.session, }) ); }); it("does NOT call sendGuardianAlert when alertWebhookUrl is not configured (no alert without config)", async () => { const sender = uniqueSender(); const phone = phoneFromSender(sender); const config = makeConfig({ allowList: [phone] }); // makeConfig() has no alertWebhookUrl // Mock sessions list for getAccountSessions mockCallWahaApi .mockResolvedValueOnce([{ name: MOCK_ACCOUNT.session, status: "WORKING" }]) .mockResolvedValueOnce({ ok: true }); await handleKillCommand({ command: "kill", senderJid: sender, chatId: CHAT_ID, account: MOCK_ACCOUNT, config, }); await handleKillPendingResponse({ senderJid: sender, text: "123456", chatId: CHAT_ID, account: MOCK_ACCOUNT, config, }); expect(mockSendGuardianAlert).not.toHaveBeenCalled(); }); });