#!/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 counts v2-only externals (old clients not served)" { make_v1_store m_gradle_src $'beaconClerkPkTest=pk_test_abc\n' create_project_dir v2onlyext printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files "$SECRETS_BIN" push v2onlyext >/dev/null 2>&1 # v2-only (fresh, no v1 twin) run "$SECRETS_BIN" migrate --status [ "$status" -eq 0 ] # no v1 blobs -> finalize-ready [[ "$output" == *"v2-only"* ]] || false # surfaced as v2-only coverage } @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 }