This PR has a large blast radius because it takes the final step and bumps up the Controller to be fully async. We have historically done a piece-by-piece update, so all operations were already async but the controller interface was kept synchronous. With this update, the controller is now fully async and all tests are updated. Most places where the new C# compiler warns about function names not ending in `Async` were also adressed, giving a massive refactor change. Functionally, no changes are done.
787 lines
33 KiB
C#
787 lines
33 KiB
C#
// Copyright (C) 2026, The Duplicati Team
|
|
// https://duplicati.com, hello@duplicati.com
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a
|
|
// copy of this software and associated documentation files (the "Software"),
|
|
// to deal in the Software without restriction, including without limitation
|
|
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
// and/or sell copies of the Software, and to permit persons to whom the
|
|
// Software is furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
// DEALINGS IN THE SOFTWARE.
|
|
|
|
#nullable enable
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using System.Threading.Tasks;
|
|
using Duplicati.Library.AutoUpdater;
|
|
using Duplicati.Library.Certificates;
|
|
using Duplicati.Library.RestAPI.Database;
|
|
using Duplicati.Library.SQLiteHelper;
|
|
using Duplicati.Server.Database;
|
|
using NUnit.Framework;
|
|
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
|
|
|
namespace Duplicati.UnitTest;
|
|
|
|
/// <summary>
|
|
/// Integration tests for HTTPS certificate configuration.
|
|
/// Tests the end-to-end flow of certificate generation, storage, renewal, and server integration.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("HTTPSConfiguration")]
|
|
public class HttpsConfigurationTests
|
|
{
|
|
private string _tempDataFolder = null!;
|
|
private string _databasePath = null!;
|
|
private Connection _connection = null!;
|
|
|
|
[SetUp]
|
|
public async Task SetUpAsync()
|
|
{
|
|
// Create a temporary folder for test data
|
|
_tempDataFolder = Path.Combine(Path.GetTempPath(), $"duplicati-test-{Guid.NewGuid()}");
|
|
Directory.CreateDirectory(_tempDataFolder);
|
|
|
|
_databasePath = Path.Combine(_tempDataFolder, DataFolderManager.SERVER_DATABASE_FILENAME);
|
|
|
|
// Create a new test database
|
|
await CreateTestDatabaseAsync(_databasePath);
|
|
|
|
// Open the database connection
|
|
var dbConnection = await SQLiteLoader.LoadConnectionAsync(_databasePath);
|
|
_connection = new Connection(dbConnection, true, null, _tempDataFolder, () => { });
|
|
}
|
|
|
|
[TearDown]
|
|
public void TearDown()
|
|
{
|
|
_connection?.Dispose();
|
|
|
|
// Clean up temp folder
|
|
if (Directory.Exists(_tempDataFolder))
|
|
{
|
|
try
|
|
{
|
|
Directory.Delete(_tempDataFolder, true);
|
|
}
|
|
catch
|
|
{
|
|
// Best effort cleanup
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a minimal test database for integration testing.
|
|
/// </summary>
|
|
private static async Task CreateTestDatabaseAsync(string databasePath)
|
|
{
|
|
var dbConnection = await SQLiteLoader.LoadConnectionAsync(databasePath);
|
|
|
|
// Use DatabaseUpgrader to create the schema from embedded resources
|
|
DatabaseUpgrader.UpgradeDatabase(dbConnection, databasePath, typeof(DatabaseSchemaMarker));
|
|
|
|
dbConnection.Dispose();
|
|
}
|
|
|
|
#region Certificate Generation and Storage Tests
|
|
|
|
[Test]
|
|
public void GenerateAndStoreCertificates_StoresValidCertificatesInDatabase()
|
|
{
|
|
// Act
|
|
var hostnames = new[] { "localhost", "127.0.0.1" };
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
var serverPair = CertificateGenerator.GenerateServerCertificate(
|
|
caPair.Certificate,
|
|
caPair.PrivateKey,
|
|
hostnames);
|
|
|
|
var pfxPassword = "test-password-123";
|
|
var pfxBytes = CertificateGenerator.CreatePfxBundle(serverPair, caPair, pfxPassword);
|
|
var pfxBase64 = Convert.ToBase64String(pfxBytes);
|
|
|
|
var (caCertBase64, caKeyEncrypted) = CertificateStorageHelper.SerializeCACertificatePair(caPair, pfxPassword);
|
|
|
|
// Store in database
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE] = caCertBase64,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_KEY] = caKeyEncrypted,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_PASSWORD] = pfxPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE] = pfxBase64,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD] = pfxPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE_AUTOGENERATED] = "true"
|
|
};
|
|
|
|
_connection.ApplicationSettings.UpdateSettings(settings, false);
|
|
|
|
// Assert - Verify stored values
|
|
Assert.IsNotNull(_connection.ApplicationSettings.ServerCACertificate);
|
|
Assert.IsNotNull(_connection.ApplicationSettings.ServerCACertificateKey);
|
|
Assert.IsNotNull(_connection.ApplicationSettings.ServerCACertificatePassword);
|
|
Assert.IsNotNull(_connection.ApplicationSettings.ServerSSLCertificate);
|
|
Assert.IsTrue(_connection.ApplicationSettings.ServerSSLCertificateAutogenerated);
|
|
|
|
// Verify certificate can be loaded and is valid
|
|
var loadedCertCollection = _connection.ApplicationSettings.ServerSSLCertificate;
|
|
Assert.IsNotNull(loadedCertCollection);
|
|
Assert.That(loadedCertCollection!.Count, Is.GreaterThan(0));
|
|
|
|
var serverCert = loadedCertCollection.Cast<X509Certificate2>().FirstOrDefault(c => c.HasPrivateKey);
|
|
Assert.IsNotNull(serverCert);
|
|
Assert.IsTrue(serverCert!.HasPrivateKey);
|
|
Assert.That(serverCert.NotAfter, Is.GreaterThan(DateTime.Now));
|
|
}
|
|
|
|
[Test]
|
|
public void StoreAndRetrieveCAData_RoundTripsCorrectly()
|
|
{
|
|
// Arrange
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
var password = "test-password-456";
|
|
|
|
// Act
|
|
var (caCertBase64, caKeyEncrypted) = CertificateStorageHelper.SerializeCACertificatePair(caPair, password);
|
|
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE] = caCertBase64,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_KEY] = caKeyEncrypted,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_PASSWORD] = password
|
|
};
|
|
|
|
_connection.ApplicationSettings.UpdateSettings(settings, false);
|
|
|
|
// Retrieve from settings (API changed - RetrieveCAData removed)
|
|
var retrievedCert = _connection.ApplicationSettings.ServerCACertificate;
|
|
var retrievedKey = _connection.ApplicationSettings.ServerCACertificateKey;
|
|
var retrievedPassword = _connection.ApplicationSettings.ServerCACertificatePassword;
|
|
|
|
// Assert
|
|
Assert.IsNotNull(retrievedCert);
|
|
Assert.IsNotNull(retrievedKey);
|
|
Assert.IsNotNull(retrievedPassword);
|
|
|
|
// Verify can be deserialized
|
|
var deserializedCa = CertificateStorageHelper.DeserializeCACertificatePair(
|
|
retrievedCert!,
|
|
retrievedKey!,
|
|
retrievedPassword!);
|
|
|
|
Assert.AreEqual(caPair.Certificate.Thumbprint, deserializedCa.Certificate.Thumbprint);
|
|
}
|
|
|
|
[Test]
|
|
public void UseHTTPS_WithValidCertificates_ReturnsTrue()
|
|
{
|
|
// Arrange - Store valid certificates
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
var serverPair = CertificateGenerator.GenerateServerCertificate(
|
|
caPair.Certificate,
|
|
caPair.PrivateKey,
|
|
new[] { "localhost" });
|
|
|
|
var pfxPassword = "test-password-789";
|
|
var pfxBytes = CertificateGenerator.CreatePfxBundle(serverPair, caPair, pfxPassword);
|
|
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE] = Convert.ToBase64String(pfxBytes),
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD] = pfxPassword,
|
|
[ServerSettings.CONST.SERVER_DISABLE_HTTPS] = "false"
|
|
};
|
|
|
|
_connection.ApplicationSettings.UpdateSettings(settings, false);
|
|
|
|
// Act & Assert
|
|
Assert.IsTrue(_connection.ApplicationSettings.UseHTTPS);
|
|
}
|
|
|
|
[Test]
|
|
public void UseHTTPS_WithDisableHttpsFlag_ReturnsFalse()
|
|
{
|
|
// Arrange - Store valid certificates but disable HTTPS
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
var serverPair = CertificateGenerator.GenerateServerCertificate(
|
|
caPair.Certificate,
|
|
caPair.PrivateKey,
|
|
new[] { "localhost" });
|
|
|
|
var pfxPassword = "test-password-abc";
|
|
var pfxBytes = CertificateGenerator.CreatePfxBundle(serverPair, caPair, pfxPassword);
|
|
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE] = Convert.ToBase64String(pfxBytes),
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD] = pfxPassword,
|
|
[ServerSettings.CONST.SERVER_DISABLE_HTTPS] = "true"
|
|
};
|
|
|
|
_connection.ApplicationSettings.UpdateSettings(settings, false);
|
|
|
|
// Act & Assert
|
|
Assert.IsFalse(_connection.ApplicationSettings.UseHTTPS);
|
|
}
|
|
|
|
[Test]
|
|
public void UseHTTPS_WithoutCertificates_ReturnsFalse()
|
|
{
|
|
// Arrange - No certificates stored
|
|
Assert.IsNull(_connection.ApplicationSettings.ServerSSLCertificate);
|
|
|
|
// Act & Assert
|
|
Assert.IsFalse(_connection.ApplicationSettings.UseHTTPS);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Auto-Renewal Tests
|
|
|
|
[Test]
|
|
public void CertificateRenewalChecker_HasValidCertificates_WithValidCert_ReturnsTrue()
|
|
{
|
|
// Arrange - Store valid certificates
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
var serverPair = CertificateGenerator.GenerateServerCertificate(
|
|
caPair.Certificate,
|
|
caPair.PrivateKey,
|
|
new[] { "localhost" });
|
|
|
|
var pfxPassword = "renewal-test-1";
|
|
var pfxBytes = CertificateGenerator.CreatePfxBundle(serverPair, caPair, pfxPassword);
|
|
|
|
var (caCertBase64, caKeyEncrypted) = CertificateStorageHelper.SerializeCACertificatePair(caPair, pfxPassword);
|
|
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE] = caCertBase64,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_KEY] = caKeyEncrypted,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_PASSWORD] = pfxPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE] = Convert.ToBase64String(pfxBytes),
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD] = pfxPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE_AUTOGENERATED] = "true"
|
|
};
|
|
|
|
_connection.ApplicationSettings.UpdateSettings(settings, false);
|
|
|
|
// Act - New API signature: pass parameters directly instead of Connection
|
|
var caData = new CACertificateData
|
|
{
|
|
CACertificate = caCertBase64,
|
|
CAKey = caKeyEncrypted,
|
|
CAPassword = pfxPassword
|
|
};
|
|
var serverCert = _connection.ApplicationSettings.ServerSSLCertificate;
|
|
var isAutogenerated = _connection.ApplicationSettings.ServerSSLCertificateAutogenerated;
|
|
var hasValidCerts = CertificateRenewalChecker.HasValidCertificates(isAutogenerated, serverCert, caData);
|
|
|
|
// Assert
|
|
Assert.IsTrue(hasValidCerts);
|
|
}
|
|
|
|
[Test]
|
|
public void CertificateRenewalChecker_HasValidCertificates_WithExpiringCert_ReturnsFalse()
|
|
{
|
|
// Arrange - Create an expired certificate by manipulating validity dates is not possible
|
|
// with the current generator, so we use reflection to create a mock expired certificate
|
|
// or we test the IsExpiringSoon method directly
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
|
|
// Create a certificate with short validity to simulate near-expiry
|
|
var serverPair = CertificateGenerator.GenerateServerCertificate(
|
|
caPair.Certificate,
|
|
caPair.PrivateKey,
|
|
new[] { "localhost" });
|
|
|
|
// The certificate should not be expiring soon (it's newly created)
|
|
Assert.IsFalse(CertificateGenerator.IsExpiringSoon(serverPair.Certificate, 30));
|
|
}
|
|
|
|
[Test]
|
|
public void CertificateRenewalChecker_HasValidCertificates_NonAutogenerated_ReturnsFalse()
|
|
{
|
|
// Arrange - Store certificates but mark as not autogenerated
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
var serverPair = CertificateGenerator.GenerateServerCertificate(
|
|
caPair.Certificate,
|
|
caPair.PrivateKey,
|
|
new[] { "localhost" });
|
|
|
|
var pfxPassword = "renewal-test-2";
|
|
var pfxBytes = CertificateGenerator.CreatePfxBundle(serverPair, caPair, pfxPassword);
|
|
|
|
var (caCertBase64, caKeyEncrypted) = CertificateStorageHelper.SerializeCACertificatePair(caPair, pfxPassword);
|
|
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE] = caCertBase64,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_KEY] = caKeyEncrypted,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_PASSWORD] = pfxPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE] = Convert.ToBase64String(pfxBytes),
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD] = pfxPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE_AUTOGENERATED] = "false" // Not autogenerated
|
|
};
|
|
|
|
_connection.ApplicationSettings.UpdateSettings(settings, false);
|
|
|
|
// Act - New API signature: pass parameters directly instead of Connection
|
|
var caData = new CACertificateData
|
|
{
|
|
CACertificate = caCertBase64,
|
|
CAKey = caKeyEncrypted,
|
|
CAPassword = pfxPassword
|
|
};
|
|
var serverCert = _connection.ApplicationSettings.ServerSSLCertificate;
|
|
var isAutogenerated = _connection.ApplicationSettings.ServerSSLCertificateAutogenerated;
|
|
var hasValidCerts = CertificateRenewalChecker.HasValidCertificates(isAutogenerated, serverCert, caData);
|
|
|
|
// Assert - Should return false for non-autogenerated certs
|
|
Assert.IsFalse(hasValidCerts);
|
|
}
|
|
|
|
[Test]
|
|
public void CheckAndRenewIfNeeded_WithExpiringCertificate_PerformsRenewal()
|
|
{
|
|
// Arrange - Store valid certificates
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
var serverPair = CertificateGenerator.GenerateServerCertificate(
|
|
caPair.Certificate,
|
|
caPair.PrivateKey,
|
|
new[] { "localhost" });
|
|
|
|
var pfxPassword = "renewal-test-3";
|
|
var pfxBytes = CertificateGenerator.CreatePfxBundle(serverPair, caPair, pfxPassword);
|
|
|
|
var (caCertBase64, caKeyEncrypted) = CertificateStorageHelper.SerializeCACertificatePair(caPair, pfxPassword);
|
|
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE] = caCertBase64,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_KEY] = caKeyEncrypted,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_PASSWORD] = pfxPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE] = Convert.ToBase64String(pfxBytes),
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD] = pfxPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE_AUTOGENERATED] = "true"
|
|
};
|
|
|
|
_connection.ApplicationSettings.UpdateSettings(settings, false);
|
|
|
|
var originalThumbprint = serverPair.Certificate.Thumbprint;
|
|
|
|
// Act - Try renewal (won't actually renew since cert is not expiring)
|
|
// New API signature: pass parameters directly instead of Connection
|
|
var caData = new CACertificateData
|
|
{
|
|
CACertificate = caCertBase64,
|
|
CAKey = caKeyEncrypted,
|
|
CAPassword = pfxPassword
|
|
};
|
|
var serverCert = _connection.ApplicationSettings.ServerSSLCertificate;
|
|
var isAutogenerated = _connection.ApplicationSettings.ServerSSLCertificateAutogenerated;
|
|
var result = CertificateRenewalChecker.CheckAndRenewIfNeeded(isAutogenerated, serverCert, caData);
|
|
|
|
// Assert - Should not renew since certificate is new (return type is now CertificateRenewalResult)
|
|
Assert.IsFalse(result.Renewed);
|
|
Assert.IsNull(result.RenewedCertificate);
|
|
|
|
// Verify original certificate is still in place
|
|
var currentCert = _connection.ApplicationSettings.ServerSSLCertificate;
|
|
var currentThumbprint = currentCert!.Cast<X509Certificate2>().First(c => c.HasPrivateKey).Thumbprint;
|
|
Assert.AreEqual(originalThumbprint, currentThumbprint);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region CA Regeneration Tests
|
|
|
|
[Test]
|
|
public void RegenerateCA_StoresNewCertificatesAndPreservesCAData()
|
|
{
|
|
// Arrange - First generation
|
|
var originalCaPair = CertificateGenerator.GenerateCACertificate();
|
|
var originalServerPair = CertificateGenerator.GenerateServerCertificate(
|
|
originalCaPair.Certificate,
|
|
originalCaPair.PrivateKey,
|
|
new[] { "localhost" });
|
|
|
|
var originalPassword = "original-password";
|
|
var originalPfxBytes = CertificateGenerator.CreatePfxBundle(originalServerPair, originalCaPair, originalPassword);
|
|
var (originalCaCert, originalCaKey) = CertificateStorageHelper.SerializeCACertificatePair(originalCaPair, originalPassword);
|
|
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE] = originalCaCert,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_KEY] = originalCaKey,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_PASSWORD] = originalPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE] = Convert.ToBase64String(originalPfxBytes),
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD] = originalPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE_AUTOGENERATED] = "true"
|
|
};
|
|
|
|
_connection.ApplicationSettings.UpdateSettings(settings, false);
|
|
|
|
var originalThumbprint = originalCaPair.Certificate.Thumbprint;
|
|
|
|
// Act - Simulate CA regeneration by generating new certificates
|
|
var newCaPair = CertificateGenerator.GenerateCACertificate();
|
|
var newServerPair = CertificateGenerator.GenerateServerCertificate(
|
|
newCaPair.Certificate,
|
|
newCaPair.PrivateKey,
|
|
new[] { "localhost", "example.com" });
|
|
|
|
var newPassword = "new-password";
|
|
var newPfxBytes = CertificateGenerator.CreatePfxBundle(newServerPair, newCaPair, newPassword);
|
|
var (newCaCert, newCaKey) = CertificateStorageHelper.SerializeCACertificatePair(newCaPair, newPassword);
|
|
|
|
var newSettings = new Dictionary<string, string?>
|
|
{
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE] = newCaCert,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_KEY] = newCaKey,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_PASSWORD] = newPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE] = Convert.ToBase64String(newPfxBytes),
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD] = newPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE_AUTOGENERATED] = "true"
|
|
};
|
|
|
|
_connection.ApplicationSettings.UpdateSettings(newSettings, false);
|
|
|
|
// Assert - Verify new CA is different
|
|
var retrievedCa = _connection.ApplicationSettings.ServerCACertificate;
|
|
Assert.IsNotNull(retrievedCa);
|
|
var loadedCa = CertificateStorageHelper.DeserializeCertificate(retrievedCa!);
|
|
Assert.AreNotEqual(originalThumbprint, loadedCa.Thumbprint);
|
|
|
|
// Verify new server cert has additional hostname
|
|
var serverCert = _connection.ApplicationSettings.ServerSSLCertificate;
|
|
Assert.IsNotNull(serverCert);
|
|
var cert = serverCert!.Cast<X509Certificate2>().First(c => c.HasPrivateKey);
|
|
var sanExt = cert.Extensions["2.5.29.17"] as X509SubjectAlternativeNameExtension;
|
|
Assert.IsNotNull(sanExt);
|
|
Assert.That(sanExt!.EnumerateDnsNames(), Contains.Item("example.com"));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Database Integration Tests
|
|
|
|
[Test]
|
|
public void RemoveCertificates_ClearsAllCertificateData()
|
|
{
|
|
// Arrange - Store certificates
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
var serverPair = CertificateGenerator.GenerateServerCertificate(
|
|
caPair.Certificate,
|
|
caPair.PrivateKey,
|
|
new[] { "localhost" });
|
|
|
|
var pfxPassword = "remove-test";
|
|
var pfxBytes = CertificateGenerator.CreatePfxBundle(serverPair, caPair, pfxPassword);
|
|
var (caCert, caKey) = CertificateStorageHelper.SerializeCACertificatePair(caPair, pfxPassword);
|
|
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE] = caCert,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_KEY] = caKey,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_PASSWORD] = pfxPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE] = Convert.ToBase64String(pfxBytes),
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD] = pfxPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE_AUTOGENERATED] = "true"
|
|
};
|
|
|
|
_connection.ApplicationSettings.UpdateSettings(settings, false);
|
|
|
|
// Verify certificates exist
|
|
Assert.IsNotNull(_connection.ApplicationSettings.ServerCACertificate);
|
|
Assert.IsNotNull(_connection.ApplicationSettings.ServerSSLCertificate);
|
|
|
|
// Act - Remove all certificate data
|
|
var removeSettings = new Dictionary<string, string?>
|
|
{
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE] = null,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_KEY] = null,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_PASSWORD] = null,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE] = null,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD] = null,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE_AUTOGENERATED] = null
|
|
};
|
|
|
|
_connection.ApplicationSettings.UpdateSettings(removeSettings, false);
|
|
|
|
// Assert - All certificate data should be cleared
|
|
Assert.IsNull(_connection.ApplicationSettings.ServerCACertificate);
|
|
Assert.IsNull(_connection.ApplicationSettings.ServerCACertificateKey);
|
|
Assert.IsNull(_connection.ApplicationSettings.ServerCACertificatePassword);
|
|
Assert.IsNull(_connection.ApplicationSettings.ServerSSLCertificate);
|
|
Assert.IsFalse(_connection.ApplicationSettings.ServerSSLCertificateAutogenerated);
|
|
}
|
|
|
|
[Test]
|
|
public void CertificateStorage_WithEncryptedFieldsEnabled_StoresSecurely()
|
|
{
|
|
// Note: This test verifies the database structure supports encrypted fields.
|
|
// Full encryption testing requires a database initialized with encryption key.
|
|
|
|
// Arrange & Act - Store certificates
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
var serverPair = CertificateGenerator.GenerateServerCertificate(
|
|
caPair.Certificate,
|
|
caPair.PrivateKey,
|
|
new[] { "localhost" });
|
|
|
|
var pfxPassword = "encryption-test";
|
|
var pfxBytes = CertificateGenerator.CreatePfxBundle(serverPair, caPair, pfxPassword);
|
|
var (caCert, caKey) = CertificateStorageHelper.SerializeCACertificatePair(caPair, pfxPassword);
|
|
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE] = caCert,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_KEY] = caKey,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_PASSWORD] = pfxPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE] = Convert.ToBase64String(pfxBytes),
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD] = pfxPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE_AUTOGENERATED] = "true"
|
|
};
|
|
|
|
_connection.ApplicationSettings.UpdateSettings(settings, false);
|
|
|
|
// Assert - Verify encrypted key is actually encrypted (not plaintext)
|
|
var storedKey = _connection.ApplicationSettings.ServerCACertificateKey;
|
|
Assert.IsNotNull(storedKey);
|
|
|
|
// The key should be encrypted and not directly parseable as a key
|
|
// (This is a basic sanity check - the actual encryption is tested in CertificateTests)
|
|
Assert.DoesNotThrow(() => Convert.FromBase64String(storedKey!));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Autogenerated Flag Tests
|
|
|
|
[Test]
|
|
public void StoreCAAndServerCertificate_MarksAsAutogenerated()
|
|
{
|
|
// Arrange
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
var serverPair = CertificateGenerator.GenerateServerCertificate(
|
|
caPair.Certificate,
|
|
caPair.PrivateKey,
|
|
new[] { "localhost" });
|
|
|
|
var pfxPassword = "autogen-test";
|
|
var pfxBytes = CertificateGenerator.CreatePfxBundle(serverPair, caPair, pfxPassword);
|
|
var (caCert, caKey) = CertificateStorageHelper.SerializeCACertificatePair(caPair, pfxPassword);
|
|
|
|
// Act - Store directly (StoreCAAndServerCertificate method removed)
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE] = caCert,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_KEY] = caKey,
|
|
[ServerSettings.CONST.SERVER_CA_CERTIFICATE_PASSWORD] = pfxPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE] = Convert.ToBase64String(pfxBytes),
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD] = pfxPassword,
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE_AUTOGENERATED] = "true"
|
|
};
|
|
_connection.ApplicationSettings.UpdateSettings(settings, false);
|
|
|
|
// Assert
|
|
Assert.IsTrue(_connection.ApplicationSettings.ServerSSLCertificateAutogenerated);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Kestrel HTTPS Configuration Tests
|
|
|
|
[Test]
|
|
public void ServerCertificate_HasPrivateKey_ForKestrelUsage()
|
|
{
|
|
// Arrange
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
var serverPair = CertificateGenerator.GenerateServerCertificate(
|
|
caPair.Certificate,
|
|
caPair.PrivateKey,
|
|
new[] { "localhost" });
|
|
|
|
var pfxPassword = "kestrel-test";
|
|
var pfxBytes = CertificateGenerator.CreatePfxBundle(serverPair, caPair, pfxPassword);
|
|
|
|
// Store in database
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE] = Convert.ToBase64String(pfxBytes),
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD] = pfxPassword,
|
|
};
|
|
|
|
_connection.ApplicationSettings.UpdateSettings(settings, false);
|
|
|
|
// Act
|
|
var certCollection = _connection.ApplicationSettings.ServerSSLCertificate;
|
|
|
|
// Assert - Kestrel requires a certificate with private key
|
|
Assert.IsNotNull(certCollection);
|
|
var certWithKey = certCollection!.Cast<X509Certificate2>().FirstOrDefault(c => c.HasPrivateKey);
|
|
Assert.IsNotNull(certWithKey);
|
|
Assert.IsTrue(certWithKey!.HasPrivateKey);
|
|
}
|
|
|
|
[Test]
|
|
public void ServerCertificate_ContainsIntermediateCA_ForChainValidation()
|
|
{
|
|
// Arrange
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
var serverPair = CertificateGenerator.GenerateServerCertificate(
|
|
caPair.Certificate,
|
|
caPair.PrivateKey,
|
|
new[] { "localhost" });
|
|
|
|
var pfxPassword = "chain-test";
|
|
var pfxBytes = CertificateGenerator.CreatePfxBundle(serverPair, caPair, pfxPassword);
|
|
|
|
// Store in database
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATE] = Convert.ToBase64String(pfxBytes),
|
|
[ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD] = pfxPassword,
|
|
};
|
|
|
|
_connection.ApplicationSettings.UpdateSettings(settings, false);
|
|
|
|
// Act
|
|
var certCollection = _connection.ApplicationSettings.ServerSSLCertificate;
|
|
|
|
// Assert - PFX bundle should contain both server cert and CA
|
|
Assert.IsNotNull(certCollection);
|
|
Assert.That(certCollection!.Count, Is.GreaterThanOrEqualTo(1));
|
|
|
|
// Verify chain can be built
|
|
using var chain = new X509Chain();
|
|
var serverCert = certCollection.Cast<X509Certificate2>().First(c => c.HasPrivateKey);
|
|
chain.ChainPolicy.ExtraStore.AddRange(certCollection);
|
|
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
|
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
|
|
|
|
var chainBuilt = chain.Build(serverCert);
|
|
Assert.IsTrue(chainBuilt);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Certificate Validation Tests
|
|
|
|
[Test]
|
|
public void GeneratedCertificate_HasCorrectExtendedKeyUsage()
|
|
{
|
|
// Arrange & Act
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
var serverPair = CertificateGenerator.GenerateServerCertificate(
|
|
caPair.Certificate,
|
|
caPair.PrivateKey,
|
|
new[] { "localhost" });
|
|
|
|
// Assert
|
|
var ekuExtension = serverPair.Certificate.Extensions["2.5.29.37"] as X509EnhancedKeyUsageExtension;
|
|
Assert.IsNotNull(ekuExtension);
|
|
Assert.That(ekuExtension!.EnhancedKeyUsages, Has.Some.Matches<Oid>(oid => oid.Value == "1.3.6.1.5.5.7.3.1")); // Server Authentication
|
|
}
|
|
|
|
[Test]
|
|
public void GeneratedCertificate_HasSubjectAlternativeNames()
|
|
{
|
|
// Arrange & Act
|
|
var hostnames = new[] { "localhost", "test.example.com", "192.168.1.1" };
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
var serverPair = CertificateGenerator.GenerateServerCertificate(
|
|
caPair.Certificate,
|
|
caPair.PrivateKey,
|
|
hostnames);
|
|
|
|
// Assert
|
|
var sanExtension = serverPair.Certificate.Extensions["2.5.29.17"] as X509SubjectAlternativeNameExtension;
|
|
Assert.IsNotNull(sanExtension);
|
|
|
|
var dnsNames = sanExtension!.EnumerateDnsNames().ToList();
|
|
var ipAddresses = sanExtension.EnumerateIPAddresses().Select(ip => ip.ToString()).ToList();
|
|
|
|
Assert.That(dnsNames, Contains.Item("localhost"));
|
|
Assert.That(dnsNames, Contains.Item("test.example.com"));
|
|
Assert.That(ipAddresses, Has.Some.EqualTo("192.168.1.1"));
|
|
}
|
|
|
|
[Test]
|
|
public void GeneratedCA_HasCorrectBasicConstraints()
|
|
{
|
|
// Arrange & Act
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
|
|
// Assert
|
|
var basicConstraints = caPair.Certificate.Extensions["2.5.29.19"] as X509BasicConstraintsExtension;
|
|
Assert.IsNotNull(basicConstraints);
|
|
Assert.IsTrue(basicConstraints!.CertificateAuthority);
|
|
Assert.IsTrue(basicConstraints.HasPathLengthConstraint);
|
|
Assert.AreEqual(0, basicConstraints.PathLengthConstraint); // Cannot sign sub-CAs
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Certificate Expiration Tests
|
|
|
|
[Test]
|
|
public void IsExpiringSoon_WithValidCertificate_ReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
var serverPair = CertificateGenerator.GenerateServerCertificate(
|
|
caPair.Certificate,
|
|
caPair.PrivateKey,
|
|
new[] { "localhost" });
|
|
|
|
// Act & Assert - New certificate should not be expiring
|
|
Assert.IsFalse(CertificateGenerator.IsExpiringSoon(serverPair.Certificate, 30));
|
|
}
|
|
|
|
[Test]
|
|
public void CertificateValidity_ServerCert_Approximately90Days()
|
|
{
|
|
// Arrange & Act
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
var serverPair = CertificateGenerator.GenerateServerCertificate(
|
|
caPair.Certificate,
|
|
caPair.PrivateKey,
|
|
new[] { "localhost" });
|
|
|
|
// Assert
|
|
var validity = serverPair.Certificate.NotAfter - serverPair.Certificate.NotBefore;
|
|
Assert.That(validity.TotalDays, Is.GreaterThan(80));
|
|
Assert.That(validity.TotalDays, Is.LessThan(100));
|
|
}
|
|
|
|
[Test]
|
|
public void CertificateValidity_CACert_Approximately10Years()
|
|
{
|
|
// Arrange & Act
|
|
var caPair = CertificateGenerator.GenerateCACertificate();
|
|
|
|
// Assert
|
|
var validity = caPair.Certificate.NotAfter - caPair.Certificate.NotBefore;
|
|
Assert.That(validity.TotalDays, Is.GreaterThan(365 * 9));
|
|
Assert.That(validity.TotalDays, Is.LessThan(365 * 11));
|
|
}
|
|
|
|
#endregion
|
|
}
|