400 lines
17 KiB
C#
400 lines
17 KiB
C#
extern alias controlapp;
|
|
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using System.Text;
|
|
using System.Text.Encodings.Web;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.AspNetCore.TestHost;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using ServerMonitorManager.Core;
|
|
using Xunit;
|
|
|
|
namespace ServerMonitorManager.Control.Tests;
|
|
|
|
public sealed class NodeEnrollmentCodeTests : IAsyncDisposable
|
|
{
|
|
private readonly NodeEnrollmentTestFactory _factory = new();
|
|
|
|
[Fact]
|
|
public async Task AnonymousRequestIsRejectedWithUnauthorized()
|
|
{
|
|
using var anonymous = _factory.CreateClient();
|
|
var response = await anonymous.PostAsync(
|
|
"/api/v1/control/agents/test-node/enrollment-code",
|
|
null,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AgentRoleIsRejectedWithForbidden()
|
|
{
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Test-Identity", "agent-node");
|
|
client.DefaultRequestHeaders.Add("X-Test-Role", "Agent");
|
|
|
|
var response = await client.PostAsync(
|
|
"/api/v1/control/agents/test-node/enrollment-code",
|
|
null,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AutomationRoleIsRejectedWithForbidden()
|
|
{
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Test-Identity", "automation-worker");
|
|
client.DefaultRequestHeaders.Add("X-Test-Role", "Automation");
|
|
|
|
var response = await client.PostAsync(
|
|
"/api/v1/control/agents/test-node/enrollment-code",
|
|
null,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(" ")]
|
|
[InlineData("NODE_UPPERCASE")]
|
|
[InlineData("node_with_underscore")]
|
|
[InlineData("node with spaces")]
|
|
[InlineData("this-node-name-is-way-too-long-and-exceeds-the-maximum-sixty-three-characters-limit")]
|
|
public async Task InvalidNodeIdIsRejectedWithBadRequestBeforeTokenCreation(string invalidNodeId)
|
|
{
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-1");
|
|
client.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
|
|
|
|
var response = await client.PostAsync(
|
|
$"/api/v1/control/agents/{Uri.EscapeDataString(invalidNodeId)}/enrollment-code",
|
|
null,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
|
|
// Verify that no token was generated in database
|
|
var store = _factory.Services.GetRequiredService<ControlStore>();
|
|
var dbPath = _factory.DatabasePath;
|
|
await using var conn = new SqliteConnection($"Data Source={dbPath}");
|
|
await conn.OpenAsync(TestContext.Current.CancellationToken);
|
|
var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "SELECT COUNT(*) FROM enrollment_tokens;";
|
|
var count = Convert.ToInt32(await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken), System.Globalization.CultureInfo.InvariantCulture);
|
|
Assert.Equal(0, count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EmptyNodeIdThrowsArgumentExceptionInService()
|
|
{
|
|
var service = _factory.Services.GetRequiredService<NodeEnrollmentService>();
|
|
await Assert.ThrowsAsync<ArgumentException>(() =>
|
|
service.CreateEnrollmentCodeAsync(string.Empty, "operator-1", TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OperatorCanRequestNodeEnrollmentCodeWithCorrectStructure()
|
|
{
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin");
|
|
client.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
|
|
|
|
var response = await client.PostAsync(
|
|
"/api/v1/control/agents/node-alpha/enrollment-code",
|
|
null,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
var result = await response.Content.ReadFromJsonAsync<NodeEnrollmentCodeResponse>(
|
|
SmmJsonContext.Default.NodeEnrollmentCodeResponse,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal("node-alpha", result.NodeId);
|
|
Assert.False(string.IsNullOrWhiteSpace(result.CaFingerprint));
|
|
Assert.True(result.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(9));
|
|
Assert.True(result.ExpiresAt <= DateTimeOffset.UtcNow.AddMinutes(11));
|
|
|
|
// Format check: SMMNODE2.<control_url>.<ca_pem>.<node_id>.<token>.<hub_endpoint>.<hub_public_key>.<node_address>.<mesh_network>
|
|
var segments = result.Code.Split('.');
|
|
Assert.Equal(9, segments.Length);
|
|
Assert.Equal("SMMNODE2", segments[0]);
|
|
|
|
var controlUrl = DecodeBase64UrlString(segments[1]);
|
|
var caPem = DecodeBase64UrlString(segments[2]);
|
|
var nodeId = DecodeBase64UrlString(segments[3]);
|
|
var token = DecodeBase64UrlString(segments[4]);
|
|
var hubEndpoint = DecodeBase64UrlString(segments[5]);
|
|
var hubPublicKey = DecodeBase64UrlString(segments[6]);
|
|
var nodeAddress = DecodeBase64UrlString(segments[7]);
|
|
var meshNetwork = DecodeBase64UrlString(segments[8]);
|
|
|
|
Assert.Equal("https://hub.example.com:7443", controlUrl);
|
|
Assert.Contains("-----BEGIN CERTIFICATE-----", caPem);
|
|
Assert.Equal("node-alpha", nodeId);
|
|
Assert.Equal(43, token.Length); // 32 bytes base64url unpadded is 43 chars
|
|
Assert.Equal("hub.example.com:51820", hubEndpoint);
|
|
Assert.Equal("mQZ/Y4yQpQhX6j0rL8vU2w==", hubPublicKey);
|
|
Assert.Equal("10.77.0.2", nodeAddress);
|
|
Assert.Equal("10.77.0.0/24", meshNetwork);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TwoDifferentNodeIdsGetDistinctMeshAddresses()
|
|
{
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin");
|
|
client.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
|
|
|
|
var response1 = await client.PostAsync(
|
|
"/api/v1/control/agents/node-first/enrollment-code",
|
|
null,
|
|
TestContext.Current.CancellationToken);
|
|
Assert.Equal(HttpStatusCode.OK, response1.StatusCode);
|
|
var result1 = await response1.Content.ReadFromJsonAsync<NodeEnrollmentCodeResponse>(
|
|
SmmJsonContext.Default.NodeEnrollmentCodeResponse,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
var response2 = await client.PostAsync(
|
|
"/api/v1/control/agents/node-second/enrollment-code",
|
|
null,
|
|
TestContext.Current.CancellationToken);
|
|
Assert.Equal(HttpStatusCode.OK, response2.StatusCode);
|
|
var result2 = await response2.Content.ReadFromJsonAsync<NodeEnrollmentCodeResponse>(
|
|
SmmJsonContext.Default.NodeEnrollmentCodeResponse,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
var address1 = DecodeBase64UrlString(result1!.Code.Split('.')[7]);
|
|
var address2 = DecodeBase64UrlString(result2!.Code.Split('.')[7]);
|
|
|
|
Assert.Equal("10.77.0.2", address1);
|
|
Assert.Equal("10.77.0.3", address2);
|
|
Assert.NotEqual(address1, address2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RepeatedRequestForSameNodeIdReusesReservedAddress()
|
|
{
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin");
|
|
client.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
|
|
|
|
var response1 = await client.PostAsync(
|
|
"/api/v1/control/agents/node-repeat/enrollment-code",
|
|
null,
|
|
TestContext.Current.CancellationToken);
|
|
var result1 = await response1.Content.ReadFromJsonAsync<NodeEnrollmentCodeResponse>(
|
|
SmmJsonContext.Default.NodeEnrollmentCodeResponse,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
var response2 = await client.PostAsync(
|
|
"/api/v1/control/agents/node-repeat/enrollment-code",
|
|
null,
|
|
TestContext.Current.CancellationToken);
|
|
var result2 = await response2.Content.ReadFromJsonAsync<NodeEnrollmentCodeResponse>(
|
|
SmmJsonContext.Default.NodeEnrollmentCodeResponse,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
var address1 = DecodeBase64UrlString(result1!.Code.Split('.')[7]);
|
|
var address2 = DecodeBase64UrlString(result2!.Code.Split('.')[7]);
|
|
|
|
Assert.Equal("10.77.0.2", address1);
|
|
Assert.Equal("10.77.0.2", address2);
|
|
|
|
// Tokens should be distinct per request
|
|
var token1 = DecodeBase64UrlString(result1.Code.Split('.')[4]);
|
|
var token2 = DecodeBase64UrlString(result2.Code.Split('.')[4]);
|
|
Assert.NotEqual(token1, token2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AuditLogRecordsEnrollmentCodeIssuance()
|
|
{
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-carol");
|
|
client.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
|
|
|
|
var response = await client.PostAsync(
|
|
"/api/v1/control/agents/audited-node/enrollment-code",
|
|
null,
|
|
TestContext.Current.CancellationToken);
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
await using var conn = new SqliteConnection($"Data Source={_factory.DatabasePath}");
|
|
await conn.OpenAsync(TestContext.Current.CancellationToken);
|
|
var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "SELECT actor, action, subject, details_json FROM audit WHERE action = 'agent.enrollment_code.issued';";
|
|
await using var reader = await cmd.ExecuteReaderAsync(TestContext.Current.CancellationToken);
|
|
Assert.True(await reader.ReadAsync(TestContext.Current.CancellationToken));
|
|
|
|
var actor = reader.GetString(0);
|
|
var action = reader.GetString(1);
|
|
var subject = reader.GetString(2);
|
|
var detailsJson = reader.GetString(3);
|
|
|
|
Assert.Equal("operator-carol", actor);
|
|
Assert.Equal("agent.enrollment_code.issued", action);
|
|
Assert.Equal("audited-node", subject);
|
|
Assert.Contains("audited-node", detailsJson);
|
|
Assert.Contains("10.77.0.2", detailsJson);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CodeStructureMatchesBashReferenceFormatFixture()
|
|
{
|
|
// Fixture matching the reference implementation in create_node_code:
|
|
// SMMNODE2.<control_url>.<ca_pem>.<node_id>.<token>.<hub_endpoint>.<hub_public_key>.<node_address>.<mesh_network>
|
|
using var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin");
|
|
client.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
|
|
|
|
var response = await client.PostAsync(
|
|
"/api/v1/control/agents/reference-node/enrollment-code",
|
|
null,
|
|
TestContext.Current.CancellationToken);
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
var result = await response.Content.ReadFromJsonAsync<NodeEnrollmentCodeResponse>(
|
|
SmmJsonContext.Default.NodeEnrollmentCodeResponse,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
Assert.NotNull(result);
|
|
var parts = result.Code.Split('.');
|
|
Assert.Equal(9, parts.Length);
|
|
Assert.Equal("SMMNODE2", parts[0]);
|
|
|
|
// Validate each segment character set (base64url characters only, no =)
|
|
for (int i = 1; i < parts.Length; i++)
|
|
{
|
|
Assert.Matches("^[A-Za-z0-9_-]+$", parts[i]);
|
|
Assert.DoesNotContain("=", parts[i]);
|
|
}
|
|
}
|
|
|
|
public async ValueTask DisposeAsync() => await _factory.DisposeAsync();
|
|
|
|
private static string DecodeBase64UrlString(string base64Url)
|
|
{
|
|
var padded = base64Url.Replace('-', '+').Replace('_', '/');
|
|
padded += (padded.Length % 4) switch
|
|
{
|
|
2 => "==",
|
|
3 => "=",
|
|
0 => string.Empty,
|
|
_ => throw new FormatException("Invalid base64url length.")
|
|
};
|
|
return Encoding.UTF8.GetString(Convert.FromBase64String(padded));
|
|
}
|
|
|
|
private sealed class NodeEnrollmentTestFactory : WebApplicationFactory<controlapp::Program>
|
|
{
|
|
private readonly string _directory = Path.Combine(
|
|
Path.GetTempPath(), $"smm-node-enrollment-tests-{Guid.NewGuid():N}");
|
|
|
|
public string DatabasePath => Path.Combine(_directory, "control.db");
|
|
|
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
|
{
|
|
Directory.CreateDirectory(_directory);
|
|
var authorityPath = Path.Combine(_directory, "control-ca.pfx");
|
|
if (!File.Exists(authorityPath))
|
|
{
|
|
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
|
var request = new CertificateRequest("CN=SMM Node Code Test CA", key, HashAlgorithmName.SHA256);
|
|
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
|
request.CertificateExtensions.Add(new X509KeyUsageExtension(
|
|
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
|
|
using var certificate = request.CreateSelfSigned(
|
|
DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1));
|
|
File.WriteAllBytes(authorityPath, certificate.Export(X509ContentType.Pfx));
|
|
}
|
|
|
|
var publicUrlPath = Path.Combine(_directory, "control-public-url");
|
|
File.WriteAllText(publicUrlPath, "https://hub.example.com:7443\n");
|
|
|
|
var meshEnvPath = Path.Combine(_directory, "mesh.env");
|
|
File.WriteAllText(meshEnvPath, "HUB_ENDPOINT=hub.example.com:51820\nHUB_PUBLIC_KEY=mQZ/Y4yQpQhX6j0rL8vU2w==\nMESH_NETWORK=10.77.0.0/24\n");
|
|
|
|
var meshNodesPath = Path.Combine(_directory, "mesh", "nodes.tsv");
|
|
|
|
builder.ConfigureAppConfiguration((_, configuration) =>
|
|
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["Control:DatabasePath"] = DatabasePath,
|
|
["Control:CertificateAuthorityPath"] = authorityPath,
|
|
["Control:BackupDirectory"] = Path.Combine(_directory, "backups"),
|
|
["Control:PublicUrlPath"] = publicUrlPath,
|
|
["Control:MeshEnvironmentPath"] = meshEnvPath,
|
|
["Control:MeshNodesPath"] = meshNodesPath
|
|
}));
|
|
|
|
builder.ConfigureTestServices(services =>
|
|
{
|
|
services.RemoveAll<IHostedService>();
|
|
services.AddAuthentication(options =>
|
|
{
|
|
options.DefaultAuthenticateScheme = "Test";
|
|
options.DefaultChallengeScheme = "Test";
|
|
})
|
|
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>("Test", _ => { });
|
|
});
|
|
}
|
|
|
|
public override async ValueTask DisposeAsync()
|
|
{
|
|
await base.DisposeAsync();
|
|
try
|
|
{
|
|
if (Directory.Exists(_directory))
|
|
{
|
|
Directory.Delete(_directory, recursive: true);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best effort cleanup in tests
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class TestAuthenticationHandler(
|
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
|
ILoggerFactory logger,
|
|
UrlEncoder encoder)
|
|
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
|
{
|
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
|
{
|
|
if (!Request.Headers.TryGetValue("X-Test-Identity", out var identity)
|
|
|| !Request.Headers.TryGetValue("X-Test-Role", out var role))
|
|
{
|
|
return Task.FromResult(AuthenticateResult.NoResult());
|
|
}
|
|
var claims = new List<Claim>
|
|
{
|
|
new(ClaimTypes.NameIdentifier, identity.ToString()),
|
|
new(ClaimTypes.Role, role.ToString())
|
|
};
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name));
|
|
return Task.FromResult(AuthenticateResult.Success(
|
|
new AuthenticationTicket(principal, Scheme.Name)));
|
|
}
|
|
}
|
|
}
|