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: *") } }