753cd83456
Claude-Session: https://claude.ai/code/session_01GzammAMzsJTgpQHqxwM2bC
138 lines
4.8 KiB
Go
138 lines
4.8 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// .fab download exit (v0.124.0 Part 3) — the containment guard, the post-stream cleanup and
|
|
// the TTL sweep. The guard is NON-NEGOTIABLE (§9): only an expected-shaped basename resolved
|
|
// strictly inside the staging exports dir is ever opened.
|
|
|
|
func downloadTestServer(t *testing.T) (*Server, string) {
|
|
t.Helper()
|
|
s := testServer(t)
|
|
s.cfg.Paths.DataDir = t.TempDir()
|
|
dir := s.fabDownloadDir()
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s, dir
|
|
}
|
|
|
|
func fetchDownload(s *Server, file string) *httptest.ResponseRecorder {
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/export/download?file="+file, nil)
|
|
s.apiExportDownloadFetch(rr, req)
|
|
return rr
|
|
}
|
|
|
|
// Traversal guard: ../, absolute paths, separators and out-of-dir names must all be refused
|
|
// with NO file opened; a legit staged bundle streams byte-exactly and is removed afterwards.
|
|
// Red-proof: loosen the guard to raw prefix-matching → the `..` case FAILS (serves the decoy).
|
|
func TestFabDownload_TraversalGuardAndCleanup(t *testing.T) {
|
|
s, dir := downloadTestServer(t)
|
|
|
|
// A decoy OUTSIDE the staging dir that a traversal would reach.
|
|
decoy := filepath.Join(filepath.Dir(dir), "decoy.fab")
|
|
if err := os.WriteFile(decoy, []byte("DECOY"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The legit staged bundle.
|
|
want := []byte("FAB-BUNDLE-BYTES")
|
|
if err := os.WriteFile(filepath.Join(dir, "actualbudget_20260713-080000.fab"), want, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
refused := []string{
|
|
"..%2Fdecoy.fab", // ../decoy.fab
|
|
"..%5Cdecoy.fab", // ..\decoy.fab
|
|
"%2Fetc%2Fpasswd", // absolute path
|
|
"sub%2Fx.fab", // separator
|
|
"..", // bare traversal
|
|
".hidden.fab", // not the exporter's naming (leading dot)
|
|
"x.txt", // not a .fab
|
|
}
|
|
for _, f := range refused {
|
|
if rr := fetchDownload(s, f); rr.Code != http.StatusBadRequest {
|
|
t.Errorf("file=%q: got %d, want 400", f, rr.Code)
|
|
}
|
|
}
|
|
if body, err := os.ReadFile(decoy); err != nil || string(body) != "DECOY" {
|
|
t.Fatal("the decoy outside the staging dir was touched")
|
|
}
|
|
|
|
// A legit-looking name that does not exist → 404 (never an open of anything else).
|
|
if rr := fetchDownload(s, "nosuch_20260713-080000.fab"); rr.Code != http.StatusNotFound {
|
|
t.Errorf("missing bundle: got %d, want 404", rr.Code)
|
|
}
|
|
|
|
// The legit bundle: streamed byte-exactly with attachment headers, then REMOVED.
|
|
rr := fetchDownload(s, "actualbudget_20260713-080000.fab")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("legit download: got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
if rr.Body.String() != string(want) {
|
|
t.Fatalf("streamed bytes differ from the staged bundle")
|
|
}
|
|
if cd := rr.Header().Get("Content-Disposition"); !strings.Contains(cd, `attachment; filename="actualbudget_20260713-080000.fab"`) {
|
|
t.Errorf("Content-Disposition = %q", cd)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "actualbudget_20260713-080000.fab")); !os.IsNotExist(err) {
|
|
t.Error("staged bundle must be removed after a successful stream")
|
|
}
|
|
}
|
|
|
|
// The download API inherits the post-claim auth: an unclaimed box answers 401 before any
|
|
// handler runs (asserted per §7C even though it rides the existing RequireAuth).
|
|
func TestFabDownload_UnauthenticatedRefused(t *testing.T) {
|
|
s, _, _ := claimTestServer(t)
|
|
mux := s.fullMux()
|
|
rr := httptest.NewRecorder()
|
|
mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/export/download?file=x_1.fab", nil))
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("unauthenticated download: got %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
// TTL sweep: an aged bundle (and exporter temp) is removed; a fresh bundle and non-bundle
|
|
// files are kept. Time is injected — no clock dependency.
|
|
func TestFabDownload_TTLSweep(t *testing.T) {
|
|
dir := t.TempDir()
|
|
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
|
|
mk := func(name string, age time.Duration) {
|
|
p := filepath.Join(dir, name)
|
|
if err := os.WriteFile(p, []byte("x"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chtimes(p, now.Add(-age), now.Add(-age)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
mk("old_20260713-000000.fab", 2*time.Hour)
|
|
mk("stale.fab.tmp", 3*time.Hour)
|
|
mk("fresh_20260713-115500.fab", 5*time.Minute)
|
|
mk("unrelated.txt", 48*time.Hour)
|
|
|
|
if n := sweepFabDownloads(dir, now, time.Hour, nil); n != 2 {
|
|
t.Fatalf("sweep removed %d entries, want 2 (the aged bundle + temp)", n)
|
|
}
|
|
for name, wantGone := range map[string]bool{
|
|
"old_20260713-000000.fab": true,
|
|
"stale.fab.tmp": true,
|
|
"fresh_20260713-115500.fab": false,
|
|
"unrelated.txt": false,
|
|
} {
|
|
_, err := os.Stat(filepath.Join(dir, name))
|
|
gone := os.IsNotExist(err)
|
|
if gone != wantGone {
|
|
t.Errorf("%s: gone=%v want %v", name, gone, wantGone)
|
|
}
|
|
}
|
|
}
|