diff --git a/.gitignore b/.gitignore index 19b8cbb..7d64e26 100644 --- a/.gitignore +++ b/.gitignore @@ -231,7 +231,6 @@ Generated_Code/ # to a newer Visual Studio version. Backup files are not needed, # because we have git ;-) _UpgradeReport_Files/ -Backup*/ UpgradeLog*.XML UpgradeLog*.htm ServiceFabricBackup/ diff --git a/Directory.Packages.props b/Directory.Packages.props index b1c1280..0d9d967 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,22 +3,22 @@ true - - - - - - - - + + + + + + + + - - - + + + diff --git a/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj b/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj index aa6a69c..6b1dc00 100644 --- a/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj +++ b/source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj @@ -1,4 +1,4 @@ - + enable @@ -11,5 +11,8 @@ + + + diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClient.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClient.cs new file mode 100644 index 0000000..1d18648 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClient.cs @@ -0,0 +1,98 @@ +using CreativeCoders.Core; +using CreativeCoders.HomeMatic.FirmwareBackup.Internal; +using System.IO.Abstractions; + +namespace CreativeCoders.HomeMatic.FirmwareBackup; + +/// +/// Default implementation. Orchestrates login, backup download +/// and logout against a HomeMatic CCU. +/// +public sealed class FirmwareBackupClient : IFirmwareBackupClient +{ + private readonly ICcuSessionClient _sessionClient; + private readonly IFirmwareBackupDownloader _downloader; + private readonly FirmwareBackupOptions _options; + private readonly IFileSystem _fileSystem; + + internal FirmwareBackupClient( + ICcuSessionClient sessionClient, + IFirmwareBackupDownloader downloader, + FirmwareBackupOptions options, + IFileSystem fileSystem) + { + _sessionClient = Ensure.NotNull(sessionClient); + _downloader = Ensure.NotNull(downloader); + _options = Ensure.NotNull(options); + _fileSystem = Ensure.NotNull(fileSystem); + } + + /// + public async Task CreateBackupAsync(CancellationToken cancellationToken = default) + { + var sessionId = await _sessionClient + .LoginAsync(_options.Credential.UserName, _options.Credential.Password, cancellationToken) + .ConfigureAwait(false); + + try + { + var download = await _downloader.DownloadAsync(sessionId, cancellationToken).ConfigureAwait(false); + + return new FirmwareBackupResult( + download.Content, + download.FileName, + download.ContentLength, + download.HttpResources, + new LogoutDisposable(_sessionClient, sessionId)); + } + catch + { + await _sessionClient.LogoutAsync(sessionId, CancellationToken.None).ConfigureAwait(false); + throw; + } + } + + /// + public async Task CreateBackupToFileAsync(string targetFilePath, + CancellationToken cancellationToken = default) + { + Ensure.IsNotNullOrWhitespace(targetFilePath); + + var backup = await CreateBackupAsync(cancellationToken).ConfigureAwait(false); + await using var backup1 = backup.ConfigureAwait(false); + + var resolvedPath = ResolveFilePath(targetFilePath, backup.FileName); + + var directory = _fileSystem.Path.GetDirectoryName(resolvedPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + _fileSystem.Directory.CreateDirectory(directory); + } + + var fileStream = _fileSystem.File.Create(resolvedPath); + await using var stream = fileStream.ConfigureAwait(false); + await backup.Content.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + + return resolvedPath; + } + + private string ResolveFilePath(string targetFilePath, string suggestedFileName) + { + if (_fileSystem.Directory.Exists(targetFilePath) || + targetFilePath.EndsWith(_fileSystem.Path.DirectorySeparatorChar) || + targetFilePath.EndsWith(_fileSystem.Path.AltDirectorySeparatorChar)) + { + return _fileSystem.Path.Combine(targetFilePath, suggestedFileName); + } + + return targetFilePath; + } + + private sealed class LogoutDisposable(ICcuSessionClient sessionClient, string sessionId) : IAsyncDisposable + { + public async ValueTask DisposeAsync() + { + await sessionClient.LogoutAsync(sessionId, CancellationToken.None).ConfigureAwait(false); + } + } +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClientFactory.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClientFactory.cs new file mode 100644 index 0000000..d76c9c3 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClientFactory.cs @@ -0,0 +1,62 @@ +using CreativeCoders.Core; +using CreativeCoders.HomeMatic.FirmwareBackup.Internal; +using System.IO.Abstractions; + +namespace CreativeCoders.HomeMatic.FirmwareBackup; + +/// +/// Default implementation. Resolves the configured +/// via and wires up a +/// with its internal collaborators. +/// +public sealed class FirmwareBackupClientFactory : IFirmwareBackupClientFactory +{ + /// + /// Name of the named registered for firmware backup operations + /// using the platform's standard server certificate validation. + /// + public const string HttpClientName = "CreativeCoders.HomeMatic.FirmwareBackup"; + + /// + /// Name of the named registered for firmware backup operations + /// that accepts any (including self-signed) server certificate. + /// + public const string HttpClientNameAcceptAnyCertificate = + HttpClientName + ".AcceptAnyCertificate"; + + private readonly IHttpClientFactory _httpClientFactory; + private readonly IFileSystem _fileSystem; + + /// + /// Initializes a new instance of . + /// + /// Factory used to obtain the named HTTP client. + /// File system abstraction used by created clients. + public FirmwareBackupClientFactory(IHttpClientFactory httpClientFactory, IFileSystem fileSystem) + { + _httpClientFactory = Ensure.NotNull(httpClientFactory); + _fileSystem = Ensure.NotNull(fileSystem); + } + + /// + public IFirmwareBackupClient Create(FirmwareBackupOptions options) + { + Ensure.NotNull(options); + + var clientName = options.AcceptAnyServerCertificate + ? HttpClientNameAcceptAnyCertificate + : HttpClientName; + + var httpClient = _httpClientFactory.CreateClient(clientName); + httpClient.Timeout = options.Timeout; + + var sessionClient = new CcuSessionClient(httpClient, options.BaseUrl, options.JsonRpcPath); + var downloader = new FirmwareBackupDownloader( + httpClient, + options.BaseUrl, + options.BackupCgiPath, + options.BackupAction); + + return new FirmwareBackupClient(sessionClient, downloader, options, _fileSystem); + } +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupException.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupException.cs new file mode 100644 index 0000000..6a5452c --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupException.cs @@ -0,0 +1,51 @@ +using System.Net; +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.FirmwareBackup; + +/// +/// Exception thrown when a firmware backup operation against a HomeMatic CCU fails. +/// +[PublicAPI] +public class FirmwareBackupException : Exception +{ + /// + /// Initializes a new instance of . + /// + /// A human-readable description of the failure. + public FirmwareBackupException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of with an inner exception. + /// + /// A human-readable description of the failure. + /// The exception that caused the failure. + public FirmwareBackupException(string message, Exception innerException) : base(message, innerException) + { + } + + /// + /// Initializes a new instance of describing an HTTP failure. + /// + /// A human-readable description of the failure. + /// HTTP status code returned by the CCU. + /// Optional truncated response body for diagnostics. + public FirmwareBackupException(string message, HttpStatusCode statusCode, string? responseBody = null) + : base(message) + { + StatusCode = statusCode; + ResponseBody = responseBody; + } + + /// + /// Gets the HTTP status code returned by the CCU, if available. + /// + public HttpStatusCode? StatusCode { get; } + + /// + /// Gets a (possibly truncated) snippet of the CCU response body for diagnostics. + /// + public string? ResponseBody { get; } +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupOptions.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupOptions.cs new file mode 100644 index 0000000..3a278c8 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupOptions.cs @@ -0,0 +1,62 @@ +using System.Net; +using CreativeCoders.Core; +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.FirmwareBackup; + +/// +/// Connection and behavior options used to create a firmware backup of a HomeMatic CCU. +/// +[PublicAPI] +public class FirmwareBackupOptions +{ + /// + /// Initializes a new instance of . + /// + /// Base URL of the CCU (e.g. https://homematic-ccu.local). + /// Credentials of a CCU user that is allowed to download backups. + public FirmwareBackupOptions(Uri baseUrl, NetworkCredential credential) + { + BaseUrl = Ensure.NotNull(baseUrl); + Credential = Ensure.NotNull(credential); + } + + /// + /// Gets the base URL of the CCU. + /// + public Uri BaseUrl { get; } + + /// + /// Gets the credentials used to log in against the CCU. + /// + public NetworkCredential Credential { get; } + + /// + /// Gets or sets the relative path of the JSON-RPC endpoint used for login/logout. Default: /api/homematic.cgi. + /// + public string JsonRpcPath { get; set; } = "/api/homematic.cgi"; + + /// + /// Gets or sets the relative path of the CGI endpoint that produces the firmware backup file. + /// Default: /config/cp_security.cgi. + /// + public string BackupCgiPath { get; set; } = "/config/cp_security.cgi"; + + /// + /// Gets or sets the form action value sent to the backup CGI endpoint. Default: create_backup. + /// + public string BackupAction { get; set; } = "create_backup"; + + /// + /// Gets or sets a value indicating whether the HTTP client should accept any (including self-signed) + /// server certificate. CCU devices typically use a self-signed certificate, therefore the default is + /// . + /// + public bool AcceptAnyServerCertificate { get; set; } = true; + + /// + /// Gets or sets the request timeout used for both the JSON-RPC and the CGI download call. + /// Default: 5 minutes (creating a backup on the CCU can take a while). + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5); +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs new file mode 100644 index 0000000..67e016a --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupResult.cs @@ -0,0 +1,71 @@ +using CreativeCoders.Core; +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.FirmwareBackup; + +/// +/// Represents the result of a firmware backup download from a HomeMatic CCU. +/// +/// +/// The instance owns the underlying and any HTTP resources required to read it. +/// Always dispose the result to release the connection. +/// +[PublicAPI] +public sealed class FirmwareBackupResult : IAsyncDisposable, IDisposable +{ + private readonly IAsyncDisposable[] _additionalResources; + + /// + /// Initializes a new instance of . + /// + /// Stream containing the backup payload. + /// Suggested file name for the backup (e.g. ccu_backup.sbk). + /// Optional content length in bytes if reported by the server. + /// + /// Optional additional resources (such as the underlying ) that need + /// to be disposed together with the content stream. + /// + public FirmwareBackupResult( + Stream content, + string fileName, + long? contentLength = null, + params IAsyncDisposable[] additionalResources) + { + Content = Ensure.NotNull(content); + FileName = Ensure.IsNotNullOrWhitespace(fileName); + ContentLength = contentLength; + _additionalResources = Ensure.NotNull(additionalResources); + } + + /// + /// Gets the stream that contains the backup payload. + /// + public Stream Content { get; } + + /// + /// Gets the suggested file name for the downloaded backup. + /// + public string FileName { get; } + + /// + /// Gets the content length in bytes if reported by the CCU; otherwise . + /// + public long? ContentLength { get; } + + /// + public async ValueTask DisposeAsync() + { + await Content.DisposeAsync().ConfigureAwait(false); + + foreach (var resource in _additionalResources) + { + await resource.DisposeAsync().ConfigureAwait(false); + } + } + + /// + public void Dispose() + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + } +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupServiceCollectionExtensions.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupServiceCollectionExtensions.cs new file mode 100644 index 0000000..d6fab56 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupServiceCollectionExtensions.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; +using CreativeCoders.Core.IO; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CreativeCoders.HomeMatic.FirmwareBackup; + +/// +/// Extension methods for registering the firmware backup feature on an . +/// +[PublicAPI] +public static class FirmwareBackupServiceCollectionExtensions +{ + /// + /// Registers and the named + /// used to talk to a HomeMatic CCU. + /// + /// The service collection to register the services on. + /// The same instance to allow chaining calls. + [SuppressMessage("csharpsquid", "S4830:Server certificate validation should not be disabled", + Justification = + "Only used for explicitly opting in to accepting any server certificate, e.g. for self-signed certificates on a CCU.")] + public static IServiceCollection AddHomeMaticFirmwareBackup(this IServiceCollection services) + { + services.AddFileSystem(); + + services.AddHttpClient(FirmwareBackupClientFactory.HttpClientName); + + services + .AddHttpClient(FirmwareBackupClientFactory.HttpClientNameAcceptAnyCertificate) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }); + + services.TryAddTransient(); + + return services; + } +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/IFirmwareBackupClient.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/IFirmwareBackupClient.cs new file mode 100644 index 0000000..02e6755 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/IFirmwareBackupClient.cs @@ -0,0 +1,29 @@ +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.FirmwareBackup; + +/// +/// Client for creating and downloading a firmware backup of a HomeMatic CCU. +/// +[PublicAPI] +public interface IFirmwareBackupClient +{ + /// + /// Creates a firmware backup on the CCU and returns it as a stream wrapped in a + /// . The caller is responsible for disposing the result. + /// + /// Cancellation token. + /// The backup result containing stream and metadata. + Task CreateBackupAsync(CancellationToken cancellationToken = default); + + /// + /// Creates a firmware backup on the CCU and writes it to the given target file. + /// + /// + /// Either an absolute file path or a directory path. If a directory is given, the file name + /// reported by the CCU is appended. + /// + /// Cancellation token. + /// The full path of the written backup file. + Task CreateBackupToFileAsync(string targetFilePath, CancellationToken cancellationToken = default); +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/IFirmwareBackupClientFactory.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/IFirmwareBackupClientFactory.cs new file mode 100644 index 0000000..a6b5434 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/IFirmwareBackupClientFactory.cs @@ -0,0 +1,17 @@ +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.FirmwareBackup; + +/// +/// Factory for creating instances configured for a specific CCU. +/// +[PublicAPI] +public interface IFirmwareBackupClientFactory +{ + /// + /// Creates a new using the given options. + /// + /// Connection and behavior options for the CCU. + /// A new instance. + IFirmwareBackupClient Create(FirmwareBackupOptions options); +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/CcuSessionClient.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/CcuSessionClient.cs new file mode 100644 index 0000000..496dc26 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/CcuSessionClient.cs @@ -0,0 +1,125 @@ +using System.Text; +using System.Text.Json; +using CreativeCoders.Core; + +namespace CreativeCoders.HomeMatic.FirmwareBackup.Internal; + +/// +/// Default implementation that talks to the CCU JSON-RPC endpoint via +/// . +/// +internal sealed class CcuSessionClient : ICcuSessionClient +{ + private readonly HttpClient _httpClient; + private readonly Uri _jsonRpcUrl; + + public CcuSessionClient(HttpClient httpClient, Uri baseUrl, string jsonRpcPath) + { + _httpClient = Ensure.NotNull(httpClient); + Ensure.NotNull(baseUrl); + Ensure.IsNotNullOrWhitespace(jsonRpcPath); + + _jsonRpcUrl = new Uri(baseUrl, jsonRpcPath); + } + + public async Task LoginAsync(string userName, string password, + CancellationToken cancellationToken = default) + { + Ensure.IsNotNullOrWhitespace(userName); + Ensure.NotNull(password); + + var payload = new + { + version = "1.1", + method = "Session.login", + @params = new { username = userName, password } + }; + + var response = await PostJsonRpcAsync(payload, cancellationToken).ConfigureAwait(false); + + var sessionId = ReadResultString(response); + + if (string.IsNullOrWhiteSpace(sessionId)) + { + throw new FirmwareBackupException( + "CCU did not return a session id. Please verify the credentials."); + } + + return sessionId; + } + + public async Task LogoutAsync(string sessionId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + return; + } + + var payload = new + { + version = "1.1", + method = "Session.logout", + @params = new { _session_id_ = sessionId } + }; + + try + { + await PostJsonRpcAsync(payload, cancellationToken).ConfigureAwait(false); + } + catch + { + // Logout is best-effort; do not propagate errors when releasing a session. + } + } + + private async Task PostJsonRpcAsync(object payload, CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(payload); + + using var request = new HttpRequestMessage(HttpMethod.Post, _jsonRpcUrl); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + throw new FirmwareBackupException( + $"JSON-RPC call against CCU failed with HTTP status {(int)response.StatusCode}.", + response.StatusCode, + Truncate(body)); + } + + return body; + } + + private static string? ReadResultString(string jsonResponse) + { + using var document = JsonDocument.Parse(jsonResponse); + var root = document.RootElement; + + if (root.TryGetProperty("error", out var errorElement) && errorElement.ValueKind != JsonValueKind.Null) + { + var message = errorElement.TryGetProperty("message", out var msgEl) && + msgEl.ValueKind == JsonValueKind.String + ? msgEl.GetString() + : errorElement.ToString(); + + throw new FirmwareBackupException($"CCU returned a JSON-RPC error: {message}"); + } + + if (!root.TryGetProperty("result", out var resultElement)) + { + return null; + } + + return resultElement.ValueKind == JsonValueKind.String ? resultElement.GetString() : resultElement.ToString(); + } + + private static string Truncate(string value) + { + const int maxLength = 512; + return value.Length <= maxLength ? value : value[..maxLength]; + } +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/FirmwareBackupDownloader.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/FirmwareBackupDownloader.cs new file mode 100644 index 0000000..a0e5cf9 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/FirmwareBackupDownloader.cs @@ -0,0 +1,108 @@ +using CreativeCoders.Core; + +namespace CreativeCoders.HomeMatic.FirmwareBackup.Internal; + +/// +/// Default that calls the CCU CGI backup endpoint. +/// +internal sealed class FirmwareBackupDownloader : IFirmwareBackupDownloader +{ + private const string DefaultFileName = "ccu_backup.sbk"; + + private readonly HttpClient _httpClient; + private readonly Uri _backupUrl; + private readonly string _backupAction; + + public FirmwareBackupDownloader(HttpClient httpClient, Uri baseUrl, string backupCgiPath, string backupAction) + { + _httpClient = Ensure.NotNull(httpClient); + Ensure.NotNull(baseUrl); + Ensure.IsNotNullOrWhitespace(backupCgiPath); + + _backupUrl = new Uri(baseUrl, backupCgiPath); + _backupAction = Ensure.IsNotNullOrWhitespace(backupAction); + } + + public async Task DownloadAsync(string sessionId, + CancellationToken cancellationToken = default) + { + Ensure.IsNotNullOrWhitespace(sessionId); + + var requestUri = BuildRequestUri(sessionId); + + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + + var response = await _httpClient + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var body = await SafeReadAsync(response, cancellationToken).ConfigureAwait(false); + + response.Dispose(); + request.Dispose(); + + throw new FirmwareBackupException( + $"Firmware backup download failed with HTTP status {(int)response.StatusCode}.", + response.StatusCode, + body); + } + + var fileName = ResolveFileName(response); + var contentLength = response.Content.Headers.ContentLength; + + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + + return new FirmwareBackupDownloadResult( + stream, + fileName, + contentLength, + new HttpResources(request, response)); + } + + private static string ResolveFileName(HttpResponseMessage response) + { + var disposition = response.Content.Headers.ContentDisposition; + + var fileName = disposition?.FileNameStar ?? disposition?.FileName; + + return string.IsNullOrWhiteSpace(fileName) + ? DefaultFileName + : fileName.Trim('"'); + } + + private Uri BuildRequestUri(string sessionId) + { + var encodedSid = Uri.EscapeDataString($"@{sessionId}@"); + var encodedAction = Uri.EscapeDataString(_backupAction); + + var separator = string.IsNullOrEmpty(_backupUrl.Query) ? "?" : "&"; + var query = $"{separator}sid={encodedSid}&action={encodedAction}"; + + return new Uri(_backupUrl + query); + } + + private static async Task SafeReadAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + try + { + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return body.Length <= 512 ? body : body[..512]; + } + catch + { + return null; + } + } + + private sealed class HttpResources(HttpRequestMessage request, HttpResponseMessage response) : IAsyncDisposable + { + public ValueTask DisposeAsync() + { + response.Dispose(); + request.Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/ICcuSessionClient.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/ICcuSessionClient.cs new file mode 100644 index 0000000..92bec98 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/ICcuSessionClient.cs @@ -0,0 +1,25 @@ +namespace CreativeCoders.HomeMatic.FirmwareBackup.Internal; + +/// +/// Performs JSON-RPC login/logout against a HomeMatic CCU to obtain a session id usable for +/// subsequent CGI calls (e.g. firmware backup download). +/// +internal interface ICcuSessionClient +{ + /// + /// Logs in against the CCU and returns the session id. + /// + /// Username used for login. + /// Password used for login. + /// Cancellation token. + /// The session id assigned by the CCU. + Task LoginAsync(string userName, string password, CancellationToken cancellationToken = default); + + /// + /// Logs out the given session. Errors are swallowed so that this method can safely be called from a + /// finally block. + /// + /// Session id previously returned by . + /// Cancellation token. + Task LogoutAsync(string sessionId, CancellationToken cancellationToken = default); +} diff --git a/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/IFirmwareBackupDownloader.cs b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/IFirmwareBackupDownloader.cs new file mode 100644 index 0000000..cb2fe02 --- /dev/null +++ b/source/CreativeCoders.HomeMatic/FirmwareBackup/Internal/IFirmwareBackupDownloader.cs @@ -0,0 +1,29 @@ +namespace CreativeCoders.HomeMatic.FirmwareBackup.Internal; + +/// +/// Triggers backup creation on a HomeMatic CCU and downloads the resulting .sbk file. +/// +internal interface IFirmwareBackupDownloader +{ + /// + /// Downloads the firmware backup using the given CCU session id. + /// + /// Session id obtained from a prior login. + /// Cancellation token. + /// The download result containing stream, metadata and HTTP resources. + Task DownloadAsync(string sessionId, CancellationToken cancellationToken = default); +} + +/// +/// Internal result type produced by . +/// +/// The backup payload stream. +/// The file name reported by the CCU (or a default). +/// Optional content length in bytes. +/// Disposable wrapping the underlying HTTP request/response. +internal sealed record FirmwareBackupDownloadResult( + Stream Content, + string FileName, + long? ContentLength, + IAsyncDisposable HttpResources); + diff --git a/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs b/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs index 18a11c9..3c08dc2 100644 --- a/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs +++ b/source/CreativeCoders.HomeMatic/HomeMaticServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using CreativeCoders.HomeMatic.Core; using CreativeCoders.HomeMatic.Exporting; +using CreativeCoders.HomeMatic.FirmwareBackup; using CreativeCoders.HomeMatic.JsonRpc; using CreativeCoders.HomeMatic.XmlRpc; using JetBrains.Annotations; @@ -29,6 +30,7 @@ public static IServiceCollection AddHomeMatic(this IServiceCollection services) { services.AddHomeMaticXmlRpc(); services.AddHomeMaticJsonRpc(); + services.AddHomeMaticFirmwareBackup(); services.TryAddTransient(); services.TryAddTransient(); diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuCommand.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuCommand.cs new file mode 100644 index 0000000..873d445 --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuCommand.cs @@ -0,0 +1,85 @@ +using CreativeCoders.Cli.Core; +using CreativeCoders.Core; +using CreativeCoders.HomeMatic.FirmwareBackup; +using CreativeCoders.HomeMatic.Tools.Cli.Base.Connections; +using JetBrains.Annotations; +using Spectre.Console; +using System.IO.Abstractions; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu.Backup; + +[UsedImplicitly] +[CliCommand([CcuCommandGroup.Name, "backup"], Description = "Create a firmware backup of a configured CCU")] +public class BackupCcuCommand( + IAnsiConsole console, + ICcuConnectionsStore ccuConnectionsStore, + IFirmwareBackupClientFactory firmwareBackupClientFactory, + IFileSystem fileSystem) + : ICliCommand +{ + private readonly IAnsiConsole _console = Ensure.NotNull(console); + + private readonly ICcuConnectionsStore _ccuConnectionsStore = Ensure.NotNull(ccuConnectionsStore); + + private readonly IFirmwareBackupClientFactory _firmwareBackupClientFactory = + Ensure.NotNull(firmwareBackupClientFactory); + + private readonly IFileSystem _fileSystem = Ensure.NotNull(fileSystem); + + public async Task ExecuteAsync(BackupCcuOptions options) + { + if (string.IsNullOrWhiteSpace(options.Name)) + { + _console.MarkupLine("[bold italic red3]A CCU connection name is required.[/]"); + return -1; + } + + if (string.IsNullOrWhiteSpace(options.OutputFile)) + { + _console.MarkupLine("[bold italic red3]An output file path is required.[/]"); + return -1; + } + + var connections = await _ccuConnectionsStore.GetConnectionsAsync().ConfigureAwait(false); + + var connection = connections + .FirstOrDefault(x => string.Equals(x.Name, options.Name, StringComparison.OrdinalIgnoreCase)); + + if (connection is null) + { + _console.MarkupLine( + $"[bold italic red3]No CCU connection named '{Markup.Escape(options.Name)}' found.[/]"); + return -1; + } + + var credential = _ccuConnectionsStore.GetCredentials(connection); + + var backupOptions = new FirmwareBackupOptions(connection.Url, credential); + var client = _firmwareBackupClientFactory.Create(backupOptions); + + var targetPath = _fileSystem.Path.GetFullPath(options.OutputFile); + var targetDirectory = _fileSystem.Path.GetDirectoryName(targetPath); + + if (!string.IsNullOrEmpty(targetDirectory)) + { + _fileSystem.Directory.CreateDirectory(targetDirectory); + } + + _console.MarkupLine( + $"Creating firmware backup of CCU [bold]{Markup.Escape(connection.Name)}[/] " + + $"([italic]{Markup.Escape(connection.Url.ToString())}[/])..."); + + try + { + var writtenPath = await client.CreateBackupToFileAsync(targetPath).ConfigureAwait(false); + + _console.MarkupLine($"[bold lime]Backup created:[/] {Markup.Escape(writtenPath)}"); + return CommandResult.Success; + } + catch (FirmwareBackupException ex) + { + _console.MarkupLine($"[bold italic red3]Backup failed: {Markup.Escape(ex.Message)}[/]"); + return -1; + } + } +} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuOptions.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuOptions.cs new file mode 100644 index 0000000..715a7ac --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/Backup/BackupCcuOptions.cs @@ -0,0 +1,15 @@ +using CreativeCoders.SysConsole.Cli.Parsing; +using JetBrains.Annotations; + +namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu.Backup; + +[PublicAPI] +public class BackupCcuOptions +{ + [OptionValue(0, IsRequired = true, HelpText = "Name of the configured CCU connection")] + public string Name { get; set; } = string.Empty; + + [OptionParameter('o', "output", IsRequired = true, + HelpText = "Path of the backup file to create")] + public string OutputFile { get; set; } = string.Empty; +} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/CcuCommandGroup.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/CcuCommandGroup.cs new file mode 100644 index 0000000..5e31c96 --- /dev/null +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Commands/Ccu/CcuCommandGroup.cs @@ -0,0 +1,11 @@ +using CreativeCoders.Cli.Core; +using CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu; + +[assembly: CliCommandGroup([CcuCommandGroup.Name], "Commands for working with a HomeMatic CCU")] + +namespace CreativeCoders.HomeMatic.Tools.Cli.Commands.Ccu; + +public static class CcuCommandGroup +{ + public const string Name = "ccu"; +} diff --git a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Hmc/Program.cs b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Hmc/Program.cs index 386dda9..f1d5ec4 100644 --- a/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Hmc/Program.cs +++ b/source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Hmc/Program.cs @@ -42,5 +42,7 @@ private static void ConfigureServices(IServiceCollection services) services.AddHomeMaticXmlRpc(); services.AddHomeMaticJsonRpc(); + + services.AddFileSystem(); } } diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs new file mode 100644 index 0000000..240e2a0 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryIntegrationTests.cs @@ -0,0 +1,193 @@ +using System.IO.Abstractions; +using System.Net; +using System.Text; +using AwesomeAssertions; +using CreativeCoders.Core.IO; +using CreativeCoders.HomeMatic.FirmwareBackup; +using Microsoft.Extensions.DependencyInjection; + +namespace CreativeCoders.HomeMatic.Tests.FirmwareBackup; + +public class FirmwareBackupClientFactoryIntegrationTests +{ + private const string FakeSessionId = "session-id-xyz"; + private static readonly byte[] BackupPayload = "BACKUP-CONTENT"u8.ToArray(); + + [Fact] + public async Task CreateBackupAsync_HappyPath_PerformsLoginDownloadAndLogout() + { + // Arrange + var (factory, handler) = CreateFactory(); + handler.EnqueueJsonResponse($"{{\"version\":\"1.1\",\"result\":\"{FakeSessionId}\",\"error\":null}}"); + handler.EnqueueBinaryResponse(BackupPayload, "ccu_backup.sbk"); + handler.EnqueueJsonResponse("{\"version\":\"1.1\",\"result\":true,\"error\":null}"); + + var options = new FirmwareBackupOptions( + new Uri("https://ccu.example.local"), + new NetworkCredential("Admin", "secret")); + + var client = factory.Create(options); + + // Act + await using var result = await client.CreateBackupAsync(); + using var ms = new MemoryStream(); + await result.Content.CopyToAsync(ms); + + // Assert + ms.ToArray().Should().BeEquivalentTo(BackupPayload); + result.FileName.Should().Be("ccu_backup.sbk"); + + handler.Requests.Should().HaveCount(2); + handler.Requests[0].Uri.AbsolutePath.Should().Be("/api/homematic.cgi"); + handler.Requests[0].Body.Should().Contain("\"Session.login\"").And.Contain("\"Admin\""); + handler.Requests[1].Uri.AbsolutePath.Should().Be("/config/cp_security.cgi"); + handler.Requests[1].Method.Should().Be(HttpMethod.Get); + handler.Requests[1].Uri.Query.Should().Contain($"sid=%40{FakeSessionId}%40").And + .Contain("action=create_backup"); + } + + [Fact] + public async Task CreateBackupAsync_HappyPath_LogoutCalledOnDispose() + { + // Arrange + var (factory, handler) = CreateFactory(); + handler.EnqueueJsonResponse($"{{\"result\":\"{FakeSessionId}\",\"error\":null}}"); + handler.EnqueueBinaryResponse(BackupPayload, "backup.sbk"); + handler.EnqueueJsonResponse("{\"result\":true,\"error\":null}"); + + var client = factory.Create(new FirmwareBackupOptions( + new Uri("https://ccu.example.local"), + new NetworkCredential("Admin", "x"))); + + // Act + var result = await client.CreateBackupAsync(); + await result.DisposeAsync(); + + // Assert + handler.Requests.Should().HaveCount(3); + handler.Requests[2].Body.Should().Contain("\"Session.logout\"").And.Contain(FakeSessionId); + } + + [Fact] + public async Task CreateBackupAsync_DownloadFails_LogoutStillCalled() + { + // Arrange + var (factory, handler) = CreateFactory(); + handler.EnqueueJsonResponse($"{{\"result\":\"{FakeSessionId}\",\"error\":null}}"); + handler.EnqueueResponse(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("boom") + }); + handler.EnqueueJsonResponse("{\"result\":true,\"error\":null}"); + + var client = factory.Create(new FirmwareBackupOptions( + new Uri("https://ccu.example.local"), + new NetworkCredential("Admin", "x"))); + + // Act + var act = async () => await client.CreateBackupAsync(); + + // Assert + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + handler.Requests.Should().HaveCount(3); + handler.Requests[2].Body.Should().Contain("\"Session.logout\""); + } + + [Fact] + public async Task CreateBackupAsync_LoginFailsWithError_ThrowsFirmwareBackupException() + { + // Arrange + var (factory, handler) = CreateFactory(); + handler.EnqueueJsonResponse( + "{\"result\":null,\"error\":{\"code\":2,\"message\":\"invalid credentials\"}}"); + + var client = factory.Create(new FirmwareBackupOptions( + new Uri("https://ccu.example.local"), + new NetworkCredential("Admin", "wrong"))); + + // Act + var act = async () => await client.CreateBackupAsync(); + + // Assert + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("invalid credentials"); + } + + [Fact] + public async Task CreateBackupToFileAsync_WritesContentToFile() + { + // Arrange + var (factory, handler) = CreateFactory(); + handler.EnqueueJsonResponse($"{{\"result\":\"{FakeSessionId}\",\"error\":null}}"); + handler.EnqueueBinaryResponse(BackupPayload, "ccu_backup.sbk"); + handler.EnqueueJsonResponse("{\"result\":true,\"error\":null}"); + + var tempDir = Path.Combine(Path.GetTempPath(), "fwbackup-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + + try + { + var client = factory.Create(new FirmwareBackupOptions( + new Uri("https://ccu.example.local"), + new NetworkCredential("Admin", "x"))); + + // Act + var path = await client.CreateBackupToFileAsync(tempDir); + + // Assert + File.Exists(path).Should().BeTrue(); + (await File.ReadAllBytesAsync(path)).Should().BeEquivalentTo(BackupPayload); + Path.GetFileName(path).Should().Be("ccu_backup.sbk"); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task CreateBackupToFileAsync_WithExplicitFilePath_UsesGivenPath() + { + // Arrange + var (factory, handler) = CreateFactory(); + handler.EnqueueJsonResponse($"{{\"result\":\"{FakeSessionId}\",\"error\":null}}"); + handler.EnqueueBinaryResponse(BackupPayload, "default.sbk"); + handler.EnqueueJsonResponse("{\"result\":true,\"error\":null}"); + + var tempFile = Path.Combine(Path.GetTempPath(), "fwbackup-" + Guid.NewGuid().ToString("N") + ".sbk"); + + try + { + var client = factory.Create(new FirmwareBackupOptions( + new Uri("https://ccu.example.local"), + new NetworkCredential("Admin", "x"))); + + // Act + var path = await client.CreateBackupToFileAsync(tempFile); + + // Assert + path.Should().Be(tempFile); + (await File.ReadAllBytesAsync(path)).Should().BeEquivalentTo(BackupPayload); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + private static (IFirmwareBackupClientFactory Factory, QueueingHttpMessageHandler Handler) CreateFactory() + { + var handler = new QueueingHttpMessageHandler(); + var httpClientFactory = new SingleHandlerHttpClientFactory(handler); + var fileSystem = new ServiceCollection() + .AddFileSystem() + .BuildServiceProvider() + .GetRequiredService(); + var factory = new FirmwareBackupClientFactory(httpClientFactory, fileSystem); + return (factory, handler); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryTests.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryTests.cs new file mode 100644 index 0000000..b691a43 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupClientFactoryTests.cs @@ -0,0 +1,73 @@ +using System.IO.Abstractions; +using System.Net; +using AwesomeAssertions; +using CreativeCoders.Core.IO; +using CreativeCoders.HomeMatic.FirmwareBackup; +using FakeItEasy; +using Microsoft.Extensions.DependencyInjection; + +namespace CreativeCoders.HomeMatic.Tests.FirmwareBackup; + +public class FirmwareBackupClientFactoryTests +{ + [Fact] + public void Create_WithAcceptAnyServerCertificateTrue_UsesAcceptAnyHttpClient() + { + // Arrange + var (factory, httpClientFactory) = CreateSut(); + var options = new FirmwareBackupOptions( + new Uri("https://ccu.local"), + new NetworkCredential("user", "pass")) + { + AcceptAnyServerCertificate = true + }; + + // Act + factory.Create(options); + + // Assert + A.CallTo(() => httpClientFactory.CreateClient( + FirmwareBackupClientFactory.HttpClientNameAcceptAnyCertificate)) + .MustHaveHappenedOnceExactly(); + A.CallTo(() => httpClientFactory.CreateClient(FirmwareBackupClientFactory.HttpClientName)) + .MustNotHaveHappened(); + } + + [Fact] + public void Create_WithAcceptAnyServerCertificateFalse_UsesDefaultHttpClient() + { + // Arrange + var (factory, httpClientFactory) = CreateSut(); + var options = new FirmwareBackupOptions( + new Uri("https://ccu.local"), + new NetworkCredential("user", "pass")) + { + AcceptAnyServerCertificate = false + }; + + // Act + factory.Create(options); + + // Assert + A.CallTo(() => httpClientFactory.CreateClient(FirmwareBackupClientFactory.HttpClientName)) + .MustHaveHappenedOnceExactly(); + A.CallTo(() => httpClientFactory.CreateClient( + FirmwareBackupClientFactory.HttpClientNameAcceptAnyCertificate)) + .MustNotHaveHappened(); + } + + private static (FirmwareBackupClientFactory Factory, IHttpClientFactory HttpClientFactory) CreateSut() + { + var httpClientFactory = A.Fake(); + A.CallTo(() => httpClientFactory.CreateClient(A._)) + .ReturnsLazily(() => new HttpClient(new QueueingHttpMessageHandler(), disposeHandler: false)); + + var fileSystem = new ServiceCollection() + .AddFileSystem() + .BuildServiceProvider() + .GetRequiredService(); + + var factory = new FirmwareBackupClientFactory(httpClientFactory, fileSystem); + return (factory, httpClientFactory); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupOptionsTests.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupOptionsTests.cs new file mode 100644 index 0000000..909c719 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupOptionsTests.cs @@ -0,0 +1,54 @@ +using System.Net; +using AwesomeAssertions; +using CreativeCoders.HomeMatic.FirmwareBackup; + +namespace CreativeCoders.HomeMatic.Tests.FirmwareBackup; + +public class FirmwareBackupOptionsTests +{ + [Fact] + public void Constructor_WithValidArgs_SetsProperties() + { + // Arrange + var url = new Uri("https://ccu.example.local"); + var credential = new NetworkCredential("admin", "pwd"); + + // Act + var options = new FirmwareBackupOptions(url, credential); + + // Assert + options.BaseUrl.Should().BeSameAs(url); + options.Credential.Should().BeSameAs(credential); + options.JsonRpcPath.Should().Be("/api/homematic.cgi"); + options.BackupCgiPath.Should().Be("/config/cp_security.cgi"); + options.BackupAction.Should().Be("create_backup"); + options.AcceptAnyServerCertificate.Should().BeTrue(); + options.Timeout.Should().Be(TimeSpan.FromMinutes(5)); + } + + [Fact] + public void Constructor_WithNullUrl_Throws() + { + // Arrange + var credential = new NetworkCredential("admin", "pwd"); + + // Act + var act = () => new FirmwareBackupOptions(null!, credential); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullCredential_Throws() + { + // Arrange + var url = new Uri("https://ccu.example.local"); + + // Act + var act = () => new FirmwareBackupOptions(url, null!); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupServiceCollectionExtensionsTests.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..05aec79 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/FirmwareBackupServiceCollectionExtensionsTests.cs @@ -0,0 +1,132 @@ +using AwesomeAssertions; +using CreativeCoders.HomeMatic.FirmwareBackup; +using FakeItEasy; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Http; +using Microsoft.Extensions.Options; + +namespace CreativeCoders.HomeMatic.Tests.FirmwareBackup; + +public class FirmwareBackupServiceCollectionExtensionsTests +{ + [Fact] + public void AddHomeMaticFirmwareBackup_RegistersFactory() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + + // Act + services.AddHomeMaticFirmwareBackup(); + var sp = services.BuildServiceProvider(); + + // Assert + var factory = sp.GetRequiredService(); + factory.Should().BeOfType(); + } + + [Fact] + public void AddHomeMaticFirmwareBackup_RegistersHttpClientFactory() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + + // Act + services.AddHomeMaticFirmwareBackup(); + var sp = services.BuildServiceProvider(); + + // Assert + var httpClientFactory = sp.GetRequiredService(); + var client = httpClientFactory.CreateClient(FirmwareBackupClientFactory.HttpClientName); + client.Should().NotBeNull(); + } + + [Fact] + public void AddHomeMaticFirmwareBackup_RegistersAcceptAnyCertificateHttpClient() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + + // Act + services.AddHomeMaticFirmwareBackup(); + var sp = services.BuildServiceProvider(); + + // Assert + var httpClientFactory = sp.GetRequiredService(); + var client = httpClientFactory.CreateClient( + FirmwareBackupClientFactory.HttpClientNameAcceptAnyCertificate); + client.Should().NotBeNull(); + } + + [Fact] + public void AddHomeMaticFirmwareBackup_AcceptAnyCertificateClient_ConfiguresBypassCallback() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + services.AddHomeMaticFirmwareBackup(); + var sp = services.BuildServiceProvider(); + + // Act + var primaryHandler = BuildPrimaryHandler( + sp, + FirmwareBackupClientFactory.HttpClientNameAcceptAnyCertificate); + + // Assert + primaryHandler.Should().BeOfType(); + ((HttpClientHandler)primaryHandler).ServerCertificateCustomValidationCallback + .Should().NotBeNull(); + } + + [Fact] + public void AddHomeMaticFirmwareBackup_DefaultClient_DoesNotBypassCertificate() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + services.AddHomeMaticFirmwareBackup(); + var sp = services.BuildServiceProvider(); + + // Act + var primaryHandler = BuildPrimaryHandler( + sp, + FirmwareBackupClientFactory.HttpClientName); + + // Assert + if (primaryHandler is HttpClientHandler httpClientHandler) + { + httpClientHandler.ServerCertificateCustomValidationCallback.Should().BeNull(); + } + } + + [Fact] + public void AddHomeMatic_RegistersFirmwareBackupFactory() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + + // Act + services.AddHomeMatic(); + var sp = services.BuildServiceProvider(); + + // Assert + sp.GetRequiredService().Should().NotBeNull(); + } + + private static HttpMessageHandler BuildPrimaryHandler(IServiceProvider sp, string clientName) + { + var optionsMonitor = sp.GetRequiredService>(); + var clientOptions = optionsMonitor.Get(clientName); + + var builder = A.Fake(); + var primaryHandler = (HttpMessageHandler)new HttpClientHandler(); + A.CallTo(() => builder.PrimaryHandler).Returns(primaryHandler); + A.CallToSet(() => builder.PrimaryHandler).Invokes((HttpMessageHandler h) => primaryHandler = h); + A.CallTo(() => builder.Name).Returns(clientName); + + foreach (var action in clientOptions.HttpMessageHandlerBuilderActions) + { + action(builder); + } + + return primaryHandler; + } +} diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/QueueingHttpMessageHandler.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/QueueingHttpMessageHandler.cs new file mode 100644 index 0000000..e28f8b3 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/QueueingHttpMessageHandler.cs @@ -0,0 +1,61 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; + +namespace CreativeCoders.HomeMatic.Tests.FirmwareBackup; + +/// +/// Test that returns a queued response per request and records +/// the requests it receives. +/// +internal sealed class QueueingHttpMessageHandler : HttpMessageHandler +{ + private readonly Queue> _responders = new(); + + public List Requests { get; } = []; + + public void EnqueueJsonResponse(string json, HttpStatusCode statusCode = HttpStatusCode.OK) + { + _responders.Enqueue(_ => new HttpResponseMessage(statusCode) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + } + + public void EnqueueBinaryResponse(byte[] payload, string fileName, HttpStatusCode statusCode = HttpStatusCode.OK) + { + _responders.Enqueue(_ => + { + var content = new ByteArrayContent(payload); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment") + { + FileName = fileName + }; + + return new HttpResponseMessage(statusCode) { Content = content }; + }); + } + + public void EnqueueResponse(Func responder) + { + _responders.Enqueue(responder); + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var body = request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + Requests.Add(new RecordedRequest(request.Method, request.RequestUri!, body)); + + return _responders.Count == 0 + ? throw new InvalidOperationException("No more responses queued.") + : _responders.Dequeue()(request); + } +} + +internal sealed record RecordedRequest(HttpMethod Method, Uri Uri, string Body); diff --git a/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/SingleHandlerHttpClientFactory.cs b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/SingleHandlerHttpClientFactory.cs new file mode 100644 index 0000000..6ac4850 --- /dev/null +++ b/tests/CreativeCoders.HomeMatic.Tests/FirmwareBackup/SingleHandlerHttpClientFactory.cs @@ -0,0 +1,13 @@ +namespace CreativeCoders.HomeMatic.Tests.FirmwareBackup; + +/// +/// Minimal that returns a single backed by +/// the given . +/// +internal sealed class SingleHandlerHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory +{ + public HttpClient CreateClient(string name) + { + return new HttpClient(handler, disposeHandler: false); + } +}