344 lines
10 KiB
Go
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))
|
|
}
|