datarhei-dragonfork-core/app/webrtc/whip_handler_test.go
ZGaetano 278ebaa087
Some checks failed
ci / race tests (push) Blocked by required conditions
ci / WebRTC smoke (5-viewer fanout) (push) Blocked by required conditions
ci / WebRTC latency p95 gate (push) Blocked by required conditions
ci / vet + build (push) Has been cancelled
webrtc: add 409 single-publisher enforcement test and SetMetrics/PublisherCount tests (issues #22, #26)
2026-05-10 21:08:41 -04:00

278 lines
9 KiB
Go

package webrtc
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/labstack/echo/v4"
corewebrtc "github.com/datarhei/core/v16/core/webrtc"
)
// TestWHIPHandler_Publish_404WhenNoIngest verifies POST /whip/:id returns
// 404 when no process has registered a WHIP ingest for that id.
func TestWHIPHandler_Publish_404WhenNoIngest(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/whip/ghost", strings.NewReader("v=0\r\n"))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("ghost")
if err := h.Publish(c); err != nil {
t.Fatalf("Publish returned error: %v", err)
}
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestWHIPHandler_Publish_400OnEmptyBody verifies that an empty SDP body
// is rejected before any peer negotiation. A dummy ingest is registered so
// the handler reaches body validation.
func TestWHIPHandler_Publish_400OnEmptyBody(t *testing.T) {
sub := newTestSubsystem(t)
sub.mu.Lock()
sub.whipIngests["probe"] = &ingestStream{id: "probe", videoPort: 5100, audioPort: 5101}
sub.mu.Unlock()
h := NewWHIPHandler(sub, 0)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/whip/probe", strings.NewReader(""))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("probe")
if err := h.Publish(c); err != nil {
t.Fatalf("Publish returned error: %v", err)
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestWHIPHandler_Publish_400OnNonSDP verifies that a body which doesn't
// start with "v=" is rejected as an invalid SDP.
func TestWHIPHandler_Publish_400OnNonSDP(t *testing.T) {
sub := newTestSubsystem(t)
sub.mu.Lock()
sub.whipIngests["probe"] = &ingestStream{id: "probe", videoPort: 5102, audioPort: 5103}
sub.mu.Unlock()
h := NewWHIPHandler(sub, 0)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/whip/probe",
strings.NewReader("not-an-sdp-body"))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("probe")
if err := h.Publish(c); err != nil {
t.Fatalf("Publish returned error: %v", err)
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestWHIPHandler_Publish_409OnSecondPublisher verifies that attempting to
// publish a second time on the same stream while a publisher is already
// active returns 409 Conflict, not 201, and does not increment the counter.
func TestWHIPHandler_Publish_409OnSecondPublisher(t *testing.T) {
sub := newTestSubsystem(t)
sub.mu.Lock()
sub.whipIngests["probe"] = &ingestStream{id: "probe", videoPort: 5104, audioPort: 5105}
sub.mu.Unlock()
h := NewWHIPHandler(sub, 0)
// Inject a fake active publisher directly into the handler's index.
// We use a nil *IngestPeer because the 409 check only tests map length
// and never dereferences the peer pointer.
h.mu.Lock()
h.ingestByStream["probe"] = map[string]*corewebrtc.IngestPeer{
"existing-rid": nil,
}
h.ingestStream["existing-rid"] = "probe"
h.mu.Unlock()
// Verify initial count is 0 (the fake was injected, not published).
if c := h.PublisherCount(); c != 0 {
t.Fatalf("expected initial count 0, got %d", c)
}
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/whip/probe",
strings.NewReader("v=0\r\nm=video 0 RTP/AVP 96\r\n"))
rec := httptest.NewRecorder()
ctx := e.NewContext(req, rec)
ctx.SetParamNames("id")
ctx.SetParamValues("probe")
if err := h.Publish(ctx); err != nil {
t.Fatalf("Publish returned error: %v", err)
}
if rec.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d: %s", rec.Code, rec.Body.String())
}
// Count must not have incremented on the rejected request.
if c := h.PublisherCount(); c != 0 {
t.Errorf("expected count still 0 after 409, got %d", c)
}
}
// TestWHIPHandler_Unpublish_204WhenUnknown verifies DELETE returns 204
// even for unknown resource ids — idempotent per the WHIP spec.
func TestWHIPHandler_Unpublish_204WhenUnknown(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodDelete, "/whip/id/unknown-resource", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "resource")
c.SetParamValues("id", "unknown-resource")
if err := h.Unpublish(c); err != nil {
t.Fatalf("Unpublish returned error: %v", err)
}
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d", rec.Code)
}
}
// TestWHIPHandler_Unpublish_400OnMissingResource verifies DELETE without
// a resource id param returns 400.
func TestWHIPHandler_Unpublish_400OnMissingResource(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodDelete, "/whip/id/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "resource")
c.SetParamValues("id", "")
if err := h.Unpublish(c); err != nil {
t.Fatalf("Unpublish returned error: %v", err)
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
// TestWHIPHandler_TrickleIngest_404WhenPeerUnknown verifies PATCH returns
// 404 when there is no peer registered for the resource id.
func TestWHIPHandler_TrickleIngest_404WhenPeerUnknown(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodPatch, "/whip/id/ghost",
strings.NewReader("a=candidate:1 1 UDP 2122252543 192.168.1.1 12345 typ host\r\n"))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "resource")
c.SetParamValues("id", "ghost")
if err := h.TrickleIngest(c); err != nil {
t.Fatalf("TrickleIngest returned error: %v", err)
}
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
// TestWHIPHandler_TrickleIngest_400OnMissingResource verifies PATCH
// without a resource id returns 400.
func TestWHIPHandler_TrickleIngest_400OnMissingResource(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodPatch, "/whip/id/",
strings.NewReader("a=candidate:1 1 UDP 2122252543 192.168.1.1 12345 typ host\r\n"))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "resource")
c.SetParamValues("id", "")
if err := h.TrickleIngest(c); err != nil {
t.Fatalf("TrickleIngest returned error: %v", err)
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
// TestWHIPHandler_Preflight_ExposesLinkHeader verifies that CORS preflight
// responses include "Link" in Access-Control-Expose-Headers so browsers
// can read the RFC 9261 §5.2 Link headers on the 201 Publish response.
func TestWHIPHandler_Preflight_ExposesLinkHeader(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodOptions, "/whip/any", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("any")
if err := h.preflight(c); err != nil {
t.Fatalf("preflight returned error: %v", err)
}
expose := rec.Header().Get("Access-Control-Expose-Headers")
if !strings.Contains(expose, "Link") {
t.Errorf("Access-Control-Expose-Headers %q does not contain 'Link'", expose)
}
if !strings.Contains(expose, "Location") {
t.Errorf("Access-Control-Expose-Headers %q does not contain 'Location'", expose)
}
if !strings.Contains(expose, "ETag") {
t.Errorf("Access-Control-Expose-Headers %q does not contain 'ETag'", expose)
}
}
// TestWHIPHandler_SetMetrics_DoesNotPanic verifies that SetMetrics accepts
// a nil argument without panicking (nil-safe guard for wiring code).
func TestWHIPHandler_SetMetrics_DoesNotPanic(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
// nil metrics is explicitly allowed — recordRequest guards on h.met == nil.
h.SetMetrics(nil)
}
// TestWHIPHandler_PublisherCount_ZeroOnEmpty verifies that a freshly
// constructed handler reports 0 active publishers.
func TestWHIPHandler_PublisherCount_ZeroOnEmpty(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
if n := h.PublisherCount(); n != 0 {
t.Errorf("expected 0 publishers on empty handler, got %d", n)
}
}
// TestWHIPHandler_Publish_CORSHeadersPresent verifies that every Publish
// response (even a 404) carries the CORS headers required for cross-origin
// browser-based publishers.
func TestWHIPHandler_Publish_CORSHeadersPresent(t *testing.T) {
h := NewWHIPHandler(newTestSubsystem(t), 0)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/whip/ghost", strings.NewReader("v=0\r\n"))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("ghost")
_ = h.Publish(c)
if rec.Header().Get("Access-Control-Allow-Origin") != "*" {
t.Error("expected Access-Control-Allow-Origin: *")
}
}