test: add bash test suite covering pipelock helpers and smoke flows
Adds tests/ with a tiny bash assert harness, manifest fixtures, and a
runner. No framework dependency — each test file is self-contained
and exits 0 on pass / 1 on fail; tests/run_tests.sh aggregates.
Unit tests (no docker):
- pipelock_naming: container_name, proxy_url, proxy_host_port shape
- pipelock_classify: _pipelock_is_ipv4_literal classifier coverage
- pipelock_allowlist: bottle_allowlist + ssh hostnames/ip_cidrs/
trusted_domains + effective_allowlist union/dedup/sort, plus
rejection of non-string entries
- pipelock_yaml: emitter shape (mode/enforce/api_allowlist/forward_proxy/
dlp), conditional ssrf+trusted_domains blocks, secret hygiene
(manifest env values must not appear in YAML), file mode 600
Integration tests (require docker, skip cleanly otherwise):
- pipelock_image: pinned digest's ENTRYPOINT is /pipelock and CMD
contains 'run' and the binary --version succeeds — would catch a
future image bump that changes the launcher's argv contract
- pipelock_sidecar_smoke: docker create + cp YAML to /etc/pipelock.yaml
+ start, then probe /health — the regression test for the bug
where the YAML was written to /etc/pipelock/ (parent dir absent in
the distroless image)
- dry_run_plan: cli.sh start --dry-run shows the egress line,
counts the bottle's entry into the effective allowlist, prints
the dry-run banner, and creates zero docker resources
- orphan_cleanup: the cleanup primitives the start-flow trap depends
on (network_remove, pipelock_stop) are idempotent against
missing/never-existed resources, so the trap is safe even if
pipelock_start dies before everything is wired up
Assisted-by: Claude Code
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tiny assertion helpers. No framework — each test file sources this,
|
||||
# calls `assert_*` functions, and ends with `test_summary` which exits
|
||||
# 0 if every assertion passed and 1 otherwise.
|
||||
#
|
||||
# Counters are file-local: every test process gets its own TEST_PASS /
|
||||
# TEST_FAIL. run_tests.sh aggregates by exit code, not by reading these.
|
||||
|
||||
if [ -n "${CLAUDE_BOTTLE_TESTS_ASSERT_SOURCED:-}" ]; then
|
||||
return 0
|
||||
fi
|
||||
CLAUDE_BOTTLE_TESTS_ASSERT_SOURCED=1
|
||||
|
||||
TEST_PASS=0
|
||||
TEST_FAIL=0
|
||||
TEST_NAME="${TEST_NAME:-unnamed}"
|
||||
|
||||
if [ -t 1 ]; then
|
||||
_C_PASS=$'\033[32m'
|
||||
_C_FAIL=$'\033[31m'
|
||||
_C_SKIP=$'\033[33m'
|
||||
_C_RESET=$'\033[0m'
|
||||
else
|
||||
_C_PASS=""
|
||||
_C_FAIL=""
|
||||
_C_SKIP=""
|
||||
_C_RESET=""
|
||||
fi
|
||||
|
||||
_pass() {
|
||||
TEST_PASS=$((TEST_PASS + 1))
|
||||
printf ' %sPASS%s %s\n' "$_C_PASS" "$_C_RESET" "$1"
|
||||
}
|
||||
|
||||
_fail() {
|
||||
TEST_FAIL=$((TEST_FAIL + 1))
|
||||
printf ' %sFAIL%s %s\n' "$_C_FAIL" "$_C_RESET" "$1" >&2
|
||||
shift
|
||||
local line
|
||||
for line in "$@"; do
|
||||
printf ' %s\n' "$line" >&2
|
||||
done
|
||||
}
|
||||
|
||||
assert_eq() {
|
||||
local expected="$1" actual="$2" msg="${3:-equal}"
|
||||
if [ "$expected" = "$actual" ]; then
|
||||
_pass "$msg"
|
||||
else
|
||||
_fail "$msg" "expected: ${expected}" "actual: ${actual}"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local haystack="$1" needle="$2" msg="${3:-contains}"
|
||||
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
|
||||
_pass "$msg"
|
||||
else
|
||||
_fail "$msg" "expected to contain: ${needle}" "haystack: ${haystack}"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local haystack="$1" needle="$2" msg="${3:-does not contain}"
|
||||
if ! printf '%s' "$haystack" | grep -qF -- "$needle"; then
|
||||
_pass "$msg"
|
||||
else
|
||||
_fail "$msg" "expected NOT to contain: ${needle}" "haystack: ${haystack}"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_match() {
|
||||
local haystack="$1" pattern="$2" msg="${3:-matches}"
|
||||
if printf '%s' "$haystack" | grep -qE -- "$pattern"; then
|
||||
_pass "$msg"
|
||||
else
|
||||
_fail "$msg" "expected pattern: ${pattern}" "haystack: ${haystack}"
|
||||
fi
|
||||
}
|
||||
|
||||
# assert_exit_zero <cmd...> — runs the command, fails the assertion
|
||||
# if it exits non-zero. Captures stdout+stderr for the failure message.
|
||||
assert_exit_zero() {
|
||||
local label="$1"; shift
|
||||
local out
|
||||
if out="$("$@" 2>&1)"; then
|
||||
_pass "$label"
|
||||
else
|
||||
_fail "$label" "exit non-zero" "output: ${out}"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_exit_nonzero() {
|
||||
local label="$1"; shift
|
||||
local out
|
||||
if out="$("$@" 2>&1)"; then
|
||||
_fail "$label" "exit was 0; expected non-zero" "output: ${out}"
|
||||
else
|
||||
_pass "$label"
|
||||
fi
|
||||
}
|
||||
|
||||
skip() {
|
||||
printf ' %sSKIP%s %s\n' "$_C_SKIP" "$_C_RESET" "$1"
|
||||
}
|
||||
|
||||
skip_test_if_no_docker() {
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
printf '%sSKIP%s %s — docker not on PATH\n' "$_C_SKIP" "$_C_RESET" "$TEST_NAME"
|
||||
exit 0
|
||||
fi
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
printf '%sSKIP%s %s — docker daemon unreachable\n' "$_C_SKIP" "$_C_RESET" "$TEST_NAME"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
test_summary() {
|
||||
printf '\n%s: %d passed, %d failed\n' "$TEST_NAME" "$TEST_PASS" "$TEST_FAIL"
|
||||
if [ "$TEST_FAIL" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Common scaffolding for every test file. Sources assert.sh and computes
|
||||
# REPO_ROOT so tests can `. "${REPO_ROOT}/lib/<x>.sh"` to load the code
|
||||
# they're exercising.
|
||||
|
||||
if [ -n "${CLAUDE_BOTTLE_TESTS_COMMON_SOURCED:-}" ]; then
|
||||
return 0
|
||||
fi
|
||||
CLAUDE_BOTTLE_TESTS_COMMON_SOURCED=1
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
_tests_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TESTS_ROOT="$_tests_dir"
|
||||
REPO_ROOT="$(CDPATH= cd -- "${TESTS_ROOT}/.." && pwd)"
|
||||
|
||||
# shellcheck source=./assert.sh
|
||||
. "${TESTS_ROOT}/lib/assert.sh"
|
||||
# shellcheck source=./fixtures.sh
|
||||
. "${TESTS_ROOT}/lib/fixtures.sh"
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# Manifest fixture builders. Each function prints a JSON manifest on
|
||||
# stdout; callers can pipe to a temp file or pass through `write_fixture`.
|
||||
|
||||
if [ -n "${CLAUDE_BOTTLE_TESTS_FIXTURES_SOURCED:-}" ]; then
|
||||
return 0
|
||||
fi
|
||||
CLAUDE_BOTTLE_TESTS_FIXTURES_SOURCED=1
|
||||
|
||||
# fixture_minimal — one bottle, one agent, no env / ssh / skills.
|
||||
fixture_minimal() {
|
||||
cat <<'JSON'
|
||||
{
|
||||
"bottles": {
|
||||
"dev": {}
|
||||
},
|
||||
"agents": {
|
||||
"demo": {
|
||||
"skills": [],
|
||||
"prompt": "",
|
||||
"bottle": "dev"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
}
|
||||
|
||||
# fixture_with_egress — bottle declares an egress.allowlist.
|
||||
fixture_with_egress() {
|
||||
cat <<'JSON'
|
||||
{
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"egress": {
|
||||
"allowlist": [
|
||||
"github.com",
|
||||
"gitlab.com",
|
||||
"registry.npmjs.org"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"demo": {
|
||||
"skills": [],
|
||||
"prompt": "",
|
||||
"bottle": "dev"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
}
|
||||
|
||||
# fixture_with_ssh — bottle has both an IPv4-literal SSH host (Tailscale
|
||||
# CGNAT range) and a hostname SSH host, exercising both
|
||||
# ssrf.ip_allowlist and trusted_domains code paths.
|
||||
fixture_with_ssh() {
|
||||
cat <<'JSON'
|
||||
{
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"ssh": [
|
||||
{
|
||||
"Host": "tailscale-gitea",
|
||||
"IdentityFile": "/dev/null",
|
||||
"Hostname": "100.78.141.42",
|
||||
"User": "git",
|
||||
"Port": 30009
|
||||
},
|
||||
{
|
||||
"Host": "github",
|
||||
"IdentityFile": "/dev/null",
|
||||
"Hostname": "github.com",
|
||||
"User": "git",
|
||||
"Port": 22
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"demo": {
|
||||
"skills": [],
|
||||
"prompt": "",
|
||||
"bottle": "dev"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
}
|
||||
|
||||
# write_fixture <fixture_func> — write fixture to a temp file, print
|
||||
# the path. Caller must rm.
|
||||
write_fixture() {
|
||||
local fn="${1:?write_fixture: missing fixture function}"
|
||||
local f
|
||||
f="$(mktemp)"
|
||||
"$fn" > "$f"
|
||||
printf '%s' "$f"
|
||||
}
|
||||
Reference in New Issue
Block a user