- 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
463 lines
11 KiB
Go
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"])
|
|
}
|
|
}
|