Files
Cyrene_v2/plugins/channels/webhook/inbound_test.go
T

344 lines
10 KiB
Go

package webhook
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// recorder captures inbound messages delivered to a message handler.
type recorder struct {
mu sync.Mutex
msgs []adapter.UnifiedMessage
}
func (r *recorder) handle(_ context.Context, m adapter.UnifiedMessage) {
r.mu.Lock()
defer r.mu.Unlock()
r.msgs = append(r.msgs, m)
}
func (r *recorder) snapshot() []adapter.UnifiedMessage {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]adapter.UnifiedMessage, len(r.msgs))
copy(out, r.msgs)
return out
}
func (r *recorder) last() (adapter.UnifiedMessage, bool) {
msgs := r.snapshot()
if len(msgs) == 0 {
return adapter.UnifiedMessage{}, false
}
return msgs[len(msgs)-1], true
}
// recAdapter returns an adapter with a recording handler installed and a
// connected lifecycle state.
func recAdapter() (*Adapter, *recorder) {
rec := &recorder{}
a := New()
a.SetMessageHandler(rec.handle)
_ = a.Connect(context.Background())
return a, rec
}
func TestInboundMethodNotAllowed(t *testing.T) {
t.Parallel()
a := New()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/webhook", nil)
a.InboundHandler().ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", rec.Code)
}
}
func TestInboundJSONDirect(t *testing.T) {
t.Parallel()
a, rec := recAdapter()
body := `{"messageId":"m-1","senderId":"u-1","senderName":"Alice","content":"hello","contentType":"text","channelType":"direct"}`
rec2 := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
a.InboundHandler().ServeHTTP(rec2, req)
if rec2.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec2.Code, rec2.Body.String())
}
msg, ok := rec.last()
if !ok {
t.Fatal("no message delivered")
}
if msg.Platform != "webhook" {
t.Errorf("Platform = %q, want webhook", msg.Platform)
}
if msg.Content != "hello" {
t.Errorf("Content = %q, want hello", msg.Content)
}
if msg.SenderID != "u-1" || msg.SenderName != "Alice" {
t.Errorf("sender = %q/%q, want u-1/Alice", msg.SenderID, msg.SenderName)
}
if msg.ChannelType != adapter.ChannelDirect {
t.Errorf("ChannelType = %q, want direct", msg.ChannelType)
}
if msg.ContentType != adapter.ContentText {
t.Errorf("ContentType = %q, want text", msg.ContentType)
}
if msg.Sender.Role != adapter.RoleUnknown {
t.Errorf("Sender.Role = %q, want unknown", msg.Sender.Role)
}
if err := msg.Validate(); err != nil {
t.Errorf("Validate() error = %v", err)
}
}
func TestInboundJSONGroupWithMedia(t *testing.T) {
t.Parallel()
a, rec := recAdapter()
body := `{
"messageId":"m-2","senderId":"u-9","senderName":"Bob","content":"pic",
"contentType":"image","channelType":"group","groupId":"g-1","groupName":"Team",
"media":[{"kind":"image","url":"https://cdn.example/a.png","name":"a.png","size":1024,"mimeType":"image/png"}]
}`
rec2 := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
a.InboundHandler().ServeHTTP(rec2, req)
if rec2.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec2.Code, rec2.Body.String())
}
msg, ok := rec.last()
if !ok {
t.Fatal("no message delivered")
}
if msg.ChannelType != adapter.ChannelGroup {
t.Errorf("ChannelType = %q, want group", msg.ChannelType)
}
if msg.GroupID != "g-1" || msg.GroupName != "Team" {
t.Errorf("group = %q/%q, want g-1/Team", msg.GroupID, msg.GroupName)
}
if msg.ChannelID != "g-1" {
t.Errorf("ChannelID = %q, want g-1 (group channel)", msg.ChannelID)
}
if !msg.Sender.IsGroupChat() {
t.Error("Sender.IsGroupChat() = false, want true")
}
if len(msg.Media) != 1 {
t.Fatalf("Media len = %d, want 1", len(msg.Media))
}
if msg.Media[0].URL != "https://cdn.example/a.png" {
t.Errorf("Media[0].URL = %q, want cdn url", msg.Media[0].URL)
}
if err := msg.Validate(); err != nil {
t.Errorf("Validate() error = %v", err)
}
}
func TestInboundFormDirect(t *testing.T) {
t.Parallel()
a, rec := recAdapter()
body := "messageId=m-3&senderId=u-2&senderName=Carol&content=hey&contentType=text&channelType=direct"
rec2 := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
a.InboundHandler().ServeHTTP(rec2, req)
if rec2.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec2.Code, rec2.Body.String())
}
msg, ok := rec.last()
if !ok {
t.Fatal("no message delivered")
}
if msg.Content != "hey" || msg.SenderID != "u-2" || msg.SenderName != "Carol" {
t.Errorf("msg = %+v, want content=hey sender=u-2/Carol", msg)
}
if msg.ContentType != adapter.ContentText || msg.ChannelType != adapter.ChannelDirect {
t.Errorf("type = %q/%q, want text/direct", msg.ContentType, msg.ChannelType)
}
}
func TestInboundFormGroupWithMediaURL(t *testing.T) {
t.Parallel()
a, rec := recAdapter()
body := "channelType=group&groupId=g-7&senderId=u-4&content=clip&contentType=image&mediaUrl=https://cdn.example/b.png&mediaKind=image&mediaName=b.png"
rec2 := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
a.InboundHandler().ServeHTTP(rec2, req)
if rec2.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec2.Code, rec2.Body.String())
}
msg, ok := rec.last()
if !ok {
t.Fatal("no message delivered")
}
if msg.ChannelType != adapter.ChannelGroup || msg.GroupID != "g-7" {
t.Errorf("channel = %q/%q, want group/g-7", msg.ChannelType, msg.GroupID)
}
if len(msg.Media) != 1 || msg.Media[0].URL != "https://cdn.example/b.png" {
t.Errorf("Media = %+v, want single url media", msg.Media)
}
}
func TestInboundSignatureValid(t *testing.T) {
t.Parallel()
secret := "shared-secret"
a := New(WithConfig(Config{Secret: secret}))
rec := &recorder{}
a.SetMessageHandler(rec.handle)
body := []byte(`{"content":"signed"}`)
rec2 := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(defaultSignatureHeader, signBody(secret, body))
a.InboundHandler().ServeHTTP(rec2, req)
if rec2.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec2.Code, rec2.Body.String())
}
if _, ok := rec.last(); !ok {
t.Fatal("no message delivered")
}
}
func TestInboundSignatureMissing(t *testing.T) {
t.Parallel()
a := New(WithConfig(Config{Secret: "shared-secret"}))
rec := &recorder{}
a.SetMessageHandler(rec.handle)
rec2 := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(`{"content":"x"}`))
req.Header.Set("Content-Type", "application/json")
a.InboundHandler().ServeHTTP(rec2, req)
if rec2.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec2.Code)
}
if _, ok := rec.last(); ok {
t.Error("message delivered despite missing signature")
}
}
func TestInboundSignatureMismatch(t *testing.T) {
t.Parallel()
a := New(WithConfig(Config{Secret: "shared-secret"}))
rec := &recorder{}
a.SetMessageHandler(rec.handle)
body := []byte(`{"content":"x"}`)
rec2 := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(defaultSignatureHeader, "deadbeef")
a.InboundHandler().ServeHTTP(rec2, req)
if rec2.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec2.Code)
}
}
func TestInboundBadJSON(t *testing.T) {
t.Parallel()
a := New()
rec2 := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader("{not-json"))
req.Header.Set("Content-Type", "application/json")
a.InboundHandler().ServeHTTP(rec2, req)
if rec2.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec2.Code)
}
}
func TestInboundUnsupportedContentType(t *testing.T) {
t.Parallel()
a := New()
rec2 := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader("hello"))
req.Header.Set("Content-Type", "text/plain")
a.InboundHandler().ServeHTTP(rec2, req)
if rec2.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec2.Code)
}
}
func TestInboundGroupWithoutGroupID(t *testing.T) {
t.Parallel()
a := New()
rec2 := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(`{"channelType":"group","content":"hi"}`))
req.Header.Set("Content-Type", "application/json")
a.InboundHandler().ServeHTTP(rec2, req)
if rec2.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec2.Code)
}
}
func TestInboundNoHandlerDelivers503(t *testing.T) {
t.Parallel()
a := New()
rec2 := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(`{"content":"hi"}`))
req.Header.Set("Content-Type", "application/json")
a.InboundHandler().ServeHTTP(rec2, req)
if rec2.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503", rec2.Code)
}
}
func TestInboundAcceptsCharsetInContentType(t *testing.T) {
t.Parallel()
a, rec := recAdapter()
rec2 := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(`{"content":"ok"}`))
req.Header.Set("Content-Type", "application/json; charset=utf-8")
a.InboundHandler().ServeHTTP(rec2, req)
if rec2.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec2.Code, rec2.Body.String())
}
if _, ok := rec.last(); !ok {
t.Fatal("no message delivered")
}
}
// signBody computes the HMAC-SHA256 hex signature over body with secret.
func signBody(secret string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}