Go app for capturing and archiving HSA-eligible receipts: OIDC/PKCE auth against Authelia, SQLite storage with dual-write (filesystem + DB blob), mobile-first upload, and DB export. Adds AI receipt classification: a config.json catalog of people and categories (seeded into the DB on startup), a prompt builder that derives name-order/initial variants from the data (with same-surname ambiguity handling), and an Anthropic tool-use client behind POST /classify. Tests run against a mock endpoint; a live integration test is env-gated to the cheapest model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
128 lines
3.4 KiB
Go
128 lines
3.4 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"maisym.com/hsa/internal/auth"
|
|
"maisym.com/hsa/internal/config"
|
|
)
|
|
|
|
func testServer() *Server {
|
|
var key [32]byte
|
|
copy(key[:], "web-test-key-32-bytes-padded!!!!")
|
|
return &Server{cfg: config.Config{
|
|
SessionKey: key,
|
|
RequiredGroup: "hsa-users",
|
|
SecureCookies: false,
|
|
}}
|
|
}
|
|
|
|
func TestStaticCSS(t *testing.T) {
|
|
s := testServer()
|
|
req := httptest.NewRequest(http.MethodGet, "/static/style.css", nil)
|
|
rec := httptest.NewRecorder()
|
|
s.Routes().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "font-family") {
|
|
t.Errorf("CSS not served: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHealthz(t *testing.T) {
|
|
s := testServer()
|
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
|
rec := httptest.NewRecorder()
|
|
s.Routes().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "ok") {
|
|
t.Errorf("body = %q, want to contain ok", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHome_NoSession_RedirectsToLogin(t *testing.T) {
|
|
s := testServer()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
s.Routes().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, want 302", rec.Code)
|
|
}
|
|
if loc := rec.Header().Get("Location"); loc != "/login" {
|
|
t.Errorf("Location = %q, want /login", loc)
|
|
}
|
|
}
|
|
|
|
func TestHome_ValidSession_ShowsUploadForm(t *testing.T) {
|
|
s := testServerWithStore(t)
|
|
sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}}
|
|
cookie, err := auth.EncodeSession(sess, s.cfg.SessionKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: cookie})
|
|
rec := httptest.NewRecorder()
|
|
s.Routes().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "jm@example.com") {
|
|
t.Errorf("body missing subject: %q", body)
|
|
}
|
|
if !strings.Contains(body, "Add receipt") {
|
|
t.Errorf("body missing upload form: %q", body)
|
|
}
|
|
}
|
|
|
|
func TestHome_TamperedSession_RedirectsToLogin(t *testing.T) {
|
|
s := testServer()
|
|
sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}}
|
|
cookie, _ := auth.EncodeSession(sess, s.cfg.SessionKey)
|
|
tampered := cookie[:len(cookie)-3] + "AAA"
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: tampered})
|
|
rec := httptest.NewRecorder()
|
|
s.Routes().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, want 302", rec.Code)
|
|
}
|
|
if loc := rec.Header().Get("Location"); loc != "/login" {
|
|
t.Errorf("Location = %q, want /login", loc)
|
|
}
|
|
}
|
|
|
|
func TestLogout_ClearsCookieAndRedirects(t *testing.T) {
|
|
s := testServer()
|
|
req := httptest.NewRequest(http.MethodGet, "/logout", nil)
|
|
rec := httptest.NewRecorder()
|
|
s.Routes().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, want 302", rec.Code)
|
|
}
|
|
// The session cookie should be expired (MaxAge < 0).
|
|
var cleared bool
|
|
for _, c := range rec.Result().Cookies() {
|
|
if c.Name == sessionCookie && c.MaxAge < 0 {
|
|
cleared = true
|
|
}
|
|
}
|
|
if !cleared {
|
|
t.Error("logout did not clear the session cookie")
|
|
}
|
|
}
|