diff --git a/app/webrtc/whip_handler_test.go b/app/webrtc/whip_handler_test.go new file mode 100644 index 0000000..d3b3482 --- /dev/null +++ b/app/webrtc/whip_handler_test.go @@ -0,0 +1,213 @@ +package webrtc + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v4" +) + +// 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_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_Publish_CORSHeadersPresent verifies that every Publish +// response (even a 404) carries the CORS headers required for cross-origin +// browser-based publishers (e.g., a browser-based OBS alternative). +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: *") + } +}