mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:11:31 +00:00
995b72df05
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>
287 lines
6.0 KiB
Go
287 lines
6.0 KiB
Go
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())
|
|
}
|
|
}
|