#!/bin/bash
# End-to-end test for QVLT v2 (QVLT2_SPEC.md §9.5) against a THROWAWAY vault
# (SECRETS_DIR + SECRETS_PASSPHRASE — no Touch ID, no real Keychain).
#
# Covers: fresh-vault-is-v2, single-key read/write, multi-line values, scoped
# keys, delete/has, gen, tamper detection, the v2 session broker (deny without
# agent, serve with grant, unknown-key, legacy-GET empty close, END), the exec
# broker fast path, v1→v2 migrate (vault + registry, backup), and rekey (stale
# sibling warning).
#
# IMPORTANT: never invokes `secrets lock`/`unlock` — those touch the REAL
# user Keychain even under SECRETS_DIR. Broker shutdown goes via END.
set -uo pipefail

BIN="$(cd "$(dirname "$0")/.." && pwd)/target/release/secrets"
V1BIN="$(cd "$(dirname "$0")/../.." && pwd)/secret_cli/target/release/secrets"
# Windows/git-bash: cargo emits secrets.exe.
[ -x "$BIN" ] || [ ! -x "$BIN.exe" ] || BIN="$BIN.exe"
[ -x "$V1BIN" ] || [ ! -x "$V1BIN.exe" ] || V1BIN="$V1BIN.exe"
[ -x "$BIN" ] || { echo "build first: cargo build --release"; exit 1; }

# Pick an interpreter that actually RUNS. On Windows `python3` on PATH is
# usually the Microsoft Store stub, which prints an ad and exits non-zero, so
# name-resolution alone isn't enough — probe each candidate.
PY=""
for cand in python3 python py; do
  if command -v "$cand" >/dev/null 2>&1 && "$cand" -c 'pass' >/dev/null 2>&1; then
    PY="$cand"; break
  fi
done
[ -n "$PY" ] || { echo "no working python interpreter found"; exit 1; }

# The session broker is Unix-only (unix sockets + setsid; see src/session.rs).
# On Windows its sections are SKIPPED rather than silently "passing".
BROKER_SUPPORTED=1
case "$(uname -s 2>/dev/null)" in
  MINGW*|MSYS*|CYGWIN*) BROKER_SUPPORTED=0 ;;
esac

TMP=$(mktemp -d)
cleanup() { command rm -rf "$TMP"; }   # throwaway mktemp dir only
trap cleanup EXIT

export SECRETS_DIR="$TMP/vault"
export SECRETS_PASSPHRASE="test-pass-123"
VAULT="$SECRETS_DIR/vault.qvlt"
SOCK="$SECRETS_DIR/session.sock"
LOG="$SECRETS_DIR/session.log"

PASS=0
step() { printf '\n== %s\n' "$*"; }
ok() { PASS=$((PASS+1)); echo "   ok: $*"; }
fail() { echo "FAIL: $*"; exit 1; }

py_send() { # py_send <socket> <line> — prints the raw response
  "$PY" - "$1" "$2" <<'EOF'
import socket, sys
s = socket.socket(socket.AF_UNIX)
s.settimeout(3)
try:
    s.connect(sys.argv[1])
    s.sendall(sys.argv[2].encode() + b"\n")
    data = b""
    while True:
        chunk = s.recv(4096)
        if not chunk:
            break
        data += chunk
    sys.stdout.buffer.write(data)
except Exception:
    pass
EOF
}

step "fresh vault is born v2"
printf 'hello-world' | "$BIN" set GREETING 2>/dev/null || fail "set"
[ "$(head -c 5 "$VAULT" | xxd -p)" = "51564c5402" ] || fail "magic/version != QVLT 0x02"
ok "QVLT v2 header"
[ "$("$BIN" get GREETING)" = "hello-world" ] || fail "round trip"
ok "get round-trips"

step "multi-line value survives"
printf 'line1\nline2' | "$BIN" set ML 2>/dev/null
[ "$("$BIN" get ML)" = "$(printf 'line1\nline2')" ] || fail "multi-line"
ok "multi-line intact"

step "scoped keys (project/KEY)"
printf 'proj-secret' | "$BIN" set KEY -p proj 2>/dev/null
[ "$("$BIN" get KEY -p proj)" = "proj-secret" ] || fail "scoped get"
ok "scoped round-trip"

step "has / delete via index and splice"
"$BIN" has GREETING >/dev/null || fail "has GREETING"
printf 'x' | "$BIN" set TODEL 2>/dev/null
"$BIN" delete TODEL 2>/dev/null || fail "delete"
"$BIN" has TODEL >/dev/null 2>&1 && fail "TODEL still present"
"$BIN" get TODEL >/dev/null 2>&1 && fail "TODEL still readable"
ok "delete removes record"

step "gen never prints, stores hex"
"$BIN" gen GENKEY --bytes 16 2>/dev/null | grep -q . && fail "gen printed to stdout"
[ "$("$BIN" get GENKEY | wc -c | tr -d ' ')" = "32" ] || fail "gen length"
ok "gen stored 32 hex chars, printed nothing"

step "tamper detection (flip one ciphertext byte)"
command cp "$VAULT" "$TMP/vault.bak"
"$PY" - "$VAULT" <<'EOF'
import sys
p = sys.argv[1]
d = bytearray(open(p, "rb").read())
d[len(d)//2] ^= 0xFF
open(p, "wb").write(d)
EOF
"$BIN" get GREETING >/dev/null 2>&1 && fail "tampered vault still readable"
command cp "$TMP/vault.bak" "$VAULT"
[ "$("$BIN" get GREETING)" = "hello-world" ] || fail "restore failed"
ok "tamper rejected, restore reads again"

# Project manifest used by BOTH the broker and the lease exec fast paths.
# Created before the broker block so it still exists when that block is
# skipped (Windows) and the lease section runs.
cat > "$TMP/.secrets.toml" <<'EOF'
[projects.proj]
secrets = ["KEY"]
EOF

step "session broker v2 — key server"
if [ "$BROKER_SUPPORTED" = 0 ]; then
  # Windows: the broker is a NAMED PIPE (src/session_win.rs) — same protocol and
  # the same shared enforcement pipeline, different transport. It gets its own
  # block rather than contorting the Unix one, so the Unix path above/below stays
  # exactly as it is on macOS.
  BINW=$(cygpath -w "$BIN")
  # Ancestry resolution needs a real process IMAGE named claude; a shell script
  # called `claude` does not rename the process on Windows.
  cp "$SYSTEMROOT/System32/cmd.exe" "$TMP/claude.exe" 2>/dev/null \
    || cp /c/Windows/System32/cmd.exe "$TMP/claude.exe"
  as_agent() { MSYS2_ARG_CONV_EXCL='*' "$TMP/claude.exe" /c "$@"; }

  py_send_win() { # py_send_win <pipe-name> <line>
    "$PY" - "$1" "$2" <<'PYEOF'
import sys
name, line = sys.argv[1], sys.argv[2]
try:
    f = open(name, 'r+b', buffering=0)
    f.write(line.encode() + b"\n")
    sys.stdout.buffer.write(f.read())
    f.close()
except Exception:
    pass
PYEOF
  }

  "$BIN" authorize claude proj 2>/dev/null || fail "authorize"
  "$BIN" session 1 >/dev/null 2>&1 || fail "session start"
  sleep 1
  grep -q "START lifetime=1m" "$LOG" 2>/dev/null || fail "broker did not bind"
  PIPE=$(grep 'PIPE ' "$LOG" | tail -1 | awk '{print $3}')
  [ -n "$PIPE" ] || fail "no pipe name recorded"
  ok "broker up (named pipe bound, owner-only DACL)"

  # An UNGRANTED project is DENIED before any vault lookup, and the answer is
  # `denied`, never `unknown-key` — no existence oracle.
  as_agent "$BINW" get KEY -p noproj >/dev/null 2>&1 && fail "ungranted project resolved?!"
  grep -q "DENY" "$LOG" || fail "no DENY audit line for ungranted project"
  grep -q "UNKNOWN.*noproj/KEY" "$LOG" && fail "existence oracle: unknown-key leaked"
  ok "ungranted project denied (no existence oracle)"

  # Granted agent → served, tap-free, audited.
  OUT=$(as_agent "$BINW" get KEY -p proj | tr -d '\r\n')
  [ "$OUT" = "proj-secret" ] || fail "agent get via broker (got '$OUT')"
  grep -q "SERVE agent=claude proj/KEY" "$LOG" || fail "no SERVE audit line"
  ok "granted agent served by broker (audited)"

  as_agent "$BINW" get NOPE -p proj >/dev/null 2>&1 && fail "NOPE resolved?!"
  grep -q "UNKNOWN agent=claude proj/NOPE" "$LOG" || fail "no UNKNOWN audit line"
  ok "unknown key surfaced as unknown-key (audited)"

  # exec broker fast path.
  PRINTENV=$(cygpath -w /usr/bin/printenv)
  OUT=$(cd "$TMP" && as_agent "$BINW" exec proj -- "$PRINTENV" KEY 2>"$TMP/exec.err" | tr -d '\r\n')
  [ "$OUT" = "proj-secret" ] || fail "exec injection via broker (got '$OUT')"
  grep -q "via session broker" "$TMP/exec.err" || fail "exec did not use the broker fast path"
  ok "exec fast path: tap-free, broker-checked injection"

  # Legacy v1 client sends bare GET → EMPTY response + close.
  RESP=$(py_send_win "$PIPE" "GET")
  [ -z "$RESP" ] || fail "legacy GET got a non-empty response: $RESP"
  ok "legacy bare GET → empty close"

  # Revoke → broker re-reads the registry per request → DENY again.
  BEFORE=$(grep -c "no grant" "$LOG")
  "$BIN" revoke claude proj 2>/dev/null || fail "revoke"
  as_agent "$BINW" get KEY -p proj >/dev/null 2>&1
  [ "$(grep -c "no grant" "$LOG")" -gt "$BEFORE" ] || fail "no post-revoke DENY"
  ok "revocation takes effect mid-session"

  # END shuts the broker down; afterwards nothing is listening.
  py_send_win "$PIPE" "END" >/dev/null
  sleep 1
  grep -q "END (requested)" "$LOG" || fail "END not received"
  grep -q "STOP" "$LOG" || fail "broker did not stop"
  ok "END terminates broker"
else
"$BIN" authorize claude proj 2>/dev/null || fail "authorize"
"$BIN" session 1 2>/dev/null || fail "session start"
sleep 1
[ -S "$SOCK" ] || fail "no session socket"
ok "broker up"

# An UNGRANTED project is DENIED — before any vault lookup, whether or not an
# agent resolves in the caller's ancestry (this harness itself may run under a
# 'claude' process, so the no-agent case can't be produced here; the no-grant
# ordering covers the same normative deny path and the no-oracle property:
# noproj/KEY doesn't exist, yet the answer is denied, not unknown-key).
"$BIN" get KEY -p noproj >/dev/null 2>&1 && fail "ungranted project resolved?!"
grep -q "DENY" "$LOG" && grep -q "noproj/KEY" "$LOG" || fail "no DENY audit line for ungranted project"
grep -q "UNKNOWN.*noproj/KEY" "$LOG" && fail "existence oracle: unknown-key leaked for ungranted project"
ok "ungranted project denied (no existence oracle)"

# Same request resolved as agent 'claude' → broker serves it. NOTE: the
# wrapper script does NOT rename the process (interpreter scripts run with
# comm=/bin/bash); resolution succeeds because this harness itself runs under
# a 'claude' ancestor. From a bare terminal these agent-resolution checks
# would not resolve an agent (see the §DENY comment above).
printf '#!/bin/bash\n"$@"\n' > "$TMP/claude"
chmod +x "$TMP/claude"
[ "$("$TMP/claude" "$BIN" get KEY -p proj)" = "proj-secret" ] || fail "agent get"
grep -q "SERVE agent=claude proj/KEY" "$LOG" || fail "no SERVE audit line"
ok "granted agent served by broker (audited)"

# Unknown key for a granted agent → ERR unknown-key (client falls back, then
# genuinely not found).
"$TMP/claude" "$BIN" get NOPE -p proj >/dev/null 2>&1 && fail "NOPE resolved?!"
grep -q "UNKNOWN agent=claude proj/NOPE" "$LOG" || fail "no UNKNOWN audit line"
ok "unknown key surfaced as unknown-key (audited)"

# exec broker fast path: manifest in cwd, all keys served → no passphrase path.
OUT=$("$TMP/claude" bash -c "cd \"$TMP\" && \"$BIN\" exec proj -- /usr/bin/printenv KEY" 2>"$TMP/exec.err")
[ "$OUT" = "proj-secret" ] || fail "exec injection via broker"
grep -q "via session broker" "$TMP/exec.err" || fail "exec did not use the broker fast path"
ok "exec fast path: tap-free, broker-checked injection"

# Legacy v1 client sends bare GET → EMPTY response + close (never an error
# string a v1 client would mistake for a passphrase).
RESP=$(py_send "$SOCK" "GET")
[ -z "$RESP" ] || fail "legacy GET got a non-empty response: $RESP"
ok "legacy bare GET → empty close"

# Revoke → broker re-reads the registry per request → DENY again.
"$BIN" revoke claude proj 2>/dev/null || fail "revoke"
"$TMP/claude" "$BIN" get KEY -p proj >/dev/null 2>&1 # falls back to env pass
grep -q "DENY agent=claude (no grant) proj/KEY" "$LOG" || fail "no post-revoke DENY"
ok "revocation takes effect mid-session"

# END shuts the broker down and removes the socket.
py_send "$SOCK" "END" >/dev/null
sleep 1
[ -S "$SOCK" ] && fail "socket survived END"
ok "END terminates broker"
fi

step "project lease — one approval, tap-free TTL window (LEASE_DESIGN.md)"
# File keystore = the explicit test mode (no Keychain, unsigned binary OK).
export SECRETS_LEASE_KEYSTORE=file
LEASE="$SECRETS_DIR/leases/proj.lease"
LLOG="$SECRETS_DIR/lease.log"

# Create (the passphrase env stands in for the tap), UNBOUND (--any-agent) so
# the serve checks hold whether or not this harness runs under an agent
# ancestor. Binding enforcement is checked separately below (and in the
# per-caller unit tests, where the caller identity is explicit).
(cd "$TMP" && "$BIN" lease create proj --ttl 5m --any-agent) 2>"$TMP/lease.err" \
  || fail "lease create: $(cat "$TMP/lease.err")"
[ -f "$LEASE" ] || fail "no lease file written"
grep -q "proj-secret" "$LEASE" && fail "lease file contains PLAINTEXT"
ok "lease created, file is ciphertext"

# Reads are NOT gated: no passphrase, no broker, no keychain — the lease serves.
[ "$(env -u SECRETS_PASSPHRASE "$BIN" lease get proj KEY)" = "proj-secret" ] \
  || fail "lease get (explicit)"
[ "$(env -u SECRETS_PASSPHRASE "$BIN" get KEY -p proj)" = "proj-secret" ] \
  || fail "get -p did not fall through to the lease"
grep -q "SERVE project=proj key=KEY" "$LLOG" || fail "no SERVE audit line"
ok "tap-free reads via lease (explicit + transparent), audited"

# exec fast path through the lease (broker is dead — END'd above).
OUT=$(env -u SECRETS_PASSPHRASE bash -c \
  "cd \"$TMP\" && \"$BIN\" exec proj -- /usr/bin/printenv KEY" 2>"$TMP/lexec.err")
[ "$OUT" = "proj-secret" ] || fail "exec injection via lease"
grep -q "via project lease" "$TMP/lexec.err" || fail "exec did not use the lease fast path"
ok "exec fast path: tap-free, lease-served injection"

# Agent binding: a lease bound to 'grok' denies this caller (which resolves as
# 'claude' under the harness, or as no agent from a bare terminal — either way
# ≠ grok). Soft layer, but enforced + audited.
(cd "$TMP" && "$BIN" lease create proj --ttl 5m --agent grok) 2>/dev/null || fail "bound lease"
env -u SECRETS_PASSPHRASE "$BIN" lease get proj KEY >/dev/null 2>&1 \
  && fail "caller not matching the binding read a leased value"
grep -Eq "DENY project=proj key=KEY caller=.* bound=grok" "$LLOG" || fail "no DENY audit line"
(cd "$TMP" && "$BIN" lease create proj --ttl 5m --any-agent) 2>/dev/null || fail "re-unbind"
ok "agent binding denies other callers"

# Keys outside the approved set are never served.
env -u SECRETS_PASSPHRASE "$BIN" lease get proj GREETING >/dev/null 2>&1 \
  && fail "lease served a key outside the approved set"
ok "not-in-lease key refused"

# Tamper: flip a ciphertext byte → verification fails, nothing served.
command cp "$LEASE" "$TMP/lease.bak"
"$PY" - "$LEASE" <<'EOF'
import sys
p = sys.argv[1]
d = bytearray(open(p, "rb").read())
d[len(d)//2] ^= 0xFF
open(p, "wb").write(d)
EOF
env -u SECRETS_PASSPHRASE "$BIN" lease get proj KEY >/dev/null 2>&1 \
  && fail "tampered lease still served"
command cp "$TMP/lease.bak" "$LEASE"
ok "tampered lease refused"

# TTL is enforced ON READ: the expired lease is destroyed by its first touch.
(cd "$TMP" && "$BIN" lease create proj --ttl 1s --any-agent) 2>/dev/null || fail "short lease"
sleep 2
env -u SECRETS_PASSPHRASE "$BIN" lease get proj KEY >/dev/null 2>&1 \
  && fail "expired lease still served"
[ -f "$LEASE" ] && fail "expired lease file survived the read"
[ -f "$SECRETS_DIR/leases/proj.key" ] && fail "expired lease key survived the read"
grep -q "EXPIRE project=proj" "$LLOG" || fail "no EXPIRE audit line"
ok "TTL enforced on read — lease self-destructed"

# Revoke: immediate destruction, no tap.
(cd "$TMP" && "$BIN" lease create proj --ttl 5m --any-agent) 2>/dev/null || fail "recreate"
"$BIN" lease status | grep -q "^proj: 1 key" || fail "status missing live lease"
"$BIN" lease revoke proj 2>/dev/null || fail "lease revoke"
[ -f "$LEASE" ] && fail "revoked lease file survived"
env -u SECRETS_PASSPHRASE "$BIN" lease get proj KEY >/dev/null 2>&1 \
  && fail "revoked lease still served"
ok "revoke destroys key + file"

# rekey revokes live leases (they served pre-rekey values tap-free).
(cd "$TMP" && "$BIN" lease create proj --ttl 5m --any-agent) 2>/dev/null || fail "lease for rekey"
"$BIN" rekey 2>"$TMP/rekey-lease.err" || fail "rekey with live lease"
grep -q "revoked 1 live lease" "$TMP/rekey-lease.err" || fail "rekey did not report lease revocation"
[ -f "$LEASE" ] && fail "lease survived rekey"
[ "$("$BIN" get GREETING)" = "hello-world" ] || fail "vault broken after rekey"
ok "rekey revokes leases, vault intact"

step "bare-name fallback — declared keys stored without -p still resolve"
# The documented default flow stores bare (`secrets set NAME`, gen, import)
# while the project surfaces used to look up ONLY `project/NAME` — so `has`
# said true and `lease create`/`exec` said "not in vault". Resolution is now
# scoped override first, bare shared value second, on every project surface.
printf 'bare-secret' | "$BIN" set BAREKEY 2>/dev/null
printf '\n[projects.barep]\nsecrets = ["BAREKEY"]\n' >> "$TMP/.secrets.toml"
"$BIN" authorize claude barep 2>/dev/null || true
"$BIN" has BAREKEY -p barep >/dev/null 2>"$TMP/has.err" || fail "has -p missed the bare entry"
grep -q "no scoped copy" "$TMP/has.err" || fail "has -p did not disclose bare provenance"
"$BIN" has BAREKEY -p barep --scoped-only >/dev/null 2>&1 \
  && fail "--scoped-only claimed a scoped copy that does not exist"
(cd "$TMP" && "$BIN" lease create barep --ttl 5m --any-agent) 2>"$TMP/bare.err" \
  || fail "lease create over a bare-stored key: $(cat "$TMP/bare.err")"
[ "$(env -u SECRETS_PASSPHRASE "$BIN" lease get barep BAREKEY)" = "bare-secret" ] \
  || fail "lease serve of bare-stored key"
OUT=$(env -u SECRETS_PASSPHRASE bash -c \
  "cd \"$TMP\" && \"$BIN\" exec barep -- /usr/bin/printenv BAREKEY" 2>/dev/null)
[ "$OUT" = "bare-secret" ] || fail "exec injection of bare-stored key (got '$OUT')"
# A scoped override written later must win over the shared bare value.
printf 'override-secret' | "$BIN" set BAREKEY -p barep 2>/dev/null
"$BIN" lease revoke barep >/dev/null 2>&1
[ "$("$BIN" get BAREKEY -p barep)" = "override-secret" ] || fail "scoped override did not win"
"$BIN" has BAREKEY -p barep --scoped-only >/dev/null 2>&1 || fail "--scoped-only missed the scoped copy"
OUT=$(cd "$TMP" && "$BIN" exec barep -- /usr/bin/printenv BAREKEY 2>/dev/null)
[ "$OUT" = "override-secret" ] || fail "exec did not prefer the scoped override (got '$OUT')"
[ "$("$BIN" get BAREKEY)" = "bare-secret" ] || fail "bare get changed by the override"
ok "bare-stored declared keys lease/exec/get; scoped override wins"

step "calendar window — the schedule is the boundary, refusal is oracle-free"
# (goal 32A45613) A windowed lease serves inside its declared hours and
# refuses outside them EXACTLY as if no lease existed.
printf 'win-secret' | "$BIN" set WINKEY 2>/dev/null
# A weekday 3 days out — never today, so the shut window is deterministically
# closed at any hour this test runs. BSD and GNU date spellings both tried.
FARDAY=$(date -u -v+3d +%a 2>/dev/null || date -u -d '+3 days' +%a)
FARDAY=$(printf '%s' "$FARDAY" | tr 'A-Z' 'a-z')
{
  printf '\n[projects.winopen]\nsecrets = ["WINKEY"]\n'
  printf 'window = { hours = "00:00-23:59", tz = "UTC", grace_before = "30s", grace_after = "30s" }\n'
  printf '\n[projects.winshut]\nsecrets = ["WINKEY"]\n'
  printf 'window = { hours = "12:00-12:01", tz = "UTC", days = "%s", grace_before = "0s", grace_after = "0s" }\n' "$FARDAY"
} >> "$TMP/.secrets.toml"
(cd "$TMP" && "$BIN" lease create winopen --ttl 5m --any-agent) 2>"$TMP/wo.err" \
  || fail "windowed lease create (open): $(cat "$TMP/wo.err")"
grep -q "inside" "$TMP/wo.err" || fail "create output did not name the window"
[ "$(env -u SECRETS_PASSPHRASE "$BIN" lease get winopen WINKEY)" = "win-secret" ] \
  || fail "in-window lease read"
"$BIN" lease status | grep winopen | grep -q "OPEN" || fail "status did not show the window open"
(cd "$TMP" && "$BIN" lease create winshut --ttl 5m --any-agent) >/dev/null 2>&1 \
  || fail "windowed lease create (shut)"
# The no-oracle property, proven literally: the out-of-window refusal and a
# genuinely-absent lease must be byte-identical once the project name is
# normalized — a difference is a probe's clock.
SHUT=$( (env -u SECRETS_PASSPHRASE "$BIN" lease get winshut WINKEY 2>&1; echo "exit=$?") | sed 's/winshut/PROJ/g')
NONE=$( (env -u SECRETS_PASSPHRASE "$BIN" lease get neverleased WINKEY 2>&1; echo "exit=$?") | sed 's/neverleased/PROJ/g')
[ "$SHUT" = "$NONE" ] || fail "out-of-window refusal differs from no-lease: '$SHUT' vs '$NONE'"
grep -q "DENY-WINDOW project=winshut" "$LLOG" || fail "no DENY-WINDOW audit line"
"$BIN" lease revoke winopen >/dev/null 2>&1
"$BIN" lease revoke winshut >/dev/null 2>&1
ok "window serves inside, refuses outside as no-lease (byte-identical), audited"

step "v1 → v2 migration"
if [ -x "$V1BIN" ]; then
  export SECRETS_DIR="$TMP/v1vault"
  VAULT1="$SECRETS_DIR/vault.qvlt"
  printf 'old-value' | "$V1BIN" set OLDKEY 2>/dev/null || fail "v1 set"
  "$V1BIN" authorize claude legacyproj 2>/dev/null || fail "v1 authorize"
  [ "$(head -c 5 "$VAULT1" | xxd -p)" = "51564c5401" ] || fail "not a v1 vault"
  "$BIN" migrate 2>"$TMP/migrate.err" || fail "migrate"
  [ "$(head -c 5 "$VAULT1" | xxd -p)" = "51564c5402" ] || fail "vault not upgraded"
  [ -f "$SECRETS_DIR/vault.qvlt.v1.bak" ] || fail "no v1 backup"
  [ "$("$BIN" get OLDKEY)" = "old-value" ] || fail "value lost in migration"
  "$BIN" list-projects 2>/dev/null | grep -q "claude → legacyproj" || fail "grant lost in migration"
  ok "vault + registry migrated, backup kept, values intact"

  step "rekey (fresh salt) + stale-sibling warning"
  "$BIN" rekey 2>"$TMP/rekey.err" || fail "rekey"
  [ "$("$BIN" get OLDKEY)" = "old-value" ] || fail "value lost in rekey"
  grep -q "STALE CIPHERTEXT" "$TMP/rekey.err" || fail "rekey did not warn about v1.bak"
  "$BIN" list-projects 2>/dev/null | grep -q "claude → legacyproj" || fail "registry lost in rekey"
  ok "rekey preserves data + registry, warns about stale v1 backup"
else
  echo "   (skip: v1 binary not found at $V1BIN)"
fi

printf '\nALL PASSED (%d checks)\n' "$PASS"
