package handlers import ( "context" "net/http" "net/http/httptest" "net/url" "path/filepath" "regexp" "strings" "testing" "veola/internal/apify" "veola/internal/auth" "veola/internal/config" "veola/internal/crypto" "veola/internal/db" "veola/internal/models" "veola/internal/ntfy" "veola/internal/scheduler" ) // newTestApp builds an App backed by a fresh sqlite db in t.TempDir(). The // scheduler, apify, and ntfy clients are wired but unused by the routes we // hit here. The returned http.Handler is App.Routes(). func newTestApp(t *testing.T) (*App, http.Handler) { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") sqlDB, err := db.Open(dbPath) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { sqlDB.Close() }) key, err := crypto.DeriveKey([]byte("test-encryption-key-32-bytes-min-aaaaaa")) if err != nil { t.Fatalf("DeriveKey: %v", err) } store := db.NewStore(sqlDB, key) am, err := auth.NewManager(sqlDB, store, strings.Repeat("a", 32), false) if err != nil { t.Fatalf("auth.NewManager: %v", err) } cfg := &config.Config{} ap := apify.New("") nt := ntfy.New("") sc := scheduler.New(cfg, store, ap, nt) app := New(cfg, store, am, ap, nt, sc) return app, app.Routes() } func TestHealthz(t *testing.T) { _, h := newTestApp(t) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if got := rec.Body.String(); got != "ok" { t.Fatalf("body = %q, want %q", got, "ok") } } func TestSetupGateRedirectsWhenNoUsers(t *testing.T) { _, h := newTestApp(t) req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303", rec.Code) } if loc := rec.Header().Get("Location"); loc != "/setup" { t.Fatalf("Location = %q, want /setup", loc) } } func TestRequireAuthRedirectsToLogin(t *testing.T) { app, h := newTestApp(t) hash, err := auth.HashPassword("a-long-enough-password") if err != nil { t.Fatalf("HashPassword: %v", err) } if _, err := app.Store.CreateUser(context.Background(), "admin", hash, models.RoleAdmin); err != nil { t.Fatalf("CreateUser: %v", err) } req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303", rec.Code) } if loc := rec.Header().Get("Location"); loc != "/login" { t.Fatalf("Location = %q, want /login", loc) } } func TestLoginPageRenders(t *testing.T) { app, h := newTestApp(t) hash, _ := auth.HashPassword("a-long-enough-password") if _, err := app.Store.CreateUser(context.Background(), "admin", hash, models.RoleAdmin); err != nil { t.Fatalf("CreateUser: %v", err) } req := httptest.NewRequest(http.MethodGet, "/login", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if !strings.Contains(rec.Body.String(), "") } } // ============ forward-auth (Authentik) integration ============ // Authentik header names, matching internal/config defaults. const ( hdrEmail = "X-Authentik-Email" hdrUsername = "X-Authentik-Username" hdrName = "X-Authentik-Name" hdrGroups = "X-Authentik-Groups" ) // enableForwardAuth turns on forward-auth trust for the 10.0.0.0/8 range with // admin group "veola-admins", mirroring a Traefik-in-front deployment. func enableForwardAuth(t *testing.T, app *App) { t.Helper() fa, err := auth.NewForwardAuthConfig(hdrUsername, hdrEmail, hdrName, hdrGroups, "veola-admins", []string{"10.0.0.0/8"}) if err != nil { t.Fatalf("NewForwardAuthConfig: %v", err) } app.Auth.SetForwardAuth(fa) } // testClient threads cookies across requests so an scs session survives a // GET-then-POST flow, and applies a fixed peer address + identity headers. type testClient struct { h http.Handler remoteAddr string headers map[string]string cookies map[string]*http.Cookie } func newClient(h http.Handler, remoteAddr string, headers map[string]string) *testClient { return &testClient{h: h, remoteAddr: remoteAddr, headers: headers, cookies: map[string]*http.Cookie{}} } func (c *testClient) do(method, target string, form url.Values) *httptest.ResponseRecorder { var req *http.Request if form != nil { req = httptest.NewRequest(method, target, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } else { req = httptest.NewRequest(method, target, nil) } if c.remoteAddr != "" { req.RemoteAddr = c.remoteAddr } for k, v := range c.headers { req.Header.Set(k, v) } for _, ck := range c.cookies { req.AddCookie(ck) } rec := httptest.NewRecorder() c.h.ServeHTTP(rec, req) for _, ck := range rec.Result().Cookies() { c.cookies[ck.Name] = ck } return rec } var csrfRe = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`) func extractCSRF(t *testing.T, body string) string { t.Helper() m := csrfRe.FindStringSubmatch(body) if m == nil { t.Fatal("no csrf_token field in body") } return m[1] } func TestForwardAuthProvisionsAdminFromTrustedProxy(t *testing.T) { app, h := newTestApp(t) enableForwardAuth(t, app) c := newClient(h, "10.0.0.5:5555", map[string]string{ hdrEmail: "ada@example.com", hdrUsername: "ada", hdrGroups: "users|veola-admins", }) rec := c.do(http.MethodGet, "/", nil) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (provisioned dashboard); body: %s", rec.Code, rec.Body.String()) } u, err := app.Store.GetUserByEmail(context.Background(), "ada@example.com") if err != nil || u == nil { t.Fatalf("GetUserByEmail: u=%v err=%v", u, err) } if u.Role != models.RoleAdmin { t.Errorf("role = %q, want admin (from veola-admins group)", u.Role) } if u.AuthSource != "forward" { t.Errorf("auth_source = %q, want forward", u.AuthSource) } } func TestForwardAuthUntrustedPeerIgnoresSpoofedHeaders(t *testing.T) { app, h := newTestApp(t) enableForwardAuth(t, app) // Same headers, but the direct peer is NOT in the trusted range: the // headers must be ignored and no user provisioned. c := newClient(h, "203.0.113.9:5555", map[string]string{ hdrEmail: "mallory@example.com", hdrUsername: "mallory", hdrGroups: "veola-admins", }) rec := c.do(http.MethodGet, "/", nil) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303 (no trust, setup gate)", rec.Code) } if loc := rec.Header().Get("Location"); loc != "/setup" { t.Fatalf("Location = %q, want /setup", loc) } if u, _ := app.Store.GetUserByEmail(context.Background(), "mallory@example.com"); u != nil { t.Fatalf("spoofed header provisioned a user: %+v", u) } } func TestForwardAuthReSyncsRoleOnEveryRequest(t *testing.T) { app, h := newTestApp(t) enableForwardAuth(t, app) admin := newClient(h, "10.0.0.5:5555", map[string]string{ hdrEmail: "bea@example.com", hdrGroups: "veola-admins", }) if rec := admin.do(http.MethodGet, "/", nil); rec.Code != http.StatusOK { t.Fatalf("first request status = %d, want 200", rec.Code) } u, _ := app.Store.GetUserByEmail(context.Background(), "bea@example.com") if u == nil || u.Role != models.RoleAdmin { t.Fatalf("after admin login, role = %v, want admin", u) } // IdP later drops the admin group: the next request must downgrade the role. demoted := newClient(h, "10.0.0.5:5555", map[string]string{ hdrEmail: "bea@example.com", hdrGroups: "users", }) if rec := demoted.do(http.MethodGet, "/", nil); rec.Code != http.StatusOK { t.Fatalf("second request status = %d, want 200", rec.Code) } u, _ = app.Store.GetUserByEmail(context.Background(), "bea@example.com") if u == nil || u.Role != models.RoleUser { t.Fatalf("after group drop, role = %v, want user", u) } } func TestForwardAuthEmailPrefsRoundTrip(t *testing.T) { app, h := newTestApp(t) enableForwardAuth(t, app) // A forward-auth session authenticates without local login; carry its // cookie + CSRF token into the POST. c := newClient(h, "10.0.0.5:5555", map[string]string{ hdrEmail: "cleo@example.com", hdrGroups: "users", }) rec := c.do(http.MethodGet, "/settings", nil) if rec.Code != http.StatusOK { t.Fatalf("GET /settings = %d, want 200", rec.Code) } token := extractCSRF(t, rec.Body.String()) provisioned, _ := app.Store.GetUserByEmail(context.Background(), "cleo@example.com") if provisioned == nil { t.Fatal("forward user not provisioned by GET /settings") } // A forward user can toggle opt-ins, but a form that tries to change the // email must be ignored: the address is the Authentik match key, so // changing it here would orphan the row on the next sign-in. form := url.Values{ "csrf_token": {token}, "email": {"cleo+spoofed@example.com"}, "email_deal_alerts": {"1"}, "email_weekly_digest": {"1"}, } rec = c.do(http.MethodPost, "/settings/email", form) if rec.Code != http.StatusOK { t.Fatalf("POST /settings/email = %d, want 200; body: %s", rec.Code, rec.Body.String()) } // Email is unchanged (still the IdP address), so the row still re-matches. u, err := app.Store.GetUserByEmail(context.Background(), "cleo@example.com") if err != nil || u == nil { t.Fatalf("forward user orphaned: GetUserByEmail(IdP addr) u=%v err=%v", u, err) } if u.ID != provisioned.ID { t.Errorf("re-matched a different row: id %d != %d", u.ID, provisioned.ID) } if u.Email != "cleo@example.com" { t.Errorf("email = %q, want the unchanged IdP address cleo@example.com", u.Email) } if !u.EmailDealAlerts || !u.EmailWeeklyDigest { t.Errorf("opt-ins = (deal:%v digest:%v), want both true", u.EmailDealAlerts, u.EmailWeeklyDigest) } // The user appears in the deal-alert recipient list under their IdP address. recips, err := app.Store.UsersWithDealAlerts(context.Background()) if err != nil { t.Fatalf("UsersWithDealAlerts: %v", err) } if !containsEmail(recips, "cleo@example.com") { t.Errorf("deal-alert recipients %v missing cleo", recips) } } func containsEmail(us []models.User, email string) bool { for _, u := range us { if u.Email == email { return true } } return false }