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 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(() => 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(() => 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(() => 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(() => context.SwitchTo("account-b", identities)); Assert.False(context.Snapshot.Confirmed); Assert.Throws(() => 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 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(() => client.RegisterAsync( new RemoteNodeRegistration("node-1", "test", RemoteProtocol.Version, [], 0, []))); Assert.Empty(handler.Requests); } private sealed class RecordingHandler : HttpMessageHandler { public List Requests { get; } = []; protected override Task 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); }