Implements D-025 (revised 2026-10-15) and D-043. Setup gate (D-025): - Remove the bind refusal (CheckSetupBind/ErrNonLoopbackSetup/server.IsLoopback); Serve binds any address, so the app can listen on 0.0.0.0:8080 behind Zoraxy. - The gate is now per request and strict (httpx.SetupRequestAllowed): a loopback TCP peer AND no X-Forwarded-For — the direct SSH tunnel. Anything else is 404 before dispatch; an unparseable peer fails closed (AC-7/AC-10). The raw XFF is read before ClientIP SETS it (A6). Proxy scheme (D-043): - httpx.RequestIsHTTPS resolves the scheme per request: direct TLS, or a trusted proxy's X-Forwarded-Proto. The door carries it on the request context (httpx.WithSecure) so the session cookie, its __Host- prefix, the pre-session and ceremony cookies, and HSTS all agree (AC-13). - Over plain http (the tunnel) cookies stay bare and non-Secure, which fixes the bootstrap from an https:// config.example.yaml; Config.IsSecure() is removed as now dead. Tests: rewrite the setup-gate tests for AC-7/AC-10, add AC-13, and unit-test SetupRequestAllowed / RequestIsHTTPS.
305 lines
8.8 KiB
Go
305 lines
8.8 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)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|