using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using WxAgent.Service; using Xunit; namespace WxAgent.Service.Tests; public sealed class ServiceBoundaryTests { private sealed class Backend : IAgentBackend { public IReadOnlyList Capabilities => [ new("agent-status", true, true, true, false, false, "read", false, 30, null, []), new("accounts-list", true, true, true, false, false, "read", false, 30, null, []), new("sessions-list", true, true, true, true, false, "read", false, 30, null, []), new("messages-read", true, true, true, true, false, "read", false, 30, null, []), new("contacts-list", true, true, true, false, false, "read", false, 30, null, []) ]; public Task StatusAsync(CancellationToken ct) => Task.FromResult(new { ok = true }); public Task> AccountsAsync(CancellationToken ct) => Task.FromResult>([new("account-1", null, null, null, "fingerprint", false)]); } [Fact] public async Task StaticUiAndBoundedReadOnlyPagesAreRealProductionRoutes() { var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); var token = new string('A', 43); var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir }; File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] { new ServiceCredential("p", ServiceOptions.HashToken(token), ["read"], []) })); await using var app = ServiceHost.Build(options, new Backend(), b => b.WebHost.UseTestServer()); try { await app.StartAsync(); using var client = app.GetTestClient(); client.BaseAddress = new Uri("http://localhost:5088"); var page = await client.GetAsync("/"); Assert.Equal(HttpStatusCode.OK, page.StatusCode); Assert.Contains("WxAgent", await page.Content.ReadAsStringAsync()); Assert.True(page.Headers.Contains("X-Content-Type-Options")); Assert.True(page.Headers.Contains("Content-Security-Policy")); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); var accounts = await client.GetFromJsonAsync("/api/v1/accounts?limit=1&offset=0"); Assert.Equal(1, accounts.GetProperty("items").GetArrayLength()); Assert.False(accounts.GetProperty("hasMore").GetBoolean()); Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/v1/accounts?limit=201")).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/v1/accounts?offset=-1")).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/v1/status?token=leak")).StatusCode); } finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); } } [Fact] public void LoopbackRequestsCanUseLocalIdentityWithoutToken() { var options = new ServiceOptions { CredentialFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), DataDirectory = Path.GetTempPath() }; var security = new ServiceSecurity(options); var local = new DefaultHttpContext(); local.Connection.RemoteIpAddress = IPAddress.Loopback; var identity = security.AuthenticateRequest(local); Assert.True(identity.LocalOnly); Assert.True(identity.Allows("manage")); var remote = new DefaultHttpContext(); remote.Connection.RemoteIpAddress = IPAddress.Parse("192.0.2.1"); Assert.Throws(() => security.AuthenticateRequest(remote)); } [Fact] public void ExternalBindingRequiresExplicitOptIn() { var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); var options = new ServiceOptions { ListenUrl = "http://192.0.2.10:5088", CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir }; File.WriteAllText(options.CredentialFile, "[]"); try { Assert.Throws(() => options.Validate()); } finally { Directory.Delete(dir, true); } } }