From 615b40f23ca598f21cf4a3e471d646187c092dcc Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:36:43 +0000 Subject: [PATCH 1/3] fix(webui): keep large findings sets responsive Batch replay and live rendering, paginate findings, and cache filtering data. Add browser regressions for large result sets and document pagination behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 6 ++ docs/guides/scanning.md | 2 + pkg/webui/server.go | 73 +++++++++++--- pkg/webui/server_browser_test.go | 131 ++++++++++++++++++++++++ pkg/webui/server_browser_test.js | 164 +++++++++++++++++++++++++++++++ pkg/webui/server_test.go | 58 +++++++++++ 6 files changed, 420 insertions(+), 14 deletions(-) create mode 100644 pkg/webui/server_browser_test.go create mode 100644 pkg/webui/server_browser_test.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 11086de2..86d97bc6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,6 +90,12 @@ make test-e2e-devops make test-e2e-gitea ``` +The Web UI regression tests exercise a real browser with 2,000 findings, including replay, filtering, pagination, CSV export, and live updates. They run automatically with the unit tests when Chromium or Google Chrome is on `PATH`; otherwise the browser test reports a skip. To use a browser installed elsewhere: + +```bash +PIPELEEK_WEBUI_BROWSER=/path/to/chromium go test ./pkg/webui -race -v +``` + ### Linting ```bash diff --git a/docs/guides/scanning.md b/docs/guides/scanning.md index 00320974..08364474 100644 --- a/docs/guides/scanning.md +++ b/docs/guides/scanning.md @@ -39,6 +39,8 @@ Add `--webui` to serve findings in a live web UI while the scan runs. Pipeleek b Below **Live secret findings**, the UI shows the target instance and effective scan flags, including values from configuration and defaults. Credential values such as tokens and cookies are redacted, and credentials, query parameters, and fragments in the target URL are omitted. +Findings are shown newest first, with 100 results per page. Use **Previous** and **Next** to browse large result sets. Severity, type, and search filters apply to all findings, not just the current page, and changing a filter returns to the first page. **Export CSV** exports every matching finding across all pages. Summary counts always cover the full scan. Incoming findings are rendered in batches to keep the controls responsive. + Use `-v` with `--webui` to see debug-level startup details: ```bash diff --git a/pkg/webui/server.go b/pkg/webui/server.go index 1d4909a5..b5e743fb 100644 --- a/pkg/webui/server.go +++ b/pkg/webui/server.go @@ -1,6 +1,7 @@ package webui import ( + "bufio" "crypto/rand" "encoding/csv" "encoding/hex" @@ -238,6 +239,9 @@ var pageTemplate = strings.Join([]string{ " table th:nth-child(6), table td:nth-child(6) { width: 14%; }", " .details-list li, .details-list a { overflow-wrap: anywhere; word-break: break-word; }", " .findings-table-card { overflow: hidden; }", + " .pagination { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .75rem; }", + " .pagination-actions { display: flex; gap: .5rem; }", + " .control-btn:disabled { opacity: .5; cursor: default; }", " #toast { position: fixed; right: 1rem; bottom: 1rem; background: rgba(42,74,37,.96); color: #fff; padding: .7rem .9rem; border-radius: 999px; font-size: .84rem; box-shadow: 0 8px 20px rgba(0,0,0,.2); opacity: 0; pointer-events: none; transform: translateY(8px); transition: opacity .18s ease, transform .18s ease; z-index: 2000; }", " #toast.visible { opacity: 1; transform: translateY(0); }", " @media (max-width: 720px) { main { padding: 0 .5rem; } .top-nav-inner { padding: .6rem .75rem; } h1 { font-size: 1.6rem; } }", @@ -332,6 +336,10 @@ var pageTemplate = strings.Join([]string{ " ", " ", "
", + " ", " ", " ", " ", @@ -340,13 +348,18 @@ var pageTemplate = strings.Join([]string{ " ", "
Secret copied to clipboard
", " ", "", @@ -891,12 +927,21 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { } flusher.Flush() - for _, finding := range findings { + replay := bufio.NewWriter(w) + for i, finding := range findings { payload, _ := json.Marshal(finding) - if _, err := fmt.Fprintf(w, "event: finding\ndata: %s\n\n", string(payload)); err != nil { + if _, err := fmt.Fprintf(replay, "event: finding\ndata: %s\n\n", string(payload)); err != nil { return } - flusher.Flush() + if (i+1)%100 == 0 { + if err := replay.Flush(); err != nil { + return + } + flusher.Flush() + } + } + if err := replay.Flush(); err != nil { + return } if _, err := fmt.Fprint(w, "event: replay-complete\ndata: complete\n\n"); err != nil { return diff --git a/pkg/webui/server_browser_test.go b/pkg/webui/server_browser_test.go new file mode 100644 index 00000000..dcc57c62 --- /dev/null +++ b/pkg/webui/server_browser_test.go @@ -0,0 +1,131 @@ +package webui + +import ( + "bytes" + "context" + _ "embed" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/CompassSecurity/pipeleek/pkg/logging" +) + +//go:embed server_browser_test.js +var browserTests string + +func TestBrowserLargeFindings(t *testing.T) { + browser := os.Getenv("PIPELEEK_WEBUI_BROWSER") + if browser == "" { + for _, name := range []string{"chromium", "chromium-browser", "google-chrome", "google-chrome-stable"} { + if path, err := exec.LookPath(name); err == nil { + browser = path + break + } + } + } + if browser == "" { + t.Skip("Chromium not installed; set PIPELEEK_WEBUI_BROWSER to run browser regression tests") + } + + s := &Server{token: "browser-fixture", clients: make(map[chan string]struct{})} + start := time.Now().Add(-time.Hour) + for i := range 2000 { + s.handleHit(logging.HitRecord{ + Time: start.Add(time.Duration(i) * time.Millisecond), + Type: []string{"log", "archive"}[i%2], + Confidence: []string{"verified", "high", "trufflehog"}[i%3], + RuleName: fmt.Sprintf("fixture-rule-%04d", i+1), + Value: fmt.Sprintf("fixture-value-%04d\n%s", i+1, strings.Repeat("fixture context ", 16)), + Fields: map[string]interface{}{ + "url": fmt.Sprintf("https://example.test/jobs/%04d", i+1), + "project": fmt.Sprintf("project-%02d", i%20), + "job": "fixture-job", + }, + }) + } + s.Complete() + app := s.handler() + results := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/test-result" { + result, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10)) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + select { + case results <- string(result): + default: + http.Error(w, "test result already received", http.StatusConflict) + } + return + } + r.AddCookie(&http.Cookie{Name: "pipeleek-webui-token", Value: s.token}) + if r.URL.Path != "/" { + app.ServeHTTP(w, r) + return + } + res := httptest.NewRecorder() + app.ServeHTTP(res, r) + for key, values := range res.Header() { + w.Header()[key] = values + } + page := strings.Replace(res.Body.String(), " ", 1) + _, _ = fmt.Fprint(w, page) + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, browser, + "--headless", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", + "--disable-background-networking", "--no-proxy-server", + "--user-data-dir="+filepath.Join(t.TempDir(), "browser"), + server.URL) + var output bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &output + cmd.WaitDelay = 5 * time.Second + if err := cmd.Start(); err != nil { + t.Fatalf("could not start browser: %v", err) + } + exited := make(chan struct{}) + var browserErr error + go func() { + browserErr = cmd.Wait() + close(exited) + }() + stop := func() { + _ = cmd.Process.Kill() + <-exited + } + defer stop() + select { + case result := <-results: + if !strings.HasPrefix(result, "PASS ") { + t.Fatalf("browser regression failed: %s", result) + } + t.Log(result) + case <-exited: + t.Fatalf("browser exited before reporting results: %v\n%s", browserErr, output.String()) + case <-ctx.Done(): + stop() + t.Fatalf("browser test timed out: %v\n%s", ctx.Err(), output.String()) + } +} diff --git a/pkg/webui/server_browser_test.js b/pkg/webui/server_browser_test.js new file mode 100644 index 00000000..07b4b942 --- /dev/null +++ b/pkg/webui/server_browser_test.js @@ -0,0 +1,164 @@ +(async function() { + var result = document.createElement('pre'); + result.id = 'webui-test-result'; + document.body.appendChild(result); + var timings = {}; + var reportResult = window.fetch.bind(window); + var renderCount = 0; + var originalRenderRows = renderRows; + renderRows = function() { renderCount += 1; originalRenderRows(); }; + + function assert(condition, message) { + if (!condition) throw new Error(message); + } + function delay(ms) { + return new Promise(function(resolve) { window.setTimeout(resolve, ms); }); + } + async function waitFor(predicate, message) { + for (var i = 0; i < 500; i += 1) { + if (predicate()) return; + await delay(20); + } + throw new Error(message); + } + async function rendered() { + await waitFor(function() { return renderTimer === null; }, 'render did not finish'); + assert(tableBody.children.length <= 100, 'table exceeded the 100-row rendering bound'); + } + function emit(name, item) { + source.dispatchEvent(new MessageEvent(name, { data: JSON.stringify(item) })); + } + function firstSecret() { + return tableBody.querySelector('button[data-copy]').dataset.copy; + } + + try { + var started = performance.now(); + await waitFor(function() { return state.all.length === 2000 && state.notificationsReady; }, 'SSE replay did not deliver 2,000 findings'); + await rendered(); + timings.reloadMillis = Math.round(performance.now() - started); + assert(timings.reloadMillis < 5000, '2,000-finding reload took over five seconds'); + assert(renderCount <= 2, 'replay rebuilt the table ' + renderCount + ' times'); + assert(tableBody.children.length === 100, 'first page must show 100 findings'); + assert(firstSecret().startsWith('fixture-value-2000'), 'findings must be ordered by precise timestamp, newest first'); + assert(pageStatus.textContent === 'Showing 1-100 of 2000 findings (2000 total)', 'incorrect first-page count'); + assert(previousPage.disabled && !nextPage.disabled, 'incorrect first-page navigation'); + assert(connectionState.textContent === 'complete', 'completed SSE replay lost completion state'); + assert(window.testNotifications.length === 0, 'historical findings triggered notifications'); + var all = filteredFindings().slice(); + assert(filteredFindings() === filteredFindings(), 'filtered findings should be cached'); + assert(Array.from(summaryCards.querySelectorAll('strong')).map(function(el) { return el.textContent; }).join(',') === '667,667,0,0,666', 'incorrect full-scan summary'); + + nextPage.click(); + assert(firstSecret().startsWith('fixture-value-1900'), 'next page did not advance'); + assert(pageStatus.textContent.startsWith('Showing 101-200'), 'incorrect second-page count'); + previousPage.click(); + assert(firstSecret().startsWith('fixture-value-2000'), 'previous page did not return'); + for (var page = 1; page < 20; page += 1) nextPage.click(); + assert(nextPage.disabled && !previousPage.disabled, 'last-page navigation should stop at the boundary'); + assert(firstSecret().startsWith('fixture-value-0100'), 'last page omitted findings'); + + started = performance.now(); + document.getElementById('severity-trigger').click(); + document.querySelector('#severity-options [data-value="verified"]').click(); + document.querySelector('#severity-options [data-value="high"]').click(); + document.getElementById('type-trigger').click(); + document.querySelector('#type-options [data-value="archive"]').click(); + await rendered(); + timings.filterMillis = Math.round(performance.now() - started); + assert(timings.filterMillis < 1000, 'multi-select filter took over one second'); + var expected = all.filter(function(item) { + return ['verified', 'high'].includes(item.confidence) && item.type === 'archive'; + }); + assert(filteredFindings().length === expected.length, 'multi-select filters did not cover all pages'); + assert(state.page === 0 && previousPage.disabled, 'filter changes must return to the first page'); + assert(firstSecret() === expected[0].value, 'filtered results lost timestamp order'); + assert(summaryCards.querySelector('strong').textContent === '667', 'filter changed full-scan summary'); + + resetButton.click(); + await rendered(); + var searches = [ + ['FIXTURE-RULE-0001', 'fixture-value-0001'], + ['fixture-value-0002', 'fixture-value-0002'], + ['https://example.test/jobs/0003', 'fixture-value-0003'], + ['PROJECT-07', null], + ['archive', null] + ]; + for (var search of searches) { + searchFilter.value = search[0]; + searchFilter.dispatchEvent(new Event('input')); + await rendered(); + assert(filteredFindings().length > 0, 'search failed for ' + search[0]); + if (search[1]) assert(firstSecret().startsWith(search[1]), 'search omitted an off-page finding'); + } + searchFilter.value = 'not-a-fixture-match'; + searchFilter.dispatchEvent(new Event('input')); + await rendered(); + assert(tableBody.textContent.includes('No findings match the current filters.'), 'filtered empty state missing'); + assert(previousPage.disabled && nextPage.disabled, 'empty results should disable pagination'); + + resetFilters(); + await rendered(); + nextPage.click(); + var exportBody; + var originalFetch = window.fetch; + var originalClick = HTMLAnchorElement.prototype.click; + window.fetch = function(url, options) { + assert(url === '/api/export.csv', 'CSV request used an unexpected URL'); + exportBody = JSON.parse(options.body); + return Promise.resolve({ ok: true, blob: function() { return Promise.resolve(new Blob(['fixture CSV'])); } }); + }; + HTMLAnchorElement.prototype.click = function() {}; + exportCSVButton.click(); + assert(exportBody.length === 2000, 'CSV export was limited to the current page'); + assert(exportBody[0].id === '2000' && exportBody[1999].id === '1', 'CSV lost order or finding fields'); + assert(!('search' in exportBody[0]) && !('item' in exportBody[0]), 'CSV payload leaked internal indexing fields'); + // Export immediately after typing, before the scheduled table render. + searchFilter.value = 'PROJECT-07'; + searchFilter.dispatchEvent(new Event('input')); + exportCSVButton.click(); + assert(exportBody.length === 100 && exportBody.every(function(item) { return item.details.project === 'project-07'; }), 'CSV did not use the latest filters'); + await delay(20); + window.fetch = originalFetch; + HTMLAnchorElement.prototype.click = originalClick; + await rendered(); + resetFilters(); + await rendered(); + + // Reconnection replays immutable IDs without rebuilding, recounting, or notifying. + var beforeReplay = renderCount; + source.onopen(); + all.forEach(function(item) { emit('finding', item); }); + assert(renderCount === beforeReplay && renderTimer === null, 'duplicate replay scheduled table work'); + assert(state.all.length === 2000, 'reconnect replay duplicated findings'); + var liveTime = Date.now() + 1000; + emit('finding', { id: '2001', time: new Date(liveTime).toISOString(), timestampMillis: liveTime, confidence: 'verified', type: 'log', value: 'fixture-replay-new' }); + assert(window.testNotifications.length === 0, 'replayed finding triggered a notification'); + assert(renderTimer === null, 'replay should wait for replay-complete before rendering'); + emit('replay-complete', null); + await rendered(); + assert(state.all.length === 2001, 'reconnect omitted a new finding'); + for (var i = 0; i < 200; i += 1) { + emit('finding', { id: String(2002 + i), time: new Date(liveTime + i + 1).toISOString(), timestampMillis: liveTime + i + 1, confidence: 'verified', type: 'log', value: 'fixture-live-' + i }); + } + assert(renderCount === beforeReplay + 1, 'live burst rendered synchronously per finding'); + await rendered(); + assert(renderCount === beforeReplay + 2, 'live burst was not coalesced into one render'); + assert(state.all.length === 2201 && filteredFindings().length === 2201, 'live updates lost findings or cached stale results'); + assert(firstSecret() === 'fixture-live-199', 'live finding did not invalidate timestamp ordering'); + assert(summaryCards.querySelector('strong').textContent === '868', 'live or duplicate updates corrupted summary counts'); + assert(window.testNotifications.length === 200, 'live findings lost notifications'); + assert(window.testNotifications.every(function(n) { return !n.options.body.includes('fixture-live'); }), 'notification text exposed a secret'); + for (var lastPage = 0; lastPage < 22; lastPage += 1) nextPage.click(); + assert(nextPage.disabled && tableBody.children.length === 1, 'partial last page must show the final finding exactly once'); + assert(pageStatus.textContent === 'Showing 2201-2201 of 2201 findings (2201 total)', 'partial-page count is incorrect'); + emit('done', { elapsedMillis: 42135 }); + await rendered(); + assert(connectionState.textContent === 'complete' && elapsedTime.textContent === 'Elapsed 00:00:42', 'completion or elapsed-time state regressed'); + result.textContent = 'PASS ' + JSON.stringify(timings) + ', bounded rows and batched replay/live updates'; + } catch (error) { + result.textContent = 'FAIL ' + error.stack; + } finally { + await reportResult('/test-result', { method: 'POST', body: result.textContent }); + } +})(); diff --git a/pkg/webui/server_test.go b/pkg/webui/server_test.go index 17e2690c..1e6627b4 100644 --- a/pkg/webui/server_test.go +++ b/pkg/webui/server_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/csv" "encoding/json" + "fmt" "net/http" "net/http/httptest" "regexp" @@ -99,6 +100,63 @@ func TestCompletedEventsReplayFindingsAndDone(t *testing.T) { } } +type flushCountingRecorder struct { + *httptest.ResponseRecorder + flushes int +} + +func (r *flushCountingRecorder) Flush() { + r.flushes++ + r.ResponseRecorder.Flush() +} + +func TestLargeReplayBatchesFlushesAndPreservesAllFindings(t *testing.T) { + const count = 2001 + s := &Server{token: "abc", clients: make(map[chan string]struct{})} + for i := range count { + s.handleHit(logging.HitRecord{ + Time: time.UnixMilli(int64(i)), Value: fmt.Sprintf("fixture-%d", i), + }) + } + s.Complete() + req := httptest.NewRequest(http.MethodGet, "/events?token=abc", nil) + res := &flushCountingRecorder{ResponseRecorder: httptest.NewRecorder()} + s.handler().ServeHTTP(res, req) + + if res.Code != http.StatusOK { + t.Fatalf("expected successful replay, got %d", res.Code) + } + events := strings.Split(res.Body.String(), "\n\n") + var findings []Finding + for _, event := range events { + event = strings.TrimSpace(event) + if !strings.HasPrefix(event, "event: finding\ndata: ") { + continue + } + var finding Finding + if err := json.Unmarshal([]byte(strings.TrimPrefix(event, "event: finding\ndata: ")), &finding); err != nil { + t.Fatal(err) + } + findings = append(findings, finding) + } + if len(findings) != count { + t.Fatalf("replayed %d findings, want %d", len(findings), count) + } + for i, finding := range findings { + if finding.ID != fmt.Sprint(i+1) || finding.Value != fmt.Sprintf("fixture-%d", i) { + t.Fatalf("finding %d was lost or reordered: %#v", i, finding) + } + } + // Initial connection, full batches, replay completion, and scan completion. + if want := 1 + count/100 + 2; res.flushes != want { + t.Fatalf("flushed %d times, want %d instead of once per finding", res.flushes, want) + } + body := res.Body.String() + if strings.Index(body, "event: replay-complete") > strings.Index(body, "event: done") { + t.Fatal("scan completion preceded replay completion") + } +} + func TestDoneEventUsesServerCompletionTime(t *testing.T) { startedAt := time.Date(2026, time.October, 1, 12, 0, 0, 0, time.UTC) s := &Server{ From f499c4e535f0b15ee7f358602dfee071b1e2135e Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:48:53 +0000 Subject: [PATCH 2/3] docs: remove additions requested in PR review Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 6 ------ docs/guides/scanning.md | 2 -- 2 files changed, 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 86d97bc6..11086de2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,12 +90,6 @@ make test-e2e-devops make test-e2e-gitea ``` -The Web UI regression tests exercise a real browser with 2,000 findings, including replay, filtering, pagination, CSV export, and live updates. They run automatically with the unit tests when Chromium or Google Chrome is on `PATH`; otherwise the browser test reports a skip. To use a browser installed elsewhere: - -```bash -PIPELEEK_WEBUI_BROWSER=/path/to/chromium go test ./pkg/webui -race -v -``` - ### Linting ```bash diff --git a/docs/guides/scanning.md b/docs/guides/scanning.md index 08364474..00320974 100644 --- a/docs/guides/scanning.md +++ b/docs/guides/scanning.md @@ -39,8 +39,6 @@ Add `--webui` to serve findings in a live web UI while the scan runs. Pipeleek b Below **Live secret findings**, the UI shows the target instance and effective scan flags, including values from configuration and defaults. Credential values such as tokens and cookies are redacted, and credentials, query parameters, and fragments in the target URL are omitted. -Findings are shown newest first, with 100 results per page. Use **Previous** and **Next** to browse large result sets. Severity, type, and search filters apply to all findings, not just the current page, and changing a filter returns to the first page. **Export CSV** exports every matching finding across all pages. Summary counts always cover the full scan. Incoming findings are rendered in batches to keep the controls responsive. - Use `-v` with `--webui` to see debug-level startup details: ```bash From b7f1d3f48b5f7e513944f8f48bbdad27a9a96be3 Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:50:57 +0000 Subject: [PATCH 3/3] test(webui): remove browser regression harness Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/webui/server_browser_test.go | 131 ------------------------ pkg/webui/server_browser_test.js | 164 ------------------------------- 2 files changed, 295 deletions(-) delete mode 100644 pkg/webui/server_browser_test.go delete mode 100644 pkg/webui/server_browser_test.js diff --git a/pkg/webui/server_browser_test.go b/pkg/webui/server_browser_test.go deleted file mode 100644 index dcc57c62..00000000 --- a/pkg/webui/server_browser_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package webui - -import ( - "bytes" - "context" - _ "embed" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/CompassSecurity/pipeleek/pkg/logging" -) - -//go:embed server_browser_test.js -var browserTests string - -func TestBrowserLargeFindings(t *testing.T) { - browser := os.Getenv("PIPELEEK_WEBUI_BROWSER") - if browser == "" { - for _, name := range []string{"chromium", "chromium-browser", "google-chrome", "google-chrome-stable"} { - if path, err := exec.LookPath(name); err == nil { - browser = path - break - } - } - } - if browser == "" { - t.Skip("Chromium not installed; set PIPELEEK_WEBUI_BROWSER to run browser regression tests") - } - - s := &Server{token: "browser-fixture", clients: make(map[chan string]struct{})} - start := time.Now().Add(-time.Hour) - for i := range 2000 { - s.handleHit(logging.HitRecord{ - Time: start.Add(time.Duration(i) * time.Millisecond), - Type: []string{"log", "archive"}[i%2], - Confidence: []string{"verified", "high", "trufflehog"}[i%3], - RuleName: fmt.Sprintf("fixture-rule-%04d", i+1), - Value: fmt.Sprintf("fixture-value-%04d\n%s", i+1, strings.Repeat("fixture context ", 16)), - Fields: map[string]interface{}{ - "url": fmt.Sprintf("https://example.test/jobs/%04d", i+1), - "project": fmt.Sprintf("project-%02d", i%20), - "job": "fixture-job", - }, - }) - } - s.Complete() - app := s.handler() - results := make(chan string, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/test-result" { - result, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10)) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - select { - case results <- string(result): - default: - http.Error(w, "test result already received", http.StatusConflict) - } - return - } - r.AddCookie(&http.Cookie{Name: "pipeleek-webui-token", Value: s.token}) - if r.URL.Path != "/" { - app.ServeHTTP(w, r) - return - } - res := httptest.NewRecorder() - app.ServeHTTP(res, r) - for key, values := range res.Header() { - w.Header()[key] = values - } - page := strings.Replace(res.Body.String(), " ", 1) - _, _ = fmt.Fprint(w, page) - })) - defer server.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) - defer cancel() - cmd := exec.CommandContext(ctx, browser, - "--headless", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", - "--disable-background-networking", "--no-proxy-server", - "--user-data-dir="+filepath.Join(t.TempDir(), "browser"), - server.URL) - var output bytes.Buffer - cmd.Stdout = &output - cmd.Stderr = &output - cmd.WaitDelay = 5 * time.Second - if err := cmd.Start(); err != nil { - t.Fatalf("could not start browser: %v", err) - } - exited := make(chan struct{}) - var browserErr error - go func() { - browserErr = cmd.Wait() - close(exited) - }() - stop := func() { - _ = cmd.Process.Kill() - <-exited - } - defer stop() - select { - case result := <-results: - if !strings.HasPrefix(result, "PASS ") { - t.Fatalf("browser regression failed: %s", result) - } - t.Log(result) - case <-exited: - t.Fatalf("browser exited before reporting results: %v\n%s", browserErr, output.String()) - case <-ctx.Done(): - stop() - t.Fatalf("browser test timed out: %v\n%s", ctx.Err(), output.String()) - } -} diff --git a/pkg/webui/server_browser_test.js b/pkg/webui/server_browser_test.js deleted file mode 100644 index 07b4b942..00000000 --- a/pkg/webui/server_browser_test.js +++ /dev/null @@ -1,164 +0,0 @@ -(async function() { - var result = document.createElement('pre'); - result.id = 'webui-test-result'; - document.body.appendChild(result); - var timings = {}; - var reportResult = window.fetch.bind(window); - var renderCount = 0; - var originalRenderRows = renderRows; - renderRows = function() { renderCount += 1; originalRenderRows(); }; - - function assert(condition, message) { - if (!condition) throw new Error(message); - } - function delay(ms) { - return new Promise(function(resolve) { window.setTimeout(resolve, ms); }); - } - async function waitFor(predicate, message) { - for (var i = 0; i < 500; i += 1) { - if (predicate()) return; - await delay(20); - } - throw new Error(message); - } - async function rendered() { - await waitFor(function() { return renderTimer === null; }, 'render did not finish'); - assert(tableBody.children.length <= 100, 'table exceeded the 100-row rendering bound'); - } - function emit(name, item) { - source.dispatchEvent(new MessageEvent(name, { data: JSON.stringify(item) })); - } - function firstSecret() { - return tableBody.querySelector('button[data-copy]').dataset.copy; - } - - try { - var started = performance.now(); - await waitFor(function() { return state.all.length === 2000 && state.notificationsReady; }, 'SSE replay did not deliver 2,000 findings'); - await rendered(); - timings.reloadMillis = Math.round(performance.now() - started); - assert(timings.reloadMillis < 5000, '2,000-finding reload took over five seconds'); - assert(renderCount <= 2, 'replay rebuilt the table ' + renderCount + ' times'); - assert(tableBody.children.length === 100, 'first page must show 100 findings'); - assert(firstSecret().startsWith('fixture-value-2000'), 'findings must be ordered by precise timestamp, newest first'); - assert(pageStatus.textContent === 'Showing 1-100 of 2000 findings (2000 total)', 'incorrect first-page count'); - assert(previousPage.disabled && !nextPage.disabled, 'incorrect first-page navigation'); - assert(connectionState.textContent === 'complete', 'completed SSE replay lost completion state'); - assert(window.testNotifications.length === 0, 'historical findings triggered notifications'); - var all = filteredFindings().slice(); - assert(filteredFindings() === filteredFindings(), 'filtered findings should be cached'); - assert(Array.from(summaryCards.querySelectorAll('strong')).map(function(el) { return el.textContent; }).join(',') === '667,667,0,0,666', 'incorrect full-scan summary'); - - nextPage.click(); - assert(firstSecret().startsWith('fixture-value-1900'), 'next page did not advance'); - assert(pageStatus.textContent.startsWith('Showing 101-200'), 'incorrect second-page count'); - previousPage.click(); - assert(firstSecret().startsWith('fixture-value-2000'), 'previous page did not return'); - for (var page = 1; page < 20; page += 1) nextPage.click(); - assert(nextPage.disabled && !previousPage.disabled, 'last-page navigation should stop at the boundary'); - assert(firstSecret().startsWith('fixture-value-0100'), 'last page omitted findings'); - - started = performance.now(); - document.getElementById('severity-trigger').click(); - document.querySelector('#severity-options [data-value="verified"]').click(); - document.querySelector('#severity-options [data-value="high"]').click(); - document.getElementById('type-trigger').click(); - document.querySelector('#type-options [data-value="archive"]').click(); - await rendered(); - timings.filterMillis = Math.round(performance.now() - started); - assert(timings.filterMillis < 1000, 'multi-select filter took over one second'); - var expected = all.filter(function(item) { - return ['verified', 'high'].includes(item.confidence) && item.type === 'archive'; - }); - assert(filteredFindings().length === expected.length, 'multi-select filters did not cover all pages'); - assert(state.page === 0 && previousPage.disabled, 'filter changes must return to the first page'); - assert(firstSecret() === expected[0].value, 'filtered results lost timestamp order'); - assert(summaryCards.querySelector('strong').textContent === '667', 'filter changed full-scan summary'); - - resetButton.click(); - await rendered(); - var searches = [ - ['FIXTURE-RULE-0001', 'fixture-value-0001'], - ['fixture-value-0002', 'fixture-value-0002'], - ['https://example.test/jobs/0003', 'fixture-value-0003'], - ['PROJECT-07', null], - ['archive', null] - ]; - for (var search of searches) { - searchFilter.value = search[0]; - searchFilter.dispatchEvent(new Event('input')); - await rendered(); - assert(filteredFindings().length > 0, 'search failed for ' + search[0]); - if (search[1]) assert(firstSecret().startsWith(search[1]), 'search omitted an off-page finding'); - } - searchFilter.value = 'not-a-fixture-match'; - searchFilter.dispatchEvent(new Event('input')); - await rendered(); - assert(tableBody.textContent.includes('No findings match the current filters.'), 'filtered empty state missing'); - assert(previousPage.disabled && nextPage.disabled, 'empty results should disable pagination'); - - resetFilters(); - await rendered(); - nextPage.click(); - var exportBody; - var originalFetch = window.fetch; - var originalClick = HTMLAnchorElement.prototype.click; - window.fetch = function(url, options) { - assert(url === '/api/export.csv', 'CSV request used an unexpected URL'); - exportBody = JSON.parse(options.body); - return Promise.resolve({ ok: true, blob: function() { return Promise.resolve(new Blob(['fixture CSV'])); } }); - }; - HTMLAnchorElement.prototype.click = function() {}; - exportCSVButton.click(); - assert(exportBody.length === 2000, 'CSV export was limited to the current page'); - assert(exportBody[0].id === '2000' && exportBody[1999].id === '1', 'CSV lost order or finding fields'); - assert(!('search' in exportBody[0]) && !('item' in exportBody[0]), 'CSV payload leaked internal indexing fields'); - // Export immediately after typing, before the scheduled table render. - searchFilter.value = 'PROJECT-07'; - searchFilter.dispatchEvent(new Event('input')); - exportCSVButton.click(); - assert(exportBody.length === 100 && exportBody.every(function(item) { return item.details.project === 'project-07'; }), 'CSV did not use the latest filters'); - await delay(20); - window.fetch = originalFetch; - HTMLAnchorElement.prototype.click = originalClick; - await rendered(); - resetFilters(); - await rendered(); - - // Reconnection replays immutable IDs without rebuilding, recounting, or notifying. - var beforeReplay = renderCount; - source.onopen(); - all.forEach(function(item) { emit('finding', item); }); - assert(renderCount === beforeReplay && renderTimer === null, 'duplicate replay scheduled table work'); - assert(state.all.length === 2000, 'reconnect replay duplicated findings'); - var liveTime = Date.now() + 1000; - emit('finding', { id: '2001', time: new Date(liveTime).toISOString(), timestampMillis: liveTime, confidence: 'verified', type: 'log', value: 'fixture-replay-new' }); - assert(window.testNotifications.length === 0, 'replayed finding triggered a notification'); - assert(renderTimer === null, 'replay should wait for replay-complete before rendering'); - emit('replay-complete', null); - await rendered(); - assert(state.all.length === 2001, 'reconnect omitted a new finding'); - for (var i = 0; i < 200; i += 1) { - emit('finding', { id: String(2002 + i), time: new Date(liveTime + i + 1).toISOString(), timestampMillis: liveTime + i + 1, confidence: 'verified', type: 'log', value: 'fixture-live-' + i }); - } - assert(renderCount === beforeReplay + 1, 'live burst rendered synchronously per finding'); - await rendered(); - assert(renderCount === beforeReplay + 2, 'live burst was not coalesced into one render'); - assert(state.all.length === 2201 && filteredFindings().length === 2201, 'live updates lost findings or cached stale results'); - assert(firstSecret() === 'fixture-live-199', 'live finding did not invalidate timestamp ordering'); - assert(summaryCards.querySelector('strong').textContent === '868', 'live or duplicate updates corrupted summary counts'); - assert(window.testNotifications.length === 200, 'live findings lost notifications'); - assert(window.testNotifications.every(function(n) { return !n.options.body.includes('fixture-live'); }), 'notification text exposed a secret'); - for (var lastPage = 0; lastPage < 22; lastPage += 1) nextPage.click(); - assert(nextPage.disabled && tableBody.children.length === 1, 'partial last page must show the final finding exactly once'); - assert(pageStatus.textContent === 'Showing 2201-2201 of 2201 findings (2201 total)', 'partial-page count is incorrect'); - emit('done', { elapsedMillis: 42135 }); - await rendered(); - assert(connectionState.textContent === 'complete' && elapsedTime.textContent === 'Elapsed 00:00:42', 'completion or elapsed-time state regressed'); - result.textContent = 'PASS ' + JSON.stringify(timings) + ', bounded rows and batched replay/live updates'; - } catch (error) { - result.textContent = 'FAIL ' + error.stack; - } finally { - await reportResult('/test-result', { method: 'POST', body: result.textContent }); - } -})();
SeverityRuleSecretTypeDetailsTime