mirror of
https://github.com/kerberos-io/onvif.git
synced 2026-08-23 15:08:33 +00:00
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.
354 lines
11 KiB
Go
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()
|
|
})
|
|
}
|