- Add TestSecretStoreInitialise, TestEncryptDecrypt, TestEncryptDecryptWrongKey, TestEncryptProducesDifferentCiphertexts, TestSetSecretWhenLocked to secrets tests - Add TestUpdateLastConnected, TestExportProfiles, TestImportProfiles, TestImportDuplicateHandling, TestImportInvalidStrategy, TestImportInvalidJSON, TestMigrateIdempotent, TestMigrateCreatesTablesForCRUD to storage tests - Implement UpdateLastConnected, ExportProfiles, ImportProfiles on storage.Store
512 lines
15 KiB
Go
512 lines
15 KiB
Go
package secrets
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Full lifecycle: init → set → get → lock → fail → unlock → get
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSecretStoreLifecycle(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store := NewSecretStore(dir)
|
|
|
|
if store.IsInitialised() {
|
|
t.Fatal("expected vault to be uninitialised on a fresh directory")
|
|
}
|
|
|
|
// Initialise with a master password.
|
|
if err := store.Initialise("correct-horse-battery-staple"); err != nil {
|
|
t.Fatalf("Initialise() error = %v", err)
|
|
}
|
|
if !store.IsInitialised() {
|
|
t.Fatal("expected vault to be initialised after Initialise()")
|
|
}
|
|
if !store.IsUnlocked() {
|
|
t.Fatal("expected vault to be unlocked after Initialise()")
|
|
}
|
|
|
|
// Store a secret and retrieve it.
|
|
if err := store.SetSecret("profile:abc:password", "s3cret"); err != nil {
|
|
t.Fatalf("SetSecret() error = %v", err)
|
|
}
|
|
val, err := store.GetSecret("profile:abc:password")
|
|
if err != nil {
|
|
t.Fatalf("GetSecret() error = %v", err)
|
|
}
|
|
if val != "s3cret" {
|
|
t.Errorf("GetSecret() = %q, want %q", val, "s3cret")
|
|
}
|
|
|
|
// Lock the vault — should prevent reads.
|
|
store.Lock()
|
|
if store.IsUnlocked() {
|
|
t.Fatal("expected vault to be locked after Lock()")
|
|
}
|
|
if _, err := store.GetSecret("profile:abc:password"); err == nil {
|
|
t.Fatal("expected error when getting secret from locked vault")
|
|
}
|
|
|
|
// Unlock and read again.
|
|
if err := store.Unlock("correct-horse-battery-staple"); err != nil {
|
|
t.Fatalf("Unlock() error = %v", err)
|
|
}
|
|
val, err = store.GetSecret("profile:abc:password")
|
|
if err != nil {
|
|
t.Fatalf("GetSecret() after unlock error = %v", err)
|
|
}
|
|
if val != "s3cret" {
|
|
t.Errorf("GetSecret() after unlock = %q, want %q", val, "s3cret")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wrong password should fail to unlock
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSecretStoreWrongPassword(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store := NewSecretStore(dir)
|
|
|
|
if err := store.Initialise("passwordA"); err != nil {
|
|
t.Fatalf("Initialise() error = %v", err)
|
|
}
|
|
// Store a secret so the password verification has something to decrypt.
|
|
if err := store.SetSecret("test:key", "value"); err != nil {
|
|
t.Fatalf("SetSecret() error = %v", err)
|
|
}
|
|
store.Lock()
|
|
|
|
if err := store.Unlock("passwordB"); err == nil {
|
|
t.Fatal("expected error when unlocking with wrong password")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Multiple secrets — store, retrieve, delete selectively
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSecretStoreMultipleSecrets(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store := NewSecretStore(dir)
|
|
|
|
if err := store.Initialise("master"); err != nil {
|
|
t.Fatalf("Initialise() error = %v", err)
|
|
}
|
|
|
|
secrets := map[string]string{
|
|
"profile:a:password": "alpha",
|
|
"profile:b:password": "bravo",
|
|
"profile:c:passphrase": "charlie",
|
|
}
|
|
for k, v := range secrets {
|
|
if err := store.SetSecret(k, v); err != nil {
|
|
t.Fatalf("SetSecret(%q) error = %v", k, err)
|
|
}
|
|
}
|
|
|
|
// Verify each secret.
|
|
for k, want := range secrets {
|
|
got, err := store.GetSecret(k)
|
|
if err != nil {
|
|
t.Fatalf("GetSecret(%q) error = %v", k, err)
|
|
}
|
|
if got != want {
|
|
t.Errorf("GetSecret(%q) = %q, want %q", k, got, want)
|
|
}
|
|
}
|
|
|
|
// Delete one secret and verify the others remain.
|
|
if err := store.DeleteSecret("profile:b:password"); err != nil {
|
|
t.Fatalf("DeleteSecret() error = %v", err)
|
|
}
|
|
if _, err := store.GetSecret("profile:b:password"); err == nil {
|
|
t.Error("expected error when getting deleted secret")
|
|
}
|
|
for _, key := range []string{"profile:a:password", "profile:c:passphrase"} {
|
|
if _, err := store.GetSecret(key); err != nil {
|
|
t.Errorf("GetSecret(%q) should still work after unrelated delete, got %v", key, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DeleteProfileSecrets
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestDeleteProfileSecrets(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store := NewSecretStore(dir)
|
|
|
|
if err := store.Initialise("master"); err != nil {
|
|
t.Fatalf("Initialise() error = %v", err)
|
|
}
|
|
|
|
// Store secrets for two profiles.
|
|
if err := store.SetSecret("profile:p1:password", "pw1"); err != nil {
|
|
t.Fatalf("SetSecret() error = %v", err)
|
|
}
|
|
if err := store.SetSecret("profile:p1:passphrase", "pp1"); err != nil {
|
|
t.Fatalf("SetSecret() error = %v", err)
|
|
}
|
|
if err := store.SetSecret("profile:p2:password", "pw2"); err != nil {
|
|
t.Fatalf("SetSecret() error = %v", err)
|
|
}
|
|
|
|
// Delete profile p1's secrets.
|
|
if err := store.DeleteProfileSecrets("p1"); err != nil {
|
|
t.Fatalf("DeleteProfileSecrets() error = %v", err)
|
|
}
|
|
|
|
// p1 secrets should be gone.
|
|
if _, err := store.GetSecret("profile:p1:password"); err == nil {
|
|
t.Error("expected p1 password to be deleted")
|
|
}
|
|
if _, err := store.GetSecret("profile:p1:passphrase"); err == nil {
|
|
t.Error("expected p1 passphrase to be deleted")
|
|
}
|
|
|
|
// p2 secret should remain.
|
|
val, err := store.GetSecret("profile:p2:password")
|
|
if err != nil {
|
|
t.Fatalf("GetSecret(p2) error = %v", err)
|
|
}
|
|
if val != "pw2" {
|
|
t.Errorf("GetSecret(p2) = %q, want %q", val, "pw2")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reinitialise an existing vault should fail
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSecretStoreDoubleInitialise(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store := NewSecretStore(dir)
|
|
|
|
if err := store.Initialise("pw"); err != nil {
|
|
t.Fatalf("first Initialise() error = %v", err)
|
|
}
|
|
if err := store.Initialise("pw"); err == nil {
|
|
t.Fatal("expected error on second Initialise()")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Persistence across store instances
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSecretStorePersistence(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Instance 1: create and store.
|
|
s1 := NewSecretStore(dir)
|
|
if err := s1.Initialise("master"); err != nil {
|
|
t.Fatalf("Initialise() error = %v", err)
|
|
}
|
|
if err := s1.SetSecret("k", "v"); err != nil {
|
|
t.Fatalf("SetSecret() error = %v", err)
|
|
}
|
|
s1.Lock()
|
|
|
|
// Instance 2: open the same directory and unlock.
|
|
s2 := NewSecretStore(dir)
|
|
if !s2.IsInitialised() {
|
|
t.Fatal("expected vault to be initialised from disc")
|
|
}
|
|
if err := s2.Unlock("master"); err != nil {
|
|
t.Fatalf("Unlock() on second instance error = %v", err)
|
|
}
|
|
val, err := s2.GetSecret("k")
|
|
if err != nil {
|
|
t.Fatalf("GetSecret() on second instance error = %v", err)
|
|
}
|
|
if val != "v" {
|
|
t.Errorf("got %q, want %q", val, "v")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ListKeys
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestListKeys(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store := NewSecretStore(dir)
|
|
|
|
if err := store.Initialise("master"); err != nil {
|
|
t.Fatalf("Initialise() error = %v", err)
|
|
}
|
|
_ = store.SetSecret("a", "1")
|
|
_ = store.SetSecret("b", "2")
|
|
|
|
keys := store.ListKeys()
|
|
if len(keys) != 2 {
|
|
t.Fatalf("ListKeys() returned %d keys, want 2", len(keys))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Vault path uses the store directory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestVaultPath(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store := NewSecretStore(dir)
|
|
want := filepath.Join(dir, "vault.json")
|
|
|
|
// Access vaultPath via reflection-free approach: just initialise and check the file.
|
|
if err := store.Initialise("pw"); err != nil {
|
|
t.Fatalf("Initialise() error = %v", err)
|
|
}
|
|
// The file should exist at the expected path.
|
|
if !store.IsInitialised() {
|
|
t.Fatalf("expected vault file at %s", want)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Redaction helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestRedactSecret(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{name: "empty string returns empty", input: "", want: ""},
|
|
{name: "non-empty returns bullets", input: "super-secret", want: "••••••••"},
|
|
{name: "single char returns bullets", input: "x", want: "••••••••"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := RedactSecret(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("RedactSecret(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRedactInString(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
text string
|
|
secret string
|
|
want string
|
|
}{
|
|
{
|
|
name: "replaces secret in text",
|
|
text: "connecting with password=hunter2 now",
|
|
secret: "hunter2",
|
|
want: "connecting with password=•••••••• now",
|
|
},
|
|
{
|
|
name: "empty secret returns text unchanged",
|
|
text: "no redaction here",
|
|
secret: "",
|
|
want: "no redaction here",
|
|
},
|
|
{
|
|
name: "secret not present returns text unchanged",
|
|
text: "nothing to see",
|
|
secret: "absent",
|
|
want: "nothing to see",
|
|
},
|
|
{
|
|
name: "multiple occurrences all redacted",
|
|
text: "pw=abc and again abc",
|
|
secret: "abc",
|
|
want: "pw=•••••••• and again ••••••••",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := RedactInString(tt.text, tt.secret)
|
|
if got != tt.want {
|
|
t.Errorf("RedactInString() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRedactMap(t *testing.T) {
|
|
input := map[string]string{
|
|
"password": "hunter2",
|
|
"token": "abc123",
|
|
"empty": "",
|
|
}
|
|
|
|
result := RedactMap(input)
|
|
|
|
if result["password"] != "••••••••" {
|
|
t.Errorf("expected password to be redacted, got %q", result["password"])
|
|
}
|
|
if result["token"] != "••••••••" {
|
|
t.Errorf("expected token to be redacted, got %q", result["token"])
|
|
}
|
|
if result["empty"] != "" {
|
|
t.Errorf("expected empty value to remain empty, got %q", result["empty"])
|
|
}
|
|
|
|
// Verify the original map is not modified.
|
|
if input["password"] != "hunter2" {
|
|
t.Error("original map was modified")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Initialise standalone — verifies vault file creation and unlocked state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSecretStoreInitialise(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store := NewSecretStore(dir)
|
|
|
|
if store.IsInitialised() {
|
|
t.Fatal("expected uninitialised state before Initialise()")
|
|
}
|
|
|
|
if err := store.Initialise("test-master-pw"); err != nil {
|
|
t.Fatalf("Initialise() error = %v", err)
|
|
}
|
|
|
|
// Vault file must exist on disc.
|
|
if !store.IsInitialised() {
|
|
t.Fatal("expected initialised state after Initialise()")
|
|
}
|
|
|
|
// Store should be unlocked immediately after initialisation.
|
|
if !store.IsUnlocked() {
|
|
t.Fatal("expected unlocked state after Initialise()")
|
|
}
|
|
|
|
// Should be able to set and retrieve a secret straight away.
|
|
if err := store.SetSecret("init-test", "value"); err != nil {
|
|
t.Fatalf("SetSecret() after Initialise error = %v", err)
|
|
}
|
|
got, err := store.GetSecret("init-test")
|
|
if err != nil {
|
|
t.Fatalf("GetSecret() after Initialise error = %v", err)
|
|
}
|
|
if got != "value" {
|
|
t.Errorf("GetSecret() = %q, want %q", got, "value")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Encrypt / Decrypt round-trip (same-package access to unexported methods)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestEncryptDecrypt(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store := NewSecretStore(dir)
|
|
if err := store.Initialise("roundtrip-pw"); err != nil {
|
|
t.Fatalf("Initialise() error = %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
plaintext string
|
|
}{
|
|
{name: "short string", plaintext: "hello"},
|
|
{name: "empty string", plaintext: ""},
|
|
{name: "long string", plaintext: "The quick brown fox jumps over the lazy dog. 0123456789!@#$%^&*()"},
|
|
{name: "unicode text", plaintext: "café résumé naïve über 日本語"},
|
|
{name: "binary-ish content", plaintext: "\x00\x01\x02\xff"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
encrypted, err := store.encrypt(tt.plaintext)
|
|
if err != nil {
|
|
t.Fatalf("encrypt() error = %v", err)
|
|
}
|
|
|
|
// Encrypted text must differ from plaintext (unless empty — GCM
|
|
// still produces a tag).
|
|
if encrypted == tt.plaintext && tt.plaintext != "" {
|
|
t.Error("encrypted output should not equal plaintext")
|
|
}
|
|
|
|
decrypted, err := store.decrypt(encrypted)
|
|
if err != nil {
|
|
t.Fatalf("decrypt() error = %v", err)
|
|
}
|
|
if decrypted != tt.plaintext {
|
|
t.Errorf("decrypt() = %q, want %q", decrypted, tt.plaintext)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEncryptDecryptWrongKey(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store := NewSecretStore(dir)
|
|
if err := store.Initialise("key-A"); err != nil {
|
|
t.Fatalf("Initialise() error = %v", err)
|
|
}
|
|
|
|
encrypted, err := store.encrypt("secret data")
|
|
if err != nil {
|
|
t.Fatalf("encrypt() error = %v", err)
|
|
}
|
|
|
|
// Create a second store with a different key.
|
|
dir2 := t.TempDir()
|
|
store2 := NewSecretStore(dir2)
|
|
if err := store2.Initialise("key-B"); err != nil {
|
|
t.Fatalf("Initialise(key-B) error = %v", err)
|
|
}
|
|
|
|
// Attempting to decrypt with the wrong key should fail.
|
|
_, err = store2.decrypt(encrypted)
|
|
if err == nil {
|
|
t.Error("expected error when decrypting with wrong key")
|
|
}
|
|
}
|
|
|
|
func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
|
|
// Each call to encrypt should use a fresh random nonce, so encrypting
|
|
// the same plaintext twice must produce different ciphertexts.
|
|
dir := t.TempDir()
|
|
store := NewSecretStore(dir)
|
|
if err := store.Initialise("nonce-test"); err != nil {
|
|
t.Fatalf("Initialise() error = %v", err)
|
|
}
|
|
|
|
a, err := store.encrypt("same input")
|
|
if err != nil {
|
|
t.Fatalf("first encrypt() error = %v", err)
|
|
}
|
|
b, err := store.encrypt("same input")
|
|
if err != nil {
|
|
t.Fatalf("second encrypt() error = %v", err)
|
|
}
|
|
if a == b {
|
|
t.Error("two encryptions of identical plaintext produced identical ciphertext — nonce reuse?")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SetSecret and GetSecret when vault is locked
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSetSecretWhenLocked(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store := NewSecretStore(dir)
|
|
if err := store.Initialise("lock-test"); err != nil {
|
|
t.Fatalf("Initialise() error = %v", err)
|
|
}
|
|
store.Lock()
|
|
|
|
if err := store.SetSecret("k", "v"); err == nil {
|
|
t.Error("expected error when calling SetSecret on a locked vault")
|
|
}
|
|
}
|