package server import ( "context" "encoding/json" "log/slog" "net/http" "net/http/httptest" "net/url" "path/filepath" "regexp" "strings" "testing" "time" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/config" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/session" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" ) const testPassword = "test-password" func newTestServer(t *testing.T) *Server { t.Helper() cfg := &config.Config{ Env: "test", Addr: ":0", DBPath: filepath.Join(t.TempDir(), "email_agent.db"), LogLevel: "error", SessionSecret: "test-session-secret", Password: testPassword, } st, err := store.Open(context.Background(), cfg.DBPath) if err != nil { t.Fatalf("store.Open() error = %v", err) } t.Cleanup(func() { _ = st.Close() }) srv, err := New(cfg, st, slog.New(slog.DiscardHandler)) if err != nil { t.Fatalf("New() error = %v", err) } return srv } func TestHealthOK(t *testing.T) { srv := newTestServer(t) req := httptest.NewRequest(http.MethodGet, "/health", nil) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode body: %v", err) } if body["status"] != "ok" { t.Errorf("status field = %v, want ok", body["status"]) } } func TestReadyz(t *testing.T) { srv := newTestServer(t) req := httptest.NewRequest(http.MethodGet, "/readyz", nil) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } } func TestStaticTokensServed(t *testing.T) { srv := newTestServer(t) req := httptest.NewRequest(http.MethodGet, "/static/tokens.css", nil) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } if !strings.Contains(rec.Body.String(), "--canvas-soft") { t.Error("tokens.css does not contain the design tokens") } } func TestUnauthenticatedRedirectsToLogin(t *testing.T) { srv := newTestServer(t) req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want %d", rec.Code, http.StatusSeeOther) } if loc := rec.Header().Get("Location"); !strings.HasPrefix(loc, "/login") { t.Errorf("Location = %q, want /login…", loc) } } func TestLoginPageRenders(t *testing.T) { srv := newTestServer(t) req := httptest.NewRequest(http.MethodGet, "/login", nil) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } body := rec.Body.String() if !strings.Contains(body, "Sign in") || !strings.Contains(body, `name="csrf"`) { t.Error("login page is missing the form or CSRF field") } if findCookie(t, rec.Result().Cookies(), csrfCookieName) == nil { t.Error("login page did not set the CSRF cookie") } } func TestLoginFlowAndDashboard(t *testing.T) { srv := newTestServer(t) h := srv.Handler() // 1. Load the login page to obtain the CSRF token. rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) csrf := csrfFromBody(t, rec.Body.String()) csrfCookie := findCookie(t, rec.Result().Cookies(), csrfCookieName) // 2. Sign in with the right password. rec = httptest.NewRecorder() h.ServeHTTP(rec, loginRequest(csrf, testPassword, csrfCookie)) if rec.Code != http.StatusSeeOther { t.Fatalf("login status = %d, want %d (body: %s)", rec.Code, http.StatusSeeOther, rec.Body.String()) } sessionCookie := findCookie(t, rec.Result().Cookies(), session.CookieName) if sessionCookie == nil { t.Fatal("login did not set the session cookie") } // 3. The dashboard renders with the session cookie. req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(sessionCookie) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("dashboard status = %d, want %d", rec.Code, http.StatusOK) } body := rec.Body.String() for _, want := range []string{"Good ", "Inbox", "Categories", "Folders", "Log out", "Check mail", "All mail"} { if !strings.Contains(body, want) { t.Errorf("dashboard body missing %q", want) } } } func TestInboxShowsStoredMail(t *testing.T) { srv := newTestServer(t) if _, err := srv.store.UpsertMessage(context.Background(), &store.Message{ GmailID: "1234000000000000000", FromAddr: "favrolfe@gmail.com", ToAddr: "Hello@where-woof.com", Subject: "Real forwarded mail", BodyText: "hello from phase 2", SourceAddress: "Hello@where-woof.com", DateSent: time.Now().UTC(), }); err != nil { t.Fatalf("seed message: %v", err) } h := srv.Handler() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) csrf := csrfFromBody(t, rec.Body.String()) csrfCookie := findCookie(t, rec.Result().Cookies(), csrfCookieName) rec = httptest.NewRecorder() h.ServeHTTP(rec, loginRequest(csrf, testPassword, csrfCookie)) sessionCookie := findCookie(t, rec.Result().Cookies(), session.CookieName) if sessionCookie == nil { t.Fatal("login did not set the session cookie") } req := httptest.NewRequest(http.MethodGet, "/partials/inbox", nil) req.AddCookie(sessionCookie) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("inbox partial status = %d, want %d", rec.Code, http.StatusOK) } body := rec.Body.String() for _, want := range []string{"Real forwarded mail", "favrolfe@gmail.com", "Hello@where-woof.com", "Unclassified", "Review"} { if !strings.Contains(body, want) { t.Errorf("inbox partial missing %q", want) } } } func TestFetchRequiresCSRF(t *testing.T) { srv := newTestServer(t) req := httptest.NewRequest(http.MethodPost, "/actions/fetch", nil) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) // Unauthenticated requests are redirected to login before the CSRF check. if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want %d", rec.Code, http.StatusSeeOther) } } func TestLoginWrongPassword(t *testing.T) { srv := newTestServer(t) h := srv.Handler() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) csrf := csrfFromBody(t, rec.Body.String()) csrfCookie := findCookie(t, rec.Result().Cookies(), csrfCookieName) rec = httptest.NewRecorder() h.ServeHTTP(rec, loginRequest(csrf, "wrong-password", csrfCookie)) if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) } if findCookie(t, rec.Result().Cookies(), session.CookieName) != nil { t.Error("a session cookie was issued for a failed login") } } func TestLoginRejectsMissingCSRF(t *testing.T) { srv := newTestServer(t) form := url.Values{"password": {testPassword}} req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) } } func TestLogoutDestroysSession(t *testing.T) { srv := newTestServer(t) h := srv.Handler() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) csrf := csrfFromBody(t, rec.Body.String()) csrfCookie := findCookie(t, rec.Result().Cookies(), csrfCookieName) rec = httptest.NewRecorder() h.ServeHTTP(rec, loginRequest(csrf, testPassword, csrfCookie)) sessionCookie := findCookie(t, rec.Result().Cookies(), session.CookieName) if sessionCookie == nil { t.Fatal("login did not set the session cookie") } // Log out, carrying both the CSRF and session cookies. form := url.Values{"csrf": {csrf}} req := httptest.NewRequest(http.MethodPost, "/logout", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(csrfCookie) req.AddCookie(sessionCookie) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("logout status = %d, want %d", rec.Code, http.StatusSeeOther) } // The old session no longer reaches the dashboard. req = httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(sessionCookie) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("dashboard after logout = %d, want %d", rec.Code, http.StatusSeeOther) } } func loginRequest(csrf, password string, csrfCookie *http.Cookie) *http.Request { form := url.Values{"password": {password}, "csrf": {csrf}} req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if csrfCookie != nil { req.AddCookie(csrfCookie) } return req } var csrfFieldRE = regexp.MustCompile(`name="csrf" value="([^"]*)"`) func csrfFromBody(t *testing.T, body string) string { t.Helper() m := csrfFieldRE.FindStringSubmatch(body) if len(m) < 2 || m[1] == "" { t.Fatalf("no CSRF token in body") } return m[1] } func findCookie(t *testing.T, cookies []*http.Cookie, name string) *http.Cookie { t.Helper() for _, c := range cookies { if c.Name == name && c.Value != "" { return c } } return nil }