All checks were successful
Build and Test / build-and-test (push) Successful in 38s
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>
144 lines
4.7 KiB
Go
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")
|
|
}
|
|
}
|
|
}
|