secrets/test/secrets.bats
Brian Majewski 585367b9a6 Add support for package.json workspaces in secrets CLI
- Introduced `--workspaces` flag for `push` and `pull` commands to handle environment files in monorepos.
- Updated README and CLAUDE.md to reflect new workspace functionality and installation instructions.
- Enhanced test suite with cases for workspace operations, ensuring proper encryption and decryption of environment files.
- Improved error handling for missing package.json and workspaces field.
- Increased test coverage from 20 to 25 tests.
2026-03-23 17:01:22 -07:00

352 lines
9.7 KiB
Bash

#!/usr/bin/env bats
load test_helper
# ─── init ──────────────────────────────────────────────────────────────
@test "init creates repo with key and gitignore" {
run "$SECRETS_BIN" init
[ "$status" -eq 0 ]
[ -d "$SECRETS_DIR/.git" ]
[ -f "$SECRETS_DIR/key.txt" ]
[ -f "$SECRETS_DIR/.gitignore" ]
grep -q "key.txt" "$SECRETS_DIR/.gitignore"
grep -qF '!**/.env.*.age' "$SECRETS_DIR/.gitignore"
}
@test "init installs pre-commit hook" {
run "$SECRETS_BIN" init
[ "$status" -eq 0 ]
[ -x "$SECRETS_DIR/.git/hooks/pre-commit" ]
}
@test "init warns if already initialized" {
"$SECRETS_BIN" init >/dev/null 2>&1
local key_before
key_before=$(cat "$SECRETS_DIR/key.txt")
run "$SECRETS_BIN" init
[ "$status" -eq 1 ]
[[ "$output" == *"Already initialized"* ]]
# Key must not be overwritten
local key_after
key_after=$(cat "$SECRETS_DIR/key.txt")
[ "$key_before" = "$key_after" ]
}
@test "init fails without age" {
# Create a temp PATH without age
local fake_path="$TEST_TMPDIR/fake-bin"
mkdir -p "$fake_path"
ln -s "$(which git)" "$fake_path/git"
ln -s "$(which bash)" "$fake_path/bash"
ln -s "$(which mkdir)" "$fake_path/mkdir"
ln -s "$(which cat)" "$fake_path/cat"
ln -s "$(which chmod)" "$fake_path/chmod"
ln -s "$(which cp)" "$fake_path/cp"
ln -s "$(which basename)" "$fake_path/basename"
ln -s "$(which dirname)" "$fake_path/dirname"
ln -s "$(which cd)" "$fake_path/cd" 2>/dev/null || true
run env PATH="$fake_path" "$SECRETS_BIN" init
[ "$status" -eq 1 ]
[[ "$output" == *"age"* ]]
}
# ─── push ──────────────────────────────────────────────────────────────
@test "push encrypts .env files" {
init_with_remote
create_project_dir testproj
run "$SECRETS_BIN" push testproj
[ "$status" -eq 0 ]
[ -f "$SECRETS_DIR/testproj/.env.age" ]
[ -f "$SECRETS_DIR/testproj/.env.staging.age" ]
}
@test "push errors with no .env files" {
init_with_remote
mkdir -p "$WORK_DIR/empty"
cd "$WORK_DIR/empty"
run "$SECRETS_BIN" push testproj
[ "$status" -eq 1 ]
[[ "$output" == *"No .env"* ]]
}
@test "push errors with missing key" {
init_with_remote
create_project_dir testproj
rm "$SECRETS_DIR/key.txt"
run "$SECRETS_BIN" push testproj
[ "$status" -eq 1 ]
[[ "$output" == *"Key file"* ]]
}
@test "push derives project name from dirname" {
init_with_remote
create_project_dir myproject
# Don't pass explicit project name
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
[ -d "$SECRETS_DIR/myproject" ]
}
@test "push succeeds on repeated push (age is non-deterministic)" {
init_with_remote
create_project_dir testproj
"$SECRETS_BIN" push testproj >/dev/null 2>&1
# Push again — age produces different ciphertext each time, so this creates a new commit
run "$SECRETS_BIN" push testproj
[ "$status" -eq 0 ]
}
# ─── pull ──────────────────────────────────────────────────────────────
@test "pull decrypts files correctly" {
init_with_remote
create_project_dir testproj
"$SECRETS_BIN" push testproj >/dev/null 2>&1
# Pull into a different directory
local pull_dir="$WORK_DIR/pull-target"
mkdir -p "$pull_dir"
cd "$pull_dir"
run "$SECRETS_BIN" pull testproj
[ "$status" -eq 0 ]
[ -f "$pull_dir/.env" ]
[ -f "$pull_dir/.env.staging" ]
[ "$(cat "$pull_dir/.env")" = "SECRET_KEY=abc123" ]
[ "$(cat "$pull_dir/.env.staging")" = "DB_HOST=staging.db.example.com" ]
}
@test "pull errors for nonexistent project" {
init_with_remote
run "$SECRETS_BIN" pull nonexistent
[ "$status" -eq 1 ]
[[ "$output" == *"not found"* ]]
}
@test "pull errors with missing key" {
init_with_remote
create_project_dir testproj
"$SECRETS_BIN" push testproj >/dev/null 2>&1
rm "$SECRETS_DIR/key.txt"
local pull_dir="$WORK_DIR/pull-target"
mkdir -p "$pull_dir"
cd "$pull_dir"
run "$SECRETS_BIN" pull testproj
[ "$status" -eq 1 ]
[[ "$output" == *"Key file"* ]]
}
@test "pull overwrites existing files" {
init_with_remote
create_project_dir testproj
"$SECRETS_BIN" push testproj >/dev/null 2>&1
local pull_dir="$WORK_DIR/pull-target"
mkdir -p "$pull_dir"
echo "OLD_VALUE=stale" > "$pull_dir/.env"
cd "$pull_dir"
run "$SECRETS_BIN" pull testproj
[ "$status" -eq 0 ]
[ "$(cat "$pull_dir/.env")" = "SECRET_KEY=abc123" ]
}
@test "pull reinstalls missing pre-commit hook" {
init_with_remote
create_project_dir testproj
"$SECRETS_BIN" push testproj >/dev/null 2>&1
# Remove the hook
rm -f "$SECRETS_DIR/.git/hooks/pre-commit"
[ ! -f "$SECRETS_DIR/.git/hooks/pre-commit" ]
local pull_dir="$WORK_DIR/pull-target"
mkdir -p "$pull_dir"
cd "$pull_dir"
run "$SECRETS_BIN" pull testproj
[ "$status" -eq 0 ]
[ -x "$SECRETS_DIR/.git/hooks/pre-commit" ]
[[ "$output" == *"Reinstalled"* ]]
}
# ─── list ──────────────────────────────────────────────────────────────
@test "list shows projects and files" {
init_with_remote
create_project_dir projA
"$SECRETS_BIN" push projA >/dev/null 2>&1
create_project_dir projB
"$SECRETS_BIN" push projB >/dev/null 2>&1
run "$SECRETS_BIN" list
[ "$status" -eq 0 ]
[[ "$output" == *"projA"* ]]
[[ "$output" == *"projB"* ]]
}
@test "list shows empty message" {
"$SECRETS_BIN" init >/dev/null 2>&1
run "$SECRETS_BIN" list
[ "$status" -eq 0 ]
[[ "$output" == *"No projects"* ]]
}
# ─── rm ────────────────────────────────────────────────────────────────
@test "rm removes project from repo" {
init_with_remote
create_project_dir testproj
"$SECRETS_BIN" push testproj >/dev/null 2>&1
[ -d "$SECRETS_DIR/testproj" ]
run "$SECRETS_BIN" rm testproj
[ "$status" -eq 0 ]
[ ! -d "$SECRETS_DIR/testproj" ]
}
@test "rm errors for nonexistent project" {
init_with_remote
run "$SECRETS_BIN" rm nonexistent
[ "$status" -eq 1 ]
[[ "$output" == *"not found"* ]]
}
# ─── pre-commit hook ──────────────────────────────────────────────────
@test "pre-commit blocks plaintext env files" {
init_with_remote
cd "$SECRETS_DIR"
echo "LEAKED=true" > .env.test
git add -f .env.test
run git commit -m "should fail"
[ "$status" -eq 1 ]
[[ "$output" == *"Plaintext"* ]]
}
@test "pre-commit allows .age files" {
init_with_remote
cd "$SECRETS_DIR"
mkdir -p testproj
echo "encrypted-blob" > testproj/.env.test.age
git add testproj/.env.test.age
run git commit -m "should succeed"
[ "$status" -eq 0 ]
}
# ─── workspaces ────────────────────────────────────────────────────────
# Helper: create a monorepo with package.json workspaces
create_monorepo() {
local dir="$WORK_DIR/myapp"
mkdir -p "$dir/apps/web" "$dir/apps/api" "$dir/packages/auth"
cat > "$dir/package.json" << 'PKGJSON'
{
"name": "myapp",
"workspaces": ["apps/*", "packages/*"]
}
PKGJSON
# Root env
echo "ROOT_SECRET=top" > "$dir/.env"
# Workspace envs
echo "WEB_DB=webdb" > "$dir/apps/web/.env.staging"
echo "API_KEY=abc" > "$dir/apps/api/.env"
# packages/auth has no .env — should be skipped silently
# Init a git repo so derive_project_name can use dirname
git init "$dir" >/dev/null 2>&1
echo "$dir"
}
@test "push --workspaces encrypts root and workspace env files" {
init_with_remote
local mono
mono=$(create_monorepo)
cd "$mono"
run "$SECRETS_BIN" push --workspaces
[ "$status" -eq 0 ]
# Root env
[ -f "$SECRETS_DIR/myapp/.env.age" ]
# Workspace envs
[ -f "$SECRETS_DIR/myapp/apps/web/.env.staging.age" ]
[ -f "$SECRETS_DIR/myapp/apps/api/.env.age" ]
# packages/auth should NOT have a dir (no .env files)
[ ! -d "$SECRETS_DIR/myapp/packages/auth" ]
}
@test "pull --workspaces decrypts into correct directories" {
init_with_remote
local mono
mono=$(create_monorepo)
cd "$mono"
"$SECRETS_BIN" push --workspaces >/dev/null 2>&1
# Remove the original env files
rm "$mono/.env" "$mono/apps/web/.env.staging" "$mono/apps/api/.env"
run "$SECRETS_BIN" pull --workspaces
[ "$status" -eq 0 ]
# Verify decrypted into correct locations
[ "$(cat "$mono/.env")" = "ROOT_SECRET=top" ]
[ "$(cat "$mono/apps/web/.env.staging")" = "WEB_DB=webdb" ]
[ "$(cat "$mono/apps/api/.env")" = "API_KEY=abc" ]
}
@test "push --workspaces errors without package.json" {
init_with_remote
mkdir -p "$WORK_DIR/nopkg"
cd "$WORK_DIR/nopkg"
run "$SECRETS_BIN" push --workspaces
[ "$status" -eq 1 ]
[[ "$output" == *"No package.json"* ]]
}
@test "push --workspaces errors without workspaces field" {
init_with_remote
mkdir -p "$WORK_DIR/nows"
echo '{"name": "nows"}' > "$WORK_DIR/nows/package.json"
cd "$WORK_DIR/nows"
run "$SECRETS_BIN" push --workspaces
[ "$status" -eq 1 ]
[[ "$output" == *"No workspaces"* ]]
}
@test "push --workspaces errors when no env files anywhere" {
init_with_remote
local dir="$WORK_DIR/empty-mono"
mkdir -p "$dir/apps/web" "$dir/packages/lib"
cat > "$dir/package.json" << 'EOF'
{"workspaces": ["apps/*", "packages/*"]}
EOF
git init "$dir" >/dev/null 2>&1
cd "$dir"
run "$SECRETS_BIN" push --workspaces
[ "$status" -eq 1 ]
[[ "$output" == *"No .env files"* ]]
}