mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-20 01:37:05 +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:
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -145,3 +146,78 @@ func TestWebBase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionsDefaultOff(t *testing.T) {
|
||||
c := valid()
|
||||
if err := c.finalize(env(nil), keyFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.Actions) != 0 {
|
||||
t.Fatalf("actions must default to none: %+v", c.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionsFinalize(t *testing.T) {
|
||||
good := func() Action {
|
||||
return Action{ID: "rerun", Webhook: Webhook{URL: "https://hooks.example.test/rerun", TokenEnv: "T"}}
|
||||
}
|
||||
tests := map[string]struct {
|
||||
mutate func(*Action)
|
||||
extra []Action
|
||||
wantErr bool
|
||||
}{
|
||||
"ok": {mutate: func(*Action) {}},
|
||||
"no token env": {mutate: func(a *Action) { a.Webhook.TokenEnv = "" }},
|
||||
"bad id upper": {mutate: func(a *Action) { a.ID = "Re_run" }, wantErr: true},
|
||||
"empty id": {mutate: func(a *Action) { a.ID = "" }, wantErr: true},
|
||||
"duplicate id": {mutate: func(*Action) {}, extra: []Action{good()}, wantErr: true},
|
||||
"bad webhook": {mutate: func(a *Action) { a.Webhook.URL = "ftp://x" }, wantErr: true},
|
||||
"no webhook": {mutate: func(a *Action) { a.Webhook.URL = "" }, wantErr: true},
|
||||
"bad status url": {mutate: func(a *Action) { a.StatusURL = "nope" }, wantErr: true},
|
||||
"status url ok": {mutate: func(a *Action) { a.StatusURL = "http://status.example.test/s" }},
|
||||
"negative timeout": {mutate: func(a *Action) { a.Webhook.Timeout = -time.Second }, wantErr: true},
|
||||
"token env unset": {mutate: func(a *Action) { a.Webhook.TokenEnv = "MISSING" }, wantErr: true},
|
||||
}
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
a := good()
|
||||
tc.mutate(&a)
|
||||
c := valid()
|
||||
c.Actions = append([]Action{a}, tc.extra...)
|
||||
err := c.finalize(env(map[string]string{"T": "s3cret"}), keyFile)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), "s3cret") {
|
||||
t.Fatal("error leaks token")
|
||||
}
|
||||
if err == nil {
|
||||
got := c.Actions[0]
|
||||
if got.Label != "rerun" || got.Webhook.Timeout != DefaultActionTimeout {
|
||||
t.Fatalf("defaults wrong: %+v", got)
|
||||
}
|
||||
if a.Webhook.TokenEnv == "T" && got.Token != "s3cret" {
|
||||
t.Fatalf("token not read: %+v", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionAppliesTo(t *testing.T) {
|
||||
tests := []struct {
|
||||
kind string
|
||||
kinds []string
|
||||
want bool
|
||||
}{
|
||||
{kinds: nil, kind: "anything", want: true},
|
||||
{kinds: []string{"workflow_failed"}, kind: "workflow_failed", want: true},
|
||||
{kinds: []string{"workflow_failed"}, kind: "pr_waiting", want: false},
|
||||
{kinds: []string{"a", "b"}, kind: "b", want: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := (Action{Kinds: tc.kinds}).AppliesTo(tc.kind); got != tc.want {
|
||||
t.Errorf("%v.AppliesTo(%q) = %v", tc.kinds, tc.kind, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user