Merge origin/main into EGB-283 (multi-recipient age encryption)

Reconcile the multi-recipient branch (cut from v0.6.1.0) with origin/main
at v0.7.4.0. The two feature lines are largely orthogonal; the one real
integration point is the external-blob encrypt path:

- EGB-712 added an additive-v2 dual-write loop (_external_blob_write_targets,
  writing v2 + any v1 twin). EGB-283 routes every encrypt site through
  RECIPIENT_ARGS for N-recipient encryption. Resolution keeps the dual-write
  loop but encrypts each target to the full recipient set
  (age "${RECIPIENT_ARGS[@]}" per write target), so dual-write and
  multi-recipient compose. cmd_push loads recipients before both external
  push sites; legacy single-key rekey keeps its fresh-keypair pubkey path.

Version: 0.6.2.0 + 0.7.4.0 -> 0.7.5.0. Docs (CLAUDE.md/README/CHANGELOG)
merged to carry both feature sets; subcommand list now includes
recipients/reencrypt and upgrade.

Tests: full `bats test/` green except 6 pre-existing host-environment
failures (4 chmod-600 restore assertions + 2 jq-PATH-shadow tests, all
macOS-authored), none touching merged code. recipients.bats 34/34 pass;
external/dual-write area passes except the same mode-600 host artifacts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brian Majewski 2026-06-24 14:52:32 -07:00
commit e29024bd63
16 changed files with 2723 additions and 142 deletions

72
test/install.bats Normal file
View file

@ -0,0 +1,72 @@
#!/usr/bin/env bats
# EGB-671: install.sh thin bootstrap. It ships IN the repo (you clone the repo
# to get it), so its job is: verify deps (age + jq + git), PRINT the PATH line
# and next-step commands — never edit dotfiles, never invoke sudo. Security-rail
# concerns are operator-local (.ship-policy.json); these are functional checks.
load test_helper
INSTALL_SH="$(cd "$(dirname "${BATS_TEST_FILENAME}")/.." && pwd)/install.sh"
@test "install.sh exists and is executable" {
[ -f "$INSTALL_SH" ]
[ -x "$INSTALL_SH" ]
}
@test "install.sh --help prints usage and exits 0" {
run "$INSTALL_SH" --help
[ "$status" -eq 0 ]
[[ "$output" == *"install.sh"* ]] || false
[[ "$output" == *"join"* ]] || false
}
@test "install.sh prints the PATH export line for the tool dir (does not edit rc)" {
local tool_dir
tool_dir="$(cd "$(dirname "$INSTALL_SH")" && pwd)"
run "$INSTALL_SH"
[ "$status" -eq 0 ]
[[ "$output" == *"export PATH="* ]] || false
[[ "$output" == *"$tool_dir"* ]] || false
# It must NOT have written to any shell rc in the isolated HOME.
[ ! -f "$HOME/.zshrc" ]
[ ! -f "$HOME/.bashrc" ]
}
@test "install.sh prints both onboarding next-steps (init --remote and join)" {
run "$INSTALL_SH"
[ "$status" -eq 0 ]
[[ "$output" == *"secrets init --remote"* ]] || false
[[ "$output" == *"secrets join --remote"* ]] || false
}
@test "install.sh prints the upgrade one-liner" {
run "$INSTALL_SH"
[ "$status" -eq 0 ]
[[ "$output" == *"git -C"* ]] || false
[[ "$output" == *"pull"* ]] || false
}
@test "install.sh prints a key-transfer hint" {
run "$INSTALL_SH"
[ "$status" -eq 0 ]
[[ "$output" == *"key.txt"* ]] || false
}
@test "install.sh never invokes sudo (prints it for the user instead)" {
# No executed 'sudo' — any sudo reference must be quoted guidance text.
run grep -nE '^[[:space:]]*sudo ' "$INSTALL_SH"
[ "$status" -ne 0 ]
}
@test "install.sh reports a missing dependency with an install hint and non-zero exit" {
# Build a minimal PATH that has the tools install.sh needs but NOT jq.
local fake="$TEST_TMPDIR/fakebin"
mkdir -p "$fake"
for t in bash uname env cat grep sed tr dirname command age git printf; do
src="$(command -v "$t" 2>/dev/null || true)"
[ -n "$src" ] && ln -sf "$src" "$fake/$t" 2>/dev/null || true
done
run env PATH="$fake" "$INSTALL_SH"
[ "$status" -ne 0 ]
[[ "$output" == *"jq"* ]] || false
}

145
test/join.bats Normal file
View file

@ -0,0 +1,145 @@
#!/usr/bin/env bats
# EGB-671: `secrets join` (second-machine onboarding) + `secrets init --remote`
# + day-2 silent-decrypt fix. Functional paths only — security-rail tests
# (path traversal on --key/--store, URL injection) are operator-local per
# .ship-policy.json and live in test/run-security.sh.
load test_helper
# Push a project to REMOTE_DIR and save the key, then remove the local store
# to simulate a fresh second machine. Leaves: REMOTE_DIR has blobs,
# $TEST_TMPDIR/saved-key.txt is the decrypting key, $SECRETS_DIR is gone.
_machine1_push_then_wipe() {
init_with_remote
cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt"
create_project_dir "joinproj"
"$SECRETS_BIN" push >/dev/null 2>&1
cd "$HOME"
rm -rf "$SECRETS_DIR"
}
# Like above but never pushes a project — remote has a store with zero blobs.
_machine1_empty_then_wipe() {
init_with_remote
cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt"
cd "$HOME"
rm -rf "$SECRETS_DIR"
}
# ─── secrets join ────────────────────────────────────────────────────────
@test "join without --remote fails with usage" {
run "$SECRETS_BIN" join
[ "$status" -ne 0 ]
[[ "$output" == *"--remote"* ]] || false
}
@test "join clones the store, installs the key at 600, verifies, and succeeds" {
_machine1_push_then_wipe
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt"
[ "$status" -eq 0 ]
[[ "$output" == *"VERIFIED"* ]] || false
[ -d "$SECRETS_DIR/.git" ]
[ -f "$SECRETS_DIR/key.txt" ]
# key installed at mode 600
local perms
perms=$(stat -f '%Lp' "$SECRETS_DIR/key.txt" 2>/dev/null || stat -c '%a' "$SECRETS_DIR/key.txt")
[ "$perms" = "600" ]
}
@test "join with the wrong key fails loudly and does not report VERIFIED" {
_machine1_push_then_wipe
age-keygen -o "$TEST_TMPDIR/wrong-key.txt" 2>/dev/null
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/wrong-key.txt"
[ "$status" -ne 0 ]
[[ "$output" != *"VERIFIED"* ]] || false
}
@test "join against an empty store reports nothing-to-verify, NOT VERIFIED" {
_machine1_empty_then_wipe
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt"
[ "$status" -eq 0 ]
[[ "$output" == *"nothing to verify"* ]] || false
[[ "$output" != *"VERIFIED"* ]] || false
}
@test "join refuses when a store already exists at the target" {
"$SECRETS_BIN" init >/dev/null 2>&1
cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt"
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt"
[ "$status" -ne 0 ]
[[ "$output" == *"already"* ]] || false
}
@test "join fails clearly when the key file is missing" {
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/nope.txt"
[ "$status" -ne 0 ]
[[ "$output" == *"key"* ]] || false
}
@test "join detects a directory passed as --key" {
_machine1_push_then_wipe
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR"
[ "$status" -ne 0 ]
[[ "$output" == *"key"* ]] || false
}
# ─── secrets init --remote ────────────────────────────────────────────────
@test "init --remote sets origin and establishes an upstream branch" {
run "$SECRETS_BIN" init --remote "$REMOTE_DIR"
[ "$status" -eq 0 ]
run git -C "$SECRETS_DIR" remote get-url origin
[ "$status" -eq 0 ]
[ "$output" = "$REMOTE_DIR" ]
# upstream branch exists on the remote (so a later push won't ff-only die)
run git -C "$SECRETS_DIR" rev-parse --abbrev-ref '@{u}'
[ "$status" -eq 0 ]
}
@test "init --remote then push does not die on the brand-new remote" {
"$SECRETS_BIN" init --remote "$REMOTE_DIR" >/dev/null 2>&1
create_project_dir "freshproj"
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
[[ "$output" != *"Fast-forward pull failed"* ]] || false
}
@test "init with no flags still works (clean primitive)" {
run "$SECRETS_BIN" init
[ "$status" -eq 0 ]
[ -f "$SECRETS_DIR/key.txt" ]
}
@test "init does not hang on the first-add prompt when stdin is a tty but stdout is captured" {
# Regression: run-security.sh runs bats in a real terminal, so the command's
# stdin stays a tty while bats captures its stdout. The interactive first-add
# prompt must NOT fire in that shape (it gates on stdout being a tty too),
# or the whole suite hangs. Reproduce with a pty via `script`.
command -v script >/dev/null 2>&1 || skip "script (pty) not available"
# macOS/BSD syntax: `script -q <file> <cmd...>`. Skip on other syntaxes.
script -q /dev/null true >/dev/null 2>&1 || skip "unsupported script syntax"
local out="$TEST_TMPDIR/pty-initout"
run timeout 10 script -q /dev/null bash -c "'$SECRETS_BIN' init > '$out' 2>&1"
[ "$status" -ne 124 ] # 124 == timeout == it hung on a prompt
run grep -c "Add a project's secrets" "$out"
[ "$output" = "0" ]
}
# ─── day-2 silent-decrypt fix ─────────────────────────────────────────────
@test "pull dies loudly when a blob cannot be decrypted with the current key" {
init_with_remote
create_project_dir "decryptproj"
"$SECRETS_BIN" push >/dev/null 2>&1
# Swap in a different key so the stored blob no longer decrypts.
# (age-keygen refuses to overwrite, so generate elsewhere then copy.)
age-keygen -o "$TEST_TMPDIR/other-key.txt" 2>/dev/null
cp "$TEST_TMPDIR/other-key.txt" "$SECRETS_DIR/key.txt"
chmod 600 "$SECRETS_DIR/key.txt"
cd "$WORK_DIR/decryptproj"
rm -f .env .env.staging
run "$SECRETS_BIN" pull
[ "$status" -ne 0 ]
[[ "$output" == *"decrypt"* ]] || false
}

View file

@ -167,6 +167,17 @@ load test_helper
[ "$output" = "2" ]
}
@test "bootstrap: first push writes an explicit options.autoAdd value (EGB-677 contract #2)" {
init_with_remote
create_project_dir autoaddproj
# Non-interactive (bats has no tty): the prompt is skipped and the tool
# default (ON) is written explicitly so the value is committed + team-shared.
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
run jq -r '.options.autoAdd' .secrets.json
[ "$output" = "true" ]
}
@test "failed push leaves no bootstrap manifest behind" {
init_with_remote
mkdir -p "$WORK_DIR/emptyproj"
@ -631,6 +642,31 @@ m_nojq_path() {
[[ "$output" == *"k1"* ]] || false
}
# EGB-701 item 1: `which` and the push/pull external extractor share one
# helper (_json_external_entries), so `which` applies the same
# properties→gradle-properties normalization the sync path uses — no drift.
@test "which normalizes a properties external to the gradle-properties token (EGB-701)" {
create_project_dir whichnorm
printf '{"version":2,"dotenv":[".env"],"external":[{"type":"properties","path":"~/.gradle/gradle.properties","keys":["k1"]}]}\n' > .secrets.json
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"gradle-properties"* ]] || false
}
# EGB-701 item 1: a malformed external (a properties entry with no keys) is
# skipped by the sync path; routing `which` through the shared extractor means
# `which` skips+warns it too, so it faithfully shows what actually syncs
# rather than printing an entry push/pull silently drop.
@test "which skips a malformed external entry the sync path would drop (EGB-701)" {
create_project_dir whichmalformed
printf '{"version":2,"dotenv":[".env"],"external":[{"type":"properties","path":"~/.gradle/gradle.properties"}]}\n' > .secrets.json
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"has no keys"* ]] || false
# The skipped entry's path must NOT appear in the printed manifest summary.
[[ "$output" != *" gradle-properties ~/.gradle/gradle.properties"* ]] || false
}
# ─── F: ship Step 7 coverage backfill (audit gaps) ─────────────────────
@test "which flags an unsafe dotenv entry with the UNSAFE marker" {
@ -728,6 +764,46 @@ m_nojq_path() {
[ "$(cat packages/web/.env.development)" = "N=nested" ]
}
@test "legacy (manifest-less) pull warns about nested blobs it can't restore (EGB-701)" {
# The legacy pull path globs only top-level *.age/.*.age. A nested dotenv
# blob (<project>/<relpath>.age) written by a manifest-driven push on another
# machine is invisible to those globs — restored nothing, counted nothing.
# The fix: warn so a manifest-less pull never silently under-restores.
init_with_remote
create_project_dir nestlegacy
mkdir -p packages/web
echo "N=nested" > packages/web/.env.development
"$SECRETS_BIN" add packages/web/.env.development >/dev/null
"$SECRETS_BIN" push >/dev/null 2>&1
[ -f "$SECRETS_DIR/nestlegacy/packages/web/.env.development.age" ]
# Simulate a machine with no manifest: drop .secrets.json + local files,
# forcing the legacy non-recursive glob branch.
rm -f .secrets.json
rm -rf packages
run "$SECRETS_BIN" pull nestlegacy
[ "$status" -eq 0 ]
# The warning names the nested blob and points at the manifest as the fix.
[[ "$output" == *"packages/web/.env.development"* ]] || false
[[ "$output" == *"$SECRETS_JSON_NAME"* || "$output" == *".secrets.json"* ]] || false
# The legacy path genuinely can't restore it (the warning is the contract).
[ ! -f packages/web/.env.development ]
}
@test "legacy pull does NOT warn about external/ blobs (handled separately, EGB-701)" {
# external/<slug>.age blobs are restored by pull_external_files, not the
# dotenv globs, so they must not trip the nested-blob warning.
init_with_remote
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir extnolwarn
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push >/dev/null 2>&1
[ -d "$SECRETS_DIR/extnolwarn/external" ]
run "$SECRETS_BIN" pull extnolwarn
[ "$status" -eq 0 ]
[[ "$output" != *"can't restore"* ]] || false
[[ "$output" != *"nested encrypted"* ]] || false
}
@test "list shows a nested manifest blob" {
init_with_remote
create_project_dir nestlist

View file

@ -10,6 +10,19 @@ 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"; }
@ -49,13 +62,41 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' >
[ "$status" -ne 0 ]
}
@test "push on a v1 store still writes .gradle-properties.age (back-compat)" {
@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/*.gradle-properties.age"
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 ]
}
@ -67,6 +108,21 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' >
[ "$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" {
@ -75,6 +131,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\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
@ -100,6 +157,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\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 ]
@ -115,6 +173,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\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 ]
@ -131,6 +190,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\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 ]
@ -146,6 +206,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\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
@ -206,6 +267,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\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
@ -224,6 +286,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\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 ]
@ -254,6 +317,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\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-')
@ -269,6 +333,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\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 ]
@ -352,6 +417,7 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\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
@ -371,6 +437,17 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' >
[[ "$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

View file

@ -1484,9 +1484,10 @@ gradle_project() {
# ─── init second-machine guard + store .gitignore self-heal ────────────
@test "init with existing key but no repo dies with clone guidance" {
# Second-machine trap: user copies key.txt into ~/.secrets, then runs
# `secrets init` instead of cloning their secrets repo.
@test "init with existing key but no repo dies with join guidance" {
# Second-machine trap (EGB-671): user copies key.txt into ~/.secrets, then
# runs `secrets init` instead of joining their existing vault. The trap now
# points at `secrets join` (the real one-command path), not a manual clone.
mkdir -p "$SECRETS_DIR"
age-keygen -o "$SECRETS_DIR/key.txt" 2>/dev/null
# Guard against a vacuous '' = '' comparison if age-keygen failed
@ -1496,7 +1497,7 @@ gradle_project() {
run "$SECRETS_BIN" init
[ "$status" -eq 1 ]
[[ "$output" == *"git clone"* ]] || false
[[ "$output" == *"secrets join"* ]] || false
# Must not leave a half-initialized store behind
[ ! -d "$SECRETS_DIR/.git" ]
# Key untouched
@ -1651,7 +1652,7 @@ gradle_project() {
[[ "$output" != *"key.txt"* ]] || false
}
@test "init guard renders the real clone URL when .secrets-store carries a remote" {
@test "init guard renders the real remote URL in join guidance when .secrets-store carries a remote" {
mkdir -p "$HOME/.secrets-work"
age-keygen -o "$HOME/.secrets-work/key.txt" 2>/dev/null
[ -s "$HOME/.secrets-work/key.txt" ]
@ -1660,7 +1661,7 @@ gradle_project() {
run "$SECRETS_BIN" init
[ "$status" -eq 1 ]
[[ "$output" == *"git clone git@example.com:me/secrets-work.git"* ]] || false
[[ "$output" == *"secrets join --remote git@example.com:me/secrets-work.git"* ]] || false
}
# ─── EGB-652: `file` external type (whole-file sync, e.g. Android keystore) ──
@ -1763,3 +1764,84 @@ file_project() {
[[ "$output" == *"Extracted 1 key"* ]] || false
[[ "$output" == *"Encrypted file"* ]] || false
}
# ─── EGB-699: `list --json` machine-readable output ──────────────────────
@test "EGB-699: list --json emits valid JSON with project and dotenv entry" {
init_with_remote
create_project_dir jproj
"$SECRETS_BIN" push jproj >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
# entire stdout parses as JSON
echo "$output" | jq -e . >/dev/null
# project is present
echo "$output" | jq -e '.projects[] | select(.name == "jproj")' >/dev/null
# .env shows up as a dotenv entry
echo "$output" | jq -e '.projects[] | select(.name == "jproj")
| .entries[] | select(.type == "dotenv" and .path == ".env")' >/dev/null
}
@test "EGB-699: list --json includes a nested dotenv relpath" {
init_with_remote
create_project_dir nestjson
mkdir -p packages/web
echo "N=nested" > packages/web/.env.development
"$SECRETS_BIN" add packages/web/.env.development >/dev/null
"$SECRETS_BIN" push >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
echo "$output" | jq -e '.projects[] | select(.name == "nestjson")
| .entries[] | select(.type == "dotenv" and .path == "packages/web/.env.development")' >/dev/null
}
@test "EGB-699: list --json marks an external properties entry with subtype" {
init_with_remote
gradle_src $'beaconClerkPkTest=pk_test_abc\n'
gradle_project gjson beaconClerkPkTest
"$SECRETS_BIN" push gjson >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
echo "$output" | jq -e '.projects[] | select(.name == "gjson")
| .entries[] | select(.type == "external" and .subtype == "properties")' >/dev/null
}
@test "EGB-699: list --json marks an external file entry with subtype" {
init_with_remote
file_src
local dir="$WORK_DIR/fjson"; mkdir -p "$dir"
printf 'file ~/keystores/upload.keystore\n' > "$dir/.secrets-files"
cd "$dir"
"$SECRETS_BIN" push fjson >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
echo "$output" | jq -e '.projects[] | select(.name == "fjson")
| .entries[] | select(.type == "external" and .subtype == "file")' >/dev/null
}
@test "EGB-699: list --json on an empty store emits an empty projects array" {
"$SECRETS_BIN" init >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
echo "$output" | jq -e '.projects == []' >/dev/null
}
@test "EGB-699: list --json keeps stdout pure JSON (notices go to stderr)" {
# The non-default-store hint normally prints to stdout in human mode; under
# --json it must not, or it would corrupt the document. Capture stdout only.
init_with_remote
create_project_dir purejson
"$SECRETS_BIN" push purejson >/dev/null 2>&1
local json
json=$("$SECRETS_BIN" list --json 2>/dev/null)
echo "$json" | jq -e . >/dev/null
}
@test "EGB-699: list --json reports the active store path" {
init_with_remote
create_project_dir storejson
"$SECRETS_BIN" push storejson >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
echo "$output" | jq -e --arg s "$SECRETS_DIR" '.store == $s' >/dev/null
}

115
test/upgrade.bats Normal file
View file

@ -0,0 +1,115 @@
#!/usr/bin/env bats
# EGB-716: `secrets upgrade` verb — self-update (git pull --ff-only) + skew re-check.
#
# These tests never touch the real tool checkout. Each test relocates a COPY of
# the script into a throwaway git repo wired to a bare upstream, so $SCRIPT_DIR
# (computed from BASH_SOURCE) resolves to the fake tool repo and the pull/fetch
# operate there.
load test_helper
# Create a fake tool repo at $TOOL (script copy + VERSION), wired to a bare
# upstream at $TOOL_REMOTE, at version $1. cd's into $TOOL (under $HOME so
# resolve_store's walk-up stays bounded and never strays to a real store).
setup_tool_repo() {
TOOL="$TEST_TMPDIR/tool"
TOOL_REMOTE="$TEST_TMPDIR/tool-remote.git"
mkdir -p "$TOOL"
cp "$SECRETS_BIN" "$TOOL/secrets"
echo "$1" > "$TOOL/VERSION"
git -c init.defaultBranch=main init -q "$TOOL"
git -C "$TOOL" add -A
git -C "$TOOL" -c user.email=t@t -c user.name=t commit -qm "v$1"
git -c init.defaultBranch=main init --bare -q "$TOOL_REMOTE"
git -C "$TOOL" remote add origin "$TOOL_REMOTE"
git -C "$TOOL" push -q -u origin HEAD:main
cd "$TOOL"
}
# Publish a newer VERSION to the upstream (as a different clone would).
advance_tool_remote() {
local clone="$TEST_TMPDIR/tool-pub"
rm -rf "$clone"
git clone -q "$TOOL_REMOTE" "$clone"
echo "$1" > "$clone/VERSION"
git -C "$clone" -c user.email=t@t -c user.name=t commit -qam "v$1"
git -C "$clone" push -q origin HEAD:main
rm -rf "$clone"
}
@test "upgrade --check reports an available update without changing VERSION (EGB-716)" {
setup_tool_repo 0.1.0.0
advance_tool_remote 0.2.0.0
run "$TOOL/secrets" upgrade --check
[ "$status" -eq 0 ]
[[ "$output" == *"Update available"* ]] || false
[[ "$output" == *"0.1.0.0"* ]] || false
# --check must not pull: local VERSION is untouched.
[ "$(cat "$TOOL/VERSION")" = "0.1.0.0" ]
}
@test "upgrade --check is clean when already current (EGB-716)" {
setup_tool_repo 0.2.0.0
run "$TOOL/secrets" upgrade --check
[ "$status" -eq 0 ]
[[ "$output" == *"up to date"* ]] || false
}
@test "upgrade fast-forwards and reports old -> new (EGB-716)" {
setup_tool_repo 0.1.0.0
advance_tool_remote 0.2.0.0
run "$TOOL/secrets" upgrade
[ "$status" -eq 0 ]
[[ "$output" == *"v0.1.0.0 -> v0.2.0.0"* ]] || false
[ "$(cat "$TOOL/VERSION")" = "0.2.0.0" ]
}
@test "upgrade is a no-op when already at the latest (EGB-716)" {
setup_tool_repo 0.2.0.0
run "$TOOL/secrets" upgrade
[ "$status" -eq 0 ]
[[ "$output" == *"up to date"* ]] || false
[ "$(cat "$TOOL/VERSION")" = "0.2.0.0" ]
}
@test "upgrade refuses when the tool dir is not a git checkout (EGB-716)" {
local d="$HOME/plain-tool"
mkdir -p "$d"
cp "$SECRETS_BIN" "$d/secrets"
echo 0.1.0.0 > "$d/VERSION"
cd "$d"
run "$d/secrets" upgrade
[ "$status" -eq 1 ]
[[ "$output" == *"git checkout"* ]] || false
}
@test "upgrade rejects an unknown flag (EGB-716)" {
setup_tool_repo 0.1.0.0
run "$TOOL/secrets" upgrade --bogus
[ "$status" -eq 1 ]
[[ "$output" == *"Unknown upgrade flag"* ]] || false
}
@test "upgrade re-checks store skew and confirms the client caught up (EGB-716)" {
setup_tool_repo 0.1.0.0
advance_tool_remote 0.9.0.0
# A store last written by a newer client than our starting version.
git -c init.defaultBranch=main init -q "$SECRETS_DIR"
echo 0.8.0.0 > "$SECRETS_DIR/.secrets-writer-version"
run "$TOOL/secrets" upgrade
[ "$status" -eq 0 ]
[[ "$output" == *"v0.1.0.0 -> v0.9.0.0"* ]] || false
# New client (0.9.0.0) is now ahead of the store's last writer (0.8.0.0).
[[ "$output" == *"at or ahead"* ]] || false
}
@test "upgrade still notes when the store is ahead of the upgraded client (EGB-716)" {
setup_tool_repo 0.1.0.0
advance_tool_remote 0.2.0.0
git -c init.defaultBranch=main init -q "$SECRETS_DIR"
echo 0.9.0.0 > "$SECRETS_DIR/.secrets-writer-version"
run "$TOOL/secrets" upgrade
[ "$status" -eq 0 ]
[[ "$output" == *"v0.1.0.0 -> v0.2.0.0"* ]] || false
[[ "$output" == *"still ahead"* ]] || false
}

106
test/version.bats Normal file
View file

@ -0,0 +1,106 @@
#!/usr/bin/env bats
# EGB-713 version-skew nudge: writer-version stamp, numeric comparator, skew
# warning, `which` surface. bash 3.2: every standalone [[ ]] ends with || false.
load test_helper
VERSION_FILE() { echo "$(cd "$(dirname "$SECRETS_BIN")" && pwd)/VERSION"; }
# ─── comparator contract ──────────────────────────────────────────────
@test "version comparator orders 0.7.0.0 < 0.10.0.0 numerically (not lexically)" {
run bash -c '
_version_gt() {
local a="$1" b="$2" i ai bi; local -a af bf
IFS=. read -r -a af <<< "$a"; IFS=. read -r -a bf <<< "$b"
for i in 0 1 2 3; do
ai=${af[$i]:-0}; ai=${ai//[!0-9]/}; [ -n "$ai" ] || ai=0
bi=${bf[$i]:-0}; bi=${bi//[!0-9]/}; [ -n "$bi" ] || bi=0
if [ "$((10#$ai))" -gt "$((10#$bi))" ]; then return 0; fi
if [ "$((10#$ai))" -lt "$((10#$bi))" ]; then return 1; fi
done; return 1
}
_version_gt 0.10.0.0 0.7.0.0 && echo "10gt7"
_version_gt 0.7.0.0 0.10.0.0 || echo "7not_gt_10"
_version_gt 0.7.0.0 0.7.0.0 || echo "equal_not_gt"
_version_gt 1.0.0.0 0.9.9.9 && echo "major_wins"
'
[ "$status" -eq 0 ]
[[ "$output" == *"10gt7"* ]] || false
[[ "$output" == *"7not_gt_10"* ]] || false
[[ "$output" == *"equal_not_gt"* ]] || false
[[ "$output" == *"major_wins"* ]] || false
}
# ─── stamp on write ───────────────────────────────────────────────────
@test "push stamps the store writer-version with the client version" {
init_with_remote
create_project_dir wvstamp
"$SECRETS_BIN" push wvstamp >/dev/null 2>&1
[ -f "$SECRETS_DIR/.secrets-writer-version" ]
run cat "$SECRETS_DIR/.secrets-writer-version"
[ "$output" = "$(cat "$(VERSION_FILE)")" ]
}
@test "writer-version stamp is monotonic (a push never lowers a higher stamp)" {
init_with_remote
create_project_dir wvmono
printf '9.9.9.9\n' > "$SECRETS_DIR/.secrets-writer-version"
"$SECRETS_BIN" push wvmono >/dev/null 2>&1
run cat "$SECRETS_DIR/.secrets-writer-version"
[ "$output" = "9.9.9.9" ]
}
@test "writer-version stamp is committed, not gitignored" {
init_with_remote
create_project_dir wvcommit
"$SECRETS_BIN" push wvcommit >/dev/null 2>&1
run bash -c "git -C $SECRETS_DIR ls-files | grep -qx .secrets-writer-version"
[ "$status" -eq 0 ]
}
# ─── skew warning on command ──────────────────────────────────────────
@test "a store written by a newer version warns on a command (non-fatal)" {
init_with_remote
create_project_dir skewwarn
"$SECRETS_BIN" push skewwarn >/dev/null 2>&1
printf '99.0.0.0\n' > "$SECRETS_DIR/.secrets-writer-version"
run "$SECRETS_BIN" list
[ "$status" -eq 0 ]
[[ "$output" == *"last written by secrets v99.0.0.0"* ]] || false
[[ "$output" == *"Update your secrets tool"* ]] || false
}
@test "a store at the same/older version is silent" {
init_with_remote
create_project_dir noskew
"$SECRETS_BIN" push noskew >/dev/null 2>&1
run "$SECRETS_BIN" list
[ "$status" -eq 0 ]
[[ "$output" != *"Update your secrets tool"* ]] || false
}
@test "a store with no writer-version marker is silent (legacy store)" {
init_with_remote
create_project_dir legacynostamp
"$SECRETS_BIN" push legacynostamp >/dev/null 2>&1
rm -f "$SECRETS_DIR/.secrets-writer-version"
run "$SECRETS_BIN" list
[ "$status" -eq 0 ]
[[ "$output" != *"Update your secrets tool"* ]] || false
}
# ─── which surface ────────────────────────────────────────────────────
@test "which prints the store writer-version and a behind note" {
init_with_remote
create_project_dir whichwv
"$SECRETS_BIN" push whichwv >/dev/null 2>&1
printf '99.0.0.0\n' > "$SECRETS_DIR/.secrets-writer-version"
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"written-by: v99.0.0.0"* ]] || false
[[ "$output" == *"behind"* ]] || false
}