#!/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 `. 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 }