2026-06-18 01:40:12 +00:00
|
|
|
package web
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
2026-06-19 11:06:15 +00:00
|
|
|
"fmt"
|
2026-06-18 01:40:12 +00:00
|
|
|
"mime/multipart"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
2026-06-21 00:56:38 +00:00
|
|
|
"time"
|
2026-06-18 01:40:12 +00:00
|
|
|
|
|
|
|
|
"maisym.com/hsa/internal/auth"
|
|
|
|
|
"maisym.com/hsa/internal/config"
|
|
|
|
|
"maisym.com/hsa/internal/storage"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func testServerWithStore(t *testing.T) *Server {
|
|
|
|
|
t.Helper()
|
|
|
|
|
var key [32]byte
|
|
|
|
|
copy(key[:], "web-test-key-32-bytes-padded!!!!")
|
|
|
|
|
store, err := storage.Open(filepath.Join(t.TempDir(), "test.db"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
t.Cleanup(func() { store.Close() })
|
|
|
|
|
return &Server{
|
|
|
|
|
cfg: config.Config{
|
|
|
|
|
SessionKey: key,
|
|
|
|
|
RequiredGroup: "hsa-users",
|
|
|
|
|
StorageDir: filepath.Join(t.TempDir(), "files"),
|
|
|
|
|
MaxUploadBytes: 32 << 20,
|
|
|
|
|
},
|
|
|
|
|
store: store,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func authCookie(t *testing.T, s *Server) *http.Cookie {
|
|
|
|
|
t.Helper()
|
2026-06-21 00:56:38 +00:00
|
|
|
sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}, IssuedAt: time.Now()}
|
2026-06-18 01:40:12 +00:00
|
|
|
v, err := auth.EncodeSession(sess, s.cfg.SessionKey)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return &http.Cookie{Name: sessionCookie, Value: v}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// aCategoryID returns the id of a seeded category as a string.
|
|
|
|
|
func aCategoryID(t *testing.T, s *Server) string {
|
|
|
|
|
t.Helper()
|
|
|
|
|
cats, err := s.store.ListCategories()
|
|
|
|
|
if err != nil || len(cats) == 0 {
|
|
|
|
|
t.Fatalf("ListCategories: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return strconv.FormatInt(cats[0].ID, 10)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// fakePNG returns bytes that http.DetectContentType recognises as image/png.
|
|
|
|
|
func fakePNG() []byte {
|
|
|
|
|
return append([]byte("\x89PNG\r\n\x1a\n"), []byte("fake-image-content")...)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func multipartUpload(t *testing.T, fields map[string]string, fileField, fileName string, fileData []byte) (*bytes.Buffer, string) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
var body bytes.Buffer
|
|
|
|
|
mw := multipart.NewWriter(&body)
|
|
|
|
|
for k, v := range fields {
|
|
|
|
|
_ = mw.WriteField(k, v)
|
|
|
|
|
}
|
|
|
|
|
if fileData != nil {
|
|
|
|
|
fw, err := mw.CreateFormFile(fileField, fileName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
fw.Write(fileData)
|
|
|
|
|
}
|
|
|
|
|
mw.Close()
|
|
|
|
|
return &body, mw.FormDataContentType()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestUpload_HappyPath_WritesFileAndRow(t *testing.T) {
|
|
|
|
|
s := testServerWithStore(t)
|
|
|
|
|
body, ct := multipartUpload(t,
|
|
|
|
|
map[string]string{"amount": "12.34", "receipt_date": "2026-06-01", "category_id": aCategoryID(t, s)},
|
|
|
|
|
"receipt", "receipt.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("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(rec.Body.String(), "Receipt saved") {
|
|
|
|
|
t.Errorf("missing confirmation: %s", rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Row written.
|
|
|
|
|
n, err := s.store.CountActive()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if n != 1 {
|
|
|
|
|
t.Fatalf("CountActive = %d, want 1", n)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:06:15 +00:00
|
|
|
// File written to disk under receipts/<year> with a dated, amount-tagged name
|
|
|
|
|
// (date 2026-06-01, amount 12.34 -> receipts/2026/06_01_12.34.png).
|
|
|
|
|
entries, err := os.ReadDir(filepath.Join(s.cfg.StorageDir, "receipts", "2026"))
|
2026-06-18 01:40:12 +00:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(entries) != 1 {
|
2026-06-19 02:10:41 +00:00
|
|
|
t.Fatalf("year dir has %d files, want 1", len(entries))
|
2026-06-18 01:40:12 +00:00
|
|
|
}
|
2026-06-19 02:10:41 +00:00
|
|
|
if entries[0].Name() != "06_01_12.34.png" {
|
|
|
|
|
t.Errorf("stored file name = %q, want 06_01_12.34.png", entries[0].Name())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestUpload_SameDateAmount_DisambiguatesFilename(t *testing.T) {
|
|
|
|
|
s := testServerWithStore(t)
|
|
|
|
|
cat := aCategoryID(t, s)
|
|
|
|
|
post := func() {
|
|
|
|
|
body, ct := multipartUpload(t,
|
|
|
|
|
map[string]string{"amount": "12.34", "receipt_date": "2026-06-01", "category_id": cat},
|
|
|
|
|
"receipt", "receipt.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("status = %d; body=%s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
post()
|
|
|
|
|
post() // same date + amount → must not overwrite the first file
|
|
|
|
|
|
2026-06-19 11:06:15 +00:00
|
|
|
entries, err := os.ReadDir(filepath.Join(s.cfg.StorageDir, "receipts", "2026"))
|
2026-06-19 02:10:41 +00:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
got := map[string]bool{}
|
|
|
|
|
for _, e := range entries {
|
|
|
|
|
got[e.Name()] = true
|
|
|
|
|
}
|
|
|
|
|
if !got["06_01_12.34.png"] || !got["06_01_12.34_1.png"] {
|
|
|
|
|
t.Errorf("expected both base and _1 files, got %v", got)
|
2026-06-18 01:40:12 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:06:15 +00:00
|
|
|
func TestUpload_WithAttachments(t *testing.T) {
|
|
|
|
|
s := testServerWithStore(t)
|
|
|
|
|
|
|
|
|
|
var body bytes.Buffer
|
|
|
|
|
mw := multipart.NewWriter(&body)
|
|
|
|
|
_ = mw.WriteField("amount", "12.34")
|
|
|
|
|
_ = mw.WriteField("receipt_date", "2026-06-01")
|
|
|
|
|
_ = mw.WriteField("category_id", aCategoryID(t, s))
|
|
|
|
|
rw, _ := mw.CreateFormFile("receipt", "receipt.png")
|
|
|
|
|
rw.Write(fakePNG())
|
|
|
|
|
for i := 0; i < 2; i++ { // two extra files in the same submit
|
|
|
|
|
aw, _ := mw.CreateFormFile("attachments", fmt.Sprintf("page%d.png", i))
|
|
|
|
|
aw.Write(fakePNG())
|
|
|
|
|
}
|
|
|
|
|
mw.Close()
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/upload", &body)
|
|
|
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
|
|
|
req.AddCookie(authCookie(t, s))
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
s.Routes().ServeHTTP(rec, req)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(rec.Body.String(), "attachment(s)") {
|
|
|
|
|
t.Errorf("confirm missing attachment listing: %s", rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Files written under attachments/2026 with parent-stem _att naming.
|
|
|
|
|
entries, err := os.ReadDir(filepath.Join(s.cfg.StorageDir, "attachments", "2026"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
got := map[string]bool{}
|
|
|
|
|
for _, e := range entries {
|
|
|
|
|
got[e.Name()] = true
|
|
|
|
|
}
|
|
|
|
|
if !got["06_01_12.34_att.png"] || !got["06_01_12.34_att_1.png"] {
|
|
|
|
|
t.Errorf("attachment files = %v, want _att and _att_1", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Two attachment rows linked to the receipt; serving works.
|
|
|
|
|
rows, _, err := s.store.ListRecent("uploaded_at", 10, 0)
|
|
|
|
|
if err != nil || len(rows) != 1 {
|
|
|
|
|
t.Fatalf("ListRecent: %v len=%d", err, len(rows))
|
|
|
|
|
}
|
|
|
|
|
atts, err := s.store.ListAttachmentMeta(rows[0].ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(atts) != 2 {
|
|
|
|
|
t.Fatalf("attachments = %d, want 2", len(atts))
|
|
|
|
|
}
|
|
|
|
|
rec2 := get(t, s, "/attachment/"+atts[0].ID+"/file")
|
|
|
|
|
if rec2.Code != http.StatusOK || !strings.HasPrefix(rec2.Body.String(), "\x89PNG") {
|
|
|
|
|
t.Errorf("attachment serve failed: %d %q", rec2.Code, rec2.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 12:35:14 +00:00
|
|
|
func TestUploadForm_RendersTagPicker(t *testing.T) {
|
|
|
|
|
s := testServerWithStore(t)
|
|
|
|
|
if err := s.store.SetReceiptTags("noop", nil); err != nil { // ensure table is queryable
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
rec := get(t, s, "/")
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status=%d", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
body := rec.Body.String()
|
|
|
|
|
for _, want := range []string{`id="tags-btn"`, `id="tags-modal"`, `Done with tags`} {
|
|
|
|
|
if !strings.Contains(body, want) {
|
|
|
|
|
t.Errorf("upload form missing %q", want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestUpload_WithTags(t *testing.T) {
|
|
|
|
|
s := testServerWithStore(t)
|
|
|
|
|
|
|
|
|
|
var body bytes.Buffer
|
|
|
|
|
mw := multipart.NewWriter(&body)
|
|
|
|
|
_ = mw.WriteField("amount", "12.34")
|
|
|
|
|
_ = mw.WriteField("receipt_date", "2026-06-01")
|
|
|
|
|
_ = mw.WriteField("category_id", aCategoryID(t, s))
|
|
|
|
|
_ = mw.WriteField("tags", "Dental")
|
|
|
|
|
_ = mw.WriteField("tags", "tax-2026")
|
|
|
|
|
_ = mw.WriteField("tags", "dental") // case-dup of the first — must collapse
|
|
|
|
|
rw, _ := mw.CreateFormFile("receipt", "receipt.png")
|
|
|
|
|
rw.Write(fakePNG())
|
|
|
|
|
mw.Close()
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/upload", &body)
|
|
|
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
|
|
|
req.AddCookie(authCookie(t, s))
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
s.Routes().ServeHTTP(rec, req)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(rec.Body.String(), "Tags:") {
|
|
|
|
|
t.Errorf("confirm page missing tags listing: %s", rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rows, _, err := s.store.ListRecent("uploaded_at", 10, 0)
|
|
|
|
|
if err != nil || len(rows) != 1 {
|
|
|
|
|
t.Fatalf("ListRecent: %v len=%d", err, len(rows))
|
|
|
|
|
}
|
|
|
|
|
got, err := s.store.ListReceiptTags(rows[0].ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(got) != 2 || got[0] != "Dental" || got[1] != "tax-2026" {
|
|
|
|
|
t.Errorf("receipt tags = %v, want [Dental tax-2026]", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:06:15 +00:00
|
|
|
func TestUpload_RejectsBadAttachment(t *testing.T) {
|
|
|
|
|
s := testServerWithStore(t)
|
|
|
|
|
|
|
|
|
|
var body bytes.Buffer
|
|
|
|
|
mw := multipart.NewWriter(&body)
|
|
|
|
|
_ = mw.WriteField("amount", "12.34")
|
|
|
|
|
_ = mw.WriteField("receipt_date", "2026-06-01")
|
|
|
|
|
_ = mw.WriteField("category_id", aCategoryID(t, s))
|
|
|
|
|
rw, _ := mw.CreateFormFile("receipt", "receipt.png")
|
|
|
|
|
rw.Write(fakePNG())
|
|
|
|
|
bad, _ := mw.CreateFormFile("attachments", "notes.txt")
|
|
|
|
|
bad.Write([]byte("this is plain text, not an image or pdf"))
|
|
|
|
|
mw.Close()
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/upload", &body)
|
|
|
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
|
|
|
req.AddCookie(authCookie(t, s))
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
s.Routes().ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if !strings.Contains(rec.Body.String(), "Attachments must be images or PDFs") {
|
|
|
|
|
t.Errorf("expected attachment-type error, got: %s", rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
// Nothing should have been written.
|
|
|
|
|
if n, _ := s.store.CountActive(); n != 0 {
|
|
|
|
|
t.Errorf("CountActive = %d, want 0 (rejected upload)", n)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 01:40:12 +00:00
|
|
|
func TestUpload_BadAmount_RerendersWithErrorNoRow(t *testing.T) {
|
|
|
|
|
s := testServerWithStore(t)
|
|
|
|
|
body, ct := multipartUpload(t,
|
|
|
|
|
map[string]string{"amount": "abc", "receipt_date": "2026-06-01", "category_id": aCategoryID(t, s)},
|
|
|
|
|
"receipt", "receipt.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("status = %d, want 200 (re-render)", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(rec.Body.String(), "valid amount") {
|
|
|
|
|
t.Errorf("missing amount error: %s", rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
n, _ := s.store.CountActive()
|
|
|
|
|
if n != 0 {
|
|
|
|
|
t.Errorf("CountActive = %d, want 0 (nothing stored on validation error)", n)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestUpload_RejectsNonImageNonPDF(t *testing.T) {
|
|
|
|
|
s := testServerWithStore(t)
|
|
|
|
|
body, ct := multipartUpload(t,
|
|
|
|
|
map[string]string{"amount": "5.00", "receipt_date": "2026-06-01", "category_id": aCategoryID(t, s)},
|
|
|
|
|
"receipt", "notes.txt", []byte("just plain text, not an image or pdf"))
|
|
|
|
|
|
|
|
|
|
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 !strings.Contains(rec.Body.String(), "images and PDFs") {
|
|
|
|
|
t.Errorf("expected mime rejection: %s", rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
n, _ := s.store.CountActive()
|
|
|
|
|
if n != 0 {
|
|
|
|
|
t.Errorf("CountActive = %d, want 0", n)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestExportDB_StreamsSQLiteFile(t *testing.T) {
|
|
|
|
|
s := testServerWithStore(t)
|
|
|
|
|
// Seed one receipt via the upload handler.
|
|
|
|
|
body, ct := multipartUpload(t,
|
|
|
|
|
map[string]string{"amount": "9.99", "receipt_date": "2026-06-01", "category_id": aCategoryID(t, s)},
|
|
|
|
|
"receipt", "r.png", fakePNG())
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/upload", body)
|
|
|
|
|
req.Header.Set("Content-Type", ct)
|
|
|
|
|
req.AddCookie(authCookie(t, s))
|
|
|
|
|
s.Routes().ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
|
|
|
|
|
|
for _, blobs := range []string{"true", "false"} {
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/export/db?blobs="+blobs, nil)
|
|
|
|
|
req.AddCookie(authCookie(t, s))
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
s.Routes().ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("blobs=%s: status = %d, want 200", blobs, rec.Code)
|
|
|
|
|
}
|
|
|
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/octet-stream" {
|
|
|
|
|
t.Errorf("blobs=%s: content-type = %q", blobs, ct)
|
|
|
|
|
}
|
|
|
|
|
if !strings.HasPrefix(rec.Body.String(), "SQLite format 3") {
|
|
|
|
|
t.Errorf("blobs=%s: body is not a SQLite file", blobs)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|