package web import ( "errors" "fmt" "io/fs" "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 }) parser.Register("webnamed", func(acc *config.Account) (parser.Parser, error) { return namingParser{csvParser{digits: acc.Digits()}}, nil }) } type csvParser struct{ digits int } // namingParser is csvParser for a bank whose downloads are named unhelpfully: // it names a statement after its first date, as nlb does after the statement // date, and says nothing for a statement with no rows. type namingParser struct{ csvParser } func (namingParser) StatementName(path string) (string, error) { body, err := os.ReadFile(path) if err != nil { return "", err } lines := strings.Split(strings.TrimSpace(string(body)), "\n") if len(lines) < 2 { return "", nil } return "stmt_" + strings.Split(lines[1], ",")[0], nil } 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" // An upload only saves: the file waits on the statements list as new until // the user presses Import. func TestUploadSavesWithoutImporting(t *testing.T) { dir, h := newUploadServer(t, checkingTOML) req := uploadReq{Account: "checking", Files: []uploadFile{{Name: "2026-02.csv", Data: []byte(statement)}}} var res status call(t, h, "POST", "/api/upload", req, http.StatusOK, &res) if res.Status != "uploaded 1 file(s) to checking · press Import to read them" { 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) } if got := descriptions(t, h); got != "" { t.Errorf("upload imported %s; it should only save", got) } var files struct{ Files []fileRow } call(t, h, "GET", "/api/files", nil, http.StatusOK, &files) if len(files.Files) != 1 || files.Files[0].Status != "new" { t.Errorf("files = %+v, want the upload waiting as new", files.Files) } call(t, h, "POST", "/api/import", importReq{}, http.StatusOK, nil) if got := descriptions(t, h); got != "SALARY,LIDL SOFIA" { t.Errorf("after Import the index holds %s", got) } // The same file again is not an error, and saves nothing. call(t, h, "POST", "/api/upload", req, http.StatusOK, &res) if res.Status != "uploaded 0 file(s) to checking (1 already there)" { t.Errorf("re-upload status = %q", res.Status) } } // 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"].Rows != 2 || got["a.csv"].Added != 2 || got["c.csv"].Rows != 1 || 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) } } } // A parser that names its statements decides the stored name, so the bank's // "download (3).pdf" lands as something that sorts by date. A reissue of the // same date is numbered rather than refused, and a statement that does not // say keeps the name it came with. func TestUploadNamesStatementsTheParserCanName(t *testing.T) { dir, h := newUploadServer(t, "currency = \"EUR\"\nparser = \"webnamed\"\n") upload := func(files ...uploadFile) string { t.Helper() var res status call(t, h, "POST", "/api/upload", uploadReq{Account: "checking", Files: files}, http.StatusOK, &res) return res.Status } reissue := "date,description,amount\n2026-02-01,LIDL SOFIA,-12.50\n2026-02-04,ZARA,-30.00\n" other := "date,description,amount\n2026-03-01,KAUFLAND,-9.00\n" status := upload(uploadFile{Name: "download (3).csv", Data: []byte(statement)}) if !strings.Contains(status, "download (3).csv → stmt_2026-02-01.csv") { t.Errorf("status = %q, want the rename named", status) } // The same statement downloaded again is recognised under its new name. if status := upload(uploadFile{Name: "download (4).csv", Data: []byte(statement)}); !strings.Contains(status, "1 already there") { t.Errorf("second download: status = %q", status) } // A different statement of the same date, and two of one date in a batch. upload(uploadFile{Name: "x.csv", Data: []byte(reissue)}, uploadFile{Name: "y.csv", Data: []byte(reissue + "2026-02-05,BOLT,-4.00\n")}, uploadFile{Name: "Z.CSV", Data: []byte(other)}, // a chosen name is lowercase uploadFile{Name: "empty.csv", Data: []byte("date,description,amount\n")}) entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } var names []string for _, e := range entries { names = append(names, e.Name()) } want := config.AccountFile + " empty.csv stmt_2026-02-01.csv stmt_2026-02-01_2.csv stmt_2026-02-01_3.csv stmt_2026-03-01.csv" if strings.Join(names, " ") != want { t.Errorf("folder = %v, want %s", names, want) } } func writeFile(t *testing.T, path, body string) { t.Helper() if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } } func descriptions(t *testing.T, h http.Handler) string { t.Helper() var txns struct{ Rows []txnRow } call(t, h, "GET", "/api/transactions", nil, http.StatusOK, &txns) var out []string for _, r := range txns.Rows { out = append(out, r.Description) } return strings.Join(out, ",") } // Removing a statement deletes the file and takes out what it brought in. A // row an overlapping statement also holds was stored under the file imported // first; the next Import brings it back from the other one. func TestRemoveFileKeepsWhatAnotherStatementHolds(t *testing.T) { dir, h := newUploadServer(t, checkingTOML) writeFile(t, filepath.Join(dir, "a.csv"), "date,description,amount\n2026-01-30,ONLY IN A,-1.00\n2026-02-01,SHARED,-2.00\n") writeFile(t, filepath.Join(dir, "b.csv"), "date,description,amount\n2026-02-01,SHARED,-2.00\n2026-02-03,ONLY IN B,-3.00\n") call(t, h, "POST", "/api/import", importReq{}, http.StatusOK, nil) // Each holds two rows; b.csv was imported second, so its shared row // stayed with a.csv and it brought in only one. var before struct{ Files []fileRow } call(t, h, "GET", "/api/files", nil, http.StatusOK, &before) if f := before.Files; len(f) != 2 || f[0].Rows != 2 || f[0].Added != 2 || f[1].Rows != 2 || f[1].Added != 1 { t.Errorf("files = %+v, want a.csv 2 rows · 2 new and b.csv 2 rows · 1 new", f) } var res status call(t, h, "POST", "/api/files/delete", removeFileReq{Account: "checking", Name: "a.csv"}, http.StatusOK, &res) if !strings.HasPrefix(res.Status, "deleted checking/a.csv, 2 transaction(s) dropped") { t.Errorf("status = %q", res.Status) } // Deleting does not import, so the shared row is gone for now, and b.csv // says it is due to be read again. if got := descriptions(t, h); got != "ONLY IN B" { t.Errorf("index holds %s right after the delete, want only b's own row", got) } var files struct{ Files []fileRow } call(t, h, "GET", "/api/files", nil, http.StatusOK, &files) if len(files.Files) != 1 || files.Files[0].Status != "changed" { t.Errorf("files = %+v, want b.csv marked to be re-read", files.Files) } call(t, h, "POST", "/api/import", importReq{}, http.StatusOK, nil) if got := descriptions(t, h); got != "ONLY IN B,SHARED" { t.Errorf("after Import the index holds %s, want the shared row restored from b", got) } if _, err := os.Stat(filepath.Join(dir, "a.csv")); !errors.Is(err, fs.ErrNotExist) { t.Errorf("a.csv is still on disk: %v", err) } if entries, _ := os.ReadDir(dir); len(entries) != 2 { t.Errorf("folder holds %d entries, want account.toml and b.csv only", len(entries)) } call(t, h, "GET", "/api/files", nil, http.StatusOK, &files) if len(files.Files) != 1 || files.Files[0].Name != "b.csv" || files.Files[0].Status != "imported" { t.Errorf("files = %+v, want only b.csv, imported", files.Files) } } // A removed leg takes its transfer pairing with it, and the other leg is left // unpaired — which is the truth once one side is gone. func TestRemoveFileUnpairsItsTransfers(t *testing.T) { dir, h := newUploadServer(t, checkingTOML) writeFile(t, filepath.Join(filepath.Dir(dir), config.RulesFile), ` [[transfer]] from_account = "checking" from_desc = "*OUT*" to_account = "checking" to_desc = "*IN*" `) writeFile(t, filepath.Join(dir, "out.csv"), "date,description,amount\n2026-02-01,MOVE OUT,-5.00\n") writeFile(t, filepath.Join(dir, "in.csv"), "date,description,amount\n2026-02-02,MOVE IN,5.00\n") call(t, h, "POST", "/api/import", importReq{}, http.StatusOK, nil) var res status call(t, h, "POST", "/api/files/delete", removeFileReq{Account: "checking", Name: "in.csv"}, http.StatusOK, &res) if !strings.Contains(res.Status, "1 transfer leg(s) unpaired") { t.Errorf("status = %q, want the remaining leg reported unpaired", res.Status) } } // A file already gone from disk is forgotten by the index; a name the index // never had is not found. func TestRemoveFileForgetsAMissingOne(t *testing.T) { dir, h := newUploadServer(t, checkingTOML) writeFile(t, filepath.Join(dir, "a.csv"), statement) call(t, h, "POST", "/api/import", importReq{}, http.StatusOK, nil) if err := os.Remove(filepath.Join(dir, "a.csv")); err != nil { t.Fatal(err) } var res status call(t, h, "POST", "/api/files/delete", removeFileReq{Account: "checking", Name: "a.csv"}, http.StatusOK, &res) if !strings.HasPrefix(res.Status, "forgot checking/a.csv, 2 transaction(s) dropped") { t.Errorf("status = %q", res.Status) } if got := descriptions(t, h); got != "" { t.Errorf("index still holds %s", got) } call(t, h, "POST", "/api/files/delete", removeFileReq{Account: "checking", Name: "a.csv"}, http.StatusNotFound, nil) call(t, h, "POST", "/api/files/delete", removeFileReq{Account: "checking", Name: "../rules.toml"}, http.StatusBadRequest, nil) if _, err := os.Stat(filepath.Join(dir, config.AccountFile)); err != nil { t.Fatal(err) } call(t, h, "POST", "/api/files/delete", removeFileReq{Account: "checking", Name: config.AccountFile}, http.StatusNotFound, nil) }