Files
onvif/event/stream/stream_test.go
Sebastian Norling 93620f04a3 fix(event/stream): production-grade SOAP and lifecycle hardening
Addresses the five ship-blocker findings from the second review:

1. Bounded body read (review F1 / R-HIGH)
   readClose now wraps resp.Body with io.LimitReader(10 MiB). A
   hostile or buggy camera streaming an unbounded body cannot OOM
   the agent. Legitimate PullMessages payloads are <200KB even with
   dense analytics.

2. SOAP Fault detection (review F3)
   unmarshalNode now scans for SOAP 1.1 faultstring and SOAP 1.2
   Reason/Text BEFORE the missing-element error path. Auth failures
   ('not authorized'), InvalidFilterFault and expired-subscription
   faults now surface their reason text instead of collapsing to
   the unhelpful 'response missing PullMessagesResponse element'.
   This is the difference between a debuggable error and a hidden
   one when a customer's credentials change.

3. Absolute Renew TerminationTime (review F1 wire-correctness)
   renewPullPoint now sends an RFC3339 UTC datetime
   ('2026-05-21T10:30:00Z') instead of a relative xsd:duration
   ('PT60S'). WS-BaseNotification §6.1.1 accepts both, but older
   Hikvision, some Dahua and Bosch firmwares only accept the
   absolute form — the library's own type comment even flags this
   ('BUG(r) Bad AbsoluteOrRelativeTimeType type').

4. Bounded Close (review P0)
   Close now wraps Unsubscribe in a 5s timeout. Previously a
   TCP-accepted-but-never-replying camera would wedge Close
   indefinitely; now Close returns with a timeout error and the
   subscription expires on its own at InitialTermination.

5. Explicit channel-close ordering after wg.Wait
   The run goroutine previously relied on defer-LIFO to guarantee
   renew exits before close(errors). Future maintainers extending
   run() could invert that order silently. Closes are now explicit
   sequential statements after wg.Wait() so the invariant is
   local, not order-of-defers magic.

Also expands wsnt:UtcTime parsing in decode.go to cover the four
formats observed across vendor firmwares: RFC3339 with sub-seconds,
compact offsets ('+0200', Geovision/Dahua), and naked timestamps
without timezone (older Hikvision; per spec UTC is implied).

Caller interface gains a doc comment noting it must be safe for
concurrent use, documenting the contract Stream depends on (*onvif.
Device satisfies it via http.Client).

Tests added: SOAP 1.1 and 1.2 fault extraction, fault surfacing
through unmarshalNode, Renew absolute-datetime assertion,
Close-with-blocked-Unsubscribe returning within the timeout. -race
clean.
2026-05-21 14:54:05 +02:00

354 lines
11 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. Used to
// verify Close's timeout path.
type fakeCaller struct {
mu sync.Mutex
callMethodResps []fakeResp
sendSoapResps []fakeResp
defaultSendSoap fakeResp
defaultCall fakeResp
callMethodCalls []any
sendSoapCalls [][2]string
blockUnsubscribe 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:]
}
if r.err != nil {
return nil, r.err
}
return &http.Response{Body: io.NopCloser(strings.NewReader(r.body))}, nil
}
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
f.mu.Unlock()
if block != nil && strings.Contains(body, "Unsubscribe") {
<-block
}
if r.err != nil {
return nil, r.err
}
return &http.Response{Body: io.NopCloser(strings.NewReader(r.body))}, nil
}
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:CurrentTime>2026-05-21T10:30:00Z</tev:CurrentTime>
<tev:TerminationTime>2026-05-21T10:31:00Z</tev:TerminationTime>
</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, 10, 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()
})
}