mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-12 00:45:38 +00:00
feat: add GitHub repository dashboard with Prometheus metrics
ghtracker authenticates as a GitHub App and scans a configured list of repositories on a schedule. It serves a dashboard, a JSON state endpoint, a health probe and Prometheus metrics. The dashboard lists only items that need attention: failing workflows, pull requests that wait too long or fail their checks, stale issues and projects that failed to scan. It has search, dynamic filters and a button that rescans on demand. The Vue app is embedded in the Go binary. The client protects the GitHub rate limit with conditional requests, a request reserve and Retry-After handling.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/collector"
|
||||
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||
)
|
||||
|
||||
// apiState is the JSON contract with the UI. Lists of healthy items stay on GitHub;
|
||||
// the UI gets counts, release info and the alerts that explain every non-green state.
|
||||
type apiState struct {
|
||||
Now time.Time `json:"now"`
|
||||
LastRefresh time.Time `json:"lastRefresh"`
|
||||
Repos []apiRepo `json:"repos"`
|
||||
Alerts []apiAlert `json:"alerts"`
|
||||
Thresholds apiThresholds `json:"thresholds"`
|
||||
Counts apiCounts `json:"counts"`
|
||||
IntervalSeconds int64 `json:"intervalSeconds"`
|
||||
Loaded bool `json:"loaded"`
|
||||
Refreshing bool `json:"refreshing"`
|
||||
ManualRefresh bool `json:"manualRefresh"`
|
||||
}
|
||||
|
||||
type apiThresholds struct {
|
||||
PRWarnSeconds int64 `json:"prWarnSeconds"`
|
||||
PRCritSeconds int64 `json:"prCritSeconds"`
|
||||
IssueStaleSecs int64 `json:"issueStaleSeconds"`
|
||||
WorkflowStuck int64 `json:"workflowStuckSeconds"`
|
||||
WorkflowDormant int64 `json:"workflowDormantSeconds"`
|
||||
}
|
||||
|
||||
type apiCounts struct {
|
||||
Crit int `json:"crit"`
|
||||
Warn int `json:"warn"`
|
||||
}
|
||||
|
||||
type apiRepo struct {
|
||||
RefreshedAt time.Time `json:"refreshedAt"`
|
||||
Release *apiRelease `json:"release"`
|
||||
FullName string `json:"fullName"`
|
||||
URL string `json:"url"`
|
||||
ReleasesURL string `json:"releasesUrl"`
|
||||
Description string `json:"description"`
|
||||
Health string `json:"health"`
|
||||
Error string `json:"error"`
|
||||
Workflows apiWorkflows `json:"workflows"`
|
||||
PRs apiPRs `json:"prs"`
|
||||
Issues apiIssues `json:"issues"`
|
||||
Archived bool `json:"archived"`
|
||||
Up bool `json:"up"`
|
||||
}
|
||||
|
||||
type apiWorkflows struct {
|
||||
Total int `json:"total"`
|
||||
Failing int `json:"failing"`
|
||||
Running int `json:"running"`
|
||||
}
|
||||
|
||||
type apiPRs struct {
|
||||
OldestSeconds int64 `json:"oldestSeconds"`
|
||||
Open int `json:"open"`
|
||||
Waiting int `json:"waiting"`
|
||||
FailingChecks int `json:"failingChecks"`
|
||||
}
|
||||
|
||||
type apiIssues struct {
|
||||
Open int `json:"open"`
|
||||
Stale int `json:"stale"`
|
||||
}
|
||||
|
||||
type apiRelease struct {
|
||||
PublishedAt time.Time `json:"publishedAt"`
|
||||
Tag string `json:"tag"`
|
||||
URL string `json:"url"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
FromTag bool `json:"fromTag"`
|
||||
}
|
||||
|
||||
type apiAlert struct {
|
||||
Since time.Time `json:"since"`
|
||||
Repo string `json:"repo"`
|
||||
Kind string `json:"kind"`
|
||||
Severity string `json:"severity"`
|
||||
Subject string `json:"subject"`
|
||||
Detail string `json:"detail"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func seconds(d time.Duration) int64 { return int64(d / time.Second) }
|
||||
|
||||
func buildState(v collector.View, manualRefresh bool) apiState {
|
||||
st := apiState{
|
||||
Now: v.Now,
|
||||
LastRefresh: v.LastRefresh,
|
||||
IntervalSeconds: seconds(v.Interval),
|
||||
Loaded: v.Loaded,
|
||||
Refreshing: v.Refreshing,
|
||||
ManualRefresh: manualRefresh,
|
||||
Counts: apiCounts{Crit: v.Crit, Warn: v.Warn},
|
||||
Thresholds: apiThresholds{
|
||||
PRWarnSeconds: seconds(v.Thresholds.PRWarnAfter),
|
||||
PRCritSeconds: seconds(v.Thresholds.PRCritAfter),
|
||||
IssueStaleSecs: seconds(v.Thresholds.IssueStaleAfter),
|
||||
WorkflowStuck: seconds(v.Thresholds.WorkflowStuck),
|
||||
WorkflowDormant: seconds(v.Thresholds.WorkflowDormant),
|
||||
},
|
||||
Repos: make([]apiRepo, 0, len(v.Repos)),
|
||||
Alerts: make([]apiAlert, 0, len(v.Alerts)),
|
||||
}
|
||||
for _, r := range v.Repos {
|
||||
ar := apiRepo{
|
||||
FullName: r.FullName, URL: r.URL, ReleasesURL: r.ReleasesURL, Description: r.Description,
|
||||
Health: r.Health.String(), Error: r.Err, Archived: r.Archived, Up: r.Up, RefreshedAt: r.RefreshedAt,
|
||||
Workflows: workflowStats(r, v), PRs: prStats(r, v), Issues: issueStats(r, v),
|
||||
}
|
||||
if rel := r.Release; rel != nil {
|
||||
ar.Release = &apiRelease{Tag: rel.Tag, URL: rel.URL, PublishedAt: rel.PublishedAt, Prerelease: rel.Prerelease, FromTag: rel.FromTag}
|
||||
}
|
||||
st.Repos = append(st.Repos, ar)
|
||||
}
|
||||
for _, a := range v.Alerts {
|
||||
st.Alerts = append(st.Alerts, apiAlert{
|
||||
Severity: a.Severity.String(), Repo: a.Repo, Kind: a.Kind, Subject: a.Subject,
|
||||
Detail: a.Detail, URL: a.URL, Since: a.Since,
|
||||
})
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func workflowStats(r model.Repo, v collector.View) apiWorkflows {
|
||||
out := apiWorkflows{Total: len(r.Workflows)}
|
||||
for _, wf := range r.Workflows {
|
||||
if wf.LastDone != nil && wf.LastDone.Failed() && !collector.Dormant(wf, v.Now, v.Thresholds) {
|
||||
out.Failing++
|
||||
}
|
||||
if wf.Latest.Running() {
|
||||
out.Running++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// prStats counts non-draft pull requests; drafts never wait on anyone.
|
||||
func prStats(r model.Repo, v collector.View) apiPRs {
|
||||
out := apiPRs{Open: len(r.PRs)}
|
||||
for _, pr := range r.PRs {
|
||||
if pr.Draft {
|
||||
continue
|
||||
}
|
||||
age := v.Now.Sub(pr.CreatedAt)
|
||||
out.OldestSeconds = max(out.OldestSeconds, seconds(age))
|
||||
if v.Thresholds.PRWarnAfter > 0 && age >= v.Thresholds.PRWarnAfter {
|
||||
out.Waiting++
|
||||
}
|
||||
if pr.Checks.State == model.ChecksFail {
|
||||
out.FailingChecks++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func issueStats(r model.Repo, v collector.View) apiIssues {
|
||||
out := apiIssues{Open: len(r.Issues)}
|
||||
if v.Thresholds.IssueStaleAfter <= 0 {
|
||||
return out
|
||||
}
|
||||
for _, is := range r.Issues {
|
||||
if v.Now.Sub(is.UpdatedAt) >= v.Thresholds.IssueStaleAfter {
|
||||
out.Stale++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Package web serves the embedded UI, the JSON state, the health probe and metrics.
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/collector"
|
||||
)
|
||||
|
||||
const (
|
||||
minManualRefreshGap = 5 * time.Minute
|
||||
indexFile = "index.html"
|
||||
assetsPrefix = "/assets/"
|
||||
immutableCache = "public, max-age=31536000, immutable"
|
||||
contentSecurity = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
||||
"connect-src 'self'; img-src 'self' data:; font-src 'self'; form-action 'self'; base-uri 'none'"
|
||||
)
|
||||
|
||||
// Source supplies dashboard state and accepts refresh requests.
|
||||
type Source interface {
|
||||
View() collector.View
|
||||
Stats() collector.Stats
|
||||
RequestRefresh() bool
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
lastManual time.Time
|
||||
now func() time.Time
|
||||
src Source
|
||||
ui fs.FS
|
||||
metrics http.Handler
|
||||
log *slog.Logger
|
||||
mu sync.Mutex
|
||||
manualRefresh bool
|
||||
}
|
||||
|
||||
// New builds the server; ui is the built frontend (index.html at its root).
|
||||
func New(src Source, metrics http.Handler, ui fs.FS, manualRefresh bool, log *slog.Logger) *Server {
|
||||
return &Server{src: src, ui: ui, metrics: metrics, manualRefresh: manualRefresh, log: log, now: time.Now}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /{$}", s.index)
|
||||
mux.HandleFunc("GET /assets/", s.asset)
|
||||
mux.HandleFunc("GET /api/state", s.state)
|
||||
mux.HandleFunc("GET /healthz", s.health)
|
||||
mux.Handle("GET /metrics", s.metrics)
|
||||
if s.manualRefresh {
|
||||
mux.HandleFunc("POST /refresh", s.refresh)
|
||||
}
|
||||
return securityHeaders(mux)
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", contentSecurity)
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) index(w http.ResponseWriter, _ *http.Request) {
|
||||
b, err := fs.ReadFile(s.ui, indexFile)
|
||||
if err != nil {
|
||||
http.Error(w, "UI is not built: run `pnpm build` before `go build`", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
// asset serves hashed build output under /assets/ from the embedded filesystem.
|
||||
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
|
||||
name := path.Clean(strings.TrimPrefix(r.URL.Path, "/"))
|
||||
if !strings.HasPrefix("/"+name, assetsPrefix) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", immutableCache)
|
||||
http.ServeFileFS(w, r, s.ui, name)
|
||||
}
|
||||
|
||||
func (s *Server) state(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err := json.NewEncoder(w).Encode(buildState(s.src.View(), s.manualRefresh)); err != nil {
|
||||
s.log.Error("encode state", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// health reports 503 until the first refresh finishes, for probes.
|
||||
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
||||
if !s.src.Stats().Loaded {
|
||||
http.Error(w, "loading", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
}
|
||||
|
||||
func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
|
||||
// Browsers label cross-site requests; refuse them so another page cannot spend the API budget.
|
||||
if site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" {
|
||||
http.Error(w, "cross-site refresh refused", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
tooSoon := s.now().Sub(s.lastManual) < minManualRefreshGap
|
||||
if !tooSoon {
|
||||
s.lastManual = s.now()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if tooSoon {
|
||||
http.Error(w, "refresh allowed once per "+minManualRefreshGap.String(), http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
s.src.RequestRefresh()
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"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 {
|
||||
st collector.Stats
|
||||
v collector.View
|
||||
refreshed int
|
||||
}
|
||||
|
||||
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 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,
|
||||
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("<!doctype html><title>ui</title>")},
|
||||
"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(), "<title>ui</title>") {
|
||||
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 }
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user