Files
zoneminder/tests/zm_onvif_renewal.cpp
T
Isaac ConnorandClaude Opus 5.5 82901ac1fb fix: schedule ONVIF subscription renewal for short and unreported lifetimes (#5181)
* fix: schedule ONVIF subscription renewal for short and unreported lifetimes refs #5179

The Beward camera from #5165 grants a 60 second subscription and its
RenewResponse carries no TerminationTime. Three things combined so that
ZM renewed after every PullMessages that returned messages (61 renewals
in 74 seconds of the reporter's log):

- The renewal was scheduled a fixed ONVIF_RENEWAL_ADVANCE_SECONDS (60)
  before termination, which for a 60 second subscription is its creation
  time. ONVIFNextRenewalTime() now uses the smaller of that advance and
  half the remaining lifetime.
- Renew() only logged a missing TerminationTime and left
  next_renewal_time in the past. assume_renewal_times() now schedules
  from the lifetime we asked for, capped at what the camera last granted
  (ONVIFAssumedLifetime()), so this camera renews every 30 seconds.
- IsRenewalNeeded() was only checked after a response carrying messages,
  so a camera quiet for longer than its subscription was never renewed.
  It is now also checked after an empty (SOAP_EOF) poll.

Since renewal is now checked after every poll, a camera that answers
Renew with ActionNotSupported has renewal disabled rather than being
asked again on each poll, matching the existing handling of unusable
TerminationTimes.

Tests cover the renewal time for long, short and very short
subscriptions, and the assumed lifetime with no, smaller and larger
previously granted lifetimes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: only disable ONVIF renewal for ActionNotSupported, keep sub-second grants refs #5179

Review follow-ups on the renewal scheduling change:

- Renew() treated soap->error == 12 as ActionNotSupported, but 12 is
  gSOAP's generic SOAP_FAULT. With renewal now disabled on that path, a
  NotAuthorized or InvalidArgVal fault from Renew would have stopped
  renewal for good while marking the subscription healthy.
  ONVIFIsActionNotSupported() checks the fault subcode and string for
  ActionNotSupported (wsa: or ter:); any other fault now takes the
  existing cleanup and re-subscribe path.
- granted_lifetime truncated the remaining time to whole seconds. A
  termination less than a second away stored 0, which reads as "never
  reported", so the next Renew without a TerminationTime assumed the full
  requested 300 seconds. ONVIFGrantedLifetime() rounds up instead.

Tests cover ActionNotSupported subcodes and strings against other faults
and non-fault results, and the granted lifetime for whole, fractional and
sub-second remainders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: count an assumed ONVIF renewal lifetime from the request, not the response refs #5179

When a RenewResponse has no TerminationTime, assume_renewal_times()
started the assumed lifetime when the response arrived, but the camera
counts it from when it received the request. A slow response pushed the
deadline and the next renewal later by the response time: with
subscription_timeout=10 and a 6 second response, the camera's deadline
was request+10 but the renewal was scheduled for request+11. In absolute
renewal mode it also ignored the exact deadline that was sent.

Renew() now records the time just before building the request and the
deadline it asks for: request time plus subscription_timeout, or the
absolute whole-second time it sends. ONVIFAssumedTermination() replaces
ONVIFAssumedLifetime() and returns that deadline, capped at request time
plus the lifetime the camera last granted. The renewal is scheduled from
the request time; if the response arrived after it, it is already due and
the next poll renews.

Tests cover no, smaller and larger previous grants, an absolute deadline
kept exactly, and the slow-response case from review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:33:00 -04:00

471 lines
18 KiB
C++

/*
* This file is part of the ZoneMinder Project. See AUTHORS file for Copyright information
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "zm_catch2.h"
#include "zm_monitor_onvif.h"
#include "zm_time.h"
#include <chrono>
#include <string>
#include <unordered_map>
// Test the ONVIF subscription renewal timing logic
TEST_CASE("ONVIF Subscription Renewal Timing") {
SECTION("Calculate renewal time from termination time") {
// Simulate a termination time 60 seconds from now
auto now = std::chrono::system_clock::now();
time_t termination_time_t = std::chrono::system_clock::to_time_t(
now + std::chrono::seconds(60));
// Convert to SystemTimePoint
SystemTimePoint termination_time = std::chrono::system_clock::from_time_t(termination_time_t);
// Calculate renewal time (10 seconds before termination)
SystemTimePoint renewal_time = termination_time - std::chrono::seconds(10);
// Check that renewal time is 50 seconds from now (60 - 10)
auto seconds_until_renewal = std::chrono::duration_cast<std::chrono::seconds>(
renewal_time - now).count();
// Allow 1 second tolerance for test execution time
REQUIRE(seconds_until_renewal >= 49);
REQUIRE(seconds_until_renewal <= 51);
}
SECTION("Check if renewal is needed - not yet time") {
auto now = std::chrono::system_clock::now();
// Renewal time is 30 seconds in the future
SystemTimePoint renewal_time = now + std::chrono::seconds(30);
// Should not need renewal yet
bool renewal_needed = (now >= renewal_time);
REQUIRE_FALSE(renewal_needed);
}
SECTION("Check if renewal is needed - time has come") {
auto now = std::chrono::system_clock::now();
// Renewal time was 1 second ago
SystemTimePoint renewal_time = now - std::chrono::seconds(1);
// Should need renewal
bool renewal_needed = (now >= renewal_time);
REQUIRE(renewal_needed);
}
SECTION("Check if renewal times are uninitialized") {
// Default constructed SystemTimePoint has epoch (0)
SystemTimePoint uninitialized_time;
bool is_uninitialized = (uninitialized_time.time_since_epoch().count() == 0);
REQUIRE(is_uninitialized);
}
SECTION("Time conversion round-trip") {
// Test that time_t -> SystemTimePoint -> time_t conversion is accurate
time_t original_time = 1704844800; // 2024-01-10 00:00:00 UTC
SystemTimePoint tp = std::chrono::system_clock::from_time_t(original_time);
time_t converted_time = std::chrono::system_clock::to_time_t(tp);
REQUIRE(original_time == converted_time);
}
}
// Test the ONVIF subscription cleanup logic
// Note: These tests document the expected behavior. Full integration testing
// with actual ONVIF cameras would require a mock SOAP server.
TEST_CASE("ONVIF Subscription Cleanup Logic") {
SECTION("Cleanup should prevent subscription leaks on renewal failure") {
// When Renew() fails (non-ActionNotSupported error), cleanup_subscription()
// should be called to unsubscribe from the camera before returning false.
// This prevents orphaned subscriptions from accumulating on the camera.
//
// Expected behavior verified in zm_monitor_onvif.cpp:
// 1. Renew() calls proxyEvent.Renew()
// 2. If result != SOAP_OK and error != 12 (ActionNotSupported):
// a. Log the renewal failure
// b. Call cleanup_subscription() to unsubscribe
// c. Set healthy = false
// d. Return false
REQUIRE(true); // Behavior verified through code inspection
}
SECTION("Cleanup should be called before creating new subscription in start()") {
// When start() is called and soap != nullptr (from previous failed attempt),
// cleanup_subscription() should be called before creating a new subscription.
// This ensures any stale subscription is cleaned up first.
//
// Expected behavior verified in zm_monitor_onvif.cpp:
// 1. start() checks if soap != nullptr at beginning
// 2. If true:
// a. Log that existing soap context was found
// b. Call cleanup_subscription() to unsubscribe from stale subscription
// c. Clean up the old soap context (disable logging, destroy, end, free)
// d. Set soap = nullptr
// 3. Then proceed with normal subscription creation
REQUIRE(true); // Behavior verified through code inspection
}
SECTION("Destructor should log unsubscribe failures") {
// The destructor should check the result of Unsubscribe() and log warnings
// if it fails, helping identify cameras that don't properly handle cleanup.
//
// Expected behavior verified in zm_monitor_onvif.cpp:
// 1. Destructor attempts to unsubscribe
// 2. Captures result from proxyEvent.Unsubscribe()
// 3. If result != SOAP_OK:
// a. Log a Warning with error details
// b. Indicate that subscription may remain on camera
// 4. If result == SOAP_OK:
// a. Log Debug message confirming successful unsubscribe
REQUIRE(true); // Behavior verified through code inspection
}
SECTION("WS-Addressing failure in Renew should trigger cleanup") {
// If do_wsa_request() fails during Renew(), cleanup_subscription() should
// be called before returning false to prevent subscription leaks.
//
// Expected behavior verified in zm_monitor_onvif.cpp:
// 1. Renew() calls do_wsa_request() if WS-Addressing is enabled
// 2. If do_wsa_request() returns false:
// a. Log that WS-Addressing setup failed
// b. Call cleanup_subscription()
// c. Set healthy = false
// d. Return false
REQUIRE(true); // Behavior verified through code inspection
}
}
// Test the ISO 8601 absolute time formatting for ONVIF renewal requests
TEST_CASE("ONVIF Absolute Time Formatting") {
SECTION("Format known timestamp as ISO 8601") {
// Test with known timestamp: 2024-01-13 13:14:56 UTC
time_t test_time = 1705151696; // 2024-01-13 13:14:56 UTC
std::string result = format_absolute_time_iso8601(test_time);
// Should be formatted as ISO 8601 with .000Z suffix
REQUIRE(result == "2024-01-13T13:14:56.000Z");
}
SECTION("Format current time as ISO 8601") {
time_t now = time(nullptr);
std::string result = format_absolute_time_iso8601(now);
// Should not be empty
REQUIRE_FALSE(result.empty());
// Should have expected format with 'T' separator and 'Z' suffix
REQUIRE(result.find('T') != std::string::npos);
REQUIRE(result.find('Z') != std::string::npos);
REQUIRE(result.back() == 'Z');
// Should have the correct length (YYYY-MM-DDTHH:MM:SS.000Z = 24 characters)
REQUIRE(result.length() == 24);
}
SECTION("Format future time for renewal") {
// Simulate renewal: current time + 60 seconds
time_t now = time(nullptr);
time_t renewal_time = now + 60;
std::string result = format_absolute_time_iso8601(renewal_time);
// Should not be empty
REQUIRE_FALSE(result.empty());
// Should have expected format
REQUIRE(result.find('T') != std::string::npos);
REQUIRE(result.find('Z') != std::string::npos);
REQUIRE(result.length() == 24);
}
SECTION("Verify ISO 8601 format components") {
time_t test_time = 1705151696; // 2024-01-13 13:14:56 UTC
std::string result = format_absolute_time_iso8601(test_time);
// Check year
REQUIRE(result.substr(0, 4) == "2024");
// Check separators
REQUIRE(result[4] == '-'); // After year
REQUIRE(result[7] == '-'); // After month
REQUIRE(result[10] == 'T'); // Date/time separator
REQUIRE(result[13] == ':'); // After hour
REQUIRE(result[16] == ':'); // After minute
REQUIRE(result[19] == '.'); // After second
REQUIRE(result[23] == 'Z'); // UTC indicator
}
}
// Standalone AlarmEntry struct matching the one in zm_monitor_onvif.h.
// We replicate it here so tests don't depend on gSOAP headers.
namespace onvif_test {
struct AlarmEntry {
std::string value;
SystemTimePoint termination_time;
};
using AlarmMap = std::unordered_map<std::string, AlarmEntry>;
// Mirror of ONVIF::expire_stale_alarms logic for unit testing.
// Returns true if the map became empty (caller should setAlarmed(false)).
bool expire_stale_alarms(AlarmMap &alarms, const SystemTimePoint &now) {
auto it = alarms.begin();
while (it != alarms.end()) {
// Skip entries with no termination time set (epoch = uninitialized)
if (it->second.termination_time.time_since_epoch().count() == 0) {
++it;
continue;
}
if (it->second.termination_time <= now) {
it = alarms.erase(it);
} else {
++it;
}
}
return alarms.empty();
}
} // namespace onvif_test
// Test per-topic TerminationTime alarm expiry logic
TEST_CASE("ONVIF Per-Topic Alarm Expiry") {
using namespace onvif_test;
auto now = std::chrono::system_clock::now();
SECTION("Expired alarms are removed by sweep") {
AlarmMap alarms;
// Alarm with TerminationTime 10 seconds in the past
alarms["PeopleDetect"] = AlarmEntry{"true", now - std::chrono::seconds(10)};
bool empty = expire_stale_alarms(alarms, now);
REQUIRE(alarms.empty());
REQUIRE(empty);
}
SECTION("Future alarms are retained by sweep") {
AlarmMap alarms;
// Alarm with TerminationTime 60 seconds in the future
alarms["MotionAlarm"] = AlarmEntry{"true", now + std::chrono::seconds(60)};
bool empty = expire_stale_alarms(alarms, now);
REQUIRE(alarms.size() == 1);
REQUIRE_FALSE(empty);
}
SECTION("Mixed expired and future alarms") {
AlarmMap alarms;
alarms["PeopleDetect"] = AlarmEntry{"true", now - std::chrono::seconds(10)};
alarms["MotionAlarm"] = AlarmEntry{"true", now + std::chrono::seconds(60)};
bool empty = expire_stale_alarms(alarms, now);
REQUIRE(alarms.size() == 1);
REQUIRE(alarms.count("MotionAlarm") == 1);
REQUIRE(alarms.count("PeopleDetect") == 0);
REQUIRE_FALSE(empty);
}
SECTION("Re-triggering an alarm updates its TerminationTime") {
AlarmMap alarms;
// Initial alarm with TerminationTime 5 seconds from now
SystemTimePoint initial_term = now + std::chrono::seconds(5);
alarms["PeopleDetect"] = AlarmEntry{"true", initial_term};
// Simulate re-trigger with new TerminationTime 65 seconds from now
SystemTimePoint new_term = now + std::chrono::seconds(65);
alarms["PeopleDetect"] = AlarmEntry{"true", new_term};
// Sweep at now+10s - alarm should NOT be expired because it was refreshed
SystemTimePoint sweep_time = now + std::chrono::seconds(10);
bool empty = expire_stale_alarms(alarms, sweep_time);
REQUIRE(alarms.size() == 1);
REQUIRE_FALSE(empty);
// Verify the termination time was updated
REQUIRE(alarms["PeopleDetect"].termination_time == new_term);
}
SECTION("Alarms with epoch termination time (uninitialized) are not expired") {
AlarmMap alarms;
// Alarm with default-constructed (epoch) termination time
alarms["SomeAlarm"] = AlarmEntry{"true", SystemTimePoint{}};
bool empty = expire_stale_alarms(alarms, now);
REQUIRE(alarms.size() == 1);
REQUIRE_FALSE(empty);
}
SECTION("TerminationTime exactly equal to now is expired") {
AlarmMap alarms;
alarms["PeopleDetect"] = AlarmEntry{"true", now};
bool empty = expire_stale_alarms(alarms, now);
REQUIRE(alarms.empty());
REQUIRE(empty);
}
SECTION("Multiple expired alarms are all removed") {
AlarmMap alarms;
alarms["PeopleDetect"] = AlarmEntry{"true", now - std::chrono::seconds(30)};
alarms["VehicleDetect"] = AlarmEntry{"true", now - std::chrono::seconds(20)};
alarms["DogCatDetect"] = AlarmEntry{"true", now - std::chrono::seconds(10)};
bool empty = expire_stale_alarms(alarms, now);
REQUIRE(alarms.empty());
REQUIRE(empty);
}
SECTION("Empty alarms map is handled gracefully") {
AlarmMap alarms;
bool empty = expire_stale_alarms(alarms, now);
REQUIRE(empty);
}
SECTION("AlarmEntry stores value correctly") {
AlarmEntry entry{"true", now + std::chrono::seconds(60)};
REQUIRE(entry.value == "true");
AlarmEntry entry2{"false", now};
REQUIRE(entry2.value == "false");
}
SECTION("Alarm value accessible via map for SetNoteSet") {
AlarmMap alarms;
alarms["MyRuleDetector/PeopleDetect"] = AlarmEntry{"true", now + std::chrono::seconds(60)};
// Simulate SetNoteSet logic: iterate and access .value
for (auto it = alarms.begin(); it != alarms.end(); ++it) {
std::string note = it->first + "/" + it->second.value;
REQUIRE(note == "MyRuleDetector/PeopleDetect/true");
}
}
}
#ifdef WITH_GSOAP
TEST_CASE("ONVIFNextRenewalTime", "[onvif]") {
const SystemTimePoint now = std::chrono::system_clock::from_time_t(1790880716);
SECTION("Long subscription renews 60 seconds before it ends") {
SystemTimePoint termination = now + std::chrono::seconds(300);
REQUIRE(ONVIFNextRenewalTime(now, termination) == now + std::chrono::seconds(240));
}
SECTION("Subscription no longer than the advance renews halfway through") {
// The #5179 Beward grants 60 seconds. A fixed 60 second advance put the
// renewal at the creation time, so it was due immediately.
SystemTimePoint termination = now + std::chrono::seconds(60);
REQUIRE(ONVIFNextRenewalTime(now, termination) == now + std::chrono::seconds(30));
termination = now + std::chrono::seconds(10);
REQUIRE(ONVIFNextRenewalTime(now, termination) == now + std::chrono::seconds(5));
}
SECTION("Renewal is always strictly in the future for a future termination") {
SystemTimePoint termination = now + std::chrono::seconds(1);
REQUIRE(ONVIFNextRenewalTime(now, termination) > now);
}
}
TEST_CASE("ONVIFAssumedTermination", "[onvif]") {
using std::chrono::seconds;
// The moment Renew() sent the request
const SystemTimePoint request_time = std::chrono::system_clock::from_time_t(1790880716);
const SystemTimePoint requested = request_time + seconds(300);
SECTION("Camera never reported a lifetime: assume the deadline we asked for") {
REQUIRE(ONVIFAssumedTermination(request_time, requested, seconds(0)) == requested);
}
SECTION("Camera granted less than we asked for before: assume it does so again") {
// The #5179 Beward granted 60 seconds on Subscribe and sends no
// TerminationTime on Renew.
REQUIRE(ONVIFAssumedTermination(request_time, requested, seconds(60)) == request_time + seconds(60));
}
SECTION("Camera granted more than we asked for: assume only what we asked for") {
REQUIRE(ONVIFAssumedTermination(request_time, requested, seconds(3600)) == requested);
}
SECTION("An absolute deadline we sent is kept exactly") {
// Absolute renewal requests a whole-second time, which can be slightly
// less than request_time + subscription_timeout.
const SystemTimePoint absolute = std::chrono::system_clock::from_time_t(1790880716 + 10);
REQUIRE(ONVIFAssumedTermination(request_time + std::chrono::milliseconds(700), absolute, seconds(0)) == absolute);
}
SECTION("A slow response does not push the deadline or renewal later") {
// subscription_timeout=10 and the response arrives 6 seconds after the
// request. Counting from the response put the renewal at request+11,
// after the camera's deadline at request+10.
const SystemTimePoint deadline = request_time + seconds(10);
const SystemTimePoint response_time = request_time + seconds(6);
SystemTimePoint termination = ONVIFAssumedTermination(request_time, deadline, seconds(0));
REQUIRE(termination == deadline);
SystemTimePoint renewal = ONVIFNextRenewalTime(request_time, termination);
REQUIRE(renewal < termination);
// Already due when the response arrives, so the next poll renews
REQUIRE(renewal <= response_time);
}
}
TEST_CASE("ONVIFGrantedLifetime", "[onvif]") {
// Termination times have whole-second precision; now does not.
const SystemTimePoint termination = std::chrono::system_clock::from_time_t(1790880776);
SECTION("Whole seconds are kept") {
REQUIRE(ONVIFGrantedLifetime(termination - std::chrono::seconds(60), termination) == std::chrono::seconds(60));
}
SECTION("A fraction of a second rounds up, not down") {
REQUIRE(ONVIFGrantedLifetime(termination - std::chrono::milliseconds(59200), termination) == std::chrono::seconds(60));
}
SECTION("Less than a second left is still a known, positive lifetime") {
// Truncating to zero would read as "never reported" and make the next
// TerminationTime-less Renew assume the full requested lifetime.
REQUIRE(ONVIFGrantedLifetime(termination - std::chrono::milliseconds(300), termination) == std::chrono::seconds(1));
const SystemTimePoint request_time = termination - std::chrono::milliseconds(300);
REQUIRE(ONVIFAssumedTermination(request_time, request_time + std::chrono::seconds(300),
ONVIFGrantedLifetime(request_time, termination))
== request_time + std::chrono::seconds(1));
}
}
TEST_CASE("ONVIFIsActionNotSupported", "[onvif]") {
SECTION("WS-Addressing and ONVIF ActionNotSupported faults") {
REQUIRE(ONVIFIsActionNotSupported(SOAP_FAULT, "wsa:ActionNotSupported", nullptr));
REQUIRE(ONVIFIsActionNotSupported(SOAP_FAULT, "wsa5:ActionNotSupported", nullptr));
REQUIRE(ONVIFIsActionNotSupported(SOAP_FAULT, "ter:ActionNotSupported", "Optional Action Not Implemented"));
REQUIRE(ONVIFIsActionNotSupported(SOAP_FAULT, nullptr, "ActionNotSupported"));
}
SECTION("Other SOAP faults are not ActionNotSupported") {
// SOAP_FAULT (12) covers every fault. Treating them all as unsupported
// disabled renewal for good after e.g. an authorization failure.
REQUIRE_FALSE(ONVIFIsActionNotSupported(SOAP_FAULT, "ter:NotAuthorized", "Sender not authorized"));
REQUIRE_FALSE(ONVIFIsActionNotSupported(SOAP_FAULT, "ter:InvalidArgVal", nullptr));
REQUIRE_FALSE(ONVIFIsActionNotSupported(SOAP_FAULT, nullptr, nullptr));
}
SECTION("Non-fault results are never ActionNotSupported") {
REQUIRE_FALSE(ONVIFIsActionNotSupported(SOAP_EOF, "wsa:ActionNotSupported", nullptr));
REQUIRE_FALSE(ONVIFIsActionNotSupported(401, nullptr, "ActionNotSupported"));
}
}
#endif // WITH_GSOAP