Import ING and Kontist statements behind a reviewed column mapping

CSV import is now mapping-driven: N26, ING (metadata preamble, Windows-1252,
German decimals) and Kontist exports are recognized locally, and any other
layout can have its columns proposed by the configured model from a sample in
which letters are replaced by x and digits by 0. Proposals are untrusted: every
column must name a supplied header, money must come from one signed column or
one debit/credit pair, and formats must be from a closed list.

Uploading no longer imports. /api/import is replaced by prepare/confirm/cancel:
prepare parses, deduplicates and previews the exact facts, and only confirming
at the reviewed revision writes them. ING and AI-mapped facts carry no
transaction reference, because repeating SEPA mandate references must never
become a transaction identity.
This commit is contained in:
Lars Nolden
2026-09-11 17:49:03 +02:00
parent 6f791b1277
commit dc767799bc
16 changed files with 2182 additions and 301 deletions
+108
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"encoding/pem"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
@@ -307,3 +308,110 @@ func TestManualBankContextUsesOnlyTrustedPeerAndBrowserMetadata(t *testing.T) {
})
}
}
// The whole import must travel through prepare -> review -> confirm over HTTP,
// and only the multipart upload may bypass the JSON content-type CSRF guard.
func TestCSVImportOverHTTPImportsOnlyAfterConfirmation(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "")
t.Setenv("ENABLEBANKING_APP_ID", "")
t.Setenv("ENABLEBANKING_KEY_FILE", "")
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
a, err := app.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer a.Close()
h, err := New(a, fstest.MapFS{}, "")
if err != nil {
t.Fatal(err)
}
const origin = "http://localhost:8080"
send := func(path, contentType, body, origin string, want int) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(http.MethodPost, "http://localhost:8080"+path, strings.NewReader(body))
r.Header.Set("Content-Type", contentType)
r.Header.Set("Origin", origin)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != want {
t.Fatalf("POST %s: got %d, want %d: %s", path, w.Code, want, w.Body.String())
}
return w
}
state := func(w *httptest.ResponseRecorder) app.State {
t.Helper()
var s app.State
if err := json.Unmarshal(w.Body.Bytes(), &s); err != nil {
t.Fatal(err)
}
return s
}
get := func(path string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://localhost:8080"+path, nil))
if w.Code != http.StatusOK {
t.Fatalf("GET %s: got %d: %s", path, w.Code, w.Body.String())
}
return w
}
account := `{"revision":"` + state(get("/api/state")).Revision + `","account":{"display_name":"ING","institution":"ING","currency":"EUR","active":true}}`
current := state(send("/api/accounts", "application/json", account, origin, http.StatusOK))
if len(current.Data.Accounts) != 1 {
t.Fatalf("account was not created: %+v", current.Data.Accounts)
}
statement := "Buchung;Wertstellungsdatum;Auftraggeber/Empfänger;Buchungstext;Verwendungszweck;Betrag;Währung\n" +
"09.12.2025;09.12.2025;VISA Firma;Lastschrift;NR XXXX 4025 KAUFUMSATZ;-13,98;EUR\n" +
"28.11.2025;28.11.2025;Rente;Gehalt/Rente;RV-RENTE 11.2025;2.647,74;EUR\n"
upload := func(revision, accountID string) (string, string) {
t.Helper()
var body strings.Builder
form := multipart.NewWriter(&body)
for name, value := range map[string]string{"revision": revision, "account_id": accountID} {
if err := form.WriteField(name, value); err != nil {
t.Fatal(err)
}
}
file, err := form.CreateFormFile("file", "umsatzanzeige.csv")
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(file, statement); err != nil {
t.Fatal(err)
}
if err := form.Close(); err != nil {
t.Fatal(err)
}
return form.FormDataContentType(), body.String()
}
contentType, body := upload(current.Revision, current.Data.Accounts[0].ID)
// A multipart upload from another origin is still refused.
send("/api/import/prepare", contentType, body, "https://attacker.example", http.StatusForbidden)
var prepared app.CSVImport
if err := json.Unmarshal(send("/api/import/prepare", contentType, body, origin, http.StatusOK).Body.Bytes(), &prepared); err != nil {
t.Fatal(err)
}
if prepared.SourceLabel != "ING" || prepared.MappedBy != "preset" || prepared.New != 2 || len(prepared.Samples) != 2 {
t.Fatalf("unexpected prepared import: %+v", prepared)
}
if transactions := state(get("/api/state")).Data.Transactions; len(transactions) != 0 {
t.Fatalf("preparing an import wrote %d transactions", len(transactions))
}
// Confirmation is an ordinary JSON mutation, guarded like every other one.
confirm := `{"id":"` + prepared.ID + `","revision":"` + prepared.Revision + `"}`
send("/api/import/confirm", "text/plain", confirm, origin, http.StatusUnsupportedMediaType)
send("/api/import/cancel", "text/plain", `{"id":"`+prepared.ID+`"}`, origin, http.StatusUnsupportedMediaType)
var result app.ImportResult
if err := json.Unmarshal(send("/api/import/confirm", "application/json", confirm, origin, http.StatusOK).Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Imported != 2 || len(result.State.Data.Transactions) != 2 {
t.Fatalf("confirmation did not import the reviewed statement: %+v", result)
}
for _, tx := range result.State.Data.Transactions {
if tx.Facts.Source != "ing_csv" || tx.Facts.Currency != "EUR" {
t.Fatalf("unexpected imported facts: %+v", tx.Facts)
}
}
send("/api/import/confirm", "application/json", confirm, origin, http.StatusBadRequest)
}