893 lines
27 KiB
Go
893 lines
27 KiB
Go
// Copyright (c) 2026 Winlin
|
|
//
|
|
// SPDX-License-Identifier: MIT
|
|
package proxy
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"srsx/internal/env/envfakes"
|
|
"srsx/internal/lb/lbfakes"
|
|
)
|
|
|
|
// fakeWebRTCProxyServer is a minimal in-package WebRTCProxyServer used by
|
|
// httpAPIProxyServer tests. Only the WHIP/WHEP handler methods are exercised.
|
|
// Run/Close are inert stubs so the type satisfies the interface.
|
|
type fakeWebRTCProxyServer struct {
|
|
whipCalls atomic.Int32
|
|
whepCalls atomic.Int32
|
|
whipReturn error
|
|
whepReturn error
|
|
whipResponseBody string
|
|
whepResponseBody string
|
|
}
|
|
|
|
func (f *fakeWebRTCProxyServer) Run(ctx context.Context) error { return nil }
|
|
func (f *fakeWebRTCProxyServer) Close() error { return nil }
|
|
func (f *fakeWebRTCProxyServer) HandleApiForWHIP(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
|
|
f.whipCalls.Add(1)
|
|
if f.whipResponseBody != "" {
|
|
w.WriteHeader(http.StatusOK)
|
|
io.WriteString(w, f.whipResponseBody)
|
|
}
|
|
return f.whipReturn
|
|
}
|
|
func (f *fakeWebRTCProxyServer) HandleApiForWHEP(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
|
|
f.whepCalls.Add(1)
|
|
if f.whepResponseBody != "" {
|
|
w.WriteHeader(http.StatusOK)
|
|
io.WriteString(w, f.whepResponseBody)
|
|
}
|
|
return f.whepReturn
|
|
}
|
|
|
|
// captureMuxFromHTTPAPIRun drives NewHTTPAPIProxyServer.Run with a fake server
|
|
// that captures the registered mux. Caller is responsible for cancelling ctx
|
|
// to trigger shutdown.
|
|
func captureMuxFromHTTPAPIRun(t *testing.T, env *envfakes.FakeProxyEnvironment,
|
|
rtc WebRTCProxyServer, ctx context.Context,
|
|
opts ...func(*httpAPIProxyServer)) (*http.ServeMux, *fakeHTTPProxyServer, *httpAPIProxyServer) {
|
|
t.Helper()
|
|
|
|
fakeSrv := newFakeHTTPProxyServer()
|
|
var capturedMux *http.ServeMux
|
|
|
|
baseOpts := []func(*httpAPIProxyServer){
|
|
func(s *httpAPIProxyServer) {
|
|
s.newServer = func(addr string) (httpServer, *http.ServeMux) {
|
|
mux := http.NewServeMux()
|
|
capturedMux = mux
|
|
return fakeSrv, mux
|
|
}
|
|
},
|
|
}
|
|
srvIface := NewHTTPAPIProxyServer(env, 50*time.Millisecond, rtc, append(baseOpts, opts...)...)
|
|
srv := srvIface.(*httpAPIProxyServer)
|
|
|
|
if err := srv.Run(ctx); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if capturedMux == nil {
|
|
t.Fatal("newServer was not called by Run")
|
|
}
|
|
return capturedMux, fakeSrv, srv
|
|
}
|
|
|
|
// captureMuxFromSystemAPIRun drives NewSystemAPI.Run with a fake server that
|
|
// captures the registered mux. Caller cancels ctx to trigger shutdown.
|
|
func captureMuxFromSystemAPIRun(t *testing.T, env *envfakes.FakeProxyEnvironment,
|
|
lbFake *lbfakes.FakeOriginLoadBalancer, ctx context.Context,
|
|
opts ...func(*systemAPI)) (*http.ServeMux, *fakeHTTPProxyServer, *systemAPI) {
|
|
t.Helper()
|
|
|
|
fakeSrv := newFakeHTTPProxyServer()
|
|
var capturedMux *http.ServeMux
|
|
|
|
baseOpts := []func(*systemAPI){
|
|
func(s *systemAPI) {
|
|
s.newServer = func(addr string) (httpServer, *http.ServeMux) {
|
|
mux := http.NewServeMux()
|
|
capturedMux = mux
|
|
return fakeSrv, mux
|
|
}
|
|
},
|
|
}
|
|
srv := NewSystemAPI(env, lbFake, 50*time.Millisecond, append(baseOpts, opts...)...)
|
|
|
|
if err := srv.Run(ctx); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if capturedMux == nil {
|
|
t.Fatal("newServer was not called by Run")
|
|
}
|
|
return capturedMux, fakeSrv, srv
|
|
}
|
|
|
|
// =============================================================================
|
|
// NewHTTPAPIProxyServer
|
|
// =============================================================================
|
|
|
|
func TestHTTPAPIProxyServer_New_StoresFieldsAndDefaultsSeams(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
rtc := &fakeWebRTCProxyServer{}
|
|
timeout := 2 * time.Second
|
|
srv := NewHTTPAPIProxyServer(env, timeout, rtc).(*httpAPIProxyServer)
|
|
|
|
if srv.environment != env {
|
|
t.Error("environment not stored")
|
|
}
|
|
if srv.rtc != rtc {
|
|
t.Error("rtc not stored")
|
|
}
|
|
if srv.gracefulQuitTimeout != timeout {
|
|
t.Errorf("gracefulQuitTimeout = %v, want %v", srv.gracefulQuitTimeout, timeout)
|
|
}
|
|
if srv.shutdown == nil {
|
|
t.Error("shutdown seam should default to non-nil")
|
|
}
|
|
if srv.newServer == nil {
|
|
t.Error("newServer seam should default to non-nil")
|
|
}
|
|
}
|
|
|
|
func TestHTTPAPIProxyServer_New_AppliesOpts(t *testing.T) {
|
|
var called bool
|
|
srv := NewHTTPAPIProxyServer(&envfakes.FakeProxyEnvironment{}, time.Second,
|
|
&fakeWebRTCProxyServer{},
|
|
func(s *httpAPIProxyServer) { called = true }).(*httpAPIProxyServer)
|
|
if !called {
|
|
t.Fatal("opt was not invoked")
|
|
}
|
|
if srv.shutdown == nil {
|
|
t.Error("default seams should still be set when opt doesn't override them")
|
|
}
|
|
}
|
|
|
|
func TestHTTPAPIProxyServer_New_OptCanOverrideAllSeams(t *testing.T) {
|
|
customShutdown := func(context.Context) error { return errors.New("custom") }
|
|
customNewServer := func(string) (httpServer, *http.ServeMux) { return nil, nil }
|
|
|
|
srv := NewHTTPAPIProxyServer(&envfakes.FakeProxyEnvironment{}, time.Second,
|
|
&fakeWebRTCProxyServer{},
|
|
func(s *httpAPIProxyServer) {
|
|
s.shutdown = customShutdown
|
|
s.newServer = customNewServer
|
|
}).(*httpAPIProxyServer)
|
|
|
|
if err := srv.shutdown(context.Background()); err == nil || err.Error() != "custom" {
|
|
t.Errorf("custom shutdown not applied: %v", err)
|
|
}
|
|
// Pointer comparison on func values isn't supported by ==; call the value
|
|
// and observe the override via behavior.
|
|
if got, _ := srv.newServer(""); got != nil {
|
|
t.Error("custom newServer not applied")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// httpAPIProxyServer — default factory behavior
|
|
// =============================================================================
|
|
|
|
func TestHTTPAPIProxyServer_DefaultNewServer_BuildsRealServerAndMux(t *testing.T) {
|
|
srv := NewHTTPAPIProxyServer(&envfakes.FakeProxyEnvironment{}, time.Second,
|
|
&fakeWebRTCProxyServer{}).(*httpAPIProxyServer)
|
|
|
|
got, mux := srv.newServer(":12321")
|
|
if mux == nil {
|
|
t.Fatal("mux is nil")
|
|
}
|
|
real, ok := got.(*http.Server)
|
|
if !ok {
|
|
t.Fatalf("expected *http.Server, got %T", got)
|
|
}
|
|
if real.Addr != ":12321" {
|
|
t.Errorf("Addr = %q, want :12321", real.Addr)
|
|
}
|
|
if real.Handler != mux {
|
|
t.Error("Handler should be the returned mux")
|
|
}
|
|
}
|
|
|
|
func TestHTTPAPIProxyServer_DefaultShutdown_DelegatesToServer(t *testing.T) {
|
|
fakeSrv := newFakeHTTPProxyServer()
|
|
srv := NewHTTPAPIProxyServer(&envfakes.FakeProxyEnvironment{}, time.Second,
|
|
&fakeWebRTCProxyServer{}).(*httpAPIProxyServer)
|
|
srv.server = fakeSrv // simulate what Run() would assign
|
|
|
|
if err := srv.shutdown(context.Background()); err != nil {
|
|
t.Fatalf("shutdown: %v", err)
|
|
}
|
|
if fakeSrv.shutdownCalls.Load() != 1 {
|
|
t.Fatalf("shutdown was not delegated to server, calls=%d", fakeSrv.shutdownCalls.Load())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// httpAPIProxyServer — Close
|
|
// =============================================================================
|
|
|
|
func TestHTTPAPIProxyServer_Close_InvokesShutdownWithDeadline(t *testing.T) {
|
|
var gotCtx context.Context
|
|
var calls int
|
|
srv := NewHTTPAPIProxyServer(&envfakes.FakeProxyEnvironment{}, 50*time.Millisecond,
|
|
&fakeWebRTCProxyServer{},
|
|
func(s *httpAPIProxyServer) {
|
|
s.shutdown = func(ctx context.Context) error {
|
|
gotCtx = ctx
|
|
calls++
|
|
return nil
|
|
}
|
|
}).(*httpAPIProxyServer)
|
|
|
|
if err := srv.Close(); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("shutdown calls = %d, want 1", calls)
|
|
}
|
|
if _, ok := gotCtx.Deadline(); !ok {
|
|
t.Error("Close should pass a deadline-bearing ctx to shutdown")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// httpAPIProxyServer — Run lifecycle
|
|
// =============================================================================
|
|
|
|
func TestHTTPAPIProxyServer_Run_AddrWithoutColonPrependsIt(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.HttpAPIReturns("11985")
|
|
|
|
var capturedAddr string
|
|
fakeSrv := newFakeHTTPProxyServer()
|
|
srvIface := NewHTTPAPIProxyServer(env, 50*time.Millisecond, &fakeWebRTCProxyServer{},
|
|
func(s *httpAPIProxyServer) {
|
|
s.newServer = func(addr string) (httpServer, *http.ServeMux) {
|
|
capturedAddr = addr
|
|
return fakeSrv, http.NewServeMux()
|
|
}
|
|
})
|
|
defer srvIface.Close()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
if err := srvIface.Run(ctx); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if capturedAddr != ":11985" {
|
|
t.Fatalf("newServer addr = %q, want :11985", capturedAddr)
|
|
}
|
|
}
|
|
|
|
func TestHTTPAPIProxyServer_Run_AddrWithColonUnchanged(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.HttpAPIReturns("127.0.0.1:9999")
|
|
|
|
var capturedAddr string
|
|
fakeSrv := newFakeHTTPProxyServer()
|
|
srvIface := NewHTTPAPIProxyServer(env, 50*time.Millisecond, &fakeWebRTCProxyServer{},
|
|
func(s *httpAPIProxyServer) {
|
|
s.newServer = func(addr string) (httpServer, *http.ServeMux) {
|
|
capturedAddr = addr
|
|
return fakeSrv, http.NewServeMux()
|
|
}
|
|
})
|
|
defer srvIface.Close()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
if err := srvIface.Run(ctx); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if capturedAddr != "127.0.0.1:9999" {
|
|
t.Fatalf("newServer addr = %q", capturedAddr)
|
|
}
|
|
}
|
|
|
|
func TestHTTPAPIProxyServer_Run_CtxCancelTriggersShutdown(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.HttpAPIReturns(":0")
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
_, fakeSrv, _ := captureMuxFromHTTPAPIRun(t, env, &fakeWebRTCProxyServer{}, ctx)
|
|
|
|
deadline := time.Now().Add(time.Second)
|
|
for fakeSrv.listenCalls.Load() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if fakeSrv.listenCalls.Load() == 0 {
|
|
t.Fatal("ListenAndServe goroutine did not start")
|
|
}
|
|
|
|
cancel()
|
|
|
|
deadline = time.Now().Add(time.Second)
|
|
for fakeSrv.shutdownCalls.Load() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if fakeSrv.shutdownCalls.Load() == 0 {
|
|
t.Fatal("Shutdown was not invoked after ctx cancel")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// httpAPIProxyServer — handler dispatch
|
|
// =============================================================================
|
|
|
|
func TestHTTPAPIProxyServer_Run_HandlerVersionsReturnsJSON(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.HttpAPIReturns(":0")
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
mux, _, _ := captureMuxFromHTTPAPIRun(t, env, &fakeWebRTCProxyServer{}, ctx)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/versions", nil)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
var body map[string]string
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("json: %v\nbody=%s", err, rec.Body.String())
|
|
}
|
|
if body["signature"] == "" {
|
|
t.Error("signature should be populated")
|
|
}
|
|
if body["version"] == "" {
|
|
t.Error("version should be populated")
|
|
}
|
|
}
|
|
|
|
func TestHTTPAPIProxyServer_Run_HandlerWHIPDelegatesToRTC(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.HttpAPIReturns(":0")
|
|
rtc := &fakeWebRTCProxyServer{whipResponseBody: "ok-whip"}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
mux, _, _ := captureMuxFromHTTPAPIRun(t, env, rtc, ctx)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/rtc/v1/whip/?app=live&stream=s", nil)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rtc.whipCalls.Load() != 1 {
|
|
t.Fatalf("HandleApiForWHIP calls = %d, want 1", rtc.whipCalls.Load())
|
|
}
|
|
if rtc.whepCalls.Load() != 0 {
|
|
t.Errorf("HandleApiForWHEP should not be invoked")
|
|
}
|
|
if !bytes.Equal(rec.Body.Bytes(), []byte("ok-whip")) {
|
|
t.Errorf("body = %q, want ok-whip", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHTTPAPIProxyServer_Run_HandlerLegacyPublishRoutesToWHIP(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.HttpAPIReturns(":0")
|
|
rtc := &fakeWebRTCProxyServer{}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
mux, _, _ := captureMuxFromHTTPAPIRun(t, env, rtc, ctx)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/rtc/v1/publish/", nil)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rtc.whipCalls.Load() != 1 {
|
|
t.Fatalf("HandleApiForWHIP via /rtc/v1/publish/ calls = %d, want 1", rtc.whipCalls.Load())
|
|
}
|
|
if rtc.whepCalls.Load() != 0 {
|
|
t.Errorf("HandleApiForWHEP should not be invoked via /rtc/v1/publish/")
|
|
}
|
|
}
|
|
|
|
func TestHTTPAPIProxyServer_Run_HandlerWHIPErrorInvokesApiError(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.HttpAPIReturns(":0")
|
|
rtc := &fakeWebRTCProxyServer{whipReturn: errors.New("boom-whip")}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
mux, _, _ := captureMuxFromHTTPAPIRun(t, env, rtc, ctx)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/rtc/v1/whip/", nil)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500", rec.Code)
|
|
}
|
|
if !bytes.Contains(rec.Body.Bytes(), []byte("boom-whip")) {
|
|
t.Errorf("body = %q, expected to contain error message", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHTTPAPIProxyServer_Run_HandlerWHEPDelegatesToRTC(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.HttpAPIReturns(":0")
|
|
rtc := &fakeWebRTCProxyServer{whepResponseBody: "ok-whep"}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
mux, _, _ := captureMuxFromHTTPAPIRun(t, env, rtc, ctx)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/rtc/v1/whep/", nil)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rtc.whepCalls.Load() != 1 {
|
|
t.Fatalf("HandleApiForWHEP calls = %d, want 1", rtc.whepCalls.Load())
|
|
}
|
|
if rtc.whipCalls.Load() != 0 {
|
|
t.Errorf("HandleApiForWHIP should not be invoked")
|
|
}
|
|
if !bytes.Equal(rec.Body.Bytes(), []byte("ok-whep")) {
|
|
t.Errorf("body = %q, want ok-whep", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHTTPAPIProxyServer_Run_HandlerLegacyPlayRoutesToWHEP(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.HttpAPIReturns(":0")
|
|
rtc := &fakeWebRTCProxyServer{}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
mux, _, _ := captureMuxFromHTTPAPIRun(t, env, rtc, ctx)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/rtc/v1/play/", nil)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rtc.whepCalls.Load() != 1 {
|
|
t.Fatalf("HandleApiForWHEP via /rtc/v1/play/ calls = %d, want 1", rtc.whepCalls.Load())
|
|
}
|
|
if rtc.whipCalls.Load() != 0 {
|
|
t.Errorf("HandleApiForWHIP should not be invoked via /rtc/v1/play/")
|
|
}
|
|
}
|
|
|
|
func TestHTTPAPIProxyServer_Run_HandlerWHEPErrorInvokesApiError(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.HttpAPIReturns(":0")
|
|
rtc := &fakeWebRTCProxyServer{whepReturn: errors.New("boom-whep")}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
mux, _, _ := captureMuxFromHTTPAPIRun(t, env, rtc, ctx)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/rtc/v1/whep/", nil)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500", rec.Code)
|
|
}
|
|
if !bytes.Contains(rec.Body.Bytes(), []byte("boom-whep")) {
|
|
t.Errorf("body = %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// NewSystemAPI
|
|
// =============================================================================
|
|
|
|
func TestSystemAPI_New_StoresFieldsAndDefaultsSeams(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
lbFake := &lbfakes.FakeOriginLoadBalancer{}
|
|
timeout := 2 * time.Second
|
|
srv := NewSystemAPI(env, lbFake, timeout)
|
|
|
|
if srv.environment != env {
|
|
t.Error("environment not stored")
|
|
}
|
|
if srv.loadBalancer != lbFake {
|
|
t.Error("loadBalancer not stored")
|
|
}
|
|
if srv.gracefulQuitTimeout != timeout {
|
|
t.Errorf("gracefulQuitTimeout = %v, want %v", srv.gracefulQuitTimeout, timeout)
|
|
}
|
|
if srv.shutdown == nil {
|
|
t.Error("shutdown seam should default to non-nil")
|
|
}
|
|
if srv.newServer == nil {
|
|
t.Error("newServer seam should default to non-nil")
|
|
}
|
|
}
|
|
|
|
func TestSystemAPI_New_AppliesOpts(t *testing.T) {
|
|
var called bool
|
|
srv := NewSystemAPI(&envfakes.FakeProxyEnvironment{}, &lbfakes.FakeOriginLoadBalancer{},
|
|
time.Second, func(s *systemAPI) { called = true })
|
|
if !called {
|
|
t.Fatal("opt was not invoked")
|
|
}
|
|
if srv.shutdown == nil {
|
|
t.Error("default seams should still be set when opt doesn't override them")
|
|
}
|
|
}
|
|
|
|
func TestSystemAPI_New_OptCanOverrideAllSeams(t *testing.T) {
|
|
customShutdown := func(context.Context) error { return errors.New("custom") }
|
|
customNewServer := func(string) (httpServer, *http.ServeMux) { return nil, nil }
|
|
|
|
srv := NewSystemAPI(&envfakes.FakeProxyEnvironment{}, &lbfakes.FakeOriginLoadBalancer{},
|
|
time.Second, func(s *systemAPI) {
|
|
s.shutdown = customShutdown
|
|
s.newServer = customNewServer
|
|
})
|
|
|
|
if err := srv.shutdown(context.Background()); err == nil || err.Error() != "custom" {
|
|
t.Errorf("custom shutdown not applied: %v", err)
|
|
}
|
|
if got, _ := srv.newServer(""); got != nil {
|
|
t.Error("custom newServer not applied")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// systemAPI — default factory behavior
|
|
// =============================================================================
|
|
|
|
func TestSystemAPI_DefaultNewServer_BuildsRealServerAndMux(t *testing.T) {
|
|
srv := NewSystemAPI(&envfakes.FakeProxyEnvironment{}, &lbfakes.FakeOriginLoadBalancer{}, time.Second)
|
|
|
|
got, mux := srv.newServer(":12321")
|
|
if mux == nil {
|
|
t.Fatal("mux is nil")
|
|
}
|
|
real, ok := got.(*http.Server)
|
|
if !ok {
|
|
t.Fatalf("expected *http.Server, got %T", got)
|
|
}
|
|
if real.Addr != ":12321" {
|
|
t.Errorf("Addr = %q, want :12321", real.Addr)
|
|
}
|
|
if real.Handler != mux {
|
|
t.Error("Handler should be the returned mux")
|
|
}
|
|
}
|
|
|
|
func TestSystemAPI_DefaultShutdown_DelegatesToServer(t *testing.T) {
|
|
fakeSrv := newFakeHTTPProxyServer()
|
|
srv := NewSystemAPI(&envfakes.FakeProxyEnvironment{}, &lbfakes.FakeOriginLoadBalancer{}, time.Second)
|
|
srv.server = fakeSrv
|
|
|
|
if err := srv.shutdown(context.Background()); err != nil {
|
|
t.Fatalf("shutdown: %v", err)
|
|
}
|
|
if fakeSrv.shutdownCalls.Load() != 1 {
|
|
t.Fatalf("shutdown was not delegated, calls=%d", fakeSrv.shutdownCalls.Load())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// systemAPI — Close
|
|
// =============================================================================
|
|
|
|
func TestSystemAPI_Close_InvokesShutdownWithDeadline(t *testing.T) {
|
|
var gotCtx context.Context
|
|
var calls int
|
|
srv := NewSystemAPI(&envfakes.FakeProxyEnvironment{}, &lbfakes.FakeOriginLoadBalancer{},
|
|
50*time.Millisecond, func(s *systemAPI) {
|
|
s.shutdown = func(ctx context.Context) error {
|
|
gotCtx = ctx
|
|
calls++
|
|
return nil
|
|
}
|
|
})
|
|
|
|
if err := srv.Close(); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("shutdown calls = %d, want 1", calls)
|
|
}
|
|
if _, ok := gotCtx.Deadline(); !ok {
|
|
t.Error("Close should pass a deadline-bearing ctx to shutdown")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// systemAPI — Run lifecycle
|
|
// =============================================================================
|
|
|
|
func TestSystemAPI_Run_AddrWithoutColonPrependsIt(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.SystemAPIReturns("12025")
|
|
|
|
var capturedAddr string
|
|
fakeSrv := newFakeHTTPProxyServer()
|
|
srv := NewSystemAPI(env, &lbfakes.FakeOriginLoadBalancer{}, 50*time.Millisecond,
|
|
func(s *systemAPI) {
|
|
s.newServer = func(addr string) (httpServer, *http.ServeMux) {
|
|
capturedAddr = addr
|
|
return fakeSrv, http.NewServeMux()
|
|
}
|
|
})
|
|
defer srv.Close()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
if err := srv.Run(ctx); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if capturedAddr != ":12025" {
|
|
t.Fatalf("newServer addr = %q, want :12025", capturedAddr)
|
|
}
|
|
}
|
|
|
|
func TestSystemAPI_Run_AddrWithColonUnchanged(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.SystemAPIReturns("127.0.0.1:9999")
|
|
|
|
var capturedAddr string
|
|
fakeSrv := newFakeHTTPProxyServer()
|
|
srv := NewSystemAPI(env, &lbfakes.FakeOriginLoadBalancer{}, 50*time.Millisecond,
|
|
func(s *systemAPI) {
|
|
s.newServer = func(addr string) (httpServer, *http.ServeMux) {
|
|
capturedAddr = addr
|
|
return fakeSrv, http.NewServeMux()
|
|
}
|
|
})
|
|
defer srv.Close()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
if err := srv.Run(ctx); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if capturedAddr != "127.0.0.1:9999" {
|
|
t.Fatalf("newServer addr = %q", capturedAddr)
|
|
}
|
|
}
|
|
|
|
func TestSystemAPI_Run_CtxCancelTriggersShutdown(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.SystemAPIReturns(":0")
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
_, fakeSrv, _ := captureMuxFromSystemAPIRun(t, env, &lbfakes.FakeOriginLoadBalancer{}, ctx)
|
|
|
|
deadline := time.Now().Add(time.Second)
|
|
for fakeSrv.listenCalls.Load() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if fakeSrv.listenCalls.Load() == 0 {
|
|
t.Fatal("ListenAndServe goroutine did not start")
|
|
}
|
|
|
|
cancel()
|
|
|
|
deadline = time.Now().Add(time.Second)
|
|
for fakeSrv.shutdownCalls.Load() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if fakeSrv.shutdownCalls.Load() == 0 {
|
|
t.Fatal("Shutdown was not invoked after ctx cancel")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// systemAPI — handler dispatch
|
|
// =============================================================================
|
|
|
|
func TestSystemAPI_Run_HandlerVersionsReturnsJSON(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.SystemAPIReturns(":0")
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
mux, _, _ := captureMuxFromSystemAPIRun(t, env, &lbfakes.FakeOriginLoadBalancer{}, ctx)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/versions", nil)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
var body map[string]string
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("json: %v\nbody=%s", err, rec.Body.String())
|
|
}
|
|
if body["signature"] == "" {
|
|
t.Error("signature should be populated")
|
|
}
|
|
if body["version"] == "" {
|
|
t.Error("version should be populated")
|
|
}
|
|
}
|
|
|
|
// validRegisterBody returns the JSON body for a happy-path /api/v1/srs/register call.
|
|
func validRegisterBody(t *testing.T) io.Reader {
|
|
t.Helper()
|
|
b, err := json.Marshal(map[string]any{
|
|
"ip": "1.2.3.4",
|
|
"server": "srv-abc",
|
|
"service": "svc-1",
|
|
"pid": "12345",
|
|
"rtmp": []string{"1935"},
|
|
"http": []string{"8080"},
|
|
"api": []string{"1985"},
|
|
"srt": []string{"10080"},
|
|
"rtc": []string{"8000"},
|
|
"device_id": "dev-x",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
return bytes.NewReader(b)
|
|
}
|
|
|
|
func TestSystemAPI_Run_HandlerRegisterHappyPathCallsUpdate(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.SystemAPIReturns(":0")
|
|
lbFake := &lbfakes.FakeOriginLoadBalancer{}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
mux, _, _ := captureMuxFromSystemAPIRun(t, env, lbFake, ctx)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/srs/register", validRegisterBody(t))
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if lbFake.UpdateCallCount() != 1 {
|
|
t.Fatalf("Update calls = %d, want 1", lbFake.UpdateCallCount())
|
|
}
|
|
_, server := lbFake.UpdateArgsForCall(0)
|
|
if server.IP != "1.2.3.4" {
|
|
t.Errorf("IP = %q", server.IP)
|
|
}
|
|
if server.ServerID != "srv-abc" {
|
|
t.Errorf("ServerID = %q", server.ServerID)
|
|
}
|
|
if server.ServiceID != "svc-1" {
|
|
t.Errorf("ServiceID = %q", server.ServiceID)
|
|
}
|
|
if server.PID != "12345" {
|
|
t.Errorf("PID = %q", server.PID)
|
|
}
|
|
if got := server.RTMP; len(got) != 1 || got[0] != "1935" {
|
|
t.Errorf("RTMP = %v", got)
|
|
}
|
|
if got := server.HTTP; len(got) != 1 || got[0] != "8080" {
|
|
t.Errorf("HTTP = %v", got)
|
|
}
|
|
if got := server.API; len(got) != 1 || got[0] != "1985" {
|
|
t.Errorf("API = %v", got)
|
|
}
|
|
if got := server.SRT; len(got) != 1 || got[0] != "10080" {
|
|
t.Errorf("SRT = %v", got)
|
|
}
|
|
if got := server.RTC; len(got) != 1 || got[0] != "8000" {
|
|
t.Errorf("RTC = %v", got)
|
|
}
|
|
if server.DeviceID != "dev-x" {
|
|
t.Errorf("DeviceID = %q", server.DeviceID)
|
|
}
|
|
if server.UpdatedAt.IsZero() {
|
|
t.Error("UpdatedAt should be set")
|
|
}
|
|
}
|
|
|
|
func TestSystemAPI_Run_HandlerRegisterParseBodyError(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.SystemAPIReturns(":0")
|
|
lbFake := &lbfakes.FakeOriginLoadBalancer{}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
mux, _, _ := captureMuxFromSystemAPIRun(t, env, lbFake, ctx)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/srs/register",
|
|
bytes.NewReader([]byte("not json")))
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if lbFake.UpdateCallCount() != 0 {
|
|
t.Fatalf("Update should not be called on parse body err, calls = %d", lbFake.UpdateCallCount())
|
|
}
|
|
if !bytes.Contains(rec.Body.Bytes(), []byte("parse body")) {
|
|
t.Errorf("body = %q, expected parse body error", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// registerWithField returns a body with one field replaced. Other mandatory
|
|
// fields default to valid values so only the field under test triggers an
|
|
// error.
|
|
func registerWithField(t *testing.T, field string, value any) io.Reader {
|
|
t.Helper()
|
|
m := map[string]any{
|
|
"ip": "1.2.3.4",
|
|
"server": "srv-abc",
|
|
"service": "svc-1",
|
|
"pid": "12345",
|
|
"rtmp": []string{"1935"},
|
|
}
|
|
m[field] = value
|
|
b, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
return bytes.NewReader(b)
|
|
}
|
|
|
|
func TestSystemAPI_Run_HandlerRegisterValidationErrors(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
field string
|
|
value any
|
|
wantErrText string
|
|
}{
|
|
{"empty-ip", "ip", "", "empty ip"},
|
|
{"empty-server", "server", "", "empty server"},
|
|
{"empty-service", "service", "", "empty service"},
|
|
{"empty-pid", "pid", "", "empty pid"},
|
|
{"empty-rtmp", "rtmp", []string{}, "empty rtmp"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.SystemAPIReturns(":0")
|
|
lbFake := &lbfakes.FakeOriginLoadBalancer{}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
mux, _, _ := captureMuxFromSystemAPIRun(t, env, lbFake, ctx)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/srs/register",
|
|
registerWithField(t, tc.field, tc.value))
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if lbFake.UpdateCallCount() != 0 {
|
|
t.Errorf("Update should not be called when %s is invalid, calls = %d",
|
|
tc.field, lbFake.UpdateCallCount())
|
|
}
|
|
if !bytes.Contains(rec.Body.Bytes(), []byte(tc.wantErrText)) {
|
|
t.Errorf("body = %q, expected to contain %q", rec.Body.String(), tc.wantErrText)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSystemAPI_Run_HandlerRegisterLoadBalancerUpdateError(t *testing.T) {
|
|
env := &envfakes.FakeProxyEnvironment{}
|
|
env.SystemAPIReturns(":0")
|
|
lbFake := &lbfakes.FakeOriginLoadBalancer{}
|
|
lbFake.UpdateReturns(errors.New("lb-update-fail"))
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
mux, _, _ := captureMuxFromSystemAPIRun(t, env, lbFake, ctx)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/srs/register", validRegisterBody(t))
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if lbFake.UpdateCallCount() != 1 {
|
|
t.Fatalf("Update calls = %d, want 1", lbFake.UpdateCallCount())
|
|
}
|
|
if !bytes.Contains(rec.Body.Bytes(), []byte("lb-update-fail")) {
|
|
t.Errorf("body = %q, expected lb error", rec.Body.String())
|
|
}
|
|
}
|