secrets/test/migrate.bats

457 lines
18 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"
}
# Simulate an old (v1) client's properties blob: copy the pushed v2
# .properties.age to its v1 .gradle-properties.age twin (KEEPS both present).
m_fake_v1_twin() {
local proj="$1" v2
v2=$(ls "$SECRETS_DIR/$proj/external/"*.properties.age)
cp "$v2" "${v2%.properties.age}.gradle-properties.age"
}
# Like m_fake_v1_twin but RENAMES (leaves ONLY the v1 blob) — for copy-forward fixtures.
m_make_v1_only() {
local proj="$1" v2
v2=$(ls "$SECRETS_DIR/$proj/external/"*.properties.age)
mv "$v2" "${v2%.properties.age}.gradle-properties.age"
}
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 writes the v2 suffix for a fresh external (additive v2)" {
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/*.properties.age"
[ "$status" -eq 0 ]
}
@test "push writes the v2 suffix for a fresh external even on a v1 store" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir freshv1
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push freshv1 >/dev/null 2>&1
run bash -c "ls $SECRETS_DIR/freshv1/external/*.properties.age"
[ "$status" -eq 0 ]
run bash -c "ls $SECRETS_DIR/freshv1/external/*.gradle-properties.age 2>/dev/null"
[ "$status" -ne 0 ]
}
@test "push dual-writes the v1 twin so old clients stay fresh" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_old\n'
create_project_dir dualwrite
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push dualwrite >/dev/null 2>&1
m_fake_v1_twin dualwrite
m_gradle_src $'beaconClerkPkTest=pk_test_new\n'
"$SECRETS_BIN" push dualwrite >/dev/null 2>&1
rm -f "$SECRETS_DIR/dualwrite/external/"*.properties.age
rm -f "$HOME/.gradle/gradle.properties"
"$SECRETS_BIN" pull dualwrite >/dev/null 2>&1
run grep -q 'beaconClerkPkTest=pk_test_new' "$HOME/.gradle/gradle.properties"
[ "$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 ]
}
@test "pull reads a v1-suffix properties blob on a v2 store (read-fallback)" {
init_with_remote # born-v2 store (marker=2)
m_gradle_src $'beaconClerkPkTest=pk_test_v1\n'
create_project_dir rffallback
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push rffallback >/dev/null 2>&1 # writes .properties.age on a v2 store
# Simulate an external that exists only in the v1 suffix (an old client wrote it):
local v2blob; v2blob=$(ls "$SECRETS_DIR/rffallback/external/"*.properties.age)
mv "$v2blob" "${v2blob%.properties.age}.gradle-properties.age"
rm -f "$HOME/.gradle/gradle.properties"
"$SECRETS_BIN" pull rffallback >/dev/null 2>&1
run grep -q 'beaconClerkPkTest=pk_test_v1' "$HOME/.gradle/gradle.properties"
[ "$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
m_make_v1_only dryproj
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
m_make_v1_only cfproj
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
m_make_v1_only idemproj
"$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 copy-forwards a v1 properties blob with no .secrets.json (manifest-free)" {
# The EGB-710 repro: a legacy project has a v1 properties blob in the store
# but no .secrets.json (it predates the manifest). migrate must NOT dead-end.
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir nomanifestblob
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push nomanifestblob >/dev/null 2>&1
m_make_v1_only nomanifestblob
rm -f .secrets.json # simulate a pre-manifest project
run "$SECRETS_BIN" migrate
[ "$status" -eq 0 ]
run bash -c "ls $SECRETS_DIR/nomanifestblob/external/*.properties.age"
[ "$status" -eq 0 ] # v2 twin written despite no manifest
run bash -c "ls $SECRETS_DIR/nomanifestblob/external/*.gradle-properties.age"
[ "$status" -eq 0 ] # v1 kept (non-destructive)
}
@test "migrate twins a store blob even when the manifest no longer declares it" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir staleblob
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push staleblob >/dev/null 2>&1
m_make_v1_only staleblob
# The blob is now in the store. Drop the external from the project's manifest
# entirely (and remove the legacy file) so NO manifest declares it.
printf '{"version":2,"dotenv":[".env",".env.staging"]}\n' > .secrets.json
rm -f .secrets-files
run bash -c "ls $SECRETS_DIR/staleblob/external/*.gradle-properties.age"
[ "$status" -eq 0 ] # precondition: the v1 blob exists in the store
run "$SECRETS_BIN" migrate
[ "$status" -eq 0 ]
run bash -c "ls $SECRETS_DIR/staleblob/external/*.properties.age"
[ "$status" -eq 0 ] # twinned despite not being declared anywhere
}
@test "migrate in a project with no manifest and no store blobs is a clean no-op" {
make_v1_store
local dir="$WORK_DIR/nomanifest"; mkdir -p "$dir"; cd "$dir"
run "$SECRETS_BIN" migrate
[ "$status" -eq 0 ]
[[ "$output" == *"no v1 properties blobs"* ]] || 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
m_make_v1_only failverify
"$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
m_make_v1_only untwinned
# 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
m_make_v1_only tagproj
"$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
m_make_v1_only confproj
"$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 "migrate --status flags a project that needs migrating" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir needsmig
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push needsmig >/dev/null 2>&1 # v1 blob, no twin yet
m_make_v1_only needsmig
run "$SECRETS_BIN" migrate --status
[ "$status" -ne 0 ] # not finalize-ready
[[ "$output" == *"needsmig"* ]] || false
[[ "$output" == *"NEEDS MIGRATE"* ]] || false
[[ "$output" == *"Not finalize-ready"* ]] || false
}
@test "migrate --status reports finalize-ready once every blob is twinned" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir readymig
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push readymig >/dev/null 2>&1
"$SECRETS_BIN" migrate >/dev/null 2>&1 # create the twin
run "$SECRETS_BIN" migrate --status
[ "$status" -eq 0 ]
[[ "$output" == *"Finalize-ready"* ]] || false
}
@test "migrate --status on an already-v2 store says nothing to do" {
init_with_remote
create_project_dir v2status
run "$SECRETS_BIN" migrate --status
[ "$status" -eq 0 ]
[[ "$output" == *"v2"* ]] || false
[[ "$output" == *"nothing to migrate"* ]] || 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
}