- .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
381 lines
13 KiB
Bash
381 lines
13 KiB
Bash
#!/usr/bin/env bats
|
|
# EGB-677 stage 1: .secrets.json manifest — parse, rails, add, generators.
|
|
|
|
load test_helper
|
|
|
|
# ─── A: manifest core — secrets add + rails + canonical form ──────────
|
|
|
|
@test "add creates .secrets.json with version 2 and the dotenv entry" {
|
|
create_project_dir addproj
|
|
run "$SECRETS_BIN" add .env
|
|
[ "$status" -eq 0 ]
|
|
[ -f ".secrets.json" ]
|
|
run jq -r '.version' .secrets.json
|
|
[ "$output" = "2" ]
|
|
run jq -r '.dotenv[0]' .secrets.json
|
|
[ "$output" = ".env" ]
|
|
}
|
|
|
|
@test "add is idempotent — no duplicate entries" {
|
|
create_project_dir addproj
|
|
"$SECRETS_BIN" add .env >/dev/null
|
|
run "$SECRETS_BIN" add .env
|
|
[ "$status" -eq 0 ]
|
|
run jq -r '.dotenv | length' .secrets.json
|
|
[ "$output" = "1" ]
|
|
}
|
|
|
|
@test "add accepts nested workspace paths" {
|
|
create_project_dir addproj
|
|
mkdir -p packages/web
|
|
echo "K=v" > packages/web/.env.development
|
|
run "$SECRETS_BIN" add packages/web/.env.development
|
|
[ "$status" -eq 0 ]
|
|
run jq -r '.dotenv | index("packages/web/.env.development") != null' .secrets.json
|
|
[ "$output" = "true" ]
|
|
}
|
|
|
|
@test "add accepts npm-scoped workspace paths (@)" {
|
|
create_project_dir addproj
|
|
mkdir -p "packages/@acme/web"
|
|
echo "K=v" > "packages/@acme/web/.env"
|
|
run "$SECRETS_BIN" add "packages/@acme/web/.env"
|
|
[ "$status" -eq 0 ]
|
|
run jq -r '.dotenv | index("packages/@acme/web/.env") != null' .secrets.json
|
|
[ "$output" = "true" ]
|
|
}
|
|
|
|
@test "add rejects path traversal (..)" {
|
|
create_project_dir addproj
|
|
run "$SECRETS_BIN" add ../escape/.env
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"project-relative"* ]] || false
|
|
[ ! -f ".secrets.json" ]
|
|
}
|
|
|
|
@test "add rejects absolute paths" {
|
|
create_project_dir addproj
|
|
run "$SECRETS_BIN" add /etc/passwd
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"project-relative"* ]] || false
|
|
[ ! -f ".secrets.json" ]
|
|
}
|
|
|
|
@test "add rejects shell metacharacters in path" {
|
|
create_project_dir addproj
|
|
run "$SECRETS_BIN" add '.env;rm -rf ~'
|
|
[ "$status" -eq 1 ]
|
|
[ ! -f ".secrets.json" ]
|
|
}
|
|
|
|
@test "add requires the file to exist" {
|
|
create_project_dir addproj
|
|
run "$SECRETS_BIN" add .env.missing
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"not found"* ]] || false
|
|
}
|
|
|
|
@test "manifest serialization is canonical — order of adds does not matter" {
|
|
create_project_dir addproj
|
|
echo "A=1" > .env.alpha
|
|
echo "B=2" > .env.beta
|
|
"$SECRETS_BIN" add .env.alpha >/dev/null
|
|
"$SECRETS_BIN" add .env.beta >/dev/null
|
|
cp .secrets.json "$TEST_TMPDIR/order1.json"
|
|
rm .secrets.json
|
|
"$SECRETS_BIN" add .env.beta >/dev/null
|
|
"$SECRETS_BIN" add .env.alpha >/dev/null
|
|
cmp -s .secrets.json "$TEST_TMPDIR/order1.json"
|
|
}
|
|
|
|
@test "which shows manifest summary when .secrets.json is present" {
|
|
create_project_dir addproj
|
|
"$SECRETS_BIN" add .env >/dev/null
|
|
run "$SECRETS_BIN" which
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *".secrets.json"* ]] || false
|
|
[[ "$output" == *".env"* ]] || false
|
|
}
|
|
|
|
@test "malformed .secrets.json dies with a directed error naming the file" {
|
|
create_project_dir addproj
|
|
echo '{ not json' > .secrets.json
|
|
run "$SECRETS_BIN" which
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *".secrets.json"* ]] || false
|
|
[[ "$output" == *"invalid"* ]] || false
|
|
}
|
|
|
|
@test "unsupported manifest version dies with a directed upgrade error" {
|
|
create_project_dir addproj
|
|
echo '{"version": 99, "dotenv": [".env"]}' > .secrets.json
|
|
run "$SECRETS_BIN" which
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"version 99"* ]] || false
|
|
[[ "$output" == *"supports"* ]] || false
|
|
}
|
|
|
|
@test "symlinked .secrets.json is refused" {
|
|
create_project_dir addproj
|
|
echo '{"version":2,"dotenv":[".env"]}' > "$TEST_TMPDIR/real-manifest.json"
|
|
ln -s "$TEST_TMPDIR/real-manifest.json" .secrets.json
|
|
run "$SECRETS_BIN" which
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"symlink"* ]] || false
|
|
}
|
|
|
|
# ─── B: push from manifest — generators, autoAdd, --frozen/--dry-run ───
|
|
|
|
@test "push with manifest syncs nested declared file into v1 store layout" {
|
|
init_with_remote
|
|
create_project_dir nestproj
|
|
mkdir -p packages/web
|
|
echo "K=v" > packages/web/.env.development
|
|
"$SECRETS_BIN" add packages/web/.env.development >/dev/null
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
[ -f "$SECRETS_DIR/nestproj/packages/web/.env.development.age" ]
|
|
}
|
|
|
|
@test "push auto-adds newly discovered root files to an existing manifest" {
|
|
init_with_remote
|
|
create_project_dir autoproj
|
|
"$SECRETS_BIN" add .env >/dev/null
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"Added"* ]] || false
|
|
run jq -r '.dotenv | index(".env.staging") != null' .secrets.json
|
|
[ "$output" = "true" ]
|
|
[ -f "$SECRETS_DIR/autoproj/.env.staging.age" ]
|
|
}
|
|
|
|
@test "bootstrap: plain push creates the manifest from discovered files" {
|
|
init_with_remote
|
|
create_project_dir bootproj
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
[ -f ".secrets.json" ]
|
|
run jq -r '.dotenv | length' .secrets.json
|
|
[ "$output" = "2" ]
|
|
}
|
|
|
|
@test "failed push leaves no bootstrap manifest behind" {
|
|
init_with_remote
|
|
mkdir -p "$WORK_DIR/emptyproj"
|
|
cd "$WORK_DIR/emptyproj"
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 1 ]
|
|
[ ! -f ".secrets.json" ]
|
|
}
|
|
|
|
@test "autoAdd=false: undeclared discovered file is warned about, not added or synced" {
|
|
init_with_remote
|
|
create_project_dir noaddproj
|
|
printf '{"version":2,"options":{"autoAdd":false},"dotenv":[".env"]}\n' > .secrets.json
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"not declared"* ]] || false
|
|
run jq -r '.dotenv | index(".env.staging") != null' .secrets.json
|
|
[ "$output" = "false" ]
|
|
[ -f "$SECRETS_DIR/noaddproj/.env.age" ]
|
|
[ ! -f "$SECRETS_DIR/noaddproj/.env.staging.age" ]
|
|
}
|
|
|
|
@test "push --frozen skips auto-add even when autoAdd is on" {
|
|
init_with_remote
|
|
create_project_dir frozenproj
|
|
"$SECRETS_BIN" add .env >/dev/null
|
|
run "$SECRETS_BIN" push --frozen
|
|
[ "$status" -eq 0 ]
|
|
run jq -r '.dotenv | index(".env.staging") != null' .secrets.json
|
|
[ "$output" = "false" ]
|
|
[ ! -f "$SECRETS_DIR/frozenproj/.env.staging.age" ]
|
|
# declared entry still synced under the REAL project name
|
|
[ -f "$SECRETS_DIR/frozenproj/.env.age" ]
|
|
}
|
|
|
|
@test "push --dry-run reports would-add entries and changes nothing" {
|
|
init_with_remote
|
|
create_project_dir dryproj
|
|
"$SECRETS_BIN" add .env >/dev/null
|
|
cp .secrets.json "$TEST_TMPDIR/manifest-before.json"
|
|
run "$SECRETS_BIN" push --dry-run
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *".env.staging"* ]] || false
|
|
cmp -s .secrets.json "$TEST_TMPDIR/manifest-before.json"
|
|
[ ! -f "$SECRETS_DIR/dryproj/.env.age" ]
|
|
# nothing committed to the store at all
|
|
[ "$(git -C "$SECRETS_DIR" rev-list --count HEAD)" -eq 1 ]
|
|
}
|
|
|
|
@test "plain push re-scans package.json workspaces when a manifest exists" {
|
|
init_with_remote
|
|
local mono="$WORK_DIR/wsproj"
|
|
mkdir -p "$mono/packages/api"
|
|
printf '{"workspaces": ["packages/*"]}\n' > "$mono/package.json"
|
|
echo "ROOT=1" > "$mono/.env"
|
|
echo "API=1" > "$mono/packages/api/.dev.vars"
|
|
git init "$mono" >/dev/null 2>&1
|
|
cd "$mono"
|
|
"$SECRETS_BIN" add .env >/dev/null
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
run jq -r '.dotenv | index("packages/api/.dev.vars") != null' .secrets.json
|
|
[ "$output" = "true" ]
|
|
[ -f "$SECRETS_DIR/wsproj/packages/api/.dev.vars.age" ]
|
|
}
|
|
|
|
@test "declared-but-missing file warns and push continues" {
|
|
init_with_remote
|
|
create_project_dir missproj
|
|
"$SECRETS_BIN" add .env >/dev/null
|
|
printf '{"version":2,"dotenv":[".env",".env.gone"]}\n' > .secrets.json
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *".env.gone"* ]] || false
|
|
[ -f "$SECRETS_DIR/missproj/.env.age" ]
|
|
}
|
|
|
|
@test "unsafe dotenv entry in a committed manifest dies on push" {
|
|
init_with_remote
|
|
create_project_dir evilproj
|
|
printf '{"version":2,"dotenv":["../escape/.env"]}\n' > .secrets.json
|
|
run "$SECRETS_BIN" push
|
|
[ "$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
|
|
}
|