Upload statements from the web app

The Accounts screen gets an Add statements panel: pick an account, drop
files on it or choose them, and they are saved into that account's folder
and imported. The folder stays the source of truth -- an upload only puts
a file where `money import` looks, then runs the same import as the
Import button, so deleting index.db and re-importing still loses nothing.

Files travel base64 inside JSON rather than as multipart. There is no
auth, and the JSON-only rule is what keeps another site's form from
posting here; multipart is exactly what such a form can send.

An upload never replaces a statement: identical contents are a no-op and
different ones are refused with 409. Names import would not read back --
not a plain file name, dotfiles, account.toml, outside the account's
include patterns -- are refused, and a batch is checked whole before any
of it is written. Files are written through a dotfile and renamed, so a
concurrent import never reads half of one.

The overview now lists the account folders on disk, read fresh so one
created after startup is a valid target; it replaces the configured
count the empty accounts screen used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 18:51:56 +02:00
co-authored by Claude Opus 5.5
parent ec8a844441
commit 7ec9976b80
6 changed files with 489 additions and 22 deletions
+166
View File
@@ -0,0 +1,166 @@
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)
}
}