#!/usr/bin/env bats load test_helper # ─── init ────────────────────────────────────────────────────────────── @test "init creates repo with key and gitignore" { run "$SECRETS_BIN" init [ "$status" -eq 0 ] [ -d "$SECRETS_DIR/.git" ] [ -f "$SECRETS_DIR/key.txt" ] [ -f "$SECRETS_DIR/.gitignore" ] grep -q "key.txt" "$SECRETS_DIR/.gitignore" grep -qF '!**/.env.*.age' "$SECRETS_DIR/.gitignore" } @test "init installs pre-commit hook" { run "$SECRETS_BIN" init [ "$status" -eq 0 ] [ -x "$SECRETS_DIR/.git/hooks/pre-commit" ] } @test "init warns if already initialized" { "$SECRETS_BIN" init >/dev/null 2>&1 local key_before key_before=$(cat "$SECRETS_DIR/key.txt") run "$SECRETS_BIN" init [ "$status" -eq 1 ] [[ "$output" == *"Already initialized"* ]] # Key must not be overwritten local key_after key_after=$(cat "$SECRETS_DIR/key.txt") [ "$key_before" = "$key_after" ] } @test "init fails without age" { # Create a temp PATH without age local fake_path="$TEST_TMPDIR/fake-bin" mkdir -p "$fake_path" ln -s "$(which git)" "$fake_path/git" ln -s "$(which bash)" "$fake_path/bash" ln -s "$(which mkdir)" "$fake_path/mkdir" ln -s "$(which cat)" "$fake_path/cat" ln -s "$(which chmod)" "$fake_path/chmod" ln -s "$(which cp)" "$fake_path/cp" ln -s "$(which basename)" "$fake_path/basename" ln -s "$(which dirname)" "$fake_path/dirname" ln -s "$(which cd)" "$fake_path/cd" 2>/dev/null || true run env PATH="$fake_path" "$SECRETS_BIN" init [ "$status" -eq 1 ] [[ "$output" == *"age"* ]] } # ─── push ────────────────────────────────────────────────────────────── @test "push encrypts .env files" { init_with_remote create_project_dir testproj run "$SECRETS_BIN" push testproj [ "$status" -eq 0 ] [ -f "$SECRETS_DIR/testproj/.env.age" ] [ -f "$SECRETS_DIR/testproj/.env.staging.age" ] } @test "push errors with no .env files" { init_with_remote mkdir -p "$WORK_DIR/empty" cd "$WORK_DIR/empty" run "$SECRETS_BIN" push testproj [ "$status" -eq 1 ] [[ "$output" == *"No secret files"* ]] } @test "push errors with missing key" { init_with_remote create_project_dir testproj rm "$SECRETS_DIR/key.txt" run "$SECRETS_BIN" push testproj [ "$status" -eq 1 ] [[ "$output" == *"Key file"* ]] } @test "push derives project name from dirname" { init_with_remote create_project_dir myproject # Don't pass explicit project name run "$SECRETS_BIN" push [ "$status" -eq 0 ] [ -d "$SECRETS_DIR/myproject" ] } @test "push succeeds on repeated push (age is non-deterministic)" { init_with_remote create_project_dir testproj "$SECRETS_BIN" push testproj >/dev/null 2>&1 # Push again — age produces different ciphertext each time, so this creates a new commit run "$SECRETS_BIN" push testproj [ "$status" -eq 0 ] } @test "push encrypts .dev.vars files" { init_with_remote create_project_dir testproj echo "CF_SECRET=wrangler123" > "$WORK_DIR/testproj/.dev.vars" run "$SECRETS_BIN" push testproj [ "$status" -eq 0 ] [ -f "$SECRETS_DIR/testproj/.env.age" ] [ -f "$SECRETS_DIR/testproj/.dev.vars.age" ] } @test "pull decrypts .dev.vars files" { init_with_remote create_project_dir testproj echo "CF_SECRET=wrangler123" > "$WORK_DIR/testproj/.dev.vars" "$SECRETS_BIN" push testproj >/dev/null 2>&1 local pull_dir="$WORK_DIR/pull-devvars" mkdir -p "$pull_dir" cd "$pull_dir" run "$SECRETS_BIN" pull testproj [ "$status" -eq 0 ] [ "$(cat "$pull_dir/.dev.vars")" = "CF_SECRET=wrangler123" ] } @test "pre-commit blocks plaintext .dev.vars files" { init_with_remote cd "$SECRETS_DIR" echo "LEAKED=true" > .dev.vars git add -f .dev.vars run git commit -m "should fail" [ "$status" -eq 1 ] [[ "$output" == *"Plaintext"* ]] } # ─── pull ────────────────────────────────────────────────────────────── @test "pull decrypts files correctly" { init_with_remote create_project_dir testproj "$SECRETS_BIN" push testproj >/dev/null 2>&1 # Pull into a different directory local pull_dir="$WORK_DIR/pull-target" mkdir -p "$pull_dir" cd "$pull_dir" run "$SECRETS_BIN" pull testproj [ "$status" -eq 0 ] [ -f "$pull_dir/.env" ] [ -f "$pull_dir/.env.staging" ] [ "$(cat "$pull_dir/.env")" = "SECRET_KEY=abc123" ] [ "$(cat "$pull_dir/.env.staging")" = "DB_HOST=staging.db.example.com" ] } @test "pull errors for nonexistent project" { init_with_remote run "$SECRETS_BIN" pull nonexistent [ "$status" -eq 1 ] [[ "$output" == *"not found"* ]] } @test "pull errors with missing key" { init_with_remote create_project_dir testproj "$SECRETS_BIN" push testproj >/dev/null 2>&1 rm "$SECRETS_DIR/key.txt" local pull_dir="$WORK_DIR/pull-target" mkdir -p "$pull_dir" cd "$pull_dir" run "$SECRETS_BIN" pull testproj [ "$status" -eq 1 ] [[ "$output" == *"Key file"* ]] } @test "pull overwrites existing files" { init_with_remote create_project_dir testproj "$SECRETS_BIN" push testproj >/dev/null 2>&1 local pull_dir="$WORK_DIR/pull-target" mkdir -p "$pull_dir" echo "OLD_VALUE=stale" > "$pull_dir/.env" cd "$pull_dir" run "$SECRETS_BIN" pull testproj [ "$status" -eq 0 ] [ "$(cat "$pull_dir/.env")" = "SECRET_KEY=abc123" ] } @test "pull reinstalls missing pre-commit hook" { init_with_remote create_project_dir testproj "$SECRETS_BIN" push testproj >/dev/null 2>&1 # Remove the hook rm -f "$SECRETS_DIR/.git/hooks/pre-commit" [ ! -f "$SECRETS_DIR/.git/hooks/pre-commit" ] local pull_dir="$WORK_DIR/pull-target" mkdir -p "$pull_dir" cd "$pull_dir" run "$SECRETS_BIN" pull testproj [ "$status" -eq 0 ] [ -x "$SECRETS_DIR/.git/hooks/pre-commit" ] [[ "$output" == *"Reinstalled"* ]] } # ─── list ────────────────────────────────────────────────────────────── @test "list shows projects and files" { init_with_remote create_project_dir projA "$SECRETS_BIN" push projA >/dev/null 2>&1 create_project_dir projB "$SECRETS_BIN" push projB >/dev/null 2>&1 run "$SECRETS_BIN" list [ "$status" -eq 0 ] [[ "$output" == *"projA"* ]] [[ "$output" == *"projB"* ]] } @test "list shows empty message" { "$SECRETS_BIN" init >/dev/null 2>&1 run "$SECRETS_BIN" list [ "$status" -eq 0 ] [[ "$output" == *"No projects"* ]] } # ─── rm ──────────────────────────────────────────────────────────────── @test "rm removes project from repo" { init_with_remote create_project_dir testproj "$SECRETS_BIN" push testproj >/dev/null 2>&1 [ -d "$SECRETS_DIR/testproj" ] run "$SECRETS_BIN" rm testproj [ "$status" -eq 0 ] [ ! -d "$SECRETS_DIR/testproj" ] } @test "rm errors for nonexistent project" { init_with_remote run "$SECRETS_BIN" rm nonexistent [ "$status" -eq 1 ] [[ "$output" == *"not found"* ]] } # ─── pre-commit hook ────────────────────────────────────────────────── @test "pre-commit blocks plaintext env files" { init_with_remote cd "$SECRETS_DIR" echo "LEAKED=true" > .env.test git add -f .env.test run git commit -m "should fail" [ "$status" -eq 1 ] [[ "$output" == *"Plaintext"* ]] } @test "pre-commit allows .age files" { init_with_remote cd "$SECRETS_DIR" mkdir -p testproj echo "encrypted-blob" > testproj/.env.test.age git add testproj/.env.test.age run git commit -m "should succeed" [ "$status" -eq 0 ] } # ─── clear ───────────────────────────────────────────────────────────── @test "clear removes plaintext secret files" { init_with_remote create_project_dir testproj echo "CF_SECRET=wrangler123" > "$WORK_DIR/testproj/.dev.vars" # Verify files exist [ -f "$WORK_DIR/testproj/.env" ] [ -f "$WORK_DIR/testproj/.env.staging" ] [ -f "$WORK_DIR/testproj/.dev.vars" ] run "$SECRETS_BIN" clear [ "$status" -eq 0 ] [[ "$output" == *"Cleared 3"* ]] # Files should be gone [ ! -f "$WORK_DIR/testproj/.env" ] [ ! -f "$WORK_DIR/testproj/.env.staging" ] [ ! -f "$WORK_DIR/testproj/.dev.vars" ] } @test "clear does nothing when no secret files exist" { mkdir -p "$WORK_DIR/empty" cd "$WORK_DIR/empty" run "$SECRETS_BIN" clear [ "$status" -eq 0 ] [[ "$output" == *"No secret files"* ]] } @test "clear does not remove non-secret files" { mkdir -p "$WORK_DIR/mixed" cd "$WORK_DIR/mixed" echo "SECRET=yes" > .env echo "config" > .envrc echo "other" > app.js run "$SECRETS_BIN" clear [ "$status" -eq 0 ] [ ! -f "$WORK_DIR/mixed/.env" ] [ -f "$WORK_DIR/mixed/.envrc" ] [ -f "$WORK_DIR/mixed/app.js" ] } @test "clear --workspaces removes secrets from all workspaces" { local mono mono=$(create_monorepo) cd "$mono" # Verify files exist [ -f "$mono/.env" ] [ -f "$mono/apps/web/.env.staging" ] [ -f "$mono/apps/api/.env" ] run "$SECRETS_BIN" clear --workspaces [ "$status" -eq 0 ] [[ "$output" == *"Cleared"* ]] # All should be gone [ ! -f "$mono/.env" ] [ ! -f "$mono/apps/web/.env.staging" ] [ ! -f "$mono/apps/api/.env" ] } # ─── run ─────────────────────────────────────────────────────────────── @test "run pulls secrets, runs command, then clears" { init_with_remote create_project_dir testproj "$SECRETS_BIN" push testproj >/dev/null 2>&1 # Remove plaintext files rm "$WORK_DIR/testproj/.env" "$WORK_DIR/testproj/.env.staging" # Run a command that reads the secret run "$SECRETS_BIN" run cat .env [ "$status" -eq 0 ] [[ "$output" == *"SECRET_KEY=abc123"* ]] # After run completes, plaintext files should be cleared [ ! -f "$WORK_DIR/testproj/.env" ] [ ! -f "$WORK_DIR/testproj/.env.staging" ] } @test "run clears secrets even if command fails" { init_with_remote create_project_dir testproj "$SECRETS_BIN" push testproj >/dev/null 2>&1 # Remove plaintext files rm "$WORK_DIR/testproj/.env" "$WORK_DIR/testproj/.env.staging" # Run a command that will fail (set +e so bats captures it) run "$SECRETS_BIN" run false [ "$status" -ne 0 ] # Secrets should still be cleared [ ! -f "$WORK_DIR/testproj/.env" ] [ ! -f "$WORK_DIR/testproj/.env.staging" ] } @test "run errors with no command" { run "$SECRETS_BIN" run [ "$status" -eq 1 ] [[ "$output" == *"Usage"* ]] } @test "run passes arguments through to command" { init_with_remote create_project_dir testproj "$SECRETS_BIN" push testproj >/dev/null 2>&1 rm "$WORK_DIR/testproj/.env" "$WORK_DIR/testproj/.env.staging" # Run with multiple args run "$SECRETS_BIN" run ls -la .env [ "$status" -eq 0 ] [[ "$output" == *".env"* ]] } @test "run supports -- separator" { init_with_remote create_project_dir testproj "$SECRETS_BIN" push testproj >/dev/null 2>&1 rm "$WORK_DIR/testproj/.env" "$WORK_DIR/testproj/.env.staging" run "$SECRETS_BIN" run -- cat .env [ "$status" -eq 0 ] [[ "$output" == *"SECRET_KEY=abc123"* ]] } # ─── workspaces ──────────────────────────────────────────────────────── # Helper: create a monorepo with package.json workspaces create_monorepo() { local dir="$WORK_DIR/myapp" mkdir -p "$dir/apps/web" "$dir/apps/api" "$dir/packages/auth" cat > "$dir/package.json" << 'PKGJSON' { "name": "myapp", "workspaces": ["apps/*", "packages/*"] } PKGJSON # Root env echo "ROOT_SECRET=top" > "$dir/.env" # Workspace envs echo "WEB_DB=webdb" > "$dir/apps/web/.env.staging" echo "API_KEY=abc" > "$dir/apps/api/.env" # packages/auth has no .env — should be skipped silently # Init a git repo so derive_project_name can use dirname git init "$dir" >/dev/null 2>&1 echo "$dir" } @test "push --workspaces encrypts root and workspace env files" { init_with_remote local mono mono=$(create_monorepo) cd "$mono" run "$SECRETS_BIN" push --workspaces [ "$status" -eq 0 ] # Root env [ -f "$SECRETS_DIR/myapp/.env.age" ] # Workspace envs [ -f "$SECRETS_DIR/myapp/apps/web/.env.staging.age" ] [ -f "$SECRETS_DIR/myapp/apps/api/.env.age" ] # packages/auth should NOT have a dir (no .env files) [ ! -d "$SECRETS_DIR/myapp/packages/auth" ] } @test "pull --workspaces decrypts into correct directories" { init_with_remote local mono mono=$(create_monorepo) cd "$mono" "$SECRETS_BIN" push --workspaces >/dev/null 2>&1 # Remove the original env files rm "$mono/.env" "$mono/apps/web/.env.staging" "$mono/apps/api/.env" run "$SECRETS_BIN" pull --workspaces [ "$status" -eq 0 ] # Verify decrypted into correct locations [ "$(cat "$mono/.env")" = "ROOT_SECRET=top" ] [ "$(cat "$mono/apps/web/.env.staging")" = "WEB_DB=webdb" ] [ "$(cat "$mono/apps/api/.env")" = "API_KEY=abc" ] } @test "push --workspaces errors without package.json" { init_with_remote mkdir -p "$WORK_DIR/nopkg" cd "$WORK_DIR/nopkg" run "$SECRETS_BIN" push --workspaces [ "$status" -eq 1 ] [[ "$output" == *"No package.json"* ]] } @test "push --workspaces errors without workspaces field" { init_with_remote mkdir -p "$WORK_DIR/nows" echo '{"name": "nows"}' > "$WORK_DIR/nows/package.json" cd "$WORK_DIR/nows" run "$SECRETS_BIN" push --workspaces [ "$status" -eq 1 ] [[ "$output" == *"No workspaces"* ]] } @test "push --workspaces errors when no env files anywhere" { init_with_remote local dir="$WORK_DIR/empty-mono" mkdir -p "$dir/apps/web" "$dir/packages/lib" cat > "$dir/package.json" << 'EOF' {"workspaces": ["apps/*", "packages/*"]} EOF git init "$dir" >/dev/null 2>&1 cd "$dir" run "$SECRETS_BIN" push --workspaces [ "$status" -eq 1 ] [[ "$output" == *"No secret files"* ]] } # ─── EGB-281: multi-store resolution ────────────────────────────────── @test "which reports default store when no overrides" { unset SECRETS_DIR cd "$HOME" mkdir -p subdir cd subdir run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" == *"$HOME/.secrets"* ]] [[ "$output" == *"source: default"* ]] } @test "which uses .secrets-store file in cwd" { unset SECRETS_DIR mkdir -p "$HOME/.secrets-work" create_bound_project_dir myapp "~/.secrets-work" run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" == *"$HOME/.secrets-work"* ]] # Source line must include both the rule name AND the resolved file path, # not the empty parens (".secrets-store file ()") that v0.1.0.0 shipped. [[ "$output" == *".secrets-store file ("*"$WORK_DIR/myapp/.secrets-store)"* ]] } @test "--store flag overrides .secrets-store file and SECRETS_DIR env" { export SECRETS_DIR="$HOME/.secrets-from-env" create_bound_project_dir myapp "~/.secrets-from-file" run "$SECRETS_BIN" --store "$HOME/.secrets-from-flag" which [ "$status" -eq 0 ] [[ "$output" == *"$HOME/.secrets-from-flag"* ]] [[ "$output" == *"--store flag"* ]] } @test "which walks up to find .secrets-store in ancestor" { unset SECRETS_DIR mkdir -p "$HOME/.secrets-work" mkdir -p "$WORK_DIR/repo/sub/deep" echo "~/.secrets-work" > "$WORK_DIR/repo/.secrets-store" cd "$WORK_DIR/repo/sub/deep" run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" == *"$HOME/.secrets-work"* ]] } @test "which walk-up stops at HOME boundary, does not read \$HOME/.secrets-store" { unset SECRETS_DIR echo "should-not-be-used" > "$HOME/.secrets-store" mkdir -p "$WORK_DIR/repo" cd "$WORK_DIR/repo" run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" != *"should-not-be-used"* ]] [[ "$output" == *"$HOME/.secrets"* ]] [[ "$output" == *"source: default"* ]] } @test "which from outside HOME falls through to default" { unset SECRETS_DIR cd /tmp run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" == *"$HOME/.secrets"* ]] [[ "$output" == *"source: default"* ]] } @test "empty .secrets-store falls through to next rule" { unset SECRETS_DIR mkdir -p "$WORK_DIR/repo" cd "$WORK_DIR/repo" : > .secrets-store run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" == *"source: default"* ]] } @test "comment-only .secrets-store falls through" { unset SECRETS_DIR mkdir -p "$WORK_DIR/repo" cd "$WORK_DIR/repo" printf '# this is a comment\n \n# another\n' > .secrets-store run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" == *"source: default"* ]] } @test "bare name 'work' resolves to ~/.secrets-work" { unset SECRETS_DIR mkdir -p "$HOME/.secrets-work/.git" mkdir -p "$WORK_DIR/repo" echo "work" > "$WORK_DIR/repo/.secrets-store" cd "$WORK_DIR/repo" run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" == *"$HOME/.secrets-work"* ]] } @test "~/-prefix in .secrets-store expands to HOME" { unset SECRETS_DIR mkdir -p "$HOME/.secrets-x" mkdir -p "$WORK_DIR/repo" echo "~/.secrets-x" > "$WORK_DIR/repo/.secrets-store" cd "$WORK_DIR/repo" run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" == *"$HOME/.secrets-x"* ]] } @test ".secrets-store with command injection content does not execute" { unset SECRETS_DIR mkdir -p "$WORK_DIR/repo" cd "$WORK_DIR/repo" local pwn_marker="/tmp/secrets-pwn-$$" rm -f "$pwn_marker" echo "\$(touch $pwn_marker)" > .secrets-store run "$SECRETS_BIN" which [ ! -f "$pwn_marker" ] } @test "pull --store uses the target store's key, not the default key" { unset SECRETS_DIR # Set up store A (default) with its own key "$SECRETS_BIN" init >/dev/null 2>&1 cd "$HOME/.secrets" && git remote add origin "$REMOTE_DIR" && cd - # Set up store B at a different path, with its OWN key local STORE_B="$HOME/.secrets-b" "$SECRETS_BIN" --store "$STORE_B" init >/dev/null 2>&1 # Push secrets to STORE B using B's key create_project_dir testproj "$SECRETS_BIN" --store "$STORE_B" push testproj >/dev/null 2>&1 # Pull to a new dir using --store B (must use B's key.txt, not the default) local pull_dir="$WORK_DIR/pull-target" mkdir -p "$pull_dir" cd "$pull_dir" run "$SECRETS_BIN" --store "$STORE_B" pull testproj [ "$status" -eq 0 ] [ -f "$pull_dir/.env" ] grep -q "SECRET_KEY=abc123" "$pull_dir/.env" } @test "run accepts --store flag" { init_with_remote create_project_dir testproj "$SECRETS_BIN" push testproj >/dev/null 2>&1 rm "$WORK_DIR/testproj/.env" "$WORK_DIR/testproj/.env.staging" run "$SECRETS_BIN" --store "$SECRETS_DIR" run -- cat .env [ "$status" -eq 0 ] [[ "$output" == *"SECRET_KEY=abc123"* ]] } @test "uninitialized store referenced by .secrets-store gives directed error" { unset SECRETS_DIR "$SECRETS_BIN" init >/dev/null 2>&1 mkdir -p "$WORK_DIR/repo" echo "missing-store" > "$WORK_DIR/repo/.secrets-store" cd "$WORK_DIR/repo" run "$SECRETS_BIN" pull [ "$status" -eq 1 ] [[ "$output" == *"git clone"* ]] [[ "$output" == *"--store"* ]] } @test "push -w ignores per-workspace .secrets-store, uses monorepo root binding" { unset SECRETS_DIR mkdir -p "$HOME/.secrets-monorepo" "$SECRETS_BIN" --store "$HOME/.secrets-monorepo" init >/dev/null 2>&1 cd "$HOME/.secrets-monorepo" && git remote add origin "$REMOTE_DIR" && \ git commit --allow-empty -m "init" >/dev/null 2>&1 && \ (git push -u origin main >/dev/null 2>&1 || git push -u origin master >/dev/null 2>&1) && \ cd - local mono="$WORK_DIR/myapp" mkdir -p "$mono/apps/web" cat > "$mono/package.json" << 'PKG' {"workspaces": ["apps/*"]} PKG echo "ROOT=top" > "$mono/.env" echo "WEB=val" > "$mono/apps/web/.env" echo "monorepo" > "$mono/.secrets-store" echo "other-store" > "$mono/apps/web/.secrets-store" git init "$mono" >/dev/null 2>&1 cd "$mono" run "$SECRETS_BIN" push -w [ "$status" -eq 0 ] [ -f "$HOME/.secrets-monorepo/myapp/.env.age" ] [ -f "$HOME/.secrets-monorepo/myapp/apps/web/.env.age" ] [ ! -d "$HOME/.secrets-other-store" ] } @test "push echoes Store info when non-default store is active" { unset SECRETS_DIR mkdir -p "$HOME/.secrets-work" "$SECRETS_BIN" --store "$HOME/.secrets-work" init >/dev/null 2>&1 cd "$HOME/.secrets-work" && git remote add origin "$REMOTE_DIR" && \ git commit --allow-empty -m "init" >/dev/null 2>&1 && \ (git push -u origin main >/dev/null 2>&1 || git push -u origin master >/dev/null 2>&1) && \ cd - create_project_dir myapp run "$SECRETS_BIN" --store "$HOME/.secrets-work" push myapp [ "$status" -eq 0 ] [[ "$output" == *"Store: $HOME/.secrets-work"* ]] } # ─── EGB-281: gap-filler tests (auto-decided during /ship coverage audit) ─ @test "--store with missing argument errors out" { run "$SECRETS_BIN" --store [ "$status" -eq 1 ] [[ "$output" == *"--store requires"* ]] } @test "--store=value (equals form) is accepted" { unset SECRETS_DIR mkdir -p "$HOME/.secrets-equals" cd "$HOME" run "$SECRETS_BIN" --store="$HOME/.secrets-equals" which [ "$status" -eq 0 ] [[ "$output" == *"$HOME/.secrets-equals"* ]] } @test "where and status are aliases of which" { unset SECRETS_DIR cd "$HOME" mkdir -p subdir cd subdir run "$SECRETS_BIN" where [ "$status" -eq 0 ] [[ "$output" == *"source:"* ]] run "$SECRETS_BIN" status [ "$status" -eq 0 ] [[ "$output" == *"source:"* ]] } @test "--store default sugar resolves to ~/.secrets" { unset SECRETS_DIR cd "$HOME" run "$SECRETS_BIN" --store default which [ "$status" -eq 0 ] [[ "$output" == *"$HOME/.secrets"* ]] } @test "missing key.txt in non-default store gives directed error" { unset SECRETS_DIR # Make a "store" directory with .git but no key.txt — simulates a teammate # who cloned the remote but hasn't received the key yet. local STORE_NOKEY="$HOME/.secrets-nokey" mkdir -p "$STORE_NOKEY" git init "$STORE_NOKEY" >/dev/null 2>&1 mkdir -p "$WORK_DIR/proj" echo "nokey" > "$WORK_DIR/proj/.secrets-store" echo "VAL=x" > "$WORK_DIR/proj/.env" cd "$WORK_DIR/proj" run "$SECRETS_BIN" push myapp [ "$status" -eq 1 ] [[ "$output" == *"key.txt"* ]] [[ "$output" == *"teammate"* ]] } @test "CRLF line endings in .secrets-store are tolerated" { unset SECRETS_DIR mkdir -p "$HOME/.secrets-crlf" mkdir -p "$WORK_DIR/proj" printf '~/.secrets-crlf\r\n' > "$WORK_DIR/proj/.secrets-store" cd "$WORK_DIR/proj" run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" == *"$HOME/.secrets-crlf"* ]] } @test "list hints at 'secrets which' when non-default store is active" { unset SECRETS_DIR mkdir -p "$HOME/.secrets-x" "$SECRETS_BIN" --store "$HOME/.secrets-x" init >/dev/null 2>&1 run "$SECRETS_BIN" --store "$HOME/.secrets-x" list [ "$status" -eq 0 ] [[ "$output" == *"secrets which"* ]] } # ─── EGB-281: adversarial-review regression tests (F1-F5) ───────────── @test "F1: cmd_run cleans up plaintext when project path contains apostrophe" { init_with_remote # Project dir whose name contains a single quote — string-interpolated trap # form would close its quoting early on this path and silently fail to clean up. local QUOTED_DIR="$WORK_DIR/dont-leak'apostrophe-test" mkdir -p "$QUOTED_DIR" echo "SECRET_KEY=should-not-leak" > "$QUOTED_DIR/.env" cd "$QUOTED_DIR" # Push with the auto-derived project name (matches the dir name, including the ') "$SECRETS_BIN" push >/dev/null 2>&1 rm "$QUOTED_DIR/.env" # Run a command, then verify .env is cleared by the EXIT trap run "$SECRETS_BIN" run -- cat .env [ "$status" -eq 0 ] [[ "$output" == *"should-not-leak"* ]] # CRITICAL: the trap must have cleaned up — .env must NOT exist on disk. # If F1 regressed (string-interpolated trap), the file would still be here. [ ! -f "$QUOTED_DIR/.env" ] } @test "F2: symlinked .secrets-store is skipped, not followed" { unset SECRETS_DIR # Create a sensitive-looking target outside the project local TARGET="$HOME/.secret-target" echo "/etc/passwd" > "$TARGET" mkdir -p "$WORK_DIR/proj" ln -s "$TARGET" "$WORK_DIR/proj/.secrets-store" cd "$WORK_DIR/proj" run "$SECRETS_BIN" which [ "$status" -eq 0 ] # The symlink should be ignored, falling through to default [[ "$output" != *"/etc/passwd"* ]] [[ "$output" == *"$HOME/.secrets"* ]] [[ "$output" == *"source: default"* ]] } @test "F3: --store rejects flag-shaped value" { run "$SECRETS_BIN" --store --workspaces which [ "$status" -eq 1 ] [[ "$output" == *"looks like a flag"* ]] } @test "F3: --store rejects literal --" { run "$SECRETS_BIN" --store -- which [ "$status" -eq 1 ] [[ "$output" == *"looks like a flag"* ]] } @test "F4: --store= empty value is rejected" { run "$SECRETS_BIN" --store= which [ "$status" -eq 1 ] [[ "$output" == *"requires a value"* ]] } @test "F5: HOME unset gives directed error" { # Capture current HOME so we can restore for teardown local SAVED_HOME="$HOME" unset HOME run "$SECRETS_BIN" which export HOME="$SAVED_HOME" # restore before assertions in case bats relies on it [ "$status" -ne 0 ] [[ "$output" == *"HOME"* ]] } # ─── EGB-282: optional remote URL in .secrets-store ────────────────── @test "EGB-282: .secrets-store without URL still works (backward compat)" { unset SECRETS_DIR mkdir -p "$HOME/.secrets-work" mkdir -p "$WORK_DIR/proj" # Single token only — same as v0.1.0.0 format echo "work" > "$WORK_DIR/proj/.secrets-store" cd "$WORK_DIR/proj" run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" == *"$HOME/.secrets-work"* ]] } @test "EGB-282: .secrets-store with URL parses both tokens" { unset SECRETS_DIR mkdir -p "$WORK_DIR/proj" echo "work git@github.com:acme/work-secrets.git" > "$WORK_DIR/proj/.secrets-store" cd "$WORK_DIR/proj" # Store doesn't exist yet — pull should fail with the directed error # that includes the actual URL (not the placeholder). run "$SECRETS_BIN" pull [ "$status" -eq 1 ] [[ "$output" == *"git clone git@github.com:acme/work-secrets.git $HOME/.secrets-work"* ]] # Placeholder must NOT appear when a real URL was supplied [[ "$output" != *""* ]] } @test "EGB-282: missing-store error still works without URL (placeholder)" { unset SECRETS_DIR mkdir -p "$WORK_DIR/proj" echo "missing-only" > "$WORK_DIR/proj/.secrets-store" cd "$WORK_DIR/proj" run "$SECRETS_BIN" pull [ "$status" -eq 1 ] # No URL given — placeholder is the right behavior. [[ "$output" == *""* ]] } @test "EGB-282: https URL is preserved literally" { unset SECRETS_DIR mkdir -p "$WORK_DIR/proj" echo "work https://github.com/acme/work-secrets.git" > "$WORK_DIR/proj/.secrets-store" cd "$WORK_DIR/proj" run "$SECRETS_BIN" pull [ "$status" -eq 1 ] [[ "$output" == *"https://github.com/acme/work-secrets.git"* ]] } @test "EGB-282: ~/-prefixed path with URL works" { unset SECRETS_DIR mkdir -p "$WORK_DIR/proj" printf '~/.secrets-x git@github.com:acme/x.git\n' > "$WORK_DIR/proj/.secrets-store" cd "$WORK_DIR/proj" run "$SECRETS_BIN" pull [ "$status" -eq 1 ] [[ "$output" == *"git clone git@github.com:acme/x.git $HOME/.secrets-x"* ]] } @test "EGB-282: comments before URL line are still skipped" { unset SECRETS_DIR mkdir -p "$WORK_DIR/proj" printf '# this binding was set by alice\n# please do not delete\nwork git@github.com:acme/work-secrets.git\n' > "$WORK_DIR/proj/.secrets-store" cd "$WORK_DIR/proj" run "$SECRETS_BIN" pull [ "$status" -eq 1 ] [[ "$output" == *"git clone git@github.com:acme/work-secrets.git"* ]] } # ─── EGB-282 adversarial regressions: URL injection prevention ──────── @test "EGB-282 SECURITY: URL with shell metachars is dropped (rm -rf payload)" { unset SECRETS_DIR mkdir -p "$WORK_DIR/proj" # The classic copy-paste shell injection: a `;` after the "URL" splits # the rendered git clone into two commands, the second of which is the # attacker payload. The teammate copy-pasting the directed-error one-liner # would execute `rm -rf ~`. The parser must reject this. printf 'work evil.git;rm -rf ~\n' > "$WORK_DIR/proj/.secrets-store" cd "$WORK_DIR/proj" run "$SECRETS_BIN" pull [ "$status" -eq 1 ] # Must use the placeholder, NOT the attacker URL [[ "$output" == *""* ]] [[ "$output" != *"rm -rf"* ]] # And must have warned the user that something was dropped [[ "$output" == *"WARNING"* ]] [[ "$output" == *"unsafe"* ]] } @test "EGB-282 SECURITY: URL with backticks is dropped" { unset SECRETS_DIR mkdir -p "$WORK_DIR/proj" printf 'work evil.git`whoami`\n' > "$WORK_DIR/proj/.secrets-store" cd "$WORK_DIR/proj" run "$SECRETS_BIN" pull [ "$status" -eq 1 ] [[ "$output" == *""* ]] } @test "EGB-282 SECURITY: URL with command substitution \$() is dropped" { unset SECRETS_DIR mkdir -p "$WORK_DIR/proj" printf 'work evil.git$(whoami)\n' > "$WORK_DIR/proj/.secrets-store" cd "$WORK_DIR/proj" run "$SECRETS_BIN" pull [ "$status" -eq 1 ] [[ "$output" == *""* ]] } @test "EGB-282 SECURITY: URL with ANSI escape is dropped (terminal-spoof prevention)" { unset SECRETS_DIR mkdir -p "$WORK_DIR/proj" # ESC ([ \x1b) lets a malicious URL render differently from what gets pasted printf 'work evil.git\x1b[2K\r\n' > "$WORK_DIR/proj/.secrets-store" cd "$WORK_DIR/proj" run "$SECRETS_BIN" pull [ "$status" -eq 1 ] [[ "$output" == *""* ]] } @test "EGB-282 SECURITY: multi-token URL ('work url1 url2') is dropped" { unset SECRETS_DIR mkdir -p "$WORK_DIR/proj" # `git clone url1 url2 /path` would clone url1 into directory `url2` — wrong # behavior either way. Treat any whitespace inside the URL token as unsafe. printf 'work url1 url2\n' > "$WORK_DIR/proj/.secrets-store" cd "$WORK_DIR/proj" run "$SECRETS_BIN" pull [ "$status" -eq 1 ] [[ "$output" == *""* ]] } @test "EGB-282 SECURITY: glob char in URL is dropped (no expansion either way)" { unset SECRETS_DIR mkdir -p "$WORK_DIR/proj" # In v0.1.1.0-alpha (set -- $line), this used to expand to filenames. # The fixed parser uses `read -r`, so it doesn't glob — but glob chars # are still rejected as suspicious. printf 'work /tmp/*\n' > "$WORK_DIR/proj/.secrets-store" cd "$WORK_DIR/proj" run "$SECRETS_BIN" pull [ "$status" -eq 1 ] [[ "$output" == *""* ]] } @test "EGB-282: spec parsing is glob-safe (work * does NOT expand)" { unset SECRETS_DIR mkdir -p "$WORK_DIR/proj" # Create files in cwd that would expand if `set -- $line` were used. touch "$WORK_DIR/proj/file1" "$WORK_DIR/proj/file2" printf 'work *\n' > "$WORK_DIR/proj/.secrets-store" cd "$WORK_DIR/proj" run "$SECRETS_BIN" which [ "$status" -eq 0 ] # Spec is the literal "work" (resolves to ~/.secrets-work). The "*" gets # rejected as unsafe URL and dropped. Resolution works; no globbing. [[ "$output" == *"$HOME/.secrets-work"* ]] } @test "EGB-282: URL with - + _ : / @ . is preserved (positive test)" { unset SECRETS_DIR mkdir -p "$WORK_DIR/proj" # Standard git URL chars must NOT be rejected printf 'work git+ssh://user@host:2222/path/to-repo_v2.git\n' > "$WORK_DIR/proj/.secrets-store" cd "$WORK_DIR/proj" run "$SECRETS_BIN" pull [ "$status" -eq 1 ] [[ "$output" == *"git+ssh://user@host:2222/path/to-repo_v2.git"* ]] } # ─── EGB-531: gradle.properties external file support ────────────────── # Helper: write a fake global gradle.properties under the sandboxed HOME. gradle_src() { mkdir -p "$HOME/.gradle" printf '%s' "$1" > "$HOME/.gradle/gradle.properties" } # Helper: bind a project dir to a gradle entry via .secrets-files, cd into it. gradle_project() { local name="${1:-gproj}" local keys="${2:-beaconClerkPkTest beaconClerkPkLive}" local dir="$WORK_DIR/$name" mkdir -p "$dir" printf 'gradle-properties ~/.gradle/gradle.properties %s\n' "$keys" > "$dir/.secrets-files" cd "$dir" } @test "EGB-531: which shows parsed .secrets-files entries" { init_with_remote gradle_project gproj run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" == *"gradle-properties"* ]] [[ "$output" == *"~/.gradle/gradle.properties"* ]] [[ "$output" == *"beaconClerkPkTest"* ]] } @test "EGB-531: push extracts managed keys into external/ blob (no .env needed)" { init_with_remote gradle_src $'beaconClerkPkTest=pk_test_abc\nbeaconClerkPkLive=pk_live_xyz\nunrelated=keep\n' gradle_project gproj run "$SECRETS_BIN" push gproj [ "$status" -eq 0 ] [[ "$output" == *"Extracted 2 key"* ]] run bash -c "ls $SECRETS_DIR/gproj/external/*.gradle-properties.age" [ "$status" -eq 0 ] } @test "EGB-531: push dies if all managed keys missing from source" { init_with_remote gradle_src $'somethingelse=1\n' gradle_project gproj run "$SECRETS_BIN" push gproj [ "$status" -eq 1 ] [[ "$output" == *"not found"* ]] } @test "EGB-531: pull merges managed keys, preserves unrelated entries" { init_with_remote gradle_src $'beaconClerkPkTest=pk_test_abc\nbeaconClerkPkLive=pk_live_xyz\n' gradle_project gproj "$SECRETS_BIN" push gproj >/dev/null 2>&1 # Simulate a second machine: target holds only unrelated keys gradle_src $'unrelated.key=keepme\norg.gradle.jvmargs=-Xmx2g\n' run "$SECRETS_BIN" pull gproj [ "$status" -eq 0 ] [[ "$output" == *"Merged 2 key"* ]] grep -q 'beaconClerkPkTest=pk_test_abc' "$HOME/.gradle/gradle.properties" grep -q 'beaconClerkPkLive=pk_live_xyz' "$HOME/.gradle/gradle.properties" grep -q 'unrelated.key=keepme' "$HOME/.gradle/gradle.properties" grep -q 'org.gradle.jvmargs=-Xmx2g' "$HOME/.gradle/gradle.properties" } @test "EGB-531: merge does NOT touch a substring key" { init_with_remote gradle_src $'beaconClerkPkTest=secretval\n' gradle_project gproj beaconClerkPkTest "$SECRETS_BIN" push gproj >/dev/null 2>&1 # Target has the shorter key AND a stale managed key gradle_src $'beaconClerkPk=DONOTCHANGE\nbeaconClerkPkTest=old\n' run "$SECRETS_BIN" pull gproj [ "$status" -eq 0 ] grep -q '^beaconClerkPk=DONOTCHANGE$' "$HOME/.gradle/gradle.properties" grep -q '^beaconClerkPkTest=secretval$' "$HOME/.gradle/gradle.properties" } @test "EGB-531: value with sed/regex metacharacters round-trips byte-exact" { init_with_remote local val='a/b&c\d.e|f$g' gradle_src "$(printf 'beaconClerkPkTest=%s\n' "$val")" gradle_project gproj beaconClerkPkTest "$SECRETS_BIN" push gproj >/dev/null 2>&1 gradle_src $'other=1\n' run "$SECRETS_BIN" pull gproj [ "$status" -eq 0 ] run grep -F "beaconClerkPkTest=$val" "$HOME/.gradle/gradle.properties" [ "$status" -eq 0 ] } @test "EGB-531: colon and space separators are parsed" { init_with_remote gradle_src $'beaconClerkPkTest : pk_colon\nbeaconClerkPkLive pk_space\n' gradle_project gproj "$SECRETS_BIN" push gproj >/dev/null 2>&1 gradle_src $'x=1\n' run "$SECRETS_BIN" pull gproj [ "$status" -eq 0 ] grep -q '^beaconClerkPkTest=pk_colon$' "$HOME/.gradle/gradle.properties" grep -q '^beaconClerkPkLive=pk_space$' "$HOME/.gradle/gradle.properties" } @test "EGB-531: pull is idempotent (second pull leaves file byte-identical)" { init_with_remote gradle_src $'beaconClerkPkTest=pk_test_abc\nbeaconClerkPkLive=pk_live_xyz\n' gradle_project gproj "$SECRETS_BIN" push gproj >/dev/null 2>&1 gradle_src $'unrelated=x\n# a comment\n' "$SECRETS_BIN" pull gproj >/dev/null 2>&1 cp "$HOME/.gradle/gradle.properties" "$TEST_TMPDIR/snap1" "$SECRETS_BIN" pull gproj >/dev/null 2>&1 run diff "$TEST_TMPDIR/snap1" "$HOME/.gradle/gradle.properties" [ "$status" -eq 0 ] } @test "EGB-531: merge preserves comments and blank lines" { init_with_remote gradle_src $'beaconClerkPkTest=pk_test_abc\n' gradle_project gproj beaconClerkPkTest "$SECRETS_BIN" push gproj >/dev/null 2>&1 gradle_src $'# header comment\n\nunrelated=x\n' run "$SECRETS_BIN" pull gproj [ "$status" -eq 0 ] grep -q '^# header comment$' "$HOME/.gradle/gradle.properties" grep -q '^unrelated=x$' "$HOME/.gradle/gradle.properties" grep -q '^beaconClerkPkTest=pk_test_abc$' "$HOME/.gradle/gradle.properties" } @test "EGB-531: duplicate managed key in target collapses to one canonical line" { init_with_remote gradle_src $'beaconClerkPkTest=newval\n' gradle_project gproj beaconClerkPkTest "$SECRETS_BIN" push gproj >/dev/null 2>&1 gradle_src $'beaconClerkPkTest=old1\nx=1\nbeaconClerkPkTest=old2\n' run "$SECRETS_BIN" pull gproj [ "$status" -eq 0 ] run grep -c '^beaconClerkPkTest=' "$HOME/.gradle/gradle.properties" [ "$output" -eq 1 ] grep -q '^beaconClerkPkTest=newval$' "$HOME/.gradle/gradle.properties" grep -q '^x=1$' "$HOME/.gradle/gradle.properties" } @test "EGB-531: continuation-line-adjacent managed key is not clobbered" { init_with_remote gradle_src $'beaconClerkPkTest=realval\n' gradle_project gproj beaconClerkPkTest "$SECRETS_BIN" push gproj >/dev/null 2>&1 # 'beaconClerkPkTest=...' here is a CONTINUATION of unrelated's value, not a definition printf 'unrelated=foo\\\nbeaconClerkPkTest=continuation\n' > "$HOME/.gradle/gradle.properties" run "$SECRETS_BIN" pull gproj [ "$status" -eq 0 ] # continuation line preserved verbatim grep -q '^beaconClerkPkTest=continuation$' "$HOME/.gradle/gradle.properties" # and the real managed key appended grep -q '^beaconClerkPkTest=realval$' "$HOME/.gradle/gradle.properties" } @test "EGB-531: first-create target gets mode 600" { init_with_remote gradle_src $'beaconClerkPkTest=pk_test_abc\n' gradle_project gproj beaconClerkPkTest "$SECRETS_BIN" push gproj >/dev/null 2>&1 rm -f "$HOME/.gradle/gradle.properties" run "$SECRETS_BIN" pull gproj [ "$status" -eq 0 ] [ -f "$HOME/.gradle/gradle.properties" ] local mode mode=$(stat -f '%Lp' "$HOME/.gradle/gradle.properties" 2>/dev/null || stat -c '%a' "$HOME/.gradle/gradle.properties") [ "$mode" = "600" ] } @test "EGB-531: target with wrong basename is refused" { init_with_remote mkdir -p "$HOME/.gradle" printf 'beaconClerkPkTest=x\n' > "$HOME/.gradle/custom.properties" mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" printf 'gradle-properties ~/.gradle/custom.properties beaconClerkPkTest\n' > .secrets-files run "$SECRETS_BIN" push gproj [ "$status" -eq 1 ] [[ "$output" == *"gradle.properties"* ]] } @test "EGB-531: target outside HOME is refused" { init_with_remote local outside outside=$(mktemp -d) mkdir -p "$outside/.gradle" printf 'beaconClerkPkTest=x\n' > "$outside/.gradle/gradle.properties" mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" printf 'gradle-properties %s/.gradle/gradle.properties beaconClerkPkTest\n' "$outside" > .secrets-files run "$SECRETS_BIN" push gproj rm -rf "$outside" [ "$status" -eq 1 ] [[ "$output" == *"HOME"* ]] } @test "EGB-531: symlinked target is refused" { init_with_remote mkdir -p "$HOME/.gradle" printf 'beaconClerkPkTest=x\n' > "$HOME/realgradle" ln -s "$HOME/realgradle" "$HOME/.gradle/gradle.properties" gradle_project gproj beaconClerkPkTest run "$SECRETS_BIN" push gproj [ "$status" -eq 1 ] [[ "$output" == *"symlink"* ]] } @test "EGB-531: unknown type in manifest warns and skips" { init_with_remote mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" printf 'gradle-props ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files echo "X=1" > .env run "$SECRETS_BIN" push gproj [[ "$output" == *"unknown type"* ]] [ ! -d "$SECRETS_DIR/gproj/external" ] } @test "EGB-531: malformed manifest line (no path/keys) is skipped with warning" { init_with_remote mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" printf 'gradle-properties\n' > .secrets-files run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" == *"WARNING"* ]] } @test "EGB-531: manifest path with command-substitution chars is rejected" { init_with_remote mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" local pwn="$TEST_TMPDIR/pwn-$$" rm -f "$pwn" printf 'gradle-properties ~/.gradle/gradle.properties$(touch %s) beaconClerkPkTest\n' "$pwn" > .secrets-files run "$SECRETS_BIN" which [ ! -f "$pwn" ] [[ "$output" == *"WARNING"* ]] } @test "EGB-531: symlinked .secrets-files is ignored" { init_with_remote printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$HOME/realmanifest" mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" ln -s "$HOME/realmanifest" .secrets-files run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" != *"beaconClerkPkTest"* ]] } @test "EGB-531: rekey re-encrypts the external blob (still decryptable after)" { init_with_remote gradle_src $'beaconClerkPkTest=pk_test_abc\n' gradle_project gproj beaconClerkPkTest "$SECRETS_BIN" push gproj >/dev/null 2>&1 "$SECRETS_BIN" rekey >/dev/null 2>&1 gradle_src $'other=1\n' run "$SECRETS_BIN" pull gproj [ "$status" -eq 0 ] grep -q '^beaconClerkPkTest=pk_test_abc$' "$HOME/.gradle/gradle.properties" } @test "EGB-531: gradle blob is NOT decrypted into cwd by dotenv pull" { init_with_remote gradle_src $'beaconClerkPkTest=pk_test_abc\n' mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files echo "DOTENV=1" > .env "$SECRETS_BIN" push gproj >/dev/null 2>&1 local pulldir="$WORK_DIR/pull-gproj" mkdir -p "$pulldir" cp "$WORK_DIR/gproj/.secrets-files" "$pulldir/.secrets-files" cd "$pulldir" run "$SECRETS_BIN" pull gproj [ "$status" -eq 0 ] [ -f "$pulldir/.env" ] [ ! -f "$pulldir/gradle.properties" ] } @test "EGB-531: list shows external entry" { init_with_remote gradle_src $'beaconClerkPkTest=pk_test_abc\n' gradle_project gproj beaconClerkPkTest "$SECRETS_BIN" push gproj >/dev/null 2>&1 run "$SECRETS_BIN" list [ "$status" -eq 0 ] [[ "$output" == *"external"* ]] } @test "EGB-531: no .secrets-files behaves exactly as before (backward compat)" { init_with_remote create_project_dir plainproj run "$SECRETS_BIN" push plainproj [ "$status" -eq 0 ] [ ! -d "$SECRETS_DIR/plainproj/external" ] } @test "EGB-531: pre-commit blocks plaintext gradle.properties in store" { init_with_remote cd "$SECRETS_DIR" echo "beaconClerkPkTest=leak" > gradle.properties git add -f gradle.properties run git commit -m "should fail" [ "$status" -eq 1 ] [[ "$output" == *"Plaintext"* ]] } # ── EGB-531: coverage for warning/error branches, workspaces, multi-entry ── @test "EGB-531: push -w pushes external blob once at monorepo root" { init_with_remote gradle_src $'beaconClerkPkTest=pk_w\n' local mono="$WORK_DIR/mono" mkdir -p "$mono/apps/web" printf '{"workspaces":["apps/*"]}\n' > "$mono/package.json" echo "ROOT=1" > "$mono/.env" echo "WEB=1" > "$mono/apps/web/.env" printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$mono/.secrets-files" git init "$mono" >/dev/null 2>&1 cd "$mono" run "$SECRETS_BIN" push -w [ "$status" -eq 0 ] # External blob pushed exactly once (not once per workspace) run bash -c "ls $SECRETS_DIR/mono/external/*.gradle-properties.age 2>/dev/null | wc -l | tr -d ' '" [ "$output" = "1" ] } @test "EGB-531: pull -w merges external keys at monorepo root" { init_with_remote gradle_src $'beaconClerkPkTest=pk_w\n' local mono="$WORK_DIR/mono" mkdir -p "$mono/apps/web" printf '{"workspaces":["apps/*"]}\n' > "$mono/package.json" echo "ROOT=1" > "$mono/.env" echo "WEB=1" > "$mono/apps/web/.env" printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$mono/.secrets-files" git init "$mono" >/dev/null 2>&1 cd "$mono" "$SECRETS_BIN" push -w >/dev/null 2>&1 gradle_src $'unrelated=keep\n' run "$SECRETS_BIN" pull -w [ "$status" -eq 0 ] grep -q '^beaconClerkPkTest=pk_w$' "$HOME/.gradle/gradle.properties" grep -q '^unrelated=keep$' "$HOME/.gradle/gradle.properties" } @test "EGB-531: push warns for missing key but still syncs present ones" { init_with_remote gradle_src $'beaconClerkPkTest=present\n' gradle_project gproj run "$SECRETS_BIN" push gproj [ "$status" -eq 0 ] [[ "$output" == *"beaconClerkPkLive"* ]] [[ "$output" == *"not found"* ]] [[ "$output" == *"Extracted 1 key"* ]] } @test "EGB-531: pull warns when manifest entry has no blob in store" { init_with_remote create_project_dir gproj "$SECRETS_BIN" push gproj >/dev/null 2>&1 printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$WORK_DIR/gproj/.secrets-files" cd "$WORK_DIR/gproj" run "$SECRETS_BIN" pull gproj [ "$status" -eq 0 ] [[ "$output" == *"no encrypted data exists"* ]] } @test "EGB-531: multi-entry manifest syncs each target" { init_with_remote mkdir -p "$HOME/.gradle" "$HOME/.gradle-b" printf 'beaconClerkPkTest=a\n' > "$HOME/.gradle/gradle.properties" printf 'beaconClerkPkLive=b\n' > "$HOME/.gradle-b/gradle.properties" mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\ngradle-properties ~/.gradle-b/gradle.properties beaconClerkPkLive\n' > .secrets-files run "$SECRETS_BIN" push gproj [ "$status" -eq 0 ] run bash -c "ls $SECRETS_DIR/gproj/external/*.age 2>/dev/null | wc -l | tr -d ' '" [ "$output" = "2" ] printf 'x=1\n' > "$HOME/.gradle/gradle.properties" printf 'y=1\n' > "$HOME/.gradle-b/gradle.properties" run "$SECRETS_BIN" pull gproj [ "$status" -eq 0 ] grep -q '^beaconClerkPkTest=a$' "$HOME/.gradle/gradle.properties" grep -q '^beaconClerkPkLive=b$' "$HOME/.gradle-b/gradle.properties" } @test "EGB-531: manifest with unsafe key chars is skipped with warning" { init_with_remote mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" printf 'gradle-properties ~/.gradle/gradle.properties bad=key\n' > .secrets-files run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" == *"WARNING"* ]] [[ "$output" != *"bad=key"* ]] } @test "EGB-531: symlinked parent dir of target is refused" { init_with_remote mkdir -p "$HOME/realdir" printf 'beaconClerkPkTest=x\n' > "$HOME/realdir/gradle.properties" ln -s "$HOME/realdir" "$HOME/.gradle" gradle_project gproj beaconClerkPkTest run "$SECRETS_BIN" push gproj [ "$status" -eq 1 ] [[ "$output" == *"symlink"* ]] } @test "EGB-531: push skips a multi-line (continuation) managed value with a warning" { init_with_remote mkdir -p "$HOME/.gradle" # beaconClerkPkTest has a continuation value (trailing backslash); Live is single-line printf '%s' $'beaconClerkPkTest=part1\\\npart2\nbeaconClerkPkLive=fine\n' > "$HOME/.gradle/gradle.properties" gradle_project gproj run "$SECRETS_BIN" push gproj [ "$status" -eq 0 ] [[ "$output" == *"multi-line"* ]] [[ "$output" == *"Extracted 1 key"* ]] } @test "EGB-531: push skips comment and continuation lines in source" { init_with_remote mkdir -p "$HOME/.gradle" printf '%s' $'! bang comment\nunrelated=foo\\\nbeaconClerkPkTest=is_a_continuation\nbeaconClerkPkTest=realkey\n' > "$HOME/.gradle/gradle.properties" gradle_project gproj beaconClerkPkTest "$SECRETS_BIN" push gproj >/dev/null 2>&1 gradle_src $'z=1\n' run "$SECRETS_BIN" pull gproj [ "$status" -eq 0 ] # The continuation line that looks like the key must NOT win; the real # definition must, and the '!' comment must be ignored. grep -q '^beaconClerkPkTest=realkey$' "$HOME/.gradle/gradle.properties" }