package config_test import ( "errors" "os" "path/filepath" "strings" "testing" "time" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config" ) // writeConfig writes body to dir/name and returns the full path. func writeConfig(t *testing.T, dir, name, body string) string { t.Helper() p := filepath.Join(dir, name) if err := os.WriteFile(p, []byte(body), 0o600); err != nil { t.Fatalf("write %s: %v", p, err) } return p } const setupYAML = ` setup: true server: listen: "127.0.0.1:8080" base_url: "http://127.0.0.1:8080" trusted_proxies: - "127.0.0.1" app: locale: "en-GB" timezone: "Europe/London" database: path: "./data/myday.db" secrets: cookie_secret: "CHANGE_ME_32_BYTES_RANDOM" csrf_key: "CHANGE_ME_32_BYTES_RANDOM" totp_encryption_key: "CHANGE_ME_32_BYTES_RANDOM" mail: host: "smtp.example.com" port: 587 username: "" password: "CHANGE_ME" from: "myday@example.com" modules: requests: true expense: false ` const productiveYAML = ` setup: false server: listen: "0.0.0.0:8080" base_url: "https://myday.example.com" database: path: "./data/myday.db" secrets: cookie_secret: "0123456789abcdef0123456789abcdef" csrf_key: "0123456789abcdef0123456789abcdef" totp_encryption_key: "0123456789abcdef0123456789abcdef" ` func TestLoadParsesTypedStruct(t *testing.T) { dir := t.TempDir() writeConfig(t, dir, config.FileName, setupYAML) cfg, err := config.Load(filepath.Join(dir, config.FileName)) if err != nil { t.Fatalf("Load: %v", err) } if !cfg.Setup { t.Error("Setup = false, want true") } if cfg.Server.Listen != "127.0.0.1:8080" { t.Errorf("Listen = %q", cfg.Server.Listen) } if cfg.Mail.Port != 587 { t.Errorf("Mail.Port = %d, want 587", cfg.Mail.Port) } if cfg.App.Timezone != "Europe/London" { t.Errorf("Timezone = %q", cfg.App.Timezone) } if !cfg.Modules["requests"] || cfg.Modules["expense"] { t.Errorf("Modules = %v", cfg.Modules) } if cfg.IsSecure() { t.Error("IsSecure = true for an http base_url") } } // TestLoadFallsBackToExample covers the dev fallback: a fresh checkout has only // the committed example. func TestLoadFallsBackToExample(t *testing.T) { dir := t.TempDir() writeConfig(t, dir, config.ExampleFileName, setupYAML) cfg, err := config.Load(filepath.Join(dir, config.FileName)) if err != nil { t.Fatalf("Load (fallback): %v", err) } if filepath.Base(cfg.Source()) != config.ExampleFileName { t.Errorf("Source = %q, want the example fallback", cfg.Source()) } } // TestLoadNonCanonicalMissingIsError: only config.yaml gets the fallback. func TestLoadNonCanonicalMissingIsError(t *testing.T) { dir := t.TempDir() writeConfig(t, dir, config.ExampleFileName, setupYAML) if _, err := config.Load(filepath.Join(dir, "other.yaml")); err == nil { t.Error("Load(other.yaml, missing) = nil, want an error (no fallback)") } } // TestSetupModeToleratesPlaceholders: setup mode may use the example as-is. func TestSetupModeToleratesPlaceholders(t *testing.T) { dir := t.TempDir() writeConfig(t, dir, config.FileName, setupYAML) if _, err := config.Load(filepath.Join(dir, config.FileName)); err != nil { t.Fatalf("setup-mode load failed on placeholder secrets: %v", err) } } // TestProductiveModeRequiresStrongSecrets is the fail-closed gate (SPEC-003 §1). func TestProductiveModeRequiresStrongSecrets(t *testing.T) { cases := []struct { name string body string want error }{ { name: "placeholder secret", body: strings.Replace(productiveYAML, "0123456789abcdef0123456789abcdef", "CHANGE_ME_32_BYTES_RANDOM", 1), want: config.ErrWeakSecret, }, { name: "short secret", body: strings.Replace(productiveYAML, "0123456789abcdef0123456789abcdef", "tooshort", 1), want: config.ErrWeakSecret, }, { name: "totp key wrong length", body: strings.Replace(productiveYAML, `totp_encryption_key: "0123456789abcdef0123456789abcdef"`, `totp_encryption_key: "0123456789abcdef0123456789abcdefff"`, 1), want: config.ErrWeakSecret, }, { name: "empty listen", body: strings.Replace(productiveYAML, `listen: "0.0.0.0:8080"`, `listen: ""`, 1), want: config.ErrListenRequired, }, { name: "missing base_url", body: strings.Replace(productiveYAML, `base_url: "https://myday.example.com"`, `base_url: ""`, 1), want: config.ErrBaseURLRequired, }, { name: "empty database path", body: strings.Replace(productiveYAML, `path: "./data/myday.db"`, `path: ""`, 1), want: config.ErrDatabaseRequired, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() writeConfig(t, dir, config.FileName, tc.body) _, err := config.Load(filepath.Join(dir, config.FileName)) if !errors.Is(err, tc.want) { t.Errorf("Load = %v, want %v", err, tc.want) } }) } } // TestSecretIsPlaceholder covers the R-6 detector the wizard uses to decide // which secrets to generate. func TestSecretIsPlaceholder(t *testing.T) { cases := []struct { value string want bool }{ {"", true}, {" ", true}, {"CHANGE_ME_32_BYTES_RANDOM", true}, {"change_me", true}, {"my-PLACEHOLDER-value", true}, {"0123456789abcdef0123456789abcdef", false}, {"a-strong-generated-secret-value-here", false}, } for _, tc := range cases { if got := config.SecretIsPlaceholder(tc.value); got != tc.want { t.Errorf("SecretIsPlaceholder(%q) = %v, want %v", tc.value, got, tc.want) } } } func TestProductiveModeStrongConfigLoads(t *testing.T) { dir := t.TempDir() writeConfig(t, dir, config.FileName, productiveYAML) cfg, err := config.Load(filepath.Join(dir, config.FileName)) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Setup { t.Error("Setup = true, want false") } if !cfg.IsSecure() { t.Error("IsSecure = false for an https base_url") } } // TestUnknownKeyFailsClosed: a typo in config.yaml must not be silently ignored. func TestUnknownKeyFailsClosed(t *testing.T) { dir := t.TempDir() writeConfig(t, dir, config.FileName, setupYAML+"\nnot_a_real_key: true\n") if _, err := config.Load(filepath.Join(dir, config.FileName)); err == nil { t.Error("Load with an unknown key = nil, want an error (fail closed)") } } // TestEnrollWindow covers the config-driven bounded window (A1). func TestEnrollWindow(t *testing.T) { // Unset -> the 72h default. if got := (&config.Config{}).EnrollWindow(); got != config.DefaultEnrollWindow { t.Errorf("default EnrollWindow = %v, want %v", got, config.DefaultEnrollWindow) } // Configured. c := &config.Config{Enrollment: config.Enrollment{Window: "12h"}} if got := c.EnrollWindow(); got != 12*time.Hour { t.Errorf("EnrollWindow(12h) = %v, want 12h", got) } // A malformed window is a configuration error in any mode. bad := &config.Config{ Server: config.Server{Listen: "127.0.0.1:8080"}, Database: config.Database{Path: "./data/myday.db"}, Enrollment: config.Enrollment{Window: "not-a-duration"}, } if err := bad.Validate(); !errors.Is(err, config.ErrInvalidEnrollWindow) { t.Errorf("Validate(bad window) = %v, want ErrInvalidEnrollWindow", err) } } // TestLoadParsesEnrollmentWindow reads the window from YAML. func TestLoadParsesEnrollmentWindow(t *testing.T) { dir := t.TempDir() writeConfig(t, dir, config.FileName, setupYAML+"\nenrollment:\n window: \"48h\"\n") cfg, err := config.Load(filepath.Join(dir, config.FileName)) if err != nil { t.Fatalf("Load: %v", err) } if got := cfg.EnrollWindow(); got != 48*time.Hour { t.Errorf("EnrollWindow = %v, want 48h", got) } } // TestMarshalRoundTrip: the wizard's persister produces a document that loads // back through the SAME strict loader (SPEC-005 §4, D-035). A written config // must be exactly as valid as a hand-written one. func TestMarshalRoundTrip(t *testing.T) { const good = "0123456789abcdef0123456789abcdef" src := &config.Config{ Setup: false, Server: config.Server{ Listen: "127.0.0.1:8080", BaseURL: "http://127.0.0.1:8080", TrustedProxies: []string{"127.0.0.1"}, }, App: config.App{Locale: "en-GB", Timezone: "Europe/London"}, Database: config.Database{Path: "./data/myday.db"}, Secrets: config.Secrets{ CookieSecret: good, CSRFKey: good, TOTPEncryptionKey: good, }, Mail: config.Mail{Host: "smtp.example.com", Port: 587, From: "myday@example.com"}, Modules: map[string]bool{"time": true}, Enrollment: config.Enrollment{Window: "48h"}, } data, err := config.Marshal(src) if err != nil { t.Fatalf("Marshal: %v", err) } dir := t.TempDir() path := writeConfig(t, dir, config.FileName, string(data)) got, err := config.Load(path) if err != nil { t.Fatalf("the marshalled config does not load back: %v", err) } if got.Server.BaseURL != src.Server.BaseURL || got.App.Locale != src.App.Locale || got.App.Timezone != src.App.Timezone || got.Mail.Port != src.Mail.Port || got.Secrets.TOTPEncryptionKey != src.Secrets.TOTPEncryptionKey { t.Errorf("round-trip lost fields: %+v", got) } if got.EnrollWindow() != 48*time.Hour { t.Errorf("round-trip EnrollWindow = %v, want 48h", got.EnrollWindow()) } if got.Setup { t.Error("round-trip Setup = true, want false") } }