Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions control-plane/internal/handlers/triggers_provider_ingest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package handlers

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/Agent-Field/agentfield/control-plane/pkg/types"
"github.com/stretchr/testify/require"

_ "github.com/Agent-Field/agentfield/control-plane/internal/sources/all"
)

func TestIngestProviderWebhookFixtures_LinearAndSentry(t *testing.T) {
provider, _, ctx := setupAPIContractTestEnv(t)
h := NewTriggerHandlers(provider, nil, nil)
r := triggerCoverageRouter(h)

tests := []struct {
name string
sourceName string
triggerID string
secretEnv string
secret string
config json.RawMessage
body []byte
headers map[string]string
expectedType string
expectedIdempotent string
normalizedContains []string
}{
{
name: "linear issue create",
sourceName: "linear",
triggerID: "linear-ingest-fixture",
secretEnv: "LINEAR_FIXTURE_SECRET",
secret: "linear-fixture-secret",
config: json.RawMessage(`{"tolerance_seconds":60}`),
body: []byte(fmt.Sprintf(
`{"action":"create","type":"Issue","createdAt":"2026-06-15T12:00:00Z","webhookTimestamp":%d,"webhookId":"linear-hook-1","data":{"id":"issue-1","identifier":"AF-1"}}`,
time.Now().UnixMilli(),
)),
headers: map[string]string{
"Linear-Delivery": "linear-delivery-1",
"Linear-Event": "Issue",
},
expectedType: "issue.create",
expectedIdempotent: "linear-delivery-1",
normalizedContains: []string{
`"delivery":"linear-delivery-1"`,
`"identifier":"AF-1"`,
},
},
{
name: "sentry issue created",
sourceName: "sentry",
triggerID: "sentry-ingest-fixture",
secretEnv: "SENTRY_FIXTURE_SECRET",
secret: "sentry-fixture-secret",
config: json.RawMessage(`{}`),
body: []byte(`{"action":"created","installation":{"uuid":"inst-1"},"data":{"issue":{"id":"123","shortId":"ORG-1"}},"actor":{"type":"user","name":"Ada"}}`),
headers: map[string]string{
"Request-ID": "sentry-request-1",
"Sentry-Hook-Resource": "issue",
"Sentry-Hook-Timestamp": time.Now().UTC().Format(time.RFC3339),
"Sentry-Hook-Signature": "",
"X-Unused-Provider-Test": "kept",
},
expectedType: "issue.created",
expectedIdempotent: "sentry-request-1",
normalizedContains: []string{
`"request_id":"sentry-request-1"`,
`"shortId":"ORG-1"`,
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(tt.secretEnv, tt.secret)
trig := &types.Trigger{
ID: tt.triggerID,
SourceName: tt.sourceName,
Config: tt.config,
SecretEnvVar: tt.secretEnv,
TargetNodeID: "provider-target",
TargetReasoner: "handle_provider_event",
ManagedBy: types.ManagedByUI,
Enabled: true,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
require.NoError(t, provider.CreateTrigger(ctx, trig))

req := httptest.NewRequest(http.MethodPost, "/sources/"+tt.triggerID, strings.NewReader(string(tt.body)))
req.Header.Set("Content-Type", "application/json")
for key, value := range tt.headers {
if value != "" {
req.Header.Set(key, value)
}
}
switch tt.sourceName {
case "linear":
req.Header.Set("Linear-Signature", signProviderFixture(tt.body, tt.secret))
case "sentry":
req.Header.Set("Sentry-Hook-Signature", signProviderFixture(tt.body, tt.secret))
}

w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equalf(t, http.StatusOK, w.Code, "body=%s", w.Body.String())

var resp struct {
Status string `json:"status"`
Received int `json:"received"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.Equal(t, "ok", resp.Status)
require.Equal(t, 1, resp.Received)

events, err := provider.ListInboundEvents(ctx, tt.triggerID, 10)
require.NoError(t, err)
require.Len(t, events, 1)
require.Equal(t, tt.sourceName, events[0].SourceName)
require.Equal(t, tt.expectedType, events[0].EventType)
require.Equal(t, tt.expectedIdempotent, events[0].IdempotencyKey)
for _, want := range tt.normalizedContains {
require.Contains(t, string(events[0].NormalizedPayload), want)
}
})
}
}

func signProviderFixture(body []byte, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}
2 changes: 2 additions & 0 deletions control-plane/internal/sources/all/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
_ "github.com/Agent-Field/agentfield/control-plane/internal/sources/genericbearer"
_ "github.com/Agent-Field/agentfield/control-plane/internal/sources/generichmac"
_ "github.com/Agent-Field/agentfield/control-plane/internal/sources/github"
_ "github.com/Agent-Field/agentfield/control-plane/internal/sources/linear"
_ "github.com/Agent-Field/agentfield/control-plane/internal/sources/sentry"
_ "github.com/Agent-Field/agentfield/control-plane/internal/sources/slack"
_ "github.com/Agent-Field/agentfield/control-plane/internal/sources/snowflake"
_ "github.com/Agent-Field/agentfield/control-plane/internal/sources/stripe"
Expand Down
189 changes: 189 additions & 0 deletions control-plane/internal/sources/linear/linear.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Package linear implements the Linear webhook Source.
//
// Linear signs deliveries with HMAC-SHA256 over the raw body using the webhook
// signing secret. The hex digest is sent in Linear-Signature, the entity family
// in Linear-Event, and a unique delivery UUID in Linear-Delivery.
package linear

import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"

"github.com/Agent-Field/agentfield/control-plane/internal/sources"
)

const defaultToleranceSeconds = 60

type source struct{}

func init() {
sources.Register(&source{})
}

func (s *source) Name() string { return "linear" }
func (s *source) Kind() sources.Kind { return sources.KindHTTP }
func (s *source) SecretRequired() bool { return true }

func (s *source) ConfigSchema() json.RawMessage {
return json.RawMessage(`{
"type":"object",
"properties":{
"tolerance_seconds":{"type":"integer","minimum":0,"default":60,"description":"Max age of Linear webhookTimestamp before rejection"}
},
"additionalProperties": false
}`)
}

func (s *source) Validate(cfg json.RawMessage) error {
if len(cfg) == 0 {
return nil
}
var parsed struct {
ToleranceSeconds *int `json:"tolerance_seconds"`
}
if err := json.Unmarshal(cfg, &parsed); err != nil {
return fmt.Errorf("invalid linear config: %w", err)
}
if parsed.ToleranceSeconds != nil && *parsed.ToleranceSeconds < 0 {
return errors.New("tolerance_seconds must be >= 0")
}
return nil
}

func (s *source) HandleRequest(ctx context.Context, req *sources.RawRequest, cfg json.RawMessage, secret string) ([]sources.Event, error) {
if secret == "" {
return nil, errors.New("linear: missing webhook signing secret")
}
signature := req.Headers.Get("Linear-Signature")
if signature == "" {
return nil, errors.New("linear: missing Linear-Signature header")
}
if err := verifySignature(req.Body, signature, secret); err != nil {
return nil, err
}

var payload struct {
Action string `json:"action"`
Type string `json:"type"`
Actor json.RawMessage `json:"actor"`
CreatedAt string `json:"createdAt"`
Data json.RawMessage `json:"data"`
URL string `json:"url"`
UpdatedFrom json.RawMessage `json:"updatedFrom"`
WebhookID string `json:"webhookId"`
WebhookTimestamp int64 `json:"webhookTimestamp"`
}
if err := json.Unmarshal(req.Body, &payload); err != nil {
return nil, fmt.Errorf("linear: invalid event JSON: %w", err)
}

tolerance := defaultToleranceSeconds
if len(cfg) > 0 {
var parsed struct {
ToleranceSeconds *int `json:"tolerance_seconds"`
}
if err := json.Unmarshal(cfg, &parsed); err == nil && parsed.ToleranceSeconds != nil {
tolerance = *parsed.ToleranceSeconds
}
}
if tolerance > 0 {
if payload.WebhookTimestamp == 0 {
return nil, errors.New("linear: missing webhookTimestamp")
}
sentAt := time.UnixMilli(payload.WebhookTimestamp)
if diff := time.Since(sentAt); diff > time.Duration(tolerance)*time.Second || diff < -time.Duration(tolerance)*time.Second {
return nil, errors.New("linear: webhookTimestamp outside tolerance window")
}
}

eventType := normalizedEventType(firstNonBlank(payload.Type, req.Headers.Get("Linear-Event")), payload.Action)
normalized, _ := json.Marshal(map[string]any{
"action": payload.Action,
"type": payload.Type,
"actor": rawOrNil(payload.Actor),
"created_at": payload.CreatedAt,
"data": rawOrNil(payload.Data),
"url": payload.URL,
"updated_from": rawOrNil(payload.UpdatedFrom),
"webhook_id": payload.WebhookID,
"webhook_timestamp": payload.WebhookTimestamp,
"linear": map[string]string{
"delivery": req.Headers.Get("Linear-Delivery"),
"event": req.Headers.Get("Linear-Event"),
},
})

// Determine idempotency key: use Linear-Delivery if present, otherwise compute hash
idempotencyKey := req.Headers.Get("Linear-Delivery")
if idempotencyKey == "" {
idempotencyKey = deliveryHash(payload.WebhookID, payload.WebhookTimestamp, payload.Type, payload.Action)
}

return []sources.Event{{
Type: eventType,
IdempotencyKey: idempotencyKey,
Raw: req.Body,
Normalized: normalized,
}}, nil
}

func verifySignature(body []byte, header, secret string) error {
got, err := hex.DecodeString(strings.TrimSpace(header))
if err != nil {
return fmt.Errorf("linear: invalid Linear-Signature header: %w", err)
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
if !hmac.Equal(got, mac.Sum(nil)) {
return errors.New("linear: signature mismatch")
}
return nil
}

func deliveryHash(webhookID string, ts int64, entityType, action string) string {
h := sha256.New()
h.Write([]byte(webhookID))
h.Write([]byte{0})
h.Write([]byte(strconv.FormatInt(ts, 10)))
h.Write([]byte{0})
h.Write([]byte(entityType))
h.Write([]byte{0})
h.Write([]byte(action))
return hex.EncodeToString(h.Sum(nil))
}

func normalizedEventType(entityType, action string) string {
entityType = strings.ToLower(strings.TrimSpace(entityType))
action = strings.ToLower(strings.TrimSpace(action))
if entityType == "" {
return action
}
if action == "" {
return entityType
}
return entityType + "." + action
}

func rawOrNil(raw json.RawMessage) any {
if len(raw) == 0 {
return nil
}
return raw
}

func firstNonBlank(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
Loading
Loading