240 lines
13 KiB
C#
240 lines
13 KiB
C#
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<AgentCapability> 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<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)]);
|
|
}
|
|
|
|
[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<JsonElement>("/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<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 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<ArgumentException>(() => 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<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);
|
|
Assert.True(identity.Allows("manage"));
|
|
var remote = new DefaultHttpContext();
|
|
remote.Connection.RemoteIpAddress = IPAddress.Parse("192.0.2.1");
|
|
Assert.Throws<ServiceException>(() => 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<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); }
|
|
}
|
|
}
|