feat: external entries via .secrets.json + legacy absorb + properties rail (EGB-677 stage 1)

- .secrets.json external[] drives push/pull: type 'properties' (alias of
  gradle-properties; blob suffix stays legacy-compatible in stage 1) and
  type 'file'; same charset rails as the legacy parser
- push absorbs uncovered .secrets-files entries into the manifest
  (idempotent, gradle-properties → properties) with a delete hint
- pull: manifest wins entirely; a coexisting .secrets-files warns as
  superseded instead of being silently ignored
- basename rail generalized: properties targets must end '.properties'
  (was exact 'gradle.properties') — rc files/gitconfig still blocked;
  EGB-531 wrong-basename test updated for the sanctioned change
This commit is contained in:
Brian Majewski 2026-06-07 08:44:02 -07:00
parent 884da0965c
commit d7e1400487
3 changed files with 322 additions and 32 deletions

View file

@ -244,3 +244,138 @@ load test_helper
[ "$status" -eq 1 ]
[[ "$output" == *"project-relative"* ]] || false
}
# ─── C: legacy absorb + external entries via .secrets.json ─────────────
# Local fixtures (mirror secrets.bats EGB-531/652 helpers)
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"; }
@test "push absorbs .secrets-files into .secrets.json (properties + file)" {
init_with_remote
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
m_file_src
local dir="$WORK_DIR/absorbproj"; mkdir -p "$dir"
echo "K=v" > "$dir/.env"
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\nfile ~/keystores/upload.keystore\n' > "$dir/.secrets-files"
cd "$dir"
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
[[ "$output" == *"Absorbed"* ]] || false
run jq -r '.external | length' .secrets.json
[ "$output" = "2" ]
run jq -r '.external[] | select(.path == "~/.gradle/gradle.properties") | .type' .secrets.json
[ "$output" = "properties" ]
run jq -r '.external[] | select(.type == "file") | .path' .secrets.json
[ "$output" = "~/keystores/upload.keystore" ]
# stage 1: blob naming stays legacy-compatible
run bash -c "ls $SECRETS_DIR/absorbproj/external/*.gradle-properties.age"
[ "$status" -eq 0 ]
}
@test "absorb is idempotent — second push adds no duplicate externals" {
init_with_remote
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
local dir="$WORK_DIR/absorb2"; mkdir -p "$dir"
echo "K=v" > "$dir/.env"
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$dir/.secrets-files"
cd "$dir"
"$SECRETS_BIN" push >/dev/null 2>&1
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
run jq -r '.external | length' .secrets.json
[ "$output" = "1" ]
}
@test "external properties entry in .secrets.json drives push without .secrets-files" {
init_with_remote
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
local dir="$WORK_DIR/jsonextproj"; mkdir -p "$dir"
printf '{"version":2,"external":[{"type":"properties","path":"~/.gradle/gradle.properties","keys":["beaconClerkPkTest"]}]}\n' > "$dir/.secrets.json"
cd "$dir"
run "$SECRETS_BIN" push jsonextproj
[ "$status" -eq 0 ]
[[ "$output" == *"Extracted 1 key"* ]] || false
run bash -c "ls $SECRETS_DIR/jsonextproj/external/*.gradle-properties.age"
[ "$status" -eq 0 ]
}
@test "pull merges properties keys sourced from .secrets.json" {
init_with_remote
m_gradle_src $'beaconClerkPkTest=pk_test_abc\nunrelated=keep\n'
local dir="$WORK_DIR/jsonpull"; mkdir -p "$dir"
printf '{"version":2,"external":[{"type":"properties","path":"~/.gradle/gradle.properties","keys":["beaconClerkPkTest"]}]}\n' > "$dir/.secrets.json"
cd "$dir"
"$SECRETS_BIN" push jsonpull >/dev/null 2>&1
m_gradle_src $'beaconClerkPkTest=STALE\nunrelated=keep\n'
run "$SECRETS_BIN" pull jsonpull
[ "$status" -eq 0 ]
run grep -c 'beaconClerkPkTest=pk_test_abc' "$HOME/.gradle/gradle.properties"
[ "$output" = "1" ]
run grep -c 'unrelated=keep' "$HOME/.gradle/gradle.properties"
[ "$output" = "1" ]
}
@test "properties rail generalized: any *.properties basename is accepted" {
init_with_remote
mkdir -p "$HOME/.config"
printf 'apiKey=abc123\n' > "$HOME/.config/app.properties"
local dir="$WORK_DIR/genprops"; mkdir -p "$dir"
printf '{"version":2,"external":[{"type":"properties","path":"~/.config/app.properties","keys":["apiKey"]}]}\n' > "$dir/.secrets.json"
cd "$dir"
run "$SECRETS_BIN" push genprops
[ "$status" -eq 0 ]
[[ "$output" == *"Extracted 1 key"* ]] || false
}
@test "properties rail still blocks a non-.properties target" {
init_with_remote
printf 'PATH=/evil\n' > "$HOME/.bashrc"
local dir="$WORK_DIR/evilprops"; mkdir -p "$dir"
printf '{"version":2,"external":[{"type":"properties","path":"~/.bashrc","keys":["PATH"]}]}\n' > "$dir/.secrets.json"
cd "$dir"
run "$SECRETS_BIN" push evilprops
[ "$status" -eq 1 ]
[[ "$output" == *".properties"* ]] || false
}
@test "file entry via .secrets.json round-trips binary with mode 600" {
init_with_remote
m_file_src
local dir="$WORK_DIR/jsonfile"; mkdir -p "$dir"
printf '{"version":2,"external":[{"type":"file","path":"~/keystores/upload.keystore"}]}\n' > "$dir/.secrets.json"
cd "$dir"
"$SECRETS_BIN" push jsonfile >/dev/null 2>&1
cp "$HOME/keystores/upload.keystore" "$TEST_TMPDIR/orig.keystore"
rm "$HOME/keystores/upload.keystore"
run "$SECRETS_BIN" pull jsonfile
[ "$status" -eq 0 ]
cmp -s "$HOME/keystores/upload.keystore" "$TEST_TMPDIR/orig.keystore"
local mode
mode=$(stat -f '%Lp' "$HOME/keystores/upload.keystore" 2>/dev/null || stat -c '%a' "$HOME/keystores/upload.keystore")
[ "$mode" = "600" ]
}
@test "json file entry with keys is rejected with a warning" {
init_with_remote
m_file_src
local dir="$WORK_DIR/badfile"; mkdir -p "$dir"
echo "K=v" > "$dir/.env"
printf '{"version":2,"external":[{"type":"file","path":"~/keystores/upload.keystore","keys":["nope"]}]}\n' > "$dir/.secrets.json"
cd "$dir"
run "$SECRETS_BIN" push badfile
[ "$status" -eq 0 ]
[[ "$output" == *"no keys"* ]] || false
run bash -c "ls $SECRETS_DIR/badfile/external/*.file.age 2>/dev/null"
[ "$status" -ne 0 ]
}
@test "pull warns that .secrets-files is superseded when .secrets.json exists" {
init_with_remote
create_project_dir superproj
"$SECRETS_BIN" push superproj >/dev/null 2>&1
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
run "$SECRETS_BIN" pull superproj
[ "$status" -eq 0 ]
[[ "$output" == *"superseded"* ]] || false
}

View file

@ -1217,15 +1217,17 @@ gradle_project() {
[ "$mode" = "600" ]
}
@test "EGB-531: target with wrong basename is refused" {
@test "EGB-531: target with non-.properties basename is refused" {
# EGB-677 generalized the rail from exact 'gradle.properties' to any
# '*.properties' basename — shell rc files and gitconfig stay blocked.
init_with_remote
mkdir -p "$HOME/.gradle"
printf 'beaconClerkPkTest=x\n' > "$HOME/.gradle/custom.properties"
printf 'beaconClerkPkTest=x\n' > "$HOME/.gradle/evil.sh"
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
printf 'gradle-properties ~/.gradle/custom.properties beaconClerkPkTest\n' > .secrets-files
printf 'gradle-properties ~/.gradle/evil.sh beaconClerkPkTest\n' > .secrets-files
run "$SECRETS_BIN" push gproj
[ "$status" -eq 1 ]
[[ "$output" == *"gradle.properties"* ]] || false
[[ "$output" == *".properties"* ]] || false
}
@test "EGB-531: target outside HOME is refused" {
@ -1404,6 +1406,9 @@ gradle_project() {
init_with_remote
create_project_dir gproj
"$SECRETS_BIN" push gproj >/dev/null 2>&1
# EGB-677: drop the bootstrap .secrets.json so the legacy manifest path
# is exercised (with a manifest present, .secrets-files is superseded).
rm -f "$WORK_DIR/gproj/.secrets.json"
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$WORK_DIR/gproj/.secrets-files"
cd "$WORK_DIR/gproj"
run "$SECRETS_BIN" pull gproj