Files
multica/server/internal/handler/autopilot_webhook_test.go
603519d158 MUL-6977: test: remove implementation-only coverage (#7932)
* test: remove implementation-only coverage

Co-authored-by: multica-agent <[email protected]>

* test: remove remaining implementation-only coverage

Co-authored-by: multica-agent <[email protected]>

* test: restore compatibility and fragment navigation coverage

Co-authored-by: multica-agent <[email protected]>

* test: preserve behavioral cleanup contracts

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 13:23:25 +08:00

342 lines
12 KiB
Go

package handler
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
// ── Token generation ────────────────────────────────────────────────────────
func TestGenerateWebhookToken_PrefixAndLength(t *testing.T) {
token, err := generateWebhookToken()
if err != nil {
t.Fatalf("generateWebhookToken: %v", err)
}
if !strings.HasPrefix(token, "awt_") {
t.Fatalf("expected awt_ prefix, got %q", token)
}
// 32 random bytes -> 43 base64-url chars (no padding).
if len(token) != len("awt_")+43 {
t.Fatalf("unexpected token length: %d (token=%q)", len(token), token)
}
}
func TestGenerateWebhookToken_NoUnsafeURLChars(t *testing.T) {
token, err := generateWebhookToken()
if err != nil {
t.Fatalf("generateWebhookToken: %v", err)
}
if strings.ContainsAny(token, "+/= ") {
t.Fatalf("token has unsafe characters: %q", token)
}
}
// ── Payload normalization ───────────────────────────────────────────────────
func TestNormalizeWebhookPayload_PreservesCallerProvidedEnvelope(t *testing.T) {
body := []byte(`{"event":"caller.event","eventPayload":{"k":"v"}}`)
headers := http.Header{}
headers.Set("Content-Type", "application/json")
env, err := normalizeWebhookPayload(body, headers)
if err != nil {
t.Fatalf("normalize: %v", err)
}
if env.Event != "caller.event" {
t.Fatalf("event: got %q want %q", env.Event, "caller.event")
}
var inner map[string]string
if err := json.Unmarshal(env.EventPayload, &inner); err != nil {
t.Fatalf("eventPayload not preserved: %v", err)
}
if inner["k"] != "v" {
t.Fatalf("eventPayload contents lost: %#v", inner)
}
if env.Request.ContentType != "application/json" {
t.Fatalf("contentType: %q", env.Request.ContentType)
}
if env.Request.ReceivedAt == "" {
t.Fatal("receivedAt not set")
}
}
func TestNormalizeWebhookPayload_GitHubHeaderInferEvent(t *testing.T) {
body := []byte(`{"action":"opened","pull_request":{"number":7}}`)
headers := http.Header{}
headers.Set("Content-Type", "application/json")
headers.Set("X-GitHub-Event", "pull_request")
env, err := normalizeWebhookPayload(body, headers)
if err != nil {
t.Fatalf("normalize: %v", err)
}
if env.Event != "github.pull_request.opened" {
t.Fatalf("github event: got %q", env.Event)
}
// Original body preserved in eventPayload.
if !strings.Contains(string(env.EventPayload), `"pull_request"`) {
t.Fatalf("body not preserved in eventPayload: %s", env.EventPayload)
}
}
func TestNormalizeWebhookPayload_GitLabHeader(t *testing.T) {
body := []byte(`{"object_kind":"push"}`)
headers := http.Header{}
headers.Set("X-Gitlab-Event", "Push Hook")
env, err := normalizeWebhookPayload(body, headers)
if err != nil {
t.Fatalf("normalize: %v", err)
}
if env.Event != "gitlab.Push Hook" {
t.Fatalf("gitlab event: got %q", env.Event)
}
}
func TestNormalizeWebhookPayload_BodyEventField(t *testing.T) {
body := []byte(`{"event":"demo.received","data":{"x":1}}`)
headers := http.Header{}
env, err := normalizeWebhookPayload(body, headers)
if err != nil {
t.Fatalf("normalize: %v", err)
}
if env.Event != "demo.received" {
t.Fatalf("event: %q", env.Event)
}
}
func TestNormalizeWebhookPayload_BodyTypeFallback(t *testing.T) {
body := []byte(`{"type":"foo.bar"}`)
env, err := normalizeWebhookPayload(body, http.Header{})
if err != nil {
t.Fatalf("normalize: %v", err)
}
if env.Event != "foo.bar" {
t.Fatalf("event: %q", env.Event)
}
}
func TestNormalizeWebhookPayload_BodyActionFallback(t *testing.T) {
body := []byte(`{"action":"opened"}`)
env, err := normalizeWebhookPayload(body, http.Header{})
if err != nil {
t.Fatalf("normalize: %v", err)
}
if env.Event != "opened" {
t.Fatalf("event: %q", env.Event)
}
}
func TestNormalizeWebhookPayload_DefaultEvent(t *testing.T) {
body := []byte(`{"foo":"bar"}`)
env, err := normalizeWebhookPayload(body, http.Header{})
if err != nil {
t.Fatalf("normalize: %v", err)
}
if env.Event != "webhook.received" {
t.Fatalf("event: %q", env.Event)
}
if !strings.Contains(string(env.EventPayload), `"foo"`) {
t.Fatalf("event payload not preserved: %s", env.EventPayload)
}
}
func TestNormalizeWebhookPayload_PreservesArray(t *testing.T) {
body := []byte(`[{"a":1},{"b":2}]`)
env, err := normalizeWebhookPayload(body, http.Header{})
if err != nil {
t.Fatalf("normalize: %v", err)
}
if env.Event != "webhook.received" {
t.Fatalf("array event: %q", env.Event)
}
var arr []map[string]int
if err := json.Unmarshal(env.EventPayload, &arr); err != nil {
t.Fatalf("array not preserved: %v", err)
}
if len(arr) != 2 {
t.Fatalf("array length: %d", len(arr))
}
}
func TestNormalizeWebhookPayload_RejectsInvalidJSON(t *testing.T) {
if _, err := normalizeWebhookPayload([]byte(`not json`), http.Header{}); err == nil {
t.Fatal("expected error on invalid JSON")
}
}
func TestNormalizeWebhookPayload_RejectsScalarBody(t *testing.T) {
// Bare scalar JSON ("hello", 42) is not a useful webhook payload.
if _, err := normalizeWebhookPayload([]byte(`"hello"`), http.Header{}); err == nil {
t.Fatal("expected error on scalar JSON body")
}
}
func TestNormalizeWebhookPayload_GitHubHeaderWithoutAction(t *testing.T) {
body := []byte(`{"some":"thing"}`)
headers := http.Header{}
headers.Set("X-GitHub-Event", "push")
env, err := normalizeWebhookPayload(body, headers)
if err != nil {
t.Fatalf("normalize: %v", err)
}
if env.Event != "github.push" {
t.Fatalf("event: %q", env.Event)
}
}
func TestNormalizeWebhookPayload_XEventTypeHeader(t *testing.T) {
body := []byte(`{"a":1}`)
headers := http.Header{}
headers.Set("X-Event-Type", "custom.thing")
env, err := normalizeWebhookPayload(body, headers)
if err != nil {
t.Fatalf("normalize: %v", err)
}
if env.Event != "custom.thing" {
t.Fatalf("event: %q", env.Event)
}
}
// ── Event filter helpers ────────────────────────────────────────────────────
func TestWebhookEventAllowedByTriggerScope_NoFiltersAllowsAll(t *testing.T) {
if !webhookEventAllowedByTriggerScope(nil, WebhookEnvelope{Event: "github.push"}) {
t.Fatal("nil filters should allow all")
}
if !webhookEventAllowedByTriggerScope([]byte{}, WebhookEnvelope{Event: "github.push"}) {
t.Fatal("empty filters should allow all")
}
if !webhookEventAllowedByTriggerScope([]byte("[]"), WebhookEnvelope{Event: "github.push"}) {
t.Fatal("empty JSON array should allow all")
}
}
func TestWebhookEventAllowedByTriggerScope_FiltersUndeclaredEvent(t *testing.T) {
filters := []byte(`[{"event":"workflow_run","actions":["completed"]}]`)
env := WebhookEnvelope{Event: "github.push", EventPayload: json.RawMessage(`{"action":"pushed"}`)}
if webhookEventAllowedByTriggerScope(filters, env) {
t.Fatal("undeclared event should be filtered")
}
}
func TestWebhookEventAllowedByTriggerScope_FiltersUndeclaredAction(t *testing.T) {
filters := []byte(`[{"event":"workflow_run","actions":["completed"]}]`)
env := WebhookEnvelope{Event: "github.workflow_run.in_progress", EventPayload: json.RawMessage(`{"action":"in_progress"}`)}
if webhookEventAllowedByTriggerScope(filters, env) {
t.Fatal("undeclared action should be filtered")
}
}
func TestWebhookEventAllowedByTriggerScope_AllowsDeclaredAction(t *testing.T) {
filters := []byte(`[{"event":"workflow_run","actions":["completed"]}]`)
env := WebhookEnvelope{Event: "github.workflow_run.completed", EventPayload: json.RawMessage(`{"action":"completed"}`)}
if !webhookEventAllowedByTriggerScope(filters, env) {
t.Fatal("declared action should be allowed")
}
}
func TestWebhookEventAllowedByTriggerScope_AnyActionWhenEmpty(t *testing.T) {
filters := []byte(`[{"event":"workflow_run"}]`)
env := WebhookEnvelope{Event: "github.workflow_run.in_progress", EventPayload: json.RawMessage(`{"action":"in_progress"}`)}
if !webhookEventAllowedByTriggerScope(filters, env) {
t.Fatal("empty actions should allow any action for the event")
}
}
// TestWebhookEventAllowedByTriggerScope_MultipleFiltersSameEvent pins the
// fix for PR #3231 review: the matcher used to return false as soon as it
// hit the first event-name match whose actions didn't line up, which made
// later filters covering the same event but different actions unreachable
// (order-dependent silent drops). The fix is to keep scanning and only
// short-circuit on a positive match.
func TestWebhookEventAllowedByTriggerScope_MultipleFiltersSameEvent(t *testing.T) {
filters := []byte(`[
{"event":"workflow_run","actions":["completed"]},
{"event":"workflow_run","actions":["requested"]}
]`)
completed := WebhookEnvelope{
Event: "github.workflow_run.completed",
EventPayload: json.RawMessage(`{"action":"completed"}`),
}
if !webhookEventAllowedByTriggerScope(filters, completed) {
t.Fatal("workflow_run.completed should match the first filter")
}
requested := WebhookEnvelope{
Event: "github.workflow_run.requested",
EventPayload: json.RawMessage(`{"action":"requested"}`),
}
if !webhookEventAllowedByTriggerScope(filters, requested) {
t.Fatal("workflow_run.requested should match the second filter — pre-fix this silently dropped")
}
inProgress := WebhookEnvelope{
Event: "github.workflow_run.in_progress",
EventPayload: json.RawMessage(`{"action":"in_progress"}`),
}
if webhookEventAllowedByTriggerScope(filters, inProgress) {
t.Fatal("workflow_run.in_progress is in neither filter and should be filtered out")
}
}
// TestWebhookEventAllowedByTriggerScope_MalformedDenies pins the
// fail-closed behavior for corrupted rows. Strict write-time validation
// (validateWebhookEventFilters) is the primary defense; this is the
// defense-in-depth check for "what if a malformed row somehow exists".
func TestWebhookEventAllowedByTriggerScope_MalformedDenies(t *testing.T) {
corrupt := []byte(`{not a json array}`)
env := WebhookEnvelope{
Event: "github.workflow_run.completed",
EventPayload: json.RawMessage(`{"action":"completed"}`),
}
if webhookEventAllowedByTriggerScope(corrupt, env) {
t.Fatal("malformed event_filters must fail closed (deny), never widen the allowlist")
}
}
func TestWebhookEventAllowedByTriggerScope_MultipleFilters(t *testing.T) {
filters := []byte(`[{"event":"workflow_run","actions":["completed"]},{"event":"check_suite","actions":["completed","failure"]}]`)
allowed1 := WebhookEnvelope{Event: "github.check_suite.completed", EventPayload: json.RawMessage(`{"action":"completed"}`)}
if !webhookEventAllowedByTriggerScope(filters, allowed1) {
t.Fatal("check_suite.completed should be allowed")
}
allowed2 := WebhookEnvelope{Event: "github.check_suite.failure", EventPayload: json.RawMessage(`{"action":"failure"}`)}
if !webhookEventAllowedByTriggerScope(filters, allowed2) {
t.Fatal("check_suite.failure should be allowed")
}
filtered := WebhookEnvelope{Event: "github.check_suite.requested", EventPayload: json.RawMessage(`{"action":"requested"}`)}
if webhookEventAllowedByTriggerScope(filters, filtered) {
t.Fatal("check_suite.requested should be filtered")
}
}
func TestSplitWebhookEvent(t *testing.T) {
tests := []struct {
input string
wantProvider string
wantName string
wantAction string
}{
{"github.workflow_run.completed", "github", "workflow_run", "completed"},
{"github.push", "github", "push", ""},
{"gitlab.Merge Request Hook", "gitlab", "Merge Request Hook", ""},
{"webhook.received", "", "webhook", "received"},
{"custom", "", "custom", ""},
}
for _, tc := range tests {
p, n, a := splitWebhookEvent(tc.input)
if p != tc.wantProvider || n != tc.wantName || a != tc.wantAction {
t.Fatalf("splitWebhookEvent(%q) = (%q, %q, %q), want (%q, %q, %q)",
tc.input, p, n, a, tc.wantProvider, tc.wantName, tc.wantAction)
}
}
}