test: add comprehensive test suite (44 → 169 tests) and v1.1 plan

Add 125 new test functions across 10 new test files, covering:
- CSRF middleware (8 tests): double-submit cookie validation
- Auth middleware (12 tests): SessionLoader, RequireAdmin, context helpers
- API handlers (28 tests): auth, faves CRUD, tags, users, export/import
- Web handlers (41 tests): signup, login, password reset, fave CRUD,
  admin panel, feeds, import/export, profiles, settings
- Config (8 tests): env parsing, defaults, trusted proxies, normalization
- Database (6 tests): migrations, PRAGMAs, idempotency, seeding
- Image processing (10 tests): JPEG/PNG, resize, EXIF strip, path traversal
- Render (6 tests): page/error/partial rendering, template functions
- Settings store (3 tests): CRUD operations
- Regression tests for display name fallback and CSP-safe autocomplete

Also adds CSRF middleware to testServer chain for end-to-end CSRF
verification, TESTPLAN.md documenting coverage, and PLANS-v1.1.md
with implementation plans for notes+OG, PWA, editing UX, and admin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-04-04 00:18:01 +02:00
commit a8f3aa6f7e
12 changed files with 3820 additions and 2 deletions

View file

@ -0,0 +1,204 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package config
import (
"testing"
"time"
)
func TestLoadDefaults(t *testing.T) {
// Clear all FAVORITTER_ env vars to test defaults.
for _, key := range []string{
"FAVORITTER_DB_PATH", "FAVORITTER_LISTEN", "FAVORITTER_BASE_PATH",
"FAVORITTER_EXTERNAL_URL", "FAVORITTER_UPLOAD_DIR", "FAVORITTER_MAX_UPLOAD_SIZE",
"FAVORITTER_SESSION_LIFETIME", "FAVORITTER_ARGON2_MEMORY", "FAVORITTER_ARGON2_TIME",
"FAVORITTER_ARGON2_PARALLELISM", "FAVORITTER_RATE_LIMIT", "FAVORITTER_ADMIN_USERNAME",
"FAVORITTER_ADMIN_PASSWORD", "FAVORITTER_SITE_NAME", "FAVORITTER_DEV_MODE",
"FAVORITTER_TRUSTED_PROXIES",
} {
t.Setenv(key, "")
}
cfg := Load()
if cfg.DBPath != "./data/favoritter.db" {
t.Errorf("DBPath = %q, want default", cfg.DBPath)
}
if cfg.Listen != ":8080" {
t.Errorf("Listen = %q, want :8080", cfg.Listen)
}
if cfg.BasePath != "" {
t.Errorf("BasePath = %q, want empty (root)", cfg.BasePath)
}
if cfg.MaxUploadSize != 10<<20 {
t.Errorf("MaxUploadSize = %d, want %d", cfg.MaxUploadSize, 10<<20)
}
if cfg.SessionLifetime != 720*time.Hour {
t.Errorf("SessionLifetime = %v, want 720h", cfg.SessionLifetime)
}
if cfg.SiteName != "Favoritter" {
t.Errorf("SiteName = %q, want Favoritter", cfg.SiteName)
}
if cfg.DevMode {
t.Error("DevMode should be false by default")
}
if cfg.RateLimit != 60 {
t.Errorf("RateLimit = %d, want 60", cfg.RateLimit)
}
}
func TestLoadFromEnv(t *testing.T) {
t.Setenv("FAVORITTER_DB_PATH", "/custom/db.sqlite")
t.Setenv("FAVORITTER_LISTEN", ":9090")
t.Setenv("FAVORITTER_BASE_PATH", "/faves")
t.Setenv("FAVORITTER_EXTERNAL_URL", "https://faves.example.com/")
t.Setenv("FAVORITTER_UPLOAD_DIR", "/custom/uploads")
t.Setenv("FAVORITTER_MAX_UPLOAD_SIZE", "20971520")
t.Setenv("FAVORITTER_SESSION_LIFETIME", "48h")
t.Setenv("FAVORITTER_ARGON2_MEMORY", "131072")
t.Setenv("FAVORITTER_ARGON2_TIME", "5")
t.Setenv("FAVORITTER_ARGON2_PARALLELISM", "4")
t.Setenv("FAVORITTER_RATE_LIMIT", "100")
t.Setenv("FAVORITTER_ADMIN_USERNAME", "admin")
t.Setenv("FAVORITTER_ADMIN_PASSWORD", "secret")
t.Setenv("FAVORITTER_SITE_NAME", "Mine Favoritter")
t.Setenv("FAVORITTER_DEV_MODE", "true")
t.Setenv("FAVORITTER_TRUSTED_PROXIES", "10.0.0.0/8,192.168.1.0/24")
cfg := Load()
if cfg.DBPath != "/custom/db.sqlite" {
t.Errorf("DBPath = %q", cfg.DBPath)
}
if cfg.Listen != ":9090" {
t.Errorf("Listen = %q", cfg.Listen)
}
if cfg.BasePath != "/faves" {
t.Errorf("BasePath = %q, want /faves", cfg.BasePath)
}
// External URL should have trailing slash stripped.
if cfg.ExternalURL != "https://faves.example.com" {
t.Errorf("ExternalURL = %q, want trailing slash stripped", cfg.ExternalURL)
}
if cfg.MaxUploadSize != 20971520 {
t.Errorf("MaxUploadSize = %d", cfg.MaxUploadSize)
}
if cfg.SessionLifetime != 48*time.Hour {
t.Errorf("SessionLifetime = %v", cfg.SessionLifetime)
}
if cfg.Argon2Memory != 131072 {
t.Errorf("Argon2Memory = %d", cfg.Argon2Memory)
}
if cfg.Argon2Time != 5 {
t.Errorf("Argon2Time = %d", cfg.Argon2Time)
}
if cfg.Argon2Parallelism != 4 {
t.Errorf("Argon2Parallelism = %d", cfg.Argon2Parallelism)
}
if cfg.RateLimit != 100 {
t.Errorf("RateLimit = %d", cfg.RateLimit)
}
if cfg.AdminUsername != "admin" {
t.Errorf("AdminUsername = %q", cfg.AdminUsername)
}
if cfg.AdminPassword != "secret" {
t.Errorf("AdminPassword = %q", cfg.AdminPassword)
}
if cfg.SiteName != "Mine Favoritter" {
t.Errorf("SiteName = %q", cfg.SiteName)
}
if !cfg.DevMode {
t.Error("DevMode should be true")
}
if len(cfg.TrustedProxies) != 2 {
t.Errorf("TrustedProxies: got %d, want 2", len(cfg.TrustedProxies))
}
}
func TestTrustedProxiesParsing(t *testing.T) {
t.Setenv("FAVORITTER_TRUSTED_PROXIES", "10.0.0.0/8, 192.168.0.0/16, 127.0.0.1")
cfg := Load()
if len(cfg.TrustedProxies) != 3 {
t.Fatalf("TrustedProxies: got %d, want 3", len(cfg.TrustedProxies))
}
// 127.0.0.1 without CIDR should become 127.0.0.1/32.
last := cfg.TrustedProxies[2]
if ones, _ := last.Mask.Size(); ones != 32 {
t.Errorf("bare IP mask = /%d, want /32", ones)
}
}
func TestTrustedProxiesInvalid(t *testing.T) {
t.Setenv("FAVORITTER_TRUSTED_PROXIES", "not-an-ip, 10.0.0.0/8")
cfg := Load()
// Invalid entries are skipped; valid ones remain.
if len(cfg.TrustedProxies) != 1 {
t.Errorf("TrustedProxies: got %d, want 1 (invalid skipped)", len(cfg.TrustedProxies))
}
}
func TestBasePathNormalization(t *testing.T) {
tests := []struct {
input string
want string
}{
{"/", ""},
{"", ""},
{"/faves", "/faves"},
{"/faves/", "/faves"},
{"faves", "/faves"},
{"/sub/path/", "/sub/path"},
}
for _, tt := range tests {
got := normalizePath(tt.input)
if got != tt.want {
t.Errorf("normalizePath(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestDevModeFlag(t *testing.T) {
t.Setenv("FAVORITTER_DEV_MODE", "true")
cfg := Load()
if !cfg.DevMode {
t.Error("DevMode should be true when env is 'true'")
}
t.Setenv("FAVORITTER_DEV_MODE", "false")
cfg = Load()
if cfg.DevMode {
t.Error("DevMode should be false when env is 'false'")
}
}
func TestExternalHostname(t *testing.T) {
cfg := &Config{ExternalURL: "https://faves.example.com/base"}
if got := cfg.ExternalHostname(); got != "faves.example.com" {
t.Errorf("ExternalHostname = %q, want faves.example.com", got)
}
cfg = &Config{}
if got := cfg.ExternalHostname(); got != "" {
t.Errorf("empty ExternalURL: ExternalHostname = %q, want empty", got)
}
}
func TestBaseURL(t *testing.T) {
// With external URL configured.
cfg := &Config{ExternalURL: "https://faves.example.com"}
if got := cfg.BaseURL("localhost:8080"); got != "https://faves.example.com" {
t.Errorf("BaseURL with external = %q", got)
}
// Without external URL — falls back to request host.
cfg = &Config{BasePath: "/faves"}
if got := cfg.BaseURL("myhost.local:8080"); got != "https://myhost.local:8080/faves" {
t.Errorf("BaseURL fallback = %q", got)
}
}