diff options
Diffstat (limited to 'secrets/test')
| -rw-r--r-- | secrets/test/README.md | 148 | ||||
| -rwxr-xr-x | secrets/test/common.sh | 108 | ||||
| -rwxr-xr-x | secrets/test/create-local.sh | 122 | ||||
| -rwxr-xr-x | secrets/test/edge-cases.sh | 100 | ||||
| -rwxr-xr-x | secrets/test/ensure.sh | 29 | ||||
| -rwxr-xr-x | secrets/test/get.sh | 44 | ||||
| -rwxr-xr-x | secrets/test/list.sh | 55 | ||||
| -rwxr-xr-x | secrets/test/local.sh | 132 | ||||
| -rwxr-xr-x | secrets/test/main.sh | 57 | ||||
| -rwxr-xr-x | secrets/test/paths.sh | 119 | ||||
| -rwxr-xr-x | secrets/test/put.sh | 34 | ||||
| -rwxr-xr-x | secrets/test/ready.sh | 27 | ||||
| -rwxr-xr-x | secrets/test/rotate.sh | 69 | ||||
| -rwxr-xr-x | secrets/test/run.sh | 82 |
14 files changed, 1126 insertions, 0 deletions
diff --git a/secrets/test/README.md b/secrets/test/README.md new file mode 100644 index 0000000..49d70b2 --- /dev/null +++ b/secrets/test/README.md @@ -0,0 +1,148 @@ +# szecret Test Suite + +Unit tests for the szecret secret management system. + +## Running Tests + +```bash +# Run all tests +./test/run.sh + +# Run specific test files +./test/run.sh list get + +# Run a single test file directly +./test/list.sh +``` + +## Test Structure + +``` +test/ +├── common.sh # Shared utilities, setup/teardown, assertions +├── run.sh # Test runner entrypoint +├── create-local.sh # Tests for create-local command +├── edge-cases.sh # Edge case tests (special chars, unicode, etc.) +├── ensure.sh # Tests for ensure command +├── get.sh # Tests for get command +├── list.sh # Tests for list command +├── local.sh # Tests for put-local/get-local commands +├── main.sh # Tests for main entrypoint +├── paths.sh # Tests for where the store and public key are found +├── put.sh # Tests for put command +└── ready.sh # Tests for ready command +``` + +## Writing New Tests + +### Basic test file template + +```bash +#!/bin/bash +# Tests for <command> command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_something() { + # Test code here + pass "description of what passed" +} + +test_something_else() { + # Test code here + pass "another test" +} + +run_tests() { + test_something + test_something_else +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests +``` + +### Available helpers + +From `common.sh`: + +| Function | Description | +|----------|-------------| +| `szecret <cmd> [args]` | Run a szecret command via main.sh | +| `szecret_script <name> [args]` | Run a specific script directly (e.g., `szecret_script get secret-name`) | +| `pass <msg>` | Print green PASS message | +| `fail <msg>` | Print red FAIL message and exit | +| `skip <msg>` | Print yellow SKIP message | +| `assert_success <cmd>` | Assert command exits 0 | +| `assert_failure <cmd>` | Assert command exits non-zero | +| `assert_equals <expected> <actual> [msg]` | Assert string equality | +| `assert_contains <haystack> <needle> [msg]` | Assert string contains substring | +| `assert_file_exists <path> [msg]` | Assert file exists | + +### Environment variables + +Tests run in an isolated environment with these variables set: + +| Variable | Description | +|----------|-------------| +| `TEST_DIR` | Temporary directory for test artifacts | +| `SZECRET_STORE` | Points to `$TEST_DIR/store` | +| `SZECRET_PUBLIC_KEY` | Points to test public key | +| `SZECRET_PRIVATE_KEY` | Points to test private key | + +The test environment is created automatically when `common.sh` is sourced and cleaned up on exit. + +## Design Notes + +### Isolation + +Each test file runs in its own subprocess with a fresh environment. This ensures tests don't interfere with each other. The `run.sh` runner executes each test file as a separate process. + +### Auto-setup + +The `common.sh` file automatically runs `setup` and registers the `teardown` trap when first sourced. A guard variable (`_SZECRET_TEST_INITIALIZED`) prevents duplicate initialization if common.sh is sourced multiple times. + +### Configurable paths + +The szecret scripts support these environment variables for testing: + +- `SZECRET_STORE` - Override the store directory +- `SZECRET_PUBLIC_KEY` - Override the public key path +- `SZECRET_PRIVATE_KEY` - Override the private key path + +This allows tests to use a temporary directory without affecting the real secret store. + +## Known Limitations + +### Trailing newlines + +Bash command substitution `$()` strips trailing newlines. This means: + +```bash +# This will lose the trailing newline +retrieved="$(szecret get my-secret)" +``` + +This is bash behavior, not a szecret bug. The encrypted data preserves trailing newlines correctly. + +### Interactive commands + +The `ensure` command prompts for private key input when run interactively. This flow isn't fully tested since it requires TTY input. The tests verify it fails gracefully in non-interactive mode. + +### Binary data + +The tests don't currently cover binary data. Secrets are expected to be text (the encryption handles binary, but shell pipelines may have issues with null bytes). + +## Adding Tests for New Commands + +1. Create `test/<command>.sh` following the template above +2. Make it executable: `chmod +x test/<command>.sh` +3. The runner will automatically discover and run it + +## CI Integration + +The test suite exits with code 0 on success, non-zero on failure. Example CI usage: + +```bash +./secrets/test/run.sh || exit 1 +``` diff --git a/secrets/test/common.sh b/secrets/test/common.sh new file mode 100755 index 0000000..33d7d78 --- /dev/null +++ b/secrets/test/common.sh @@ -0,0 +1,108 @@ +# Common test utilities for szecret tests +# Source this file from individual test files + +set -euo pipefail + +SECRETS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +pass() { echo -e "${GREEN}PASS${NC}: $1"; } +fail() { echo -e "${RED}FAIL${NC}: $1"; exit 1; } +skip() { echo -e "${YELLOW}SKIP${NC}: $1"; } + +# Create temporary test environment +setup() { + export TEST_DIR="$(mktemp -d)" + export SZECRET_STORE="${TEST_DIR}/store" + export SZECRET_PUBLIC_KEY="${TEST_DIR}/.id.pub" + export SZECRET_PRIVATE_KEY="${TEST_DIR}/.id" + + mkdir -p "${SZECRET_STORE}" + + # Generate test keypair + age-keygen -o "${SZECRET_PRIVATE_KEY}" 2>/dev/null + age-keygen -y "${SZECRET_PRIVATE_KEY}" > "${SZECRET_PUBLIC_KEY}" +} + +teardown() { + if [[ -n "${TEST_DIR:-}" && -d "${TEST_DIR}" ]]; then + rm -rf "${TEST_DIR}" + fi +} + +# Run a command from the secrets directory +szecret() { + "${SECRETS_DIR}/main.sh" "$@" +} + +# Direct access to individual scripts (useful for testing specific scripts) +szecret_script() { + local script="$1" + shift + "${SECRETS_DIR}/${script}.sh" "$@" +} + +# Assert that a command succeeds +assert_success() { + if "$@"; then + return 0 + else + fail "Command failed: $*" + fi +} + +# Assert that a command fails +assert_failure() { + if "$@" 2>/dev/null; then + fail "Command should have failed: $*" + else + return 0 + fi +} + +# Assert string equality +assert_equals() { + local expected="$1" + local actual="$2" + local msg="${3:-values}" + if [[ "${expected}" == "${actual}" ]]; then + return 0 + else + fail "${msg}: expected '${expected}', got '${actual}'" + fi +} + +# Assert string contains substring +assert_contains() { + local haystack="$1" + local needle="$2" + local msg="${3:-string}" + if [[ "${haystack}" == *"${needle}"* ]]; then + return 0 + else + fail "${msg}: '${haystack}' does not contain '${needle}'" + fi +} + +# Assert file exists +assert_file_exists() { + local file="$1" + local msg="${2:-file}" + if [[ -f "${file}" ]]; then + return 0 + else + fail "${msg}: file '${file}' does not exist" + fi +} + +# Auto-setup: only run once per process +if [[ -z "${_SZECRET_TEST_INITIALIZED:-}" ]]; then + export _SZECRET_TEST_INITIALIZED=1 + trap teardown EXIT + setup +fi diff --git a/secrets/test/create-local.sh b/secrets/test/create-local.sh new file mode 100755 index 0000000..310ba23 --- /dev/null +++ b/secrets/test/create-local.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# Tests for create-local command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_create_local() { + local target_dir="${TEST_DIR}/local-store-dir" + szecret create-local "test-local-store" "${target_dir}" + + assert_file_exists "${SZECRET_STORE}/local-key-test-local-store.age" "local key file" + assert_file_exists "${target_dir}/.szecret" "metadata file" + + # Check metadata format + assert_contains "$(cat "${target_dir}/.szecret")" "store=test-local-store" "store in metadata" + assert_contains "$(cat "${target_dir}/.szecret")" "pubkey=age1" "pubkey in metadata" + pass "create-local creates local key and metadata" +} + +test_create_local_creates_directory() { + local target_dir="${TEST_DIR}/nested/new/directory" + szecret create-local "nested-store" "${target_dir}" + + assert_file_exists "${target_dir}/.szecret" "metadata file in created directory" + pass "create-local creates target directory" +} + +test_create_local_migration() { + # Simulate existing store without metadata (old-style) + local dir1="${TEST_DIR}/dir1" + local dir2="${TEST_DIR}/dir2" + + # Create store with first directory + szecret create-local "migration-store" "${dir1}" + + # Now "migrate" to a second directory - should just add metadata + szecret create-local "migration-store" "${dir2}" + + assert_file_exists "${dir2}/.szecret" "metadata in second directory" + + # Check metadata format and that pubkey is same in both dirs + assert_contains "$(cat "${dir2}/.szecret")" "store=migration-store" "store in metadata" + + local pubkey1 pubkey2 + pubkey1="$(grep pubkey "${dir1}/.szecret" | cut -d= -f2)" + pubkey2="$(grep pubkey "${dir2}/.szecret" | cut -d= -f2)" + assert_equals "${pubkey1}" "${pubkey2}" "pubkey matches across directories" + pass "create-local adds metadata for existing key (migration)" +} + +test_create_local_migration_preserves_key() { + local target_dir="${TEST_DIR}/preserve-key" + szecret create-local "preserve-store" "${target_dir}" + + # Encrypt a secret + local secret_value="test-secret-value" + echo "${secret_value}" | szecret put-local "${target_dir}/secret.age" + + # Remove metadata to simulate old-style store + rm "${target_dir}/.szecret" + + # Run create-local again - should add metadata without regenerating key + szecret create-local "preserve-store" "${target_dir}" + + # Verify we can still decrypt - proves key wasn't overwritten + local retrieved + retrieved="$(szecret get-local "${target_dir}/secret.age")" + assert_equals "${secret_value}" "${retrieved}" "decrypted value after migration" + pass "create-local migration preserves existing key" +} + +test_create_local_reuses_key_across_directories() { + local dir1="${TEST_DIR}/multi-dir-1" + local dir2="${TEST_DIR}/multi-dir-2" + + # Create store with first directory and encrypt a secret + szecret create-local "shared-store" "${dir1}" + local secret_value="shared-secret" + echo "${secret_value}" | szecret put-local "${dir1}/secret.age" + + # Add same store to second directory + szecret create-local "shared-store" "${dir2}" + + # Verify secret in dir1 still decrypts - proves key was reused not overwritten + local retrieved + retrieved="$(szecret get-local "${dir1}/secret.age")" + assert_equals "${secret_value}" "${retrieved}" "secret from first directory" + + # Verify we can also encrypt/decrypt in dir2 with same key + echo "dir2-secret" | szecret put-local "${dir2}/secret.age" + retrieved="$(szecret get-local "${dir2}/secret.age")" + assert_equals "dir2-secret" "${retrieved}" "secret from second directory" + + pass "create-local reuses key across directories" +} + +test_create_local_existing_metadata_fails() { + local target_dir="${TEST_DIR}/existing-metadata" + mkdir -p "${target_dir}" + echo "other-store" > "${target_dir}/.szecret" + + assert_failure szecret create-local "new-store" "${target_dir}" + pass "create-local fails if metadata exists" +} + +test_create_local_requires_args() { + assert_failure szecret create-local + assert_failure szecret create-local "store-only" + pass "create-local requires store name and directory" +} + +run_tests() { + test_create_local + test_create_local_creates_directory + test_create_local_migration + test_create_local_migration_preserves_key + test_create_local_reuses_key_across_directories + test_create_local_existing_metadata_fails + test_create_local_requires_args +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/edge-cases.sh b/secrets/test/edge-cases.sh new file mode 100755 index 0000000..ab1a56e --- /dev/null +++ b/secrets/test/edge-cases.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Edge case tests + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_special_characters_in_value() { + local secret_value='!@#$%^&*()_+-=[]{}|;:",.<>?/`~'"'" + echo "${secret_value}" | szecret put "special-chars" + + local retrieved + retrieved="$(szecret get "special-chars")" + assert_equals "${secret_value}" "${retrieved}" "special characters" + pass "handles special characters in value" +} + +test_whitespace_in_value() { + local secret_value=$' leading and trailing spaces ' + echo "${secret_value}" | szecret put "whitespace" + + local retrieved + retrieved="$(szecret get "whitespace")" + assert_equals "${secret_value}" "${retrieved}" "whitespace" + pass "preserves whitespace in value" +} + +test_empty_value() { + echo -n "" | szecret put "empty-secret" + + local retrieved + retrieved="$(szecret get "empty-secret")" + assert_equals "" "${retrieved}" "empty value" + pass "handles empty value" +} + +test_multiline_value() { + # Note: trailing newlines are stripped by bash $() substitution + # This is bash behavior, not a szecret limitation + local secret_value=$'line1\nline2\n\nline4 with spaces' + echo "${secret_value}" | szecret put "multiline" + + local retrieved + retrieved="$(szecret get "multiline")" + assert_equals "${secret_value}" "${retrieved}" "multiline value" + pass "handles multiline value with blank lines" +} + +test_unicode_in_value() { + local secret_value='Hello 世界 🔐 émoji café' + echo "${secret_value}" | szecret put "unicode" + + local retrieved + retrieved="$(szecret get "unicode")" + assert_equals "${secret_value}" "${retrieved}" "unicode value" + pass "handles unicode in value" +} + +test_secret_name_with_dashes() { + echo "value" | szecret put "my-secret-with-dashes" + assert_success szecret get "my-secret-with-dashes" >/dev/null + pass "handles dashes in secret name" +} + +test_secret_name_with_underscores() { + echo "value" | szecret put "my_secret_with_underscores" + assert_success szecret get "my_secret_with_underscores" >/dev/null + pass "handles underscores in secret name" +} + +test_secret_name_with_dots() { + echo "value" | szecret put "my.secret.with.dots" + assert_success szecret get "my.secret.with.dots" >/dev/null + pass "handles dots in secret name" +} + +test_long_value() { + # Generate a long value (10KB) + local secret_value + secret_value="$(head -c 10240 /dev/urandom | base64)" + echo "${secret_value}" | szecret put "long-secret" + + local retrieved + retrieved="$(szecret get "long-secret")" + assert_equals "${secret_value}" "${retrieved}" "long value" + pass "handles long value (10KB)" +} + +run_tests() { + test_special_characters_in_value + test_whitespace_in_value + test_empty_value + test_multiline_value + test_unicode_in_value + test_secret_name_with_dashes + test_secret_name_with_underscores + test_secret_name_with_dots + test_long_value +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/ensure.sh b/secrets/test/ensure.sh new file mode 100755 index 0000000..720d328 --- /dev/null +++ b/secrets/test/ensure.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Tests for ensure command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_ensure_succeeds_when_key_exists() { + # Key already exists from setup, should succeed silently + assert_success szecret ensure + pass "ensure succeeds when key exists" +} + +test_ensure_fails_noninteractive_without_key() { + local saved_key="${SZECRET_PRIVATE_KEY}" + export SZECRET_PRIVATE_KEY="${TEST_DIR}/nonexistent-key" + + # Force non-interactive mode by redirecting stdin from /dev/null + assert_failure szecret ensure < /dev/null + + export SZECRET_PRIVATE_KEY="${saved_key}" + pass "ensure fails non-interactive without key" +} + +run_tests() { + test_ensure_succeeds_when_key_exists + test_ensure_fails_noninteractive_without_key +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/get.sh b/secrets/test/get.sh new file mode 100755 index 0000000..eb056d9 --- /dev/null +++ b/secrets/test/get.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Tests for get command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_get_retrieves_secret() { + local secret_value="my-secret-value-123" + echo "${secret_value}" | szecret put "test-secret" + + local retrieved + retrieved="$(szecret get "test-secret")" + assert_equals "${secret_value}" "${retrieved}" "retrieved value" + pass "get retrieves secret" +} + +test_get_nonexistent_fails() { + assert_failure szecret get "nonexistent-secret" + pass "get nonexistent returns error" +} + +test_get_requires_name() { + assert_failure szecret get + pass "get requires secret name" +} + +test_get_preserves_newlines() { + local secret_value=$'line1\nline2\nline3' + echo "${secret_value}" | szecret put "multiline-secret" + + local retrieved + retrieved="$(szecret get "multiline-secret")" + assert_equals "${secret_value}" "${retrieved}" "multiline value" + pass "get preserves newlines" +} + +run_tests() { + test_get_retrieves_secret + test_get_nonexistent_fails + test_get_requires_name + test_get_preserves_newlines +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/list.sh b/secrets/test/list.sh new file mode 100755 index 0000000..08671c8 --- /dev/null +++ b/secrets/test/list.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Tests for list command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_list_shows_secrets() { + echo "value1" | szecret put "secret-a" + echo "value2" | szecret put "secret-b" + echo "value3" | szecret put "secret-c" + + local list_output + list_output="$(szecret list)" + + assert_contains "${list_output}" "secret-a" "list output" + assert_contains "${list_output}" "secret-b" "list output" + assert_contains "${list_output}" "secret-c" "list output" + pass "list shows all secrets" +} + +test_list_empty_store() { + local list_output + list_output="$(szecret list)" + assert_equals "" "${list_output}" "empty list" + pass "list handles empty store" +} + +test_list_sorted() { + echo "v" | szecret put "zebra" + echo "v" | szecret put "apple" + echo "v" | szecret put "mango" + + local list_output + list_output="$(szecret list)" + + # Check that apple comes before mango comes before zebra + local apple_pos mango_pos zebra_pos + apple_pos=$(echo "${list_output}" | grep -n "apple" | cut -d: -f1) + mango_pos=$(echo "${list_output}" | grep -n "mango" | cut -d: -f1) + zebra_pos=$(echo "${list_output}" | grep -n "zebra" | cut -d: -f1) + + if [[ "${apple_pos}" -lt "${mango_pos}" && "${mango_pos}" -lt "${zebra_pos}" ]]; then + pass "list is sorted" + else + fail "list is not sorted: apple=${apple_pos}, mango=${mango_pos}, zebra=${zebra_pos}" + fi +} + +run_tests() { + test_list_empty_store + test_list_shows_secrets + test_list_sorted +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/local.sh b/secrets/test/local.sh new file mode 100755 index 0000000..e8d174b --- /dev/null +++ b/secrets/test/local.sh @@ -0,0 +1,132 @@ +#!/bin/bash +# Tests for put-local and get-local commands + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_put_local_and_get_local() { + local target_dir="${TEST_DIR}/secrets" + szecret create-local "test-store" "${target_dir}" + + local secret_value="local-secret-value-456" + local encrypted_file="${target_dir}/secret.age" + + echo "${secret_value}" | szecret put-local "${encrypted_file}" + + assert_file_exists "${encrypted_file}" "encrypted file" + + local retrieved + retrieved="$(szecret get-local "${encrypted_file}")" + assert_equals "${secret_value}" "${retrieved}" "local secret value" + pass "put-local and get-local" +} + +test_put_local_no_metadata_fails() { + local target_dir="${TEST_DIR}/no-metadata" + mkdir -p "${target_dir}" + + assert_failure bash -c "echo 'value' | szecret put-local '${target_dir}/secret.age'" + pass "put-local fails without metadata" +} + +test_get_local_no_metadata_fails() { + local target_dir="${TEST_DIR}/no-metadata-get" + mkdir -p "${target_dir}" + touch "${target_dir}/fake.age" + + assert_failure szecret get-local "${target_dir}/fake.age" + pass "get-local fails without metadata" +} + +test_get_local_legacy_syntax_warns() { + local target_dir="${TEST_DIR}/legacy-test" + szecret create-local "legacy-store" "${target_dir}" + + local secret_value="legacy-value" + echo "${secret_value}" | szecret put-local "${target_dir}/secret.age" + + # Use legacy 2-arg syntax - should warn even with metadata present + local stderr_output + stderr_output="$(szecret get-local "legacy-store" "${target_dir}/secret.age" 2>&1 >/dev/null)" + + assert_contains "${stderr_output}" "WARN" "deprecation warning" + assert_contains "${stderr_output}" "deprecated" "deprecation message" + + # Should still work despite warning + local retrieved + retrieved="$(szecret get-local "legacy-store" "${target_dir}/secret.age" 2>/dev/null)" + assert_equals "${secret_value}" "${retrieved}" "legacy syntax result" + pass "get-local legacy syntax warns but works" +} + +test_get_local_nonexistent_file_fails() { + local target_dir="${TEST_DIR}/nonexistent-file" + szecret create-local "file-test-store" "${target_dir}" + + assert_failure szecret get-local "${target_dir}/nonexistent.age" + pass "get-local fails for nonexistent file" +} + +test_put_local_requires_arg() { + assert_failure szecret put-local + pass "put-local requires file path" +} + +test_get_local_requires_arg() { + assert_failure szecret get-local + pass "get-local requires file path" +} + +test_put_local_pubkey_mismatch_fails() { + local target_dir="${TEST_DIR}/pubkey-mismatch-put" + szecret create-local "mismatch-put-store" "${target_dir}" + + # Corrupt the pubkey in metadata + sed -i.bak 's/pubkey=.*/pubkey=age1invalid000000000000000000000000000000000000000000000000000/' "${target_dir}/.szecret" + + # Should fail on put-local + assert_failure bash -c "echo 'value' | szecret put-local '${target_dir}/secret.age'" + pass "put-local fails on pubkey mismatch" +} + +test_get_local_pubkey_mismatch_fails() { + local target_dir="${TEST_DIR}/pubkey-mismatch-get" + szecret create-local "mismatch-get-store" "${target_dir}" + + # Encrypt a secret first + echo "secret-value" | szecret put-local "${target_dir}/secret.age" + + # Corrupt the pubkey in metadata + sed -i.bak 's/pubkey=.*/pubkey=age1invalid000000000000000000000000000000000000000000000000000/' "${target_dir}/.szecret" + + # Should fail on get-local (new syntax) + assert_failure szecret get-local "${target_dir}/secret.age" + pass "get-local fails on pubkey mismatch" +} + +test_invalid_metadata_fails() { + local target_dir="${TEST_DIR}/invalid-metadata" + mkdir -p "${target_dir}" + + # Create invalid metadata (old format without pubkey) + echo "just-store-name" > "${target_dir}/.szecret" + touch "${target_dir}/secret.age" + + assert_failure szecret get-local "${target_dir}/secret.age" + pass "invalid metadata format fails" +} + +run_tests() { + test_put_local_and_get_local + test_put_local_no_metadata_fails + test_get_local_no_metadata_fails + test_get_local_legacy_syntax_warns + test_get_local_nonexistent_file_fails + test_put_local_requires_arg + test_get_local_requires_arg + test_put_local_pubkey_mismatch_fails + test_get_local_pubkey_mismatch_fails + test_invalid_metadata_fails +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/main.sh b/secrets/test/main.sh new file mode 100755 index 0000000..23c8dd2 --- /dev/null +++ b/secrets/test/main.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Tests for main entrypoint + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_main_help() { + local output + output="$(szecret --help)" + assert_contains "${output}" "szecret" "help output" + assert_contains "${output}" "Usage" "help output" + pass "main --help shows usage" +} + +test_main_help_short() { + local output + output="$(szecret -h)" + assert_contains "${output}" "szecret" "help output" + pass "main -h shows usage" +} + +test_main_unknown_command() { + assert_failure szecret nonexistent-command + pass "main fails on unknown command" +} + +test_main_no_command() { + assert_failure szecret + pass "main fails without command" +} + +test_main_dispatches_to_command() { + echo "dispatch-test-value" | szecret put "dispatch-test" + local retrieved + retrieved="$(szecret get "dispatch-test")" + assert_equals "dispatch-test-value" "${retrieved}" "dispatched command" + pass "main dispatches to commands" +} + +test_command_help() { + local output + output="$(szecret get --help)" + assert_contains "${output}" "get" "command help" + assert_contains "${output}" "Usage" "command help" + pass "command --help shows command usage" +} + +run_tests() { + test_main_help + test_main_help_short + test_main_unknown_command + test_main_no_command + test_main_dispatches_to_command + test_command_help +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/paths.sh b/secrets/test/paths.sh new file mode 100755 index 0000000..97c8599 --- /dev/null +++ b/secrets/test/paths.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# Tests for where szecret finds the global store and its public key + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +# A copy of the scripts with no store beside them, so the tests do not depend +# on whether this checkout still carries one. +make_code_dir() { + local dir="$1" + mkdir -p "${dir}" + cp "${SECRETS_DIR}"/*.sh "${dir}/" +} + +# Runs szecret from a code dir with only XDG_DATA_HOME to go on. +szecret_at() { + local code_dir="$1" + shift + env -u SZECRET_STORE -u SZECRET_PUBLIC_KEY -u SZECRET_PRIVATE_KEY -u SZECRET_PRIVATE_KEY_DIR \ + XDG_DATA_HOME="${TEST_DIR}/xdg" \ + "${code_dir}/main.sh" "$@" +} + +# Makes a keypair: the public key goes to $1, the private key into the private +# key directory, named after its public key. +make_key() { + local pub_file="$1" + local key_dir="${TEST_DIR}/xdg/szecret" + mkdir -p "${key_dir}" "$(dirname "${pub_file}")" + age-keygen -o "${TEST_DIR}/new-key" 2>/dev/null + age-keygen -y "${TEST_DIR}/new-key" > "${pub_file}" + mv "${TEST_DIR}/new-key" "${key_dir}/$(cat "${pub_file}")" +} + +reset_xdg() { + rm -rf "${TEST_DIR}/xdg" +} + +test_no_store_fails() { + reset_xdg + local code="${TEST_DIR}/code-bare" + make_code_dir "${code}" + + local err + err="$(szecret_at "${code}" list 2>&1 >/dev/null)" && fail "list should fail without a store" + assert_contains "${err}" "no szecret store" "error message" + pass "no store: commands fail with instructions" +} + +test_no_store_ready_is_quiet() { + reset_xdg + local code="${TEST_DIR}/code-bare" + make_code_dir "${code}" + + local err + err="$(szecret_at "${code}" ready 2>&1)" && fail "ready should fail without a store" + assert_equals "" "${err}" "ready output" + pass "no store: ready fails silently" +} + +test_data_dir_store() { + reset_xdg + local code="${TEST_DIR}/code-bare" + make_code_dir "${code}" + + # The store and public key live elsewhere and are linked in, as on a host. + local elsewhere="${TEST_DIR}/checkout/szecret" + mkdir -p "${elsewhere}/store" + make_key "${elsewhere}/id.pub" + ln -s "${elsewhere}/store" "${TEST_DIR}/xdg/szecret/store" + ln -s "${elsewhere}/id.pub" "${TEST_DIR}/xdg/szecret/id.pub" + + echo "linked-value" | szecret_at "${code}" put "linked-secret" + assert_file_exists "${elsewhere}/store/linked-secret.age" "secret written through the link" + assert_equals "linked-value" "$(szecret_at "${code}" get "linked-secret")" "round trip" + pass "store and id.pub are found in the data dir" +} + +test_data_dir_wins_over_a_stray_store() { + reset_xdg + local code="${TEST_DIR}/code-stray" + make_code_dir "${code}" + mkdir -p "${code}/store" + make_key "${code}/.id.pub" + + mkdir -p "${TEST_DIR}/xdg/szecret/store" + make_key "${TEST_DIR}/xdg/szecret/id.pub" + + echo "v" | szecret_at "${code}" put "where" + assert_file_exists "${TEST_DIR}/xdg/szecret/store/where.age" "secret in data dir store" + if [[ -e "${code}/store/where.age" ]]; then + fail "secret was written to the store beside the scripts" + fi + pass "data dir is used even with a store beside the scripts" +} + +test_env_beats_data_dir() { + reset_xdg + mkdir -p "${TEST_DIR}/xdg/szecret/store" + make_key "${TEST_DIR}/xdg/szecret/id.pub" + + # The common.sh setup exported SZECRET_STORE and friends; use them as-is. + echo "v" | XDG_DATA_HOME="${TEST_DIR}/xdg" szecret put "env-wins" + assert_file_exists "${SZECRET_STORE}/env-wins.age" "secret in SZECRET_STORE" + if [[ -e "${TEST_DIR}/xdg/szecret/store/env-wins.age" ]]; then + fail "secret was written to the data dir store" + fi + pass "SZECRET_STORE takes precedence over the data dir" +} + +run_tests() { + test_no_store_fails + test_no_store_ready_is_quiet + test_data_dir_store + test_data_dir_wins_over_a_stray_store + test_env_beats_data_dir +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/put.sh b/secrets/test/put.sh new file mode 100755 index 0000000..7cfb117 --- /dev/null +++ b/secrets/test/put.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Tests for put command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_put_creates_file() { + echo "test-value" | szecret put "test-secret" + assert_file_exists "${SZECRET_STORE}/test-secret.age" "put creates encrypted file" + pass "put creates file" +} + +test_put_overwrites() { + echo "original" | szecret put "overwrite-test" + echo "updated" | szecret put "overwrite-test" + + local retrieved + retrieved="$(szecret get "overwrite-test")" + assert_equals "updated" "${retrieved}" "overwritten value" + pass "put overwrites existing secret" +} + +test_put_requires_name() { + assert_failure szecret put + pass "put requires secret name" +} + +run_tests() { + test_put_creates_file + test_put_overwrites + test_put_requires_name +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/ready.sh b/secrets/test/ready.sh new file mode 100755 index 0000000..26a000c --- /dev/null +++ b/secrets/test/ready.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Tests for ready command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_ready_with_key() { + assert_success szecret ready + pass "ready succeeds with private key" +} + +test_ready_without_key() { + local saved_key="${SZECRET_PRIVATE_KEY}" + export SZECRET_PRIVATE_KEY="${TEST_DIR}/nonexistent-key" + + assert_failure szecret ready + + export SZECRET_PRIVATE_KEY="${saved_key}" + pass "ready fails without private key" +} + +run_tests() { + test_ready_with_key + test_ready_without_key +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/rotate.sh b/secrets/test/rotate.sh new file mode 100755 index 0000000..02abe39 --- /dev/null +++ b/secrets/test/rotate.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Tests for rotate command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +setup_key_dir_layout() { + local keys_dir="${TEST_DIR}/keys" + mkdir -p "${keys_dir}" "${SZECRET_STORE}" + rm -f "${SZECRET_STORE}"/*.age + unset SZECRET_PRIVATE_KEY + export SZECRET_PRIVATE_KEY_DIR="${keys_dir}" + + local temp_key="${TEST_DIR}/initial-key" + age-keygen -o "${temp_key}" 2>/dev/null + local pub + pub="$(age-keygen -y "${temp_key}")" + echo "${pub}" > "${SZECRET_PUBLIC_KEY}" + mv "${temp_key}" "${keys_dir}/${pub}" + chmod 600 "${keys_dir}/${pub}" + SETUP_PUB="${pub}" +} + +test_rotate_reencrypts_secrets() { + setup_key_dir_layout + local old_pub="${SETUP_PUB}" + + echo "rotate-me" | szecret put "rotate-test" + echo "other" | szecret put "rotate-other" + + szecret rotate --yes + + local new_pub + new_pub="$(cat "${SZECRET_PUBLIC_KEY}")" + if [[ "${new_pub}" == "${old_pub}" ]]; then + fail "public key should change after rotate" + fi + + assert_equals "rotate-me" "$(szecret get "rotate-test")" "rotated secret" + assert_equals "other" "$(szecret get "rotate-other")" "other rotated secret" + assert_file_exists "${SZECRET_PRIVATE_KEY_DIR}/${new_pub}" "new private key file" + pass "rotate re-encrypts secrets and updates keys" +} + +test_rotate_empty_store() { + setup_key_dir_layout >/dev/null + + szecret rotate --yes + + local new_pub + new_pub="$(cat "${SZECRET_PUBLIC_KEY}")" + assert_file_exists "${SZECRET_PRIVATE_KEY_DIR}/${new_pub}" "new private key with empty store" + pass "rotate succeeds with empty store" +} + +test_rotate_requires_confirmation() { + setup_key_dir_layout >/dev/null + + assert_failure szecret rotate + pass "rotate fails without confirmation" +} + +run_tests() { + test_rotate_reencrypts_secrets + test_rotate_empty_store + test_rotate_requires_confirmation +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/run.sh b/secrets/test/run.sh new file mode 100755 index 0000000..0969618 --- /dev/null +++ b/secrets/test/run.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Test runner for szecret tests +# Runs all test files or specific ones passed as arguments +# Each test file runs in its own isolated environment + +set -euo pipefail + +this_dir="$(cd "$(dirname "$0")" && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +# Find all test files (excluding common.sh and run.sh) +find_test_files() { + find "${this_dir}" -maxdepth 1 -name '*.sh' -type f \ + | xargs -n1 basename \ + | grep -Ev '^(common|run)\.sh$' \ + | sort +} + +run_test_file() { + local test_file="$1" + local test_path="${this_dir}/${test_file}" + + if [[ ! -x "${test_path}" ]]; then + chmod +x "${test_path}" + fi + + echo "--- ${test_file%.sh} ---" + + # Run each test file in its own subprocess with isolated environment + if "${test_path}"; then + return 0 + else + return 1 + fi +} + +main() { + echo "=== szecret test suite ===" + echo + + local test_files=() + local failed=0 + + if [[ $# -gt 0 ]]; then + # Run specific test files + for arg in "$@"; do + # Allow both "list" and "list.sh" + local file="${arg%.sh}.sh" + if [[ -f "${this_dir}/${file}" ]]; then + test_files+=("${file}") + else + echo "Unknown test file: ${arg}" >&2 + exit 1 + fi + done + else + # Run all test files + while IFS= read -r file; do + test_files+=("${file}") + done < <(find_test_files) + fi + + for test_file in "${test_files[@]}"; do + if ! run_test_file "${test_file}"; then + failed=1 + fi + echo + done + + if [[ ${failed} -eq 0 ]]; then + echo -e "${GREEN}=== All tests passed ===${NC}" + else + echo -e "${RED}=== Some tests failed ===${NC}" + exit 1 + fi +} + +main "$@" |
