diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Api/PairingEndpoints.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Api/PairingEndpoints.cs index a20b04b..df789af 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Api/PairingEndpoints.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Api/PairingEndpoints.cs @@ -19,26 +19,35 @@ public static class PairingEndpoints { var code = pairingService.CreateCode(PairingCodeTtl); await auditLog.AppendAsync( - new AuditRecord("pairing_code_created", clock.UtcNow, code.Value, $"expires_at={code.ExpiresAtUtc:O}"), + new AuditRecord("pairing_code_created", clock.UtcNow, "pairing", $"expires_at={code.ExpiresAtUtc:O}"), cancellationToken); return Results.Ok(code); }); group.MapPost("/redeem", async ( - [FromBody] RedeemPairingRequest request, + [FromBody] RedeemPairingRequest? request, PairingService pairingService, AuditLog auditLog, IClock clock, CancellationToken cancellationToken) => { - var result = pairingService.Redeem(request.Code, request.DeviceName); - var response = new RedeemPairingResponse(result.Success, result.ErrorCode); + var validationError = ValidateRedeemRequest(request); + if (validationError is not null) + { + return Results.BadRequest(validationError); + } + + var validatedRequest = request!; + var result = pairingService.Redeem(validatedRequest.Code!, validatedRequest.DeviceName!); + var response = result.Success + ? new RedeemPairingResponse(true, string.Empty, "accepted") + : new RedeemPairingResponse(false, result.ErrorCode, "rejected"); var eventType = result.Success ? "pairing_redeemed" : "pairing_redeem_failed"; - var details = result.Success ? request.DeviceName : result.ErrorCode; + var details = result.Success ? "accepted" : result.ErrorCode; await auditLog.AppendAsync( - new AuditRecord(eventType, clock.UtcNow, request.DeviceName, details), + new AuditRecord(eventType, clock.UtcNow, validatedRequest.DeviceName!, details), cancellationToken); return result.Success ? Results.Ok(response) : Results.BadRequest(response); @@ -46,8 +55,23 @@ public static class PairingEndpoints return endpoints; } + + private static RedeemPairingResponse? ValidateRedeemRequest(RedeemPairingRequest? request) + { + if (request is null) + { + return new RedeemPairingResponse(false, "invalid_request", "rejected"); + } + + if (string.IsNullOrWhiteSpace(request.Code) || string.IsNullOrWhiteSpace(request.DeviceName)) + { + return new RedeemPairingResponse(false, "invalid_request", "rejected"); + } + + return null; + } } -public sealed record RedeemPairingRequest(string Code, string DeviceName); +public sealed record RedeemPairingRequest(string? Code, string? DeviceName); -public sealed record RedeemPairingResponse(bool Success, string ErrorCode); +public sealed record RedeemPairingResponse(bool Success, string ErrorCode, string Status); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs index 93101c4..e346d32 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs @@ -25,3 +25,5 @@ app.MapGet("/health", () => Results.Json(new { status = "ok" })); app.MapPairingEndpoints(); app.Run(); + +public partial class Program; diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Api/PairingEndpointsTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Api/PairingEndpointsTests.cs new file mode 100644 index 0000000..15f0110 --- /dev/null +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Api/PairingEndpointsTests.cs @@ -0,0 +1,112 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using TermRemoteCtl.Agent.Configuration; + +namespace TermRemoteCtl.Agent.IntegrationTests.Api; + +public sealed class PairingEndpointsTests +{ + [Fact] + public async Task Redeem_Returns_BadRequest_When_Request_Is_Incomplete() + { + await using var fixture = new PairingApiFixture(); + using var client = fixture.CreateClient(); + + using var response = await client.PostAsJsonAsync( + "/api/pairing/redeem", + new + { + code = "123456" + }); + + var payload = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.NotNull(payload); + Assert.False(payload.Success); + Assert.Equal("invalid_request", payload.ErrorCode); + Assert.Equal("rejected", payload.Status); + } + + [Fact] + public async Task Redeem_Returns_BadRequest_When_Json_Is_Malformed() + { + await using var fixture = new PairingApiFixture(); + using var client = fixture.CreateClient(); + using var content = new StringContent("{\"code\":", Encoding.UTF8, "application/json"); + + using var response = await client.PostAsync("/api/pairing/redeem", content); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task CreateCode_Does_Not_Write_Secret_Code_To_Audit_Log() + { + await using var fixture = new PairingApiFixture(); + using var client = fixture.CreateClient(); + + var code = await client.PostFromJsonAsync("/api/pairing/code"); + var auditLog = await File.ReadAllTextAsync(fixture.GetAuditLogPath(), Encoding.UTF8); + + Assert.NotNull(code); + Assert.DoesNotContain(code!.Value, auditLog, StringComparison.Ordinal); + } + + private sealed class PairingApiFixture : WebApplicationFactory + { + private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N")); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.ConfigureAppConfiguration((_, configBuilder) => + { + configBuilder.AddInMemoryCollection(new Dictionary + { + ["Agent:DataRoot"] = _dataRoot, + ["Agent:BindAddress"] = "127.0.0.1", + ["Agent:HttpsPort"] = "9443", + ["Agent:WebSocketFrameFlushMilliseconds"] = "33", + ["Agent:RingBufferLineLimit"] = "4000" + }); + }); + } + + public string GetAuditLogPath() + { + var options = Services.GetRequiredService>().Value; + return Path.Combine(options.DataRoot, "audit.log.jsonl"); + } + + public new async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + if (Directory.Exists(_dataRoot)) + { + Directory.Delete(_dataRoot, true); + } + } + } + + private sealed record RedeemResponse(bool Success, string ErrorCode, string Status); + + private sealed record PairingCodeResponse(string Value, DateTimeOffset ExpiresAtUtc); +} + +internal static class HttpClientJsonExtensions +{ + public static async Task PostFromJsonAsync(this HttpClient client, string requestUri) + { + using var response = await client.PostAsync(requestUri, JsonContent.Create(new { })); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } +} diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj index 9c5b30a..a3c453a 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj @@ -11,6 +11,7 @@ + @@ -20,4 +21,8 @@ + + + +