package web import ( "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "git.petrovv.com/nikola/money/internal/config" "git.petrovv.com/nikola/money/internal/parser" "git.petrovv.com/nikola/money/internal/rules" "git.petrovv.com/nikola/money/internal/store" "git.petrovv.com/nikola/money/internal/transfers" ) // The upload tests are about where files land and what is refused, not about // any bank's layout, so they read "date,description,amount" with a header. func init() { parser.Register("webtest", func(acc *config.Account) (parser.Parser, error) { return csvParser{digits: acc.Digits()}, nil }) } type csvParser struct{ digits int } func (p csvParser) Parse(path string, _ *config.Account) ([]parser.RawTxn, error) { body, err := os.ReadFile(path) if err != nil { return nil, err } var txns []parser.RawTxn for i, line := range strings.Split(strings.TrimSpace(string(body)), "\n")[1:] { f := strings.Split(line, ",") if len(f) != 3 { return nil, fmt.Errorf("row %d: want 3 fields", i+2) } amount, err := parser.ParseAmount(f[2], ".", "", p.digits) if err != nil { return nil, err } txns = append(txns, parser.RawTxn{Date: f[0], Description: f[1], AmountMinor: amount}) } return txns, nil } // newUploadServer builds a server over a data root with one empty account // folder, as `money serve` sees it before the first import. func newUploadServer(t *testing.T, accountTOML string) (string, http.Handler) { t.Helper() root := t.TempDir() dir := filepath.Join(root, "checking") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, config.AccountFile), []byte(accountTOML), 0o644); err != nil { t.Fatal(err) } db, err := store.Open(config.IndexPath(root)) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) accounts, err := config.LoadAccounts(root) if err != nil { t.Fatal(err) } loaded, err := config.LoadRules(root) if err != nil { t.Fatal(err) } s := New(root, db, accounts, rules.New(loaded), transfers.New(loaded)) s.now = func() time.Time { return time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC) } return dir, s.Handler() } const checkingTOML = "currency = \"EUR\"\nparser = \"webtest\"\n" const statement = "date,description,amount\n2026-02-01,LIDL SOFIA,-12.50\n2026-02-03,SALARY,1000.00\n" func TestUploadSavesAndImports(t *testing.T) { dir, h := newUploadServer(t, checkingTOML) req := uploadReq{Account: "checking", Files: []uploadFile{{Name: "2026-02.csv", Data: []byte(statement)}}} var res importJSON call(t, h, "POST", "/api/upload", req, http.StatusOK, &res) if !strings.Contains(res.Status, "uploaded 1 file(s) to checking") || !strings.Contains(res.Status, "2 new") { t.Errorf("status = %q", res.Status) } if got, err := os.ReadFile(filepath.Join(dir, "2026-02.csv")); err != nil || string(got) != statement { t.Errorf("file on disk = %q, %v", got, err) } var txns struct{ Rows []txnRow } call(t, h, "GET", "/api/transactions", nil, http.StatusOK, &txns) if len(txns.Rows) != 2 { t.Errorf("index holds %d rows, want the 2 uploaded", len(txns.Rows)) } // The same file again is not an error, and adds nothing. call(t, h, "POST", "/api/upload", req, http.StatusOK, &res) if !strings.Contains(res.Status, "uploaded 0 file(s)") || !strings.Contains(res.Status, "1 already there") { t.Errorf("re-upload status = %q", res.Status) } call(t, h, "GET", "/api/transactions", nil, http.StatusOK, &txns) if len(txns.Rows) != 2 { t.Errorf("re-upload left %d rows, want 2", len(txns.Rows)) } } // A statement already in the folder is the source of truth for what it // imported, so an upload never replaces one with different contents. func TestUploadNeverReplacesAStatement(t *testing.T) { dir, h := newUploadServer(t, checkingTOML) path := filepath.Join(dir, "2026-02.csv") if err := os.WriteFile(path, []byte(statement), 0o644); err != nil { t.Fatal(err) } req := uploadReq{Account: "checking", Files: []uploadFile{{Name: "2026-02.csv", Data: []byte("date,description,amount\n")}}} call(t, h, "POST", "/api/upload", req, http.StatusConflict, nil) if got, _ := os.ReadFile(path); string(got) != statement { t.Errorf("statement was overwritten: %q", got) } } // A batch is checked as a whole before anything is written, and nothing may // land outside the account folder or where import would not read it back. func TestUploadRefusesBadNames(t *testing.T) { dir, h := newUploadServer(t, checkingTOML+"include = [\"*.csv\"]\n") for _, bad := range []string{"../escape.csv", "sub/x.csv", ".hidden.csv", config.AccountFile, "notes.txt", ""} { req := uploadReq{Account: "checking", Files: []uploadFile{ {Name: "good.csv", Data: []byte(statement)}, {Name: bad, Data: []byte(statement)}, }} call(t, h, "POST", "/api/upload", req, http.StatusBadRequest, nil) } entries, _ := os.ReadDir(dir) if len(entries) != 1 { var names []string for _, e := range entries { names = append(names, e.Name()) } t.Errorf("folder holds %v, want only %s", names, config.AccountFile) } if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "escape.csv")); err == nil { t.Error("a file escaped the account folder") } call(t, h, "POST", "/api/upload", uploadReq{Account: "nope", Files: []uploadFile{{Name: "a.csv"}}}, http.StatusBadRequest, nil) } // Multipart is what a cross-site form can send, so it is refused like any // other non-JSON write. func TestUploadRefusesMultipart(t *testing.T) { _, h := newUploadServer(t, checkingTOML) r := httptest.NewRequest("POST", "/api/upload", strings.NewReader("--x\r\n")) r.Header.Set("Content-Type", "multipart/form-data; boundary=x") w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusUnsupportedMediaType { t.Errorf("status %d, want %d", w.Code, http.StatusUnsupportedMediaType) } }