1package domainsecrets
2
3import (
4 "context"
5 "fmt"
6 "os/exec"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 "github.com/bnema/gordon/internal/domain"
15)
16
17func passCmd(args ...string) error {
18 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
19 defer cancel()
20 return exec.CommandContext(ctx, "pass", args...).Run()
21}
22
23func passInsertValue(path, value string) error {
24 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
25 defer cancel()
26 cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path)
27 cmd.Stdin = strings.NewReader(value)
28 _, err := cmd.CombinedOutput()
29 return err
30}
31
32func passShow(path string) (string, error) {
33 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
34 defer cancel()
35 out, err := exec.CommandContext(ctx, "pass", "show", path).CombinedOutput()
36 if err != nil {
37 return "", err
38 }
39 return strings.TrimSpace(string(out)), nil
40}
41
42func requirePass(t *testing.T) {
43 if err := passCmd("version"); err != nil {
44 t.Skip("pass not available")
45 }
46 if err := passCmd("ls"); err != nil {
47 t.Skip("pass store not initialized")
48 }
49}
50
51func cleanupPassDomain(_ *testing.T, domainName string, keys []string) {
52 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
53 if err != nil {
54 return
55 }
56
57 for _, key := range keys {
58 path := fmt.Sprintf("%s/%s/%s", PassDomainSecretsPath, safeDomain, key)
59 _ = passCmd("rm", "-f", path)
60 }
61
62 manifestPath := fmt.Sprintf("%s/%s/.keys", PassDomainSecretsPath, safeDomain)
63 _ = passCmd("rm", "-f", manifestPath)
64}
65
66func TestPassStore_SetGetDelete(t *testing.T) {
67 requirePass(t)
68
69 store, err := NewPassStore(testLogger())
70 require.NoError(t, err)
71
72 domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano())
73 keys := []string{"API_KEY", "DB_PASSWORD"}
74 defer cleanupPassDomain(t, domainName, keys)
75
76 secretsMap := map[string]string{
77 "API_KEY": "alpha",
78 "DB_PASSWORD": "bravo",
79 }
80
81 err = store.Set(domainName, secretsMap)
82 require.NoError(t, err)
83
84 keysList, err := store.ListKeys(domainName)
85 require.NoError(t, err)
86 assert.Len(t, keysList, 2)
87 assert.ElementsMatch(t, keys, keysList)
88
89 values, err := store.GetAll(domainName)
90 require.NoError(t, err)
91 assert.Equal(t, "alpha", values["API_KEY"])
92 assert.Equal(t, "bravo", values["DB_PASSWORD"])
93
94 err = store.Delete(domainName, "DB_PASSWORD")
95 require.NoError(t, err)
96
97 keysList, err = store.ListKeys(domainName)
98 require.NoError(t, err)
99 assert.ElementsMatch(t, []string{"API_KEY"}, keysList)
100}
101
102func TestPassStore_SetGetAttachment(t *testing.T) {
103 requirePass(t)
104
105 store, err := NewPassStore(testLogger())
106 require.NoError(t, err)
107
108 containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano())
109 keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"}
110 defer CleanupPassAttachment(t, containerName, keys)
111
112 secretsMap := map[string]string{
113 "POSTGRES_USER": "gitea",
114 "POSTGRES_PASSWORD": "secret123",
115 }
116
117 err = store.SetAttachment(containerName, secretsMap)
118 require.NoError(t, err)
119
120 values, err := store.GetAllAttachment(containerName)
121 require.NoError(t, err)
122 assert.Equal(t, "gitea", values["POSTGRES_USER"])
123 assert.Equal(t, "secret123", values["POSTGRES_PASSWORD"])
124}
125
126func TestPassStore_ListAttachmentKeys_AfterSet(t *testing.T) {
127 requirePass(t)
128
129 store, err := NewPassStore(testLogger())
130 require.NoError(t, err)
131
132 containerName := fmt.Sprintf("redis-cache-%d", time.Now().UnixNano())
133 keys := []string{"REDIS_PASSWORD"}
134 defer CleanupPassAttachment(t, containerName, keys)
135
136 secretsMap := map[string]string{
137 "REDIS_PASSWORD": "redis123",
138 }
139
140 err = store.SetAttachment(containerName, secretsMap)
141 require.NoError(t, err)
142
143 values, err := store.GetAllAttachment(containerName)
144 require.NoError(t, err)
145 assert.Len(t, values, 1)
146 assert.Equal(t, "redis123", values["REDIS_PASSWORD"])
147}
148
149func TestPassStore_DeleteAttachment(t *testing.T) {
150 requirePass(t)
151
152 store, err := NewPassStore(testLogger())
153 require.NoError(t, err)
154
155 containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano())
156 keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"}
157 defer CleanupPassAttachment(t, containerName, keys)
158
159 // Set 2 secrets
160 secretsMap := map[string]string{
161 "POSTGRES_USER": "gitea",
162 "POSTGRES_PASSWORD": "secret123",
163 }
164 err = store.SetAttachment(containerName, secretsMap)
165 require.NoError(t, err)
166
167 // Delete 1
168 err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
169 require.NoError(t, err)
170
171 // Verify remaining via GetAllAttachment
172 values, err := store.GetAllAttachment(containerName)
173 require.NoError(t, err)
174 assert.Len(t, values, 1)
175 assert.Equal(t, "gitea", values["POSTGRES_USER"])
176 _, exists := values["POSTGRES_PASSWORD"]
177 assert.False(t, exists)
178}
179
180func TestPassStore_Delete_Idempotent(t *testing.T) {
181 requirePass(t)
182
183 store, err := NewPassStore(testLogger())
184 require.NoError(t, err)
185
186 domainName := fmt.Sprintf("idempotent.%d.example.com", time.Now().UnixNano())
187 keys := []string{"API_KEY"}
188 defer cleanupPassDomain(t, domainName, keys)
189
190 err = store.Set(domainName, map[string]string{"API_KEY": "alpha"})
191 require.NoError(t, err)
192
193 err = store.Delete(domainName, "API_KEY")
194 require.NoError(t, err)
195
196 // Second delete of an already-removed key must be a no-op.
197 err = store.Delete(domainName, "API_KEY")
198 require.NoError(t, err)
199
200 values, err := store.GetAll(domainName)
201 require.NoError(t, err)
202 assert.NotContains(t, values, "API_KEY")
203}
204
205func TestPassStore_DeleteAttachment_Idempotent(t *testing.T) {
206 requirePass(t)
207
208 store, err := NewPassStore(testLogger())
209 require.NoError(t, err)
210
211 containerName := fmt.Sprintf("idempotent-attachment-%d", time.Now().UnixNano())
212 keys := []string{"POSTGRES_PASSWORD"}
213 defer CleanupPassAttachment(t, containerName, keys)
214
215 err = store.SetAttachment(containerName, map[string]string{"POSTGRES_PASSWORD": "secret123"})
216 require.NoError(t, err)
217
218 err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
219 require.NoError(t, err)
220
221 // Second delete of an already-removed key must be a no-op.
222 err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
223 require.NoError(t, err)
224
225 values, err := store.GetAllAttachment(containerName)
226 require.NoError(t, err)
227 assert.NotContains(t, values, "POSTGRES_PASSWORD")
228}
229
230func TestPassStore_SetAttachment_OverwriteValue(t *testing.T) {
231 requirePass(t)
232
233 store, err := NewPassStore(testLogger())
234 require.NoError(t, err)
235
236 containerName := fmt.Sprintf("attachment-overwrite-%d", time.Now().UnixNano())
237 keys := []string{"REDIS_PASSWORD"}
238 defer CleanupPassAttachment(t, containerName, keys)
239
240 err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "first"})
241 require.NoError(t, err)
242
243 // Re-setting same key with a different value should deterministically overwrite.
244 err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "second"})
245 require.NoError(t, err)
246
247 values, err := store.GetAllAttachment(containerName)
248 require.NoError(t, err)
249 assert.Equal(t, "second", values["REDIS_PASSWORD"])
250}
251
252func TestPassStore_ListKeys_RecoversOrphanedEntries(t *testing.T) {
253 requirePass(t)
254
255 store, err := NewPassStore(testLogger())
256 require.NoError(t, err)
257
258 domainName := fmt.Sprintf("orphan.%d.example.com", time.Now().UnixNano())
259 keys := []string{"EXISTING", "ORIGIN"}
260 defer cleanupPassDomain(t, domainName, keys)
261
262 err = store.Set(domainName, map[string]string{"EXISTING": "present"})
263 require.NoError(t, err)
264
265 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
266 require.NoError(t, err)
267
268 orphanPath := fmt.Sprintf("%s/%s/ORIGIN", PassDomainSecretsPath, safeDomain)
269 err = passInsertValue(orphanPath, "https://example.com")
270 require.NoError(t, err)
271
272 manifestPath := fmt.Sprintf("%s/%s/.keys", PassDomainSecretsPath, safeDomain)
273 err = passInsertValue(manifestPath, "EXISTING\n")
274 require.NoError(t, err)
275
276 listed, err := store.ListKeys(domainName)
277 require.NoError(t, err)
278 assert.ElementsMatch(t, []string{"EXISTING", "ORIGIN"}, listed)
279
280 values, err := store.GetAll(domainName)
281 require.NoError(t, err)
282 assert.Equal(t, "present", values["EXISTING"])
283 assert.Equal(t, "https://example.com", values["ORIGIN"])
284
285 manifest, err := passShow(manifestPath)
286 require.NoError(t, err)
287 assert.Contains(t, manifest, "EXISTING")
288 assert.Contains(t, manifest, "ORIGIN")
289}
290
291func TestParsePassListOutput(t *testing.T) {
292 tests := []struct {
293 name string
294 basePath string
295 output string
296 want []passListEntry
297 }{
298 {
299 name: "simple tree",
300 basePath: "domain",
301 output: "domain\n├── key1\n└── key2",
302 want: []passListEntry{{name: "key1", depth: 1}, {name: "key2", depth: 1}},
303 },
304 {
305 name: "nested tree",
306 basePath: "domain",
307 output: "domain\n│ ├── subkey1\n│ └── subkey2\n└── key1",
308 want: []passListEntry{{name: "subkey1", depth: 2}, {name: "subkey2", depth: 2}, {name: "key1", depth: 1}},
309 },
310 {
311 name: "empty output",
312 basePath: "domain",
313 output: "",
314 want: []passListEntry{},
315 },
316 {
317 name: "ASCII fallback chars",
318 basePath: "domain",
319 output: "domain\n| |-- subkey1\n`-- key1",
320 want: []passListEntry{{name: "subkey1", depth: 2}, {name: "key1", depth: 1}},
321 },
322 }
323
324 for _, tt := range tests {
325 t.Run(tt.name, func(t *testing.T) {
326 got := parsePassListOutput(tt.basePath, tt.output)
327 assert.Equal(t, tt.want, got)
328 })
329 }
330}