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