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:
commit
e29024bd63
16 changed files with 2723 additions and 142 deletions
72
test/install.bats
Normal file
72
test/install.bats
Normal 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
145
test/join.bats
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
115
test/upgrade.bats
Normal 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
106
test/version.bats
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue