feat: implement T22, T18, T23 — dev stack, profile tests, server binary

- T22: docker-compose.dev.yml dev stack, Dockerfile, root Makefile
- T18: Profile test suite (Scenario A) — 8 integration tests with real handlers
- T23: Server binary wiring all components, config validation, /healthz
- Config: ValidateConfig with startup validation

14 test packages pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 02:18:36 +01:00
parent fa27adbc77
commit c18adb6441
9 changed files with 1345 additions and 2 deletions

View File

@@ -0,0 +1,63 @@
// Package config handles loading and validating the KeyCape server configuration
// from a YAML file. The config path is resolved from the --config flag or the
// KEYCAPE_CONFIG environment variable.
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
"keycape/internal/adapters/authelia"
"keycape/internal/adapters/lldap"
"keycape/internal/adapters/privacyidea"
)
// Config is the top-level server configuration.
type Config struct {
Issuer string `yaml:"issuer"`
Port int `yaml:"port"`
TokenLifetime string `yaml:"tokenLifetime"`
PrivateKeyPEM string `yaml:"privateKeyPem"`
LLDAP lldap.Config `yaml:"lldap"`
Authelia authelia.Config `yaml:"authelia"`
PrivacyIDEA privacyidea.Config `yaml:"privacyidea"`
Clients []ClientConfig `yaml:"clients"`
Environment string `yaml:"environment"`
}
// ClientConfig is a static OIDC client registration.
type ClientConfig struct {
ClientID string `yaml:"clientId"`
DisplayName string `yaml:"displayName"`
RedirectURIs []string `yaml:"redirectUris"`
AllowedScopes []string `yaml:"allowedScopes"`
GrantTypes []string `yaml:"grantTypes"`
ClientType string `yaml:"clientType"` // "confidential" | "public"
SecretRef string `yaml:"secretRef,omitempty"`
}
// Load reads and parses the YAML config file at path.
// If path is empty, it falls back to the KEYCAPE_CONFIG environment variable.
// Returns an error if the file cannot be read or parsed.
func Load(path string) (*Config, error) {
if path == "" {
path = os.Getenv("KEYCAPE_CONFIG")
}
if path == "" {
return nil, fmt.Errorf("config: no config path specified (use --config or KEYCAPE_CONFIG)")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config: read %q: %w", path, err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("config: parse %q: %w", path, err)
}
return &cfg, nil
}

View File

@@ -0,0 +1,225 @@
package config_test
import (
"os"
"path/filepath"
"testing"
"keycape/internal/config"
)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// writeTempFile creates a temporary file with the given content and returns its path.
func writeTempFile(t *testing.T, content string) string {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "keycape-test-*")
if err != nil {
t.Fatalf("create temp file: %v", err)
}
if _, err := f.WriteString(content); err != nil {
t.Fatalf("write temp file: %v", err)
}
f.Close()
return f.Name()
}
// validConfig returns a minimal valid Config for use in tests.
func validConfig(keyPath string) *config.Config {
return &config.Config{
Issuer: "https://auth.example.com",
Port: 8080,
TokenLifetime: "15m",
PrivateKeyPEM: keyPath,
Environment: "dev",
Clients: []config.ClientConfig{
{
ClientID: "test-app",
DisplayName: "Test App",
RedirectURIs: []string{"https://app.example.com/callback"},
ClientType: "public",
},
},
}
}
// ---------------------------------------------------------------------------
// Load tests
// ---------------------------------------------------------------------------
func TestLoad_ValidYAML(t *testing.T) {
keyPath := writeTempFile(t, "placeholder-key")
yaml := `
issuer: "https://auth.example.com"
port: 8080
tokenLifetime: "15m"
privateKeyPem: "` + keyPath + `"
environment: "dev"
clients:
- clientId: "demo"
displayName: "Demo"
redirectUris:
- "https://demo.example.com/cb"
clientType: "public"
`
cfgPath := writeTempFile(t, yaml)
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("Load: unexpected error: %v", err)
}
if cfg.Issuer != "https://auth.example.com" {
t.Errorf("Issuer: want %q, got %q", "https://auth.example.com", cfg.Issuer)
}
if cfg.Port != 8080 {
t.Errorf("Port: want 8080, got %d", cfg.Port)
}
if len(cfg.Clients) != 1 {
t.Errorf("Clients: want 1, got %d", len(cfg.Clients))
}
}
func TestLoad_FileNotFound(t *testing.T) {
_, err := config.Load(filepath.Join(t.TempDir(), "nonexistent.yaml"))
if err == nil {
t.Error("Load: expected error for missing file, got nil")
}
}
func TestLoad_InvalidYAML(t *testing.T) {
bad := writeTempFile(t, "not: valid: yaml: [[[")
_, err := config.Load(bad)
if err == nil {
t.Error("Load: expected error for invalid YAML, got nil")
}
}
// ---------------------------------------------------------------------------
// Validate tests
// ---------------------------------------------------------------------------
func TestValidate_ValidConfig(t *testing.T) {
keyPath := writeTempFile(t, "key")
errs := config.ValidateConfig(validConfig(keyPath))
if len(errs) != 0 {
t.Errorf("ValidateConfig: expected no errors, got %v", errs)
}
}
func TestValidate_MissingIssuer(t *testing.T) {
keyPath := writeTempFile(t, "key")
cfg := validConfig(keyPath)
cfg.Issuer = ""
errs := config.ValidateConfig(cfg)
if !containsErr(errs, "issuer") {
t.Errorf("expected issuer error, got %v", errs)
}
}
func TestValidate_InvalidIssuerURL(t *testing.T) {
keyPath := writeTempFile(t, "key")
cfg := validConfig(keyPath)
cfg.Issuer = "not a url"
errs := config.ValidateConfig(cfg)
if !containsErr(errs, "issuer") {
t.Errorf("expected issuer URL error, got %v", errs)
}
}
func TestValidate_PortZero(t *testing.T) {
keyPath := writeTempFile(t, "key")
cfg := validConfig(keyPath)
cfg.Port = 0
errs := config.ValidateConfig(cfg)
if !containsErr(errs, "port") {
t.Errorf("expected port error, got %v", errs)
}
}
func TestValidate_PortTooHigh(t *testing.T) {
keyPath := writeTempFile(t, "key")
cfg := validConfig(keyPath)
cfg.Port = 70000
errs := config.ValidateConfig(cfg)
if !containsErr(errs, "port") {
t.Errorf("expected port error, got %v", errs)
}
}
func TestValidate_NoClients(t *testing.T) {
keyPath := writeTempFile(t, "key")
cfg := validConfig(keyPath)
cfg.Clients = nil
errs := config.ValidateConfig(cfg)
if !containsErr(errs, "client") {
t.Errorf("expected client error, got %v", errs)
}
}
func TestValidate_ClientMissingRedirectURI(t *testing.T) {
keyPath := writeTempFile(t, "key")
cfg := validConfig(keyPath)
cfg.Clients[0].RedirectURIs = nil
errs := config.ValidateConfig(cfg)
if !containsErr(errs, "redirect") {
t.Errorf("expected redirect_uri error, got %v", errs)
}
}
func TestValidate_MissingPrivateKeyPEM(t *testing.T) {
cfg := validConfig("")
cfg.PrivateKeyPEM = ""
errs := config.ValidateConfig(cfg)
if !containsErr(errs, "privateKeyPem") {
t.Errorf("expected privateKeyPem error, got %v", errs)
}
}
// ---------------------------------------------------------------------------
// Env var loading test
// ---------------------------------------------------------------------------
func TestLoad_FromEnvVar(t *testing.T) {
keyPath := writeTempFile(t, "key")
yaml := `
issuer: "https://auth.example.com"
port: 9090
tokenLifetime: "30m"
privateKeyPem: "` + keyPath + `"
environment: "dev"
clients:
- clientId: "env-app"
displayName: "Env App"
redirectUris:
- "https://env.example.com/cb"
clientType: "public"
`
cfgPath := writeTempFile(t, yaml)
t.Setenv("KEYCAPE_CONFIG", cfgPath)
// Load with empty path triggers env var lookup.
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load with env var: %v", err)
}
if cfg.Port != 9090 {
t.Errorf("Port: want 9090, got %d", cfg.Port)
}
}
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
func containsErr(errs []string, substring string) bool {
for _, e := range errs {
for i := 0; i <= len(e)-len(substring); i++ {
if e[i:i+len(substring)] == substring {
return true
}
}
}
return false
}

View File

@@ -0,0 +1,61 @@
package config
import (
"fmt"
"net/url"
"strings"
)
// ValidateConfig validates a loaded Config and returns a list of human-readable
// error messages. An empty slice means the config is valid.
// Called at startup — the server must exit 1 if any errors are returned.
func ValidateConfig(cfg *Config) []string {
var errs []string
// Issuer must be a valid URL with an http(s) scheme.
if cfg.Issuer == "" {
errs = append(errs, "issuer: must not be empty")
} else {
u, err := url.Parse(cfg.Issuer)
if err != nil || u.Scheme == "" || u.Host == "" {
errs = append(errs, fmt.Sprintf("issuer: %q is not a valid URL (must include scheme and host)", cfg.Issuer))
} else if u.Scheme != "http" && u.Scheme != "https" {
errs = append(errs, fmt.Sprintf("issuer: scheme must be http or https, got %q", u.Scheme))
}
}
// Port must be in the valid TCP range.
if cfg.Port < 1 || cfg.Port > 65535 {
errs = append(errs, fmt.Sprintf("port: must be between 1 and 65535, got %d", cfg.Port))
}
// At least one client must be registered.
if len(cfg.Clients) == 0 {
errs = append(errs, "clients: at least one client must be defined")
}
// Each client must have at least one redirect URI and a non-empty clientId.
for i, c := range cfg.Clients {
prefix := fmt.Sprintf("clients[%d] (%s)", i, c.ClientID)
if c.ClientID == "" {
prefix = fmt.Sprintf("clients[%d]", i)
errs = append(errs, prefix+": clientId must not be empty")
}
if len(c.RedirectURIs) == 0 {
errs = append(errs, prefix+": redirect_uri: at least one redirectUri must be registered")
}
// Warn about wildcard redirect URIs (they are blocked at runtime anyway).
for _, uri := range c.RedirectURIs {
if strings.ContainsAny(uri, "*?") {
errs = append(errs, prefix+fmt.Sprintf(": redirect_uri %q must not contain wildcards", uri))
}
}
}
// Private key PEM path must be provided (existence is checked at startup).
if cfg.PrivateKeyPEM == "" {
errs = append(errs, "privateKeyPem: path must not be empty")
}
return errs
}