Pre-landing review (1 critical, 4 informational): - CRITICAL: per-project `secrets verify` during the migration window flagged the freshly-written v2 twin as a spurious orphan and exited 1 (store still reads v1, so verify's expected set only held the .gradle-properties.age name). Breaks the documented migrate->verify->finalize workflow and CI. Fix: verify's orphan set now accounts for BOTH suffix forms of a properties external, so the twin is never a false orphan mid-migration. Regression test added. - Copy-forward now pushes the twins (mirrors push/rekey) so a --finalize on another machine sees them; previously twins were local-only until finalize, a multi-machine footgun. - Dropped the undocumented `--force` alias (keep `--yes`). - Clarified the EGB-700 comment (which-format line, folded into EGB-703). Deferred to EGB-701: the two finalize find-walks over *.gradle-properties.age could collapse to one pass. Full suite 236/236.
322 lines
12 KiB
Bash
322 lines
12 KiB
Bash
#!/usr/bin/env bats
|
|
# EGB-703 store-format-v2: marker, format-aware suffix, migrate (dry-run /
|
|
# copy-forward / finalize). bash 3.2: every standalone [[ ]] ends with || false.
|
|
|
|
load test_helper
|
|
|
|
# A v1 (legacy) store: born-v2 init, then strip the marker so it reads as v1
|
|
# and pushes write the legacy .gradle-properties.age suffix.
|
|
make_v1_store() {
|
|
init_with_remote
|
|
rm -f "$SECRETS_DIR/.secrets-format"
|
|
}
|
|
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"; }
|
|
|
|
# ─── Format marker + format-aware suffix (increment 1) ────────────────
|
|
|
|
@test "init stamps the store format marker as v2 (born-v2)" {
|
|
init_with_remote
|
|
[ -f "$SECRETS_DIR/.secrets-format" ]
|
|
[ "$(cat "$SECRETS_DIR/.secrets-format")" = "2" ]
|
|
}
|
|
|
|
@test "which prints format v2 for a born-v2 store" {
|
|
init_with_remote
|
|
create_project_dir whichv2
|
|
run "$SECRETS_BIN" which
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"format: v2"* ]] || false
|
|
}
|
|
|
|
@test "which prints format v1 for a markerless (legacy) store" {
|
|
make_v1_store
|
|
create_project_dir whichv1
|
|
run "$SECRETS_BIN" which
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"format: v1"* ]] || false
|
|
}
|
|
|
|
@test "push on a born-v2 store writes the properties blob as .properties.age" {
|
|
init_with_remote
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
create_project_dir v2push
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
|
"$SECRETS_BIN" push v2push >/dev/null 2>&1
|
|
run bash -c "ls $SECRETS_DIR/v2push/external/*.properties.age"
|
|
[ "$status" -eq 0 ]
|
|
run bash -c "ls $SECRETS_DIR/v2push/external/*.gradle-properties.age 2>/dev/null"
|
|
[ "$status" -ne 0 ]
|
|
}
|
|
|
|
@test "push on a v1 store still writes .gradle-properties.age (back-compat)" {
|
|
make_v1_store
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
create_project_dir v1push
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
|
"$SECRETS_BIN" push v1push >/dev/null 2>&1
|
|
run bash -c "ls $SECRETS_DIR/v1push/external/*.gradle-properties.age"
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "the format marker is committed, not gitignored" {
|
|
init_with_remote
|
|
create_project_dir markercommit
|
|
"$SECRETS_BIN" push markercommit >/dev/null 2>&1
|
|
run bash -c "git -C $SECRETS_DIR ls-files | grep -qx .secrets-format"
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
# ─── migrate --dry-run / copy-forward (increment 2) ───────────────────
|
|
|
|
@test "migrate --dry-run reports the rename and writes nothing" {
|
|
make_v1_store
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
create_project_dir dryproj
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
|
"$SECRETS_BIN" push dryproj >/dev/null 2>&1
|
|
run "$SECRETS_BIN" migrate --dry-run
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"would migrate"* ]] || false
|
|
# nothing written
|
|
run bash -c "ls $SECRETS_DIR/dryproj/external/*.properties.age 2>/dev/null"
|
|
[ "$status" -ne 0 ]
|
|
# marker still absent (store still v1)
|
|
[ ! -f "$SECRETS_DIR/.secrets-format" ]
|
|
}
|
|
|
|
@test "migrate --dry-run on a dotenv-only project reports nothing to migrate" {
|
|
make_v1_store
|
|
create_project_dir dotenvonly
|
|
"$SECRETS_BIN" push dotenvonly >/dev/null 2>&1
|
|
run "$SECRETS_BIN" migrate --dry-run
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"0 blob(s) would be copy-forwarded"* ]] || false
|
|
}
|
|
|
|
@test "migrate copy-forward creates the v2 twin and keeps the v1 blob (byte-identical)" {
|
|
make_v1_store
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
create_project_dir cfproj
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
|
"$SECRETS_BIN" push cfproj >/dev/null 2>&1
|
|
local old; old=$(ls "$SECRETS_DIR/cfproj/external/"*.gradle-properties.age)
|
|
run "$SECRETS_BIN" migrate
|
|
[ "$status" -eq 0 ]
|
|
local new; new=$(ls "$SECRETS_DIR/cfproj/external/"*.properties.age)
|
|
[ -f "$old" ] # v1 kept (non-destructive)
|
|
[ -f "$new" ] # v2 twin written
|
|
cmp -s "$old" "$new" # byte-identical ciphertext copy
|
|
}
|
|
|
|
@test "migrate copy-forward is idempotent" {
|
|
make_v1_store
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
create_project_dir idemproj
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
|
"$SECRETS_BIN" push idemproj >/dev/null 2>&1
|
|
"$SECRETS_BIN" migrate >/dev/null 2>&1
|
|
run "$SECRETS_BIN" migrate
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"1 already present"* ]] || false
|
|
run bash -c "ls $SECRETS_DIR/idemproj/external/*.properties.age | wc -l | tr -d ' '"
|
|
[ "$output" = "1" ]
|
|
}
|
|
|
|
@test "migrate with no manifest in cwd dies with a directed message" {
|
|
make_v1_store
|
|
local dir="$WORK_DIR/nomanifest"; mkdir -p "$dir"; cd "$dir"
|
|
run "$SECRETS_BIN" migrate
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *".secrets.json"* ]] || false
|
|
}
|
|
|
|
@test "migrate on an already-v2 store is a no-op" {
|
|
init_with_remote
|
|
create_project_dir alreadyv2
|
|
run "$SECRETS_BIN" migrate
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"already format v2"* ]] || false
|
|
}
|
|
|
|
@test "migrate unknown flag dies with usage" {
|
|
init_with_remote
|
|
create_project_dir mgflag
|
|
run "$SECRETS_BIN" migrate --bogus
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"Unknown migrate flag"* ]] || false
|
|
}
|
|
|
|
@test "migrate leaves dotenv and file blobs untouched" {
|
|
make_v1_store
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
m_file_src
|
|
create_project_dir mixproj
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\nfile ~/keystores/upload.keystore\n' > .secrets-files
|
|
"$SECRETS_BIN" push mixproj >/dev/null 2>&1
|
|
local envblob; envblob=$(ls "$SECRETS_DIR/mixproj/".env.age)
|
|
local fileblob; fileblob=$(ls "$SECRETS_DIR/mixproj/external/"*.file.age)
|
|
local envsum; envsum=$(cksum "$envblob")
|
|
local filesum; filesum=$(cksum "$fileblob")
|
|
"$SECRETS_BIN" migrate >/dev/null 2>&1
|
|
[ "$(cksum "$envblob")" = "$envsum" ] # dotenv blob unchanged
|
|
[ "$(cksum "$fileblob")" = "$filesum" ] # file blob unchanged
|
|
}
|
|
|
|
# ─── migrate --finalize (increment 3) ─────────────────────────────────
|
|
|
|
@test "finalize refuses when verify --all is not green" {
|
|
make_v1_store
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
create_project_dir failverify
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
|
"$SECRETS_BIN" push failverify >/dev/null 2>&1
|
|
"$SECRETS_BIN" migrate >/dev/null 2>&1
|
|
# corrupt the v2 twin so verify --all fails
|
|
printf 'garbage' > "$SECRETS_DIR/failverify/external/"*.properties.age
|
|
run "$SECRETS_BIN" migrate --finalize --yes
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"not green"* ]] || false
|
|
# marker not stamped; v1 blob still present
|
|
[ ! -f "$SECRETS_DIR/.secrets-format" ]
|
|
run bash -c "ls $SECRETS_DIR/failverify/external/*.gradle-properties.age"
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "finalize refuses an un-twinned v1 blob (project not migrated)" {
|
|
make_v1_store
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
create_project_dir untwinned
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
|
"$SECRETS_BIN" push untwinned >/dev/null 2>&1
|
|
# do NOT migrate — leave the v1 blob with no twin
|
|
run "$SECRETS_BIN" migrate --finalize --yes
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"no v2 twin"* ]] || false
|
|
run bash -c "ls $SECRETS_DIR/untwinned/external/*.gradle-properties.age"
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "finalize green path drops v1, keeps v2, stamps the marker" {
|
|
make_v1_store
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
create_project_dir finproj
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
|
"$SECRETS_BIN" push finproj >/dev/null 2>&1
|
|
"$SECRETS_BIN" migrate >/dev/null 2>&1
|
|
run "$SECRETS_BIN" migrate --finalize --yes
|
|
[ "$status" -eq 0 ]
|
|
[ "$(cat "$SECRETS_DIR/.secrets-format")" = "2" ]
|
|
run bash -c "ls $SECRETS_DIR/finproj/external/*.properties.age"
|
|
[ "$status" -eq 0 ]
|
|
run bash -c "ls $SECRETS_DIR/finproj/external/*.gradle-properties.age 2>/dev/null"
|
|
[ "$status" -ne 0 ]
|
|
}
|
|
|
|
@test "finalize cuts a recovery tag before deleting v1 blobs" {
|
|
make_v1_store
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
create_project_dir tagproj
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
|
"$SECRETS_BIN" push tagproj >/dev/null 2>&1
|
|
"$SECRETS_BIN" migrate >/dev/null 2>&1
|
|
"$SECRETS_BIN" migrate --finalize --yes >/dev/null 2>&1
|
|
local tag; tag=$(git -C "$SECRETS_DIR" tag | grep '^pre-v2-migrate-')
|
|
[ -n "$tag" ]
|
|
# the tagged commit still contains the v1 blob (tag cut before delete)
|
|
run bash -c "git -C $SECRETS_DIR ls-tree -r --name-only $tag | grep -q gradle-properties.age"
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "finalize without --yes aborts when not confirmed" {
|
|
make_v1_store
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
create_project_dir confproj
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
|
"$SECRETS_BIN" push confproj >/dev/null 2>&1
|
|
"$SECRETS_BIN" migrate >/dev/null 2>&1
|
|
run bash -c "echo '' | $SECRETS_BIN migrate --finalize"
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"aborted"* ]] || false
|
|
[ ! -f "$SECRETS_DIR/.secrets-format" ]
|
|
}
|
|
|
|
@test "verify is green during the migration window (v2 twin is not a spurious orphan)" {
|
|
# Regression: after copy-forward the store is still v1, so verify computed the
|
|
# external blob path as .gradle-properties.age and flagged the .properties.age
|
|
# twin as an orphan, failing verify mid-migration. The orphan set now accounts
|
|
# for both suffix forms.
|
|
make_v1_store
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
create_project_dir windowverify
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
|
"$SECRETS_BIN" push windowverify >/dev/null 2>&1
|
|
"$SECRETS_BIN" migrate >/dev/null 2>&1
|
|
run "$SECRETS_BIN" verify windowverify
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "v1 client still reads during the migration window (after copy-forward, before finalize)" {
|
|
make_v1_store
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
create_project_dir windowproj
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
|
"$SECRETS_BIN" push windowproj >/dev/null 2>&1
|
|
"$SECRETS_BIN" migrate >/dev/null 2>&1
|
|
# store is still v1 (markerless); pull uses the .gradle-properties.age blob
|
|
rm "$HOME/.gradle/gradle.properties"
|
|
run "$SECRETS_BIN" pull windowproj
|
|
[ "$status" -eq 0 ]
|
|
grep -q '^beaconClerkPkTest=pk_test_abc$' "$HOME/.gradle/gradle.properties"
|
|
}
|
|
|
|
@test "post-finalize pull reads the v2 blob" {
|
|
make_v1_store
|
|
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
|
create_project_dir postfin
|
|
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
|
"$SECRETS_BIN" push postfin >/dev/null 2>&1
|
|
"$SECRETS_BIN" migrate >/dev/null 2>&1
|
|
"$SECRETS_BIN" migrate --finalize --yes >/dev/null 2>&1
|
|
rm "$HOME/.gradle/gradle.properties"
|
|
run "$SECRETS_BIN" pull postfin
|
|
[ "$status" -eq 0 ]
|
|
grep -q '^beaconClerkPkTest=pk_test_abc$' "$HOME/.gradle/gradle.properties"
|
|
}
|
|
|
|
@test "finalize on a v1 dotenv-only store stamps the marker (no v1 blobs to drop)" {
|
|
make_v1_store
|
|
create_project_dir dotenvfin
|
|
"$SECRETS_BIN" push dotenvfin >/dev/null 2>&1
|
|
# no gradle-properties externals → v1count==0 path; no confirmation needed
|
|
run "$SECRETS_BIN" migrate --finalize
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"no v1 blobs"* ]] || false
|
|
[ "$(cat "$SECRETS_DIR/.secrets-format")" = "2" ]
|
|
}
|
|
|
|
@test "migrate with a positional argument dies" {
|
|
init_with_remote
|
|
create_project_dir mgpos
|
|
run "$SECRETS_BIN" migrate someproject
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"no project argument"* ]] || false
|
|
}
|
|
|
|
@test "finalize on an already-v2 store is a no-op" {
|
|
init_with_remote
|
|
create_project_dir finv2
|
|
run "$SECRETS_BIN" migrate --finalize --yes
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"already format v2"* ]] || false
|
|
}
|
|
|
|
@test "_store_format reads a garbage marker as v1 (strict parse)" {
|
|
make_v1_store
|
|
create_project_dir garbagemarker
|
|
# a non-"2" marker (e.g. a truncated/garbled value) must read as v1, not v2
|
|
printf 'v2-ish-garbage\n' > "$SECRETS_DIR/.secrets-format"
|
|
run "$SECRETS_BIN" which
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"format: v1"* ]] || false
|
|
}
|