package server import ( "encoding/csv" "net/http/httptest" "net/url" "strings" "testing" ) func TestTaskHistoryPaginationFiltersAndOldPending(t *testing.T) { f := newFixtureWorker(t, false) token := f.login("viewer") _, err := f.db.Exec(`INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key,created_at,updated_at) SELECT 'history_'||lpad(i::text,5,'0'),'desktop','start','target_'||i,'测试桌面_'||i,'worker','success','完成','history_'||i,'2025-01-02T00:00:00Z'::timestamptz,'2025-01-02T00:00:01Z'::timestamptz FROM generate_series(1,1050) i; INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key,created_at) VALUES ('old-pending','snapshot','snapshot-create','pending-target','旧快照','Root_100%','timeout','结果未确认','old-pending','2024-01-01T00:00:00Z'), ('old-running','terminal','collect-logs','old-terminal','诊断设备','worker','running','正在执行','old-running','2024-01-01T00:00:00Z')`) if err != nil { t.Fatal(err) } code, out := f.request("GET", "/tasks?page=11&pageSize=100", token, nil, "") expect(t, 200, code, out) items := out["items"].([]any) if out["total"] != float64(1052) || len(items) != 52 || items[len(items)-1].(map[string]any)["id"] != "old-pending" { t.Fatal(out) } code, out = f.request("GET", "/tasks?active=true", token, nil, "") expect(t, 200, code, out) if out["total"] != float64(2) { t.Fatal(out) } q := url.Values{"actor": {"_100%"}, "status": {"timeout"}, "action": {"snapshot-create"}, "kind": {"snapshot"}, "targetId": {"pending-target"}, "keyword": {"结果未确认"}, "from": {"2024-01-01T08:00:00+08:00"}, "to": {"2024-01-02T08:00:00+08:00"}} code, out = f.request("GET", "/tasks?"+q.Encode(), token, nil, "") expect(t, 200, code, out) if out["total"] != float64(1) || out["items"].([]any)[0].(map[string]any)["id"] != "old-pending" { t.Fatal(out) } for _, term := range []string{"桌面_1", "%", "' OR true --"} { code, out = f.request("GET", "/tasks?keyword="+url.QueryEscape(term), token, nil, "") expect(t, 200, code, out) want := float64(0) if term == "桌面_1" { want = 162 } if term == "%" { want = 1 } if out["total"] != want { t.Fatal(term, out["total"]) } } for _, query := range []string{"from=broken", "from=2025-01-02T00:00:00Z&to=2025-01-01T00:00:00Z", "status=invalid", "active=maybe", "actor=%00", "keyword=" + strings.Repeat("x", 201)} { for _, path := range []string{"/tasks?", "/tasks/export?"} { code, out = f.request("GET", path+query, token, nil, "") expect(t, 400, code, out) } } code, out = f.request("GET", "/tasks?page=999999999&pageSize=999", token, nil, "") expect(t, 200, code, out) if out["page"] != float64(1000000) || out["pageSize"] != float64(100) || len(out["items"].([]any)) != 0 { t.Fatal(out) } } func downloadTaskCSV(f *fixture, token, query string) *httptest.ResponseRecorder { r := httptest.NewRequest("GET", "/api/v1/tasks/export"+query, nil) if token != "" { r.Header.Set("Authorization", "Bearer "+token) } w := httptest.NewRecorder() f.server.Router.ServeHTTP(w, r) return w } func TestTaskHistoryExportRolesAndPrivateFields(t *testing.T) { f := newFixtureWorker(t, false) _, err := f.db.Exec(`INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key,provider_ref) VALUES ('csv-task','desktop','start','csv-target',$1,'worker','failed',$2,'private-idempotency','private-provider-payload')`, "研发,\"一组\"\n第二行", "\t=HYPERLINK(\"x\")") if err != nil { t.Fatal(err) } if w := downloadTaskCSV(f, "", ""); w.Code != 401 { t.Fatal(w.Code) } for _, role := range []string{"admin", "operator", "viewer"} { token := f.login(role) w := downloadTaskCSV(f, token, "?keyword=csv-task&page=100&pageSize=1") if w.Code != 200 || w.Header().Get("X-Export-Count") != "1" { t.Fatal(w.Code, w.Body.String()) } body := w.Body.String() if !strings.HasPrefix(body, "\ufeff") || strings.Contains(body, "private-") { t.Fatal("CSV exposed internal fields") } rows, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(body, "\ufeff"))).ReadAll() if err != nil || len(rows) != 2 || len(rows[0]) != 10 || rows[1][2] != "csv-task" || rows[1][7] != "研发,\"一组\"\n第二行" || rows[1][9] != "'\t=HYPERLINK(\"x\")" { t.Fatal(rows, err) } var count int err = f.db.Get(&count, "SELECT count(*) FROM audit_logs WHERE action='export_tasks' AND actor=$1 AND target='rows=1'", role) if err != nil || count != 1 { t.Fatal(count, err) } } } func TestTaskHistoryExportCompleteAndLimit(t *testing.T) { f := newFixtureWorker(t, false) token := f.login("admin") _, err := f.db.Exec(`INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,idempotency_key) SELECT 'bulk_'||i,'desktop','start','target_'||i,'桌面','worker','success','bulk_'||i FROM generate_series(1,10001) i`) if err != nil { t.Fatal(err) } if w := downloadTaskCSV(f, token, ""); w.Code != 413 { t.Fatal(w.Code) } if _, err = f.db.Exec("DELETE FROM tasks WHERE id='bulk_10001'"); err != nil { t.Fatal(err) } w := downloadTaskCSV(f, token, "?pageSize=1") rows, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(w.Body.String(), "\ufeff"))).ReadAll() if w.Code != 200 || w.Header().Get("X-Export-Count") != "10000" || err != nil || len(rows) != 10001 { t.Fatal(w.Code, len(rows), err) } w = downloadTaskCSV(f, token, "?keyword=nonexistent") if w.Code != 200 || w.Header().Get("X-Export-Count") != "0" { t.Fatal(w.Code) } }