server-monitor-manager/tests/ServerMonitorManager.Desktop.Security.Tests/UpdateServiceTests.cs
Ochenstarik e1359b1b5f fix(release): publish keyless signing certificate
Close the producer/consumer certificate contract and advance the immutable correction release to alpha.14.
2026-08-13 14:29:16 +07:00

300 lines
15 KiB
C#

using System;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using ServerMonitorManager_Desktop;
namespace ServerMonitorManager.Desktop.Security.Tests
{
public class MockHttpTransport : IHttpTransport
{
public Func<string, Task<string>> GetStringAsyncFunc { get; set; } = _ => Task.FromResult("");
public Func<string, Task<byte[]>> GetByteArrayAsyncFunc { get; set; } = _ => Task.FromResult(Array.Empty<byte>());
public Func<string, string, Action<double>?, Task> DownloadFileAsyncFunc { get; set; } = (_, _, _) => Task.CompletedTask;
public Task<string> GetStringAsync(string url, CancellationToken cancellationToken = default) => GetStringAsyncFunc(url);
public Task<byte[]> GetByteArrayAsync(string url, CancellationToken cancellationToken = default) => GetByteArrayAsyncFunc(url);
public Task DownloadFileAsync(string url, string destinationPath, Action<double>? progressCallback = null, CancellationToken cancellationToken = default) => DownloadFileAsyncFunc(url, destinationPath, progressCallback);
}
public class MockSignatureVerifier : ISignatureVerifier
{
public Func<string, string, string, Task> VerifySignatureAsyncFunc { get; set; } = (_, _, _) => Task.CompletedTask;
public Task VerifySignatureAsync(string signaturePath, string manifestPath, string certificatePath, CancellationToken cancellationToken = default) => VerifySignatureAsyncFunc(signaturePath, manifestPath, certificatePath);
}
public class MockFileStorage : IFileStorage
{
public string GetTempFolder() => Path.GetTempPath();
public bool FileExists(string path) => true;
public Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task WriteAllTextAsync(string path, string text, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Func<string, Stream> OpenReadFunc { get; set; } = _ => new MemoryStream();
public Stream OpenRead(string path) => OpenReadFunc(path);
public Action<string> LaunchFileFunc { get; set; } = _ => { };
public void LaunchFile(string path) => LaunchFileFunc(path);
}
public class UpdateServiceTests
{
private const string ValidReleaseJson = @"
{
""tag_name"": ""v0.1.0-alpha.9"",
""assets"": [
{ ""name"": ""server-monitor-manager-manifest.json"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/server-monitor-manager-manifest.json"" },
{ ""name"": ""server-monitor-manager-manifest.sig"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/server-monitor-manager-manifest.sig"" },
{ ""name"": ""server-monitor-manager-manifest.pem"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/server-monitor-manager-manifest.pem"" },
{ ""name"": ""ServerMonitorManager-win-x64.msix"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/ServerMonitorManager-win-x64.msix"" }
]
}";
// Release JSON without the .sig asset — used by Test4
private const string NoSigReleaseJson = @"
{
""tag_name"": ""v0.1.0-alpha.9"",
""assets"": [
{ ""name"": ""server-monitor-manager-manifest.json"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/server-monitor-manager-manifest.json"" },
{ ""name"": ""ServerMonitorManager-win-x64.msix"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/ServerMonitorManager-win-x64.msix"" }
]
}";
private const string NoCertificateReleaseJson = @"
{
""tag_name"": ""v0.1.0-alpha.9"",
""assets"": [
{ ""name"": ""server-monitor-manager-manifest.json"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/server-monitor-manager-manifest.json"" },
{ ""name"": ""server-monitor-manager-manifest.sig"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/server-monitor-manager-manifest.sig"" },
{ ""name"": ""ServerMonitorManager-win-x64.msix"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/ServerMonitorManager-win-x64.msix"" }
]
}";
private const string ValidManifestJson = @"
{
""version"": ""v0.1.0-alpha.9"",
""hashes"": {
""ServerMonitorManager-win-x64.msix"": ""e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855""
}
}"; // Hash for empty string
[Fact]
public async Task Test1_ValidSignatureAndHash_Accepted()
{
var http = new MockHttpTransport
{
GetStringAsyncFunc = url =>
{
if (url.EndsWith("releases/latest")) return Task.FromResult(ValidReleaseJson);
if (url.EndsWith(".json")) return Task.FromResult(ValidManifestJson);
if (url.EndsWith(".sig")) return Task.FromResult("valid-sig");
return Task.FromResult("");
}
};
var verifier = new MockSignatureVerifier();
var storage = new MockFileStorage
{
OpenReadFunc = _ => new MemoryStream(Array.Empty<byte>()) // Hash of empty matches e3b0c4...
};
var service = new UpdateService(http, verifier, storage);
var update = await service.CheckForUpdatesAsync();
Assert.NotNull(update);
Assert.Equal("v0.1.0-alpha.9", update.Version);
await service.DownloadAndVerifyUpdateAsync(update);
}
[Fact]
public async Task Test2_ManifestWithWrongIdentity_Rejected()
{
var http = new MockHttpTransport { GetStringAsyncFunc = url => Task.FromResult(url.EndsWith("releases/latest") ? ValidReleaseJson : ValidManifestJson) };
var verifier = new MockSignatureVerifier
{
VerifySignatureAsyncFunc = (_, _, _) => throw new InvalidOperationException("Signature verification failed: identity mismatch")
};
var storage = new MockFileStorage();
var service = new UpdateService(http, verifier, storage);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckForUpdatesAsync());
Assert.Contains("identity mismatch", ex.Message);
}
[Fact]
public async Task Test3_ManifestHashMismatch_Rejected()
{
var http = new MockHttpTransport
{
GetStringAsyncFunc = url =>
{
if (url.EndsWith("releases/latest")) return Task.FromResult(ValidReleaseJson);
if (url.EndsWith(".json")) return Task.FromResult(ValidManifestJson);
if (url.EndsWith(".sig")) return Task.FromResult("valid-sig");
return Task.FromResult("");
}
};
var verifier = new MockSignatureVerifier();
var storage = new MockFileStorage
{
OpenReadFunc = _ => new MemoryStream(Encoding.UTF8.GetBytes("wrong content"))
};
var service = new UpdateService(http, verifier, storage);
var update = await service.CheckForUpdatesAsync();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.DownloadAndVerifyUpdateAsync(update));
Assert.Contains("hash mismatch", ex.Message);
}
[Fact]
public async Task Test4_MissingSignature_Rejected()
{
var http = new MockHttpTransport { GetStringAsyncFunc = _ => Task.FromResult(NoSigReleaseJson) };
var service = new UpdateService(http, new MockSignatureVerifier(), new MockFileStorage());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckForUpdatesAsync());
Assert.Contains("signature", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Test5_InvalidSignature_Rejected()
{
var http = new MockHttpTransport { GetStringAsyncFunc = url => Task.FromResult(url.EndsWith("releases/latest") ? ValidReleaseJson : ValidManifestJson) };
var verifier = new MockSignatureVerifier
{
VerifySignatureAsyncFunc = (_, _, _) => throw new InvalidOperationException("Signature verification failed: invalid signature format")
};
var storage = new MockFileStorage();
var service = new UpdateService(http, verifier, storage);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckForUpdatesAsync());
Assert.Contains("invalid signature format", ex.Message);
}
[Fact]
public async Task Test6_UpdateActionNotShownUntilVerified()
{
var http = new MockHttpTransport { GetStringAsyncFunc = url => Task.FromResult(url.EndsWith("releases/latest") ? ValidReleaseJson : ValidManifestJson) };
bool signatureVerified = false;
var verifier = new MockSignatureVerifier
{
VerifySignatureAsyncFunc = (_, _, _) => { signatureVerified = true; return Task.CompletedTask; }
};
var storage = new MockFileStorage();
var service = new UpdateService(http, verifier, storage);
var update = await service.CheckForUpdatesAsync();
// CheckForUpdatesAsync returns the update ONLY AFTER signature is verified
Assert.True(signatureVerified);
Assert.NotNull(update);
}
[Fact]
public async Task Test7_PreReleaseChannel_Used()
{
var preReleaseJson = "[" + ValidReleaseJson.Replace("v0.1.0-alpha.9", "v0.1.0-alpha.10") + "]";
var preReleaseManifest = ValidManifestJson.Replace("v0.1.0-alpha.9", "v0.1.0-alpha.10");
var http = new MockHttpTransport
{
GetStringAsyncFunc = url =>
{
if (url.EndsWith("releases")) return Task.FromResult(preReleaseJson); // Note: releases array
if (url.EndsWith(".json")) return Task.FromResult(preReleaseManifest);
if (url.EndsWith(".sig")) return Task.FromResult("valid-sig");
return Task.FromResult("");
}
};
var service = new UpdateService(http, new MockSignatureVerifier(), new MockFileStorage());
var update = await service.CheckForUpdatesAsync(usePreRelease: true);
Assert.Equal("v0.1.0-alpha.10", update!.Version);
}
[Fact]
public async Task Test8_UrlAssetFromAnotherTag_Rejected()
{
// Msix URL points to a different tag
var maliciousRelease = ValidReleaseJson.Replace("v0.1.0-alpha.9/ServerMonitorManager-win-x64.msix", "v0.1.0-alpha.8/ServerMonitorManager-win-x64.msix");
var http = new MockHttpTransport { GetStringAsyncFunc = url => Task.FromResult(url.EndsWith("releases/latest") ? maliciousRelease : ValidManifestJson) };
var service = new UpdateService(http, new MockSignatureVerifier(), new MockFileStorage());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckForUpdatesAsync());
Assert.Contains("does not match the release tag", ex.Message);
}
[Fact]
public async Task Test9_ManifestVersionMismatch_Rejected()
{
// Manifest says v0.1.0-alpha.8 but release tag says v0.1.0-alpha.9
var mismatchedManifest = ValidManifestJson.Replace("v0.1.0-alpha.9", "v0.1.0-alpha.8");
var http = new MockHttpTransport
{
GetStringAsyncFunc = url =>
{
if (url.EndsWith("releases/latest")) return Task.FromResult(ValidReleaseJson);
if (url.EndsWith(".json")) return Task.FromResult(mismatchedManifest);
if (url.EndsWith(".sig")) return Task.FromResult("valid-sig");
return Task.FromResult("");
}
};
var service = new UpdateService(http, new MockSignatureVerifier(), new MockFileStorage());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckForUpdatesAsync());
Assert.Contains("version", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Test10_MissingMsixAsset_Rejected()
{
// Release JSON without the MSIX asset
var noMsixRelease = @"
{
""tag_name"": ""v0.1.0-alpha.9"",
""assets"": [
{ ""name"": ""server-monitor-manager-manifest.json"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/server-monitor-manager-manifest.json"" },
{ ""name"": ""server-monitor-manager-manifest.sig"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/server-monitor-manager-manifest.sig"" },
{ ""name"": ""server-monitor-manager-manifest.pem"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/server-monitor-manager-manifest.pem"" }
]
}";
var http = new MockHttpTransport
{
GetStringAsyncFunc = url =>
{
if (url.EndsWith("releases/latest")) return Task.FromResult(noMsixRelease);
if (url.EndsWith(".json")) return Task.FromResult(ValidManifestJson);
if (url.EndsWith(".sig")) return Task.FromResult("valid-sig");
return Task.FromResult("");
}
};
var service = new UpdateService(http, new MockSignatureVerifier(), new MockFileStorage());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckForUpdatesAsync());
Assert.Contains("MSIX", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task MissingCertificateAsset_IsRejected()
{
var http = new MockHttpTransport { GetStringAsyncFunc = _ => Task.FromResult(NoCertificateReleaseJson) };
var service = new UpdateService(http, new MockSignatureVerifier(), new MockFileStorage());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckForUpdatesAsync());
Assert.Contains("certificate", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ProductionVerifierArguments_PinCertificateIssuerAndReleaseWorkflow()
{
var arguments = ProcessSignatureVerifier.BuildVerificationArguments("manifest.sig", "manifest.json", "manifest.pem");
Assert.Contains("--certificate \"manifest.pem\"", arguments, StringComparison.Ordinal);
Assert.Contains("--certificate-oidc-issuer \"https://token.actions.githubusercontent.com\"", arguments, StringComparison.Ordinal);
Assert.Contains("linux-release\\.yml@refs/tags/v.*$", arguments, StringComparison.Ordinal);
}
}
}