feat: improve service configuration and runtime diagnostics

This commit is contained in:
2026-09-11 09:38:56 +08:00
parent 945d36eb31
commit bfe349cc10
28 changed files with 1427 additions and 391 deletions
@@ -16,24 +16,22 @@ public sealed class EventPumpTests
}
[Fact]
public async Task PumpForwardsOnlyToAuthorizedPrincipals()
public async Task PumpForwardsToTheConfiguredPrincipal()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var options = new ServiceOptions { DataDirectory = dir, CredentialFile = Path.Combine(dir, "credentials.json") };
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[]
{
new ServiceCredential("alice", new string('A', 64), ["read"], []),
new ServiceCredential("scoped", new string('B', 64), ["read"], ["other-account"])
new ServiceCredential("alice", new string('A', 64), ["read"], [])
}));
try
{
var hub = new EventHub(); var all = hub.Subscribe("alice", null); var scoped = hub.Subscribe("scoped", null);
var hub = new EventHub(); var subscription = hub.Subscribe("alice", null);
using var pump = new EventPump(new SourceBackend(), hub, options);
await pump.StartAsync(default);
var ct = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!hub.TryGet(all.SubscriptionId, "alice", out var alice) || !alice.Channel.Reader.TryRead(out _))
while (!hub.TryGet(subscription.SubscriptionId, "alice", out var current) || !current.Channel.Reader.TryRead(out _))
await Task.Delay(10, ct.Token);
Assert.False(hub.TryGet(scoped.SubscriptionId, "scoped", out var no) && no.Channel.Reader.TryRead(out _));
await pump.StopAsync(default);
}
finally { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
@@ -23,7 +23,7 @@ public sealed class OperationQueueTests
try
{
using var store = new OperationStore(options);
using var queue = new OperationQueue(store, options, new ServiceSecurity(options));
using var queue = new OperationQueue(store, new ServiceSecurity(options));
await queue.StartAsync(default);
var first = queue.Submit(identity, "account", capability, "first", "one", async _ => { entered.SetResult(); await release.Task; });
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10));
@@ -4,6 +4,7 @@ using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using WxAgent.Service;
using Xunit;
@@ -19,7 +20,8 @@ public sealed class ServiceBoundaryTests
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("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<object> StatusAsync(CancellationToken ct) => Task.FromResult<object>(new { ok = true });
public Task<IReadOnlyList<AccountInfo>> AccountsAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<AccountInfo>>([new("account-1", null, null, null, "fingerprint", false)]);
@@ -48,12 +50,125 @@ public sealed class ServiceBoundaryTests
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<string>() }
}));
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 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<OperationStore>();
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<JsonElement>("/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<JsonElement>("/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<JsonElement>()).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);
@@ -72,4 +187,18 @@ public sealed class ServiceBoundaryTests
try { Assert.Throws<ArgumentException>(() => 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<InvalidDataException>(() => options.ReadCredentials()); }
finally { Directory.Delete(dir, true); }
}
}
+2 -2
View File
@@ -45,10 +45,10 @@ public sealed class ServiceTests
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode);
client.DefaultRequestHeaders.Add("Origin", "http://evil.invalid");
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/v1/status")).StatusCode);
client.DefaultRequestHeaders.Remove("Origin");
client.DefaultRequestHeaders.Host = "evil.invalid";
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/v1/status")).StatusCode);
client.DefaultRequestHeaders.Host = null;
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.Accept.ParseAdd("text/event-stream");