diff --git a/src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs b/src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs index 1b408f7..aa37465 100644 --- a/src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs +++ b/src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Security; @@ -25,14 +26,18 @@ namespace OpenIPC.Viewer.Devices.Onvif; /// the "XmlType reflection error" on Onvif.Core.Client.Common.DeviceEntity. /// Same contract, so the swap is one DI registration. /// -/// Auth mirrors the old builder: preemptive HTTP Basic (OpenIPC's -/// onvif_simple_server enforces it at the transport) plus a WS-Security -/// UsernameToken password digest. GetSystemDateAndTime (unauthenticated) yields -/// the camera clock offset the digest's Created stamp needs; it's cached per host. +/// Auth is three things at once, because cameras disagree about which they +/// want: preemptive HTTP Basic (OpenIPC's onvif_simple_server enforces it at +/// the transport and never challenges), HTTP Digest answered on a 401 by an +/// whose handler carries the credentials, and a +/// WS-Security UsernameToken password digest in the envelope. +/// GetSystemDateAndTime (unauthenticated) yields the camera clock offset the +/// token's Created stamp needs; it's cached per host. /// public sealed class SoapOnvifClient : IOnvifClient { private const string Soap = "http://www.w3.org/2003/05/soap-envelope"; + private const string Soap11 = "http://schemas.xmlsoap.org/soap/envelope/"; private const string Tds = "http://www.onvif.org/ver10/device/wsdl"; private const string Trt = "http://www.onvif.org/ver10/media/wsdl"; private const string Tptz = "http://www.onvif.org/ver20/ptz/wsdl"; @@ -52,19 +57,71 @@ public sealed class SoapOnvifClient : IOnvifClient // first authed call, refreshed on an auth fault. private readonly ConcurrentDictionary _shiftByHost = new(StringComparer.OrdinalIgnoreCase); + // Hosts that turned out to speak SOAP 1.1 only. Learned from the retry the + // first time a host answers 1.2 with nothing usable, then used as the first + // choice — so the discovery costs one extra request per host, ever, and a + // state-changing call is never the one doing the discovering. + private readonly ConcurrentDictionary _soap11Hosts = new(StringComparer.OrdinalIgnoreCase); + + // One client per camera address. A handler that carries credentials is what + // lets HttpClient answer a 401 challenge on its own, which is the only way + // to satisfy a camera that asks for Digest rather than Basic. + // + // Keyed by host:port alone — a camera has one credential at a time — with + // the credential kept beside the client so a password change swaps the + // entry and disposes the superseded one, instead of caching every password + // this process has ever seen. Growth is bounded by the number of camera + // addresses. A plain lock rather than GetOrAdd: it also stops a concurrent + // miss from constructing a second client that nothing would ever dispose. + private readonly object _clientsGate = new(); + private readonly Dictionary _authedClients = new(StringComparer.Ordinal); + public SoapOnvifClient(ILogger logger) { _logger = logger; + _http = NewClient(credentials: null); + } + + private static HttpClient NewClient(NetworkCredential? credentials) + { // onvif_simple_server is CGI-style: one request per connection, then it // closes the socket. Disable pooling so we never reuse a dead socket. - _http = new HttpClient(new SocketsHttpHandler + var handler = new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.Zero, ConnectTimeout = CallTimeout, - }) - { - Timeout = CallTimeout, }; + if (credentials is not null) + { + handler.Credentials = credentials; + // Let the camera state its terms first: preemptive auth would send + // Basic to a device that only accepts Digest. + handler.PreAuthenticate = false; + } + return new HttpClient(handler) { Timeout = CallTimeout }; + } + + private HttpClient ClientFor(Uri service, CameraCredentials? credentials) + { + if (credentials is not { } c || string.IsNullOrEmpty(c.Username)) return _http; + + var key = $"{service.Host}:{service.Port}"; + var credential = $"{c.Username}\u0000{c.Password}"; + lock (_clientsGate) + { + if (_authedClients.TryGetValue(key, out var entry)) + { + if (entry.Credential == credential) return entry.Client; + // The password changed. A request in flight on the old client + // was sent with the old password and is failing anyway, so + // disposing under it loses nothing. + entry.Client.Dispose(); + } + + var client = NewClient(new NetworkCredential(c.Username, c.Password ?? string.Empty)); + _authedClients[key] = (credential, client); + return client; + } } // --- Device service ----------------------------------------------------- @@ -126,7 +183,11 @@ public async Task GetStreamUriAsync(OnvifEndpoint endpoint, string profileT $"{Escape(profileToken)}"; var body = await CallAuthedAsync(media, endpoint, $"{Trt}/GetStreamUri", reqBody, ct).ConfigureAwait(false); - var uri = Value(body, "Uri"); + // The spec nests this as MediaUri/Uri, and Hikvision (among others) sends + // exactly that. Reading it as a direct child only matched the flatter + // shape onvif_simple_server returns, so a compliant camera looked like + // it had answered with no stream at all. + var uri = Descendant(body, "Uri")?.Value; if (string.IsNullOrWhiteSpace(uri)) throw new InvalidOperationException($"GetStreamUri returned no URI for profile {profileToken}"); return new Uri(uri, UriKind.Absolute); @@ -193,8 +254,11 @@ public async Task SetPresetAsync(OnvifEndpoint endpoint, string profileT $"" + $"{Escape(profileToken)}" + $"{Escape(name)}"; - var body = await CallAuthedAsync(ptz, endpoint, $"{Tptz}/SetPreset", reqBody, ct).ConfigureAwait(false); - return Value(body, "PresetToken") ?? string.Empty; + // retryable: false — if the camera ran the request and answered + // garbage, a resend would create a second preset. + var body = await CallAuthedAsync(ptz, endpoint, $"{Tptz}/SetPreset", reqBody, ct, retryable: false).ConfigureAwait(false); + // Nested the same way on some firmwares, for the same reason. + return Descendant(body, "PresetToken")?.Value ?? string.Empty; } public async Task RemovePresetAsync(OnvifEndpoint endpoint, string profileToken, string presetToken, CancellationToken ct) @@ -204,7 +268,10 @@ public async Task RemovePresetAsync(OnvifEndpoint endpoint, string profileToken, $"" + $"{Escape(profileToken)}" + $"{Escape(presetToken)}"; - await CallAuthedAsync(ptz, endpoint, $"{Tptz}/RemovePreset", reqBody, ct).ConfigureAwait(false); + // retryable: false — a resend after a successful-but-unreadable remove + // would fault on the now-missing preset and report failure for a + // removal that worked. + await CallAuthedAsync(ptz, endpoint, $"{Tptz}/RemovePreset", reqBody, ct, retryable: false).ConfigureAwait(false); } // --- Transport ---------------------------------------------------------- @@ -231,25 +298,30 @@ private async Task ResolveServiceAsync(OnvifEndpoint endpoint, ServiceKind // Authenticated call with a per-host clock shift; on a fault, refresh the // shift once and retry (covers a stale/absent offset causing digest rejection). - private async Task CallAuthedAsync(Uri service, OnvifEndpoint endpoint, string action, string body, CancellationToken ct) + private async Task CallAuthedAsync(Uri service, OnvifEndpoint endpoint, string action, string body, CancellationToken ct, bool retryable = true) { var host = endpoint.DeviceServiceUri.Host; if (!_shiftByHost.TryGetValue(host, out var shift)) { + // Also where the host's SOAP dialect gets discovered, since this + // probe runs before the first real call — so by the time a mutation + // goes out, the dialect is already known. shift = await GetTimeShiftAsync(endpoint.DeviceServiceUri, ct).ConfigureAwait(false); _shiftByHost[host] = shift; } try { - return await CallAsync(service, action, body, endpoint.Credentials, shift, ct).ConfigureAwait(false); + return await CallAsync(service, action, body, endpoint.Credentials, shift, retryable, ct).ConfigureAwait(false); } catch (OnvifFaultException) { - // Maybe the clock drifted / the first shift was wrong — recompute and retry once. + // Maybe the clock drifted / the first shift was wrong — recompute and + // retry once. Safe for mutations too: a fault means the camera + // refused the request, not that it ran it. var fresh = await GetTimeShiftAsync(endpoint.DeviceServiceUri, ct).ConfigureAwait(false); _shiftByHost[host] = fresh; - return await CallAsync(service, action, body, endpoint.Credentials, fresh, ct).ConfigureAwait(false); + return await CallAsync(service, action, body, endpoint.Credentials, fresh, retryable, ct).ConfigureAwait(false); } } @@ -259,7 +331,7 @@ private async Task GetTimeShiftAsync(Uri deviceService, CancellationTo { var body = await CallAsync(deviceService, $"{Tds}/GetSystemDateAndTime", $"", - credentials: null, shift: TimeSpan.Zero, ct).ConfigureAwait(false); + credentials: null, shift: TimeSpan.Zero, retryable: true, ct).ConfigureAwait(false); var utc = Descendant(body, "UTCDateTime"); var date = Child(utc, "Date"); @@ -280,30 +352,41 @@ private async Task GetTimeShiftAsync(Uri deviceService, CancellationTo } } - private async Task CallAsync(Uri service, string action, string body, CameraCredentials? credentials, TimeSpan shift, CancellationToken ct) + private async Task CallAsync(Uri service, string action, string body, CameraCredentials? credentials, TimeSpan shift, bool retryable, CancellationToken ct) { - var header = SecurityHeader(credentials, shift); - var envelope = - "" + - $"{header}{body}"; - - using var req = new HttpRequestMessage(HttpMethod.Post, service); - req.Headers.ConnectionClose = true; - if (credentials is { } c && !string.IsNullOrEmpty(c.Username)) + // SOAP 1.2 first — the version ONVIF specifies — unless this host has + // already shown it only answers 1.1. A camera that answers the first + // choice with nothing usable gets one retry in the other dialect, which + // several firmwares need and which costs one request to find out. The + // winner is remembered per host, so the discovery happens once. + // + // Except for mutations (retryable: false). An unusable response does + // not prove the request was not executed — a camera that ran SetPreset + // and then answered garbage would get a duplicate preset from a resend. + // Mutations rely on the dialect already learned from this host's + // earlier read calls (the clock probe at minimum) and fail honestly + // rather than guessing. + var soap12First = !_soap11Hosts.ContainsKey(service.Host); + var (status, text) = await SendAsync(service, action, body, credentials, shift, soap12: soap12First, ct) + .ConfigureAwait(false); + + if (!IsUsable(text) && retryable) { - var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{c.Username}:{c.Password}")); - req.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic); + _logger.LogDebug("ONVIF {Action}: SOAP {First} gave HTTP {Status} and {Length} bytes; retrying as SOAP {Second}", + action, soap12First ? "1.2" : "1.1", (int)status, text.Length, soap12First ? "1.1" : "1.2"); + (status, text) = await SendAsync(service, action, body, credentials, shift, soap12: !soap12First, ct) + .ConfigureAwait(false); + + if (IsUsable(text)) + { + // The other dialect is the one this host speaks; remember it in + // whichever direction the flip went. + if (soap12First) _soap11Hosts[service.Host] = 1; + else _soap11Hosts.TryRemove(service.Host, out _); + } } - var content = new StringContent(envelope, Encoding.UTF8); - content.Headers.ContentType = new MediaTypeHeaderValue("application/soap+xml") { CharSet = "utf-8" }; - content.Headers.ContentType.Parameters.Add(new NameValueHeaderValue("action", $"\"{action}\"")); - req.Content = content; - - using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseContentRead, ct).ConfigureAwait(false); - var text = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(text)) - throw new InvalidOperationException($"ONVIF {action}: empty response (HTTP {(int)resp.StatusCode})"); + if (string.IsNullOrWhiteSpace(text)) throw EmptyBody(action, status); XElement root; try { root = XDocument.Parse(text).Root!; } @@ -311,7 +394,11 @@ private async Task CallAsync(Uri service, string action, string body, var bodyEl = Child(Child(root, "Body"), null); if (bodyEl is null) - throw new InvalidOperationException($"ONVIF {action}: empty SOAP body"); + { + _logger.LogDebug("ONVIF {Action}: HTTP {Status}, body: {Body}", + action, (int)status, text.Length > 400 ? text[..400] : text); + throw EmptyBody(action, status); + } if (bodyEl.Name.LocalName == "Fault") { var reason = Descendant(bodyEl, "Text")?.Value @@ -322,6 +409,66 @@ private async Task CallAsync(Uri service, string action, string body, return bodyEl; } + private async Task<(HttpStatusCode Status, string Text)> SendAsync( + Uri service, string action, string body, CameraCredentials? credentials, + TimeSpan shift, bool soap12, CancellationToken ct) + { + var header = SecurityHeader(credentials, shift); + var envelope = + "" + + $"{header}{body}"; + + using var req = new HttpRequestMessage(HttpMethod.Post, service); + req.Headers.ConnectionClose = true; + if (credentials is { } c && !string.IsNullOrEmpty(c.Username)) + { + // Preemptive Basic for onvif_simple_server, which enforces it at the + // transport and never challenges. A camera that wants Digest answers + // 401 instead, and the handler's credentials settle that exchange. + var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{c.Username}:{c.Password}")); + req.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic); + } + + var content = new StringContent(envelope, Encoding.UTF8); + if (soap12) + { + content.Headers.ContentType = new MediaTypeHeaderValue("application/soap+xml") { CharSet = "utf-8" }; + content.Headers.ContentType.Parameters.Add(new NameValueHeaderValue("action", $"\"{action}\"")); + } + else + { + // SOAP 1.1 has no action parameter on the content type; it travels + // in a header of its own. + content.Headers.ContentType = new MediaTypeHeaderValue("text/xml") { CharSet = "utf-8" }; + req.Headers.TryAddWithoutValidation("SOAPAction", $"\"{action}\""); + } + req.Content = content; + + using var resp = await ClientFor(service, credentials) + .SendAsync(req, HttpCompletionOption.ResponseContentRead, ct).ConfigureAwait(false); + return (resp.StatusCode, await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false) ?? string.Empty); + } + + // Worth reading: it parses, and its Body holds something. A firmware built + // for SOAP 1.1 typically answers a 1.2 request with no bytes at all or with + // an envelope whose Body is empty, and both mean "ask again differently". + // A fault is a usable answer — a camera that says why it refused is not + // asked twice. + private static bool IsUsable(string text) + { + if (string.IsNullOrWhiteSpace(text)) return false; + try { return Child(Child(XDocument.Parse(text).Root!, "Body"), null) is not null; } + catch (Exception) { return false; } + } + + // An empty body is what a camera sends when it will not say why. In + // practice it means ONVIF is switched off in the camera's own settings or + // the account has no ONVIF rights — neither of which arrives as a fault, so + // the message has to name them. The status code is the only other clue. + private static InvalidOperationException EmptyBody(string action, HttpStatusCode status) => + new($"ONVIF {action}: the camera returned an empty SOAP body (HTTP {(int)status}). " + + "Check that ONVIF is enabled on the camera and that this account may use it."); + private static string SecurityHeader(CameraCredentials? credentials, TimeSpan shift) { if (credentials is not { } c || string.IsNullOrEmpty(c.Username)) diff --git a/tests/OpenIPC.Viewer.Devices.Tests/Onvif/SoapOnvifClientInteropTests.cs b/tests/OpenIPC.Viewer.Devices.Tests/Onvif/SoapOnvifClientInteropTests.cs new file mode 100644 index 0000000..b428f0b --- /dev/null +++ b/tests/OpenIPC.Viewer.Devices.Tests/Onvif/SoapOnvifClientInteropTests.cs @@ -0,0 +1,186 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Devices.Onvif; + +namespace OpenIPC.Viewer.Devices.Tests.Onvif; + +// Interop with firmwares that do not behave like onvif_simple_server: they want +// Digest rather than Basic, or SOAP 1.1 rather than 1.2, or they nest the +// stream URI where the spec says it goes. All three fail the same unhelpful +// way — HTTP 200 with an empty SOAP body — so each is reproduced against a stub +// camera rather than taken on trust. +// +// Every call is preceded by an unauthenticated GetSystemDateAndTime (the clock +// probe the WS-Security digest needs), so assertions count the requests that +// carry the action under test rather than all of them. +public sealed class SoapOnvifClientInteropTests +{ + private static SoapOnvifClient NewClient() => new(NullLogger.Instance); + + [Fact] + public async Task ACameraThatAnswersSoap12WithNothing_IsRetriedAsSoap11() + { + using var camera = StubCamera.Start(req => + req.IsSoap12 ? (string.Empty, 200) : (Envelope11(Capabilities()), 200)); + + var caps = await NewClient().GetCapabilitiesAsync(camera.Endpoint(null), CancellationToken.None); + + Assert.NotNull(caps); + // The host's first exchange — the clock probe — is where the flip + // happens: 1.2, nothing usable, one retry as 1.1. Everything after + // leads with what that taught, so GetCapabilities is 1.1 on the first + // try rather than failing 1.2 again. + Assert.True(camera.Requests[0].IsSoap12); + Assert.True(camera.Requests[1].IsSoap11); + var capabilities = camera.Requests.Where(r => r.Is("GetCapabilities")).ToList(); + Assert.Single(capabilities); + Assert.True(capabilities[0].IsSoap11); + } + + // SOAP 1.1 carries the action in a header of its own rather than as a + // parameter on the content type. A camera that reads SOAPAction and finds + // nothing there rejects the call, so the retry would be pointless without it. + [Fact] + public async Task TheSoap11Retry_CarriesTheActionInItsOwnHeader() + { + using var camera = StubCamera.Start(req => + req.IsSoap12 ? (string.Empty, 200) : (Envelope11(Capabilities()), 200)); + + await NewClient().GetCapabilitiesAsync(camera.Endpoint(null), CancellationToken.None); + + var retry = camera.Requests.Last(r => r.Is("GetCapabilities")); + Assert.Contains("GetCapabilities", retry.SoapAction ?? "", StringComparison.Ordinal); + } + + // The Hikvision case. The camera refuses the preemptive Basic header and + // challenges for Digest; answering that is HttpClient's job, but only when + // the handler holds the credentials. + [Fact] + public async Task ADigestChallenge_IsAnswered() + { + using var camera = StubCamera.Start( + req => (req.Authorization ?? "").StartsWith("Digest", StringComparison.OrdinalIgnoreCase) + ? (Envelope12(Capabilities()), 200) + : (string.Empty, 401), + challenge: "Digest realm=\"IP Camera\", qop=\"auth\", nonce=\"4f3a2b1c\", stale=\"FALSE\""); + + var caps = await NewClient().GetCapabilitiesAsync( + camera.Endpoint(new CameraCredentials("admin", "secret")), CancellationToken.None); + + Assert.NotNull(caps); + Assert.Contains(camera.Requests, r => + r.Is("GetCapabilities") + && (r.Authorization ?? "").StartsWith("Digest", StringComparison.OrdinalIgnoreCase)); + } + + // Neither version got anywhere. "Empty SOAP body" is not something a user + // can act on; the two things worth checking on the camera are. + [Fact] + public async Task ACameraThatSaysNothingAtAll_FailsWithSomethingActionable() + { + using var camera = StubCamera.Start(_ => (string.Empty, 200)); + + var ex = await Assert.ThrowsAsync(() => + NewClient().GetCapabilitiesAsync(camera.Endpoint(null), CancellationToken.None)); + + Assert.Contains("ONVIF is enabled", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("account", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + // A fault is an answer. Asking again in another dialect would waste a round + // trip and bury what the camera actually said. + [Fact] + public async Task AFault_IsReportedAsWorded_AndNeverRetriedAsSoap11() + { + using var camera = StubCamera.Start(_ => (Envelope12( + "" + + "Sender not authorized"), 400)); + + // The fault type is private to the client, so the assertion is on what + // reaches the caller: the camera's own wording. + var ex = await Assert.ThrowsAnyAsync(() => + NewClient().GetCapabilitiesAsync(camera.Endpoint(null), CancellationToken.None)); + + Assert.Contains("Sender not authorized", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain(camera.Requests, r => r.IsSoap11); + } + + // GetStreamUriResponse/MediaUri/Uri is what the spec defines and what most + // cameras send. Reading Uri as a direct child matched only the flatter + // shape onvif_simple_server returns, so a working camera looked like it had + // no stream at all. + [Fact] + public async Task TheStreamUri_IsReadFromWhereTheSpecPutsIt() + { + StubCamera? camera = null; + camera = StubCamera.Start(req => req.Is("GetCapabilities") + // The media service has to be advertised somewhere the client can + // actually follow — this stub. + ? (Envelope12(Capabilities($"http://127.0.0.1:{camera!.Port}/onvif/media")), 200) + : (Envelope12( + "" + + "rtsp://10.16.33.231:554/Streaming/Channels/101" + + "false" + + ""), 200)); + using var _ = camera; + + var uri = await NewClient().GetStreamUriAsync(camera.Endpoint(null), "Profile_1", CancellationToken.None); + + Assert.Equal("rtsp://10.16.33.231:554/Streaming/Channels/101", uri.ToString()); + } + + // The discovery costs one request per host, ever: once a host has answered + // 1.1 after failing 1.2, later calls lead with 1.1 instead of failing 1.2 + // again first. + [Fact] + public async Task TheWorkingDialectIsRemembered() + { + using var camera = StubCamera.Start(req => + req.IsSoap12 ? (string.Empty, 200) : (Envelope11(Capabilities()), 200)); + + var client = NewClient(); + await client.GetCapabilitiesAsync(camera.Endpoint(null), CancellationToken.None); + await client.GetCapabilitiesAsync(camera.Endpoint(null), CancellationToken.None); + + // Only the very first request on the host — the clock probe — went out + // as 1.2; everything after used what that probe learned. + Assert.Equal(1, camera.Requests.Count(r => r.IsSoap12)); + } + + // An unusable response does not prove the request was not executed. A + // camera that ran SetPreset and answered garbage must not be asked again — + // the resend would create a second preset — so mutations fail honestly + // instead of retrying in the other dialect. + [Fact] + public async Task AMutation_IsNeverRetriedInAnotherDialect() + { + using var camera = StubCamera.Start(req => + req.Is("SetPreset") ? (string.Empty, 200) : (Envelope12(Capabilities()), 200)); + + await Assert.ThrowsAsync(() => + NewClient().SetPresetAsync(camera.Endpoint(null), "Profile_1", "Gate", CancellationToken.None)); + + Assert.Equal(1, camera.Requests.Count(r => r.Is("SetPreset"))); + } + + // --- helpers ------------------------------------------------------------ + + private static string Capabilities(string mediaXAddr = "http://127.0.0.1:1/onvif/media") => + "" + + $"{mediaXAddr}" + + ""; + + private static string Envelope12(string body) => + "" + + "" + + $"{body}"; + + private static string Envelope11(string body) => + "" + + "" + + $"{body}"; +} diff --git a/tests/OpenIPC.Viewer.Devices.Tests/Onvif/StubCamera.cs b/tests/OpenIPC.Viewer.Devices.Tests/Onvif/StubCamera.cs new file mode 100644 index 0000000..61a16ee --- /dev/null +++ b/tests/OpenIPC.Viewer.Devices.Tests/Onvif/StubCamera.cs @@ -0,0 +1,133 @@ +using System.Collections.Concurrent; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Onvif; + +namespace OpenIPC.Viewer.Devices.Tests.Onvif; + +// What the client saw arrive. The body is read once, here, so a test can look +// at it without racing the handler for the request stream. +internal sealed record StubRequest( + string Body, + string? ContentType, + string? SoapAction, + string? Authorization) +{ + public bool IsSoap12 => + (ContentType ?? "").Contains("application/soap+xml", StringComparison.OrdinalIgnoreCase); + + public bool IsSoap11 => + (ContentType ?? "").Contains("text/xml", StringComparison.OrdinalIgnoreCase); + + public bool Is(string action) => Body.Contains(action, StringComparison.Ordinal); +} + +// A camera that answers however a test needs it to, over a real socket, so the +// client's own HTTP stack does the work — content types, SOAPAction, and the +// 401 handshake included. None of that would be exercised by a mocked handler. +internal sealed class StubCamera : IDisposable +{ + private readonly HttpListener _listener; + private readonly ConcurrentQueue _requests = new(); + + private StubCamera(HttpListener listener, int port) + { + _listener = listener; + Port = port; + } + + public int Port { get; } + + public IReadOnlyList Requests => _requests.ToArray(); + + public OnvifEndpoint Endpoint(CameraCredentials? credentials) => + OnvifEndpoint.FromHost("127.0.0.1", Port, credentials); + + // `challenge`, when set, is sent as WWW-Authenticate with any 401 the + // responder returns — that is what makes HttpClient try again with Digest. + public static StubCamera Start( + Func respond, + string? challenge = null) + { + var port = FreePort(); + var listener = new HttpListener(); + listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + listener.Start(); + + var camera = new StubCamera(listener, port); + _ = Task.Run(() => camera.LoopAsync(respond, challenge)); + return camera; + } + + private async Task LoopAsync(Func respond, string? challenge) + { + while (_listener.IsListening) + { + HttpListenerContext ctx; + try { ctx = await _listener.GetContextAsync().ConfigureAwait(false); } + catch (Exception) { return; } // disposed mid-wait + + try + { + string body; + using (var reader = new StreamReader(ctx.Request.InputStream, Encoding.UTF8)) + body = await reader.ReadToEndAsync().ConfigureAwait(false); + + var request = new StubRequest( + body, + ctx.Request.ContentType, + ctx.Request.Headers["SOAPAction"], + ctx.Request.Headers["Authorization"]); + _requests.Enqueue(request); + + var (payload, status) = respond(request); + ctx.Response.StatusCode = status; + if (status == 401 && challenge is not null) + ctx.Response.AddHeader("WWW-Authenticate", challenge); + + if (payload.Length > 0) + { + var bytes = Encoding.UTF8.GetBytes(payload); + ctx.Response.ContentType = "application/soap+xml; charset=utf-8"; + ctx.Response.ContentLength64 = bytes.Length; + await ctx.Response.OutputStream.WriteAsync(bytes).ConfigureAwait(false); + } + else + { + // The failure this suite is about: a status, and no body to + // explain it. + ctx.Response.ContentLength64 = 0; + } + } + catch (Exception) + { + // A test that tore the camera down mid-request is not a failure. + } + finally + { + try { ctx.Response.Close(); } catch (Exception) { /* already gone */ } + } + } + } + + // Ask the OS for a port, then hand it to HttpListener. Racy in principle, + // never in practice on a test host. + private static int FreePort() + { + var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } + + public void Dispose() + { + try { _listener.Stop(); } catch (Exception) { /* nothing to stop */ } + try { _listener.Close(); } catch (Exception) { /* already closed */ } + } +}