From b2b0b9b0fa012941c27f9d45e67554216d504341 Mon Sep 17 00:00:00 2001 From: David Ibia Date: Mon, 12 Jan 2026 13:50:43 +0100 Subject: [PATCH] test: add Node test suite and pre-commit hook --- .gitignore | 3 + .husky/.gitignore | 1 + .husky/pre-commit | 5 ++ AGENTS.md | 17 ++-- package-lock.json | 17 ++++ package.json | 6 +- test/envsitter-guard.hook.test.ts | 121 +++++++++++++++++++++++++++++ test/envsitter-guard.tools.test.ts | 105 +++++++++++++++++++++++++ tsconfig.test.json | 11 +++ 9 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 .husky/.gitignore create mode 100755 .husky/pre-commit create mode 100644 test/envsitter-guard.hook.test.ts create mode 100644 test/envsitter-guard.tools.test.ts create mode 100644 tsconfig.test.json diff --git a/.gitignore b/.gitignore index 657711b..f7ae81a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ node_modules/ .env* !.env.example +# build artifacts +/dist-test/ + # misc .DS_Store npm-debug.log* diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000..54cd2d7 --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +# Keep this directory in git. diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..acfd8a0 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npm run typecheck +npm test diff --git a/AGENTS.md b/AGENTS.md index a94c306..3979461 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,17 +40,18 @@ This repo does not emit JS as part of its normal workflow. ### Tests -- No test runner/config is configured in the root project (`package.json` has no `test` script). +Tests use Node’s built-in test runner, compiled via `tsc` into `dist-test/`. -If/when tests are added, prefer Node’s built-in test runner (matches existing ecosystem usage): -- Run all tests: - - `node --test` +- Run all tests (builds then runs): + - `npm test` +- Build test output only: + - `npm run build:test` - Run a single test file: - - `node --test path/to/some.test.js` -- Run a single test by name/pattern: - - `node --test --test-name-pattern "pattern" path/to/some.test.js` + - `npm run build:test && node --test dist-test/test/envsitter-guard.hook.test.js` -Note: This repo is TypeScript-only and `tsconfig.json` uses `noEmit: true`. If you add tests, ensure the test runner can execute them (either compile first, or use a TS-capable runner) and document the chosen approach. +Pre-commit checks are enforced via Husky: +- `npm install` installs the git hook. +- `.husky/pre-commit` runs `npm run typecheck` and `npm test`. ## TypeScript / module conventions diff --git a/package-lock.json b/package-lock.json index 55afa57..ad1d091 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "@opencode-ai/plugin": "*", "@types/node": "*", + "husky": "^9.1.7", "typescript": "*" } }, @@ -51,6 +52,22 @@ "envsitter": "dist/cli.js" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index b927ff9..35a89f4 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,16 @@ "devDependencies": { "@opencode-ai/plugin": "*", "@types/node": "*", + "husky": "^9.1.7", "typescript": "*" }, "dependencies": { "envsitter": "*" }, "scripts": { - "typecheck": "tsc -p tsconfig.json" + "typecheck": "tsc -p tsconfig.json", + "build:test": "tsc -p tsconfig.test.json", + "test": "npm run build:test && node --test dist-test/test/*.test.js", + "prepare": "husky" } } diff --git a/test/envsitter-guard.hook.test.ts b/test/envsitter-guard.hook.test.ts new file mode 100644 index 0000000..59a8311 --- /dev/null +++ b/test/envsitter-guard.hook.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import type { Hooks, PluginInput } from "@opencode-ai/plugin"; + +import EnvSitterGuard from "../index.js"; + +type ToolExecuteBeforeHook = NonNullable; + +async function createTmpDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "envsitter-guard-")); + return dir; +} + +function createClientSpy(): { + client: { + tui: { + showToast: (input: { body: { title: string; variant: string; message: string } }) => Promise; + appendPrompt: (input: { body: { text: string } }) => Promise; + }; + }; + calls: { showToast: number; appendPrompt: number }; +} { + const calls = { showToast: 0, appendPrompt: 0 }; + + return { + calls, + client: { + tui: { + async showToast() { + calls.showToast += 1; + }, + async appendPrompt() { + calls.appendPrompt += 1; + }, + }, + }, + }; +} + +async function getBeforeHook(params: { directory: string; worktree: string }) { + const { client, calls } = createClientSpy(); + + const pluginInput: PluginInput = { + client: client as unknown as PluginInput["client"], + project: {} as unknown as PluginInput["project"], + directory: params.directory, + worktree: params.worktree, + serverUrl: new URL("http://localhost"), + $: (() => { + throw new Error("not used in tests"); + }) as unknown as PluginInput["$"], + }; + + const hooks = (await EnvSitterGuard(pluginInput)) as { + "tool.execute.before": ToolExecuteBeforeHook; + }; + + return { hook: hooks["tool.execute.before"], calls }; +} + +test("blocks reading .env", async () => { + const worktree = await createTmpDir(); + const { hook } = await getBeforeHook({ directory: worktree, worktree }); + + await assert.rejects( + () => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } }), + (err: unknown) => err instanceof Error && err.message.includes("Reading `.env*` is blocked"), + ); +}); + +test("allows reading .env.example", async () => { + const worktree = await createTmpDir(); + const { hook } = await getBeforeHook({ directory: worktree, worktree }); + + await hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env.example" } }); +}); + +test("blocks editing .env", async () => { + const worktree = await createTmpDir(); + const { hook } = await getBeforeHook({ directory: worktree, worktree }); + + await assert.rejects( + () => hook({ tool: "edit", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } }), + (err: unknown) => err instanceof Error && err.message.includes("Editing `.env*"), + ); +}); + +test("blocks .envsitter/pepper", async () => { + const worktree = await createTmpDir(); + const { hook } = await getBeforeHook({ directory: worktree, worktree }); + + await assert.rejects( + () => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".envsitter/pepper" } }), + (err: unknown) => err instanceof Error && err.message.includes("blocked"), + ); +}); + +test("strips @ prefix in filePath", async () => { + const worktree = await createTmpDir(); + const { hook } = await getBeforeHook({ directory: worktree, worktree }); + + await assert.rejects( + () => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: "@.env" } }), + (err: unknown) => err instanceof Error && err.message.includes("Reading `.env*` is blocked"), + ); +}); + +test("toasts are throttled", async () => { + const worktree = await createTmpDir(); + const { hook, calls } = await getBeforeHook({ directory: worktree, worktree }); + + await assert.rejects(() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } })); + await assert.rejects(() => hook({ tool: "read", sessionID: "s", callID: "c" }, { args: { filePath: ".env" } })); + + assert.equal(calls.showToast, 1); + assert.equal(calls.appendPrompt, 1); +}); diff --git a/test/envsitter-guard.tools.test.ts b/test/envsitter-guard.tools.test.ts new file mode 100644 index 0000000..2a663cf --- /dev/null +++ b/test/envsitter-guard.tools.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import type { PluginInput } from "@opencode-ai/plugin"; + +import EnvSitterGuard from "../index.js"; + +type ToolApi = { + envsitter_keys: { + execute: (args: { filePath?: string }) => Promise; + }; + envsitter_fingerprint: { + execute: (args: { filePath?: string; key: string }) => Promise; + }; +}; + +async function createTmpDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "envsitter-guard-")); + return dir; +} + +function createMinimalClient(): PluginInput["client"] { + return { + tui: { + async showToast() {}, + async appendPrompt() {}, + }, + } as unknown as PluginInput["client"]; +} + +async function getTools(params: { directory: string; worktree: string }): Promise { + const pluginInput: PluginInput = { + client: createMinimalClient(), + project: {} as unknown as PluginInput["project"], + directory: params.directory, + worktree: params.worktree, + serverUrl: new URL("http://localhost"), + $: (() => { + throw new Error("not used in tests"); + }) as unknown as PluginInput["$"], + }; + + const hooks = (await EnvSitterGuard(pluginInput)) as unknown as { + tool: ToolApi; + }; + + return hooks.tool; +} + +test("envsitter_keys lists keys without values", async () => { + const worktree = await createTmpDir(); + await fs.writeFile(path.join(worktree, ".env"), "FOO=bar\nBAZ=qux\n"); + + const tools = await getTools({ directory: worktree, worktree }); + const out = await tools.envsitter_keys.execute({ filePath: ".env" }); + + assert.ok(!out.includes("bar")); + assert.ok(!out.includes("qux")); + + const parsed = JSON.parse(out) as { file: string; keys: string[] }; + assert.equal(parsed.file, ".env"); + assert.deepEqual(parsed.keys.sort(), ["BAZ", "FOO"].sort()); +}); + +test("envsitter_fingerprint is deterministic and does not leak values", async () => { + const worktree = await createTmpDir(); + await fs.writeFile(path.join(worktree, ".env"), "DATABASE_URL=postgres://user:pass@host/db\n"); + + const tools = await getTools({ directory: worktree, worktree }); + const out1 = await tools.envsitter_fingerprint.execute({ filePath: ".env", key: "DATABASE_URL" }); + const out2 = await tools.envsitter_fingerprint.execute({ filePath: ".env", key: "DATABASE_URL" }); + + assert.ok(!out1.includes("postgres://")); + assert.equal(out1, out2); + + const parsed = JSON.parse(out1) as { file: string; key: string; result: unknown }; + assert.equal(parsed.file, ".env"); + assert.equal(parsed.key, "DATABASE_URL"); +}); + +test("tool execution rejects paths outside worktree", async () => { + const worktree = await createTmpDir(); + const directory = path.join(worktree, "a", "b"); + await fs.mkdir(directory, { recursive: true }); + + const tools = await getTools({ directory, worktree }); + + await assert.rejects( + () => tools.envsitter_keys.execute({ filePath: "../../../.env" }), + (err: unknown) => err instanceof Error && err.message.includes("inside the current project"), + ); +}); + +test("tool execution blocks .envsitter/pepper", async () => { + const worktree = await createTmpDir(); + const tools = await getTools({ directory: worktree, worktree }); + + await assert.rejects( + () => tools.envsitter_keys.execute({ filePath: ".envsitter/pepper" }), + (err: unknown) => err instanceof Error && err.message.includes("blocked"), + ); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..952a204 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist-test", + "declaration": false, + "declarationMap": false, + "sourceMap": true + }, + "include": ["index.ts", ".opencode/plugin/**/*.ts", "test/**/*.ts"] +}