Files
mrcharles 86c39c8fc5 Rename module path to ukrrs.com/mopac/harness
The old path carried the reachableceo org prefix while the repo lives at
git.knownelement.com/ukrrs/MOPAC; the drift was cited as doc rot. go.mod
and every internal import updated; build/vet/test clean in the builder.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-28 22:01:36 -05:00

329 lines
10 KiB
Go

package events
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"ukrrs.com/mopac/harness/internal/config"
)
const (
testRedmineSecret = "redmine-hook-secret"
testDiscourseSecret = "discourse-hook-secret"
testGiteaSecret = "gitea-hook-secret"
)
// recordingDispatcher captures dispatched events for assertions.
type recordingDispatcher struct {
mu sync.Mutex
events []Event
}
func (d *recordingDispatcher) DispatchEvent(ctx context.Context, ev Event) {
d.mu.Lock()
defer d.mu.Unlock()
d.events = append(d.events, ev)
}
func (d *recordingDispatcher) dispatched() []Event {
d.mu.Lock()
defer d.mu.Unlock()
return append([]Event(nil), d.events...)
}
func newTestServer(t *testing.T) (*Server, *recordingDispatcher, string) {
t.Helper()
stateDir := t.TempDir()
cfg := config.EventsConfig{
Listen: ":0",
StateDir: stateDir,
Redmine: config.EventSourceConfig{
SecretRef: "literal:" + testRedmineSecret,
SecretHeader: DefaultRedmineSecretHeader,
},
Discourse: config.EventSourceConfig{
SecretRef: "literal:" + testDiscourseSecret,
SecretHeader: DefaultDiscourseSecretHeader,
},
Gitea: config.EventSourceConfig{
SecretRef: "literal:" + testGiteaSecret,
},
}
disp := &recordingDispatcher{}
srv, err := NewServer(cfg, disp, io.Discard)
if err != nil {
t.Fatalf("NewServer: %v", err)
}
t.Cleanup(func() { srv.Close() })
return srv, disp, stateDir
}
func post(t *testing.T, url string, headers map[string]string, body string) (int, string) {
t.Helper()
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
func readLog(t *testing.T, stateDir string) []string {
t.Helper()
data, err := os.ReadFile(filepath.Join(stateDir, "events.jsonl"))
if os.IsNotExist(err) {
return nil
}
if err != nil {
t.Fatal(err)
}
var lines []string
for _, ln := range strings.Split(strings.TrimSpace(string(data)), "\n") {
if ln != "" {
lines = append(lines, ln)
}
}
return lines
}
func TestServerVerificationEndToEnd(t *testing.T) {
srv, _, stateDir := newTestServer(t)
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
giteaBody := `{"action":"approved","number":5,"pull_request":{"number":5,"title":"PR"},"repository":{"full_name":"ukrrs/MOPAC"},"sender":{"login":"charles"}}`
giteaHeaders := func(sig string) map[string]string {
return map[string]string{
giteaDeliveryHeader: "delivery-1",
giteaSignatureHeader: sig,
"X-Gitea-Event": "pull_request",
"X-Gitea-Event-Type": "pull_request_approved",
}
}
cases := []struct {
name string
path string
headers map[string]string
body string
wantCode int
wantBody string
}{
{"gitea unsigned rejected", "/hooks/gitea", nil, giteaBody, 401, "unverified webhook"},
{"gitea bad signature rejected", "/hooks/gitea", giteaHeaders(strings.Repeat("ff", 32)), giteaBody, 401, "unverified webhook"},
{"gitea valid accepted", "/hooks/gitea", giteaHeaders(SignGitea([]byte(giteaBody), testGiteaSecret)), giteaBody, 200, `"status":"stored"`},
{"redmine unsigned rejected", "/hooks/redmine", nil, `{}`, 401, "unverified webhook"},
{
"redmine valid accepted",
"/hooks/redmine",
map[string]string{DefaultRedmineSecretHeader: testRedmineSecret},
`{"event_name":"issue_updated","payload":{"issue":{"id":42,"subject":"Ship"},"user":{"login":"charles"}}}`,
200, `"status":"stored"`,
},
{"discourse wrong secret rejected", "/hooks/discourse", map[string]string{DefaultDiscourseSecretHeader: "nope"}, `{}`, 401, "unverified webhook"},
{
"discourse valid accepted",
"/hooks/discourse",
map[string]string{DefaultDiscourseSecretHeader: testDiscourseSecret, "X-Discourse-Event": "post_created", "X-Discourse-Event-Id": "42"},
`{"post":{"id":9,"topic_id":7,"username":"charles","topic_title":"Plan"}}`,
200, `"status":"stored"`,
},
{"gitea malformed body rejected", "/hooks/gitea", giteaHeaders(SignGitea([]byte("not json"), testGiteaSecret)), `not json`, 400, "malformed webhook payload"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, body := post(t, ts.URL+tc.path, tc.headers, tc.body)
if code != tc.wantCode {
t.Errorf("code = %d, want %d (body %s)", code, tc.wantCode, body)
}
if !strings.Contains(body, tc.wantBody) {
t.Errorf("body %q does not contain %q", body, tc.wantBody)
}
for _, secret := range []string{testRedmineSecret, testDiscourseSecret, testGiteaSecret} {
if strings.Contains(body, secret) {
t.Errorf("response body leaks secret: %s", body)
}
}
})
}
if lines := readLog(t, stateDir); len(lines) != 3 {
t.Errorf("event log has %d lines, want 3 (only verified events stored):\n%s",
len(lines), strings.Join(lines, "\n"))
}
}
func TestServerDedupAndDispatch(t *testing.T) {
srv, disp, stateDir := newTestServer(t)
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
body := `{"action":"approved","number":5,"pull_request":{"number":5},"sender":{"login":"charles"}}`
headers := map[string]string{
giteaDeliveryHeader: "delivery-dedup",
giteaSignatureHeader: SignGitea([]byte(body), testGiteaSecret),
}
code, respBody := post(t, ts.URL+"/hooks/gitea", headers, body)
if code != 200 || !strings.Contains(respBody, `"status":"stored"`) {
t.Fatalf("first delivery: code=%d body=%s", code, respBody)
}
code, respBody = post(t, ts.URL+"/hooks/gitea", headers, body)
if code != 200 || !strings.Contains(respBody, `"status":"duplicate"`) {
t.Fatalf("replay: code=%d body=%s", code, respBody)
}
if lines := readLog(t, stateDir); len(lines) != 1 {
t.Errorf("event log has %d lines after replay, want 1", len(lines))
}
dispatched := disp.dispatched()
if len(dispatched) != 1 {
t.Fatalf("dispatcher ran %d times, want 1 (dupes do not dispatch)", len(dispatched))
}
if dispatched[0].Action != ActionPipelineStep {
t.Errorf("dispatched action = %s, want %s", dispatched[0].Action, ActionPipelineStep)
}
}
func TestServerIgnoreActionDoesNotDispatch(t *testing.T) {
srv, disp, stateDir := newTestServer(t)
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
body := `{"action":"opened","number":3,"pull_request":{"number":3},"sender":{"login":"bob"}}`
headers := map[string]string{
giteaDeliveryHeader: "delivery-open",
giteaSignatureHeader: SignGitea([]byte(body), testGiteaSecret),
}
code, respBody := post(t, ts.URL+"/hooks/gitea", headers, body)
if code != 200 || !strings.Contains(respBody, `"action":"ignore"`) {
t.Fatalf("opened PR: code=%d body=%s", code, respBody)
}
if got := disp.dispatched(); len(got) != 0 {
t.Errorf("ignore action dispatched %d events, want 0", len(got))
}
if lines := readLog(t, stateDir); len(lines) != 1 {
t.Errorf("ignored events are still stored: %d lines, want 1", len(lines))
}
}
func TestServerAuxRoutes(t *testing.T) {
srv, _, _ := newTestServer(t)
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
resp, err := http.Get(ts.URL + "/healthz")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("healthz = %d, want 200", resp.StatusCode)
}
resp, err = http.Get(ts.URL + "/hooks/gitea")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("GET /hooks/gitea = %d, want 405", resp.StatusCode)
}
code, body := post(t, ts.URL+"/hooks/unknown", nil, `{}`)
if code != 404 {
t.Errorf("unknown hook = %d, want 404 (body %s)", code, body)
}
}
func TestServerRejectsOversizedBody(t *testing.T) {
srv, _, _ := newTestServer(t)
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
big := bytes.Repeat([]byte("a"), maxBodyBytes+1)
sig := SignGitea(big, testGiteaSecret)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/hooks/gitea", bytes.NewReader(big))
req.Header.Set(giteaSignatureHeader, sig)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Errorf("oversized body = %d, want 413", resp.StatusCode)
}
}
func TestNewServerRequiresASecret(t *testing.T) {
cfg := config.EventsConfig{Listen: ":0", StateDir: t.TempDir()}
if _, err := NewServer(cfg, nil, io.Discard); err == nil ||
!strings.Contains(err.Error(), "no webhook secrets configured") {
t.Errorf("NewServer without secrets err = %v, want no-webhook-secrets error", err)
}
}
func TestServerLogLineLeaksNothing(t *testing.T) {
var buf bytes.Buffer
cfg := config.EventsConfig{
Listen: ":0",
StateDir: t.TempDir(),
Gitea: config.EventSourceConfig{SecretRef: "literal:" + testGiteaSecret},
}
srv, err := NewServer(cfg, nil, &buf)
if err != nil {
t.Fatal(err)
}
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
// A signed delivery and an unsigned probe both log; neither line may
// contain the secret or any header VALUE.
body := `{"action":"approved","number":1,"pull_request":{"number":1}}`
post(t, ts.URL+"/hooks/gitea", map[string]string{
giteaSignatureHeader: SignGitea([]byte(body), testGiteaSecret),
giteaDeliveryHeader: "leak-check",
}, body)
post(t, ts.URL+"/hooks/gitea", nil, body)
out := buf.String()
if strings.Contains(out, testGiteaSecret) {
t.Errorf("log leaks secret:\n%s", out)
}
if strings.Contains(out, "payload") {
t.Errorf("log echoes payload:\n%s", out)
}
for _, want := range []string{"kind=pr_approved", "provider_id=leak-check", "rejected source=gitea"} {
if !strings.Contains(out, want) {
t.Errorf("log line missing %q:\n%s", want, out)
}
}
// The stored JSONL record must not carry the secret either.
data, _ := os.ReadFile(srv.store.Path())
if strings.Contains(string(data), testGiteaSecret) {
t.Errorf("event log leaks secret: %s", data)
}
var ev Event
if err := json.Unmarshal(data, &ev); err != nil {
t.Errorf("log line is not valid Event JSON: %v (%s)", err, data)
}
}