Files
MOPAC/internal/events/normalize_test.go
T
mrcharles 05ec1a4142 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.
2026-08-28 21:38:16 -05:00

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")
}
}