refactor: convert project from bash to Python
Replaces cli.sh + lib/*.sh with a claude_bottle/ Python package and a
cli.py entry point. No external dependencies — uses only Python's
stdlib (json, subprocess, getpass, tempfile, argparse, re, etc.).
- claude_bottle/{log,docker,manifest,env_resolve,network,pipelock,
skills,ssh,cli}.py mirror the previous lib/*.sh modules.
- Tests converted to unittest under tests/test_*.py with a stdlib
runner at tests/run_tests.py (unit | integration | path).
- .githooks/commit-msg ported to Python; same Conventional Commits rules.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit was merged in pull request #2.
This commit is contained in:
+50
-54
@@ -1,83 +1,79 @@
|
||||
# Tests
|
||||
|
||||
Plain-bash test suite. No framework dependency — assertions are tiny
|
||||
helpers in `tests/lib/assert.sh` and the runner is a shell script.
|
||||
The unit tests run anywhere bash + jq are present; the integration
|
||||
Plain-Python test suite using stdlib `unittest`. No external
|
||||
dependencies. Unit tests run anywhere Python 3 is present; integration
|
||||
tests need Docker and skip cleanly otherwise.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
tests/
|
||||
run_tests.sh # entry point
|
||||
lib/
|
||||
assert.sh # assert_eq, assert_contains, assert_match, ...
|
||||
common.sh # sources assert + fixtures, sets REPO_ROOT
|
||||
fixtures.sh # JSON manifest builders
|
||||
unit/ # no docker; fast
|
||||
test_pipelock_naming.sh
|
||||
test_pipelock_classify.sh
|
||||
test_pipelock_allowlist.sh
|
||||
test_pipelock_yaml.sh
|
||||
integration/ # require docker
|
||||
test_pipelock_image.sh
|
||||
test_pipelock_sidecar_smoke.sh
|
||||
test_dry_run_plan.sh
|
||||
test_orphan_cleanup.sh
|
||||
run_tests.py # entry point
|
||||
fixtures.py # JSON manifest builders
|
||||
_docker.py # docker-availability skip helper
|
||||
test_pipelock_naming.py # unit
|
||||
test_pipelock_classify.py # unit
|
||||
test_pipelock_allowlist.py # unit
|
||||
test_pipelock_yaml.py # unit
|
||||
test_pipelock_image.py # integration
|
||||
test_pipelock_sidecar_smoke.py # integration
|
||||
test_dry_run_plan.py # integration
|
||||
test_orphan_cleanup.py # integration
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
tests/run_tests.sh # everything
|
||||
tests/run_tests.sh unit # unit only
|
||||
tests/run_tests.sh integration # integration only
|
||||
tests/run_tests.sh tests/unit/test_pipelock_yaml.sh # one file
|
||||
tests/run_tests.py # everything
|
||||
tests/run_tests.py unit # unit only
|
||||
tests/run_tests.py integration # integration only
|
||||
tests/run_tests.py tests/test_pipelock_yaml.py # one file
|
||||
```
|
||||
|
||||
Each test file exits 0 on pass, 1 on fail. The runner aggregates and
|
||||
prints a one-line summary.
|
||||
You can also run via `python -m unittest`:
|
||||
|
||||
```bash
|
||||
python -m unittest discover -s tests
|
||||
python -m unittest tests.test_pipelock_yaml
|
||||
```
|
||||
|
||||
## What the integration tests cover
|
||||
|
||||
These are versions of the smoke tests run during PR #1:
|
||||
|
||||
- `test_pipelock_image.sh` — the pinned digest is reachable, ENTRYPOINT
|
||||
is `/pipelock`, and `CMD` includes `run`. Catches a pipelock release
|
||||
that bumps the argv shape.
|
||||
- `test_pipelock_sidecar_smoke.sh` — `docker create` + `docker cp` the
|
||||
- `test_pipelock_image.py` — the pinned digest is reachable, ENTRYPOINT
|
||||
is `/pipelock`, and `CMD` includes `run`.
|
||||
- `test_pipelock_sidecar_smoke.py` — `docker create` + `docker cp` the
|
||||
generated YAML to `/etc/pipelock.yaml` + `docker start`, then probe
|
||||
`/health`. Catches the YAML-path bug we hit (the image is distroless,
|
||||
so `/etc/pipelock/` does not exist) and YAML structural breakage.
|
||||
- `test_dry_run_plan.sh` — `cli.sh start --dry-run` shows the resolved
|
||||
`/health`.
|
||||
- `test_dry_run_plan.py` — `cli.py start --dry-run` shows the resolved
|
||||
egress allowlist and creates zero docker resources.
|
||||
- `test_orphan_cleanup.sh` — when the sidecar fails to start (bogus
|
||||
image digest), the EXIT trap removes both the internal and egress
|
||||
networks. Catches regressions in trap-installation ordering.
|
||||
- `test_orphan_cleanup.py` — network_remove and pipelock_stop are
|
||||
idempotent against missing resources, so the EXIT trap can call them
|
||||
unconditionally.
|
||||
|
||||
## What's NOT covered
|
||||
|
||||
- `lib/ssh.sh` end-to-end (would need a fake SSH host inside the
|
||||
container; high effort for v1).
|
||||
- A live SSH-through-pipelock tunnel against a real Tailscale-style
|
||||
internal IP.
|
||||
- `claude_bottle/ssh.py` end-to-end (would need a fake SSH host inside
|
||||
the container).
|
||||
- A live SSH-through-pipelock tunnel against a real Tailscale-style IP.
|
||||
- DLP false-positive measurements.
|
||||
- TLS handling / cert pinning behavior.
|
||||
|
||||
## Adding a test
|
||||
|
||||
1. Pick `unit/` (no docker) or `integration/` (docker required).
|
||||
2. Name it `test_<topic>.sh`. Make it executable: `chmod +x`.
|
||||
3. Start with the boilerplate the existing files use:
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
TEST_NAME="<topic>"
|
||||
. "$(dirname "$0")/../lib/common.sh"
|
||||
. "${REPO_ROOT}/lib/log.sh"
|
||||
. "${REPO_ROOT}/lib/<file-under-test>.sh"
|
||||
# ...assert_eq / assert_contains / ...
|
||||
test_summary
|
||||
1. Pick a filename: `test_<topic>.py`. Add it to `INTEGRATION_NAMES`
|
||||
in `run_tests.py` if it needs Docker.
|
||||
2. Boilerplate:
|
||||
```python
|
||||
import unittest
|
||||
|
||||
from claude_bottle.<module> import <symbol>
|
||||
|
||||
class TestThing(unittest.TestCase):
|
||||
def test_x(self):
|
||||
...
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
```
|
||||
4. For integration tests: call `skip_test_if_no_docker` after the
|
||||
boilerplate and ensure your trap cleans up any docker resources you
|
||||
create.
|
||||
3. For Docker-dependent tests, decorate the class with
|
||||
`@skip_unless_docker()` from `tests._docker`.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Docker availability check used by integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
|
||||
def docker_available() -> bool:
|
||||
if shutil.which("docker") is None:
|
||||
return False
|
||||
return (
|
||||
subprocess.run(
|
||||
["docker", "info"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def skip_unless_docker(reason: str = "docker unreachable"):
|
||||
return unittest.skipUnless(docker_available(), reason)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Manifest fixtures for the test suite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def fixture_minimal() -> dict[str, Any]:
|
||||
"""One bottle, one agent, no env / ssh / skills."""
|
||||
return {
|
||||
"bottles": {"dev": {}},
|
||||
"agents": {
|
||||
"demo": {"skills": [], "prompt": "", "bottle": "dev"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def fixture_with_egress() -> dict[str, Any]:
|
||||
"""Bottle declares an egress.allowlist."""
|
||||
return {
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"egress": {
|
||||
"allowlist": ["github.com", "gitlab.com", "registry.npmjs.org"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
}
|
||||
|
||||
|
||||
def fixture_with_ssh() -> dict[str, Any]:
|
||||
"""Bottle has both an IPv4-literal SSH host (CGNAT) and a hostname host,
|
||||
exercising both ssrf.ip_allowlist and trusted_domains code paths."""
|
||||
return {
|
||||
"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"}},
|
||||
}
|
||||
|
||||
|
||||
def write_fixture(fn) -> Path:
|
||||
"""Write fixture dict to a temp file; return the path. Caller must rm."""
|
||||
f = tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
||||
)
|
||||
json.dump(fn(), f)
|
||||
f.close()
|
||||
return Path(f.name)
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/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
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/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"
|
||||
}
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test runner. Wraps unittest's discovery so we can split unit /
|
||||
integration the same way the bash runner did.
|
||||
|
||||
Usage:
|
||||
tests/run_tests.py # unit + integration
|
||||
tests/run_tests.py unit # unit only (no docker)
|
||||
tests/run_tests.py integration # integration only (need docker)
|
||||
tests/run_tests.py tests/test_x.py # one specific file (or path)
|
||||
|
||||
Tests are auto-classified as integration when their filename matches
|
||||
one of INTEGRATION_NAMES below; everything else is a unit test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
TESTS_DIR = REPO_ROOT / "tests"
|
||||
|
||||
INTEGRATION_NAMES = {
|
||||
"test_dry_run_plan.py",
|
||||
"test_orphan_cleanup.py",
|
||||
"test_pipelock_image.py",
|
||||
"test_pipelock_sidecar_smoke.py",
|
||||
}
|
||||
|
||||
|
||||
def _all_test_files() -> list[Path]:
|
||||
return sorted(TESTS_DIR.glob("test_*.py"))
|
||||
|
||||
|
||||
def _classify(path: Path) -> str:
|
||||
return "integration" if path.name in INTEGRATION_NAMES else "unit"
|
||||
|
||||
|
||||
def _modname(path: Path) -> str:
|
||||
return f"tests.{path.stem}"
|
||||
|
||||
|
||||
def _build_suite(files: list[Path]) -> unittest.TestSuite:
|
||||
loader = unittest.TestLoader()
|
||||
suite = unittest.TestSuite()
|
||||
for f in files:
|
||||
suite.addTests(loader.loadTestsFromName(_modname(f)))
|
||||
return suite
|
||||
|
||||
|
||||
def usage() -> None:
|
||||
sys.stderr.write(
|
||||
"usage: tests/run_tests.py [unit|integration|path/to/test.py]\n"
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
if not argv:
|
||||
files = _all_test_files()
|
||||
else:
|
||||
arg = argv[0]
|
||||
if arg in ("-h", "--help"):
|
||||
usage()
|
||||
return 0
|
||||
if arg == "unit":
|
||||
files = [f for f in _all_test_files() if _classify(f) == "unit"]
|
||||
elif arg == "integration":
|
||||
files = [f for f in _all_test_files() if _classify(f) == "integration"]
|
||||
else:
|
||||
p = Path(arg).resolve()
|
||||
if not p.is_file():
|
||||
sys.stderr.write(f"no such file: {arg}\n")
|
||||
usage()
|
||||
return 2
|
||||
files = [p]
|
||||
|
||||
if not files:
|
||||
sys.stderr.write("no test files found\n")
|
||||
return 2
|
||||
|
||||
suite = _build_suite(files)
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(suite)
|
||||
return 0 if result.wasSuccessful() else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -1,94 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test runner. Iterates over test_*.sh files in unit/ and integration/
|
||||
# (or just one of them when given a `unit` / `integration` argument)
|
||||
# and runs each as a separate process. Aggregates exit codes and
|
||||
# prints a summary.
|
||||
#
|
||||
# Usage:
|
||||
# tests/run_tests.sh # unit + integration
|
||||
# tests/run_tests.sh unit # unit only
|
||||
# tests/run_tests.sh integration # integration only
|
||||
# tests/run_tests.sh path/to/test_x.sh # one specific file
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
|
||||
if [ -t 1 ]; then
|
||||
C_PASS=$'\033[32m'
|
||||
C_FAIL=$'\033[31m'
|
||||
C_HEAD=$'\033[36m'
|
||||
C_RESET=$'\033[0m'
|
||||
else
|
||||
C_PASS=""
|
||||
C_FAIL=""
|
||||
C_HEAD=""
|
||||
C_RESET=""
|
||||
fi
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
usage: $(basename "$0") [unit|integration|path/to/test.sh]
|
||||
no arg run unit + integration
|
||||
unit run only tests/unit/test_*.sh
|
||||
integration run only tests/integration/test_*.sh
|
||||
<path> run a single test file
|
||||
EOF
|
||||
}
|
||||
|
||||
# Collect test files.
|
||||
declare -a FILES=()
|
||||
case "${1:-}" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
unit) FILES=("${_dir}"/unit/test_*.sh) ;;
|
||||
integration) FILES=("${_dir}"/integration/test_*.sh) ;;
|
||||
"") FILES=("${_dir}"/unit/test_*.sh "${_dir}"/integration/test_*.sh) ;;
|
||||
*)
|
||||
if [ -f "$1" ]; then
|
||||
FILES=("$1")
|
||||
else
|
||||
printf 'no such file: %s\n' "$1" >&2
|
||||
usage
|
||||
exit 2
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Filter out non-existent globs (no matching files).
|
||||
declare -a EXISTING=()
|
||||
for f in "${FILES[@]}"; do
|
||||
[ -f "$f" ] && EXISTING+=("$f")
|
||||
done
|
||||
|
||||
if [ "${#EXISTING[@]}" -eq 0 ]; then
|
||||
printf 'no test files found\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
declare -a FAIL_FILES=()
|
||||
|
||||
for f in "${EXISTING[@]}"; do
|
||||
rel="${f#${_dir}/}"
|
||||
printf '%s== %s ==%s\n' "$C_HEAD" "$rel" "$C_RESET"
|
||||
if bash "$f"; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
FAIL_FILES+=("$rel")
|
||||
fi
|
||||
printf '\n'
|
||||
done
|
||||
|
||||
# Summary.
|
||||
TOTAL=$((PASS_COUNT + FAIL_COUNT))
|
||||
printf '%ssummary%s: %d/%d test files passed\n' "$C_HEAD" "$C_RESET" "$PASS_COUNT" "$TOTAL"
|
||||
if [ "$FAIL_COUNT" -gt 0 ]; then
|
||||
printf '%sfailed%s:\n' "$C_FAIL" "$C_RESET"
|
||||
for f in "${FAIL_FILES[@]}"; do
|
||||
printf ' - %s\n' "$f"
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
printf '%sall tests passed%s\n' "$C_PASS" "$C_RESET"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Integration: cli.py 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)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
@skip_unless_docker()
|
||||
class TestDryRunPlan(unittest.TestCase):
|
||||
def test_dry_run(self):
|
||||
work_dir = Path(tempfile.mkdtemp())
|
||||
try:
|
||||
manifest = work_dir / "claude-bottle.json"
|
||||
manifest.write_text(json.dumps({
|
||||
"bottles": {"dev": {"egress": {"allowlist": ["example.org"]}}},
|
||||
"agents": {
|
||||
"demo": {"skills": [], "prompt": "", "bottle": "dev"},
|
||||
},
|
||||
}))
|
||||
|
||||
nets_before = self._count_claude_bottle_networks()
|
||||
ctrs_before = self._count_claude_bottle_containers()
|
||||
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = str(work_dir)
|
||||
env["CLAUDE_BOTTLE_DRY_RUN"] = "1"
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "cli.py"), "start", "demo"],
|
||||
cwd=work_dir,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
out = result.stdout + result.stderr
|
||||
|
||||
self.assertIn("egress", out, "preflight: egress line present")
|
||||
# 7 baked defaults + 1 bottle entry = 8.
|
||||
self.assertRegex(out, r"8 hosts allowed", "preflight: bottle entry counted")
|
||||
self.assertIn("api.anthropic.com", out, "preflight: baked default shown")
|
||||
self.assertIn("dry-run requested", out, "dry-run banner present")
|
||||
self.assertNotIn("/dev/tty", out, "dry-run exited before tty prompt")
|
||||
|
||||
self.assertEqual(nets_before, self._count_claude_bottle_networks(),
|
||||
"no networks created")
|
||||
self.assertEqual(ctrs_before, self._count_claude_bottle_containers(),
|
||||
"no containers created")
|
||||
finally:
|
||||
import shutil
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
|
||||
def _count_claude_bottle_networks(self) -> int:
|
||||
result = subprocess.run(
|
||||
["docker", "network", "ls", "--format", "{{.Name}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return sum(1 for n in result.stdout.splitlines() if n.startswith("claude-bottle"))
|
||||
|
||||
def _count_claude_bottle_containers(self) -> int:
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-a", "--format", "{{.Names}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return sum(1 for n in result.stdout.splitlines() if n.startswith("claude-bottle"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Integration: the cleanup primitives the start-flow trap depends on
|
||||
are idempotent. The original orphan-network bug was a trap-ordering
|
||||
issue; the fix moved the install earlier. The trap is only safe if
|
||||
network_remove and pipelock_stop are no-ops against missing resources."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from claude_bottle.network import (
|
||||
network_create_egress,
|
||||
network_create_internal,
|
||||
network_remove,
|
||||
)
|
||||
from claude_bottle.pipelock import pipelock_stop
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
|
||||
@skip_unless_docker()
|
||||
class TestOrphanCleanup(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.slug = f"cb-test-orphan-{os.getpid()}"
|
||||
self.internal_name = ""
|
||||
self.egress_name = ""
|
||||
|
||||
def tearDown(self):
|
||||
for n in (self.internal_name, self.egress_name):
|
||||
if n:
|
||||
subprocess.run(
|
||||
["docker", "network", "rm", n],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
def test_remove_missing_is_noop(self):
|
||||
# Returning True == idempotent success.
|
||||
self.assertTrue(network_remove(f"claude-bottle-net-{self.slug}-does-not-exist"))
|
||||
|
||||
def test_create_and_remove(self):
|
||||
self.internal_name = network_create_internal(self.slug)
|
||||
self.egress_name = network_create_egress(self.slug)
|
||||
|
||||
nets = subprocess.run(
|
||||
["docker", "network", "ls", "--format", "{{.Name}}"],
|
||||
capture_output=True, text=True,
|
||||
).stdout.splitlines()
|
||||
self.assertIn(self.internal_name, nets)
|
||||
self.assertIn(self.egress_name, nets)
|
||||
|
||||
self.assertTrue(network_remove(self.internal_name))
|
||||
self.assertTrue(network_remove(self.egress_name))
|
||||
|
||||
nets_after = subprocess.run(
|
||||
["docker", "network", "ls", "--format", "{{.Name}}"],
|
||||
capture_output=True, text=True,
|
||||
).stdout.splitlines()
|
||||
self.assertNotIn(self.internal_name, nets_after)
|
||||
self.assertNotIn(self.egress_name, nets_after)
|
||||
|
||||
# Idempotent on already-removed.
|
||||
self.assertTrue(network_remove(self.internal_name))
|
||||
self.assertTrue(network_remove(self.egress_name))
|
||||
|
||||
def test_pipelock_stop_missing_sidecar(self):
|
||||
# Should not raise.
|
||||
pipelock_stop(f"missing-{self.slug}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Unit: allowlist resolution — pipelock_bottle_allowlist,
|
||||
pipelock_bottle_ssh_hostnames, pipelock_bottle_ssh_ip_cidrs,
|
||||
pipelock_bottle_ssh_trusted_domains, pipelock_effective_allowlist."""
|
||||
|
||||
import unittest
|
||||
|
||||
from claude_bottle.log import Die
|
||||
from claude_bottle.pipelock import (
|
||||
pipelock_bottle_allowlist,
|
||||
pipelock_bottle_ssh_hostnames,
|
||||
pipelock_bottle_ssh_ip_cidrs,
|
||||
pipelock_bottle_ssh_trusted_domains,
|
||||
pipelock_effective_allowlist,
|
||||
)
|
||||
from tests.fixtures import fixture_minimal, fixture_with_egress, fixture_with_ssh
|
||||
|
||||
|
||||
class TestBottleAllowlist(unittest.TestCase):
|
||||
def test_egress_allowlist_present(self):
|
||||
out = pipelock_bottle_allowlist(fixture_with_egress(), "dev")
|
||||
self.assertIn("github.com", out)
|
||||
self.assertIn("gitlab.com", out)
|
||||
self.assertIn("registry.npmjs.org", out)
|
||||
|
||||
def test_empty_when_no_egress_block(self):
|
||||
out = pipelock_bottle_allowlist(fixture_minimal(), "dev")
|
||||
self.assertEqual([], out)
|
||||
|
||||
def test_rejects_non_string_entry(self):
|
||||
bad = {
|
||||
"bottles": {"dev": {"egress": {"allowlist": ["github.com", 42]}}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
}
|
||||
with self.assertRaises(Die):
|
||||
pipelock_bottle_allowlist(bad, "dev")
|
||||
|
||||
|
||||
class TestSSHHostnames(unittest.TestCase):
|
||||
def test_hostnames_include_both(self):
|
||||
hosts = pipelock_bottle_ssh_hostnames(fixture_with_ssh(), "dev")
|
||||
self.assertIn("100.78.141.42", hosts)
|
||||
self.assertIn("github.com", hosts)
|
||||
|
||||
def test_ip_cidrs_only_ipv4(self):
|
||||
cidrs = pipelock_bottle_ssh_ip_cidrs(fixture_with_ssh(), "dev")
|
||||
self.assertIn("100.78.141.42/32", cidrs)
|
||||
self.assertNotIn("github.com", cidrs)
|
||||
|
||||
def test_trusted_domains_only_hostnames(self):
|
||||
trusted = pipelock_bottle_ssh_trusted_domains(fixture_with_ssh(), "dev")
|
||||
self.assertIn("github.com", trusted)
|
||||
self.assertNotIn("100.78.141.42", trusted)
|
||||
|
||||
|
||||
class TestEffectiveAllowlist(unittest.TestCase):
|
||||
def test_union_and_dedup(self):
|
||||
manifest = {
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"egress": {"allowlist": ["registry.npmjs.org"]},
|
||||
"ssh": [
|
||||
{"Host": "ts", "IdentityFile": "/dev/null",
|
||||
"Hostname": "100.78.141.42", "User": "git", "Port": 30009},
|
||||
{"Host": "gh", "IdentityFile": "/dev/null",
|
||||
"Hostname": "github.com", "User": "git", "Port": 22},
|
||||
],
|
||||
}
|
||||
},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
}
|
||||
eff = pipelock_effective_allowlist(manifest, "dev")
|
||||
self.assertIn("api.anthropic.com", eff)
|
||||
self.assertIn("registry.npmjs.org", eff)
|
||||
self.assertIn("100.78.141.42", eff)
|
||||
self.assertIn("github.com", eff)
|
||||
self.assertEqual(len(eff), len(set(eff)), "deduplicated")
|
||||
self.assertEqual(eff, sorted(eff), "sorted")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Unit: is_ipv4_literal — the classifier that decides whether
|
||||
bottle.ssh[].Hostname goes into ssrf.ip_allowlist (IPv4 literal) or
|
||||
trusted_domains (hostname)."""
|
||||
|
||||
import unittest
|
||||
|
||||
from claude_bottle.pipelock import is_ipv4_literal
|
||||
|
||||
|
||||
class TestIPv4Classify(unittest.TestCase):
|
||||
def test_positive(self):
|
||||
for ip in ("127.0.0.1", "10.0.0.5", "100.78.141.42", "0.0.0.0", "255.255.255.255"):
|
||||
with self.subTest(ip=ip):
|
||||
self.assertTrue(is_ipv4_literal(ip), ip)
|
||||
|
||||
def test_negative(self):
|
||||
for hn in (
|
||||
"github.com",
|
||||
"gitea.dideric.is",
|
||||
"100.78.141",
|
||||
"100.78.141.42.5",
|
||||
"::1",
|
||||
"fe80::1",
|
||||
"localhost",
|
||||
"",
|
||||
"1.2.3.4.example.com",
|
||||
):
|
||||
with self.subTest(hn=hn):
|
||||
self.assertFalse(is_ipv4_literal(hn), hn)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Integration: verify the pinned pipelock image. Requires docker.
|
||||
- Pinned digest is reachable on the registry.
|
||||
- Image's ENTRYPOINT/CMD match what claude_bottle.pipelock assumes
|
||||
(`/pipelock` and `run --listen 0.0.0.0:8888`).
|
||||
- The /pipelock binary actually runs (--version succeeds)."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from claude_bottle.pipelock import PIPELOCK_IMAGE
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
|
||||
@skip_unless_docker()
|
||||
class TestPipelockImage(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Pull the pinned image (cheap if cached).
|
||||
result = subprocess.run(
|
||||
["docker", "pull", PIPELOCK_IMAGE],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise unittest.SkipTest(f"could not pull {PIPELOCK_IMAGE}")
|
||||
|
||||
def test_entrypoint_contains_pipelock(self):
|
||||
result = subprocess.run(
|
||||
["docker", "image", "inspect", PIPELOCK_IMAGE,
|
||||
"--format", "{{json .Config.Entrypoint}}"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
self.assertIn("/pipelock", result.stdout)
|
||||
|
||||
def test_cmd_contains_run(self):
|
||||
result = subprocess.run(
|
||||
["docker", "image", "inspect", PIPELOCK_IMAGE,
|
||||
"--format", "{{json .Config.Cmd}}"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
self.assertIn("run", result.stdout)
|
||||
|
||||
def test_binary_runs(self):
|
||||
result = subprocess.run(
|
||||
["docker", "run", "--rm", PIPELOCK_IMAGE, "--version"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
out = result.stdout + result.stderr
|
||||
self.assertRegex(out, r"[Pp]ipelock|2\.[0-9]+\.[0-9]+")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Unit: pipelock naming helpers (container_name, proxy_url, proxy_host_port)."""
|
||||
|
||||
import unittest
|
||||
|
||||
from claude_bottle.pipelock import (
|
||||
pipelock_container_name,
|
||||
pipelock_proxy_host_port,
|
||||
pipelock_proxy_url,
|
||||
)
|
||||
|
||||
|
||||
class TestPipelockNaming(unittest.TestCase):
|
||||
def test_container_name_simple(self):
|
||||
self.assertEqual("claude-bottle-pipelock-foo", pipelock_container_name("foo"))
|
||||
|
||||
def test_container_name_with_hyphens(self):
|
||||
self.assertEqual(
|
||||
"claude-bottle-pipelock-some-slug", pipelock_container_name("some-slug")
|
||||
)
|
||||
|
||||
def test_proxy_url_default_port(self):
|
||||
self.assertEqual(
|
||||
"http://claude-bottle-pipelock-foo:8888", pipelock_proxy_url("foo")
|
||||
)
|
||||
|
||||
def test_proxy_host_port_default_port(self):
|
||||
self.assertEqual(
|
||||
"claude-bottle-pipelock-foo:8888", pipelock_proxy_host_port("foo")
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Integration: full sidecar smoke test. Boots a pipelock container the
|
||||
same way cli.py does (docker create + docker cp YAML + docker start),
|
||||
then probes /health."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from claude_bottle.pipelock import PIPELOCK_IMAGE, pipelock_write_yaml
|
||||
from tests._docker import skip_unless_docker
|
||||
from tests.fixtures import fixture_minimal
|
||||
|
||||
|
||||
@skip_unless_docker()
|
||||
class TestPipelockSidecarSmoke(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.name = f"cb-test-pipelock-smoke-{os.getpid()}"
|
||||
self.work_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
def tearDown(self):
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", self.name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
shutil.rmtree(self.work_dir, ignore_errors=True)
|
||||
|
||||
def test_smoke(self):
|
||||
yaml_path = self.work_dir / "pipelock.yaml"
|
||||
pipelock_write_yaml(fixture_minimal(), "dev", yaml_path)
|
||||
|
||||
create = subprocess.run(
|
||||
[
|
||||
"docker", "create",
|
||||
"--name", self.name,
|
||||
"-p", "0:8888",
|
||||
PIPELOCK_IMAGE,
|
||||
"run", "--config", "/etc/pipelock.yaml",
|
||||
"--listen", "0.0.0.0:8888",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(0, create.returncode, f"docker create failed: {create.stderr}")
|
||||
|
||||
# Guard against /etc/pipelock/ regressions: the path must be
|
||||
# /etc/pipelock.yaml, since the image is distroless.
|
||||
cp = subprocess.run(
|
||||
["docker", "cp", str(yaml_path), f"{self.name}:/etc/pipelock.yaml"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(0, cp.returncode, f"docker cp failed: {cp.stderr}")
|
||||
|
||||
start = subprocess.run(
|
||||
["docker", "start", self.name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(0, start.returncode,
|
||||
f"docker start failed; check argv 'run --listen 0.0.0.0:8888'")
|
||||
|
||||
port_result = subprocess.run(
|
||||
["docker", "port", self.name, "8888"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
first_line = (port_result.stdout or "").splitlines()[0] if port_result.stdout else ""
|
||||
host_port = first_line.rsplit(":", 1)[-1] if first_line else ""
|
||||
self.assertTrue(host_port, "could not determine published port")
|
||||
|
||||
health_url = f"http://127.0.0.1:{host_port}/health"
|
||||
body = ""
|
||||
for _ in range(15):
|
||||
try:
|
||||
with urllib.request.urlopen(health_url, timeout=2) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
break
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, ConnectionError):
|
||||
time.sleep(1)
|
||||
|
||||
self.assertIn('"status":"healthy"', body, "health body status:healthy")
|
||||
self.assertRegex(body, r'"version":"[0-9]+\.[0-9]+\.[0-9]+"',
|
||||
"health body has version field")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Unit: pipelock_write_yaml — produces a YAML config containing the
|
||||
expected top-level keys and per-bottle entries. We don't fully parse
|
||||
YAML; we grep for content shape."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from claude_bottle.pipelock import pipelock_write_yaml
|
||||
from tests.fixtures import fixture_minimal, fixture_with_ssh
|
||||
|
||||
|
||||
class TestPipelockYaml(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.out_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
shutil.rmtree(self.out_dir, ignore_errors=True)
|
||||
|
||||
def test_minimal(self):
|
||||
yaml_path = self.out_dir / "min.yaml"
|
||||
pipelock_write_yaml(fixture_minimal(), "dev", yaml_path)
|
||||
content = yaml_path.read_text()
|
||||
self.assertIn("mode: strict", content)
|
||||
self.assertIn("enforce: true", content)
|
||||
self.assertIn("api_allowlist:", content)
|
||||
self.assertIn("api.anthropic.com", content)
|
||||
self.assertIn("raw.githubusercontent.com", content)
|
||||
self.assertIn("forward_proxy:", content)
|
||||
self.assertIn("enabled: true", content)
|
||||
self.assertIn("dlp:", content)
|
||||
self.assertIn("include_defaults: true", content)
|
||||
self.assertIn("scan_env: true", content)
|
||||
# No ssh entries → no trusted_domains nor ssrf block.
|
||||
self.assertNotIn("trusted_domains:", content)
|
||||
self.assertNotIn("ssrf:", content)
|
||||
|
||||
def test_ssh_blocks(self):
|
||||
yaml_path = self.out_dir / "ssh.yaml"
|
||||
pipelock_write_yaml(fixture_with_ssh(), "dev", yaml_path)
|
||||
content = yaml_path.read_text()
|
||||
self.assertIn("trusted_domains:", content)
|
||||
self.assertIn("github.com", content)
|
||||
self.assertIn("ssrf:", content)
|
||||
self.assertIn("ip_allowlist:", content)
|
||||
self.assertIn("100.78.141.42/32", content)
|
||||
# ipv4 host should also be in api_allowlist (strict mode requires both).
|
||||
self.assertIn("100.78.141.42", content)
|
||||
|
||||
def test_secret_hygiene(self):
|
||||
manifest = {
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"env": {
|
||||
"MY_SECRET": "literal-value-should-not-appear",
|
||||
"ANOTHER": "?prompt-message",
|
||||
},
|
||||
"egress": {"allowlist": ["github.com"]},
|
||||
}
|
||||
},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
}
|
||||
yaml_path = self.out_dir / "secret.yaml"
|
||||
pipelock_write_yaml(manifest, "dev", yaml_path)
|
||||
content = yaml_path.read_text()
|
||||
self.assertNotIn("literal-value-should-not-appear", content)
|
||||
self.assertNotIn("MY_SECRET", content)
|
||||
self.assertNotIn("prompt-message", content)
|
||||
|
||||
def test_file_mode_is_600(self):
|
||||
yaml_path = self.out_dir / "min.yaml"
|
||||
pipelock_write_yaml(fixture_minimal(), "dev", yaml_path)
|
||||
mode = os.stat(yaml_path).st_mode & 0o777
|
||||
self.assertEqual(0o600, mode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,89 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Unit: allowlist resolution — pipelock_bottle_allowlist,
|
||||
# pipelock_bottle_ssh_hostnames, pipelock_bottle_ssh_ip_cidrs,
|
||||
# pipelock_bottle_ssh_trusted_domains, pipelock_effective_allowlist.
|
||||
TEST_NAME="pipelock_allowlist"
|
||||
|
||||
. "$(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"
|
||||
|
||||
# --- bottle_allowlist (egress.allowlist parsing) ---
|
||||
|
||||
m="$(write_fixture fixture_with_egress)"
|
||||
out="$(pipelock_bottle_allowlist "$m" dev)"
|
||||
assert_contains "$out" "github.com" "bottle_allowlist: github.com present"
|
||||
assert_contains "$out" "gitlab.com" "bottle_allowlist: gitlab.com present"
|
||||
assert_contains "$out" "registry.npmjs.org" "bottle_allowlist: npmjs present"
|
||||
rm -f "$m"
|
||||
|
||||
m="$(write_fixture fixture_minimal)"
|
||||
out="$(pipelock_bottle_allowlist "$m" dev)"
|
||||
assert_eq "" "$out" "bottle_allowlist: empty when no egress block"
|
||||
rm -f "$m"
|
||||
|
||||
# --- ssh hostnames + classification ---
|
||||
|
||||
m="$(write_fixture fixture_with_ssh)"
|
||||
hosts="$(pipelock_bottle_ssh_hostnames "$m" dev)"
|
||||
assert_contains "$hosts" "100.78.141.42" "ssh_hostnames: ipv4 included"
|
||||
assert_contains "$hosts" "github.com" "ssh_hostnames: hostname included"
|
||||
|
||||
cidrs="$(pipelock_bottle_ssh_ip_cidrs "$m" dev)"
|
||||
assert_contains "$cidrs" "100.78.141.42/32" "ssh_ip_cidrs: ipv4 emitted as /32"
|
||||
assert_not_contains "$cidrs" "github.com" "ssh_ip_cidrs: hostname not in cidr list"
|
||||
|
||||
trusted="$(pipelock_bottle_ssh_trusted_domains "$m" dev)"
|
||||
assert_contains "$trusted" "github.com" "ssh_trusted_domains: hostname present"
|
||||
assert_not_contains "$trusted" "100.78.141.42" "ssh_trusted_domains: ipv4 not present"
|
||||
rm -f "$m"
|
||||
|
||||
# --- effective_allowlist union (defaults + bottle.allowlist + ssh.Hostname) ---
|
||||
|
||||
# Combine egress + ssh fixtures into one manifest.
|
||||
combined="$(mktemp)"
|
||||
cat > "$combined" <<'JSON'
|
||||
{
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"egress": { "allowlist": ["registry.npmjs.org"] },
|
||||
"ssh": [
|
||||
{ "Host": "ts", "IdentityFile": "/dev/null", "Hostname": "100.78.141.42", "User": "git", "Port": 30009 },
|
||||
{ "Host": "gh", "IdentityFile": "/dev/null", "Hostname": "github.com", "User": "git", "Port": 22 }
|
||||
]
|
||||
}
|
||||
},
|
||||
"agents": { "demo": { "skills": [], "prompt": "", "bottle": "dev" } }
|
||||
}
|
||||
JSON
|
||||
|
||||
eff="$(pipelock_effective_allowlist "$combined" dev)"
|
||||
assert_contains "$eff" "api.anthropic.com" "effective: baked-in default present"
|
||||
assert_contains "$eff" "registry.npmjs.org" "effective: bottle egress entry present"
|
||||
assert_contains "$eff" "100.78.141.42" "effective: ssh ipv4 hostname present"
|
||||
assert_contains "$eff" "github.com" "effective: ssh hostname present"
|
||||
|
||||
# Ensure dedup + sort: count lines, then count unique lines, expect equal.
|
||||
total="$(printf '%s\n' "$eff" | wc -l | tr -d ' ')"
|
||||
uniq="$(printf '%s\n' "$eff" | sort -u | wc -l | tr -d ' ')"
|
||||
assert_eq "$total" "$uniq" "effective: deduplicated"
|
||||
|
||||
rm -f "$combined"
|
||||
|
||||
# --- non-string entry rejection ---
|
||||
|
||||
bad="$(mktemp)"
|
||||
cat > "$bad" <<'JSON'
|
||||
{
|
||||
"bottles": { "dev": { "egress": { "allowlist": ["github.com", 42] } } },
|
||||
"agents": { "demo": { "skills": [], "prompt": "", "bottle": "dev" } }
|
||||
}
|
||||
JSON
|
||||
|
||||
assert_exit_nonzero "bottle_allowlist: rejects non-string entry" \
|
||||
bash -c '. "'"${REPO_ROOT}"'/lib/log.sh"; . "'"${REPO_ROOT}"'/lib/pipelock.sh"; pipelock_bottle_allowlist "'"$bad"'" dev'
|
||||
rm -f "$bad"
|
||||
|
||||
test_summary
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Unit: _pipelock_is_ipv4_literal — the classifier that decides
|
||||
# whether bottle.ssh[].Hostname goes into ssrf.ip_allowlist (IPv4
|
||||
# literal) or trusted_domains (hostname).
|
||||
TEST_NAME="pipelock_classify"
|
||||
|
||||
. "$(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"
|
||||
|
||||
# Positive cases — these should be classified as IPv4 literals.
|
||||
for ip in "127.0.0.1" "10.0.0.5" "100.78.141.42" "0.0.0.0" "255.255.255.255"; do
|
||||
assert_exit_zero "ipv4: ${ip}" _pipelock_is_ipv4_literal "$ip"
|
||||
done
|
||||
|
||||
# Negative cases — hostnames, partial IPs, IPv6, and edge garbage
|
||||
# should NOT match.
|
||||
for hn in \
|
||||
"github.com" \
|
||||
"gitea.dideric.is" \
|
||||
"100.78.141" \
|
||||
"100.78.141.42.5" \
|
||||
"::1" \
|
||||
"fe80::1" \
|
||||
"localhost" \
|
||||
"" \
|
||||
"1.2.3.4.example.com"
|
||||
do
|
||||
assert_exit_nonzero "non-ipv4: '${hn}'" _pipelock_is_ipv4_literal "$hn"
|
||||
done
|
||||
|
||||
test_summary
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Unit: pipelock naming helpers (container_name, proxy_url, proxy_host_port).
|
||||
TEST_NAME="pipelock_naming"
|
||||
|
||||
. "$(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"
|
||||
|
||||
assert_eq "claude-bottle-pipelock-foo" "$(pipelock_container_name foo)" "container_name simple slug"
|
||||
assert_eq "claude-bottle-pipelock-some-slug" "$(pipelock_container_name some-slug)" "container_name with hyphens"
|
||||
|
||||
# proxy_url and proxy_host_port use whatever CLAUDE_BOTTLE_PIPELOCK_PORT
|
||||
# is at source time. We sourced with default (8888).
|
||||
assert_eq "http://claude-bottle-pipelock-foo:8888" "$(pipelock_proxy_url foo)" "proxy_url default port"
|
||||
assert_eq "claude-bottle-pipelock-foo:8888" "$(pipelock_proxy_host_port foo)" "proxy_host_port default port"
|
||||
|
||||
# Both helpers should fail loudly without a slug (the `${1:?...}` guards).
|
||||
assert_exit_nonzero "container_name: missing slug" bash -c '. "'"${REPO_ROOT}"'/lib/log.sh"; . "'"${REPO_ROOT}"'/lib/pipelock.sh"; pipelock_container_name'
|
||||
assert_exit_nonzero "proxy_url: missing slug" bash -c '. "'"${REPO_ROOT}"'/lib/log.sh"; . "'"${REPO_ROOT}"'/lib/pipelock.sh"; pipelock_proxy_url'
|
||||
|
||||
test_summary
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Unit: pipelock_write_yaml — produces a YAML config containing the
|
||||
# expected top-level keys and per-bottle entries. We don't fully parse
|
||||
# YAML (no yq dependency); we grep for content shape.
|
||||
TEST_NAME="pipelock_yaml"
|
||||
|
||||
. "$(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"
|
||||
|
||||
out_dir="$(mktemp -d)"
|
||||
cleanup() { rm -rf "$out_dir"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# --- minimal bottle (no egress, no ssh): only api_allowlist defaults ---
|
||||
|
||||
m_min="$(write_fixture fixture_minimal)"
|
||||
yaml_min="${out_dir}/min.yaml"
|
||||
pipelock_write_yaml "$m_min" dev "$yaml_min"
|
||||
|
||||
content="$(cat "$yaml_min")"
|
||||
assert_contains "$content" "mode: strict" "min: mode strict"
|
||||
assert_contains "$content" "enforce: true" "min: enforce true"
|
||||
assert_contains "$content" "api_allowlist:" "min: api_allowlist block"
|
||||
assert_contains "$content" "api.anthropic.com" "min: anthropic baked default"
|
||||
assert_contains "$content" "raw.githubusercontent.com" "min: github raw baked default"
|
||||
assert_contains "$content" "forward_proxy:" "min: forward_proxy block"
|
||||
assert_contains "$content" "enabled: true" "min: forward_proxy enabled"
|
||||
assert_contains "$content" "dlp:" "min: dlp block"
|
||||
assert_contains "$content" "include_defaults: true" "min: dlp include_defaults"
|
||||
assert_contains "$content" "scan_env: true" "min: dlp scan_env"
|
||||
# No ssh entries in the manifest, so neither ssrf nor trusted_domains
|
||||
# blocks should be emitted.
|
||||
assert_not_contains "$content" "trusted_domains:" "min: no trusted_domains"
|
||||
assert_not_contains "$content" "ssrf:" "min: no ssrf block"
|
||||
|
||||
rm -f "$m_min"
|
||||
|
||||
# --- ssh bottle: trusted_domains for hostname, ssrf.ip_allowlist for ipv4 ---
|
||||
|
||||
m_ssh="$(write_fixture fixture_with_ssh)"
|
||||
yaml_ssh="${out_dir}/ssh.yaml"
|
||||
pipelock_write_yaml "$m_ssh" dev "$yaml_ssh"
|
||||
|
||||
content="$(cat "$yaml_ssh")"
|
||||
assert_contains "$content" "trusted_domains:" "ssh: trusted_domains block emitted"
|
||||
assert_contains "$content" "github.com" "ssh: hostname in trusted_domains (or allowlist)"
|
||||
assert_contains "$content" "ssrf:" "ssh: ssrf block emitted"
|
||||
assert_contains "$content" "ip_allowlist:" "ssh: ip_allowlist key under ssrf"
|
||||
assert_contains "$content" "100.78.141.42/32" "ssh: ipv4 host emitted as /32"
|
||||
# Belt-and-suspenders: the ipv4 host should also be in api_allowlist
|
||||
# (strict mode requires both).
|
||||
assert_contains "$content" "100.78.141.42" "ssh: ipv4 host in api_allowlist too"
|
||||
|
||||
rm -f "$m_ssh"
|
||||
|
||||
# --- secret hygiene: env values from the manifest never enter the YAML ---
|
||||
|
||||
m_secret="$(mktemp)"
|
||||
cat > "$m_secret" <<'JSON'
|
||||
{
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"env": {
|
||||
"MY_SECRET": "literal-value-should-not-appear",
|
||||
"ANOTHER": "?prompt-message"
|
||||
},
|
||||
"egress": { "allowlist": ["github.com"] }
|
||||
}
|
||||
},
|
||||
"agents": { "demo": { "skills": [], "prompt": "", "bottle": "dev" } }
|
||||
}
|
||||
JSON
|
||||
yaml_sec="${out_dir}/secret.yaml"
|
||||
pipelock_write_yaml "$m_secret" dev "$yaml_sec"
|
||||
content="$(cat "$yaml_sec")"
|
||||
assert_not_contains "$content" "literal-value-should-not-appear" "secret: literal env value not leaked"
|
||||
assert_not_contains "$content" "MY_SECRET" "secret: env var name not leaked"
|
||||
assert_not_contains "$content" "prompt-message" "secret: prompt sentinel not leaked"
|
||||
rm -f "$m_secret"
|
||||
|
||||
# --- file mode is 600 ---
|
||||
mode="$(stat -f '%p' "$yaml_min" 2>/dev/null || stat -c '%a' "$yaml_min")"
|
||||
# macOS stat -f '%p' returns full mode like 100600; trim. Linux stat -c '%a' gives just 600.
|
||||
mode="${mode: -3}"
|
||||
assert_eq "600" "$mode" "yaml file mode is 600"
|
||||
|
||||
test_summary
|
||||
Reference in New Issue
Block a user