Signature verification (gitea hex HMAC-SHA256 over the raw body via constant-time hmac.Equal; redmine/discourse constant-time shared-secret headers) with one generic ErrUnverified so rejects give attackers no oracle. Tolerant normalization of the known Redmine/Discourse/Gitea payload variants into one Event record (canonical subject ids, actor, title, repo, sha256 payload digest) plus the DESIGN action mapping (dispatch_turn / respond_turn / pipeline_step / ignore). Store: JSONL under state dir, 0600, dedup keyed on provider event id (delivery header, payload-digest fallback), index rebuilt at startup so replays across restarts still dedup; torn tail lines skipped, not fatal.
237 lines
7.3 KiB
Go
237 lines
7.3 KiB
Go
package events
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var testNow = time.Date(2026, 8, 28, 22, 0, 0, 0, time.UTC)
|
|
|
|
func TestNormalize(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
source string
|
|
headers map[string]string
|
|
body string
|
|
want Event
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "gitea pr approved",
|
|
source: SourceGitea,
|
|
headers: map[string]string{
|
|
giteaDeliveryHeader: "d-1234",
|
|
},
|
|
body: `{"action":"approved","number":5,
|
|
"pull_request":{"number":5,"title":"Add events receiver","merged":false},
|
|
"repository":{"full_name":"ukrrs/MOPAC"},
|
|
"sender":{"login":"charles"}}`,
|
|
want: Event{
|
|
Source: SourceGitea, Kind: "pr_approved", Action: ActionPipelineStep,
|
|
Actor: "charles", SubjectID: "gitea:pr:ukrrs/MOPAC#5",
|
|
Subject: "Add events receiver", Repo: "ukrrs/MOPAC",
|
|
ProviderID: "d-1234",
|
|
},
|
|
},
|
|
{
|
|
name: "gitea pr closed and merged maps to pr_merged",
|
|
source: SourceGitea,
|
|
body: `{"action":"closed","number":7,
|
|
"pull_request":{"number":7,"title":"Fix loop","merged":true},
|
|
"repository":{"full_name":"ukrrs/MOPAC"},"sender":{"login":"alice"}}`,
|
|
want: Event{
|
|
Source: SourceGitea, Kind: "pr_merged", Action: ActionPipelineStep,
|
|
Actor: "alice", SubjectID: "gitea:pr:ukrrs/MOPAC#7", Subject: "Fix loop", Repo: "ukrrs/MOPAC",
|
|
},
|
|
},
|
|
{
|
|
name: "gitea pr opened is stored but ignored",
|
|
source: SourceGitea,
|
|
body: `{"action":"opened","number":9,
|
|
"pull_request":{"number":9,"title":"New"},"sender":{"login":"bob"}}`,
|
|
want: Event{
|
|
Source: SourceGitea, Kind: "pr_opened", Action: ActionIgnore,
|
|
Actor: "bob", SubjectID: "gitea:pr:9", Subject: "New",
|
|
},
|
|
},
|
|
{
|
|
name: "gitea push event",
|
|
source: SourceGitea,
|
|
headers: map[string]string{
|
|
"X-Gitea-Event": "push",
|
|
giteaDeliveryHeader: "d-push",
|
|
},
|
|
body: `{"ref":"refs/heads/main","sender":{"login":"ci"}}`,
|
|
want: Event{
|
|
Source: SourceGitea, Kind: "push", Action: ActionIgnore,
|
|
Actor: "ci", ProviderID: "d-push",
|
|
},
|
|
},
|
|
{
|
|
name: "redmine webhook plugin shape",
|
|
source: SourceRedmine,
|
|
body: `{"event_name":"issue_updated",
|
|
"payload":{"issue":{"id":42,"subject":"Ship phase 2b","author":{"name":"charles"}},
|
|
"user":{"login":"charles"}}}`,
|
|
want: Event{
|
|
Source: SourceRedmine, Kind: "issue_updated", Action: ActionDispatchTurn,
|
|
Actor: "charles", SubjectID: "redmine:issue:42", Subject: "Ship phase 2b",
|
|
},
|
|
},
|
|
{
|
|
name: "redmine flat shape defaults kind",
|
|
source: SourceRedmine,
|
|
body: `{"issue":{"id":43,"subject":"Flat plugin","author":{"login":"dana"}}}`,
|
|
want: Event{
|
|
Source: SourceRedmine, Kind: "issue_updated", Action: ActionDispatchTurn,
|
|
Actor: "dana", SubjectID: "redmine:issue:43", Subject: "Flat plugin",
|
|
},
|
|
},
|
|
{
|
|
name: "redmine journal event dispatches",
|
|
source: SourceRedmine,
|
|
body: `{"event_name":"journal_created","payload":{"issue":{"id":8,"subject":"Note"}}}`,
|
|
want: Event{
|
|
Source: SourceRedmine, Kind: "journal_created", Action: ActionDispatchTurn,
|
|
SubjectID: "redmine:issue:8", Subject: "Note", Actor: "unknown",
|
|
},
|
|
},
|
|
{
|
|
name: "discourse post created",
|
|
source: SourceDiscourse,
|
|
headers: map[string]string{
|
|
"X-Discourse-Event": "post_created",
|
|
discourseEventIDHeader: "42",
|
|
},
|
|
body: `{"post":{"id":99,"topic_id":7,"username":"charles","topic_title":"Phase 2b plan"}}`,
|
|
want: Event{
|
|
Source: SourceDiscourse, Kind: "post_created", Action: ActionRespondTurn,
|
|
Actor: "charles", SubjectID: "discourse:topic:7",
|
|
Subject: "Phase 2b plan", ProviderID: "42",
|
|
},
|
|
},
|
|
{
|
|
name: "discourse without headers reads payload",
|
|
source: SourceDiscourse,
|
|
body: `{"event_type":"post_edited","post":{"topic_id":3,"username":"eve"},"id":"77"}`,
|
|
want: Event{
|
|
Source: SourceDiscourse, Kind: "post_edited", Action: ActionRespondTurn,
|
|
Actor: "eve", SubjectID: "discourse:topic:3", ProviderID: "77",
|
|
},
|
|
},
|
|
{
|
|
name: "discourse user event ignored",
|
|
source: SourceDiscourse,
|
|
headers: map[string]string{"X-Discourse-Event": "user_created"},
|
|
body: `{"user":{"username":"newbie"}}`,
|
|
want: Event{
|
|
Source: SourceDiscourse, Kind: "user_created", Action: ActionIgnore,
|
|
Actor: "newbie",
|
|
},
|
|
},
|
|
{
|
|
name: "not json",
|
|
source: SourceGitea,
|
|
body: `<html>not json</html>`,
|
|
wantErr: "payload is not a JSON object",
|
|
},
|
|
{
|
|
name: "json array not object",
|
|
source: SourceRedmine,
|
|
body: `[1,2,3]`,
|
|
wantErr: "payload is not a JSON object",
|
|
},
|
|
{
|
|
name: "unknown source",
|
|
source: "slack",
|
|
body: `{}`,
|
|
wantErr: "unknown event source",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
header := http.Header{}
|
|
for k, v := range tc.headers {
|
|
header.Set(k, v)
|
|
}
|
|
ev, err := Normalize(tc.source, header, []byte(tc.body), testNow)
|
|
if tc.wantErr != "" {
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("Normalize: %v", err)
|
|
}
|
|
got := Event{
|
|
Source: ev.Source, Kind: ev.Kind, Action: ev.Action, Actor: ev.Actor,
|
|
SubjectID: ev.SubjectID, Subject: ev.Subject, Repo: ev.Repo,
|
|
ProviderID: ev.ProviderID,
|
|
}
|
|
if tc.want.ProviderID == "" {
|
|
got.ProviderID = "" // digest fallback is asserted separately
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("normalized event:\n got %+v\n want %+v", got, tc.want)
|
|
}
|
|
if ev.ReceivedAt != testNow {
|
|
t.Errorf("ReceivedAt = %v, want %v", ev.ReceivedAt, testNow)
|
|
}
|
|
if len(ev.PayloadDigest) != 64 {
|
|
t.Errorf("PayloadDigest = %q, want sha256 hex", ev.PayloadDigest)
|
|
}
|
|
// No provider id anywhere -> digest fallback (dedup still works).
|
|
if tc.want.ProviderID == "" && ev.ProviderID != ev.PayloadDigest {
|
|
t.Errorf("ProviderID = %q, want digest fallback", ev.ProviderID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMapAction(t *testing.T) {
|
|
cases := []struct {
|
|
source, kind, want string
|
|
}{
|
|
{SourceRedmine, "issue_updated", ActionDispatchTurn},
|
|
{SourceRedmine, "issue_note_added", ActionDispatchTurn},
|
|
{SourceRedmine, "issue_created", ActionIgnore},
|
|
{SourceRedmine, "member_added", ActionIgnore},
|
|
{SourceDiscourse, "post_created", ActionRespondTurn},
|
|
{SourceDiscourse, "post", ActionRespondTurn},
|
|
{SourceDiscourse, "topic_created", ActionIgnore},
|
|
{SourceGitea, "pr_approved", ActionPipelineStep},
|
|
{SourceGitea, "pr_merged", ActionPipelineStep},
|
|
{SourceGitea, "pr_synchronized", ActionIgnore},
|
|
{SourceGitea, "issue_opened", ActionIgnore},
|
|
{SourceGitea, "push", ActionIgnore},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := MapAction(tc.source, tc.kind); got != tc.want {
|
|
t.Errorf("MapAction(%s, %s) = %s, want %s", tc.source, tc.kind, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizeDigestStable(t *testing.T) {
|
|
body := []byte(`{"action":"approved","number":1,"pull_request":{"number":1}}`)
|
|
h := http.Header{giteaDeliveryHeader: []string{"same"}}
|
|
a, err := Normalize(SourceGitea, h, body, testNow)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := Normalize(SourceGitea, h, body, testNow.Add(time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if a.DedupKey() != b.DedupKey() {
|
|
t.Errorf("same delivery must keep one dedup key: %s vs %s", a.DedupKey(), b.DedupKey())
|
|
}
|
|
if a.PayloadDigest != b.PayloadDigest {
|
|
t.Errorf("digest changed for identical payload")
|
|
}
|
|
}
|