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.
916 lines
32 KiB
Bash
916 lines
32 KiB
Bash
#!/usr/bin/env bats
|
|
# EGB-677 stage 1: .secrets.json manifest — parse, rails, add, generators.
|
|
|
|
load test_helper
|
|
|
|
# ─── A: manifest core — secrets add + rails + canonical form ──────────
|
|
|
|
@test "add creates .secrets.json with version 2 and the dotenv entry" {
|
|
create_project_dir addproj
|
|
run "$SECRETS_BIN" add .env
|
|
[ "$status" -eq 0 ]
|
|
[ -f ".secrets.json" ]
|
|
run jq -r '.version' .secrets.json
|
|
[ "$output" = "2" ]
|
|
run jq -r '.dotenv[0]' .secrets.json
|
|
[ "$output" = ".env" ]
|
|
}
|
|
|
|
@test "add is idempotent — no duplicate entries" {
|
|
create_project_dir addproj
|
|
"$SECRETS_BIN" add .env >/dev/null
|
|
run "$SECRETS_BIN" add .env
|
|
[ "$status" -eq 0 ]
|
|
run jq -r '.dotenv | length' .secrets.json
|
|
[ "$output" = "1" ]
|
|
}
|
|
|
|
@test "add accepts nested workspace paths" {
|
|
create_project_dir addproj
|
|
mkdir -p packages/web
|
|
echo "K=v" > packages/web/.env.development
|
|
run "$SECRETS_BIN" add packages/web/.env.development
|
|
[ "$status" -eq 0 ]
|
|
run jq -r '.dotenv | index("packages/web/.env.development") != null' .secrets.json
|
|
[ "$output" = "true" ]
|
|
}
|
|
|
|
@test "add accepts npm-scoped workspace paths (@)" {
|
|
create_project_dir addproj
|
|
mkdir -p "packages/@acme/web"
|
|
echo "K=v" > "packages/@acme/web/.env"
|
|
run "$SECRETS_BIN" add "packages/@acme/web/.env"
|
|
[ "$status" -eq 0 ]
|
|
run jq -r '.dotenv | index("packages/@acme/web/.env") != null' .secrets.json
|
|
[ "$output" = "true" ]
|
|
}
|
|
|
|
@test "add rejects path traversal (..)" {
|
|
create_project_dir addproj
|
|
run "$SECRETS_BIN" add ../escape/.env
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"project-relative"* ]] || false
|
|
[ ! -f ".secrets.json" ]
|
|
}
|
|
|
|
@test "add rejects absolute paths" {
|
|
create_project_dir addproj
|
|
run "$SECRETS_BIN" add /etc/passwd
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"project-relative"* ]] || false
|
|
[ ! -f ".secrets.json" ]
|
|
}
|
|
|
|
@test "add rejects shell metacharacters in path" {
|
|
create_project_dir addproj
|
|
run "$SECRETS_BIN" add '.env;rm -rf ~'
|
|
[ "$status" -eq 1 ]
|
|
[ ! -f ".secrets.json" ]
|
|
}
|
|
|
|
@test "add requires the file to exist" {
|
|
create_project_dir addproj
|
|
run "$SECRETS_BIN" add .env.missing
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"not found"* ]] || false
|
|
}
|
|
|
|
@test "manifest serialization is canonical — order of adds does not matter" {
|
|
create_project_dir addproj
|
|
echo "A=1" > .env.alpha
|
|
echo "B=2" > .env.beta
|
|
"$SECRETS_BIN" add .env.alpha >/dev/null
|
|
"$SECRETS_BIN" add .env.beta >/dev/null
|
|
cp .secrets.json "$TEST_TMPDIR/order1.json"
|
|
rm .secrets.json
|
|
"$SECRETS_BIN" add .env.beta >/dev/null
|
|
"$SECRETS_BIN" add .env.alpha >/dev/null
|
|
cmp -s .secrets.json "$TEST_TMPDIR/order1.json"
|
|
}
|
|
|
|
@test "which shows manifest summary when .secrets.json is present" {
|
|
create_project_dir addproj
|
|
"$SECRETS_BIN" add .env >/dev/null
|
|
run "$SECRETS_BIN" which
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *".secrets.json"* ]] || false
|
|
[[ "$output" == *".env"* ]] || false
|
|
}
|
|
|
|
@test "malformed .secrets.json dies with a directed error naming the file" {
|
|
create_project_dir addproj
|
|
echo '{ not json' > .secrets.json
|
|
run "$SECRETS_BIN" which
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *".secrets.json"* ]] || false
|
|
[[ "$output" == *"invalid"* ]] || false
|
|
}
|
|
|
|
@test "unsupported manifest version dies with a directed upgrade error" {
|
|
create_project_dir addproj
|
|
echo '{"version": 99, "dotenv": [".env"]}' > .secrets.json
|
|
run "$SECRETS_BIN" which
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"version 99"* ]] || false
|
|
[[ "$output" == *"supports"* ]] || false
|
|
}
|
|
|
|
@test "symlinked .secrets.json is refused" {
|
|
create_project_dir addproj
|
|
echo '{"version":2,"dotenv":[".env"]}' > "$TEST_TMPDIR/real-manifest.json"
|
|
ln -s "$TEST_TMPDIR/real-manifest.json" .secrets.json
|
|
run "$SECRETS_BIN" which
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"symlink"* ]] || false
|
|
}
|
|
|
|
# ─── B: push from manifest — generators, autoAdd, --frozen/--dry-run ───
|
|
|
|
@test "push with manifest syncs nested declared file into v1 store layout" {
|
|
init_with_remote
|
|
create_project_dir nestproj
|
|
mkdir -p packages/web
|
|
echo "K=v" > packages/web/.env.development
|
|
"$SECRETS_BIN" add packages/web/.env.development >/dev/null
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
[ -f "$SECRETS_DIR/nestproj/packages/web/.env.development.age" ]
|
|
}
|
|
|
|
@test "push auto-adds newly discovered root files to an existing manifest" {
|
|
init_with_remote
|
|
create_project_dir autoproj
|
|
"$SECRETS_BIN" add .env >/dev/null
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"Added"* ]] || false
|
|
run jq -r '.dotenv | index(".env.staging") != null' .secrets.json
|
|
[ "$output" = "true" ]
|
|
[ -f "$SECRETS_DIR/autoproj/.env.staging.age" ]
|
|
}
|
|
|
|
@test "bootstrap: plain push creates the manifest from discovered files" {
|
|
init_with_remote
|
|
create_project_dir bootproj
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
[ -f ".secrets.json" ]
|
|
run jq -r '.dotenv | length' .secrets.json
|
|
[ "$output" = "2" ]
|
|
}
|
|
|
|
@test "failed push leaves no bootstrap manifest behind" {
|
|
init_with_remote
|
|
mkdir -p "$WORK_DIR/emptyproj"
|
|
cd "$WORK_DIR/emptyproj"
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 1 ]
|
|
[ ! -f ".secrets.json" ]
|
|
}
|
|
|
|
@test "autoAdd=false: undeclared discovered file is warned about, not added or synced" {
|
|
init_with_remote
|
|
create_project_dir noaddproj
|
|
printf '{"version":2,"options":{"autoAdd":false},"dotenv":[".env"]}\n' > .secrets.json
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"not declared"* ]] || false
|
|
run jq -r '.dotenv | index(".env.staging") != null' .secrets.json
|
|
[ "$output" = "false" ]
|
|
[ -f "$SECRETS_DIR/noaddproj/.env.age" ]
|
|
[ ! -f "$SECRETS_DIR/noaddproj/.env.staging.age" ]
|
|
}
|
|
|
|
@test "push --frozen skips auto-add even when autoAdd is on" {
|
|
init_with_remote
|
|
create_project_dir frozenproj
|
|
"$SECRETS_BIN" add .env >/dev/null
|
|
run "$SECRETS_BIN" push --frozen
|
|
[ "$status" -eq 0 ]
|
|
run jq -r '.dotenv | index(".env.staging") != null' .secrets.json
|
|
[ "$output" = "false" ]
|
|
[ ! -f "$SECRETS_DIR/frozenproj/.env.staging.age" ]
|
|
# declared entry still synced under the REAL project name
|
|
[ -f "$SECRETS_DIR/frozenproj/.env.age" ]
|
|
}
|
|
|
|
@test "push --dry-run reports would-add entries and changes nothing" {
|
|
init_with_remote
|
|
create_project_dir dryproj
|
|
"$SECRETS_BIN" add .env >/dev/null
|
|
cp .secrets.json "$TEST_TMPDIR/manifest-before.json"
|
|
run "$SECRETS_BIN" push --dry-run
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *".env.staging"* ]] || false
|
|
cmp -s .secrets.json "$TEST_TMPDIR/manifest-before.json"
|
|
[ ! -f "$SECRETS_DIR/dryproj/.env.age" ]
|
|
# nothing committed to the store at all
|
|
[ "$(git -C "$SECRETS_DIR" rev-list --count HEAD)" -eq 1 ]
|
|
}
|
|
|
|
@test "plain push re-scans package.json workspaces when a manifest exists" {
|
|
init_with_remote
|
|
local mono="$WORK_DIR/wsproj"
|
|
mkdir -p "$mono/packages/api"
|
|
printf '{"workspaces": ["packages/*"]}\n' > "$mono/package.json"
|
|
echo "ROOT=1" > "$mono/.env"
|
|
echo "API=1" > "$mono/packages/api/.dev.vars"
|
|
git init "$mono" >/dev/null 2>&1
|
|
cd "$mono"
|
|
"$SECRETS_BIN" add .env >/dev/null
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
run jq -r '.dotenv | index("packages/api/.dev.vars") != null' .secrets.json
|
|
[ "$output" = "true" ]
|
|
[ -f "$SECRETS_DIR/wsproj/packages/api/.dev.vars.age" ]
|
|
}
|
|
|
|
@test "declared-but-missing file warns and push continues" {
|
|
init_with_remote
|
|
create_project_dir missproj
|
|
"$SECRETS_BIN" add .env >/dev/null
|
|
printf '{"version":2,"dotenv":[".env",".env.gone"]}\n' > .secrets.json
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *".env.gone"* ]] || false
|
|
[ -f "$SECRETS_DIR/missproj/.env.age" ]
|
|
}
|
|
|
|
@test "unsafe dotenv entry in a committed manifest dies on push" {
|
|
init_with_remote
|
|
create_project_dir evilproj
|
|
printf '{"version":2,"dotenv":["../escape/.env"]}\n' > .secrets.json
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"project-relative"* ]] || false
|
|
}
|
|
|
|
# ─── C: legacy absorb + external entries via .secrets.json ─────────────
|
|
|
|
# Local fixtures (mirror secrets.bats EGB-531/652 helpers)
|
|
m_gradle_src() { mkdir -p "$HOME/.gradle"; printf '%s' "$1" > "$HOME/.gradle/gradle.properties"; }
|
|
m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' > "$HOME/keystores/upload.keystore"; }
|
|
|
|
@test "push absorbs .secrets-files into .secrets.json (properties + file)" {
|
|
init_with_remote
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
m_file_src
|
|
local dir="$WORK_DIR/absorbproj"; mkdir -p "$dir"
|
|
echo "K=v" > "$dir/.env"
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\nfile ~/keystores/upload.keystore\n' > "$dir/.secrets-files"
|
|
cd "$dir"
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"Absorbed"* ]] || false
|
|
run jq -r '.external | length' .secrets.json
|
|
[ "$output" = "2" ]
|
|
run jq -r '.external[] | select(.path == "~/.gradle/gradle.properties") | .type' .secrets.json
|
|
[ "$output" = "properties" ]
|
|
run jq -r '.external[] | select(.type == "file") | .path' .secrets.json
|
|
[ "$output" = "~/keystores/upload.keystore" ]
|
|
# stage 1: blob naming stays legacy-compatible
|
|
run bash -c "ls $SECRETS_DIR/absorbproj/external/*.properties.age"
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "absorb is idempotent — second push adds no duplicate externals" {
|
|
init_with_remote
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
local dir="$WORK_DIR/absorb2"; mkdir -p "$dir"
|
|
echo "K=v" > "$dir/.env"
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$dir/.secrets-files"
|
|
cd "$dir"
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
run jq -r '.external | length' .secrets.json
|
|
[ "$output" = "1" ]
|
|
}
|
|
|
|
@test "external properties entry in .secrets.json drives push without .secrets-files" {
|
|
init_with_remote
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
local dir="$WORK_DIR/jsonextproj"; mkdir -p "$dir"
|
|
printf '{"version":2,"external":[{"type":"properties","path":"~/.gradle/gradle.properties","keys":["beaconClerkPkTest"]}]}\n' > "$dir/.secrets.json"
|
|
cd "$dir"
|
|
run "$SECRETS_BIN" push jsonextproj
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"Extracted 1 key"* ]] || false
|
|
run bash -c "ls $SECRETS_DIR/jsonextproj/external/*.properties.age"
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "pull merges properties keys sourced from .secrets.json" {
|
|
init_with_remote
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\nunrelated=keep\n'
|
|
local dir="$WORK_DIR/jsonpull"; mkdir -p "$dir"
|
|
printf '{"version":2,"external":[{"type":"properties","path":"~/.gradle/gradle.properties","keys":["beaconClerkPkTest"]}]}\n' > "$dir/.secrets.json"
|
|
cd "$dir"
|
|
"$SECRETS_BIN" push jsonpull >/dev/null 2>&1
|
|
m_gradle_src $'beaconClerkPkTest=STALE\nunrelated=keep\n'
|
|
run "$SECRETS_BIN" pull jsonpull
|
|
[ "$status" -eq 0 ]
|
|
run grep -c 'beaconClerkPkTest=pk_test_abc' "$HOME/.gradle/gradle.properties"
|
|
[ "$output" = "1" ]
|
|
run grep -c 'unrelated=keep' "$HOME/.gradle/gradle.properties"
|
|
[ "$output" = "1" ]
|
|
}
|
|
|
|
@test "properties rail generalized: any *.properties basename is accepted" {
|
|
init_with_remote
|
|
mkdir -p "$HOME/.config"
|
|
printf 'apiKey=abc123\n' > "$HOME/.config/app.properties"
|
|
local dir="$WORK_DIR/genprops"; mkdir -p "$dir"
|
|
printf '{"version":2,"external":[{"type":"properties","path":"~/.config/app.properties","keys":["apiKey"]}]}\n' > "$dir/.secrets.json"
|
|
cd "$dir"
|
|
run "$SECRETS_BIN" push genprops
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"Extracted 1 key"* ]] || false
|
|
}
|
|
|
|
@test "properties rail still blocks a non-.properties target" {
|
|
init_with_remote
|
|
printf 'PATH=/evil\n' > "$HOME/.bashrc"
|
|
local dir="$WORK_DIR/evilprops"; mkdir -p "$dir"
|
|
printf '{"version":2,"external":[{"type":"properties","path":"~/.bashrc","keys":["PATH"]}]}\n' > "$dir/.secrets.json"
|
|
cd "$dir"
|
|
run "$SECRETS_BIN" push evilprops
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *".properties"* ]] || false
|
|
}
|
|
|
|
@test "file entry via .secrets.json round-trips binary with mode 600" {
|
|
init_with_remote
|
|
m_file_src
|
|
local dir="$WORK_DIR/jsonfile"; mkdir -p "$dir"
|
|
printf '{"version":2,"external":[{"type":"file","path":"~/keystores/upload.keystore"}]}\n' > "$dir/.secrets.json"
|
|
cd "$dir"
|
|
"$SECRETS_BIN" push jsonfile >/dev/null 2>&1
|
|
cp "$HOME/keystores/upload.keystore" "$TEST_TMPDIR/orig.keystore"
|
|
rm "$HOME/keystores/upload.keystore"
|
|
run "$SECRETS_BIN" pull jsonfile
|
|
[ "$status" -eq 0 ]
|
|
cmp -s "$HOME/keystores/upload.keystore" "$TEST_TMPDIR/orig.keystore"
|
|
local mode
|
|
mode=$(stat -f '%Lp' "$HOME/keystores/upload.keystore" 2>/dev/null || stat -c '%a' "$HOME/keystores/upload.keystore")
|
|
[ "$mode" = "600" ]
|
|
}
|
|
|
|
@test "json file entry with keys is rejected with a warning" {
|
|
init_with_remote
|
|
m_file_src
|
|
local dir="$WORK_DIR/badfile"; mkdir -p "$dir"
|
|
echo "K=v" > "$dir/.env"
|
|
printf '{"version":2,"external":[{"type":"file","path":"~/keystores/upload.keystore","keys":["nope"]}]}\n' > "$dir/.secrets.json"
|
|
cd "$dir"
|
|
run "$SECRETS_BIN" push badfile
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"no keys"* ]] || false
|
|
run bash -c "ls $SECRETS_DIR/badfile/external/*.file.age 2>/dev/null"
|
|
[ "$status" -ne 0 ]
|
|
}
|
|
|
|
@test "pull warns that .secrets-files is superseded when .secrets.json exists" {
|
|
init_with_remote
|
|
create_project_dir superproj
|
|
"$SECRETS_BIN" push superproj >/dev/null 2>&1
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
|
run "$SECRETS_BIN" pull superproj
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"superseded"* ]] || false
|
|
}
|
|
|
|
# ─── D: pull from manifest — nested restore, restore-time rail ─────────
|
|
|
|
@test "pull restores manifest-declared nested file (mkdir -p)" {
|
|
init_with_remote
|
|
create_project_dir nestpull
|
|
mkdir -p packages/web
|
|
echo "K=v" > packages/web/.env.development
|
|
"$SECRETS_BIN" add packages/web/.env.development >/dev/null
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
rm -rf packages
|
|
run "$SECRETS_BIN" pull
|
|
[ "$status" -eq 0 ]
|
|
[ -f packages/web/.env.development ]
|
|
[ "$(cat packages/web/.env.development)" = "K=v" ]
|
|
}
|
|
|
|
@test "pull with manifest restores only declared entries" {
|
|
init_with_remote
|
|
create_project_dir onlydecl
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
# plant an undeclared stray blob in the store
|
|
local pubkey; pubkey=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
|
echo "S=1" | age -r "$pubkey" -o "$SECRETS_DIR/onlydecl/.env.stray.age"
|
|
rm -f .env .env.staging
|
|
run "$SECRETS_BIN" pull
|
|
[ "$status" -eq 0 ]
|
|
[ -f .env ]
|
|
[ ! -f .env.stray ]
|
|
}
|
|
|
|
@test "pull warns and skips an unsafe manifest entry, restores the rest" {
|
|
init_with_remote
|
|
create_project_dir unsafepull
|
|
"$SECRETS_BIN" add .env >/dev/null
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
printf '{"version":2,"dotenv":[".env","../escape/.env"]}\n' > .secrets.json
|
|
rm -f .env
|
|
run "$SECRETS_BIN" pull
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"skipping unsafe"* ]] || false
|
|
[ -f .env ]
|
|
[ ! -f "$WORK_DIR/escape/.env" ]
|
|
}
|
|
|
|
@test "pull on an empty manifest is a warn no-op" {
|
|
init_with_remote
|
|
mkdir -p "$WORK_DIR/emptypull"
|
|
cd "$WORK_DIR/emptypull"
|
|
printf '{"version":2,"dotenv":[]}\n' > .secrets.json
|
|
run "$SECRETS_BIN" pull emptypull
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"declares nothing"* ]] || false
|
|
}
|
|
|
|
@test "pull warns when a declared entry has no blob in the store" {
|
|
init_with_remote
|
|
create_project_dir nopullblob
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
jq '.dotenv += [".env.missing"]' .secrets.json > .secrets.json.tmp && mv .secrets.json.tmp .secrets.json
|
|
run "$SECRETS_BIN" pull
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *".env.missing"* ]] || false
|
|
[[ "$output" == *"no encrypted data"* ]] || false
|
|
}
|
|
|
|
@test "machine-2 flow: committed manifest + pull restores everything" {
|
|
init_with_remote
|
|
create_project_dir machine1
|
|
mkdir -p packages/api
|
|
echo "API=1" > packages/api/.dev.vars
|
|
"$SECRETS_BIN" add packages/api/.dev.vars >/dev/null
|
|
"$SECRETS_BIN" push m2proj >/dev/null 2>&1
|
|
# simulate machine 2: fresh dir, only the committed manifest present
|
|
mkdir -p "$WORK_DIR/machine2"
|
|
cp .secrets.json "$WORK_DIR/machine2/"
|
|
cd "$WORK_DIR/machine2"
|
|
run "$SECRETS_BIN" pull m2proj
|
|
[ "$status" -eq 0 ]
|
|
[ -f .env ]
|
|
[ -f packages/api/.dev.vars ]
|
|
[ "$(cat packages/api/.dev.vars)" = "API=1" ]
|
|
}
|
|
|
|
# ─── E: jq gating + install hints + help ───────────────────────────────
|
|
|
|
# Helper: PATH with age but without jq. macOS ships /usr/bin/jq, so
|
|
# /usr/bin must be excluded too — needed tools are symlinked explicitly.
|
|
m_nojq_path() {
|
|
local fake="$TEST_TMPDIR/nojq-bin"
|
|
mkdir -p "$fake"
|
|
local t
|
|
for t in age age-keygen git basename dirname mktemp grep sed tr cut cksum stat head tail sort uniq wc env touch find diff cmp; do
|
|
command -v "$t" >/dev/null 2>&1 && ln -sf "$(command -v "$t")" "$fake/$t"
|
|
done
|
|
rm -f "$fake/jq"
|
|
echo "$fake:/bin"
|
|
}
|
|
|
|
@test "manifest-less push works without jq (manifest features skipped)" {
|
|
init_with_remote
|
|
create_project_dir nojqproj
|
|
local p; p=$(m_nojq_path)
|
|
run env PATH="$p" "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
[ -f "$SECRETS_DIR/nojqproj/.env.age" ]
|
|
[ ! -f ".secrets.json" ]
|
|
[[ "$output" == *"jq"* ]] || false
|
|
}
|
|
|
|
@test "push dies with an install hint when a manifest exists but jq is missing" {
|
|
init_with_remote
|
|
create_project_dir needjq
|
|
printf '{"version":2,"dotenv":[".env"]}\n' > .secrets.json
|
|
local p; p=$(m_nojq_path)
|
|
run env PATH="$p" "$SECRETS_BIN" push
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"'jq' is not installed"* ]] || false
|
|
}
|
|
|
|
@test "help documents add, --frozen, --dry-run and the manifest" {
|
|
run "$SECRETS_BIN" help
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"secrets add"* ]] || false
|
|
[[ "$output" == *"--frozen"* ]] || false
|
|
[[ "$output" == *"--dry-run"* ]] || false
|
|
[[ "$output" == *".secrets.json"* ]] || false
|
|
}
|
|
|
|
# ─── Coverage backfill (ship Step 7 gap paths) ─────────────────────────
|
|
|
|
@test "json external entry with unknown type warns and is skipped" {
|
|
init_with_remote
|
|
local dir="$WORK_DIR/unktype"; mkdir -p "$dir"
|
|
echo "K=v" > "$dir/.env"
|
|
printf '{"version":2,"external":[{"type":"wat","path":"~/x.properties","keys":["k"]}]}\n' > "$dir/.secrets.json"
|
|
cd "$dir"
|
|
run "$SECRETS_BIN" push unktype
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"unknown external type"* ]] || false
|
|
}
|
|
|
|
@test "json external entry with unsafe path warns and is skipped" {
|
|
init_with_remote
|
|
local dir="$WORK_DIR/unsafext"; mkdir -p "$dir"
|
|
echo "K=v" > "$dir/.env"
|
|
printf '{"version":2,"external":[{"type":"properties","path":"~/../etc/x.properties","keys":["k"]}]}\n' > "$dir/.secrets.json"
|
|
cd "$dir"
|
|
run "$SECRETS_BIN" push unsafext
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"unsafe characters in external path"* ]] || false
|
|
}
|
|
|
|
@test "json external entry with unsafe keys warns and is skipped" {
|
|
init_with_remote
|
|
local dir="$WORK_DIR/unsafekeys"; mkdir -p "$dir"
|
|
echo "K=v" > "$dir/.env"
|
|
printf '{"version":2,"external":[{"type":"properties","path":"~/.gradle/gradle.properties","keys":["k;rm"]}]}\n' > "$dir/.secrets.json"
|
|
cd "$dir"
|
|
run "$SECRETS_BIN" push unsafekeys
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"unsafe characters in key list"* ]] || false
|
|
}
|
|
|
|
@test "json properties entry without keys warns and is skipped" {
|
|
init_with_remote
|
|
local dir="$WORK_DIR/nokeys"; mkdir -p "$dir"
|
|
echo "K=v" > "$dir/.env"
|
|
printf '{"version":2,"external":[{"type":"properties","path":"~/.gradle/gradle.properties"}]}\n' > "$dir/.secrets.json"
|
|
cd "$dir"
|
|
run "$SECRETS_BIN" push nokeys
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"has no keys"* ]] || false
|
|
}
|
|
|
|
@test "symlinked .secrets-files is ignored with a warning on push" {
|
|
init_with_remote
|
|
m_gradle_src $'beaconClerkPkTest=x\n'
|
|
local dir="$WORK_DIR/symlegacy"; mkdir -p "$dir"
|
|
echo "K=v" > "$dir/.env"
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$TEST_TMPDIR/real-sf"
|
|
ln -s "$TEST_TMPDIR/real-sf" "$dir/.secrets-files"
|
|
cd "$dir"
|
|
run "$SECRETS_BIN" push symlegacy
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"symlink"* ]] || false
|
|
run bash -c "ls $SECRETS_DIR/symlegacy/external/*.age 2>/dev/null"
|
|
[ "$status" -ne 0 ]
|
|
}
|
|
|
|
@test "pull with declared entries dies with directed error when project absent from store" {
|
|
init_with_remote
|
|
mkdir -p "$WORK_DIR/ghostproj"
|
|
cd "$WORK_DIR/ghostproj"
|
|
printf '{"version":2,"dotenv":[".env"]}\n' > .secrets.json
|
|
run "$SECRETS_BIN" pull ghostproj
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"not found"* ]] || false
|
|
[[ "$output" == *"secrets list"* ]] || false
|
|
}
|
|
|
|
@test "add without an argument dies with usage" {
|
|
create_project_dir noargadd
|
|
run "$SECRETS_BIN" add
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"Usage: secrets add"* ]] || false
|
|
}
|
|
|
|
@test "push rejects an unknown flag with usage" {
|
|
init_with_remote
|
|
create_project_dir badflag
|
|
run "$SECRETS_BIN" push --nope
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"Unknown push flag"* ]] || false
|
|
}
|
|
|
|
@test "add normalizes a leading ./ prefix" {
|
|
create_project_dir dotslash
|
|
run "$SECRETS_BIN" add ./.env
|
|
[ "$status" -eq 0 ]
|
|
run jq -r '.dotenv[0]' .secrets.json
|
|
[ "$output" = ".env" ]
|
|
}
|
|
|
|
@test "push --dry-run reports nothing-new when manifest covers all discovered files" {
|
|
init_with_remote
|
|
create_project_dir alldecl
|
|
"$SECRETS_BIN" add .env >/dev/null
|
|
"$SECRETS_BIN" add .env.staging >/dev/null
|
|
run "$SECRETS_BIN" push --dry-run
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"Nothing new to add"* ]] || false
|
|
}
|
|
|
|
@test "which displays external entries from the manifest" {
|
|
create_project_dir whichext
|
|
printf '{"version":2,"dotenv":[".env"],"external":[{"type":"properties","path":"~/.gradle/gradle.properties","keys":["k1"]}]}\n' > .secrets.json
|
|
run "$SECRETS_BIN" which
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"properties"* ]] || false
|
|
[[ "$output" == *"gradle.properties"* ]] || false
|
|
[[ "$output" == *"k1"* ]] || false
|
|
}
|
|
|
|
# ─── F: ship Step 7 coverage backfill (audit gaps) ─────────────────────
|
|
|
|
@test "which flags an unsafe dotenv entry with the UNSAFE marker" {
|
|
create_project_dir whichunsafe
|
|
printf '{"version":2,"dotenv":[".env","../escape/.env"]}\n' > .secrets.json
|
|
run "$SECRETS_BIN" which
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"UNSAFE"* ]] || false
|
|
[[ "$output" == *"will be refused"* ]] || false
|
|
}
|
|
|
|
@test "add to a malformed existing manifest dies with a directed error" {
|
|
create_project_dir addmalformed
|
|
echo '{ not json' > .secrets.json
|
|
run "$SECRETS_BIN" add .env
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"invalid"* ]] || false
|
|
}
|
|
|
|
@test "push --dry-run lists declared entries under 'Would sync'" {
|
|
init_with_remote
|
|
create_project_dir drysync
|
|
"$SECRETS_BIN" add .env >/dev/null
|
|
run "$SECRETS_BIN" push --dry-run
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"Would sync (declared)"* ]] || false
|
|
[[ "$output" == *".env"* ]] || false
|
|
}
|
|
|
|
@test "push --frozen does not absorb a legacy .secrets-files" {
|
|
init_with_remote
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
local dir="$WORK_DIR/frozenabsorb"; mkdir -p "$dir"
|
|
echo "K=v" > "$dir/.env"
|
|
printf '{"version":2,"dotenv":[".env"]}\n' > "$dir/.secrets.json"
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$dir/.secrets-files"
|
|
cd "$dir"
|
|
run "$SECRETS_BIN" push --frozen frozenabsorb
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" != *"Absorbed"* ]] || false
|
|
run jq -r '.external // [] | length' .secrets.json
|
|
[ "$output" = "0" ]
|
|
}
|
|
|
|
@test "file entry absorbed from legacy round-trips on pull" {
|
|
init_with_remote
|
|
m_file_src
|
|
local dir="$WORK_DIR/fileabsorb"; mkdir -p "$dir"
|
|
echo "K=v" > "$dir/.env"
|
|
printf 'file ~/keystores/upload.keystore\n' > "$dir/.secrets-files"
|
|
cd "$dir"
|
|
"$SECRETS_BIN" push fileabsorb >/dev/null 2>&1
|
|
run jq -r '.external[] | select(.type=="file") | .path' .secrets.json
|
|
[ "$output" = "~/keystores/upload.keystore" ]
|
|
cp "$HOME/keystores/upload.keystore" "$TEST_TMPDIR/orig.ks"
|
|
rm "$HOME/keystores/upload.keystore"
|
|
rm -f .secrets-files
|
|
run "$SECRETS_BIN" pull fileabsorb
|
|
[ "$status" -eq 0 ]
|
|
cmp -s "$HOME/keystores/upload.keystore" "$TEST_TMPDIR/orig.ks"
|
|
}
|
|
|
|
@test "push dedups a legacy entry already present in the manifest external[]" {
|
|
init_with_remote
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
local dir="$WORK_DIR/dedupext"; mkdir -p "$dir"
|
|
echo "K=v" > "$dir/.env"
|
|
printf '{"version":2,"dotenv":[".env"],"external":[{"type":"properties","path":"~/.gradle/gradle.properties","keys":["beaconClerkPkTest"]}]}\n' > "$dir/.secrets.json"
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$dir/.secrets-files"
|
|
cd "$dir"
|
|
run "$SECRETS_BIN" push dedupext
|
|
[ "$status" -eq 0 ]
|
|
run jq -r '.external | length' .secrets.json
|
|
[ "$output" = "1" ]
|
|
}
|
|
|
|
# ─── J: rekey + list recurse into nested manifest blobs (data-loss guard) ──────
|
|
|
|
@test "rekey re-encrypts a nested manifest dotenv blob (survives rotation)" {
|
|
# Regression: cmd_rekey's non-recursive glob skipped <project>/<relpath>.age
|
|
# blobs, leaving them on the old key = permanently undecryptable after rotation.
|
|
init_with_remote
|
|
create_project_dir nestrekey
|
|
mkdir -p packages/web
|
|
echo "N=nested" > packages/web/.env.development
|
|
"$SECRETS_BIN" add packages/web/.env.development >/dev/null
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
[ -f "$SECRETS_DIR/nestrekey/packages/web/.env.development.age" ]
|
|
run "$SECRETS_BIN" rekey
|
|
[ "$status" -eq 0 ]
|
|
rm -rf packages
|
|
run "$SECRETS_BIN" pull nestrekey
|
|
[ "$status" -eq 0 ]
|
|
[ -f packages/web/.env.development ]
|
|
[ "$(cat packages/web/.env.development)" = "N=nested" ]
|
|
}
|
|
|
|
@test "list shows a nested manifest blob" {
|
|
init_with_remote
|
|
create_project_dir nestlist
|
|
mkdir -p packages/web
|
|
echo "N=nested" > packages/web/.env.development
|
|
"$SECRETS_BIN" add packages/web/.env.development >/dev/null
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
run "$SECRETS_BIN" list
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"packages/web/.env.development"* ]] || false
|
|
}
|
|
|
|
# ─── K: secrets verify — manifest↔store consistency + decrypt integrity (EGB-698) ─
|
|
|
|
@test "verify: clean pushed project reports OK and exits 0" {
|
|
init_with_remote
|
|
create_project_dir verifyok
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
run "$SECRETS_BIN" verify
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"OK"* ]] || false
|
|
}
|
|
|
|
@test "verify: nested + external entries all pass" {
|
|
init_with_remote
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
create_project_dir verifymix
|
|
mkdir -p packages/web
|
|
echo "N=nested" > packages/web/.env.development
|
|
"$SECRETS_BIN" add packages/web/.env.development >/dev/null
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
run "$SECRETS_BIN" verify
|
|
[ "$status" -eq 0 ]
|
|
# Pin the count so a silent under-count (e.g. skipping the nested or external
|
|
# blob while still exiting 0) is caught: 3 dotenv + 1 external = 4.
|
|
[[ "$output" == *"4 blob(s) verified"* ]] || false
|
|
}
|
|
|
|
@test "verify: declared-but-missing blob is a finding (exit 1)" {
|
|
init_with_remote
|
|
create_project_dir verifymiss
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
rm "$SECRETS_DIR/verifymiss/.env.staging.age"
|
|
run "$SECRETS_BIN" verify
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *".env.staging"* ]] || false
|
|
}
|
|
|
|
@test "verify: a blob that fails to decrypt is a finding (exit 1)" {
|
|
init_with_remote
|
|
create_project_dir verifycorrupt
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
printf 'not-a-valid-age-blob' > "$SECRETS_DIR/verifycorrupt/.env.age"
|
|
run "$SECRETS_BIN" verify
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *".env"* ]] || false
|
|
}
|
|
|
|
@test "verify: orphan blob (no manifest entry) is a finding (exit 1)" {
|
|
init_with_remote
|
|
create_project_dir verifyorphan
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
# A valid, decryptable blob with no manifest entry — pure consistency miss.
|
|
cp "$SECRETS_DIR/verifyorphan/.env.age" "$SECRETS_DIR/verifyorphan/.stray.age"
|
|
run "$SECRETS_BIN" verify
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"stray"* ]] || false
|
|
}
|
|
|
|
@test "verify: missing external blob is a finding (exit 1)" {
|
|
init_with_remote
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
create_project_dir verifyextmiss
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
rm "$SECRETS_DIR/verifyextmiss/external/"*.age
|
|
run "$SECRETS_BIN" verify
|
|
[ "$status" -eq 1 ]
|
|
}
|
|
|
|
@test "verify: no manifest in cwd dies with a directed message" {
|
|
init_with_remote
|
|
local dir="$WORK_DIR/verifynomani"; mkdir -p "$dir"; cd "$dir"
|
|
run "$SECRETS_BIN" verify
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *".secrets.json"* ]] || false
|
|
[[ "$output" == *"--all"* ]] || false
|
|
}
|
|
|
|
@test "verify: symlinked manifest is refused" {
|
|
init_with_remote
|
|
create_project_dir verifysymlink
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
rm .secrets.json
|
|
ln -s /etc/hosts .secrets.json
|
|
run "$SECRETS_BIN" verify
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"symlink"* ]] || false
|
|
}
|
|
|
|
@test "verify --all: clean store passes (decrypt-only)" {
|
|
init_with_remote
|
|
create_project_dir verifyall1
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
cd "$WORK_DIR"
|
|
run "$SECRETS_BIN" verify --all
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "verify --all: a corrupted blob anywhere fails (exit 1)" {
|
|
init_with_remote
|
|
create_project_dir verifyall2
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
printf 'garbage' > "$SECRETS_DIR/verifyall2/.env.age"
|
|
cd "$WORK_DIR"
|
|
run "$SECRETS_BIN" verify --all
|
|
[ "$status" -eq 1 ]
|
|
}
|
|
|
|
@test "verify --all: an orphan that decrypts is NOT flagged (no consistency check)" {
|
|
init_with_remote
|
|
create_project_dir verifyall3
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
# Orphan blob that decrypts fine — default mode flags it, --all does not.
|
|
cp "$SECRETS_DIR/verifyall3/.env.age" "$SECRETS_DIR/verifyall3/.stray.age"
|
|
cd "$WORK_DIR"
|
|
run "$SECRETS_BIN" verify --all
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "verify: nested blob that fails to decrypt is caught (rekey-orphan guard)" {
|
|
init_with_remote
|
|
create_project_dir verifynested
|
|
mkdir -p packages/web
|
|
echo "N=nested" > packages/web/.env.development
|
|
"$SECRETS_BIN" add packages/web/.env.development >/dev/null
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
printf 'broken' > "$SECRETS_DIR/verifynested/packages/web/.env.development.age"
|
|
run "$SECRETS_BIN" verify
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"packages/web/.env.development"* ]] || false
|
|
}
|
|
|
|
@test "verify: external blob that fails to decrypt is a finding (exit 1)" {
|
|
init_with_remote
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
create_project_dir verifyextcorrupt
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
printf 'garbage' > "$SECRETS_DIR/verifyextcorrupt/external/"*.age
|
|
run "$SECRETS_BIN" verify
|
|
[ "$status" -eq 1 ]
|
|
# Pin the decrypt-fail branch specifically, not just any external finding.
|
|
[[ "$output" == *"does not decrypt"* ]] || false
|
|
}
|
|
|
|
@test "verify: an unsafe dotenv path in the manifest is a finding (exit 1)" {
|
|
init_with_remote
|
|
create_project_dir verifyunsafe
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
# Hand-edit the committed manifest to declare a traversal path the rail refuses.
|
|
jq '.dotenv += ["../evil"]' .secrets.json > .secrets.json.tmp && mv .secrets.json.tmp .secrets.json
|
|
run "$SECRETS_BIN" verify
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"unsafe"* ]] || false
|
|
}
|
|
|
|
@test "verify: unknown flag dies with usage" {
|
|
init_with_remote
|
|
create_project_dir verifyflag
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
run "$SECRETS_BIN" verify --bogus
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"Unknown verify flag"* ]] || false
|
|
}
|
|
|
|
@test "verify: malformed manifest is refused" {
|
|
init_with_remote
|
|
create_project_dir verifymalformed
|
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
|
printf 'not json{' > .secrets.json
|
|
run "$SECRETS_BIN" verify
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"JSON"* ]] || false
|
|
}
|
|
|
|
@test "verify --all: empty store reports nothing to check (exit 0)" {
|
|
init_with_remote
|
|
cd "$WORK_DIR"
|
|
run "$SECRETS_BIN" verify --all
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"empty"* ]] || false
|
|
}
|