fix: init second-machine guard, store .gitignore self-heal, rekey dotfile loss

Three fixes, found from a real Ubuntu second-machine setup failure:

1. cmd_init: if key.txt exists but the store has no .git, die BEFORE
   git init with directed guidance to clone the existing secrets repo
   instead. Previously init ran git init, then age-keygen died on the
   existing key, leaving a half-initialized store (.git but no
   .gitignore or hook).

2. ensure_store_protections: push/pull/rekey now restore a missing
   store .gitignore (and pre-commit hook) before any `git add -A`.
   Without the .gitignore, add -A would commit key.txt to the remote.

3. cmd_rekey: the re-encrypt loop used a bare "$dir"* glob, which never
   matches dotfiles — .env blobs were decrypted to the tmpdir but never
   re-encrypted, leaving them on the OLD key (undecryptable) while the
   new key overwrote key.txt. Glob now matches the decrypt loop
   ("$dir"* "$dir".*). Exposed by the self-heal test: with nothing
   staged, the empty commit failed mid-rekey.

Tests: 5 new bats tests (118 total) — init guard, .gitignore self-heal
on push/pull/rekey, and a rekey round-trip that survives key rotation.
This commit is contained in:
Brian Majewski 2026-06-05 09:38:16 -07:00
parent e947cfde19
commit 25cd1feb43
3 changed files with 144 additions and 34 deletions

View file

@ -1476,3 +1476,79 @@ gradle_project() {
# 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
local key_before
key_before=$(cat "$SECRETS_DIR/key.txt")
run "$SECRETS_BIN" init
[ "$status" -eq 1 ]
[[ "$output" == *"git clone"* ]]
# 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 ]
[ -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"* ]]
}
@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"* ]]
}
@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" ]
}