Files
wild-central/internal/secrets/secrets_test.go
Paul Payne 68d6fde80d Show routes, TLS certs, and port-forwarding on services page
- Add Routes model to services UI (paths, headers, IP whitelisting per route)
- Show TLS cert info per service with inline provision/renew actions
- Remove TLS Certificates section from dashboard (now on services page)
- Make gateway router port list dynamic from config + VPN state
- Add TODO for header validation in HAProxy config generation
2026-07-10 06:10:40 +00:00

463 lines
11 KiB
Go

package secrets
import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/wild-cloud/wild-central/internal/storage"
)
func TestGenerateSecret(t *testing.T) {
tests := []struct {
name string
length int
want int
}{
{"default length", DefaultSecretLength, DefaultSecretLength},
{"custom length 64", 64, 64},
{"custom length 128", 128, 128},
{"zero defaults", 0, DefaultSecretLength},
{"negative defaults", -1, DefaultSecretLength},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
secret, err := GenerateSecret(tt.length)
if err != nil {
t.Fatalf("GenerateSecret failed: %v", err)
}
if len(secret) != tt.want {
t.Errorf("got length %d, want %d", len(secret), tt.want)
}
for _, c := range secret {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
t.Errorf("non-alphanumeric character found: %c", c)
}
}
})
}
}
func TestGenerateSecret_Uniqueness(t *testing.T) {
const numSecrets = 100
seen := make(map[string]bool, numSecrets)
for i := 0; i < numSecrets; i++ {
secret, err := GenerateSecret(32)
if err != nil {
t.Fatalf("GenerateSecret failed: %v", err)
}
if seen[secret] {
t.Errorf("duplicate secret generated: %s", secret)
}
seen[secret] = true
}
}
func newTestManager(t *testing.T, initialYAML string) *Manager {
t.Helper()
tempDir := t.TempDir()
secretsPath := filepath.Join(tempDir, "secrets.yaml")
if err := storage.WriteFile(secretsPath, []byte(initialYAML), 0600); err != nil {
t.Fatalf("setup failed: %v", err)
}
return NewManager(secretsPath)
}
func TestNewManager(t *testing.T) {
m := NewManager("/tmp/test-secrets.yaml")
if m == nil || m.yq == nil {
t.Fatal("NewManager returned nil or Manager.yq is nil")
}
}
func TestGetSecret(t *testing.T) {
tests := []struct {
name string
secretsYAML string
key string
want string
wantErr bool
errContains string
}{
{
name: "get simple string value",
secretsYAML: `cloudflare:
apiToken: "my-secret-token"
`,
key: "cloudflare.apiToken",
want: "my-secret-token",
},
{
name: "get nested value",
secretsYAML: `cloudflare:
nested:
deep: "deep-value"
`,
key: "cloudflare.nested.deep",
want: "deep-value",
},
{
name: "non-existent key returns error",
secretsYAML: `cloudflare:
apiToken: "value"
`,
key: "nonexistent",
wantErr: true,
errContains: "secret not found",
},
{
name: "empty string returns error",
secretsYAML: `cloudflare:
apiToken: ""
`,
key: "cloudflare.apiToken",
wantErr: true,
errContains: "secret not found",
},
{
name: "null value returns error",
secretsYAML: `cloudflare:
apiToken: null
`,
key: "cloudflare.apiToken",
wantErr: true,
errContains: "secret not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := newTestManager(t, tt.secretsYAML)
got, err := m.GetSecret(tt.key)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestGetSecret_NonExistentFile(t *testing.T) {
m := NewManager(filepath.Join(t.TempDir(), "nonexistent.yaml"))
_, err := m.GetSecret("some.key")
if err == nil {
t.Error("expected error, got nil")
} else if !strings.Contains(err.Error(), "secrets file not found") {
t.Errorf("error %q does not contain 'secrets file not found'", err.Error())
}
}
func TestGetSecret_NoSecretLeakage(t *testing.T) {
secretValue := "super-secret-password-12345"
m := newTestManager(t, `cloudflare:
apiToken: "`+secretValue+`"
`)
_, err := m.GetSecret("nonexistent.key")
if err == nil {
t.Fatal("expected error, got nil")
}
if strings.Contains(err.Error(), secretValue) {
t.Errorf("error message leaked secret value: %v", err)
}
}
func TestSetSecret(t *testing.T) {
tests := []struct {
name string
initialYAML string
key string
value string
wantValue string
}{
{
name: "set simple value",
initialYAML: `cloudflare:
apiToken: ""
`,
key: "cloudflare.apiToken",
value: "new-token",
wantValue: "new-token",
},
{
name: "update existing value",
initialYAML: `cloudflare:
apiToken: "old-token"
`,
key: "cloudflare.apiToken",
value: "new-token",
wantValue: "new-token",
},
{
name: "create new nested path",
initialYAML: `cloudflare: {}
`,
key: "cloudflare.newSecret",
value: "newValue",
wantValue: "newValue",
},
{
name: "value with special characters",
initialYAML: `cloudflare:
apiToken: ""
`,
key: "cloudflare.apiToken",
value: `special"quotes'and\backslashes`,
wantValue: `special"quotes'and\backslashes`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := newTestManager(t, tt.initialYAML)
if err := m.SetSecret(tt.key, tt.value); err != nil {
t.Errorf("SetSecret failed: %v", err)
return
}
got, err := m.GetSecret(tt.key)
if err != nil {
t.Fatalf("verify failed: %v", err)
}
if got != tt.wantValue {
t.Errorf("got %q, want %q", got, tt.wantValue)
}
// Verify permissions remain 0600
info, err := os.Stat(m.secretsPath)
if err != nil {
t.Fatalf("failed to stat secrets file: %v", err)
}
if info.Mode().Perm() != 0600 {
t.Errorf("permissions changed: got %o, want 0600", info.Mode().Perm())
}
})
}
}
func TestSetSecret_NonExistentFile(t *testing.T) {
m := NewManager(filepath.Join(t.TempDir(), "nonexistent.yaml"))
err := m.SetSecret("some.key", "value")
if err == nil {
t.Error("expected error, got nil")
} else if !strings.Contains(err.Error(), "secrets file not found") {
t.Errorf("error %q does not contain 'secrets file not found'", err.Error())
}
}
func TestSetSecret_ConcurrentAccess(t *testing.T) {
m := newTestManager(t, `counter: "0"
`)
const numGoroutines = 10
var wg sync.WaitGroup
errs := make(chan error, numGoroutines)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(val int) {
defer wg.Done()
value := string(rune('0' + val))
if err := m.SetSecret("counter", value); err != nil {
errs <- err
}
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
t.Errorf("concurrent write error: %v", err)
}
info, err := os.Stat(m.secretsPath)
if err != nil {
t.Fatalf("failed to stat secrets file: %v", err)
}
if info.Mode().Perm() != 0600 {
t.Errorf("permissions changed after concurrent writes: got %o, want 0600", info.Mode().Perm())
}
value, err := m.GetSecret("counter")
if err != nil {
t.Errorf("failed to read value after concurrent writes: %v", err)
}
if value == "" || value == "null" {
t.Error("counter value is empty after concurrent writes")
}
}
func TestDeleteSecret(t *testing.T) {
m := newTestManager(t, `cloudflare:
apiToken: "token-to-delete"
zoneId: "keep-this"
`)
// Verify secret exists
_, err := m.GetSecret("cloudflare.apiToken")
if err != nil {
t.Fatalf("secret should exist before deletion: %v", err)
}
if err := m.DeleteSecret("cloudflare.apiToken"); err != nil {
t.Errorf("DeleteSecret failed: %v", err)
return
}
// Verify deleted
_, err = m.GetSecret("cloudflare.apiToken")
if err == nil {
t.Error("secret should not exist after deletion")
}
// Verify other secret untouched
kept, err := m.GetSecret("cloudflare.zoneId")
if err != nil {
t.Errorf("other secret should still exist: %v", err)
}
if kept != "keep-this" {
t.Errorf("other secret changed: got %q", kept)
}
// Verify permissions
info, err := os.Stat(m.secretsPath)
if err != nil {
t.Fatalf("failed to stat secrets file: %v", err)
}
if info.Mode().Perm() != 0600 {
t.Errorf("permissions changed: got %o, want 0600", info.Mode().Perm())
}
}
func TestDeleteSecret_NonExistentFile(t *testing.T) {
m := NewManager(filepath.Join(t.TempDir(), "nonexistent.yaml"))
err := m.DeleteSecret("some.key")
if err == nil {
t.Error("expected error, got nil")
} else if !strings.Contains(err.Error(), "secrets file not found") {
t.Errorf("error %q does not contain 'secrets file not found'", err.Error())
}
}
func TestGetAll(t *testing.T) {
m := newTestManager(t, `cloudflare:
apiToken: "my-token"
zoneId: "zone-123"
topLevel: "value"
`)
all, err := m.GetAll()
if err != nil {
t.Fatalf("GetAll failed: %v", err)
}
if all["topLevel"] != "value" {
t.Errorf("expected topLevel=value, got %v", all["topLevel"])
}
cf, ok := all["cloudflare"].(map[string]any)
if !ok {
t.Fatalf("expected cloudflare to be a map, got %T", all["cloudflare"])
}
if cf["apiToken"] != "my-token" {
t.Errorf("expected apiToken=my-token, got %v", cf["apiToken"])
}
}
func TestGetAll_NonExistentFile(t *testing.T) {
m := NewManager(filepath.Join(t.TempDir(), "nonexistent.yaml"))
all, err := m.GetAll()
if err != nil {
t.Fatalf("expected no error for missing file, got: %v", err)
}
if len(all) != 0 {
t.Errorf("expected empty map, got %v", all)
}
}
func TestMergeUpdate(t *testing.T) {
m := newTestManager(t, `cloudflare:
apiToken: "original"
existing: "keep"
`)
err := m.MergeUpdate(map[string]any{
"cloudflare": map[string]any{
"apiToken": "updated",
},
"newKey": "newValue",
})
if err != nil {
t.Fatalf("MergeUpdate failed: %v", err)
}
all, err := m.GetAll()
if err != nil {
t.Fatalf("GetAll failed: %v", err)
}
if all["existing"] != "keep" {
t.Errorf("existing key changed: got %v", all["existing"])
}
if all["newKey"] != "newValue" {
t.Errorf("expected newKey=newValue, got %v", all["newKey"])
}
cf := all["cloudflare"].(map[string]any)
if cf["apiToken"] != "updated" {
t.Errorf("expected apiToken=updated, got %v", cf["apiToken"])
}
// Verify permissions
info, err := os.Stat(m.secretsPath)
if err != nil {
t.Fatalf("failed to stat: %v", err)
}
if info.Mode().Perm() != 0600 {
t.Errorf("permissions changed: got %o, want 0600", info.Mode().Perm())
}
}
func TestMergeUpdate_CreatesFileContent(t *testing.T) {
// Start with empty file
m := newTestManager(t, ``)
err := m.MergeUpdate(map[string]any{
"cloudflare": map[string]any{
"apiToken": "new-token",
},
})
if err != nil {
t.Fatalf("MergeUpdate failed: %v", err)
}
all, err := m.GetAll()
if err != nil {
t.Fatalf("GetAll failed: %v", err)
}
cf := all["cloudflare"].(map[string]any)
if cf["apiToken"] != "new-token" {
t.Errorf("expected apiToken=new-token, got %v", cf["apiToken"])
}
}