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),
});
}