mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 14:18:52 +00:00
feat(M34): dynamic issuer configuration with encrypted config storage
Replace static env-var-based issuer wiring with GUI-driven dynamic configuration stored encrypted in PostgreSQL. Operators can now configure, test, enable/disable, and manage issuers from the dashboard without restarting the server. Key changes: - AES-256-GCM encryption for sensitive issuer config at rest (PBKDF2 key derivation with 100k iterations) - Dynamic IssuerRegistry with sync.RWMutex replacing static map - Connector factory pattern (issuerfactory.NewFromConfig) replacing 140 lines of static wiring in main.go - Migration 000009: encrypted_config, last_tested_at, test_status, source columns on issuers table - Env var seeding on first boot with ON CONFLICT DO NOTHING - Registry Rebuild() for atomic map swap after CRUD operations - Issuer type validation against domain constants on Create - Audit trail for test connection results - Conditional seeding for step-ca/OpenSSL (only when env vars set) - GUI: source badge, connection test status on issuer detail page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/crypto"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
func registryTestLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
|
||||
func TestIssuerRegistry_GetSet(t *testing.T) {
|
||||
reg := NewIssuerRegistry(registryTestLogger())
|
||||
|
||||
mock := &mockIssuerConnector{}
|
||||
reg.Set("iss-test", mock)
|
||||
|
||||
conn, ok := reg.Get("iss-test")
|
||||
if !ok {
|
||||
t.Fatal("expected to find iss-test in registry")
|
||||
}
|
||||
if conn == nil {
|
||||
t.Fatal("expected non-nil connector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerRegistry_GetNotFound(t *testing.T) {
|
||||
reg := NewIssuerRegistry(registryTestLogger())
|
||||
|
||||
_, ok := reg.Get("nonexistent")
|
||||
if ok {
|
||||
t.Fatal("expected not to find nonexistent issuer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerRegistry_Remove(t *testing.T) {
|
||||
reg := NewIssuerRegistry(registryTestLogger())
|
||||
|
||||
reg.Set("iss-test", &mockIssuerConnector{})
|
||||
reg.Remove("iss-test")
|
||||
|
||||
_, ok := reg.Get("iss-test")
|
||||
if ok {
|
||||
t.Fatal("expected issuer to be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerRegistry_List(t *testing.T) {
|
||||
reg := NewIssuerRegistry(registryTestLogger())
|
||||
|
||||
reg.Set("iss-a", &mockIssuerConnector{})
|
||||
reg.Set("iss-b", &mockIssuerConnector{})
|
||||
|
||||
list := reg.List()
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("expected 2 issuers, got %d", len(list))
|
||||
}
|
||||
|
||||
// Verify List returns a copy (modifying it doesn't affect registry)
|
||||
delete(list, "iss-a")
|
||||
if reg.Len() != 2 {
|
||||
t.Fatal("deleting from List() copy should not affect registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerRegistry_Len(t *testing.T) {
|
||||
reg := NewIssuerRegistry(registryTestLogger())
|
||||
if reg.Len() != 0 {
|
||||
t.Fatalf("expected empty registry, got %d", reg.Len())
|
||||
}
|
||||
|
||||
reg.Set("iss-a", &mockIssuerConnector{})
|
||||
if reg.Len() != 1 {
|
||||
t.Fatalf("expected 1 issuer, got %d", reg.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerRegistry_Rebuild_Enabled(t *testing.T) {
|
||||
reg := NewIssuerRegistry(registryTestLogger())
|
||||
|
||||
configs := []*domain.Issuer{
|
||||
{
|
||||
ID: "iss-local",
|
||||
Name: "Local CA",
|
||||
Type: "local",
|
||||
Config: json.RawMessage(`{}`),
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ID: "iss-disabled",
|
||||
Name: "Disabled",
|
||||
Type: "local",
|
||||
Config: json.RawMessage(`{}`),
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
err := reg.Rebuild(configs, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Rebuild failed: %v", err)
|
||||
}
|
||||
|
||||
if reg.Len() != 1 {
|
||||
t.Fatalf("expected 1 enabled issuer, got %d", reg.Len())
|
||||
}
|
||||
|
||||
_, ok := reg.Get("iss-local")
|
||||
if !ok {
|
||||
t.Fatal("expected iss-local in registry")
|
||||
}
|
||||
|
||||
_, ok = reg.Get("iss-disabled")
|
||||
if ok {
|
||||
t.Fatal("disabled issuer should not be in registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerRegistry_Rebuild_WithEncryption(t *testing.T) {
|
||||
reg := NewIssuerRegistry(registryTestLogger())
|
||||
|
||||
key := crypto.DeriveKey("test-key")
|
||||
configJSON := []byte(`{"ca_common_name":"Encrypted CA"}`)
|
||||
encrypted, err := crypto.Encrypt(configJSON, key)
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
configs := []*domain.Issuer{
|
||||
{
|
||||
ID: "iss-encrypted",
|
||||
Name: "Encrypted Local CA",
|
||||
Type: "local",
|
||||
EncryptedConfig: encrypted,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
err = reg.Rebuild(configs, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Rebuild with encryption failed: %v", err)
|
||||
}
|
||||
|
||||
_, ok := reg.Get("iss-encrypted")
|
||||
if !ok {
|
||||
t.Fatal("expected iss-encrypted in registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerRegistry_Rebuild_NilKeyFallback(t *testing.T) {
|
||||
reg := NewIssuerRegistry(registryTestLogger())
|
||||
|
||||
configs := []*domain.Issuer{
|
||||
{
|
||||
ID: "iss-plain",
|
||||
Name: "Plain Config",
|
||||
Type: "local",
|
||||
Config: json.RawMessage(`{}`),
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// nil key should work — falls back to config column
|
||||
err := reg.Rebuild(configs, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Rebuild with nil key failed: %v", err)
|
||||
}
|
||||
|
||||
_, ok := reg.Get("iss-plain")
|
||||
if !ok {
|
||||
t.Fatal("expected iss-plain in registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerRegistry_Rebuild_InvalidConfig(t *testing.T) {
|
||||
reg := NewIssuerRegistry(registryTestLogger())
|
||||
|
||||
configs := []*domain.Issuer{
|
||||
{
|
||||
ID: "iss-bad",
|
||||
Name: "Bad Config",
|
||||
Type: "UnknownType",
|
||||
Config: json.RawMessage(`{}`),
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ID: "iss-good",
|
||||
Name: "Good Config",
|
||||
Type: "local",
|
||||
Config: json.RawMessage(`{}`),
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Should return an error indicating partial failure, but still load valid issuers
|
||||
err := reg.Rebuild(configs, nil)
|
||||
if err == nil {
|
||||
t.Fatal("Rebuild should return error when some issuers fail to load")
|
||||
}
|
||||
|
||||
// Despite the error, valid issuers should be loaded
|
||||
if reg.Len() != 1 {
|
||||
t.Fatalf("expected 1 valid issuer, got %d", reg.Len())
|
||||
}
|
||||
|
||||
_, ok := reg.Get("iss-good")
|
||||
if !ok {
|
||||
t.Fatal("expected iss-good in registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerRegistry_Rebuild_ReplacesExisting(t *testing.T) {
|
||||
reg := NewIssuerRegistry(registryTestLogger())
|
||||
|
||||
// Set up initial state
|
||||
reg.Set("iss-old", &mockIssuerConnector{})
|
||||
|
||||
configs := []*domain.Issuer{
|
||||
{
|
||||
ID: "iss-new",
|
||||
Name: "New Issuer",
|
||||
Type: "local",
|
||||
Config: json.RawMessage(`{}`),
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
err := reg.Rebuild(configs, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Rebuild failed: %v", err)
|
||||
}
|
||||
|
||||
_, ok := reg.Get("iss-old")
|
||||
if ok {
|
||||
t.Fatal("old issuer should have been replaced")
|
||||
}
|
||||
|
||||
_, ok = reg.Get("iss-new")
|
||||
if !ok {
|
||||
t.Fatal("new issuer should be present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerRegistry_ConcurrentAccess(t *testing.T) {
|
||||
reg := NewIssuerRegistry(registryTestLogger())
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(3)
|
||||
id := "iss-concurrent"
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
reg.Set(id, &mockIssuerConnector{})
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
reg.Get(id)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
reg.List()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
// No race detector panics = success
|
||||
}
|
||||
|
||||
func TestIssuerRegistry_Rebuild_Empty(t *testing.T) {
|
||||
reg := NewIssuerRegistry(registryTestLogger())
|
||||
|
||||
reg.Set("iss-existing", &mockIssuerConnector{})
|
||||
|
||||
err := reg.Rebuild([]*domain.Issuer{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Rebuild with empty configs failed: %v", err)
|
||||
}
|
||||
|
||||
if reg.Len() != 0 {
|
||||
t.Fatalf("expected empty registry after rebuild with no configs, got %d", reg.Len())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user