List each account's statement files, and open them
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>
This commit is contained in:
@@ -164,3 +164,90 @@ func TestUploadRefusesMultipart(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user