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)) }