This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProductionListsAllMissingMandatoryValues(t *testing.T) {
|
||||
values := map[string]string{"PULSE_ENV": "production"}
|
||||
_, err := LoadFrom(mapLookup(values))
|
||||
if err == nil {
|
||||
t.Fatal("expected production validation error")
|
||||
}
|
||||
message := err.Error()
|
||||
for _, key := range []string{"PULSE_PUBLIC_URL", "PULSE_DATABASE_URL", "PULSE_OIDC_ISSUER", "PULSE_OIDC_CLIENT_ID", "PULSE_OIDC_CLIENT_SECRET", "PULSE_OIDC_REDIRECT_URL"} {
|
||||
if !strings.Contains(message, key) {
|
||||
t.Errorf("error %q does not mention %s", message, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookConfigurationIsSecureAndRedacted(t *testing.T) {
|
||||
secret := "webhook-runtime-secret"
|
||||
configuration, err := LoadFrom(mapLookup(map[string]string{
|
||||
"PULSE_ENV": "development",
|
||||
"PULSE_NOTIFICATION_WEBHOOK_URL": "http://127.0.0.1:8080/pulse",
|
||||
"PULSE_NOTIFICATION_WEBHOOK_TOKEN": secret,
|
||||
"PULSE_NOTIFICATION_WEBHOOK_TIMEOUT": "3s",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if configuration.NotificationWebhookTimeout != 3*time.Second {
|
||||
t.Fatalf("timeout = %s", configuration.NotificationWebhookTimeout)
|
||||
}
|
||||
if strings.Contains(configuration.String(), secret) || strings.Contains(configuration.Redacted().NotificationWebhookToken, secret) {
|
||||
t.Fatal("webhook credential leaked through config rendering")
|
||||
}
|
||||
for name, values := range map[string]map[string]string{
|
||||
"missing token": {"PULSE_NOTIFICATION_WEBHOOK_URL": "https://receiver.example/hook"},
|
||||
"query token": {"PULSE_NOTIFICATION_WEBHOOK_URL": "https://receiver.example/hook?token=value", "PULSE_NOTIFICATION_WEBHOOK_TOKEN": secret},
|
||||
"production http": {"PULSE_ENV": "production", "PULSE_NOTIFICATION_WEBHOOK_URL": "http://receiver.example/hook", "PULSE_NOTIFICATION_WEBHOOK_TOKEN": secret},
|
||||
"unbounded timeout": {"PULSE_NOTIFICATION_WEBHOOK_URL": "https://receiver.example/hook", "PULSE_NOTIFICATION_WEBHOOK_TOKEN": secret, "PULSE_NOTIFICATION_WEBHOOK_TIMEOUT": "31s"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := LoadFrom(mapLookup(values)); err == nil {
|
||||
t.Fatal("expected webhook configuration rejection")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionRejectsMockAuth(t *testing.T) {
|
||||
values := map[string]string{
|
||||
"PULSE_ENV": "production", "PULSE_PUBLIC_URL": "https://pulse.example",
|
||||
"PULSE_DATABASE_URL": "postgres://pulse@db/pulse", "PULSE_OIDC_ISSUER": "https://auth.example",
|
||||
"PULSE_OIDC_CLIENT_ID": "pulse", "PULSE_OIDC_CLIENT_SECRET": "secret-value",
|
||||
"PULSE_OIDC_REDIRECT_URL": "https://pulse.example/auth/callback", "PULSE_AUTH_MODE": "mock",
|
||||
}
|
||||
_, err := LoadFrom(mapLookup(values))
|
||||
if err == nil || !strings.Contains(err.Error(), "mock") {
|
||||
t.Fatalf("expected mock-auth rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrorsAndStringNeverExposeSecrets(t *testing.T) {
|
||||
secret := "super-secret-token"
|
||||
values := map[string]string{
|
||||
"PULSE_ENV": "production", "PULSE_DATABASE_URL": "not-a-url", "PULSE_OIDC_CLIENT_SECRET": secret,
|
||||
"PULSE_UNRAID_API_TOKEN": secret, "PULSE_AUTH_MODE": "mock",
|
||||
}
|
||||
config, err := LoadFrom(mapLookup(values))
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Fatal("validation error leaked a secret")
|
||||
}
|
||||
if strings.Contains(config.String(), secret) {
|
||||
t.Fatal("config String leaked a secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopmentDefaultsAllowExplicitMockMode(t *testing.T) {
|
||||
values := map[string]string{"PULSE_ENV": "development", "PULSE_AUTH_MODE": "mock"}
|
||||
config, err := LoadFrom(mapLookup(values))
|
||||
if err != nil {
|
||||
t.Fatalf("development defaults rejected: %v", err)
|
||||
}
|
||||
if config.Environment != Development || config.AuthMode != "mock" {
|
||||
t.Fatalf("unexpected config: %s", config)
|
||||
}
|
||||
if config.SessionIdleTTL != 8*time.Hour || config.SessionAbsoluteTTL != 7*24*time.Hour {
|
||||
t.Fatalf("unexpected session defaults: idle=%s absolute=%s", config.SessionIdleTTL, config.SessionAbsoluteTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLifetimeConfigurationIsBounded(t *testing.T) {
|
||||
configured, err := LoadFrom(mapLookup(map[string]string{
|
||||
"PULSE_ENV": "development", "PULSE_AUTH_MODE": "mock",
|
||||
"PULSE_SESSION_IDLE_TTL": "20s", "PULSE_SESSION_ABSOLUTE_TTL": "2m",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if configured.SessionIdleTTL != 20*time.Second || configured.SessionAbsoluteTTL != 2*time.Minute {
|
||||
t.Fatalf("unexpected session lifetimes: %#v", configured)
|
||||
}
|
||||
|
||||
for name, values := range map[string]map[string]string{
|
||||
"invalid duration": {"PULSE_SESSION_IDLE_TTL": "later"},
|
||||
"idle too short": {"PULSE_SESSION_IDLE_TTL": "4s"},
|
||||
"absolute shorter than idle": {"PULSE_SESSION_IDLE_TTL": "20s", "PULSE_SESSION_ABSOLUTE_TTL": "10s"},
|
||||
"absolute unbounded": {"PULSE_SESSION_ABSOLUTE_TTL": "721h"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := LoadFrom(mapLookup(values)); err == nil {
|
||||
t.Fatal("expected bounded session configuration rejection")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionRequiresWallboardCapableAbsoluteSessionLifetime(t *testing.T) {
|
||||
config := Config{
|
||||
Environment: Production, Timezone: "Europe/Brussels", DefaultLocale: "nl-BE", LogLevel: "info",
|
||||
PublicURL: "https://pulse.example", DatabaseURL: "postgres://pulse@db/pulse", AuthMode: "oidc",
|
||||
OIDCIssuer: "https://auth.example", OIDCClientID: "pulse", OIDCClientSecret: "secret",
|
||||
OIDCRedirectURL: "https://pulse.example/auth/callback", OIDCRoleMapping: map[string]string{"viewer": "viewer"},
|
||||
PrometheusTimeout: 10 * time.Second, NotificationWebhookTimeout: 10 * time.Second, BackupRetention: 5,
|
||||
SessionIdleTTL: 8 * time.Hour, SessionAbsoluteTTL: 23 * time.Hour,
|
||||
}
|
||||
if err := Validate(config); err == nil || !strings.Contains(err.Error(), "PULSE_SESSION_ABSOLUTE_TTL") {
|
||||
t.Fatalf("production accepted a session unable to cover the wallboard budget: %v", err)
|
||||
}
|
||||
config.SessionAbsoluteTTL = 24 * time.Hour
|
||||
config.SessionIdleTTL = 5 * time.Minute
|
||||
if err := Validate(config); err == nil || !strings.Contains(err.Error(), "PULSE_SESSION_IDLE_TTL") {
|
||||
t.Fatalf("production accepted an idle TTL that can race the five-minute wallboard refresh: %v", err)
|
||||
}
|
||||
config.SessionIdleTTL = 10 * time.Minute
|
||||
if err := Validate(config); err != nil {
|
||||
t.Fatalf("bounded 24-hour production session rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupConfigurationIsBoundedAndOptional(t *testing.T) {
|
||||
values := map[string]string{"PULSE_ENV": "development", "PULSE_AUTH_MODE": "mock", "PULSE_BACKUP_DIR": "C:/pulse-backups", "PULSE_BACKUP_RETENTION": "12"}
|
||||
config, err := LoadFrom(mapLookup(values))
|
||||
if err != nil {
|
||||
t.Fatalf("backup config rejected: %v", err)
|
||||
}
|
||||
if config.BackupDirectory != values["PULSE_BACKUP_DIR"] || config.BackupRetention != 12 {
|
||||
t.Fatalf("unexpected backup config: %#v", config)
|
||||
}
|
||||
values["PULSE_BACKUP_RETENTION"] = "101"
|
||||
if _, err := LoadFrom(mapLookup(values)); err == nil || !strings.Contains(err.Error(), "PULSE_BACKUP_RETENTION") {
|
||||
t.Fatalf("expected bounded retention error, got %v", err)
|
||||
}
|
||||
}
|
||||
func mapLookup(values map[string]string) func(string) (string, bool) {
|
||||
return func(key string) (string, bool) { value, ok := values[key]; return value, ok }
|
||||
}
|
||||
|
||||
func TestRoleMappingParsesClaimsOntoRoles(t *testing.T) {
|
||||
config, err := LoadFrom(mapLookup(map[string]string{
|
||||
"PULSE_OIDC_ROLE_MAPPING": "pulse-admin=administrator, pulse-staff =viewer,pulse-ops=Operator",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
expected := map[string]string{"pulse-admin": "administrator", "pulse-staff": "viewer", "pulse-ops": "operator"}
|
||||
if len(config.OIDCRoleMapping) != len(expected) {
|
||||
t.Fatalf("expected %d mapped claims, got %d", len(expected), len(config.OIDCRoleMapping))
|
||||
}
|
||||
for claim, role := range expected {
|
||||
if config.OIDCRoleMapping[claim] != role {
|
||||
t.Fatalf("claim %q mapped to %q, want %q", claim, config.OIDCRoleMapping[claim], role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleMappingDefaultsToNoAccess(t *testing.T) {
|
||||
config, err := LoadFrom(mapLookup(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if config.OIDCRoleMapping != nil {
|
||||
t.Fatal("an unset role mapping must grant nobody a role")
|
||||
}
|
||||
if config.OIDCGroupsClaim != "groups" {
|
||||
t.Fatalf("groups claim defaulted to %q, want groups", config.OIDCGroupsClaim)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleMappingRejectsMalformedInput(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"missing separator": "pulse-admin",
|
||||
"empty claim": "=administrator",
|
||||
"empty role": "pulse-admin=",
|
||||
"unknown role": "pulse-admin=superuser",
|
||||
"duplicate claim": "pulse-admin=viewer,pulse-admin=editor",
|
||||
"only separators": ",,",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := LoadFrom(mapLookup(map[string]string{"PULSE_OIDC_ROLE_MAPPING": raw})); err == nil {
|
||||
t.Fatalf("expected %q to be rejected", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionRequiresARoleMapping(t *testing.T) {
|
||||
base := map[string]string{
|
||||
"PULSE_ENV": "production",
|
||||
"PULSE_PUBLIC_URL": "https://pulse.example.test",
|
||||
"PULSE_DATABASE_URL": "postgres://pulse@db:5432/pulse",
|
||||
"PULSE_OIDC_ISSUER": "https://id.example.test",
|
||||
"PULSE_OIDC_CLIENT_ID": "pulse",
|
||||
"PULSE_OIDC_CLIENT_SECRET": "secret",
|
||||
"PULSE_OIDC_REDIRECT_URL": "https://pulse.example.test/auth/callback",
|
||||
}
|
||||
if _, err := LoadFrom(mapLookup(base)); err == nil {
|
||||
t.Fatal("production without a role mapping must fail: no identity could obtain a role")
|
||||
}
|
||||
base["PULSE_OIDC_ROLE_MAPPING"] = "pulse-admin=administrator"
|
||||
if _, err := LoadFrom(mapLookup(base)); err != nil {
|
||||
t.Fatalf("production with a role mapping must succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWorkerProductionDoesNotRequireAPICredentials(t *testing.T) {
|
||||
configuration, err := LoadWorkerFrom(mapLookup(map[string]string{
|
||||
"PULSE_ENV": "production",
|
||||
"PULSE_DATABASE_URL": "postgres://pulse:secret@pulse-postgres:5432/pulse?sslmode=disable",
|
||||
"PULSE_PROMETHEUS_URL": "http://192.0.2.10:9090",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWorkerFrom returned API-only validation error: %v", err)
|
||||
}
|
||||
if configuration.Environment != Production || configuration.DatabaseURL == "" {
|
||||
t.Fatalf("unexpected worker config: %#v", configuration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWorkerStillRequiresDatabaseAndValidatesSources(t *testing.T) {
|
||||
_, err := LoadWorkerFrom(mapLookup(map[string]string{
|
||||
"PULSE_ENV": "production",
|
||||
"PULSE_PROMETHEUS_URL": "://invalid",
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("LoadWorkerFrom accepted missing database and malformed Prometheus URL")
|
||||
}
|
||||
message := err.Error()
|
||||
if !strings.Contains(message, "PULSE_DATABASE_URL") || !strings.Contains(message, "PULSE_PROMETHEUS_URL") {
|
||||
t.Fatalf("worker validation error = %q", message)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user