summaryrefslogtreecommitdiff
path: root/secrets/test
diff options
context:
space:
mode:
Diffstat (limited to 'secrets/test')
-rw-r--r--secrets/test/README.md148
-rwxr-xr-xsecrets/test/common.sh108
-rwxr-xr-xsecrets/test/create-local.sh122
-rwxr-xr-xsecrets/test/edge-cases.sh100
-rwxr-xr-xsecrets/test/ensure.sh29
-rwxr-xr-xsecrets/test/get.sh44
-rwxr-xr-xsecrets/test/list.sh55
-rwxr-xr-xsecrets/test/local.sh132
-rwxr-xr-xsecrets/test/main.sh57
-rwxr-xr-xsecrets/test/paths.sh119
-rwxr-xr-xsecrets/test/put.sh34
-rwxr-xr-xsecrets/test/ready.sh27
-rwxr-xr-xsecrets/test/rotate.sh69
-rwxr-xr-xsecrets/test/run.sh82
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 "$@"