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) } } // The statements list is the folders, each beside what the index recorded: // imported, changed since, not imported yet, and gone from disk. func TestFileListStatuses(t *testing.T) { dir, h := newUploadServer(t, checkingTOML) write := func(name, body string) { t.Helper() if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { t.Fatal(err) } } write("a.csv", statement) write("b.csv", "date,description,amount\n2026-03-01,ZARA,-40.00\n") write("c.csv", "date,description,amount\n2026-04-01,KAUFLAND,-9.00\n") call(t, h, "POST", "/api/import", importReq{}, http.StatusOK, nil) write("b.csv", "date,description,amount\n2026-03-01,ZARA,-41.00\n") if err := os.Remove(filepath.Join(dir, "c.csv")); err != nil { t.Fatal(err) } write("d.csv", statement) write(".hidden.csv", statement) var res struct{ Files []fileRow } call(t, h, "GET", "/api/files", nil, http.StatusOK, &res) got := map[string]fileRow{} var names []string for _, f := range res.Files { got[f.Name] = f names = append(names, f.Name) } if strings.Join(names, " ") != "a.csv b.csv c.csv d.csv" { t.Fatalf("files = %v, want the four statements and nothing import would skip", names) } for name, want := range map[string]string{"a.csv": "imported", "b.csv": "changed", "c.csv": "missing", "d.csv": "new"} { if got[name].Status != want { t.Errorf("%s status = %q, want %q", name, got[name].Status, want) } } if got["a.csv"].Added != 2 || got["c.csv"].Added != 1 || got["a.csv"].ImportedAt == "" { t.Errorf("a.csv = %+v, c.csv = %+v", got["a.csv"], got["c.csv"]) } if got["a.csv"].Size != int64(len(statement)) { t.Errorf("a.csv size = %d, want %d", got["a.csv"].Size, len(statement)) } } // Only a file import would read is served, and never as a page. func TestServeFileServesOnlyStatements(t *testing.T) { dir, h := newUploadServer(t, checkingTOML) for name, body := range map[string]string{"a.csv": statement, "page.html": "", ".secret": "x"} { if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { t.Fatal(err) } } get := func(path string) *httptest.ResponseRecorder { w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) return w } w := get("/api/files/checking/a.csv") if w.Code != http.StatusOK || w.Body.String() != statement { t.Fatalf("a.csv: %d %q", w.Code, w.Body.String()) } if ct := w.Header().Get("Content-Type"); ct != "text/plain; charset=utf-8" { t.Errorf("a.csv content type = %q", ct) } w = get("/api/files/checking/page.html") if w.Code != http.StatusOK || w.Header().Get("Content-Type") != "application/octet-stream" || w.Header().Get("Content-Security-Policy") != "sandbox" || !strings.HasPrefix(w.Header().Get("Content-Disposition"), "attachment") { t.Errorf("page.html served as %d %v, want a sandboxed download", w.Code, w.Header()) } for _, path := range []string{ "/api/files/checking/" + config.AccountFile, "/api/files/checking/.secret", "/api/files/checking/..%2F" + config.RulesFile, "/api/files/nope/a.csv", } { if w := get(path); w.Code != http.StatusNotFound { t.Errorf("%s: status %d, want 404", path, w.Code) } } }