NextMyDay/internal/core/config/config_test.go
Claus Lohmar f5e4fd0055 refactor(config): drop the dead secrets.session_key
REVIEW-001 (M0) L1c/L2s: secrets.session_key was validated and generated by
the wizard but never read by any code path — dead config (KISS).

Remove the field, its validation entry and its generation; the example notes
that a placeholder is replaced at startup in setup mode (M1s) as well as by
the wizard's finish.
2026-10-14 11:30:06 +00:00

311 lines
9 KiB
Go

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")
}
}