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 }