mirror of
https://github.com/kerberos-io/onvif.git
synced 2026-08-23 15:08:33 +00:00
Critical
- Device.SendSoapWithHeader now parses the supplied header content
with etree and adds each top-level child as its own SOAP Header
block. gosoap.AddStringHeaderContent only accepts a single root
element; previously a multi-child ref-params header silently
produced a header-less request because the parse error was
discarded. Errors are now propagated.
- enrichSOAPErr scrubs <*:Security> blocks from response bodies
before fault extraction or excerpt slicing so a camera that
echoes the WS-Security header in a fault response cannot leak
Username/Password into operator logs.
Important
- extractSOAPFault falls back to the SOAP 1.2 Subcode (e.g.
ter:InvalidArgs) when Reason/Text is empty — consistent with
enrichSOAPErr and surfaces actionable detail on 200-OK fault
bodies reached via unmarshalNode.
- subscriptionRef captures the camera-granted TerminationTime
from CreatePullPointSubscription and Renew responses. renewLoop
schedules from it via the new nextRenewInterval helper so we
never miss a renew when the camera grants less than requested.
renew is now a sleep-loop driven by the latest granted time.
- enrichSOAPErr reads at most 64 KiB from the body (vs. 10 MiB
on success paths). Fault bodies are always small; the prior cap
let a wedged camera churn 10 MiB/s through the retry loop.
Suggestions
- extractReferenceParameters anchors to <SubscriptionReference> so
a wsa:ReplyTo / wsa:FaultTo that also carries ReferenceParameters
elsewhere in the envelope cannot leak through and break PullMessages.
- buildRefParamsHeader accepts either raw children or the full
<*:ReferenceParameters> wrapper, and propagates ancestor xmlns:*
onto each child so a vendor that declares the prefix on the
parent (not the child itself, as AXIS does) still produces valid
standalone children on the wire.
- SendSoapWithHeader documents that xmlHeaderContent must be
well-formed XML and that the caller is responsible for escaping
any externally sourced data.
- Error-message ordering is now context-first
("SOAP fault: X: <wrapped err>") per Go convention.
- Dead headerEnd slicing removed from the SendSoapWithHeader test.
Tests
- End-to-end multi-child wiring through pullMessages.
- Digest auth retry preserves the injected header.
- Malformed-XML header content fast-fails before any request.
- buildRefParamsHeader malformed / whitespace-only edge cases.
- goleak.VerifyTestMain in event/stream catches any pull/renew
goroutine that outlives its Stream.
No new behavioural surface added to onvif core; SendSoap retains
its signature, SendSoapWithHeader is the only new public method.
205 lines
6.2 KiB
Go
205 lines
6.2 KiB
Go
package stream
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// countSendSoapMatching counts how many recorded SendSoap calls have a
|
|
// body containing needle. Safe to call concurrently with the run loop.
|
|
func countSendSoapMatching(fc *fakeCaller, needle string) int {
|
|
fc.mu.Lock()
|
|
defer fc.mu.Unlock()
|
|
n := 0
|
|
for _, c := range fc.sendSoapCalls {
|
|
if strings.Contains(c[1], needle) {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func TestStream_RenewsSubscriptionBeforeExpiry(t *testing.T) {
|
|
fc := newFakeCaller()
|
|
fc.queueCallMethod(createPullPointResp, nil)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
// 100 ms termination with 10 ms margin -> renew every ~90 ms.
|
|
s, err := newStream(ctx, fc, Options{
|
|
DeviceID: "cam-1",
|
|
InitialTermination: 100 * time.Millisecond,
|
|
RenewMargin: 10 * time.Millisecond,
|
|
})
|
|
require.NoError(t, err)
|
|
defer s.Close()
|
|
|
|
deadline := time.Now().Add(500 * time.Millisecond)
|
|
var renewCount int
|
|
for time.Now().Before(deadline) {
|
|
renewCount = countSendSoapMatching(fc, "Renew")
|
|
if renewCount >= 1 {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
assert.GreaterOrEqual(t, renewCount, 1, "expected at least one Renew SendSoap call within 500ms")
|
|
}
|
|
|
|
func TestStream_RenewSendsToSubscriptionEndpoint(t *testing.T) {
|
|
fc := newFakeCaller()
|
|
fc.queueCallMethod(createPullPointResp, nil)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
s, err := newStream(ctx, fc, Options{
|
|
InitialTermination: 80 * time.Millisecond,
|
|
RenewMargin: 10 * time.Millisecond,
|
|
})
|
|
require.NoError(t, err)
|
|
defer s.Close()
|
|
|
|
deadline := time.Now().Add(500 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
if countSendSoapMatching(fc, "Renew") >= 1 {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
|
|
fc.mu.Lock()
|
|
defer fc.mu.Unlock()
|
|
var renewEndpoint string
|
|
for _, c := range fc.sendSoapCalls {
|
|
if strings.Contains(c[1], "Renew") {
|
|
renewEndpoint = c[0]
|
|
break
|
|
}
|
|
}
|
|
require.NotEmpty(t, renewEndpoint, "no Renew call found")
|
|
assert.Equal(t, "http://camera.local/onvif/Events/PullSub_1", renewEndpoint,
|
|
"Renew must target the SubscriptionReference Address")
|
|
}
|
|
|
|
func TestStream_RenewMarginAppliesDefault(t *testing.T) {
|
|
o := defaultOptions()
|
|
assert.Equal(t, 10*time.Second, o.RenewMargin)
|
|
}
|
|
|
|
func TestStream_RenewErrorSurfacedOnErrorsChannel(t *testing.T) {
|
|
fc := newFakeCaller()
|
|
fc.queueCallMethod(createPullPointResp, nil)
|
|
// Defaults return empty pulls indefinitely so the pull loop is clean.
|
|
// Override defaultSendSoap on the fly to return a Renew error for
|
|
// any body that looks like a Renew. We do that by tagging the
|
|
// default response with an err, then resetting after capturing one.
|
|
// Simpler: just queue several explicit Renew-error responses; the
|
|
// fake's queue is consumed in FIFO and the pull body never matches
|
|
// 'Renew', so queued errors will land on the renew call only if
|
|
// queued before any pulls. To bias the order we drain via a custom
|
|
// default.
|
|
fc.mu.Lock()
|
|
fc.defaultSendSoap = fakeResp{err: errInjected{}}
|
|
fc.mu.Unlock()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
s, err := newStream(ctx, fc, Options{
|
|
InitialTermination: 80 * time.Millisecond,
|
|
RenewMargin: 10 * time.Millisecond,
|
|
})
|
|
require.NoError(t, err)
|
|
defer s.Close()
|
|
|
|
select {
|
|
case e := <-s.Errors():
|
|
assert.Contains(t, e.Error(), "injected")
|
|
case <-time.After(time.Second):
|
|
t.Fatal("expected an error on Errors channel from failing Renew/pull")
|
|
}
|
|
}
|
|
|
|
// errInjected is a sentinel error type so the test message has a stable
|
|
// substring without depending on a wrapped string match.
|
|
type errInjected struct{}
|
|
|
|
func (errInjected) Error() string { return "injected fake error" }
|
|
|
|
func TestRenew_SendsAbsoluteDateTimeNotDuration(t *testing.T) {
|
|
fc := newFakeCaller()
|
|
fc.queueCallMethod(createPullPointResp, nil)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
s, err := newStream(ctx, fc, Options{
|
|
InitialTermination: 30 * time.Millisecond,
|
|
RenewMargin: 5 * time.Millisecond,
|
|
})
|
|
require.NoError(t, err)
|
|
defer s.Close()
|
|
|
|
deadline := time.Now().Add(500 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
if countSendSoapMatching(fc, "Renew") >= 1 {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
|
|
fc.mu.Lock()
|
|
defer fc.mu.Unlock()
|
|
var renewBody string
|
|
for _, c := range fc.sendSoapCalls {
|
|
if strings.Contains(c[1], "Renew") {
|
|
renewBody = c[1]
|
|
break
|
|
}
|
|
}
|
|
require.NotEmpty(t, renewBody, "no Renew call observed")
|
|
// Absolute form is "YYYY-MM-DDTHH:MM:SSZ" not "PTnS".
|
|
assert.NotContains(t, renewBody, "PT", "Renew should not send relative duration; some firmwares reject it")
|
|
assert.Regexp(t, `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`, renewBody, "Renew should send absolute RFC3339 UTC")
|
|
}
|
|
|
|
// --- Wiring: renew surfaces SOAP fault detail -------------------------
|
|
|
|
func TestRenewPullPoint_EnrichesTransportErrWithFaultReason(t *testing.T) {
|
|
fc := newFakeCaller()
|
|
fc.queueSendSoap(renewFaultBody, errors.New("400 Bad Request"))
|
|
_, err := renewPullPoint(fc, subscriptionRef{Address: "http://camera/sub"}, defaultOptions())
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "renew-specific complaint",
|
|
"renewPullPoint must enrich transport errors with the camera's SOAP fault")
|
|
}
|
|
|
|
const renewFaultBody = `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
|
|
<env:Body><env:Fault>
|
|
<env:Code><env:Value>env:Sender</env:Value></env:Code>
|
|
<env:Reason><env:Text xml:lang="en">renew-specific complaint</env:Text></env:Reason>
|
|
</env:Fault></env:Body>
|
|
</env:Envelope>`
|
|
|
|
func TestRenewPullPoint_EchoesRefParamsWithIsReferenceParameter(t *testing.T) {
|
|
ref := subscriptionRef{
|
|
Address: "http://192.168.1.10/onvif/services",
|
|
RefParamsXML: `<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>`,
|
|
}
|
|
fc := newFakeCaller()
|
|
_, err := renewPullPoint(fc, ref, defaultOptions())
|
|
require.NoError(t, err)
|
|
require.Len(t, fc.sendSoapHeaders, 1)
|
|
hdr := fc.sendSoapHeaders[0]
|
|
assert.Contains(t, hdr, "SubscriptionId")
|
|
assert.Contains(t, hdr, "297")
|
|
assert.Contains(t, hdr, `IsReferenceParameter="true"`)
|
|
}
|