Stage 2 of EGB-677. Makes the store self-describing and unifies the legacy external `properties` blob suffix, via a copy-forward migration that never destroys data until an explicit, gated finalize. Scope decision (see eureka): the EGB-677 CEO plan's "flatten dotenv blobs to basename" was dropped as LOSSY — it discards the restore relpath that makes the store self-describing and adds basename collisions. Engineering analysis (4 parallel design agents) showed the store is already relpath-self-describing; the only real v1→v2 delta is the `properties` suffix. This implements the minimal, safe v2 that achieves the epic's self-describing goal. What's added: - `.secrets-format` marker (committed, one line `2`). Absence ⇒ v1 (every pre-EGB-703 store). `_store_format()` reads it; `init` stamps fresh stores born-v2. `secrets which` prints `format: vN` (EGB-700 folded in). - `_external_blob_suffix(type)` — single source of truth for the external suffix (v2: gradle-properties → properties; file unchanged). push/pull/verify all route through it, so v1 and v2 stores never disagree on blob location. - `secrets migrate` — per-project copy-forward (writes `.properties.age` twins beside v1 blobs; idempotent; needs the project manifest), `--dry-run` (reports old→new, writes nothing), `--finalize` (store-wide, the only destructive step: gates on `verify --all` green + every v1 blob twinned, cuts a `pre-v2-migrate-<sha>` recovery tag, stamps the marker, then drops v1 blobs; refuses without `--yes`/operator confirmation). rekey and verify --all stay format-agnostic (recursive find walk) — no change. 21 new bats tests (test/migrate.bats): marker/born-v2, format-aware suffix, v1 back-compat, dry-run, copy-forward idempotency, no-manifest die, finalize gates (verify-not-green refusal, untwinned refusal, recovery tag, confirmation), and full v1→window→finalize round-trip. Updated 4 existing tests for the born-v2 suffix. Full suite 231/231, bash 3.2 clean.
1765 lines
57 KiB
Bash
1765 lines
57 KiB
Bash
#!/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"* ]] || false
|
|
|
|
# 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"* ]] || false
|
|
}
|
|
|
|
# ─── 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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
# ─── 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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
# ─── 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"* ]] || false
|
|
[[ "$output" == *"projB"* ]] || false
|
|
}
|
|
|
|
@test "list shows empty message" {
|
|
"$SECRETS_BIN" init >/dev/null 2>&1
|
|
|
|
run "$SECRETS_BIN" list
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"No projects"* ]] || false
|
|
}
|
|
|
|
# ─── 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"* ]] || false
|
|
}
|
|
|
|
# ─── 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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
|
|
# 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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
|
|
# 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"* ]] || false
|
|
|
|
# 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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
# ─── 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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
# ─── 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"* ]] || false
|
|
[[ "$output" == *"source: default"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
# 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)"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
[[ "$output" == *"--store flag"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
[[ "$output" == *"$HOME/.secrets"* ]] || false
|
|
[[ "$output" == *"source: default"* ]] || false
|
|
}
|
|
|
|
@test "which from outside HOME falls through to default" {
|
|
unset SECRETS_DIR
|
|
cd /tmp
|
|
run "$SECRETS_BIN" which
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"$HOME/.secrets"* ]] || false
|
|
[[ "$output" == *"source: default"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
[[ "$output" == *"--store"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
# ─── 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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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:"* ]] || false
|
|
|
|
run "$SECRETS_BIN" status
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"source:"* ]] || false
|
|
}
|
|
|
|
@test "--store default sugar resolves to ~/.secrets" {
|
|
unset SECRETS_DIR
|
|
cd "$HOME"
|
|
run "$SECRETS_BIN" --store default which
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"$HOME/.secrets"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
[[ "$output" == *"teammate"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
# ─── 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"* ]] || false
|
|
# 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"* ]] || false
|
|
[[ "$output" == *"$HOME/.secrets"* ]] || false
|
|
[[ "$output" == *"source: default"* ]] || false
|
|
}
|
|
|
|
@test "F3: --store rejects flag-shaped value" {
|
|
run "$SECRETS_BIN" --store --workspaces which
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"looks like a flag"* ]] || false
|
|
}
|
|
|
|
@test "F3: --store rejects literal --" {
|
|
run "$SECRETS_BIN" --store -- which
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"looks like a flag"* ]] || false
|
|
}
|
|
|
|
@test "F4: --store= empty value is rejected" {
|
|
run "$SECRETS_BIN" --store= which
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"requires a value"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
# ─── 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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
# Placeholder must NOT appear when a real URL was supplied
|
|
[[ "$output" != *"<their-store-remote>"* ]] || false
|
|
}
|
|
|
|
@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" == *"<their-store-remote>"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
# ─── 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" == *"<their-store-remote>"* ]] || false
|
|
[[ "$output" != *"rm -rf"* ]] || false
|
|
# And must have warned the user that something was dropped
|
|
[[ "$output" == *"WARNING"* ]] || false
|
|
[[ "$output" == *"unsafe"* ]] || false
|
|
}
|
|
|
|
@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" == *"<their-store-remote>"* ]] || false
|
|
}
|
|
|
|
@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" == *"<their-store-remote>"* ]] || false
|
|
}
|
|
|
|
@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" == *"<their-store-remote>"* ]] || false
|
|
}
|
|
|
|
@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" == *"<their-store-remote>"* ]] || false
|
|
}
|
|
|
|
@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" == *"<their-store-remote>"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
# ─── 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"* ]] || false
|
|
[[ "$output" == *"~/.gradle/gradle.properties"* ]] || false
|
|
[[ "$output" == *"beaconClerkPkTest"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
run bash -c "ls $SECRETS_DIR/gproj/external/*.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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
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 non-.properties basename is refused" {
|
|
# EGB-677 generalized the rail from exact 'gradle.properties' to any
|
|
# '*.properties' basename — shell rc files and gitconfig stay blocked.
|
|
init_with_remote
|
|
mkdir -p "$HOME/.gradle"
|
|
printf 'beaconClerkPkTest=x\n' > "$HOME/.gradle/evil.sh"
|
|
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
|
|
printf 'gradle-properties ~/.gradle/evil.sh beaconClerkPkTest\n' > .secrets-files
|
|
run "$SECRETS_BIN" push gproj
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *".properties"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
[ ! -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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
# ── 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/*.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"* ]] || false
|
|
[[ "$output" == *"not found"* ]] || false
|
|
[[ "$output" == *"Extracted 1 key"* ]] || false
|
|
}
|
|
|
|
@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
|
|
# EGB-677: drop the bootstrap .secrets.json so the legacy manifest path
|
|
# is exercised (with a manifest present, .secrets-files is superseded).
|
|
rm -f "$WORK_DIR/gproj/.secrets.json"
|
|
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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
[[ "$output" != *"bad=key"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
}
|
|
|
|
@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"* ]] || false
|
|
[[ "$output" == *"Extracted 1 key"* ]] || false
|
|
}
|
|
|
|
@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"
|
|
}
|
|
|
|
# ─── init second-machine guard + store .gitignore self-heal ────────────
|
|
|
|
@test "init with existing key but no repo dies with clone guidance" {
|
|
# Second-machine trap: user copies key.txt into ~/.secrets, then runs
|
|
# `secrets init` instead of cloning their secrets repo.
|
|
mkdir -p "$SECRETS_DIR"
|
|
age-keygen -o "$SECRETS_DIR/key.txt" 2>/dev/null
|
|
# Guard against a vacuous '' = '' comparison if age-keygen failed
|
|
[ -s "$SECRETS_DIR/key.txt" ]
|
|
local key_before
|
|
key_before=$(cat "$SECRETS_DIR/key.txt")
|
|
|
|
run "$SECRETS_BIN" init
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"git clone"* ]] || false
|
|
# Must not leave a half-initialized store behind
|
|
[ ! -d "$SECRETS_DIR/.git" ]
|
|
# Key untouched
|
|
[ "$(cat "$SECRETS_DIR/key.txt")" = "$key_before" ]
|
|
}
|
|
|
|
@test "push restores missing store .gitignore and never commits key.txt" {
|
|
init_with_remote
|
|
rm "$SECRETS_DIR/.gitignore"
|
|
create_project_dir
|
|
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"Restored store .gitignore"* ]] || false
|
|
[ -f "$SECRETS_DIR/.gitignore" ]
|
|
grep -q "key.txt" "$SECRETS_DIR/.gitignore"
|
|
# key.txt must never be tracked (push does `git add -A` in the store)
|
|
run git -C "$SECRETS_DIR" ls-files
|
|
[[ "$output" != *"key.txt"* ]] || false
|
|
}
|
|
|
|
@test "pull restores missing store .gitignore" {
|
|
init_with_remote
|
|
create_project_dir
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
rm "$SECRETS_DIR/.gitignore"
|
|
rm .env .env.staging
|
|
|
|
run "$SECRETS_BIN" pull
|
|
[ "$status" -eq 0 ]
|
|
[ -f "$SECRETS_DIR/.gitignore" ]
|
|
}
|
|
|
|
@test "rekey restores missing store .gitignore and never commits key.txt" {
|
|
init_with_remote
|
|
create_project_dir
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
rm "$SECRETS_DIR/.gitignore"
|
|
|
|
run "$SECRETS_BIN" rekey
|
|
[ "$status" -eq 0 ]
|
|
[ -f "$SECRETS_DIR/.gitignore" ]
|
|
run git -C "$SECRETS_DIR" ls-files
|
|
[[ "$output" != *"key.txt"* ]] || false
|
|
}
|
|
|
|
@test "rekey re-encrypts dotenv blobs (round-trip survives key rotation)" {
|
|
# The decrypt loop matches dotfiles (".*.age") but a re-encrypt glob of
|
|
# "$dir"* would silently skip them — leaving .env.age on the OLD key
|
|
# after rotation, i.e. undecryptable. Guard the full round-trip.
|
|
init_with_remote
|
|
create_project_dir
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
|
|
run "$SECRETS_BIN" rekey
|
|
[ "$status" -eq 0 ]
|
|
|
|
rm .env .env.staging
|
|
run "$SECRETS_BIN" pull
|
|
[ "$status" -eq 0 ]
|
|
[ "$(cat .env)" = "SECRET_KEY=abc123" ]
|
|
[ "$(cat .env.staging)" = "DB_HOST=staging.db.example.com" ]
|
|
}
|
|
|
|
@test "push reinstalls missing pre-commit hook" {
|
|
init_with_remote
|
|
rm "$SECRETS_DIR/.git/hooks/pre-commit"
|
|
create_project_dir
|
|
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"Reinstalled pre-commit hook"* ]] || false
|
|
[ -x "$SECRETS_DIR/.git/hooks/pre-commit" ]
|
|
}
|
|
|
|
@test "rekey reinstalls missing pre-commit hook" {
|
|
init_with_remote
|
|
create_project_dir
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
rm "$SECRETS_DIR/.git/hooks/pre-commit"
|
|
|
|
run "$SECRETS_BIN" rekey
|
|
[ "$status" -eq 0 ]
|
|
[ -x "$SECRETS_DIR/.git/hooks/pre-commit" ]
|
|
}
|
|
|
|
@test "store protections heal is a silent no-op when nothing is missing" {
|
|
init_with_remote
|
|
create_project_dir
|
|
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" != *"Restored store .gitignore"* ]] || false
|
|
[[ "$output" != *"Reinstalled pre-commit hook"* ]] || false
|
|
}
|
|
|
|
@test "restored store .gitignore carries the full block/allow globs" {
|
|
init_with_remote
|
|
rm "$SECRETS_DIR/.gitignore"
|
|
create_project_dir
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
|
|
grep -q "^key.txt$" "$SECRETS_DIR/.gitignore"
|
|
grep -qF '**/.env' "$SECRETS_DIR/.gitignore"
|
|
grep -qF '**/.dev.vars' "$SECRETS_DIR/.gitignore"
|
|
grep -qF '!**/.env.age' "$SECRETS_DIR/.gitignore"
|
|
grep -qF '!**/.env.*.age' "$SECRETS_DIR/.gitignore"
|
|
grep -qF '!**/.dev.vars.age' "$SECRETS_DIR/.gitignore"
|
|
}
|
|
|
|
@test "push heals .gitignore removed by remote history before staging (key never pushed)" {
|
|
init_with_remote
|
|
create_project_dir
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
# Remote history drops .gitignore (e.g. an old machine committed without it)
|
|
git clone -q "$REMOTE_DIR" "$TEST_TMPDIR/other"
|
|
git -C "$TEST_TMPDIR/other" rm -q .gitignore
|
|
git -C "$TEST_TMPDIR/other" -c user.email=t@t -c user.name=t commit -qm "drop gitignore"
|
|
git -C "$TEST_TMPDIR/other" push -q
|
|
|
|
echo "B=2" >> .env
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
[ -f "$SECRETS_DIR/.gitignore" ]
|
|
run git -C "$SECRETS_DIR" ls-files
|
|
[[ "$output" != *"key.txt"* ]] || false
|
|
}
|
|
|
|
@test "push untracks a previously committed key.txt with a warning" {
|
|
init_with_remote
|
|
# Simulate legacy damage: key.txt got committed in the past
|
|
git -C "$SECRETS_DIR" add -f key.txt
|
|
git -C "$SECRETS_DIR" -c user.email=t@t -c user.name=t commit -qm "oops"
|
|
create_project_dir
|
|
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"key.txt was tracked"* ]] || false
|
|
run git -C "$SECRETS_DIR" ls-files
|
|
[[ "$output" != *"key.txt"* ]] || false
|
|
}
|
|
|
|
@test "push rewrites a store .gitignore that is missing the key.txt line" {
|
|
init_with_remote
|
|
printf '%s\n' '**/.env' > "$SECRETS_DIR/.gitignore"
|
|
create_project_dir
|
|
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
grep -qx 'key.txt' "$SECRETS_DIR/.gitignore"
|
|
run git -C "$SECRETS_DIR" ls-files
|
|
[[ "$output" != *"key.txt"* ]] || false
|
|
}
|
|
|
|
@test "init guard renders the real clone URL when .secrets-store carries a remote" {
|
|
mkdir -p "$HOME/.secrets-work"
|
|
age-keygen -o "$HOME/.secrets-work/key.txt" 2>/dev/null
|
|
[ -s "$HOME/.secrets-work/key.txt" ]
|
|
cd "$WORK_DIR"
|
|
echo "work git@example.com:me/secrets-work.git" > .secrets-store
|
|
|
|
run "$SECRETS_BIN" init
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"git clone git@example.com:me/secrets-work.git"* ]] || false
|
|
}
|
|
|
|
# ─── EGB-652: `file` external type (whole-file sync, e.g. Android keystore) ──
|
|
|
|
# Helper: write a small binary source file under the sandboxed HOME.
|
|
file_src() {
|
|
mkdir -p "$HOME/keystores"
|
|
printf 'KS\x00\x01\x02\xffDATA-%s\n' "${1:-v1}" > "$HOME/keystores/upload.keystore"
|
|
}
|
|
|
|
# Helper: bind a project dir to a file entry via .secrets-files, cd into it.
|
|
file_project() {
|
|
local dir="$WORK_DIR/$1"
|
|
mkdir -p "$dir"
|
|
printf 'file ~/keystores/upload.keystore\n' > "$dir/.secrets-files"
|
|
cd "$dir" || exit 1
|
|
}
|
|
|
|
@test "EGB-652: which shows parsed file-type entry" {
|
|
init_with_remote
|
|
file_src
|
|
file_project fproj
|
|
run "$SECRETS_BIN" which
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"file"* ]] || false
|
|
[[ "$output" == *"~/keystores/upload.keystore"* ]] || false
|
|
}
|
|
|
|
@test "EGB-652: push encrypts a file-type entry into external/ blob" {
|
|
init_with_remote
|
|
file_src
|
|
file_project fproj
|
|
run "$SECRETS_BIN" push fproj
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"Encrypted file"* ]] || false
|
|
run bash -c "ls $SECRETS_DIR/fproj/external/*.file.age"
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "EGB-652: pull restores the file byte-identical with mode 600" {
|
|
init_with_remote
|
|
file_src v1
|
|
file_project fproj
|
|
"$SECRETS_BIN" push fproj >/dev/null 2>&1
|
|
cp "$HOME/keystores/upload.keystore" "$TEST_TMPDIR/reference"
|
|
rm -rf "$HOME/keystores"
|
|
run "$SECRETS_BIN" pull fproj
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"Restored file"* ]] || false
|
|
cmp "$HOME/keystores/upload.keystore" "$TEST_TMPDIR/reference"
|
|
mode=$(stat -f '%Lp' "$HOME/keystores/upload.keystore" 2>/dev/null || stat -c '%a' "$HOME/keystores/upload.keystore")
|
|
[ "$mode" = "600" ]
|
|
}
|
|
|
|
@test "EGB-652: pull backs up an existing divergent target before overwriting" {
|
|
init_with_remote
|
|
file_src v1
|
|
file_project fproj
|
|
"$SECRETS_BIN" push fproj >/dev/null 2>&1
|
|
file_src v2-local-edit
|
|
run "$SECRETS_BIN" pull fproj
|
|
[ "$status" -eq 0 ]
|
|
grep -q 'DATA-v1' "$HOME/keystores/upload.keystore"
|
|
grep -q 'DATA-v2-local-edit' "$HOME/keystores/upload.keystore.secrets-bak"
|
|
}
|
|
|
|
@test "EGB-652: file entry with trailing keys is rejected" {
|
|
init_with_remote
|
|
file_src
|
|
local dir="$WORK_DIR/fbad"; mkdir -p "$dir"
|
|
printf 'file ~/keystores/upload.keystore strayKey\n' > "$dir/.secrets-files"
|
|
cd "$dir"
|
|
run "$SECRETS_BIN" which
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"take no keys"* ]] || false
|
|
# The rejected entry must not be listed as parsed (header only prints
|
|
# when at least one entry parses).
|
|
[[ "$output" != *"external files ("* ]] || false
|
|
}
|
|
|
|
@test "EGB-652: file target outside HOME is refused on push" {
|
|
init_with_remote
|
|
local dir="$WORK_DIR/fout"; mkdir -p "$dir"
|
|
printf 'file /etc/hosts\n' > "$dir/.secrets-files"
|
|
cd "$dir"
|
|
run "$SECRETS_BIN" push fout
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"inside \$HOME"* ]] || [[ "$output" == *"Refusing"* ]] || false
|
|
}
|
|
|
|
@test "EGB-652: gradle-properties entries still work alongside a file entry" {
|
|
init_with_remote
|
|
gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
file_src
|
|
local dir="$WORK_DIR/fmix"; mkdir -p "$dir"
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\nfile ~/keystores/upload.keystore\n' > "$dir/.secrets-files"
|
|
cd "$dir"
|
|
run "$SECRETS_BIN" push fmix
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"Extracted 1 key"* ]] || false
|
|
[[ "$output" == *"Encrypted file"* ]] || false
|
|
}
|