events: verify, normalize, append-only store with provider-id dedup
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.
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user