diff --git a/src/hooks/claude-code-hooks/execute-http-hook-security.test.ts b/src/hooks/claude-code-hooks/execute-http-hook-security.test.ts index 11e08a668..65ee6f37d 100644 --- a/src/hooks/claude-code-hooks/execute-http-hook-security.test.ts +++ b/src/hooks/claude-code-hooks/execute-http-hook-security.test.ts @@ -1,8 +1,8 @@ /// -import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test" +import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test" import type { HookHttp } from "./types" -import * as sharedLogger from "../../shared/logger" +import * as sharedModule from "../../shared" const mockFetch = mock(() => Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) @@ -15,8 +15,20 @@ async function importFreshExecuteHttpHook() { return import(modulePath) } +function installSharedLogMock(logCalls: Array<{ message: string; data?: unknown }>): void { + const sharedMockFactory = () => ({ + ...sharedModule, + log: (message: string, data?: unknown) => { + logCalls.push({ message, data }) + }, + }) + + mock.module("../../shared", sharedMockFactory) + mock.module("../../shared/index.ts", sharedMockFactory) +} + describe("executeHttpHook TLS security", () => { - let logSpy: ReturnType | undefined + let logCalls: Array<{ message: string; data?: unknown }> beforeEach(() => { globalThis.fetch = mockFetch as unknown as typeof fetch @@ -24,13 +36,12 @@ describe("executeHttpHook TLS security", () => { mockFetch.mockImplementation(() => Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) ) + logCalls = [] }) afterEach(() => { globalThis.fetch = originalFetch process.env = { ...originalEnv } - logSpy?.mockRestore() - logSpy = undefined mockFetch.mockReset() mock.restore() }) @@ -62,9 +73,9 @@ describe("executeHttpHook TLS security", () => { expect(mockFetch).not.toHaveBeenCalled() }) - it("#when hook uses remote http:// URL #then logs warning before rejection", async () => { + it("#when hook uses remote http:// URL #then rejects with exit code 1", async () => { // given - logSpy = spyOn(sharedLogger, "log").mockImplementation(() => {}) + installSharedLogMock(logCalls) const { executeHttpHook } = await importFreshExecuteHttpHook() const hook: HookHttp = { type: "http", url: "http://tls-security-remote.invalid/hooks" } @@ -72,13 +83,8 @@ describe("executeHttpHook TLS security", () => { const result = await executeHttpHook(hook, "{}") // then - const matchingCalls = logSpy.mock.calls.filter(([message, data]) => { - return message === "HTTP hook URL uses insecure protocol" - && JSON.stringify(data) === JSON.stringify({ url: hook.url }) - }) - expect(result.exitCode).toBe(1) - expect(matchingCalls).toHaveLength(1) + expect(result.stderr).toContain("HTTP hook URL must use HTTPS") expect(mockFetch).not.toHaveBeenCalled() }) @@ -94,7 +100,7 @@ describe("executeHttpHook TLS security", () => { it("#when hook uses http://localhost #then does not log insecure warning", async () => { // given - logSpy = spyOn(sharedLogger, "log").mockImplementation(() => {}) + installSharedLogMock(logCalls) const { executeHttpHook } = await importFreshExecuteHttpHook() const hook: HookHttp = { type: "http", url: "http://localhost:49123/hooks" } @@ -102,7 +108,7 @@ describe("executeHttpHook TLS security", () => { const result = await executeHttpHook(hook, "{}") // then - const matchingCalls = logSpy.mock.calls.filter(([message, data]) => { + const matchingCalls = logCalls.filter(({ message, data }) => { return message === "HTTP hook URL uses insecure protocol" && JSON.stringify(data) === JSON.stringify({ url: hook.url }) }) @@ -168,22 +174,19 @@ describe("executeHttpHook TLS security", () => { expect(mockFetch).toHaveBeenCalledTimes(1) }) - it("#when hook uses plain remote http:// URL #then writes warning log", async () => { + it("#when hook uses plain remote http:// URL #then rejects with exit code 1", async () => { // given - logSpy = spyOn(sharedLogger, "log").mockImplementation(() => {}) + installSharedLogMock(logCalls) const { executeHttpHook } = await importFreshExecuteHttpHook() const hook: HookHttp = { type: "http", url: "http://tls-security-dev.invalid/hooks" } // when - await executeHttpHook(hook, "{}") + const result = await executeHttpHook(hook, "{}") // then - const matchingCalls = logSpy.mock.calls.filter(([message, data]) => { - return message === "HTTP hook URL uses insecure protocol" - && JSON.stringify(data) === JSON.stringify({ url: hook.url }) - }) - - expect(matchingCalls).toHaveLength(1) + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("HTTP hook URL must use HTTPS") + expect(mockFetch).not.toHaveBeenCalled() }) it("#when hook uses http://[::1] #then allows execution", async () => { diff --git a/src/hooks/comment-checker/cli.test.ts b/src/hooks/comment-checker/cli.test.ts index 376b285f3..c10a34a4a 100644 --- a/src/hooks/comment-checker/cli.test.ts +++ b/src/hooks/comment-checker/cli.test.ts @@ -3,6 +3,7 @@ import { chmodSync, mkdtempSync, writeFileSync } from "node:fs" import { join } from "node:path" import { tmpdir } from "node:os" +import { processWithCli } from "./cli-runner" import type { PendingCall } from "./types" function createMockInput() { @@ -151,20 +152,15 @@ exit 2 getCommentCheckerPath: mock(async () => "/fake"), startBackgroundInit: mock(() => {}), }) - mock.module("./cli", cliMockFactory) - mock.module("./cli.ts", cliMockFactory) - mock.module(new URL("./cli.ts", import.meta.url).href, cliMockFactory) - const concurrentRunnerBasePath = new URL("./cli-runner.ts", import.meta.url).pathname - const concurrentModulePath = `${concurrentRunnerBasePath}?semaphore-concurrent` - const { processWithCli } = await import(concurrentModulePath) + const cliMocks = cliMockFactory() const pendingCall: PendingCall = { tool: "write", sessionID: "ses-1", filePath: "/tmp/a.ts", timestamp: Date.now(), } - const firstCall = processWithCli({ tool: "write", sessionID: "ses-1", callID: "call-1" }, pendingCall, { output: "" }, "/fake", undefined, () => {}) - const secondCall = processWithCli({ tool: "write", sessionID: "ses-2", callID: "call-2" }, pendingCall, { output: "" }, "/fake", undefined, () => {}) + const firstCall = processWithCli({ tool: "write", sessionID: "ses-1", callID: "call-1" }, pendingCall, { output: "" }, "/fake", undefined, () => {}, { runCommentChecker: cliMocks.runCommentChecker }) + const secondCall = processWithCli({ tool: "write", sessionID: "ses-2", callID: "call-2" }, pendingCall, { output: "" }, "/fake", undefined, () => {}, { runCommentChecker: cliMocks.runCommentChecker }) // when await secondCall @@ -185,12 +181,7 @@ exit 2 getCommentCheckerPath: mock(async () => "/fake"), startBackgroundInit: mock(() => {}), }) - mock.module("./cli", cliMockFactory) - mock.module("./cli.ts", cliMockFactory) - mock.module(new URL("./cli.ts", import.meta.url).href, cliMockFactory) - const sequentialRunnerBasePath = new URL("./cli-runner.ts", import.meta.url).pathname - const sequentialModulePath = `${sequentialRunnerBasePath}?semaphore-sequential` - const { processWithCli } = await import(sequentialModulePath) + const cliMocks = cliMockFactory() const pendingCall: PendingCall = { tool: "write", sessionID: "ses-1", @@ -198,8 +189,8 @@ exit 2 timestamp: Date.now(), } // when - await processWithCli({ tool: "write", sessionID: "ses-1", callID: "call-1" }, pendingCall, { output: "" }, "/fake", undefined, () => {}) - await processWithCli({ tool: "write", sessionID: "ses-2", callID: "call-2" }, pendingCall, { output: "" }, "/fake", undefined, () => {}) + await processWithCli({ tool: "write", sessionID: "ses-1", callID: "call-1" }, pendingCall, { output: "" }, "/fake", undefined, () => {}, { runCommentChecker: cliMocks.runCommentChecker }) + await processWithCli({ tool: "write", sessionID: "ses-2", callID: "call-2" }, pendingCall, { output: "" }, "/fake", undefined, () => {}, { runCommentChecker: cliMocks.runCommentChecker }) // then expect(callCount).toBe(2) }) diff --git a/src/hooks/read-image-resizer/image-resizer.test.ts b/src/hooks/read-image-resizer/image-resizer.test.ts index 1bdf3f1a0..cac306516 100644 --- a/src/hooks/read-image-resizer/image-resizer.test.ts +++ b/src/hooks/read-image-resizer/image-resizer.test.ts @@ -12,6 +12,10 @@ async function importFreshImageResizerModule(): Promise { return import(`./image-resizer?test-${Date.now()}-${Math.random()}`) } +function loadUnavailableSharpModule(): Promise { + return Promise.resolve(null) +} + const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) const CRC_TABLE = (() => { @@ -160,9 +164,6 @@ describe("resizeImage", () => { it("falls back to pure-JS resizer for PNG when sharp is unavailable", async () => { //#given - mock.module("sharp", () => { - throw new Error("sharp unavailable") - }) const { resizeImage } = await importFreshImageResizerModule() const oversizedPng = createOversizedPngDataUrl(3000, 2000) @@ -170,7 +171,7 @@ describe("resizeImage", () => { const result = await resizeImage(oversizedPng, "image/png", { width: 1568, height: 1045, - }) + }, { loadSharpModule: loadUnavailableSharpModule }) //#then expect(result).not.toBeNull() @@ -181,16 +182,13 @@ describe("resizeImage", () => { it("returns null for non-PNG when sharp is unavailable", async () => { //#given - mock.module("sharp", () => { - throw new Error("sharp unavailable") - }) const { resizeImage } = await importFreshImageResizerModule() //#when const result = await resizeImage(PNG_1X1_DATA_URL, "image/jpeg", { width: 1, height: 1, - }) + }, { loadSharpModule: loadUnavailableSharpModule }) //#then expect(result).toBeNull() @@ -198,9 +196,6 @@ describe("resizeImage", () => { it("falls back to pure-JS resizer when sharp has unexpected shape", async () => { //#given - mock.module("sharp", () => ({ - default: "not-a-function", - })) const { resizeImage } = await importFreshImageResizerModule() const oversizedPng = createOversizedPngDataUrl(2000, 1000) @@ -208,7 +203,7 @@ describe("resizeImage", () => { const result = await resizeImage(oversizedPng, "image/png", { width: 1568, height: 784, - }) + }, { loadSharpModule: async () => ({ default: "not-a-function" }) }) //#then expect(result).not.toBeNull() @@ -223,16 +218,13 @@ describe("resizeImage", () => { }, })) - mock.module("sharp", () => ({ - default: mockSharpFactory, - })) const { resizeImage } = await importFreshImageResizerModule() //#when const result = await resizeImage(PNG_1X1_DATA_URL, "image/png", { width: 1, height: 1, - }) + }, { loadSharpModule: async () => ({ default: mockSharpFactory }) }) //#then expect(result).toBeNull() diff --git a/src/hooks/rules-injector/injector.test.ts b/src/hooks/rules-injector/injector.test.ts index 129d6364b..88b8076d4 100644 --- a/src/hooks/rules-injector/injector.test.ts +++ b/src/hooks/rules-injector/injector.test.ts @@ -1,10 +1,11 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import * as os from "node:os"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { RULES_INJECTOR_STORAGE } from "./constants"; +import { createRuleInjectionProcessor } from "./injector"; type StatSnapshot = { mtimeMs: number; size: number }; @@ -28,47 +29,6 @@ async function createProcessor(projectRoot: string): Promise<{ output: { title: string; output: string; metadata: unknown } ) => Promise; }> { - mock.module("node:fs", () => ({ - ...fs, - readFileSync: (filePath: string, encoding?: string) => { - if (filePath === trackedRulePath) { - trackedReadFileCount += 1; - } - return originalReadFileSync(filePath, encoding as never); - }, - statSync: (filePath: string) => { - if (filePath === trackedRulePath) { - const next = statSnapshots.shift(); - if (next instanceof Error) { - throw next; - } - if (next) { - return { - mtimeMs: next.mtimeMs, - size: next.size, - isFile: () => true, - } as ReturnType; - } - } - return originalStatSync(filePath); - }, - })); - - mock.module("node:os", () => ({ - ...os, - homedir: () => mockedHomeDir || originalHomedir(), - })); - - mock.module("./matcher", () => ({ - shouldApplyRule: () => ({ applies: true, reason: "matched" }), - isDuplicateByRealPath: (realPath: string, cache: Set) => - cache.has(realPath), - createContentHash: (content: string) => `hash:${content}`, - isDuplicateByContentHash: (hash: string, cache: Set) => cache.has(hash), - })); - - const { createRuleInjectionProcessor } = await import(`./injector?test=${Date.now()}-${Math.random()}`); - mock.restore(); const sessionCaches = new Map< string, { contentHashes: Set; realPaths: Set } @@ -95,6 +55,33 @@ async function createProcessor(projectRoot: string): Promise<{ } return cache; }, + readFileSync: (filePath: fs.PathOrFileDescriptor, options?: Parameters[1]) => { + if (filePath === trackedRulePath) { + trackedReadFileCount += 1; + } + return originalReadFileSync(filePath, options as never); + }, + statSync: (filePath: fs.PathLike) => { + if (filePath === trackedRulePath) { + const next = statSnapshots.shift(); + if (next instanceof Error) { + throw next; + } + if (next) { + return { + mtimeMs: next.mtimeMs, + size: next.size, + isFile: () => true, + } as ReturnType; + } + } + return originalStatSync(filePath); + }, + homedir: () => mockedHomeDir || originalHomedir(), + shouldApplyRule: () => ({ applies: true, reason: "matched" }), + isDuplicateByRealPath: (realPath: string, cache: Set) => cache.has(realPath), + createContentHash: (content: string) => `hash:${content}`, + isDuplicateByContentHash: (hash: string, cache: Set) => cache.has(hash), }); }