35ebc53561
Serves a self-contained vanilla-JS dashboard (embedded via go:embed): a per-model performance table — runs, minutes, findings, confirmed/false-positive/ungraded, points, points-per-minute, points-per-run, by-severity — with drill-down filters (date range, repo, provider, model, lens, grade/severity), free-text search, and a click-to-scope findings detail table. Scoring stays client-side: the page has an editable points curve and computes points + value-per-minute in the browser, so the store remains point-free. Adds GET /runs (lists all runs, incl. zero-finding ones) so minutes/runs are filterable. The /ui shell is public (carries no data); data endpoints stay token-gated and the JS sends the token. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
137 lines
4.4 KiB
Go
137 lines
4.4 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func testServer(t *testing.T, token string) *httptest.Server {
|
|
t.Helper()
|
|
store, err := Open(filepath.Join(t.TempDir(), "gadfly-reports.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { store.Close() })
|
|
srv := httptest.NewServer(newServer(store, token))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func post(t *testing.T, srv *httptest.Server, token, path string, body any) *http.Response {
|
|
t.Helper()
|
|
b, _ := json.Marshal(body)
|
|
req, _ := http.NewRequest("POST", srv.URL+path, bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("POST %s: %v", path, err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// TestServerEndToEnd: run -> reports -> grade -> scoreboard over HTTP.
|
|
func TestServerEndToEnd(t *testing.T) {
|
|
srv := testServer(t, "")
|
|
|
|
if resp := post(t, srv, "", "/runs", Run{RunID: "r1", Repo: "r", PR: 1, Model: "m", Provider: "p", DurationSecs: 120}); resp.StatusCode != 200 {
|
|
t.Fatalf("POST /runs = %d", resp.StatusCode)
|
|
}
|
|
|
|
resp := post(t, srv, "", "/reports", []ReportIn{
|
|
{Repo: "r", PR: 1, Lens: "security", File: "a.go", Line: 7, Title: "leak", Model: "m", Provider: "p", RunID: "r1"},
|
|
})
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("POST /reports = %d", resp.StatusCode)
|
|
}
|
|
var rep struct {
|
|
FindingIDs []string `json:"finding_ids"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&rep)
|
|
if len(rep.FindingIDs) != 1 {
|
|
t.Fatalf("want 1 finding id, got %v", rep.FindingIDs)
|
|
}
|
|
id := rep.FindingIDs[0]
|
|
|
|
if resp := post(t, srv, "", "/findings/"+id+"/grade", Grade{IsReal: true, Severity: "medium", Grader: "claude"}); resp.StatusCode != 200 {
|
|
t.Fatalf("POST grade = %d", resp.StatusCode)
|
|
}
|
|
|
|
resp = mustGet(t, srv, "", "/scoreboard")
|
|
var board []ModelStat
|
|
json.NewDecoder(resp.Body).Decode(&board)
|
|
if len(board) != 1 || board[0].Confirmed != 1 || board[0].BySeverity["medium"] != 1 || board[0].Minutes != 2 {
|
|
t.Fatalf("unexpected scoreboard: %+v", board)
|
|
}
|
|
}
|
|
|
|
// TestServerAuth: a set token gates writes but leaves /healthz open.
|
|
func TestServerAuth(t *testing.T) {
|
|
srv := testServer(t, "secret")
|
|
|
|
if resp := post(t, srv, "", "/runs", Run{RunID: "r1", Model: "m"}); resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("unauthenticated POST = %d, want 401", resp.StatusCode)
|
|
}
|
|
if resp := post(t, srv, "secret", "/runs", Run{RunID: "r1", Model: "m"}); resp.StatusCode != 200 {
|
|
t.Errorf("authenticated POST = %d, want 200", resp.StatusCode)
|
|
}
|
|
if resp := mustGet(t, srv, "", "/healthz"); resp.StatusCode != 200 {
|
|
t.Errorf("healthz should be open, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// TestViewShellPublic: the dashboard shell (/ and /ui) is reachable without a
|
|
// token even when one is set, but the data endpoints stay gated.
|
|
func TestViewShellPublic(t *testing.T) {
|
|
srv := testServer(t, "secret")
|
|
|
|
resp := mustGet(t, srv, "", "/ui")
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("GET /ui (no token) = %d, want 200", resp.StatusCode)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
|
t.Errorf("GET /ui content-type = %q, want text/html", ct)
|
|
}
|
|
// data must still require the token
|
|
if resp := mustGet(t, srv, "", "/runs"); resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("GET /runs (no token) = %d, want 401", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// TestListRuns: POSTed runs come back via GET /runs with created_at populated.
|
|
func TestListRuns(t *testing.T) {
|
|
srv := testServer(t, "")
|
|
if resp := post(t, srv, "", "/runs", Run{RunID: "r1", Repo: "r", PR: 1, Model: "m", Provider: "p", DurationSecs: 120}); resp.StatusCode != 200 {
|
|
t.Fatalf("POST /runs = %d", resp.StatusCode)
|
|
}
|
|
resp := mustGet(t, srv, "", "/runs")
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("GET /runs = %d", resp.StatusCode)
|
|
}
|
|
var runs []Run
|
|
json.NewDecoder(resp.Body).Decode(&runs)
|
|
if len(runs) != 1 || runs[0].RunID != "r1" || runs[0].DurationSecs != 120 || runs[0].CreatedAt == "" {
|
|
t.Fatalf("unexpected runs: %+v", runs)
|
|
}
|
|
}
|
|
|
|
func mustGet(t *testing.T, srv *httptest.Server, token, path string) *http.Response {
|
|
t.Helper()
|
|
req, _ := http.NewRequest("GET", srv.URL+path, nil)
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("GET %s: %v", path, err)
|
|
}
|
|
return resp
|
|
}
|