#!/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 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 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 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 "$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 }