hsa-app/internal/web/ai_test.go
Jean-Michel Tremblay 866bc175fb
All checks were successful
Build and Test / build-and-test (push) Successful in 38s
Security hardening: server-side session expiry + nosniff on files
1. Enforce the 12h session lifetime server-side in requireAuth (reject a
   session older than the TTL even if the sealed cookie is intact), so a
   leaked cookie value can't be replayed indefinitely. Shared sessionTTL
   const drives both the cookie MaxAge and the check.
2. Send X-Content-Type-Options: nosniff when serving user-uploaded
   receipt/attachment bytes, so the browser won't sniff past the declared
   (upload-time allowlisted) MIME type.

Update SPEC §2 and §6 accordingly; tests cover stale-session rejection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:56:38 -04:00

144 lines
4.7 KiB
Go

package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"maisym.com/hsa/internal/auth"
)
// uploadWithClassifyBlob posts a receipt plus a round-tripped AI suggestion blob.
func uploadWithClassifyBlob(t *testing.T, s *Server, amount, classifyJSON string) {
t.Helper()
body, ct := multipartUpload(t, map[string]string{
"amount": amount,
"receipt_date": "2026-06-01",
"category_id": aCategoryID(t, s),
"classify_json": classifyJSON,
}, "receipt", "r.png", fakePNG())
req := httptest.NewRequest(http.MethodPost, "/upload", body)
req.Header.Set("Content-Type", ct)
req.AddCookie(authCookie(t, s))
rec := httptest.NewRecorder()
s.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("upload status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestAI_MissRecordedAndReviewable(t *testing.T) {
s := testServerWithStore(t)
// AI suggested $99.99; user submitted $12.34 → an amount miss.
blob := `{"amount":"99.99","date":"2026-06-01","category_id":null,"person_id":null,"model":"haiku-test"}`
uploadWithClassifyBlob(t, s, "12.34", blob)
// Stored classification exists.
rows, err := s.store.ListUnreviewedClassifications()
if err != nil || len(rows) != 1 {
t.Fatalf("ListUnreviewedClassifications: err=%v len=%d", err, len(rows))
}
id := rows[0].ReceiptID
// /ai lists it as a miss touching "amount".
page := get(t, s, "/ai").Body.String()
if !strings.Contains(page, "haiku-test") || !strings.Contains(page, "amount") {
t.Errorf("/ai missing the amount miss: %s", page)
}
// Review page shows the AI guess vs the entered value.
rv := get(t, s, "/ai/review/"+id).Body.String()
if !strings.Contains(rv, "99.99") || !strings.Contains(rv, "12.34") {
t.Errorf("review page missing guess/final: %s", rv)
}
// Submitting the review (no fix ticked) closes it as unresolved and clears the queue.
req := httptest.NewRequest(http.MethodPost, "/ai/review/"+id, strings.NewReader(url.Values{}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(authCookie(t, s))
rec := httptest.NewRecorder()
s.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("review submit status=%d", rec.Code)
}
left, _ := s.store.ListUnreviewedClassifications()
if len(left) != 0 {
t.Errorf("queue not cleared after review: %d left", len(left))
}
if c, _ := s.store.GetClassification(id); c.Resolution != "unresolved" {
t.Errorf("resolution = %q, want unresolved", c.Resolution)
}
}
func TestAI_NoMissWhenSuggestionMatches(t *testing.T) {
s := testServerWithStore(t)
cat := aCategoryID(t, s)
// AI nailed every field the user submitted → not a miss.
blob := `{"amount":"12.34","date":"2026-06-01","category_id":` + cat + `,"person_id":null,"model":"m"}`
uploadWithClassifyBlob(t, s, "12.34", blob)
page := get(t, s, "/ai").Body.String()
if !strings.Contains(page, "No misreads to review") {
t.Errorf("expected no-misreads message, got: %s", page)
}
}
func TestAuth_StaleSessionRedirects(t *testing.T) {
s := testServerWithStore(t)
// A session issued well beyond the TTL must be rejected server-side even though
// the cookie value itself is a valid (untampered) sealed token.
old := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"},
IssuedAt: time.Now().Add(-13 * time.Hour)}
v, err := auth.EncodeSession(old, s.cfg.SessionKey)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: v})
rec := httptest.NewRecorder()
s.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login" {
t.Errorf("stale session: status=%d location=%q, want 302 -> /login", rec.Code, rec.Header().Get("Location"))
}
}
func TestAI_NotesCRUD(t *testing.T) {
s := testServerWithStore(t)
post := func(path string, form url.Values) {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(authCookie(t, s))
rec := httptest.NewRecorder()
s.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("%s status=%d", path, rec.Code)
}
}
post("/ai/notes", url.Values{"text": {"European dates are DD/MM"}})
notes, _ := s.store.ListActiveNotes()
var added int64
found := false
for _, n := range notes {
if n.Text == "European dates are DD/MM" {
added, found = n.ID, true
}
}
if !found {
t.Fatal("note not added")
}
post("/ai/notes/delete", url.Values{"id": {strconv.FormatInt(added, 10)}})
notes, _ = s.store.ListActiveNotes()
for _, n := range notes {
if n.ID == added {
t.Error("note not deleted")
}
}
}