package api import ( "bytes" "context" "encoding/csv" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "testing/fstest" "time" "github.com/cdnutter/playlink/internal/engine" "github.com/cdnutter/playlink/internal/probe" "github.com/cdnutter/playlink/internal/store" ) func TestExportCSV(t *testing.T) { ctx := context.Background() root, _ := filepath.EvalSymlinks(t.TempDir()) master, slave := filepath.Join(root, "Albums"), filepath.Join(root, "Singles") write := func(base, rel, content string) { p := filepath.Join(base, rel) os.MkdirAll(filepath.Dir(p), 0o755) if err := os.WriteFile(p, []byte(content), 0o644); err != nil { t.Fatal(err) } } // Enough rows to need more than one export page. for i := 0; i < 1005; i++ { rel := fmt.Sprintf("Bulk/Album/%04d.flac", i) write(master, rel, "same") write(slave, rel, "same") } write(master, "Beyoncé/Lemonade/01 Pray You Catch Me.flac", "a") write(slave, "Beyoncé/Lemonade/01 Pray You Catch Me.flac", "b") write(slave, "=HYPERLINK(evil)/song.flac", "x") // a folder name a spreadsheet would run st, err := store.Open(filepath.Join(root)) if err != nil { t.Fatal(err) } defer st.DB.Close() eng := engine.New(st, probe.Prober{}, []string{root}, slog.New(slog.NewTextHandler(io.Discard, nil))) p := store.Pair{Name: "Chris' Music", MasterRoot: master, SlaveRoot: slave} if _, err := eng.NormalizePair(&p); err != nil { t.Fatal(err) } if err := st.SavePair(ctx, &p); err != nil { t.Fatal(err) } if _, err := eng.StartScan(p.ID, "test"); err != nil { t.Fatal(err) } for deadline := time.Now().Add(20 * time.Second); ; { if j := eng.Job(p.ID); j != nil && j.State != "running" { break } if time.Now().After(deadline) { t.Fatal("scan did not finish") } time.Sleep(20 * time.Millisecond) } h := New(Config{}, st, eng, slog.New(slog.NewTextHandler(io.Discard, nil)), fstest.MapFS{}).Handler() get := func(url string, auth bool) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, url, nil) if auth { req.AddCookie(&http.Cookie{Name: cookieName, Value: "tok"}) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } url := fmt.Sprintf("/api/pairs/%d/export.csv", p.ID) if rec := get(url, false); rec.Code != http.StatusUnauthorized { t.Fatalf("export without a session must be refused, got %d", rec.Code) } if err := st.CreateFirstUser(ctx, "chris", "x"); err != nil { t.Fatal(err) } u, _ := st.UserByName(ctx, "chris") if err := st.CreateSession(ctx, hashToken("tok"), u.ID, time.Now().Add(time.Hour).Unix()); err != nil { t.Fatal(err) } rec := get(url, true) if rec.Code != 200 { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/csv") { t.Fatalf("content type %q", ct) } if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, `filename="playlink-Chris-Music-all-`) { t.Fatalf("content disposition %q", cd) } body := rec.Body.Bytes() if !bytes.HasPrefix(body, []byte{0xEF, 0xBB, 0xBF}) { t.Fatal("missing UTF-8 byte order mark") } rows, err := csv.NewReader(bytes.NewReader(body[3:])).ReadAll() if err != nil { t.Fatal(err) } if len(rows) != 1+1005+2 { t.Fatalf("want header + 1007 rows (paging), got %d", len(rows)) } if strings.Join(rows[0], ",") != strings.Join(exportHeader, ",") { t.Fatal("unexpected header") } col := map[string]int{} for i, h := range rows[0] { col[h] = i } seen := map[string][]string{} for _, r := range rows[1:] { seen[strings.TrimPrefix(r[col["slave_path"]], "'")] = r } if r := seen["Beyoncé/Lemonade/01 Pray You Catch Me.flac"]; r == nil || r[col["status"]] != "different" || r[col["recommendation"]] != "review" { t.Fatalf("non-ASCII row missing or wrong: %v", r) } if r := seen["=HYPERLINK(evil)/song.flac"]; r == nil || r[col["slave_path"]] != "'=HYPERLINK(evil)/song.flac" { t.Fatalf("a path starting with = must be neutralised, got %v", r) } if r := seen["Bulk/Album/0000.flac"]; r[col["recommendation"]] != "link (identical)" || r[col["match_type"]] != "same path" { t.Fatalf("identical row: %v", r) } // Filters narrow the export exactly like the Review list. rec = get(url+"?status=different", true) rows, _ = csv.NewReader(bytes.NewReader(rec.Body.Bytes()[3:])).ReadAll() if len(rows) != 2 { t.Fatalf("status filter: want header + 1 row, got %d", len(rows)) } rec = get(url+"?q=0100", true) rows, _ = csv.NewReader(bytes.NewReader(rec.Body.Bytes()[3:])).ReadAll() if len(rows) != 2 { t.Fatalf("search filter: want header + 1 row, got %d", len(rows)) } } func TestCSVSafe(t *testing.T) { for in, want := range map[string]string{ "=SUM(A1)": "'=SUM(A1)", "+1": "'+1", "-x": "'-x", "@cmd": "'@cmd", "Artist/Song": "Artist/Song", "": "", "Beyoncé/Halo": "Beyoncé/Halo", } { if got := csvSafe(in); got != want { t.Errorf("csvSafe(%q) = %q, want %q", in, got, want) } } }