mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-12 00:45:38 +00:00
feat(web): add opt-in webhook alert actions
An optional actions block in the config adds buttons to alerts. POST
/api/actions/{id} checks that the repository and the current alert match,
then sends the alert as JSON to the configured webhook with an optional
bearer token. The request refuses cross-site callers like POST /refresh.
GET /api/state lists the actions and merges per-alert status rows from an
optional status_url, cached for 10 seconds. Without an actions block the
API output does not change.
The ghtracker_action_requests_total counter records each result.
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/config"
|
||||
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
statusCacheTTL = 10 * time.Second
|
||||
maxActionBody = 4 << 10
|
||||
maxUpstreamBody = 1 << 20
|
||||
maxErrorChars = 300
|
||||
actionsUnavail = "action status unavailable"
|
||||
resultOK = "ok"
|
||||
resultRejected = "rejected"
|
||||
resultFailed = "error"
|
||||
actionMetricName = "action_requests_total"
|
||||
contentTypeHeader = "Content-Type"
|
||||
)
|
||||
|
||||
// apiAction is the public description of an action; it never carries URLs or tokens.
|
||||
type apiAction struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Confirm string `json:"confirm,omitempty"`
|
||||
Kinds []string `json:"kinds"`
|
||||
}
|
||||
|
||||
// apiActionStatus is what the UI shows next to an alert after an action ran.
|
||||
type apiActionStatus struct {
|
||||
State string `json:"state"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Link string `json:"link,omitempty"`
|
||||
}
|
||||
|
||||
type statusRow struct {
|
||||
Repo string `json:"repo"`
|
||||
URL string `json:"url"`
|
||||
apiActionStatus
|
||||
}
|
||||
|
||||
type alertRef struct{ repo, url string }
|
||||
|
||||
// actionRuntime holds one action and the cache of its status rows.
|
||||
type actionRuntime struct {
|
||||
fetched time.Time
|
||||
rows map[alertRef]apiActionStatus
|
||||
err error
|
||||
cfg config.Action
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Actions runs the configured webhook actions and merges their status into the state.
|
||||
type Actions struct {
|
||||
now func() time.Time
|
||||
hc *http.Client
|
||||
log *slog.Logger
|
||||
counter *prometheus.CounterVec
|
||||
byID map[string]*actionRuntime
|
||||
list []*actionRuntime
|
||||
}
|
||||
|
||||
// NewActions builds the runner and registers ghtracker_action_requests_total on reg when reg is not nil.
|
||||
func NewActions(cfgs []config.Action, log *slog.Logger, reg prometheus.Registerer) *Actions {
|
||||
a := &Actions{
|
||||
now: time.Now, log: log, byID: make(map[string]*actionRuntime, len(cfgs)),
|
||||
// Redirects would carry the bearer token to another host, so none are followed.
|
||||
hc: &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }},
|
||||
counter: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "ghtracker", Name: actionMetricName,
|
||||
Help: "Action requests sent to webhooks, by action and result.",
|
||||
}, []string{"action", "result"}),
|
||||
}
|
||||
for _, c := range cfgs {
|
||||
rt := &actionRuntime{cfg: c}
|
||||
a.list = append(a.list, rt)
|
||||
a.byID[c.ID] = rt
|
||||
for _, r := range []string{resultOK, resultRejected, resultFailed} {
|
||||
a.counter.WithLabelValues(c.ID, r)
|
||||
}
|
||||
}
|
||||
if reg != nil {
|
||||
reg.MustRegister(a.counter)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// WithActions enables POST /api/actions/{id} and the action data in the state document.
|
||||
func (s *Server) WithActions(a *Actions) *Server {
|
||||
if a != nil && len(a.list) > 0 {
|
||||
s.actions = a
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// decorate adds the action list and per-alert status to a state document.
|
||||
func (s *Server) decorate(ctx context.Context, st *apiState) {
|
||||
if s.actions == nil {
|
||||
return
|
||||
}
|
||||
for _, rt := range s.actions.list {
|
||||
kinds := rt.cfg.Kinds
|
||||
if kinds == nil {
|
||||
kinds = []string{}
|
||||
}
|
||||
st.Actions = append(st.Actions, apiAction{ID: rt.cfg.ID, Label: rt.cfg.Label, Confirm: rt.cfg.Confirm, Kinds: kinds})
|
||||
if rt.cfg.StatusURL == "" {
|
||||
continue
|
||||
}
|
||||
rows, err := s.actions.statusRows(ctx, rt)
|
||||
if err != nil {
|
||||
st.ActionsError = actionsUnavail
|
||||
continue
|
||||
}
|
||||
for i := range st.Alerts {
|
||||
al := &st.Alerts[i]
|
||||
if !rt.cfg.AppliesTo(al.Kind) {
|
||||
continue
|
||||
}
|
||||
if row, ok := rows[alertRef{strings.ToLower(al.Repo), al.URL}]; ok {
|
||||
if al.ActionStatus == nil {
|
||||
al.ActionStatus = map[string]apiActionStatus{}
|
||||
}
|
||||
al.ActionStatus[rt.cfg.ID] = row
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// statusRows returns the cached rows of an action's status_url; failures are cached for the TTL too.
|
||||
func (a *Actions) statusRows(ctx context.Context, rt *actionRuntime) (map[alertRef]apiActionStatus, error) {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
if !rt.fetched.IsZero() && a.now().Sub(rt.fetched) < statusCacheTTL {
|
||||
return rt.rows, rt.err
|
||||
}
|
||||
rows, err := a.fetchStatus(ctx, rt.cfg)
|
||||
if err != nil {
|
||||
a.log.Warn("action status unavailable", "action", rt.cfg.ID, "err", err)
|
||||
}
|
||||
rt.rows, rt.err, rt.fetched = rows, err, a.now()
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (rt *actionRuntime) invalidate() {
|
||||
rt.mu.Lock()
|
||||
rt.fetched = time.Time{}
|
||||
rt.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *Actions) fetchStatus(ctx context.Context, c config.Action) (map[alertRef]apiActionStatus, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, c.Webhook.Timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.StatusURL, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid status url")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
setBearer(req, c.Token)
|
||||
resp, err := a.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.New("request failed")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return nil, fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
var list []statusRow
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, maxUpstreamBody)).Decode(&list); err != nil {
|
||||
return nil, errors.New("invalid reply")
|
||||
}
|
||||
out := make(map[alertRef]apiActionStatus, len(list))
|
||||
for _, r := range list { // the first row per alert wins, so put the newest first
|
||||
k := alertRef{strings.ToLower(r.Repo), r.URL}
|
||||
if _, seen := out[k]; !seen {
|
||||
out[k] = cleanStatus(r.apiActionStatus)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// cleanStatus drops a link that is not http(s), so the UI never renders a script URL as an anchor.
|
||||
func cleanStatus(st apiActionStatus) apiActionStatus {
|
||||
if u, err := url.Parse(st.Link); err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
st.Link = ""
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func setBearer(req *http.Request, token string) {
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
}
|
||||
|
||||
type actionRequest struct {
|
||||
Repo string `json:"repo"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type webhookAlert 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"`
|
||||
}
|
||||
|
||||
type webhookPayload struct {
|
||||
RequestedAt time.Time `json:"requestedAt"`
|
||||
Action string `json:"action"`
|
||||
Alert webhookAlert `json:"alert"`
|
||||
}
|
||||
|
||||
// runAction validates the request against the current state and forwards it to the action's webhook.
|
||||
func (s *Server) runAction(w http.ResponseWriter, r *http.Request) {
|
||||
if crossSite(r) {
|
||||
http.Error(w, "cross-site action refused", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
rt, ok := s.actions.byID[r.PathValue("id")]
|
||||
if !ok {
|
||||
writeJSONError(w, http.StatusNotFound, "unknown action")
|
||||
return
|
||||
}
|
||||
var req actionRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxActionBody)).Decode(&req); err != nil || req.Repo == "" || req.URL == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, `body must be {"repo":"owner/name","url":"..."}`)
|
||||
return
|
||||
}
|
||||
alert, code, msg := s.findAlert(rt.cfg, req)
|
||||
if code != 0 {
|
||||
writeJSONError(w, code, msg)
|
||||
return
|
||||
}
|
||||
status, body, err := s.actions.post(r.Context(), rt.cfg, webhookPayload{
|
||||
Action: rt.cfg.ID, RequestedAt: s.now().UTC(),
|
||||
Alert: webhookAlert{Repo: alert.Repo, Kind: alert.Kind, Severity: alert.Severity.String(), Subject: alert.Subject,
|
||||
Detail: alert.Detail, URL: alert.URL, Since: alert.Since},
|
||||
})
|
||||
if err != nil {
|
||||
s.actions.count(rt.cfg.ID, resultFailed)
|
||||
s.log.Warn("action webhook failed", "action", rt.cfg.ID, "repo", alert.Repo, "err", err)
|
||||
writeJSONError(w, http.StatusBadGateway, "action webhook unreachable")
|
||||
return
|
||||
}
|
||||
s.log.Info("action webhook", "action", rt.cfg.ID, "repo", alert.Repo, "status", status)
|
||||
if status/100 != 2 {
|
||||
result, out := resultFailed, http.StatusBadGateway
|
||||
switch status / 100 {
|
||||
case 4:
|
||||
result, out = resultRejected, status
|
||||
case 5:
|
||||
out = status
|
||||
}
|
||||
s.actions.count(rt.cfg.ID, result)
|
||||
writeJSONError(w, out, fmt.Sprintf("action webhook answered HTTP %d: %s", status, truncate(string(body), maxErrorChars)))
|
||||
return
|
||||
}
|
||||
s.actions.count(rt.cfg.ID, resultOK)
|
||||
rt.invalidate()
|
||||
var st apiActionStatus
|
||||
if json.Unmarshal(body, &st) != nil {
|
||||
st = apiActionStatus{}
|
||||
}
|
||||
w.Header().Set(contentTypeHeader, "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(cleanStatus(st))
|
||||
}
|
||||
|
||||
// findAlert returns the current alert a request targets, or a status and message when it has none.
|
||||
func (s *Server) findAlert(c config.Action, req actionRequest) (model.Alert, int, string) {
|
||||
v := s.src.View()
|
||||
tracked := false
|
||||
for _, repo := range v.Repos {
|
||||
if strings.EqualFold(repo.FullName, req.Repo) {
|
||||
tracked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !tracked {
|
||||
return model.Alert{}, http.StatusNotFound, "unknown repository"
|
||||
}
|
||||
for _, a := range v.Alerts {
|
||||
if strings.EqualFold(a.Repo, req.Repo) && a.URL == req.URL && c.AppliesTo(a.Kind) {
|
||||
return a, 0, ""
|
||||
}
|
||||
}
|
||||
return model.Alert{}, http.StatusNotFound, "no such alert"
|
||||
}
|
||||
|
||||
func (a *Actions) post(ctx context.Context, c config.Action, p webhookPayload) (int, []byte, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, c.Webhook.Timeout)
|
||||
defer cancel()
|
||||
payload, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Webhook.URL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return 0, nil, errors.New("invalid webhook url")
|
||||
}
|
||||
req.Header.Set(contentTypeHeader, "application/json")
|
||||
setBearer(req, c.Token)
|
||||
resp, err := a.hc.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, errors.New("request failed")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxUpstreamBody))
|
||||
if err != nil {
|
||||
return 0, nil, errors.New("read reply failed")
|
||||
}
|
||||
return resp.StatusCode, body, nil
|
||||
}
|
||||
|
||||
func (a *Actions) count(action, result string) { a.counter.WithLabelValues(action, result).Inc() }
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if r := []rune(s); len(r) > n {
|
||||
return string(r[:n]) + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
testToken = "tok-SECRET-123"
|
||||
alertURL = "https://github.com/o/r/actions/runs/1"
|
||||
prURL = "https://github.com/o/r/pull/5"
|
||||
)
|
||||
|
||||
type fakeHook struct {
|
||||
srv *httptest.Server
|
||||
postBody string
|
||||
statBody string
|
||||
lastAuth string
|
||||
lastBody string
|
||||
lastMethod string
|
||||
postCode int
|
||||
statCode int
|
||||
mu sync.Mutex
|
||||
postCalls atomic.Int32
|
||||
statCalls atomic.Int32
|
||||
}
|
||||
|
||||
func newFakeHook(t *testing.T) *fakeHook {
|
||||
t.Helper()
|
||||
f := &fakeHook{postCode: 200, postBody: `{"state":"queued","label":"Queued","link":"https://ci.example.test/run/9"}`, statCode: 200, statBody: `[]`}
|
||||
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
f.mu.Lock()
|
||||
f.lastAuth, f.lastBody, f.lastMethod = r.Header.Get("Authorization"), string(b), r.Method
|
||||
f.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/status" {
|
||||
f.statCalls.Add(1)
|
||||
w.WriteHeader(f.statCode)
|
||||
_, _ = w.Write([]byte(f.statBody))
|
||||
return
|
||||
}
|
||||
f.postCalls.Add(1)
|
||||
w.WriteHeader(f.postCode)
|
||||
_, _ = w.Write([]byte(f.postBody))
|
||||
}))
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
type actionHarness struct {
|
||||
srv *Server
|
||||
actions *Actions
|
||||
logs *bytes.Buffer
|
||||
clock time.Time
|
||||
}
|
||||
|
||||
func newActionServer(t *testing.T, mutate func(*config.Action), base string) *actionHarness {
|
||||
t.Helper()
|
||||
c := config.Action{ID: "rerun", Label: "Re-run", Kinds: []string{"workflow_failed"}, Confirm: "Run it?",
|
||||
StatusURL: base + "/status", Token: testToken,
|
||||
Webhook: config.Webhook{URL: base + "/hook", TokenEnv: "T", Timeout: 2 * time.Second}}
|
||||
if mutate != nil {
|
||||
mutate(&c)
|
||||
}
|
||||
h := &actionHarness{logs: &bytes.Buffer{}, clock: now}
|
||||
log := slog.New(slog.NewTextHandler(h.logs, nil))
|
||||
h.actions = NewActions([]config.Action{c}, log, prometheus.NewRegistry())
|
||||
h.actions.now = func() time.Time { return h.clock }
|
||||
h.srv = New(&fakeSource{v: testView()}, http.NotFoundHandler(), testUI(), true, log).WithActions(h.actions)
|
||||
h.srv.now = func() time.Time { return now }
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *actionHarness) post(path, body string, hdr map[string]string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.srv.Handler().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func (h *actionHarness) state(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(do(h.srv.Handler(), "GET", "/api/state").Body.Bytes(), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func reqBody(repo, url string) string {
|
||||
b, _ := json.Marshal(map[string]string{"repo": repo, "url": url})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (h *actionHarness) counter(result string) float64 {
|
||||
return testutil.ToFloat64(h.actions.counter.WithLabelValues("rerun", result))
|
||||
}
|
||||
|
||||
func TestActionRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
hdr map[string]string
|
||||
mutate func(*config.Action)
|
||||
name string
|
||||
path string
|
||||
body string
|
||||
postBody string
|
||||
wantResult string
|
||||
wantBody string
|
||||
postCode int
|
||||
wantCode int
|
||||
wantPosts int32
|
||||
}{
|
||||
{name: "ok passes status through", wantCode: 200, wantPosts: 1, wantResult: "ok",
|
||||
wantBody: `"state":"queued"`},
|
||||
{name: "ok with empty body", postBody: "not json", wantCode: 200, wantPosts: 1, wantResult: "ok", wantBody: `"state":""`},
|
||||
{name: "repo case-insensitive", body: reqBody("O/R", alertURL), wantCode: 200, wantPosts: 1, wantResult: "ok"},
|
||||
{name: "javascript link dropped", postBody: `{"state":"x","link":"javascript:alert(1)"}`,
|
||||
wantCode: 200, wantPosts: 1, wantResult: "ok", wantBody: `{"state":"x"}`},
|
||||
{name: "no token configured", mutate: func(a *config.Action) { a.Token = "" }, wantCode: 200, wantPosts: 1, wantResult: "ok"},
|
||||
{name: "upstream 4xx", postCode: 429, postBody: `{"error":"busy"}`, wantCode: 429, wantPosts: 1, wantResult: "rejected", wantBody: "HTTP 429"},
|
||||
{name: "upstream 5xx", postCode: 500, postBody: "boom", wantCode: 500, wantPosts: 1, wantResult: "error", wantBody: "boom"},
|
||||
{name: "upstream redirect not followed", postCode: 302, postBody: "moved", wantCode: 502, wantPosts: 1, wantResult: "error"},
|
||||
{name: "upstream body truncated", postCode: 500, postBody: strings.Repeat("x", 1000), wantCode: 500, wantPosts: 1, wantResult: "error",
|
||||
wantBody: strings.Repeat("x", 300) + "..."},
|
||||
{name: "cross-site refused", hdr: map[string]string{"Sec-Fetch-Site": "cross-site"}, wantCode: 403},
|
||||
{name: "same-site refused", hdr: map[string]string{"Sec-Fetch-Site": "same-site"}, wantCode: 403},
|
||||
{name: "same-origin allowed", hdr: map[string]string{"Sec-Fetch-Site": "same-origin"}, wantCode: 200, wantPosts: 1, wantResult: "ok"},
|
||||
{name: "unknown action", path: "/api/actions/nope", wantCode: 404, wantBody: "unknown action"},
|
||||
{name: "unknown repo", body: reqBody("x/y", alertURL), wantCode: 404, wantBody: "unknown repository"},
|
||||
{name: "unknown alert", body: reqBody("o/r", alertURL+"9"), wantCode: 404, wantBody: "no such alert"},
|
||||
{name: "kind not in kinds", body: reqBody("o/r", prURL), wantCode: 404, wantBody: "no such alert"},
|
||||
{name: "empty kinds matches every kind", body: reqBody("o/r", prURL), mutate: func(a *config.Action) { a.Kinds = nil },
|
||||
wantCode: 200, wantPosts: 1, wantResult: "ok"},
|
||||
{name: "malformed body", body: `{`, wantCode: 400},
|
||||
{name: "missing url", body: `{"repo":"o/r"}`, wantCode: 400},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fh := newFakeHook(t)
|
||||
if tc.postCode != 0 {
|
||||
fh.postCode = tc.postCode
|
||||
}
|
||||
if tc.postBody != "" {
|
||||
fh.postBody = tc.postBody
|
||||
}
|
||||
h := newActionServer(t, tc.mutate, fh.srv.URL)
|
||||
path, body := tc.path, tc.body
|
||||
if path == "" {
|
||||
path = "/api/actions/rerun"
|
||||
}
|
||||
if body == "" {
|
||||
body = reqBody("o/r", alertURL)
|
||||
}
|
||||
rec := h.post(path, body, tc.hdr)
|
||||
if rec.Code != tc.wantCode {
|
||||
t.Fatalf("code = %d, want %d: %s", rec.Code, tc.wantCode, rec.Body)
|
||||
}
|
||||
if fh.postCalls.Load() != tc.wantPosts {
|
||||
t.Fatalf("webhook posts = %d, want %d", fh.postCalls.Load(), tc.wantPosts)
|
||||
}
|
||||
if tc.wantBody != "" && !strings.Contains(rec.Body.String(), tc.wantBody) {
|
||||
t.Fatalf("body %q lacks %q", rec.Body, tc.wantBody)
|
||||
}
|
||||
if tc.wantResult != "" && h.counter(tc.wantResult) != 1 {
|
||||
t.Fatalf("counter %s = %v, want 1", tc.wantResult, h.counter(tc.wantResult))
|
||||
}
|
||||
if tc.wantPosts > 0 {
|
||||
wantAuth := "Bearer " + testToken
|
||||
if tc.mutate != nil && tc.name == "no token configured" {
|
||||
wantAuth = ""
|
||||
}
|
||||
if fh.lastAuth != wantAuth {
|
||||
t.Fatalf("auth = %q, want %q", fh.lastAuth, wantAuth)
|
||||
}
|
||||
}
|
||||
if strings.Contains(h.logs.String(), testToken) || strings.Contains(rec.Body.String(), testToken) {
|
||||
t.Fatal("token leaked into logs or response")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionWebhookPayload(t *testing.T) {
|
||||
fh := newFakeHook(t)
|
||||
h := newActionServer(t, nil, fh.srv.URL)
|
||||
if rec := h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil); rec.Code != 200 {
|
||||
t.Fatal(rec.Body)
|
||||
}
|
||||
var got struct {
|
||||
RequestedAt time.Time
|
||||
Alert map[string]string
|
||||
Action string
|
||||
}
|
||||
if err := json.Unmarshal([]byte(fh.lastBody), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fh.lastMethod != "POST" || got.Action != "rerun" || !got.RequestedAt.Equal(now) {
|
||||
t.Fatalf("payload: %+v (%s)", got, fh.lastBody)
|
||||
}
|
||||
want := map[string]string{"repo": "o/r", "kind": "workflow_failed", "severity": "crit", "subject": "CI", "url": alertURL}
|
||||
for k, v := range want {
|
||||
if got.Alert[k] != v {
|
||||
t.Errorf("alert.%s = %q, want %q", k, got.Alert[k], v)
|
||||
}
|
||||
}
|
||||
if got.Alert["detail"] == "" || got.Alert["since"] == "" {
|
||||
t.Errorf("alert lacks detail or since: %v", got.Alert)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionWebhookDown(t *testing.T) {
|
||||
fh := newFakeHook(t)
|
||||
base := fh.srv.URL
|
||||
fh.srv.Close()
|
||||
h := newActionServer(t, nil, base)
|
||||
rec := h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil)
|
||||
if rec.Code != http.StatusBadGateway || h.counter("error") != 1 {
|
||||
t.Fatalf("%d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if strings.Contains(h.logs.String(), testToken) {
|
||||
t.Fatal("token leaked into logs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRouteAbsentWithoutActions(t *testing.T) {
|
||||
h := newServer(&fakeSource{v: testView()}, testUI(), true).Handler()
|
||||
if rec := do(h, "POST", "/api/actions/rerun"); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateUnchangedWithoutActions(t *testing.T) {
|
||||
body := do(newServer(&fakeSource{v: testView()}, testUI(), true).Handler(), "GET", "/api/state").Body.String()
|
||||
for _, key := range []string{`"actions"`, `"actionStatus"`, `"actionsError"`} {
|
||||
if strings.Contains(body, key) {
|
||||
t.Fatalf("state gained %s without actions: %s", key, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func alertField(m map[string]any, kind, field string) any {
|
||||
for _, a := range m["alerts"].([]any) {
|
||||
if al := a.(map[string]any); al["kind"] == kind {
|
||||
return al[field]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestStateMergesActionStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
wantState any
|
||||
wantLink any
|
||||
mutate func(*config.Action)
|
||||
name string
|
||||
rows string
|
||||
}{
|
||||
{name: "match by repo and url", rows: `[{"repo":"O/R","url":"` + alertURL + `","state":"running","label":"Running","link":"https://ci.example.test/1"}]`,
|
||||
wantState: "running", wantLink: "https://ci.example.test/1"},
|
||||
{name: "first row wins", rows: `[{"repo":"o/r","url":"` + alertURL + `","state":"new"},{"repo":"o/r","url":"` + alertURL + `","state":"old"}]`,
|
||||
wantState: "new"},
|
||||
{name: "other url ignored", rows: `[{"repo":"o/r","url":"` + alertURL + `x","state":"running"}]`},
|
||||
{name: "other repo ignored", rows: `[{"repo":"o/other","url":"` + alertURL + `","state":"running"}]`},
|
||||
{name: "unsafe link dropped", rows: `[{"repo":"o/r","url":"` + alertURL + `","state":"done","link":"javascript:x"}]`, wantState: "done"},
|
||||
{name: "kind filter respected", rows: `[{"repo":"o/r","url":"` + alertURL + `","state":"running"}]`,
|
||||
mutate: func(a *config.Action) { a.Kinds = []string{"pr_waiting"} }},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fh := newFakeHook(t)
|
||||
fh.statBody = tc.rows
|
||||
m := newActionServer(t, tc.mutate, fh.srv.URL).state(t)
|
||||
if _, ok := m["actionsError"]; ok {
|
||||
t.Fatalf("unexpected actionsError: %v", m["actionsError"])
|
||||
}
|
||||
byAction, _ := alertField(m, "workflow_failed", "actionStatus").(map[string]any)
|
||||
st, _ := byAction["rerun"].(map[string]any)
|
||||
if tc.wantState == nil {
|
||||
if st != nil {
|
||||
t.Fatalf("status present: %v", st)
|
||||
}
|
||||
return
|
||||
}
|
||||
if st["state"] != tc.wantState || st["link"] != tc.wantLink {
|
||||
t.Fatalf("status = %v, want state %v link %v", st, tc.wantState, tc.wantLink)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateListsActionsWithoutSecrets(t *testing.T) {
|
||||
fh := newFakeHook(t)
|
||||
h := newActionServer(t, nil, fh.srv.URL)
|
||||
rec := do(h.srv.Handler(), "GET", "/api/state")
|
||||
raw := rec.Body.String()
|
||||
for _, secret := range []string{testToken, fh.srv.URL, "/hook", "/status"} {
|
||||
if strings.Contains(raw, secret) {
|
||||
t.Fatalf("state leaks %q", secret)
|
||||
}
|
||||
}
|
||||
var m struct {
|
||||
Actions []map[string]any
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil || len(m.Actions) != 1 {
|
||||
t.Fatalf("%v %s", err, raw)
|
||||
}
|
||||
a := m.Actions[0]
|
||||
if a["id"] != "rerun" || a["label"] != "Re-run" || a["confirm"] != "Run it?" || len(a["kinds"].([]any)) != 1 {
|
||||
t.Fatalf("action = %v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateStatusDown(t *testing.T) {
|
||||
tests := map[string]func(*fakeHook, *string){
|
||||
"server error": func(f *fakeHook, _ *string) { f.statCode = 500 },
|
||||
"invalid json": func(f *fakeHook, _ *string) { f.statBody = "nope" },
|
||||
"unreachable": func(f *fakeHook, base *string) { f.srv.Close(); *base = f.srv.URL },
|
||||
}
|
||||
for name, breakIt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fh := newFakeHook(t)
|
||||
base := fh.srv.URL
|
||||
breakIt(fh, &base)
|
||||
h := newActionServer(t, nil, base)
|
||||
m := h.state(t)
|
||||
if m["actionsError"] != actionsUnavail {
|
||||
t.Fatalf("actionsError = %v", m["actionsError"])
|
||||
}
|
||||
if alertField(m, "workflow_failed", "actionStatus") != nil {
|
||||
t.Fatal("actionStatus present while status is down")
|
||||
}
|
||||
if len(m["actions"].([]any)) != 1 {
|
||||
t.Fatal("actions must still be listed so the buttons work")
|
||||
}
|
||||
if strings.Contains(h.logs.String(), testToken) {
|
||||
t.Fatal("token leaked into logs")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateWithoutStatusURL(t *testing.T) {
|
||||
fh := newFakeHook(t)
|
||||
h := newActionServer(t, func(a *config.Action) { a.StatusURL = "" }, fh.srv.URL)
|
||||
m := h.state(t)
|
||||
if _, ok := m["actionsError"]; ok || fh.statCalls.Load() != 0 || len(m["actions"].([]any)) != 1 {
|
||||
t.Fatalf("state: %v, status calls %d", m, fh.statCalls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCachedAndInvalidatedByRequest(t *testing.T) {
|
||||
fh := newFakeHook(t)
|
||||
h := newActionServer(t, nil, fh.srv.URL)
|
||||
h.state(t)
|
||||
h.state(t)
|
||||
if fh.statCalls.Load() != 1 {
|
||||
t.Fatalf("status calls = %d, want 1 inside the TTL", fh.statCalls.Load())
|
||||
}
|
||||
h.clock = h.clock.Add(statusCacheTTL + time.Second)
|
||||
h.state(t)
|
||||
if fh.statCalls.Load() != 2 {
|
||||
t.Fatalf("status calls = %d, want 2 after the TTL", fh.statCalls.Load())
|
||||
}
|
||||
h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil)
|
||||
h.state(t)
|
||||
if fh.statCalls.Load() != 3 {
|
||||
t.Fatalf("status calls = %d, want 3 after a request", fh.statCalls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectDoesNotForwardToken(t *testing.T) {
|
||||
var leaked atomic.Bool
|
||||
other := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
leaked.Store(true)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(other.Close)
|
||||
fh := newFakeHook(t)
|
||||
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, other.URL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
t.Cleanup(hook.Close)
|
||||
h := newActionServer(t, func(a *config.Action) { a.Webhook.URL = hook.URL }, fh.srv.URL)
|
||||
if rec := h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil); rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if leaked.Load() {
|
||||
t.Fatal("bearer token forwarded across a redirect")
|
||||
}
|
||||
}
|
||||
+12
-9
@@ -10,13 +10,15 @@ import (
|
||||
// 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"`
|
||||
Now time.Time `json:"now"`
|
||||
ActionsError string `json:"actionsError,omitempty"`
|
||||
Actions []apiAction `json:"actions,omitempty"`
|
||||
Repos []apiRepo `json:"repos"`
|
||||
Alerts []apiAlert `json:"alerts"`
|
||||
Thresholds apiThresholds `json:"thresholds"`
|
||||
Counts apiCounts `json:"counts"`
|
||||
Scan apiScan `json:"scan"`
|
||||
Counts apiCounts `json:"counts"`
|
||||
IntervalSeconds int64 `json:"intervalSeconds"`
|
||||
Loaded bool `json:"loaded"`
|
||||
Refreshing bool `json:"refreshing"`
|
||||
@@ -85,13 +87,14 @@ type apiRelease struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
Since time.Time `json:"since"`
|
||||
ActionStatus map[string]apiActionStatus `json:"actionStatus,omitempty"`
|
||||
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) }
|
||||
|
||||
+19
-5
@@ -41,6 +41,7 @@ type Server struct {
|
||||
lastRepo map[string]time.Time
|
||||
now func() time.Time
|
||||
src Source
|
||||
actions *Actions
|
||||
ui fs.FS
|
||||
metrics http.Handler
|
||||
log *slog.Logger
|
||||
@@ -64,6 +65,9 @@ func (s *Server) Handler() http.Handler {
|
||||
if s.manualRefresh {
|
||||
mux.HandleFunc("POST /refresh", s.refresh)
|
||||
}
|
||||
if s.actions != nil {
|
||||
mux.HandleFunc("POST /api/actions/{id}", s.runAction)
|
||||
}
|
||||
return securityHeaders(mux)
|
||||
}
|
||||
|
||||
@@ -99,10 +103,12 @@ func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFileFS(w, r, s.ui, name)
|
||||
}
|
||||
|
||||
func (s *Server) state(w http.ResponseWriter, _ *http.Request) {
|
||||
func (s *Server) state(w http.ResponseWriter, r *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 {
|
||||
st := buildState(s.src.View(), s.manualRefresh)
|
||||
s.decorate(r.Context(), &st)
|
||||
if err := json.NewEncoder(w).Encode(st); err != nil {
|
||||
s.log.Error("encode state", "err", err)
|
||||
}
|
||||
}
|
||||
@@ -117,8 +123,7 @@ func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
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" {
|
||||
if crossSite(r) {
|
||||
http.Error(w, "cross-site refresh refused", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -140,6 +145,13 @@ func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
// crossSite reports whether the browser labelled the request as cross-site, so another page
|
||||
// cannot spend the API budget or fire a webhook.
|
||||
func crossSite(r *http.Request) bool {
|
||||
site := r.Header.Get("Sec-Fetch-Site")
|
||||
return site != "" && site != "same-origin" && site != "none"
|
||||
}
|
||||
|
||||
// repoRefreshResponse is the full state after the refresh, so the UI can swap it in, plus the repo name.
|
||||
type repoRefreshResponse struct {
|
||||
Repo string `json:"repo"`
|
||||
@@ -185,7 +197,9 @@ func (s *Server) refreshRepo(w http.ResponseWriter, r *http.Request, repo string
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err := json.NewEncoder(w).Encode(repoRefreshResponse{Repo: repo, apiState: buildState(v, s.manualRefresh)}); err != nil {
|
||||
st := buildState(v, s.manualRefresh)
|
||||
s.decorate(r.Context(), &st)
|
||||
if err := json.NewEncoder(w).Encode(repoRefreshResponse{Repo: repo, apiState: st}); err != nil {
|
||||
s.log.Error("encode repo refresh", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user