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); 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_Request_Body_Is_Empty() { await using var fixture = new PairingApiFixture(); using var client = fixture.CreateClient(); using var content = new StringContent(string.Empty, Encoding.UTF8, "application/json"); using var response = await client.PostAsync("/api/pairing/redeem", content); 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 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(); } }