Files
onvif/event/stream/stream_test.go
Sebastian Norling d726ed8edb fix(event/stream): address review findings on AXIS compat work
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.
2026-05-27 18:07:56 +02:00

509 lines
16 KiB
Go

package stream
import (
"context"
"errors"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- fakeCaller --------------------------------------------------------
// fakeCaller is a test double for the caller interface. Each method
// returns the next queued response; when the queue is exhausted it falls
// back to a default response so the indefinite pull loop does not
// require tests to enumerate every call.
//
// blockUnsubscribe, when non-nil, causes SendSoap calls whose body
// contains "Unsubscribe" to block until the channel is closed.
// blockAllSendSoap, when non-nil, blocks every SendSoap call until
// closed (simulates a hung HTTP transport).
type fakeCaller struct {
mu sync.Mutex
callMethodResps []fakeResp
sendSoapResps []fakeResp
defaultSendSoap fakeResp
defaultCall fakeResp
callMethodCalls []any
sendSoapCalls [][2]string
sendSoapHeaders []string
blockUnsubscribe chan struct{}
blockAllSendSoap chan struct{}
}
type fakeResp struct {
body string
err error
}
func newFakeCaller() *fakeCaller {
return &fakeCaller{
// Default: indefinite empty pulls, indefinite OK unsubscribes.
defaultSendSoap: fakeResp{body: pullMessagesResp()},
defaultCall: fakeResp{err: errors.New("fakeCaller: no default CallMethod response")},
}
}
func (f *fakeCaller) queueCallMethod(body string, err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.callMethodResps = append(f.callMethodResps, fakeResp{body: body, err: err})
}
func (f *fakeCaller) queueSendSoap(body string, err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.sendSoapResps = append(f.sendSoapResps, fakeResp{body: body, err: err})
}
func (f *fakeCaller) CallMethod(m any) (*http.Response, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.callMethodCalls = append(f.callMethodCalls, m)
r := f.defaultCall
if len(f.callMethodResps) > 0 {
r = f.callMethodResps[0]
f.callMethodResps = f.callMethodResps[1:]
}
// Mirror networking.SendSoap*: a 4xx/5xx returns body alongside
// err. Tests opt into that shape by queueing body + err together.
if r.err != nil && r.body == "" {
return nil, r.err
}
return &http.Response{Body: io.NopCloser(strings.NewReader(r.body))}, r.err
}
func (f *fakeCaller) SendSoap(endpoint, body string) (*http.Response, error) {
f.mu.Lock()
f.sendSoapCalls = append(f.sendSoapCalls, [2]string{endpoint, body})
r := f.defaultSendSoap
if len(f.sendSoapResps) > 0 {
r = f.sendSoapResps[0]
f.sendSoapResps = f.sendSoapResps[1:]
}
block := f.blockUnsubscribe
blockAll := f.blockAllSendSoap
f.mu.Unlock()
if blockAll != nil {
<-blockAll
}
if block != nil && strings.Contains(body, "Unsubscribe") {
<-block
}
if r.err != nil && r.body == "" {
return nil, r.err
}
return &http.Response{Body: io.NopCloser(strings.NewReader(r.body))}, r.err
}
// SendSoapWithHeader delegates body+endpoint recording to SendSoap so
// existing assertions on sendSoapCalls keep working, and records the
// header XML in a parallel slice for ref-params wiring tests.
func (f *fakeCaller) SendSoapWithHeader(endpoint, body, headerXML string) (*http.Response, error) {
f.mu.Lock()
f.sendSoapHeaders = append(f.sendSoapHeaders, headerXML)
f.mu.Unlock()
return f.SendSoap(endpoint, body)
}
func (f *fakeCaller) sendSoapCallCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.sendSoapCalls)
}
// --- fixture SOAP envelopes -------------------------------------------
// createPullPointResp is the minimal SOAP envelope the lib's existing
// xml.Decoder + getXMLNode path can extract a pull-point address from.
const createPullPointResp = `<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
xmlns:wsa="http://www.w3.org/2005/08/addressing"
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
<env:Body>
<tev:CreatePullPointSubscriptionResponse>
<tev:SubscriptionReference>
<wsa:Address>http://camera.local/onvif/Events/PullSub_1</wsa:Address>
</tev:SubscriptionReference>
</tev:CreatePullPointSubscriptionResponse>
</env:Body>
</env:Envelope>`
func pullMessagesResp(messages ...string) string {
return `<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
xmlns:tev="http://www.onvif.org/ver10/events/wsdl"
xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2"
xmlns:tt="http://www.onvif.org/ver10/schema">
<env:Body>
<tev:PullMessagesResponse>
<tev:CurrentTime>2026-05-21T10:30:05Z</tev:CurrentTime>
<tev:TerminationTime>2026-05-21T10:31:05Z</tev:TerminationTime>
` + strings.Join(messages, "\n") + `
</tev:PullMessagesResponse>
</env:Body>
</env:Envelope>`
}
func motionMsg(value string) string {
return `<wsnt:NotificationMessage>
<wsnt:Topic Dialect="http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet">tns1:RuleEngine/CellMotionDetector/Motion</wsnt:Topic>
<wsnt:Message>
<tt:Message PropertyOperation="Changed" UtcTime="2026-05-21T10:30:00Z">
<tt:Source>
<tt:SimpleItem Name="VideoSourceConfigurationToken" Value="VSC0"/>
</tt:Source>
<tt:Data>
<tt:SimpleItem Name="IsMotion" Value="` + value + `"/>
</tt:Data>
</tt:Message>
</wsnt:Message>
</wsnt:NotificationMessage>`
}
const unsubscribeResp = `<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2">
<env:Body>
<wsnt:UnsubscribeResponse/>
</env:Body>
</env:Envelope>`
// --- helpers -----------------------------------------------------------
// receive waits up to d for an event on ch, failing the test if none
// arrives.
func receive(t *testing.T, ch <-chan Event, d time.Duration) Event {
t.Helper()
select {
case ev, ok := <-ch:
if !ok {
t.Fatalf("event channel closed before receiving")
}
return ev
case <-time.After(d):
t.Fatalf("timed out waiting for event after %s", d)
}
return Event{} // unreachable
}
// --- tests -------------------------------------------------------------
func TestNewStream_CreatesPullPointAtConstruction(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
// Queue an empty pull so the run loop can spin without exploding.
fc.queueSendSoap(pullMessagesResp(), nil)
fc.queueSendSoap(unsubscribeResp, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
require.NoError(t, err)
require.NotNil(t, s)
require.NoError(t, s.Close())
// CreatePullPointSubscription was called exactly once.
fc.mu.Lock()
defer fc.mu.Unlock()
require.Len(t, fc.callMethodCalls, 1, "expected one CallMethod call (CreatePullPointSubscription)")
}
func TestStream_DeliversDecodedEvents(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil)
// Provide subsequent empty pulls so the loop doesn't starve before Close.
fc.queueSendSoap(pullMessagesResp(), nil)
fc.queueSendSoap(pullMessagesResp(), nil)
fc.queueSendSoap(unsubscribeResp, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
require.NoError(t, err)
defer s.Close()
ev := receive(t, s.Events(), 2*time.Second)
assert.Equal(t, KindMotion, ev.Kind)
assert.Equal(t, StateActive, ev.State)
assert.Equal(t, "cam-1", ev.DeviceID)
assert.Equal(t, "tns1:RuleEngine/CellMotionDetector/Motion", ev.Topic)
assert.Equal(t, "VSC0", ev.Source["VideoSourceConfigurationToken"])
assert.Equal(t, "true", ev.Data["IsMotion"])
}
func TestStream_PullsAgainstSubscriptionAddress(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
fc.queueSendSoap(pullMessagesResp(), nil)
fc.queueSendSoap(pullMessagesResp(), nil)
fc.queueSendSoap(unsubscribeResp, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
require.NoError(t, err)
// Wait until at least one pull happened, then close.
for i := 0; i < 50 && fc.sendSoapCallCount() == 0; i++ {
time.Sleep(10 * time.Millisecond)
}
require.NoError(t, s.Close())
fc.mu.Lock()
defer fc.mu.Unlock()
require.NotEmpty(t, fc.sendSoapCalls, "expected at least one PullMessages SendSoap call")
endpoint := fc.sendSoapCalls[0][0]
assert.Equal(t, "http://camera.local/onvif/Events/PullSub_1", endpoint,
"PullMessages must target the SubscriptionReference Address returned by CreatePullPoint")
// Last call (Close) should target the same endpoint with an Unsubscribe body.
last := fc.sendSoapCalls[len(fc.sendSoapCalls)-1]
assert.Equal(t, "http://camera.local/onvif/Events/PullSub_1", last[0])
assert.Contains(t, last[1], "Unsubscribe")
}
func TestNewStream_ReturnsErrorWhenCreatePullPointFails(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod("", errors.New("network down"))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
assert.Error(t, err)
assert.Nil(t, s)
}
func TestStream_ClosedContextStopsRunLoop(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
// Many empty pulls so the loop is hot when we cancel.
for i := 0; i < 20; i++ {
fc.queueSendSoap(pullMessagesResp(), nil)
}
fc.queueSendSoap(unsubscribeResp, nil)
ctx, cancel := context.WithCancel(context.Background())
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
require.NoError(t, err)
// Wait for at least one pull.
for i := 0; i < 50 && fc.sendSoapCallCount() == 0; i++ {
time.Sleep(10 * time.Millisecond)
}
cancel()
// Close should still complete cleanly; the goroutine must drain.
require.NoError(t, s.Close())
// Events channel must close so consumers can range-loop safely.
select {
case _, ok := <-s.Events():
assert.False(t, ok, "Events channel should be closed after Close()")
case <-time.After(time.Second):
t.Fatal("Events channel was not closed within 1s")
}
}
func TestStream_PullErrorSurfacedOnErrorsChannel(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
fc.queueSendSoap("", errors.New("transient pull failure"))
// Then a clean pull so the loop keeps running.
fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil)
fc.queueSendSoap(pullMessagesResp(), nil)
fc.queueSendSoap(unsubscribeResp, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
require.NoError(t, err)
defer s.Close()
select {
case e := <-s.Errors():
assert.Contains(t, e.Error(), "transient pull failure")
case <-time.After(2 * time.Second):
t.Fatal("expected an error on the Errors channel")
}
// After the transient failure the loop continued and decoded.
ev := receive(t, s.Events(), 2*time.Second)
assert.Equal(t, KindMotion, ev.Kind)
}
func TestStream_OptionsApplyDefaults(t *testing.T) {
o := defaultOptions()
assert.Equal(t, 5*time.Second, o.PullTimeout)
assert.Equal(t, 32, o.MessageLimit)
assert.Equal(t, 60*time.Second, o.InitialTermination)
assert.Equal(t, 16, o.BufferSize)
}
func TestStream_DoesNotPanicOnPullExitingDuringClose(t *testing.T) {
// Regression guard: Close should not race with the run goroutine
// in a way that double-closes the events/errors channels.
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
for i := 0; i < 5; i++ {
fc.queueSendSoap(pullMessagesResp(), nil)
}
fc.queueSendSoap(unsubscribeResp, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
require.NoError(t, err)
assert.NotPanics(t, func() {
require.NoError(t, s.Close())
// Double-close should be a no-op, not a panic.
_ = s.Close()
})
}
// --- Close error / timeout paths -------------------------------------
func TestClose_ReturnsUnsubscribeError(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
fc.mu.Lock()
fc.defaultSendSoap = fakeResp{err: errors.New("simulated transport failure")}
fc.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second})
require.NoError(t, err)
err = s.Close()
require.Error(t, err)
assert.Contains(t, err.Error(), "unsubscribe pull point")
assert.Contains(t, err.Error(), "simulated transport failure")
}
func TestClose_BoundedByTimeoutOnHungUnsubscribe(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
block := make(chan struct{})
defer close(block) // release the hung Unsubscribe so the fake's goroutine exits
fc.mu.Lock()
fc.blockUnsubscribe = block
fc.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second})
require.NoError(t, err)
start := time.Now()
err = s.Close()
elapsed := time.Since(start)
require.Error(t, err)
assert.Contains(t, err.Error(), "timeout")
assert.Less(t, elapsed, closeUnsubscribeTimeout+time.Second,
"Close exceeded bound (%s); expected ~%s", elapsed, closeUnsubscribeTimeout)
}
// --- NewStream edge cases --------------------------------------------
func TestNewStream_CtxAlreadyCancelled(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel before NewStream
s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second})
require.NoError(t, err)
require.NotNil(t, s)
select {
case _, ok := <-s.Events():
assert.False(t, ok, "events channel should be closed when ctx is pre-cancelled")
case <-time.After(time.Second):
t.Fatal("events channel was not closed within 1s")
}
_ = s.Close()
}
// --- fakeCaller self-test --------------------------------------------
func TestFakeCaller_QueueThenDefaultFallback(t *testing.T) {
fc := newFakeCaller()
fc.queueSendSoap("first", nil)
fc.queueSendSoap("second", nil)
r1, err := fc.SendSoap("ep", "body")
require.NoError(t, err)
b1 := make([]byte, 10)
n, _ := r1.Body.Read(b1)
assert.Equal(t, "first", string(b1[:n]))
r2, _ := fc.SendSoap("ep", "body")
b2 := make([]byte, 10)
n, _ = r2.Body.Read(b2)
assert.Equal(t, "second", string(b2[:n]))
// Queue is exhausted; default kicks in.
r3, err := fc.SendSoap("ep", "body")
require.NoError(t, err)
require.NotNil(t, r3)
b3 := make([]byte, 2048)
n, _ = r3.Body.Read(b3)
assert.Contains(t, string(b3[:n]), "PullMessagesResponse",
"default SendSoap should be an empty PullMessagesResponse envelope")
}
func TestClose_BoundedWhenLoopsStuckOnHungHTTP(t *testing.T) {
// Simulates a hung HTTP transport: every SendSoap blocks
// indefinitely. The pull and renew loops are wedged inside
// SendSoap and ctx-cancel cannot unblock them. Close must still
// return within its bounded budget so the agent's shutdown does
// not hang.
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
blockAll := make(chan struct{})
defer close(blockAll)
fc.mu.Lock()
fc.blockAllSendSoap = blockAll
fc.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
PullTimeout: 100 * time.Millisecond,
InitialTermination: 30 * time.Second,
})
require.NoError(t, err)
// Wait until pullLoop is actually parked inside the blocked
// SendSoap. Without this, Close races with the loop's first
// iteration and exits via the ctx pre-check instead of
// exercising the drain-timeout path.
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) && fc.sendSoapCallCount() == 0 {
time.Sleep(10 * time.Millisecond)
}
require.GreaterOrEqual(t, fc.sendSoapCallCount(), 1, "pullLoop never reached SendSoap")
start := time.Now()
err = s.Close()
elapsed := time.Since(start)
require.Error(t, err)
assert.Contains(t, err.Error(), "drain", "expected a drain-timeout error")
// Total budget is closeDrainTimeout for the wait + ~0 for unsubscribe
// (which is skipped when drain times out). Give plenty of slack for
// scheduling on a loaded CI machine.
assert.Less(t, elapsed, closeDrainTimeout+2*time.Second,
"Close exceeded bound (%s); expected ~%s", elapsed, closeDrainTimeout)
}