From 13cc065ec014b86369dfe4ad60d1a43fa06fd251 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Thu, 17 Sep 2026 19:45:23 +0100 Subject: dotfiles Shell, editor and terminal configuration, the tools that go with them, and szecret, an age-based secret manager whose store lives elsewhere. History restarts here. What came before was not written to be read by anyone else, and carried machine names, addresses and an encrypted secret store; it is kept privately instead. What is left is meant to be safe on any machine, including one whose owner is not me: see CLAUDE.md for the rule it is written to. --- secrets/test/README.md | 148 +++++++++++++++++++++++++++++++++++++++++++ secrets/test/common.sh | 108 +++++++++++++++++++++++++++++++ secrets/test/create-local.sh | 122 +++++++++++++++++++++++++++++++++++ secrets/test/edge-cases.sh | 100 +++++++++++++++++++++++++++++ secrets/test/ensure.sh | 29 +++++++++ secrets/test/get.sh | 44 +++++++++++++ secrets/test/list.sh | 55 ++++++++++++++++ secrets/test/local.sh | 132 ++++++++++++++++++++++++++++++++++++++ secrets/test/main.sh | 57 +++++++++++++++++ secrets/test/paths.sh | 119 ++++++++++++++++++++++++++++++++++ secrets/test/put.sh | 34 ++++++++++ secrets/test/ready.sh | 27 ++++++++ secrets/test/rotate.sh | 69 ++++++++++++++++++++ secrets/test/run.sh | 82 ++++++++++++++++++++++++ 14 files changed, 1126 insertions(+) create mode 100644 secrets/test/README.md create mode 100755 secrets/test/common.sh create mode 100755 secrets/test/create-local.sh create mode 100755 secrets/test/edge-cases.sh create mode 100755 secrets/test/ensure.sh create mode 100755 secrets/test/get.sh create mode 100755 secrets/test/list.sh create mode 100755 secrets/test/local.sh create mode 100755 secrets/test/main.sh create mode 100755 secrets/test/paths.sh create mode 100755 secrets/test/put.sh create mode 100755 secrets/test/ready.sh create mode 100755 secrets/test/rotate.sh create mode 100755 secrets/test/run.sh (limited to 'secrets/test') 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 + +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 [args]` | Run a szecret command via main.sh | +| `szecret_script [args]` | Run a specific script directly (e.g., `szecret_script get secret-name`) | +| `pass ` | Print green PASS message | +| `fail ` | Print red FAIL message and exit | +| `skip ` | Print yellow SKIP message | +| `assert_success ` | Assert command exits 0 | +| `assert_failure ` | Assert command exits non-zero | +| `assert_equals [msg]` | Assert string equality | +| `assert_contains [msg]` | Assert string contains substring | +| `assert_file_exists [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/.sh` following the template above +2. Make it executable: `chmod +x test/.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 "$@" -- cgit v1.2.3