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 Microsoft.Extensions.DependencyInjection; using WxAgent.Core; 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, []), new("send-text", true, false, false, true, true, "write", false, 30, "Write validation is pending.", []) ]; 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 void DatabaseSyncIsAuthorizedByAgentConnectionByDefault() { var options = new ServiceOptions { CredentialFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), DataDirectory = Path.GetTempPath() }; Assert.True(options.EnableDataSync); } [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 CredentialFilesAcceptTrayStyleCamelCaseJson() { var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try { var token = new string('C', 43); var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir }; File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] { new { principalId = "tray", tokenSha256 = ServiceOptions.HashToken(token), permissions = new[] { "read" }, accountIds = Array.Empty() } })); var credentials = options.ReadCredentials(); Assert.Single(credentials); Assert.Equal("tray", credentials[0].PrincipalId); } finally { Directory.Delete(dir, true); } } [Fact] public void CustomAccessCredentialsHaveNoLengthLimit() { var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try { var token = "x"; var options = new ServiceOptions { AccessToken = token, CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir }; File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] { new ServiceCredential("custom", ServiceOptions.HashToken(token), ["read"], []) })); options.Validate(); Assert.Equal("custom", new ServiceSecurity(options).AuthenticateToken(token)?.PrincipalId); } finally { Directory.Delete(dir, true); } } [Fact] public void EmbeddedRemoteConfigurationIsValidatedWithTheService() { var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try { var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir, Remote = new RemoteAgentOptions { AuthAddress = "http://10.1.1.104:8090", Token = "node-token", NodeId = "node-1", AllowInsecureHttp = true } }; File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] { new ServiceCredential("tray", ServiceOptions.HashToken("local-token"), ["read"], []) })); options.Validate(); var invalid = new ServiceOptions { CredentialFile = options.CredentialFile, DataDirectory = options.DataDirectory, Remote = options.Remote! with { AllowInsecureHttp = false } }; Assert.Throws(() => invalid.Validate()); } finally { Directory.Delete(dir, true); } } [Fact] public void RuntimeLogWritesFullOperationalMessagesToTheRequestedPath() { var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try { var path = Path.Combine(dir, "wxagent.log"); RuntimeLog.Append(path, Microsoft.Extensions.Logging.LogLevel.Information, "test", "full diagnostic message"); var log = File.ReadAllText(path); Assert.Contains("full diagnostic message", log); Assert.Contains("[Information] test", log); } finally { Directory.Delete(dir, true); } } [Fact] public async Task OperationListIsPrincipalScopedAndSupportsCallerAccountFiltering() { 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 store = app.Services.GetRequiredService(); var first = store.Enqueue("p", "account-1", "read-demo", "key-1", "digest-1", false, TimeSpan.FromMinutes(1), 100).Record; store.Transition(first.Id, "Succeeded", "complete"); var second = store.Enqueue("p", "account-2", "read-demo", "key-2", "digest-2", false, TimeSpan.FromMinutes(1), 100).Record; store.Transition(second.Id, "Succeeded", "complete"); var other = store.Enqueue("other", "account-1", "read-demo", "key-3", "digest-3", false, TimeSpan.FromMinutes(1), 100).Record; store.Transition(other.Id, "Succeeded", "complete"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); var all = await client.GetFromJsonAsync("/api/v1/operations?limit=1"); Assert.Single(all.GetProperty("items").EnumerateArray()); Assert.True(all.GetProperty("hasMore").GetBoolean()); Assert.DoesNotContain("principalId", all.GetProperty("items")[0].EnumerateObject().Select(p => p.Name), StringComparer.OrdinalIgnoreCase); var filtered = await client.GetFromJsonAsync("/api/v1/operations?accountId=account-2&limit=50"); Assert.Single(filtered.GetProperty("items").EnumerateArray()); Assert.Equal("account-2", filtered.GetProperty("items")[0].GetProperty("accountId").GetString()); } finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); } } [Fact] public async Task DisabledTextSendDoesNotCreateAnOperation() { var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); var token = new string('B', 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", "write"], []) })); 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"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); var response = await client.PostAsJsonAsync("/api/v1/operations", new { kind = "send-text", accountId = "account-1", targetId = "session-1", text = "test", idempotencyKey = "send-1", confirmed = true }); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); Assert.Equal("CapabilityDisabled", (await response.Content.ReadFromJsonAsync()).GetProperty("error").GetProperty("code").GetString()); } 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.Request.Method = HttpMethods.Get; 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); } } [Fact] public void MultipleTokensAreRejected() { var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir }; File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] { new ServiceCredential("one", new string('A', 64), ["read"], []), new ServiceCredential("two", new string('B', 64), ["read"], []) })); try { Assert.Throws(() => options.ReadCredentials()); } finally { Directory.Delete(dir, true); } } }