package domainsecrets import ( "context" "fmt" "os/exec" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/bnema/gordon/internal/domain" ) func passCmd(args ...string) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() return exec.CommandContext(ctx, "pass", args...).Run() } func passInsertValue(path, value string) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) cmd.Stdin = strings.NewReader(value) _, err := cmd.CombinedOutput() return err } func passShow(path string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "pass", "show", path).CombinedOutput() if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } func requirePass(t *testing.T) { if err := passCmd("version"); err != nil { t.Skip("pass not available") } if err := passCmd("ls"); err != nil { t.Skip("pass store not initialized") } } func cleanupPassDomain(_ *testing.T, domainName string, keys []string) { safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) if err != nil { return } for _, key := range keys { path := fmt.Sprintf("%s/%s/%s", PassDomainSecretsPath, safeDomain, key) _ = passCmd("rm", "-f", path) } manifestPath := fmt.Sprintf("%s/%s/.keys", PassDomainSecretsPath, safeDomain) _ = passCmd("rm", "-f", manifestPath) } func TestPassStore_SetGetDelete(t *testing.T) { requirePass(t) store, err := NewPassStore(testLogger()) require.NoError(t, err) domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano()) keys := []string{"API_KEY", "DB_PASSWORD"} defer cleanupPassDomain(t, domainName, keys) secretsMap := map[string]string{ "API_KEY": "alpha", "DB_PASSWORD": "bravo", } err = store.Set(domainName, secretsMap) require.NoError(t, err) keysList, err := store.ListKeys(domainName) require.NoError(t, err) assert.Len(t, keysList, 2) assert.ElementsMatch(t, keys, keysList) values, err := store.GetAll(domainName) require.NoError(t, err) assert.Equal(t, "alpha", values["API_KEY"]) assert.Equal(t, "bravo", values["DB_PASSWORD"]) err = store.Delete(domainName, "DB_PASSWORD") require.NoError(t, err) keysList, err = store.ListKeys(domainName) require.NoError(t, err) assert.ElementsMatch(t, []string{"API_KEY"}, keysList) } func TestPassStore_SetGetAttachment(t *testing.T) { requirePass(t) store, err := NewPassStore(testLogger()) require.NoError(t, err) containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano()) keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"} defer CleanupPassAttachment(t, containerName, keys) secretsMap := map[string]string{ "POSTGRES_USER": "gitea", "POSTGRES_PASSWORD": "secret123", } err = store.SetAttachment(containerName, secretsMap) require.NoError(t, err) values, err := store.GetAllAttachment(containerName) require.NoError(t, err) assert.Equal(t, "gitea", values["POSTGRES_USER"]) assert.Equal(t, "secret123", values["POSTGRES_PASSWORD"]) } func TestPassStore_ListAttachmentKeys_AfterSet(t *testing.T) { requirePass(t) store, err := NewPassStore(testLogger()) require.NoError(t, err) containerName := fmt.Sprintf("redis-cache-%d", time.Now().UnixNano()) keys := []string{"REDIS_PASSWORD"} defer CleanupPassAttachment(t, containerName, keys) secretsMap := map[string]string{ "REDIS_PASSWORD": "redis123", } err = store.SetAttachment(containerName, secretsMap) require.NoError(t, err) values, err := store.GetAllAttachment(containerName) require.NoError(t, err) assert.Len(t, values, 1) assert.Equal(t, "redis123", values["REDIS_PASSWORD"]) } func TestPassStore_DeleteAttachment(t *testing.T) { requirePass(t) store, err := NewPassStore(testLogger()) require.NoError(t, err) containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano()) keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"} defer CleanupPassAttachment(t, containerName, keys) // Set 2 secrets secretsMap := map[string]string{ "POSTGRES_USER": "gitea", "POSTGRES_PASSWORD": "secret123", } err = store.SetAttachment(containerName, secretsMap) require.NoError(t, err) // Delete 1 err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") require.NoError(t, err) // Verify remaining via GetAllAttachment values, err := store.GetAllAttachment(containerName) require.NoError(t, err) assert.Len(t, values, 1) assert.Equal(t, "gitea", values["POSTGRES_USER"]) _, exists := values["POSTGRES_PASSWORD"] assert.False(t, exists) } func TestPassStore_Delete_Idempotent(t *testing.T) { requirePass(t) store, err := NewPassStore(testLogger()) require.NoError(t, err) domainName := fmt.Sprintf("idempotent.%d.example.com", time.Now().UnixNano()) keys := []string{"API_KEY"} defer cleanupPassDomain(t, domainName, keys) err = store.Set(domainName, map[string]string{"API_KEY": "alpha"}) require.NoError(t, err) err = store.Delete(domainName, "API_KEY") require.NoError(t, err) // Second delete of an already-removed key must be a no-op. err = store.Delete(domainName, "API_KEY") require.NoError(t, err) values, err := store.GetAll(domainName) require.NoError(t, err) assert.NotContains(t, values, "API_KEY") } func TestPassStore_DeleteAttachment_Idempotent(t *testing.T) { requirePass(t) store, err := NewPassStore(testLogger()) require.NoError(t, err) containerName := fmt.Sprintf("idempotent-attachment-%d", time.Now().UnixNano()) keys := []string{"POSTGRES_PASSWORD"} defer CleanupPassAttachment(t, containerName, keys) err = store.SetAttachment(containerName, map[string]string{"POSTGRES_PASSWORD": "secret123"}) require.NoError(t, err) err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") require.NoError(t, err) // Second delete of an already-removed key must be a no-op. err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") require.NoError(t, err) values, err := store.GetAllAttachment(containerName) require.NoError(t, err) assert.NotContains(t, values, "POSTGRES_PASSWORD") } func TestPassStore_SetAttachment_OverwriteValue(t *testing.T) { requirePass(t) store, err := NewPassStore(testLogger()) require.NoError(t, err) containerName := fmt.Sprintf("attachment-overwrite-%d", time.Now().UnixNano()) keys := []string{"REDIS_PASSWORD"} defer CleanupPassAttachment(t, containerName, keys) err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "first"}) require.NoError(t, err) // Re-setting same key with a different value should deterministically overwrite. err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "second"}) require.NoError(t, err) values, err := store.GetAllAttachment(containerName) require.NoError(t, err) assert.Equal(t, "second", values["REDIS_PASSWORD"]) } func TestPassStore_ListKeys_RecoversOrphanedEntries(t *testing.T) { requirePass(t) store, err := NewPassStore(testLogger()) require.NoError(t, err) domainName := fmt.Sprintf("orphan.%d.example.com", time.Now().UnixNano()) keys := []string{"EXISTING", "ORIGIN"} defer cleanupPassDomain(t, domainName, keys) err = store.Set(domainName, map[string]string{"EXISTING": "present"}) require.NoError(t, err) safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) require.NoError(t, err) orphanPath := fmt.Sprintf("%s/%s/ORIGIN", PassDomainSecretsPath, safeDomain) err = passInsertValue(orphanPath, "https://example.com") require.NoError(t, err) manifestPath := fmt.Sprintf("%s/%s/.keys", PassDomainSecretsPath, safeDomain) err = passInsertValue(manifestPath, "EXISTING\n") require.NoError(t, err) listed, err := store.ListKeys(domainName) require.NoError(t, err) assert.ElementsMatch(t, []string{"EXISTING", "ORIGIN"}, listed) values, err := store.GetAll(domainName) require.NoError(t, err) assert.Equal(t, "present", values["EXISTING"]) assert.Equal(t, "https://example.com", values["ORIGIN"]) manifest, err := passShow(manifestPath) require.NoError(t, err) assert.Contains(t, manifest, "EXISTING") assert.Contains(t, manifest, "ORIGIN") } func TestParsePassListOutput(t *testing.T) { tests := []struct { name string basePath string output string want []passListEntry }{ { name: "simple tree", basePath: "domain", output: "domain\n├── key1\n└── key2", want: []passListEntry{{name: "key1", depth: 1}, {name: "key2", depth: 1}}, }, { name: "nested tree", basePath: "domain", output: "domain\n│ ├── subkey1\n│ └── subkey2\n└── key1", want: []passListEntry{{name: "subkey1", depth: 2}, {name: "subkey2", depth: 2}, {name: "key1", depth: 1}}, }, { name: "empty output", basePath: "domain", output: "", want: []passListEntry{}, }, { name: "ASCII fallback chars", basePath: "domain", output: "domain\n| |-- subkey1\n`-- key1", want: []passListEntry{{name: "subkey1", depth: 2}, {name: "key1", depth: 1}}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := parsePassListOutput(tt.basePath, tt.output) assert.Equal(t, tt.want, got) }) } }