471 lines
21 KiB
C#
471 lines
21 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using WxAgent.Core;
|
|
using Xunit;
|
|
|
|
namespace WxAgent.Core.Tests;
|
|
|
|
public sealed class RemoteReportingTests
|
|
{
|
|
[Fact]
|
|
public void WhitelistUsesVerifiedScopedIdentityAndDefaultsToDeny()
|
|
{
|
|
var config = new ReportingConfig
|
|
{
|
|
Enabled = true,
|
|
ConfigVersion = 7,
|
|
Accounts =
|
|
[
|
|
new AccountReportingConfig
|
|
{
|
|
AccountId = "account-a",
|
|
Enabled = true,
|
|
AllowedChats =
|
|
[
|
|
new AllowedChat { Type = ReportingChatType.Group, ChatId = "stable-group", Enabled = true, IdentityVerified = true },
|
|
new AllowedChat { Type = ReportingChatType.Private, ChatId = "unverified", Enabled = true, IdentityVerified = false }
|
|
]
|
|
}
|
|
]
|
|
};
|
|
|
|
Assert.True(ReportingAuthorization.IsAllowed(config, "account-a", "stable-group", ReportingChatType.Group, ReportingDataType.Message));
|
|
Assert.False(ReportingAuthorization.IsAllowed(config, "account-a", "same-display-name", ReportingChatType.Group, ReportingDataType.Message));
|
|
Assert.False(ReportingAuthorization.IsAllowed(config, "account-a", "unverified", ReportingChatType.Private, ReportingDataType.Message));
|
|
Assert.False(ReportingAuthorization.IsAllowed(config with { Accounts = [config.Accounts[0] with { AllowedChats = [new AllowedChat { Type = ReportingChatType.Group, ChatId = "stable-group", Enabled = false, IdentityVerified = true }] }] }, "account-a", "stable-group", ReportingChatType.Group, ReportingDataType.Message));
|
|
Assert.False(ReportingAuthorization.IsAllowed(config, "account-b", "stable-group", ReportingChatType.Group, ReportingDataType.Message));
|
|
Assert.True(ReportingAuthorization.IsAllowed(config, "account-a", "stable-group", ReportingChatType.Group, ReportingDataType.TaskResult));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConnectionWildcardAuthorizesEveryVerifiedChatType()
|
|
{
|
|
var config = new ReportingConfig
|
|
{
|
|
Enabled = true,
|
|
ConfigVersion = 1,
|
|
Accounts = [new AccountReportingConfig
|
|
{
|
|
AccountId = "account-a", Enabled = true,
|
|
AllowedChats =
|
|
[
|
|
new AllowedChat { Type = ReportingChatType.Group, ChatId = "*", Enabled = true, IdentityVerified = true },
|
|
new AllowedChat { Type = ReportingChatType.Private, ChatId = "*", Enabled = true, IdentityVerified = true }
|
|
]
|
|
}]
|
|
};
|
|
|
|
Assert.True(ReportingAuthorization.IsAllowed(config, "account-a", "group-1@chatroom", ReportingChatType.Group, ReportingDataType.Message));
|
|
Assert.True(ReportingAuthorization.IsAllowed(config, "account-a", "private-1", ReportingChatType.Private, ReportingDataType.Message));
|
|
Assert.False(ReportingAuthorization.IsAllowed(config, "account-b", "private-1", ReportingChatType.Private, ReportingDataType.Message));
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadTaskResultsRequireEveryWhitelistedScope()
|
|
{
|
|
using var document = JsonDocument.Parse("{\"items\":[{\"id\":\"chat-a\"}]}");
|
|
var result = new RemoteTaskResult("task-1", "account-a", 1, RemoteTaskStatus.Succeeded,
|
|
null, null, false, document.RootElement.Clone(), "result-1");
|
|
var config = new ReportingConfig
|
|
{
|
|
Enabled = true,
|
|
ConfigVersion = 2,
|
|
Accounts = [new AccountReportingConfig
|
|
{
|
|
AccountId = "account-a", Enabled = true,
|
|
AllowedChats =
|
|
[
|
|
new AllowedChat { Type = ReportingChatType.Private, ChatId = "chat-a", Enabled = true, IdentityVerified = true },
|
|
new AllowedChat { Type = ReportingChatType.Private, ChatId = "chat-b", Enabled = false, IdentityVerified = true }
|
|
]
|
|
}]
|
|
};
|
|
|
|
var allowed = ReportingAuthorization.FilterTaskResultForChats(config, result,
|
|
[new RemoteReportingScope("chat-a", ReportingChatType.Private)], out var allowedDecision);
|
|
Assert.NotNull(allowed.Content);
|
|
Assert.True(allowedDecision.Allowed);
|
|
|
|
var denied = ReportingAuthorization.FilterTaskResultForChats(config, result,
|
|
[new RemoteReportingScope("chat-a", ReportingChatType.Private), new RemoteReportingScope("chat-b", ReportingChatType.Private)], out var deniedDecision);
|
|
Assert.Null(denied.Content);
|
|
Assert.False(deniedDecision.Allowed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConfigStoreLoadsInvalidConfigAsDenyAndUpdatesOneGlobalVersion()
|
|
{
|
|
var path = Path.Combine(Path.GetTempPath(), "wxagent-reporting-" + Guid.NewGuid().ToString("N") + ".json");
|
|
try
|
|
{
|
|
await File.WriteAllTextAsync(path, "{\"enabled\":true,\"configVersion\":-1}");
|
|
var invalid = await ReportingConfigStore.LoadAsync(path);
|
|
Assert.False(invalid.Enabled);
|
|
|
|
var current = new ReportingConfig { ConfigVersion = 3 };
|
|
var updated = ReportingConfigStore.Update(current, value => value with { Enabled = true });
|
|
Assert.Equal(4, updated.ConfigVersion);
|
|
await ReportingConfigStore.SaveAsync(path, updated);
|
|
var loaded = await ReportingConfigStore.LoadAsync(path);
|
|
Assert.True(loaded.Enabled);
|
|
Assert.Equal(4, loaded.ConfigVersion);
|
|
|
|
var nodePath = Path.Combine(Path.GetDirectoryName(path)!, "remote-node.json");
|
|
var nodeConfig = (new RemoteNodeConfiguration { Reporting = loaded }).WithAudit("reporting.enable");
|
|
await RemoteNodeConfigurationStore.SaveAsync(nodePath, nodeConfig);
|
|
var nodeLoaded = await RemoteNodeConfigurationStore.LoadAsync(nodePath);
|
|
Assert.Equal("reporting.enable", Assert.Single(nodeLoaded.Audit).Action);
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(path)) File.Delete(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class RemoteAgentOptionsTests
|
|
{
|
|
[Fact]
|
|
public void NonLoopbackHttpRequiresExplicitPrivateNetworkOptIn()
|
|
{
|
|
var options = new RemoteAgentOptions
|
|
{
|
|
AuthAddress = "http://10.1.1.104:18090",
|
|
Token = "node-token",
|
|
NodeId = "node-1"
|
|
};
|
|
|
|
Assert.Throws<WxAgentException>(() => options.Validate());
|
|
}
|
|
|
|
[Fact]
|
|
public void PrivateNetworkHttpIsAcceptedWithExplicitOptIn()
|
|
{
|
|
var options = new RemoteAgentOptions
|
|
{
|
|
AuthAddress = "http://10.1.1.104:18090",
|
|
Token = "node-token",
|
|
NodeId = "node-1",
|
|
AllowInsecureHttp = true
|
|
};
|
|
|
|
options.Validate();
|
|
}
|
|
|
|
[Fact]
|
|
public void PublicHttpRemainsRejectedWithOptIn()
|
|
{
|
|
var options = new RemoteAgentOptions
|
|
{
|
|
AuthAddress = "http://8.8.8.8:18090",
|
|
Token = "node-token",
|
|
NodeId = "node-1",
|
|
AllowInsecureHttp = true
|
|
};
|
|
|
|
Assert.Throws<WxAgentException>(() => options.Validate());
|
|
}
|
|
|
|
[Fact]
|
|
public void TokenFileIsAcceptedWithoutPuttingTheTokenInConfiguration()
|
|
{
|
|
var path = Path.Combine(Path.GetTempPath(), "wxagent-token-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
File.WriteAllText(path, "file-token\n");
|
|
var options = new RemoteAgentOptions
|
|
{
|
|
AuthAddress = "https://control.example",
|
|
TokenFile = path,
|
|
NodeId = "node-1"
|
|
};
|
|
|
|
options.Validate();
|
|
Assert.Equal("file-token", options.GetToken());
|
|
Assert.Null(options.Redacted().Token);
|
|
Assert.Equal("file-configured", options.TokenState);
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(path)) File.Delete(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ClientCertificateConfigurationRequiresBothPemFiles()
|
|
{
|
|
var cert = Path.GetTempFileName();
|
|
try
|
|
{
|
|
var options = new RemoteAgentOptions
|
|
{
|
|
AuthAddress = "https://control.example",
|
|
Token = "node-token",
|
|
NodeId = "node-1",
|
|
ClientCertificateFile = cert
|
|
};
|
|
Assert.Throws<WxAgentException>(() => options.Validate());
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(cert);
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class RemoteAccountContextTests
|
|
{
|
|
[Fact]
|
|
public void SwitchingRequiresOneVerifiedIdentityAndBlocksInFlightWrites()
|
|
{
|
|
var context = new RemoteAccountContext();
|
|
var identities = new[] { new RemoteAccountIdentity("account-a", true), new RemoteAccountIdentity("account-b", false) };
|
|
var first = context.SwitchTo("account-a", identities);
|
|
Assert.True(first.Confirmed);
|
|
Assert.True(context.IsConfirmedFor("account-a"));
|
|
Assert.Throws<WxAgentException>(() => context.SwitchTo("account-b", identities));
|
|
Assert.False(context.Snapshot.Confirmed);
|
|
Assert.Throws<WxAgentException>(() => context.SwitchTo("account-a", identities, hasInFlightWrites: true));
|
|
}
|
|
}
|
|
|
|
public sealed class RemoteQueueAndLedgerTests
|
|
{
|
|
[Fact]
|
|
public void RevokedQueuedEventIsDroppedAndSequenceIsNeverReused()
|
|
{
|
|
var directory = Directory.CreateTempSubdirectory("wxagent-remote-");
|
|
try
|
|
{
|
|
var config = new ReportingConfig
|
|
{
|
|
Enabled = true,
|
|
ConfigVersion = 1,
|
|
Accounts = [new AccountReportingConfig
|
|
{
|
|
AccountId = "account-a", Enabled = true,
|
|
AllowedChats = [new AllowedChat { Type = ReportingChatType.Group, ChatId = "group-a", Enabled = true, IdentityVerified = true }]
|
|
}]
|
|
};
|
|
var queue = new RemoteEventQueue(Path.Combine(directory.FullName, "events.json"));
|
|
var first = queue.Enqueue(config, "node-1", "account-a", "group-a", ReportingChatType.Group, "message", DateTimeOffset.UtcNow, "one");
|
|
Assert.True(first.Accepted);
|
|
Assert.Equal(1, first.Event!.EventSeq);
|
|
var revoked = config with { Enabled = false, ConfigVersion = 2 };
|
|
Assert.Empty(queue.PrepareForSend(revoked));
|
|
Assert.Equal(0, queue.PendingCount);
|
|
var restored = config with { ConfigVersion = 3 };
|
|
var second = queue.Enqueue(restored, "node-1", "account-a", "group-a", ReportingChatType.Group, "message", DateTimeOffset.UtcNow, "two");
|
|
Assert.True(second.Accepted);
|
|
Assert.Equal(2, second.Event!.EventSeq);
|
|
}
|
|
finally { directory.Delete(true); }
|
|
}
|
|
|
|
[Fact]
|
|
public void TaskLedgerRecoversAcceptedWorkAsUnconfirmedAndKeepsTerminalResultForRetry()
|
|
{
|
|
var directory = Directory.CreateTempSubdirectory("wxagent-remote-");
|
|
try
|
|
{
|
|
var path = Path.Combine(directory.FullName, "tasks.json");
|
|
using var document = JsonDocument.Parse("{\"target_id\":\"target\",\"text\":\"hello\",\"confirmed\":true}");
|
|
var task = new RemoteTaskEnvelope("task-1", "node-1", "account-a", "send-text", "idempotency",
|
|
document.RootElement.Clone(), 1, DateTimeOffset.UtcNow.AddMinutes(1), null, RemoteTaskStatus.Pending, 1);
|
|
var ledger = new RemoteTaskLedger(path);
|
|
Assert.True(ledger.Accept(task));
|
|
var reloaded = new RemoteTaskLedger(path);
|
|
var recovered = Assert.Single(reloaded.UnreportedResults());
|
|
Assert.Equal(RemoteTaskStatus.ResultUnconfirmed, recovered.Status);
|
|
reloaded.MarkReported(recovered.TaskId);
|
|
Assert.Empty(reloaded.UnreportedResults());
|
|
}
|
|
finally { directory.Delete(true); }
|
|
}
|
|
|
|
[Fact]
|
|
public void TaskLedgerPersistsReadResultScopesForRetry()
|
|
{
|
|
var directory = Directory.CreateTempSubdirectory("wxagent-remote-");
|
|
try
|
|
{
|
|
var path = Path.Combine(directory.FullName, "tasks.json");
|
|
using var document = JsonDocument.Parse("{\"limit\":20,\"offset\":0}");
|
|
using var resultDocument = JsonDocument.Parse("{\"items\":[{\"id\":\"chat-a\"}]}");
|
|
var task = new RemoteTaskEnvelope("task-read", "node-1", "account-a", "read-sessions", "idempotency",
|
|
document.RootElement.Clone(), 1, DateTimeOffset.UtcNow.AddMinutes(1), null, RemoteTaskStatus.Pending, 1);
|
|
var ledger = new RemoteTaskLedger(path);
|
|
Assert.True(ledger.Accept(task));
|
|
ledger.Complete(new RemoteTaskResult("task-read", "account-a", 1, RemoteTaskStatus.Succeeded,
|
|
null, null, false, resultDocument.RootElement.Clone(), "result-1"),
|
|
[new RemoteReportingScope("chat-a", ReportingChatType.Private)]);
|
|
|
|
var reloaded = new RemoteTaskLedger(path);
|
|
var pending = Assert.Single(reloaded.UnreportedResultsWithScopes());
|
|
Assert.NotNull(pending.Result.Content);
|
|
var scope = Assert.Single(pending.ReportingScopes);
|
|
Assert.Equal("chat-a", scope.ChatId);
|
|
Assert.Equal(ReportingChatType.Private, scope.ChatType);
|
|
}
|
|
finally { directory.Delete(true); }
|
|
}
|
|
}
|
|
|
|
public sealed class RemoteControlClientTests
|
|
{
|
|
[Fact]
|
|
public async Task AuthenticatesBeforeHeartbeatAndDoesNotSendDeniedEvents()
|
|
{
|
|
var handler = new RecordingHandler();
|
|
using var http = new HttpClient(handler);
|
|
using var client = new RemoteControlClient(new RemoteAgentOptions
|
|
{
|
|
AuthAddress = "http://127.0.0.1:8090",
|
|
Token = "node-token",
|
|
NodeId = "node-1",
|
|
ActiveAccountId = "account-a"
|
|
}, http);
|
|
var registration = new RemoteNodeRegistration("node-1", "test", RemoteProtocol.Version, ["heartbeat"], 1,
|
|
[new RemoteAccountSummary("account-a", true, true, 1, 0)]);
|
|
var registered = await client.RegisterAsync(registration);
|
|
Assert.Equal(RemoteAuthState.Authenticated, client.AuthState);
|
|
Assert.True(registered.Authenticated);
|
|
await client.HeartbeatAsync(new RemoteHeartbeat("node-1", "test", RemoteProtocol.Version, RemoteNodeStatus.Online,
|
|
true, true, false, "account-a", 0, 1, "hb-1"));
|
|
|
|
var config = new ReportingConfig
|
|
{
|
|
Enabled = true,
|
|
ConfigVersion = 1,
|
|
Accounts = [new AccountReportingConfig
|
|
{
|
|
AccountId = "account-a", Enabled = true,
|
|
AllowedChats = [new AllowedChat { Type = ReportingChatType.Group, ChatId = "allowed", Enabled = true, IdentityVerified = true }]
|
|
}]
|
|
};
|
|
var denied = await client.SubmitEventAsync(config, new RemoteMessageEvent("node-1", "account-a", "blocked",
|
|
ReportingChatType.Private, 1, "message", DateTimeOffset.UtcNow, "private-content", 1, 1, "event-denied"));
|
|
Assert.False(denied.Accepted);
|
|
Assert.Equal("ChatNotAuthorized", denied.Reason);
|
|
Assert.Equal(2, handler.Requests.Count);
|
|
|
|
var accepted = await client.SubmitEventAsync(config, new RemoteMessageEvent("node-1", "account-a", "allowed",
|
|
ReportingChatType.Group, 1, "message", DateTimeOffset.UtcNow, "allowed-content", 1, 1, "event-allowed"));
|
|
Assert.True(accepted.Accepted);
|
|
Assert.Equal(3, handler.Requests.Count);
|
|
Assert.All(handler.Requests, request => Assert.Equal("Bearer node-token", request.Authorization));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FlushDropsAllQueuedBatchesAndBlocksAccountAfterRevocation()
|
|
{
|
|
var directory = Directory.CreateTempSubdirectory("wxagent-revoke-");
|
|
try
|
|
{
|
|
var config = new ReportingConfig
|
|
{
|
|
Enabled = true,
|
|
ConfigVersion = 1,
|
|
Accounts = [new AccountReportingConfig
|
|
{
|
|
AccountId = "account-a", Enabled = true,
|
|
AllowedChats = [new AllowedChat { Type = ReportingChatType.Private, ChatId = "chat-a", Enabled = true, IdentityVerified = true }]
|
|
}]
|
|
};
|
|
var queue = new RemoteDataBatchQueue(Path.Combine(directory.FullName, "queue.json"));
|
|
Assert.True(queue.Enqueue(config, SyncBatch("batch-1")).Accepted);
|
|
Assert.True(queue.Enqueue(config, SyncBatch("batch-2")).Accepted);
|
|
|
|
var handler = new RevokedBatchHandler();
|
|
using var http = new HttpClient(handler);
|
|
using var client = new RemoteControlClient(new RemoteAgentOptions
|
|
{
|
|
AuthAddress = "http://127.0.0.1:8090",
|
|
Token = "node-token",
|
|
NodeId = "node-1"
|
|
}, http);
|
|
await client.RegisterAsync(new RemoteNodeRegistration("node-1", "test", RemoteProtocol.Version, ["db-messages"], 1, []));
|
|
|
|
var blockedAccounts = new HashSet<string>(StringComparer.Ordinal);
|
|
var confirmed = await client.FlushDataBatchesAsync(queue, config, blockedAccounts);
|
|
|
|
Assert.Empty(confirmed);
|
|
Assert.Contains("account-a", blockedAccounts);
|
|
Assert.Empty(queue.Pending());
|
|
Assert.Empty(new RemoteDataBatchQueue(Path.Combine(directory.FullName, "queue.json")).Pending());
|
|
Assert.Equal(1, handler.BatchRequests);
|
|
}
|
|
finally
|
|
{
|
|
directory.Delete(true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MissingRemoteCredentialsAreRejectedWithoutNetworkAccess()
|
|
{
|
|
var handler = new RecordingHandler();
|
|
using var client = new RemoteControlClient(new RemoteAgentOptions { AuthAddress = "http://127.0.0.1:8090", NodeId = "node-1" }, new HttpClient(handler));
|
|
await Assert.ThrowsAsync<WxAgentException>(() => client.RegisterAsync(
|
|
new RemoteNodeRegistration("node-1", "test", RemoteProtocol.Version, [], 0, [])));
|
|
Assert.Empty(handler.Requests);
|
|
}
|
|
|
|
private static RemoteSyncBatch SyncBatch(string batchId) => new(
|
|
"node-1", "account-a", batchId, "generation-a", "messages", 1, "{}", "{\"chat-a\\u001fmessage/a.db\":1}",
|
|
"hash-" + batchId, "complete", [],
|
|
[new RemoteSyncMessage(batchId, "chat-a", ReportingChatType.Private, batchId, "incoming", "text", "queued secret",
|
|
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "wx-1", "payload-" + batchId)]);
|
|
|
|
private sealed class RevokedBatchHandler : HttpMessageHandler
|
|
{
|
|
public int BatchRequests { get; private set; }
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
if (request.RequestUri!.AbsolutePath == "/v1/data/batches")
|
|
{
|
|
BatchRequests++;
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden)
|
|
{
|
|
Content = new StringContent("{\"error\":{\"code\":\"AccountNotAuthorized\",\"message\":\"revoked\"}}", Encoding.UTF8, "application/json")
|
|
});
|
|
}
|
|
if (request.RequestUri.AbsolutePath == "/v1/nodes/register")
|
|
{
|
|
var body = JsonSerializer.Serialize(new RemoteNodeRegistrationResponse("node-1", RemoteNodeStatus.Online, true, "register"), RemoteJson.Options);
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
|
});
|
|
}
|
|
throw new InvalidOperationException("Unexpected request " + request.RequestUri.AbsolutePath);
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingHandler : HttpMessageHandler
|
|
{
|
|
public List<RecordedRequest> Requests { get; } = [];
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
Requests.Add(new RecordedRequest(request.RequestUri!.AbsolutePath, request.Headers.Authorization?.ToString() ?? ""));
|
|
var body = request.RequestUri.AbsolutePath switch
|
|
{
|
|
"/v1/nodes/register" => JsonSerializer.Serialize(new RemoteNodeRegistrationResponse("node-1", RemoteNodeStatus.Online, true, "register"), RemoteJson.Options),
|
|
"/v1/nodes/node-1/heartbeat" => JsonSerializer.Serialize(new RemoteHeartbeatResponse("node-1", RemoteNodeStatus.Online, DateTimeOffset.UtcNow, "heartbeat"), RemoteJson.Options),
|
|
"/v1/nodes/node-1/events" => JsonSerializer.Serialize(new RemoteEventReceipt(true, false, "event-1", null), RemoteJson.Options),
|
|
_ => throw new InvalidOperationException("Unexpected request " + request.RequestUri.AbsolutePath)
|
|
};
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
|
});
|
|
}
|
|
}
|
|
|
|
private sealed record RecordedRequest(string Path, string Authorization);
|
|
}
|