The Accounts screen gets a Statements list: every file in every account folder beside what the index made of it -- imported, changed since, not imported yet (or failed), or gone from disk while its rows remain -- with its size, modified and import times, and how many transactions it brought in. Clicking a name opens the file. The list is read from the folders, not the index, through importer.StatementFiles, so a file shows exactly when import would read it; store.SourceFiles and importer.Checksum then say how far each has got. A file the index remembers but the disk lost is listed as missing rather than vanishing, since its rows would not survive a rebuild. A file is served only by finding it in that list, never by joining the requested name onto a path. Statements come from outside and are served from the app's origin, so none is rendered as a page: text is text/plain under CSP sandbox, anything not text or PDF is a sandboxed download, and PDFs -- whose viewers refuse a sandbox -- open in the browser's own isolated viewer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
254 lines
8.9 KiB
Go
254 lines
8.9 KiB
Go
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": "<script>alert(1)</script>", ".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)
|
|
}
|
|
}
|
|
}
|