diff options
Diffstat (limited to 'languages/python/tests')
| -rw-r--r-- | languages/python/tests/pre-commit.bats | 138 | ||||
| -rw-r--r-- | languages/python/tests/validate-python.bats | 117 |
2 files changed, 255 insertions, 0 deletions
diff --git a/languages/python/tests/pre-commit.bats b/languages/python/tests/pre-commit.bats new file mode 100644 index 0000000..1ac82ee --- /dev/null +++ b/languages/python/tests/pre-commit.bats @@ -0,0 +1,138 @@ +#!/usr/bin/env bats +# +# Tests for languages/python/githooks/pre-commit — the secret scan plus +# syntax/lint gate that runs on staged Python files. +# +# The secret scan is the security-critical half and is language-independent, so +# it gets the same coverage here as in the bash bundle: a real key blocks, a +# clean diff passes, and the case-sensitivity split that keeps base64 blobs from +# false-positiving is exercised directly. +# +# Each test builds a throwaway git repo, stages content, and runs the hook from +# inside it — the hook reads `git diff --cached`, so a real index is required. + +HOOK="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/githooks/pre-commit" + +setup() { + TEST_DIR="$(mktemp -d -t pre-commit-py-bats.XXXXXX)" + cd "$TEST_DIR" || exit 1 + git init -q . + git config user.email t@example.com + git config user.name Test + # A base commit so `git diff --cached` has a parent to diff against. + echo "seed" > seed.txt + git add seed.txt + git commit -qm seed +} + +teardown() { + cd / || true + rm -rf "$TEST_DIR" +} + +# ---- Normal ---------------------------------------------------------- + +@test "pre-commit(py): a clean staged Python file passes (exit 0)" { + printf 'def f(x):\n return x + 1\n' > ok.py + git add ok.py + run bash "$HOOK" + [ "$status" -eq 0 ] +} + +@test "pre-commit(py): an empty staging area passes (exit 0)" { + run bash "$HOOK" + [ "$status" -eq 0 ] +} + +# ---- Error: the secret scan ------------------------------------------ + +@test "pre-commit(py): an AWS key in a staged file blocks (exit 1)" { + printf 'KEY = "AKIAIOSFODNN7EXAMPLE"\n' > conf.py + git add conf.py + run bash "$HOOK" + [ "$status" -eq 1 ] + [[ "$output" == *"potential secret"* ]] +} + +@test "pre-commit(py): an sk- style token blocks (exit 1)" { + printf 'TOKEN = "sk-abcdefghijklmnopqrstuvwxyz0123"\n' > conf.py + git add conf.py + run bash "$HOOK" + [ "$status" -eq 1 ] +} + +@test "pre-commit(py): a quoted api_key assignment blocks (exit 1)" { + printf 'api_key = "abcdefghijklmnopqrstuvwxyz"\n' > conf.py + git add conf.py + run bash "$HOOK" + [ "$status" -eq 1 ] +} + +@test "pre-commit(py): a private-key header blocks (exit 1)" { + printf 'PEM = """-----BEGIN RSA PRIVATE KEY-----"""\n' > conf.py + git add conf.py + run bash "$HOOK" + [ "$status" -eq 1 ] +} + +# ---- Boundary: the case-sensitivity split ---------------------------- + +@test "pre-commit(py): a mixed-case base64 blob does NOT false-positive" { + # The AWS pattern is uppercase-only by design. Under -i it would match any + # 20-char mixed-case run, which random base64 hits often enough to block + # real commits. This is the regression test for that split. + printf 'BLOB = "AKIAbcdefGHIJklmnOPqr0123456789abcdefGHIJ"\n' > data.py + git add data.py + run bash "$HOOK" + [ "$status" -eq 0 ] +} + +@test "pre-commit(py): a short quoted password value does NOT block" { + # The keyword patterns require 16+ chars, so a placeholder stays quiet. + printf 'password = "short"\n' > conf.py + git add conf.py + run bash "$HOOK" + [ "$status" -eq 0 ] +} + +@test "pre-commit(py): a secret only in a REMOVED line does not block" { + printf 'KEY = "AKIAIOSFODNN7EXAMPLE"\n' > conf.py + git add conf.py + git commit -qm "add key" + rm conf.py + git add -A + run bash "$HOOK" + [ "$status" -eq 0 ] +} + +# ---- Error: the syntax gate ------------------------------------------ + +@test "pre-commit(py): a staged Python syntax error blocks (exit 1)" { + printf 'def f(:\n return 1\n' > bad.py + git add bad.py + run bash "$HOOK" + [ "$status" -eq 1 ] + [[ "$output" == *"syntax"* ]] +} + +@test "pre-commit(py): a .pyi stub with a syntax error blocks (exit 1)" { + printf 'def f( -> int: ...\n' > bad.pyi + git add bad.pyi + run bash "$HOOK" + [ "$status" -eq 1 ] +} + +@test "pre-commit(py): a broken NON-Python file does not trip the syntax gate" { + printf 'this is (((not python\n' > notes.txt + git add notes.txt + run bash "$HOOK" + [ "$status" -eq 0 ] +} + +@test "pre-commit(py): the syntax gate leaves no __pycache__ in the repo" { + printf 'def f():\n return 1\n' > ok.py + git add ok.py + run bash "$HOOK" + [ "$status" -eq 0 ] + [ ! -d __pycache__ ] +} diff --git a/languages/python/tests/validate-python.bats b/languages/python/tests/validate-python.bats new file mode 100644 index 0000000..b5e4957 --- /dev/null +++ b/languages/python/tests/validate-python.bats @@ -0,0 +1,117 @@ +#!/usr/bin/env bats +# +# Tests for languages/python/claude/hooks/validate-python.sh — the PostToolUse +# hook that syntax-checks edited Python files and blocks on a violation. +# +# The hook reads tool-call JSON on stdin and extracts the file path, so each +# test pipes a JSON payload naming a real file it wrote into a temp dir. +# +# The syntax gate is python3's own compiler, which is present wherever the hook +# can meaningfully run, so those tests never skip. The lint gate (ruff) is +# optional and its tests skip when it's absent, matching the bash bundle's +# treatment of shellcheck. + +HOOK="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/claude/hooks/validate-python.sh" + +setup() { + TEST_DIR="$(mktemp -d -t validate-python-bats.XXXXXX)" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +payload() { + printf '{"tool_input": {"file_path": "%s"}}' "$1" +} + +# ---- Normal ---------------------------------------------------------- + +@test "validate-python: a clean .py file passes silently (exit 0)" { + printf 'def f(x):\n return x + 1\n' > "$TEST_DIR/clean.py" + run bash "$HOOK" <<< "$(payload "$TEST_DIR/clean.py")" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "validate-python: a .pyi stub is validated too" { + printf 'def f(x: int) -> int: ...\n' > "$TEST_DIR/clean.pyi" + run bash "$HOOK" <<< "$(payload "$TEST_DIR/clean.pyi")" + [ "$status" -eq 0 ] +} + +# ---- Error ----------------------------------------------------------- + +@test "validate-python: a syntax error blocks (exit 2, names the failure)" { + printf 'def f(:\n return 1\n' > "$TEST_DIR/bad.py" + run bash "$HOOK" <<< "$(payload "$TEST_DIR/bad.py")" + [ "$status" -eq 2 ] + [[ "$output" == *"SYNTAX"* ]] +} + +@test "validate-python: the block payload is valid JSON carrying the context" { + printf 'def f(:\n' > "$TEST_DIR/bad.py" + run bash "$HOOK" <<< "$(payload "$TEST_DIR/bad.py")" + [ "$status" -eq 2 ] + # The first line of stdout must parse as JSON and carry the hook event name. + echo "$output" | head -1 | jq -e '.hookSpecificOutput.hookEventName == "PostToolUse"' +} + +@test "validate-python: a ruff violation blocks when ruff is installed" { + command -v ruff >/dev/null 2>&1 || skip "ruff not installed" + # F821: reference to an undefined name — syntactically valid, lint-caught. + printf 'def f():\n return undefined_name\n' > "$TEST_DIR/lint.py" + run bash "$HOOK" <<< "$(payload "$TEST_DIR/lint.py")" + [ "$status" -eq 2 ] + [[ "$output" == *"RUFF"* ]] +} + +# ---- Boundary -------------------------------------------------------- + +@test "validate-python: a non-Python file is ignored (exit 0)" { + printf 'not python at all (((\n' > "$TEST_DIR/notes.txt" + run bash "$HOOK" <<< "$(payload "$TEST_DIR/notes.txt")" + [ "$status" -eq 0 ] +} + +@test "validate-python: an extensionless file with a python shebang is validated" { + printf '#!/usr/bin/env python3\ndef f(:\n' > "$TEST_DIR/cli-tool" + run bash "$HOOK" <<< "$(payload "$TEST_DIR/cli-tool")" + [ "$status" -eq 2 ] +} + +@test "validate-python: an extensionless non-python file is ignored (exit 0)" { + printf '#!/usr/bin/env bash\necho hi\n' > "$TEST_DIR/shell-tool" + run bash "$HOOK" <<< "$(payload "$TEST_DIR/shell-tool")" + [ "$status" -eq 0 ] +} + +@test "validate-python: a dotted parent directory does not misfire the extension test" { + mkdir -p "$TEST_DIR/my.project" + printf '#!/usr/bin/env python3\ndef f(:\n' > "$TEST_DIR/my.project/cli-tool" + run bash "$HOOK" <<< "$(payload "$TEST_DIR/my.project/cli-tool")" + [ "$status" -eq 2 ] +} + +@test "validate-python: empty file_path is a no-op (exit 0)" { + run bash "$HOOK" <<< '{"tool_input": {}}' + [ "$status" -eq 0 ] +} + +@test "validate-python: a missing file is a no-op (exit 0)" { + run bash "$HOOK" <<< "$(payload "$TEST_DIR/does-not-exist.py")" + [ "$status" -eq 0 ] +} + +@test "validate-python: an empty .py file passes (valid, compiles to nothing)" { + : > "$TEST_DIR/empty.py" + run bash "$HOOK" <<< "$(payload "$TEST_DIR/empty.py")" + [ "$status" -eq 0 ] +} + +@test "validate-python: compiling leaves no __pycache__ beside the file" { + printf 'def f():\n return 1\n' > "$TEST_DIR/clean.py" + run bash "$HOOK" <<< "$(payload "$TEST_DIR/clean.py")" + [ "$status" -eq 0 ] + [ ! -d "$TEST_DIR/__pycache__" ] +} |
