Files
wx-win-agent/tests/node-agent/WxAgent.Core.Tests/RemoteControlTests.cs
T
rogee 0d29b828bb
Build web service image / build (push) Successful in 48s
feat: harden control-plane deployment
2026-09-12 11:09:42 +08:00

371 lines
16 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 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 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 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);
}