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_AutheliaSplitURLs(t *testing.T) { keyPath := writeTempFile(t, "placeholder-key") yaml := ` issuer: "https://kc.example.com" port: 8080 tokenLifetime: "15m" privateKeyPem: "` + keyPath + `" environment: "dev" authelia: baseURL: "http://authelia.sso.svc.cluster.local:9091" browserBaseURL: "https://auth.example.com" tokenBaseURL: "http://authelia.sso.svc.cluster.local:9091" clientId: "keycape" clientSecret: "secret" redirectURI: "https://kc.example.com/authorize/callback" clients: - clientId: "netkingdom-bootstrap-console" displayName: "NetKingdom Bootstrap Console" redirectUris: - "http://127.0.0.1:8876/oidc/callback" - "http://localhost:8876/oidc/callback" clientType: "public" ` cfgPath := writeTempFile(t, yaml) cfg, err := config.Load(cfgPath) if err != nil { t.Fatalf("Load: unexpected error: %v", err) } if cfg.Authelia.BaseURL != "http://authelia.sso.svc.cluster.local:9091" { t.Errorf("Authelia.BaseURL: got %q", cfg.Authelia.BaseURL) } if cfg.Authelia.BrowserBaseURL != "https://auth.example.com" { t.Errorf("Authelia.BrowserBaseURL: got %q", cfg.Authelia.BrowserBaseURL) } if cfg.Authelia.TokenBaseURL != "http://authelia.sso.svc.cluster.local:9091" { t.Errorf("Authelia.TokenBaseURL: got %q", cfg.Authelia.TokenBaseURL) } if len(cfg.Clients) != 1 || cfg.Clients[0].ClientID != "netkingdom-bootstrap-console" { t.Fatalf("bootstrap client not loaded: %+v", cfg.Clients) } if got := cfg.Clients[0].RedirectURIs; len(got) != 2 || got[0] != "http://127.0.0.1:8876/oidc/callback" { t.Errorf("bootstrap redirect URIs not loaded: %+v", got) } } func TestLoad_ClientMFAAndRegistrationURL(t *testing.T) { keyPath := writeTempFile(t, "placeholder-key") yaml := ` issuer: "https://kc.example.com" port: 8080 tokenLifetime: "15m" privateKeyPem: "` + keyPath + `" environment: "dev" clients: - clientId: "coulomb-social" displayName: "coulomb.social" redirectUris: - "https://coulomb.social/auth/callback/" clientType: "public" mfaRequired: false registrationUrl: "https://users.example.com/register" ` cfgPath := writeTempFile(t, yaml) cfg, err := config.Load(cfgPath) if err != nil { t.Fatalf("Load: unexpected error: %v", err) } if len(cfg.Clients) != 1 { t.Fatalf("clients: got %d", len(cfg.Clients)) } c := cfg.Clients[0] if c.MFARequired == nil || *c.MFARequired { t.Fatalf("mfaRequired: want false, got %+v", c.MFARequired) } if c.RegistrationURL != "https://users.example.com/register" { t.Errorf("registrationUrl: got %q", c.RegistrationURL) } } func TestValidate_InvalidRegistrationURL(t *testing.T) { keyPath := writeTempFile(t, "key") cfg := validConfig(keyPath) cfg.Clients[0].RegistrationURL = "javascript:alert(1)" errs := config.ValidateConfig(cfg) if !containsErr(errs, "registrationUrl") { t.Errorf("expected registrationUrl error, got %v", errs) } } func TestLoad_PrivacyIDEARequireForAll(t *testing.T) { keyPath := writeTempFile(t, "placeholder-key") yaml := ` issuer: "https://kc.example.com" port: 8080 tokenLifetime: "15m" privateKeyPem: "` + keyPath + `" environment: "dev" privacyidea: baseURL: "http://privacyidea.mfa.svc.cluster.local:8080" adminToken: "service-token" realm: "coulomb" requireForAll: true clients: - clientId: "netkingdom-bootstrap-console" displayName: "NetKingdom Bootstrap Console" redirectUris: - "http://127.0.0.1:8876/oidc/callback" clientType: "public" ` cfgPath := writeTempFile(t, yaml) cfg, err := config.Load(cfgPath) if err != nil { t.Fatalf("Load: unexpected error: %v", err) } if cfg.PrivacyIDEA.Realm != "coulomb" { t.Errorf("PrivacyIDEA.Realm: got %q", cfg.PrivacyIDEA.Realm) } if !cfg.PrivacyIDEA.RequireForAll { t.Error("PrivacyIDEA.RequireForAll should load from YAML") } } func TestLoad_LLDAPOrganisationalUnits(t *testing.T) { keyPath := writeTempFile(t, "placeholder-key") yaml := ` issuer: "https://kc.example.com" port: 8080 tokenLifetime: "15m" privateKeyPem: "` + keyPath + `" environment: "dev" lldap: url: "ldap://lldap.sso.svc.cluster.local:3890" bindDN: "uid=admin,ou=people,dc=netkingdom,dc=local" bindPW: "secret" baseDN: "dc=netkingdom,dc=local" userOU: "ou=people" groupOU: "ou=groups" clients: - clientId: "netkingdom-bootstrap-console" displayName: "NetKingdom Bootstrap Console" redirectUris: - "http://127.0.0.1:8876/oidc/callback" clientType: "public" ` cfgPath := writeTempFile(t, yaml) cfg, err := config.Load(cfgPath) if err != nil { t.Fatalf("Load: unexpected error: %v", err) } if cfg.LLDAP.UserOU != "ou=people" { t.Errorf("LLDAP.UserOU: got %q", cfg.LLDAP.UserOU) } if cfg.LLDAP.GroupOU != "ou=groups" { t.Errorf("LLDAP.GroupOU: got %q", cfg.LLDAP.GroupOU) } } 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) } } func TestValidate_ClientCredentialsTokenLifetime(t *testing.T) { keyPath := writeTempFile(t, "key") cfg := validConfig(keyPath) cfg.Clients[0] = config.ClientConfig{ ClientID: "service-client", ClientType: "confidential", GrantTypes: []string{"client_credentials"}, AllowedScopes: []string{"openbao:login"}, SecretRef: "env:SERVICE_CLIENT_SECRET", ServiceSubject: "service:test", Tenant: "tenant:coulomb", TokenLifetime: "15m", } if errs := config.ValidateConfig(cfg); len(errs) != 0 { t.Fatalf("valid per-client token lifetime rejected: %v", errs) } cfg.Clients[0].TokenLifetime = "90m" if errs := config.ValidateConfig(cfg); !containsErr(errs, "between 1m and 1h") { t.Fatalf("expected bounded tokenLifetime error, got %v", errs) } cfg.Clients[0].TokenLifetime = "not-a-duration" if errs := config.ValidateConfig(cfg); !containsErr(errs, "valid duration") { t.Fatalf("expected invalid tokenLifetime error, got %v", errs) } } func TestValidate_PublicClientRejectsTokenLifetime(t *testing.T) { keyPath := writeTempFile(t, "key") cfg := validConfig(keyPath) cfg.Clients[0].TokenLifetime = "15m" if errs := config.ValidateConfig(cfg); !containsErr(errs, "only supported for client_credentials") { t.Fatalf("expected public-client tokenLifetime error, got %v", errs) } } func TestServiceClientExampleContracts(t *testing.T) { cfg, err := config.Load(filepath.Join("..", "..", "..", "config", "service-clients.example.yaml")) if err != nil { t.Fatalf("load service client examples: %v", err) } cfg.Issuer = "https://kc.coulomb.social" cfg.Port = 8080 cfg.PrivateKeyPEM = writeTempFile(t, "key") if errs := config.ValidateConfig(cfg); len(errs) != 0 { t.Fatalf("service client examples must validate: %v", errs) } if len(cfg.Clients) != 4 { t.Fatalf("service client examples: want 4, got %d", len(cfg.Clients)) } codingAgent := cfg.Clients[0] if codingAgent.ClientID != "codex-railiance-platform" || codingAgent.ServiceSubject != "service:codex:railiance-platform" || codingAgent.Tenant != "tenant:coulomb" || codingAgent.TokenLifetime != "15m" { t.Fatalf("coding-agent contract drifted: %+v", codingAgent) } if len(codingAgent.Roles) != 1 || codingAgent.Roles[0] != "coding-agent" || len(codingAgent.AllowedScopes) != 1 || codingAgent.AllowedScopes[0] != "openbao:login" { t.Fatalf("coding-agent authorization contract drifted: %+v", codingAgent) } secretsEngine := cfg.Clients[1] if secretsEngine.ClientID != "secrets-engine-openbao" || secretsEngine.ServiceSubject != "service:secrets-engine" || secretsEngine.TokenLifetime != "15m" { t.Fatalf("secrets-engine contract drifted: %+v", secretsEngine) } } func TestDevConfigOpenBaoAdminAdmitsOperatorTunnelCallback(t *testing.T) { cfg, err := config.Load(filepath.Join("..", "..", "..", "config", "dev-config.yaml")) if err != nil { t.Fatalf("load dev config: %v", err) } if errs := config.ValidateConfig(cfg); len(errs) != 0 { t.Fatalf("dev config must validate: %v", errs) } const callback = "http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback" for _, client := range cfg.Clients { if client.ClientID != "openbao-admin" { continue } for _, redirectURI := range client.RedirectURIs { if redirectURI == callback { return } } t.Fatalf("openbao-admin redirectUris missing exact operator tunnel callback %q", callback) } t.Fatal("dev config missing openbao-admin client") } // --------------------------------------------------------------------------- // 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 } func TestValidateConfigAudience(t *testing.T) { for _, audience := range []string{"", "approval-engine", "https://api.example.com"} { cfg := validConfig("key.pem") cfg.Clients[0].Audience = audience if errs := config.ValidateConfig(cfg); len(errs) != 0 { t.Fatalf("valid audience %q: %v", audience, errs) } } for _, audience := range []string{" ", "approval-engine other", "approval-engine\n"} { cfg := validConfig("key.pem") cfg.Clients[0].Audience = audience if errs := config.ValidateConfig(cfg); len(errs) == 0 { t.Fatalf("accepted audience %q", audience) } } } // tenant_roles is opt-in, but a configured cache source must be usable: it sits // on the synchronous token-issuance path (KEY-WP-0024). func TestValidate_TenantEngine(t *testing.T) { cases := map[string]struct { engine config.TenantEngineConfig valid bool }{ "absent is valid": {config.TenantEngineConfig{}, true}, "http url": {config.TenantEngineConfig{BaseURL: "http://tenant-engine:8080"}, true}, "https url and timeout": {config.TenantEngineConfig{BaseURL: "https://tenant-engine", Timeout: "2s"}, true}, "not a url": {config.TenantEngineConfig{BaseURL: "tenant-engine"}, false}, "wrong scheme": {config.TenantEngineConfig{BaseURL: "ldap://tenant-engine"}, false}, "unparseable timeout": {config.TenantEngineConfig{BaseURL: "http://t", Timeout: "soon"}, false}, "zero timeout": {config.TenantEngineConfig{BaseURL: "http://t", Timeout: "0s"}, false}, "timeout far too long": {config.TenantEngineConfig{BaseURL: "http://t", Timeout: "5m"}, false}, // A timeout with no base URL means someone expected the claim to be on. "timeout without url": {config.TenantEngineConfig{Timeout: "2s"}, false}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { cfg := validConfig(writeTempFile(t, "key")) cfg.TenantEngine = tc.engine errs := config.ValidateConfig(cfg) if tc.valid && len(errs) != 0 { t.Fatalf("expected valid, got %v", errs) } if !tc.valid && len(errs) == 0 { t.Fatal("expected a validation error") } }) } }