package web import ( "context" "encoding/json" "errors" "io" "log/slog" "net/http" "net/http/httptest" "strings" "sync" "testing" "testing/fstest" "time" "github.com/lukaszraczylo/ghtracker/internal/collector" "github.com/lukaszraczylo/ghtracker/internal/config" "github.com/lukaszraczylo/ghtracker/internal/model" ) var now = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) type fakeSource struct { repoFn func(name string) (collector.View, error) repoCalls []string st collector.Stats v collector.View refreshed int mu sync.Mutex } func (f *fakeSource) View() collector.View { return f.v } func (f *fakeSource) Stats() collector.Stats { return f.st } func (f *fakeSource) RequestRefresh() bool { f.refreshed++; return true } func (f *fakeSource) RefreshRepo(_ context.Context, name string) (collector.View, error) { f.mu.Lock() f.repoCalls = append(f.repoCalls, name) f.mu.Unlock() if f.repoFn != nil { return f.repoFn(name) } return f.v, nil } func (f *fakeSource) calls() int { f.mu.Lock() defer f.mu.Unlock() return len(f.repoCalls) } func testView() collector.View { repo := model.Repo{ FullName: "o/r", URL: "https://github.com/o/r", ReleasesURL: "https://github.com/o/r/releases", Up: true, Health: model.Crit, RefreshedAt: now.Add(-time.Hour), Description: "desc", Release: &model.Release{Tag: "v1.2.3", URL: "https://github.com/o/r/releases/tag/v1.2.3", PublishedAt: now.Add(-48 * time.Hour)}, PRs: []model.PullRequest{ {Number: 5, CreatedAt: now.Add(-72 * time.Hour), Checks: model.CheckSummary{State: model.ChecksFail}}, {Number: 6, Draft: true, CreatedAt: now.Add(-900 * time.Hour)}, }, Issues: []model.Issue{{Number: 9, UpdatedAt: now.Add(-40 * 24 * time.Hour)}, {Number: 10, UpdatedAt: now}}, Workflows: []model.Workflow{ {Name: "CI", Latest: model.Run{Status: "completed", Conclusion: "failure", CreatedAt: now.Add(-time.Hour)}, LastDone: &model.Run{Status: "completed", Conclusion: "failure", CreatedAt: now.Add(-time.Hour)}}, {Name: "Nightly", Latest: model.Run{Status: "in_progress", CreatedAt: now}}, }, } bare := model.Repo{FullName: "o/bare", URL: "https://github.com/o/bare", ReleasesURL: "https://github.com/o/bare/releases", Err: "boom"} return collector.View{ Now: now, LastRefresh: now.Add(-time.Hour), Interval: 6 * time.Hour, Loaded: true, Crit: 1, Warn: 1, ScanDone: 5, ScanTotal: 9, Thresholds: config.Thresholds{PRWarnAfter: 48 * time.Hour, PRCritAfter: 168 * time.Hour, IssueStaleAfter: 720 * time.Hour}, Repos: []model.Repo{repo, bare}, Alerts: []model.Alert{ {Severity: model.Crit, Repo: "o/r", Kind: "workflow_failed", Subject: "CI", Detail: "failure on main (push)", URL: "https://github.com/o/r/actions/runs/1", Since: now.Add(-2 * time.Hour)}, {Severity: model.Warn, Repo: "o/r", Kind: "pr_waiting", Subject: "#5 fix", Detail: "open 3d", URL: "https://github.com/o/r/pull/5", Since: now.Add(-72 * time.Hour)}, }, } } func testUI() fstest.MapFS { return fstest.MapFS{ "index.html": {Data: []byte("ui")}, "assets/app.js": {Data: []byte("console.log(1)")}, "secret.txt": {Data: []byte("nope")}, } } func newServer(src *fakeSource, ui fstest.MapFS, manual bool) *Server { return New(src, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("metrics")) }), ui, manual, slog.New(slog.NewTextHandler(io.Discard, nil))) } func do(h http.Handler, method, path string) *httptest.ResponseRecorder { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(method, path, nil)) return rec } func TestIndexServesBuiltUIWithSecurityHeaders(t *testing.T) { rec := do(newServer(&fakeSource{}, testUI(), false).Handler(), "GET", "/") if rec.Code != 200 || !strings.Contains(rec.Body.String(), "ui") { t.Fatalf("%d %s", rec.Code, rec.Body.String()) } if rec.Header().Get("Cache-Control") != "no-cache" || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") { t.Fatalf("headers: %v", rec.Header()) } csp := rec.Header().Get("Content-Security-Policy") for _, want := range []string{"default-src 'none'", "script-src 'self'", "connect-src 'self'"} { if !strings.Contains(csp, want) { t.Errorf("CSP %q missing %q", csp, want) } } if rec.Header().Get("X-Content-Type-Options") != "nosniff" { t.Fatal("nosniff missing") } } func TestIndexWithoutBuiltUIIs503(t *testing.T) { rec := do(newServer(&fakeSource{}, fstest.MapFS{}, false).Handler(), "GET", "/") if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "pnpm build") { t.Fatalf("%d %s", rec.Code, rec.Body.String()) } } func TestAssetsCachedAndScopedToAssetsDir(t *testing.T) { h := newServer(&fakeSource{}, testUI(), false).Handler() rec := do(h, "GET", "/assets/app.js") if rec.Code != 200 || rec.Header().Get("Cache-Control") != immutableCache || rec.Body.String() != "console.log(1)" { t.Fatalf("%d %v", rec.Code, rec.Header()) } for _, p := range []string{"/assets/missing.js", "/secret.txt", "/assets/../secret.txt", "/nope"} { if rec := do(h, "GET", p); rec.Code != http.StatusNotFound && rec.Code != http.StatusMovedPermanently && rec.Code != http.StatusTemporaryRedirect { t.Errorf("%s -> %d, want 404", p, rec.Code) } else if rec.Body.Len() > 0 && strings.Contains(rec.Body.String(), "nope") { t.Errorf("%s leaked a file outside assets", p) } } } func TestStateJSONContract(t *testing.T) { src := &fakeSource{v: testView()} rec := do(newServer(src, testUI(), true).Handler(), "GET", "/api/state") if rec.Code != 200 || rec.Header().Get("Content-Type") != "application/json" || rec.Header().Get("Cache-Control") != "no-store" { t.Fatalf("%d %v", rec.Code, rec.Header()) } var got struct { Repos []map[string]any Alerts []map[string]any Counts struct{ Crit, Warn int } Scan struct{ Done, Total int } Thresholds struct{ PRWarnSeconds, PRCritSeconds int64 } IntervalSeconds int64 Loaded bool ManualRefresh bool } if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatal(err) } if got.Scan.Done != 5 || got.Scan.Total != 9 { t.Fatalf("scan: %+v", got.Scan) } if got.IntervalSeconds != 21600 || !got.Loaded || !got.ManualRefresh || got.Counts.Crit != 1 || got.Counts.Warn != 1 { t.Fatalf("top level: %+v", got) } if got.Thresholds.PRWarnSeconds != 172800 || got.Thresholds.PRCritSeconds != 604800 { t.Fatalf("thresholds: %+v", got.Thresholds) } r := got.Repos[0] if r["fullName"] != "o/r" || r["health"] != "crit" { t.Fatalf("repo: %v", r) } prs, issues, wf := r["prs"].(map[string]any), r["issues"].(map[string]any), r["workflows"].(map[string]any) if prs["open"] != float64(2) || prs["waiting"] != float64(1) || prs["failingChecks"] != float64(1) || prs["oldestSeconds"] != float64(72*3600) { t.Fatalf("prs: %v", prs) } if issues["open"] != float64(2) || issues["stale"] != float64(1) { t.Fatalf("issues: %v", issues) } if wf["total"] != float64(2) || wf["failing"] != float64(1) || wf["running"] != float64(1) { t.Fatalf("workflows: %v", wf) } if rel := r["release"].(map[string]any); rel["tag"] != "v1.2.3" || rel["url"] == "" { t.Fatalf("release: %v", rel) } if got.Repos[1]["release"] != nil || got.Repos[1]["error"] != "boom" || got.Repos[1]["health"] != "ok" { t.Fatalf("bare repo: %v", got.Repos[1]) } if len(got.Alerts) != 2 || got.Alerts[0]["severity"] != "crit" || got.Alerts[0]["subject"] != "CI" { t.Fatalf("alerts: %v", got.Alerts) } } func TestStateJSONEmptyListsAreArrays(t *testing.T) { rec := do(newServer(&fakeSource{}, testUI(), false).Handler(), "GET", "/api/state") body := rec.Body.String() if !strings.Contains(body, `"repos":[]`) || !strings.Contains(body, `"alerts":[]`) { t.Fatalf("lists must encode as [], got %s", body) } } func TestHealthzAndMetrics(t *testing.T) { src := &fakeSource{} h := newServer(src, testUI(), false).Handler() if rec := do(h, "GET", "/healthz"); rec.Code != http.StatusServiceUnavailable { t.Fatalf("before first refresh: %d", rec.Code) } src.st.Loaded = true if rec := do(h, "GET", "/healthz"); rec.Code != 200 { t.Fatalf("after refresh: %d", rec.Code) } if rec := do(h, "GET", "/metrics"); rec.Body.String() != "metrics" { t.Fatal("metrics handler not mounted") } } func TestManualRefreshDisabledByDefault(t *testing.T) { src := &fakeSource{} rec := do(newServer(src, testUI(), false).Handler(), "POST", "/refresh") if rec.Code == http.StatusAccepted || src.refreshed != 0 { t.Fatalf("refresh must not be routed when disabled: %d", rec.Code) } } func TestManualRefreshRateLimited(t *testing.T) { src := &fakeSource{} s := newServer(src, testUI(), true) clock := now s.now = func() time.Time { return clock } h := s.Handler() if rec := do(h, "POST", "/refresh"); rec.Code != http.StatusAccepted { t.Fatalf("first: %d", rec.Code) } clock = clock.Add(time.Minute) if rec := do(h, "POST", "/refresh"); rec.Code != http.StatusTooManyRequests { t.Fatalf("second within gap: %d", rec.Code) } clock = clock.Add(5 * time.Minute) if rec := do(h, "POST", "/refresh"); rec.Code != http.StatusAccepted || src.refreshed != 2 { t.Fatalf("after gap: %d refreshed=%d", rec.Code, src.refreshed) } if rec := do(h, "GET", "/refresh"); rec.Code != http.StatusMethodNotAllowed { t.Fatalf("GET /refresh: %d", rec.Code) } } func TestManualRefreshRefusesCrossSiteRequests(t *testing.T) { src := &fakeSource{} h := newServer(src, testUI(), true).Handler() for _, site := range []string{"cross-site", "same-site"} { req := httptest.NewRequest("POST", "/refresh", nil) req.Header.Set("Sec-Fetch-Site", site) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden || src.refreshed != 0 { t.Fatalf("%s: %d refreshed=%d", site, rec.Code, src.refreshed) } } req := httptest.NewRequest("POST", "/refresh", nil) req.Header.Set("Sec-Fetch-Site", "same-origin") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusAccepted { t.Fatalf("same-origin: %d", rec.Code) } } func post(h http.Handler, target string, hdr map[string]string) *httptest.ResponseRecorder { req := httptest.NewRequest("POST", target, nil) for k, v := range hdr { req.Header.Set(k, v) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func repoServer(src *fakeSource, manual bool) (*Server, *time.Time) { srv := newServer(src, testUI(), manual) clock := now srv.now = func() time.Time { return clock } return srv, &clock } func TestRepoRefreshReturnsStateAndRepoName(t *testing.T) { src := &fakeSource{v: testView()} srv, _ := repoServer(src, true) rec := post(srv.Handler(), "/refresh?repo=o/r", nil) if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/json" { t.Fatalf("%d %v", rec.Code, rec.Header()) } var got struct { Repo string `json:"repo"` Repos []struct { FullName string `json:"fullName"` } `json:"repos"` Counts struct{ Crit int } `json:"counts"` Loaded bool `json:"loaded"` } if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatal(err) } if got.Repo != "o/r" || !got.Loaded || len(got.Repos) != 2 || got.Counts.Crit != 1 { t.Fatalf("body: %s", rec.Body.String()) } if len(src.repoCalls) != 1 || src.repoCalls[0] != "o/r" || src.refreshed != 0 { t.Fatalf("calls=%v full=%d", src.repoCalls, src.refreshed) } } func TestRepoRefreshUnknownRepoIs404JSON(t *testing.T) { src := &fakeSource{repoFn: func(string) (collector.View, error) { return collector.View{}, collector.ErrUnknownRepo }} srv, _ := repoServer(src, true) h := srv.Handler() rec := post(h, "/refresh?repo=x/y", nil) var body map[string]string if rec.Code != http.StatusNotFound || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body["error"] == "" { t.Fatalf("%d %s", rec.Code, rec.Body.String()) } if rec := post(h, "/refresh?repo=x/y", nil); rec.Code != http.StatusNotFound { t.Fatalf("a rejected name must not start a cooldown: %d", rec.Code) } } func TestRepoRefreshCooldownPerRepo(t *testing.T) { src := &fakeSource{v: testView()} srv, clock := repoServer(src, true) h := srv.Handler() if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK { t.Fatalf("first: %d", rec.Code) } *clock = clock.Add(20 * time.Second) rec := post(h, "/refresh?repo=O/R", nil) if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") != "40" { t.Fatalf("cooldown: %d retry-after=%q", rec.Code, rec.Header().Get("Retry-After")) } if rec := post(h, "/refresh?repo=o/bare", nil); rec.Code != http.StatusOK { t.Fatalf("other repo must be independent: %d", rec.Code) } *clock = clock.Add(repoRefreshCooldown) if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK || src.calls() != 3 { t.Fatalf("after cooldown: %d calls=%d", rec.Code, src.calls()) } } func TestRepoRefreshDoesNotUseFullRefreshBudget(t *testing.T) { src := &fakeSource{v: testView()} srv, _ := repoServer(src, true) h := srv.Handler() if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK { t.Fatalf("repo: %d", rec.Code) } if rec := post(h, "/refresh", nil); rec.Code != http.StatusAccepted { t.Fatalf("full refresh after repo refresh: %d", rec.Code) } if rec := post(h, "/refresh", nil); rec.Code != http.StatusTooManyRequests { t.Fatalf("full refresh gap must still apply: %d", rec.Code) } if rec := post(h, "/refresh?repo=o/bare", nil); rec.Code != http.StatusOK { t.Fatalf("repo refresh must not be blocked by the full gap: %d", rec.Code) } } func TestRepoRefreshEmptyParamKeepsFullRefresh(t *testing.T) { src := &fakeSource{v: testView()} srv, _ := repoServer(src, true) rec := post(srv.Handler(), "/refresh?repo=", nil) if rec.Code != http.StatusAccepted || rec.Body.Len() != 0 || src.refreshed != 1 || src.calls() != 0 { t.Fatalf("%d body=%q full=%d repo=%d", rec.Code, rec.Body.String(), src.refreshed, src.calls()) } } func TestRepoRefreshBusyIs409AndKeepsCooldownFree(t *testing.T) { busy := true src := &fakeSource{v: testView(), repoFn: func(string) (collector.View, error) { if busy { return collector.View{}, collector.ErrRefreshBusy } return testView(), nil }} srv, _ := repoServer(src, true) h := srv.Handler() if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusConflict { t.Fatalf("busy: %d", rec.Code) } busy = false if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK { t.Fatalf("retry after busy: %d", rec.Code) } } func TestRepoRefreshUnexpectedErrorIs500(t *testing.T) { src := &fakeSource{repoFn: func(string) (collector.View, error) { return collector.View{}, errors.New("x") }} srv, _ := repoServer(src, true) if rec := post(srv.Handler(), "/refresh?repo=o/r", nil); rec.Code != http.StatusInternalServerError { t.Fatalf("%d", rec.Code) } } func TestRepoRefreshRefusesCrossSite(t *testing.T) { src := &fakeSource{v: testView()} srv, _ := repoServer(src, true) h := srv.Handler() for _, site := range []string{"cross-site", "same-site"} { if rec := post(h, "/refresh?repo=o/r", map[string]string{"Sec-Fetch-Site": site}); rec.Code != http.StatusForbidden { t.Fatalf("%s: %d", site, rec.Code) } } if rec := post(h, "/refresh?repo=o/r", map[string]string{"Sec-Fetch-Site": "same-origin"}); rec.Code != http.StatusOK || src.calls() != 1 { t.Fatalf("same-origin: %d calls=%d", rec.Code, src.calls()) } } func TestRepoRefreshDisabledByConfig(t *testing.T) { src := &fakeSource{v: testView()} srv, _ := repoServer(src, false) rec := post(srv.Handler(), "/refresh?repo=o/r", nil) if rec.Code == http.StatusOK || src.calls() != 0 { t.Fatalf("%d calls=%d", rec.Code, src.calls()) } } func TestRepoRefreshConcurrentRequests(t *testing.T) { src := &fakeSource{v: testView()} srv, _ := repoServer(src, true) h := srv.Handler() var wg sync.WaitGroup codes := make(chan int, 16) for range 16 { wg.Add(1) go func() { defer wg.Done() codes <- post(h, "/refresh?repo=o/r", nil).Code }() } wg.Wait() close(codes) ok, limited := 0, 0 for c := range codes { switch c { case http.StatusOK: ok++ case http.StatusTooManyRequests: limited++ } } if ok != 1 || limited != 15 || src.calls() != 1 { t.Fatalf("ok=%d limited=%d calls=%d", ok, limited, src.calls()) } }