package onvif import ( "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDevice_SetDeviceInfoFromScopes(t *testing.T) { const ( name = "DeviceName" hardware = "M9000" ) scopes := []string{ "onvif://www.onvif.org/Profile/Streaming", "onvif://www.onvif.org/SomethingElse/value", "onvif://www.onvif.org/name/" + name, "onvif://www.onvif.org/hardware/" + hardware, } device := Device{} device.SetDeviceInfoFromScopes(scopes) assert.Equal(t, device.info.Name, name) assert.Equal(t, device.info.Model, hardware) } // TestDevice_SendSoapWithHeader_InjectsHeaderXML verifies that the // supplied header XML lands inside the SOAP
element of the // outgoing request. AXIS-style WS-Addressing reference parameter // echoing depends on this — without it the camera returns // ter:InvalidArgs on every PullMessages. func TestDevice_SendSoapWithHeader_InjectsHeaderXML(t *testing.T) { const headerXML = `297` const bodyXML = `PT5S32` var captured string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) captured = string(b) w.WriteHeader(http.StatusOK) })) t.Cleanup(srv.Close) dev := Device{ params: DeviceParams{ Xaddr: strings.TrimPrefix(srv.URL, "http://"), HttpClient: srv.Client(), }, } resp, err := dev.SendSoapWithHeader(srv.URL, bodyXML, headerXML) require.NoError(t, err) if resp != nil && resp.Body != nil { resp.Body.Close() } headerStart := strings.Index(captured, "Header>") bodyStart := strings.Index(captured, "Body>") require.NotEqual(t, -1, headerStart, "envelope must contain
; got: %s", captured) require.Greater(t, bodyStart, headerStart, "Body must follow Header in the envelope") headerSlice := captured[headerStart:bodyStart] assert.Contains(t, headerSlice, "SubscriptionId", "injected header element must land inside SOAP
") assert.Contains(t, headerSlice, "297") bodySlice := captured[bodyStart:] assert.Contains(t, bodySlice, "PullMessages", "body content must land inside SOAP ") } // Per WS-Addressing 1.0 SOAP Binding §3.4 every reference parameter is a separate // SOAP Header block. Vendors that declare two ref params would silently // produce a header-less request if the implementation only accepts a // single top-level element. func TestDevice_SendSoapWithHeader_AcceptsMultipleTopLevelChildren(t *testing.T) { const headerXML = `12` const bodyXML = `` var captured string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) captured = string(b) w.WriteHeader(http.StatusOK) })) t.Cleanup(srv.Close) dev := Device{params: DeviceParams{ Xaddr: strings.TrimPrefix(srv.URL, "http://"), HttpClient: srv.Client(), }} resp, err := dev.SendSoapWithHeader(srv.URL, bodyXML, headerXML) require.NoError(t, err) if resp != nil && resp.Body != nil { resp.Body.Close() } headerSlice := captured[strings.Index(captured, "Header>"):strings.Index(captured, "Body>")] assert.Contains(t, headerSlice, "Foo") assert.Contains(t, headerSlice, "Bar") } func TestDevice_SendSoapWithHeader_RejectsElementFreeContent(t *testing.T) { var hits int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ w.WriteHeader(http.StatusOK) })) t.Cleanup(srv.Close) dev := Device{params: DeviceParams{HttpClient: srv.Client()}} // Well-formed XML but contains no child elements — would otherwise // parse, yield zero ChildElements, and send a header-less request. _, err := dev.SendSoapWithHeader(srv.URL, "", "just text content") require.Error(t, err) assert.Equal(t, 0, hits, "non-empty header content with no element children must fail fast") } // SendSoapWithOptions is the variadic shape that future per-call // options (timeout, context, ...) will hang off. SendSoap and // SendSoapWithHeader stay as thin convenience wrappers so existing // callers are not forced to migrate. func TestDevice_SendSoapWithOptions_WithHeaderMatchesSendSoapWithHeader(t *testing.T) { const headerXML = `42` const bodyXML = `` var captured string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) captured = string(b) })) t.Cleanup(srv.Close) dev := Device{params: DeviceParams{HttpClient: srv.Client()}} resp, err := dev.SendSoapWithOptions(srv.URL, bodyXML, WithSOAPHeader(headerXML)) require.NoError(t, err) if resp != nil && resp.Body != nil { resp.Body.Close() } assert.Contains(t, captured, "SubscriptionId") assert.Contains(t, captured, "42") } func TestDevice_SendSoapWithOptions_NoOptsMatchesSendSoap(t *testing.T) { var captured string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) captured = string(b) })) t.Cleanup(srv.Close) dev := Device{params: DeviceParams{HttpClient: srv.Client()}} resp, err := dev.SendSoapWithOptions(srv.URL, ``) require.NoError(t, err) if resp != nil && resp.Body != nil { resp.Body.Close() } assert.NotContains(t, captured, "IsReferenceParameter", "no opts should produce a header-less envelope") } // Digest auth fallback path: the camera 401s the first POST and the // retry computes a digest. The ref-params header must survive the // retry — losing it would silently re-introduce the AXIS regression // on every authenticated camera. func TestDevice_SendSoapWithHeader_PreservesHeaderAcrossDigestRetry(t *testing.T) { const headerXML = `297` var capturedSecondBody string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") == "" { w.Header().Set("WWW-Authenticate", `Digest realm="onvif", nonce="abc", qop="auth"`) w.WriteHeader(http.StatusUnauthorized) return } b, _ := io.ReadAll(r.Body) capturedSecondBody = string(b) w.WriteHeader(http.StatusOK) })) t.Cleanup(srv.Close) dev := Device{params: DeviceParams{ Xaddr: strings.TrimPrefix(srv.URL, "http://"), HttpClient: srv.Client(), Username: "admin", Password: "secret", }} resp, err := dev.SendSoapWithHeader(srv.URL, ``, headerXML) require.NoError(t, err) if resp != nil && resp.Body != nil { resp.Body.Close() } assert.Contains(t, capturedSecondBody, "SubscriptionId", "digest retry must carry the same ref-params header as the first attempt") assert.Contains(t, capturedSecondBody, "297") } func TestDevice_SendSoapWithHeader_PropagatesMalformedHeaderError(t *testing.T) { var hits int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ w.WriteHeader(http.StatusOK) })) t.Cleanup(srv.Close) dev := Device{params: DeviceParams{HttpClient: srv.Client()}} _, err := dev.SendSoapWithHeader(srv.URL, "", "") require.NoError(t, err) if resp != nil && resp.Body != nil { resp.Body.Close() } require.NotEmpty(t, authedBody, "expected an authenticated POST after the 401 challenge") assert.NotContains(t, authedBody, "UsernameToken", "digest retry must strip wsse:Security/UsernameToken; otherwise credentials go on the wire twice") } // Last-write-wins on duplicate SendSoapOption — pin the behaviour so // the next maintainer adding an option doesn't accidentally introduce // a merge or first-wins semantic. func TestDevice_SendSoapWithOptions_DuplicateWithSOAPHeaderLastWins(t *testing.T) { var captured string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) captured = string(b) })) t.Cleanup(srv.Close) dev := Device{params: DeviceParams{HttpClient: srv.Client()}} _, err := dev.SendSoapWithOptions(srv.URL, "", WithSOAPHeader(``), WithSOAPHeader(``), ) require.NoError(t, err) assert.NotContains(t, captured, "First", "first WithSOAPHeader must be overwritten") assert.Contains(t, captured, "Second") }