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:
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Integration: cli.sh start --dry-run renders the planned shape and
|
||||
# does not create any docker resources. Confirms the preflight contract
|
||||
# from PRD 0001 (allowlist line in the plan, no docker side effects).
|
||||
TEST_NAME="dry_run_plan"
|
||||
|
||||
. "$(dirname "$0")/../lib/common.sh"
|
||||
|
||||
skip_test_if_no_docker
|
||||
|
||||
work_dir="$(mktemp -d)"
|
||||
manifest="${work_dir}/claude-bottle.json"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$work_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Manifest with an egress.allowlist so we can grep for a known host.
|
||||
cat > "$manifest" <<'JSON'
|
||||
{
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"egress": { "allowlist": ["example.org"] }
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"demo": {
|
||||
"skills": [],
|
||||
"prompt": "",
|
||||
"bottle": "dev"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
# Snapshot docker state before we run.
|
||||
nets_before="$(docker network ls --format '{{.Name}}' | grep -c '^claude-bottle' || true)"
|
||||
ctrs_before="$(docker ps -a --format '{{.Names}}' | grep -c '^claude-bottle' || true)"
|
||||
|
||||
# Override HOME so the user's ~/claude-bottle.json doesn't leak in via
|
||||
# manifest_resolve's home+cwd merge.
|
||||
out="$(cd "$work_dir" \
|
||||
&& HOME="$work_dir" CLAUDE_BOTTLE_DRY_RUN=1 \
|
||||
"${REPO_ROOT}/cli.sh" start demo 2>&1 || true)"
|
||||
|
||||
assert_contains "$out" "egress" "preflight: egress line present"
|
||||
# 7 baked defaults + 1 bottle entry = 8. The summary line shows the
|
||||
# total count regardless of which entries fit in the visible
|
||||
# "<a>, <b>, <c>, +N more" prefix, so this assertion is robust against
|
||||
# alphabetical sort order changes.
|
||||
assert_match "$out" "8 hosts allowed" "preflight: bottle entry counted in effective allowlist"
|
||||
assert_contains "$out" "api.anthropic.com" "preflight: baked default shown"
|
||||
assert_contains "$out" "dry-run requested" "dry-run banner present"
|
||||
assert_not_contains "$out" "/dev/tty" "no /dev/tty prompt reached (dry-run exited first)"
|
||||
|
||||
# No docker side effects.
|
||||
nets_after="$(docker network ls --format '{{.Name}}' | grep -c '^claude-bottle' || true)"
|
||||
ctrs_after="$(docker ps -a --format '{{.Names}}' | grep -c '^claude-bottle' || true)"
|
||||
assert_eq "$nets_before" "$nets_after" "dry-run: no claude-bottle networks created"
|
||||
assert_eq "$ctrs_before" "$ctrs_after" "dry-run: no claude-bottle containers created"
|
||||
|
||||
test_summary
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Integration: the cleanup primitives the start-flow trap depends on
|
||||
# are idempotent. The original orphan-network bug was a trap-ordering
|
||||
# issue (cleanup_all installed AFTER networks were created); the fix
|
||||
# moved the install earlier. The trap is only safe if the helpers it
|
||||
# calls — network_remove, pipelock_stop — are no-ops against
|
||||
# already-missing or never-existed resources. We test that here.
|
||||
#
|
||||
# (The full end-to-end "cli.sh dies mid-run, networks gone" flow needs
|
||||
# a TTY and is documented as a manual verification step in tests/README.md.)
|
||||
TEST_NAME="orphan_cleanup"
|
||||
|
||||
. "$(dirname "$0")/../lib/common.sh"
|
||||
# shellcheck source=../../lib/log.sh
|
||||
. "${REPO_ROOT}/lib/log.sh"
|
||||
# shellcheck source=../../lib/docker.sh
|
||||
. "${REPO_ROOT}/lib/docker.sh"
|
||||
# shellcheck source=../../lib/network.sh
|
||||
. "${REPO_ROOT}/lib/network.sh"
|
||||
# shellcheck source=../../lib/pipelock.sh
|
||||
. "${REPO_ROOT}/lib/pipelock.sh"
|
||||
|
||||
skip_test_if_no_docker
|
||||
|
||||
slug="cb-test-orphan-$$"
|
||||
internal_name=""
|
||||
egress_name=""
|
||||
|
||||
cleanup() {
|
||||
for n in "$internal_name" "$egress_name"; do
|
||||
[ -n "$n" ] && docker network rm "$n" >/dev/null 2>&1 || true
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# 1. network_remove against a name that doesn't exist returns 0
|
||||
# (the trap can call it eagerly without crashing on the first run
|
||||
# where the network was never created).
|
||||
assert_exit_zero "network_remove: missing network is a no-op" \
|
||||
network_remove "claude-bottle-net-${slug}-does-not-exist"
|
||||
|
||||
# 2. Create both networks the way cli.sh does, then remove them with
|
||||
# network_remove. Both should succeed and the networks should be
|
||||
# gone afterwards.
|
||||
internal_name="$(network_create_internal "$slug")"
|
||||
egress_name="$(network_create_egress "$slug")"
|
||||
|
||||
assert_match "$(docker network ls --format '{{.Name}}')" "^${internal_name}$" \
|
||||
"internal network was created"
|
||||
assert_match "$(docker network ls --format '{{.Name}}')" "^${egress_name}$" \
|
||||
"egress network was created"
|
||||
|
||||
assert_exit_zero "network_remove: removes existing internal network" \
|
||||
network_remove "$internal_name"
|
||||
assert_exit_zero "network_remove: removes existing egress network" \
|
||||
network_remove "$egress_name"
|
||||
|
||||
nets_after="$(docker network ls --format '{{.Name}}')"
|
||||
assert_not_contains "$nets_after" "$internal_name" "internal network gone after removal"
|
||||
assert_not_contains "$nets_after" "$egress_name" "egress network gone after removal"
|
||||
|
||||
# 3. Removing a second time is still safe — the trap may run after a
|
||||
# clean exit, where the resources are already gone.
|
||||
assert_exit_zero "network_remove: idempotent on already-removed internal" \
|
||||
network_remove "$internal_name"
|
||||
assert_exit_zero "network_remove: idempotent on already-removed egress" \
|
||||
network_remove "$egress_name"
|
||||
|
||||
# 4. pipelock_stop against a slug whose sidecar was never started must
|
||||
# also be a no-op — same reason.
|
||||
assert_exit_zero "pipelock_stop: missing sidecar is a no-op" \
|
||||
pipelock_stop "missing-${slug}"
|
||||
|
||||
test_summary
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Integration: verify the pinned pipelock image. Requires docker.
|
||||
# - Pinned digest is reachable on the registry.
|
||||
# - Image's ENTRYPOINT/CMD match what lib/pipelock.sh assumes
|
||||
# (`/pipelock` and `run --listen 0.0.0.0:8888`).
|
||||
# - The /pipelock binary actually runs (--version succeeds).
|
||||
#
|
||||
# This is the test that would have caught the runtime bug where the
|
||||
# CMD shape diverged from what the launcher passed.
|
||||
TEST_NAME="pipelock_image"
|
||||
|
||||
. "$(dirname "$0")/../lib/common.sh"
|
||||
# shellcheck source=../../lib/log.sh
|
||||
. "${REPO_ROOT}/lib/log.sh"
|
||||
# shellcheck source=../../lib/pipelock.sh
|
||||
. "${REPO_ROOT}/lib/pipelock.sh"
|
||||
|
||||
skip_test_if_no_docker
|
||||
|
||||
# Pull the pinned image (cheap if already cached).
|
||||
if ! docker pull "$CLAUDE_BOTTLE_PIPELOCK_IMAGE" >/dev/null 2>&1; then
|
||||
skip "could not pull ${CLAUDE_BOTTLE_PIPELOCK_IMAGE}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ENTRYPOINT must be the binary path lib/pipelock.sh expects.
|
||||
entrypoint="$(docker image inspect "$CLAUDE_BOTTLE_PIPELOCK_IMAGE" --format '{{json .Config.Entrypoint}}')"
|
||||
assert_contains "$entrypoint" "/pipelock" "entrypoint contains /pipelock"
|
||||
|
||||
# CMD must include `run` — the subcommand the launcher overrides via
|
||||
# `docker create ... run --config ... --listen ...`. If a future image
|
||||
# bumps the CMD shape, this fails loudly.
|
||||
cmd="$(docker image inspect "$CLAUDE_BOTTLE_PIPELOCK_IMAGE" --format '{{json .Config.Cmd}}')"
|
||||
assert_contains "$cmd" "run" "cmd contains 'run'"
|
||||
|
||||
# Binary actually runs.
|
||||
ver="$(docker run --rm "$CLAUDE_BOTTLE_PIPELOCK_IMAGE" --version 2>&1 || true)"
|
||||
assert_match "$ver" "[Pp]ipelock|2\\.[0-9]+\\.[0-9]+" "binary --version produces version-shaped output"
|
||||
|
||||
test_summary
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# Integration: full sidecar smoke test. Boots a pipelock container the
|
||||
# same way cli.sh does (docker create + docker cp YAML + docker start),
|
||||
# then probes /health. Catches regressions in:
|
||||
# - the YAML-cp path (the /etc/pipelock.yaml vs /etc/pipelock/ bug)
|
||||
# - argv shape (the `run --listen 0.0.0.0:N` invocation)
|
||||
# - YAML structural validity (pipelock would refuse to start on a bad config)
|
||||
TEST_NAME="pipelock_sidecar_smoke"
|
||||
|
||||
. "$(dirname "$0")/../lib/common.sh"
|
||||
# shellcheck source=../../lib/log.sh
|
||||
. "${REPO_ROOT}/lib/log.sh"
|
||||
# shellcheck source=../../lib/pipelock.sh
|
||||
. "${REPO_ROOT}/lib/pipelock.sh"
|
||||
|
||||
skip_test_if_no_docker
|
||||
|
||||
# Use a distinct name so concurrent runs don't collide.
|
||||
name="cb-test-pipelock-smoke-$$"
|
||||
work_dir="$(mktemp -d)"
|
||||
yaml="${work_dir}/pipelock.yaml"
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$name" >/dev/null 2>&1 || true
|
||||
rm -rf "$work_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Generate a real config from a fixture manifest.
|
||||
m="$(write_fixture fixture_minimal)"
|
||||
pipelock_write_yaml "$m" dev "$yaml"
|
||||
rm -f "$m"
|
||||
|
||||
# Same lifecycle as lib/pipelock.sh's pipelock_start, minus the
|
||||
# network-attach steps (we just need a port we can curl).
|
||||
docker create --name "$name" -p 0:8888 \
|
||||
"$CLAUDE_BOTTLE_PIPELOCK_IMAGE" \
|
||||
run --config /etc/pipelock.yaml --listen "0.0.0.0:8888" \
|
||||
>/dev/null 2>&1 \
|
||||
|| { _fail "docker create failed"; test_summary; }
|
||||
|
||||
# This is the exact cp path that broke before — guard against
|
||||
# regressing to a /etc/pipelock/ subdirectory destination.
|
||||
if ! docker cp "$yaml" "${name}:/etc/pipelock.yaml" >/dev/null 2>&1; then
|
||||
_fail "docker cp to /etc/pipelock.yaml failed (parent dir must already exist in image)"
|
||||
test_summary
|
||||
fi
|
||||
|
||||
if ! docker start "$name" >/dev/null 2>&1; then
|
||||
_fail "docker start failed; check that argv 'run --listen 0.0.0.0:8888' still matches image"
|
||||
test_summary
|
||||
fi
|
||||
|
||||
# Find the host-side port docker mapped 8888 to.
|
||||
hostport="$(docker port "$name" 8888 2>/dev/null | head -1 | awk -F: '{print $NF}')"
|
||||
if [ -z "$hostport" ]; then
|
||||
_fail "could not determine published port" "docker port output: $(docker port "$name" 2>&1)"
|
||||
test_summary
|
||||
fi
|
||||
|
||||
# Wait up to 15 seconds for /health to come up.
|
||||
healthy=0
|
||||
for _ in $(seq 1 15); do
|
||||
if curl -fsS "http://127.0.0.1:${hostport}/health" >/dev/null 2>&1; then
|
||||
healthy=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$healthy" -eq 1 ]; then
|
||||
_pass "sidecar /health responded"
|
||||
else
|
||||
_fail "sidecar /health did not respond within 15s" "logs:" "$(docker logs "$name" 2>&1 | tail -20)"
|
||||
test_summary
|
||||
fi
|
||||
|
||||
# Body should mention the version we pinned. We don't pin the exact
|
||||
# version string here because the digest we test against is one
|
||||
# release; the next release will change the version field but should
|
||||
# keep the schema. Keep the assertion at "field is present and has
|
||||
# a numeric-dotted shape".
|
||||
body="$(curl -fsS "http://127.0.0.1:${hostport}/health" 2>&1)"
|
||||
assert_contains "$body" '"status":"healthy"' "/health body status:healthy"
|
||||
assert_match "$body" '"version":"[0-9]+\.[0-9]+\.[0-9]+"' "/health body has version field"
|
||||
|
||||
test_summary
|
||||
Reference in New Issue
Block a user