323 lines
15 KiB
C#
323 lines
15 KiB
C#
using Microsoft.Extensions.Options;
|
|
using Microsoft.Data.Sqlite;
|
|
using ServerMonitorManager.Control;
|
|
using ServerMonitorManager.Core;
|
|
using Xunit;
|
|
|
|
namespace ServerMonitorManager.Control.Tests;
|
|
|
|
public sealed class ControlStoreTests : IAsyncDisposable
|
|
{
|
|
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"smm-tests-{Guid.NewGuid():N}");
|
|
|
|
[Fact]
|
|
public async Task EnrollmentTokenIsAtomicAndIdempotent()
|
|
{
|
|
var cancellationToken = TestContext.Current.CancellationToken;
|
|
var store = CreateStore();
|
|
await store.InitializeAsync(cancellationToken);
|
|
var token = await store.CreateEnrollmentTokenAsync("home", TimeSpan.FromMinutes(10), cancellationToken);
|
|
var idempotencyKey = Guid.NewGuid().ToString();
|
|
var request = new EnrollmentRequest("home", token, "unused-in-store-test", idempotencyKey);
|
|
var issued = new IssuedCertificate("certificate", "ca", "AA11", DateTimeOffset.UtcNow.AddYears(1));
|
|
|
|
var first = await store.EnrollAsync(request, () => issued, cancellationToken);
|
|
var retry = await store.EnrollAsync(request, () => throw new InvalidOperationException("must use cache"), cancellationToken);
|
|
var replay = await store.EnrollAsync(
|
|
request with { IdempotencyKey = Guid.NewGuid().ToString() },
|
|
() => issued,
|
|
cancellationToken);
|
|
|
|
Assert.NotNull(first);
|
|
Assert.Equal(first, retry);
|
|
Assert.Null(replay);
|
|
Assert.True(await store.IsCertificateForNodeAsync("AA11", "home", cancellationToken));
|
|
Assert.False(await store.IsCertificateForNodeAsync("AA11", "other", cancellationToken));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HeartbeatRetryDoesNotDuplicateMetricSample()
|
|
{
|
|
var cancellationToken = TestContext.Current.CancellationToken;
|
|
var store = CreateStore();
|
|
await store.InitializeAsync(cancellationToken);
|
|
var token = await store.CreateEnrollmentTokenAsync("home", TimeSpan.FromMinutes(10), cancellationToken);
|
|
var issued = new IssuedCertificate("certificate", "ca", "BB22", DateTimeOffset.UtcNow.AddYears(1));
|
|
await store.EnrollAsync(
|
|
new EnrollmentRequest("home", token, "csr", Guid.NewGuid().ToString()),
|
|
() => issued,
|
|
cancellationToken);
|
|
var heartbeat = new AgentHeartbeat(
|
|
"home", "test", DateTimeOffset.UtcNow, 0.5, 1, 2, 3, 4, 5, 6, 7,
|
|
Guid.NewGuid().ToString());
|
|
|
|
var first = await store.RecordHeartbeatAsync(heartbeat, 30, cancellationToken);
|
|
var retry = await store.RecordHeartbeatAsync(heartbeat, 30, cancellationToken);
|
|
|
|
Assert.Equal(first, retry);
|
|
Assert.Equal(1, first.Sequence);
|
|
await using (var connection = new SqliteConnection(
|
|
$"Data Source={Path.Combine(_directory, "control.db")}"))
|
|
{
|
|
await connection.OpenAsync(cancellationToken);
|
|
var command = connection.CreateCommand();
|
|
command.CommandText = "SELECT recorded_at FROM metric_samples LIMIT 1;";
|
|
Assert.Equal(heartbeat.SentAt.ToString("O"), await command.ExecuteScalarAsync(cancellationToken));
|
|
}
|
|
await Assert.ThrowsAsync<IdempotencyConflictException>(() => store.RecordHeartbeatAsync(
|
|
heartbeat with { LoadOne = 0.9 },
|
|
30,
|
|
cancellationToken));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeviceCertificateReceivesOperatorIdentity()
|
|
{
|
|
var cancellationToken = TestContext.Current.CancellationToken;
|
|
var store = CreateStore();
|
|
await store.InitializeAsync(cancellationToken);
|
|
var token = await store.CreateDeviceEnrollmentTokenAsync(
|
|
"windows-pc", TimeSpan.FromMinutes(10), cancellationToken);
|
|
var issued = new IssuedCertificate("certificate", "ca", "CC33", DateTimeOffset.UtcNow.AddYears(1));
|
|
var request = new DeviceEnrollmentRequest(
|
|
"windows-pc", token, "csr", Guid.NewGuid().ToString());
|
|
|
|
var first = await store.EnrollDeviceAsync(request, () => issued, cancellationToken);
|
|
var retry = await store.EnrollDeviceAsync(
|
|
request,
|
|
() => throw new InvalidOperationException("must use cache"),
|
|
cancellationToken);
|
|
var identity = await store.ResolveIdentityAsync("CC33", cancellationToken);
|
|
|
|
Assert.NotNull(first);
|
|
Assert.Equal(first, retry);
|
|
Assert.Equal(new ControlIdentity("windows-pc", "Operator"), identity);
|
|
Assert.False(await store.IsCertificateForNodeAsync("CC33", "home", cancellationToken));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LinkDesiredStateIsPersistedBeforeActualStateChanges()
|
|
{
|
|
var cancellationToken = TestContext.Current.CancellationToken;
|
|
var store = CreateStore();
|
|
await store.InitializeAsync(cancellationToken);
|
|
await EnrollAgentAsync(store, "ai-agent", "DD44", cancellationToken);
|
|
await EnrollAgentAsync(store, "home", "EE55", cancellationToken);
|
|
var request = new LinkPolicyCreateRequest(
|
|
"ai-agent", "home", "tcp", 22, 120, "development", Guid.NewGuid().ToString());
|
|
|
|
var connectingMutation = await store.CreateLinkMutationAsync(request, "windows-pc", cancellationToken);
|
|
var connecting = connectingMutation.Link;
|
|
var active = await store.SetLinkActualStateAsync(
|
|
connecting.Id, "Active", null, "windows-pc", cancellationToken);
|
|
var disconnectingMutation = await store.BeginDisableLinkMutationAsync(
|
|
connecting.Id,
|
|
new LinkPolicyDisableRequest(Guid.NewGuid().ToString()),
|
|
"windows-pc",
|
|
cancellationToken);
|
|
var disconnecting = disconnectingMutation?.Link;
|
|
var disabled = await store.SetLinkActualStateAsync(
|
|
connecting.Id, "Disabled", null, "windows-pc", cancellationToken);
|
|
var links = await store.ListLinksAsync(cancellationToken);
|
|
|
|
Assert.Equal("Active", connecting.DesiredState);
|
|
Assert.Equal("Connecting", connecting.ActualState);
|
|
Assert.Equal("Active", active?.ActualState);
|
|
Assert.Equal("Disabled", disconnecting?.DesiredState);
|
|
Assert.Equal("Disconnecting", disconnecting?.ActualState);
|
|
Assert.Equal("Disabled", disabled?.ActualState);
|
|
Assert.Single(links);
|
|
Assert.Equal(disabled, links[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LinkServiceAppliesPersistedStatesAndPublishesEvents()
|
|
{
|
|
var cancellationToken = TestContext.Current.CancellationToken;
|
|
var store = CreateStore();
|
|
await store.InitializeAsync(cancellationToken);
|
|
await EnrollAgentAsync(store, "ai-agent", "FF66", cancellationToken);
|
|
await EnrollAgentAsync(store, "home", "0011", cancellationToken);
|
|
var broker = new ControlEventBroker();
|
|
using var subscription = broker.Subscribe();
|
|
var applier = new CheckingPolicyApplier(store);
|
|
var service = new LinkService(store, applier, broker);
|
|
var createRequest = new LinkPolicyCreateRequest(
|
|
"ai-agent", "home", "tcp", 22, 30, "test", Guid.NewGuid().ToString());
|
|
|
|
var active = await service.CreateAsync(
|
|
createRequest,
|
|
"windows-pc",
|
|
cancellationToken);
|
|
var createReplay = await service.CreateAsync(createRequest, "windows-pc", cancellationToken);
|
|
var disableRequest = new LinkPolicyDisableRequest(Guid.NewGuid().ToString());
|
|
var disabled = await service.DisableAsync(
|
|
active.Id,
|
|
disableRequest,
|
|
"windows-pc",
|
|
cancellationToken);
|
|
var disableReplay = await service.DisableAsync(
|
|
active.Id, disableRequest, "windows-pc", cancellationToken);
|
|
var eventTypes = new List<string>();
|
|
while (subscription.Reader.TryRead(out var controlEvent))
|
|
{
|
|
eventTypes.Add(controlEvent.Type);
|
|
}
|
|
|
|
Assert.Equal("Active", active.ActualState);
|
|
Assert.Equal(active, createReplay);
|
|
Assert.Equal("Disabled", disabled?.DesiredState);
|
|
Assert.Equal("Disabled", disabled?.ActualState);
|
|
Assert.Equal(disabled, disableReplay);
|
|
Assert.Equal(1, applier.ConnectCalls);
|
|
Assert.Equal(1, applier.DisconnectCalls);
|
|
Assert.Equal(
|
|
["link.connecting", "link.active", "link.disconnecting", "link.disabled"],
|
|
eventTypes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AgentReenrollmentRevokesCertificateAndDisablesLinksBeforeIssuingToken()
|
|
{
|
|
var cancellationToken = TestContext.Current.CancellationToken;
|
|
var store = CreateStore();
|
|
await store.InitializeAsync(cancellationToken);
|
|
await EnrollAgentAsync(store, "ai-agent", "AA22", cancellationToken);
|
|
await EnrollAgentAsync(store, "home", "BB33", cancellationToken);
|
|
var broker = new ControlEventBroker();
|
|
var applier = new CheckingPolicyApplier(store);
|
|
var links = new LinkService(store, applier, broker);
|
|
await links.CreateAsync(
|
|
new LinkPolicyCreateRequest(
|
|
"ai-agent", "home", "tcp", 22, 60, "development", Guid.NewGuid().ToString()),
|
|
"windows-pc",
|
|
cancellationToken);
|
|
var lifecycle = new CertificateLifecycleService(store, applier, broker);
|
|
var request = new CertificateReenrollmentRequest("rotate compromised key", Guid.NewGuid().ToString());
|
|
|
|
var ticket = await lifecycle.ReenrollAgentAsync(
|
|
"ai-agent", request, "windows-pc", cancellationToken);
|
|
var replay = await lifecycle.ReenrollAgentAsync(
|
|
"ai-agent", request, "windows-pc", cancellationToken);
|
|
var disabledLink = Assert.Single(await store.ListLinksAsync(cancellationToken));
|
|
|
|
Assert.NotNull(ticket);
|
|
Assert.Equal(ticket, replay);
|
|
Assert.Equal("Agent", ticket.EntityType);
|
|
Assert.Equal("ai-agent", ticket.EntityId);
|
|
Assert.Equal(1, ticket.DisabledLinks);
|
|
Assert.True(ticket.ExpiresAt > DateTimeOffset.UtcNow);
|
|
Assert.False(await store.IsCertificateForNodeAsync("AA22", "ai-agent", cancellationToken));
|
|
Assert.Equal("Disabled", disabledLink.DesiredState);
|
|
Assert.Equal("Disabled", disabledLink.ActualState);
|
|
Assert.Equal(1, applier.DisconnectCalls);
|
|
|
|
var issued = new IssuedCertificate(
|
|
"new-certificate", "ca", "CC44", DateTimeOffset.UtcNow.AddYears(1));
|
|
var enrolled = await store.EnrollAsync(
|
|
new EnrollmentRequest(
|
|
"ai-agent", ticket.Token, "new-csr", Guid.NewGuid().ToString()),
|
|
() => issued,
|
|
cancellationToken);
|
|
Assert.NotNull(enrolled);
|
|
Assert.True(await store.IsCertificateForNodeAsync("CC44", "ai-agent", cancellationToken));
|
|
Assert.False(await store.IsCertificateForNodeAsync("AA22", "ai-agent", cancellationToken));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeviceReenrollmentRevokesOldOperatorAndIsIdempotent()
|
|
{
|
|
var cancellationToken = TestContext.Current.CancellationToken;
|
|
var store = CreateStore();
|
|
await store.InitializeAsync(cancellationToken);
|
|
var initialToken = await store.CreateDeviceEnrollmentTokenAsync(
|
|
"windows-pc", TimeSpan.FromMinutes(10), cancellationToken);
|
|
await store.EnrollDeviceAsync(
|
|
new DeviceEnrollmentRequest(
|
|
"windows-pc", initialToken, "old-csr", Guid.NewGuid().ToString()),
|
|
() => new IssuedCertificate(
|
|
"old-certificate", "ca", "DD55", DateTimeOffset.UtcNow.AddYears(1)),
|
|
cancellationToken);
|
|
var request = new CertificateReenrollmentRequest("scheduled rotation", Guid.NewGuid().ToString());
|
|
|
|
var ticket = await store.BeginDeviceReenrollmentAsync(
|
|
"windows-pc", request, "windows-pc", TimeSpan.FromMinutes(10), cancellationToken);
|
|
var replay = await store.BeginDeviceReenrollmentAsync(
|
|
"windows-pc", request, "windows-pc", TimeSpan.FromMinutes(10), cancellationToken);
|
|
|
|
Assert.NotNull(ticket);
|
|
Assert.Equal(ticket, replay);
|
|
Assert.Null(await store.ResolveIdentityAsync("DD55", cancellationToken));
|
|
var enrolled = await store.EnrollDeviceAsync(
|
|
new DeviceEnrollmentRequest(
|
|
"windows-pc", ticket.Token, "new-csr", Guid.NewGuid().ToString()),
|
|
() => new IssuedCertificate(
|
|
"new-certificate", "ca", "EE66", DateTimeOffset.UtcNow.AddYears(1)),
|
|
cancellationToken);
|
|
Assert.NotNull(enrolled);
|
|
Assert.Equal(
|
|
new ControlIdentity("windows-pc", "Operator"),
|
|
await store.ResolveIdentityAsync("EE66", cancellationToken));
|
|
Assert.Null(await store.ResolveIdentityAsync("DD55", cancellationToken));
|
|
}
|
|
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
if (Directory.Exists(_directory))
|
|
{
|
|
Directory.Delete(_directory, recursive: true);
|
|
}
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
private ControlStore CreateStore()
|
|
{
|
|
Directory.CreateDirectory(_directory);
|
|
return new ControlStore(Options.Create(new ControlOptions
|
|
{
|
|
DatabasePath = Path.Combine(_directory, "control.db"),
|
|
CertificateAuthorityPath = Path.Combine(_directory, "unused.pfx")
|
|
}));
|
|
}
|
|
|
|
private static async Task EnrollAgentAsync(
|
|
ControlStore store,
|
|
string nodeId,
|
|
string thumbprint,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var token = await store.CreateEnrollmentTokenAsync(nodeId, TimeSpan.FromMinutes(10), cancellationToken);
|
|
var issued = new IssuedCertificate(
|
|
"certificate", "ca", thumbprint, DateTimeOffset.UtcNow.AddYears(1));
|
|
var result = await store.EnrollAsync(
|
|
new EnrollmentRequest(nodeId, token, "csr", Guid.NewGuid().ToString()),
|
|
() => issued,
|
|
cancellationToken);
|
|
Assert.NotNull(result);
|
|
}
|
|
|
|
private sealed class CheckingPolicyApplier(ControlStore store) : ILinkPolicyApplier
|
|
{
|
|
public int ConnectCalls { get; private set; }
|
|
public int DisconnectCalls { get; private set; }
|
|
|
|
public async Task ApplyConnectAsync(LinkPolicy link, CancellationToken cancellationToken)
|
|
{
|
|
ConnectCalls++;
|
|
var persisted = Assert.Single(await store.ListLinksAsync(cancellationToken));
|
|
Assert.Equal(link.Id, persisted.Id);
|
|
Assert.Equal("Active", persisted.DesiredState);
|
|
Assert.Equal("Connecting", persisted.ActualState);
|
|
}
|
|
|
|
public async Task ApplyDisconnectAsync(LinkPolicy link, CancellationToken cancellationToken)
|
|
{
|
|
DisconnectCalls++;
|
|
var persisted = Assert.Single(await store.ListLinksAsync(cancellationToken));
|
|
Assert.Equal(link.Id, persisted.Id);
|
|
Assert.Equal("Disabled", persisted.DesiredState);
|
|
Assert.Equal("Disconnecting", persisted.ActualState);
|
|
}
|
|
}
|
|
}
|