/ internal / skills / secrets_test.go
secrets_test.go
  1  package skills
  2  
  3  import (
  4  	"os"
  5  	"path/filepath"
  6  	"runtime"
  7  	"testing"
  8  )
  9  
 10  // skipIfNoKeychain skips tests that require actual Keychain access.
 11  // Keychain tests only run on darwin and when SHANNON_KEYCHAIN_TEST=1
 12  // is set, to avoid polluting the developer's login keychain during
 13  // routine `go test ./...`.
 14  func skipIfNoKeychain(t *testing.T) {
 15  	t.Helper()
 16  	if runtime.GOOS != "darwin" {
 17  		t.Skip("Keychain is only available on darwin")
 18  	}
 19  	if os.Getenv("SHANNON_KEYCHAIN_TEST") != "1" {
 20  		t.Skip("set SHANNON_KEYCHAIN_TEST=1 to run Keychain integration tests")
 21  	}
 22  }
 23  
 24  // --- Index file tests (no Keychain dependency) ---
 25  
 26  func TestSecretsStore_ConfiguredKeysAfterSet(t *testing.T) {
 27  	skipIfNoKeychain(t)
 28  	dir := t.TempDir()
 29  	store := NewSecretsStore(dir)
 30  	t.Cleanup(func() { _ = store.Delete("test-configured") })
 31  
 32  	if err := store.Set("test-configured", map[string]string{"KEY_A": "aaa", "KEY_B": "bbb"}); err != nil {
 33  		t.Fatalf("Set failed: %v", err)
 34  	}
 35  
 36  	keys := store.ConfiguredKeys("test-configured")
 37  	if len(keys) != 2 {
 38  		t.Fatalf("expected 2 keys, got %d: %v", len(keys), keys)
 39  	}
 40  	if keys[0] != "KEY_A" || keys[1] != "KEY_B" {
 41  		t.Errorf("expected sorted [KEY_A, KEY_B], got %v", keys)
 42  	}
 43  }
 44  
 45  func TestSecretsStore_ConfiguredKeysMissing(t *testing.T) {
 46  	dir := t.TempDir()
 47  	store := NewSecretsStore(dir)
 48  
 49  	keys := store.ConfiguredKeys("nonexistent")
 50  	if keys != nil {
 51  		t.Errorf("expected nil for missing skill, got %v", keys)
 52  	}
 53  }
 54  
 55  func TestSecretsStore_NilStore(t *testing.T) {
 56  	// Empty shannonDir → nil store. All methods must be safe.
 57  	store := NewSecretsStore("")
 58  	if store != nil {
 59  		t.Fatal("expected nil store for empty shannonDir")
 60  	}
 61  	if got := store.Get("any"); got != nil {
 62  		t.Error("Get on nil store should return nil")
 63  	}
 64  	if keys := store.ConfiguredKeys("any"); keys != nil {
 65  		t.Error("ConfiguredKeys on nil store should return nil")
 66  	}
 67  	if err := store.Set("any", map[string]string{"K": "V"}); err != nil {
 68  		t.Errorf("Set on nil store should be no-op, got %v", err)
 69  	}
 70  	if err := store.Delete("any"); err != nil {
 71  		t.Errorf("Delete on nil store should be no-op, got %v", err)
 72  	}
 73  	if err := store.DeleteKey("any", "K"); err != nil {
 74  		t.Errorf("DeleteKey on nil store should be no-op, got %v", err)
 75  	}
 76  }
 77  
 78  func TestSecretsStore_IndexFilePermissions(t *testing.T) {
 79  	skipIfNoKeychain(t)
 80  	dir := t.TempDir()
 81  	store := NewSecretsStore(dir)
 82  	t.Cleanup(func() { _ = store.Delete("test-perm") })
 83  
 84  	if err := store.Set("test-perm", map[string]string{"K": "V"}); err != nil {
 85  		t.Fatalf("Set failed: %v", err)
 86  	}
 87  
 88  	info, err := os.Stat(filepath.Join(dir, "secrets-index.json"))
 89  	if err != nil {
 90  		t.Fatalf("stat failed: %v", err)
 91  	}
 92  	perm := info.Mode().Perm()
 93  	if perm != 0600 {
 94  		t.Errorf("expected 0600 permissions, got %o", perm)
 95  	}
 96  }
 97  
 98  func TestIsValidEnvKey(t *testing.T) {
 99  	tests := []struct {
100  		key   string
101  		valid bool
102  	}{
103  		{"API_KEY", true},
104  		{"GEMINI_API_KEY", true},
105  		{"A", true},
106  		{"A1_B2", true},
107  		{"api_key", false},        // lowercase
108  		{"1_KEY", false},          // leading digit
109  		{"_KEY", false},           // leading underscore
110  		{"API KEY", false},        // space
111  		{"API-KEY", false},        // dash
112  		{"", false},               // empty
113  	}
114  	for _, tt := range tests {
115  		if got := IsValidEnvKey(tt.key); got != tt.valid {
116  			t.Errorf("IsValidEnvKey(%q) = %v, want %v", tt.key, got, tt.valid)
117  		}
118  	}
119  }
120  
121  // --- Keychain integration tests (opt-in) ---
122  
123  func TestSecretsStore_SetAndGet_Keychain(t *testing.T) {
124  	skipIfNoKeychain(t)
125  	dir := t.TempDir()
126  	store := NewSecretsStore(dir)
127  	t.Cleanup(func() { _ = store.Delete("test-setget") })
128  
129  	if err := store.Set("test-setget", map[string]string{"SERPER_API_KEY": "test-key-123"}); err != nil {
130  		t.Fatalf("Set failed: %v", err)
131  	}
132  
133  	got := store.Get("test-setget")
134  	if got["SERPER_API_KEY"] != "test-key-123" {
135  		t.Errorf("expected test-key-123, got %q", got["SERPER_API_KEY"])
136  	}
137  }
138  
139  func TestSecretsStore_SetMerges_Keychain(t *testing.T) {
140  	skipIfNoKeychain(t)
141  	dir := t.TempDir()
142  	store := NewSecretsStore(dir)
143  	t.Cleanup(func() { _ = store.Delete("test-merge") })
144  
145  	store.Set("test-merge", map[string]string{"KEY_A": "aaa"})
146  	store.Set("test-merge", map[string]string{"KEY_B": "bbb"})
147  
148  	got := store.Get("test-merge")
149  	if got["KEY_A"] != "aaa" {
150  		t.Errorf("KEY_A should be preserved, got %q", got["KEY_A"])
151  	}
152  	if got["KEY_B"] != "bbb" {
153  		t.Errorf("KEY_B should be added, got %q", got["KEY_B"])
154  	}
155  }
156  
157  func TestSecretsStore_SetOverwrites_Keychain(t *testing.T) {
158  	skipIfNoKeychain(t)
159  	dir := t.TempDir()
160  	store := NewSecretsStore(dir)
161  	t.Cleanup(func() { _ = store.Delete("test-overwrite") })
162  
163  	store.Set("test-overwrite", map[string]string{"KEY_A": "old"})
164  	store.Set("test-overwrite", map[string]string{"KEY_A": "new"})
165  
166  	got := store.Get("test-overwrite")
167  	if got["KEY_A"] != "new" {
168  		t.Errorf("KEY_A should be overwritten, got %q", got["KEY_A"])
169  	}
170  }
171  
172  func TestSecretsStore_Delete_Keychain(t *testing.T) {
173  	skipIfNoKeychain(t)
174  	dir := t.TempDir()
175  	store := NewSecretsStore(dir)
176  
177  	store.Set("test-delete", map[string]string{"KEY": "val"})
178  	if err := store.Delete("test-delete"); err != nil {
179  		t.Fatalf("Delete failed: %v", err)
180  	}
181  
182  	if keys := store.ConfiguredKeys("test-delete"); keys != nil {
183  		t.Errorf("expected nil after delete, got %v", keys)
184  	}
185  	if got := store.Get("test-delete"); got != nil {
186  		t.Errorf("expected nil after delete, got %v", got)
187  	}
188  }
189  
190  func TestSecretsStore_DeleteKey_Keychain(t *testing.T) {
191  	skipIfNoKeychain(t)
192  	dir := t.TempDir()
193  	store := NewSecretsStore(dir)
194  	t.Cleanup(func() { _ = store.Delete("test-deletekey") })
195  
196  	store.Set("test-deletekey", map[string]string{"KEY_A": "aaa", "KEY_B": "bbb"})
197  	if err := store.DeleteKey("test-deletekey", "KEY_A"); err != nil {
198  		t.Fatalf("DeleteKey failed: %v", err)
199  	}
200  
201  	keys := store.ConfiguredKeys("test-deletekey")
202  	if len(keys) != 1 || keys[0] != "KEY_B" {
203  		t.Errorf("expected [KEY_B], got %v", keys)
204  	}
205  	got := store.Get("test-deletekey")
206  	if got["KEY_B"] != "bbb" {
207  		t.Errorf("KEY_B should be preserved, got %q", got["KEY_B"])
208  	}
209  }