feat: add remote control plane and whitelist reads
Build web service image / build (push) Successful in 1m53s
Build web service image / build (push) Successful in 1m53s
This commit is contained in:
@@ -237,6 +237,18 @@ public sealed class SessionTests
|
||||
Assert.Equal("文件传输助手", session.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripsPreviewMetadataFromVisibleSessionNames()
|
||||
{
|
||||
var sessions = WechatSessionParser.Parse(
|
||||
[("session_item_文件传输助手", "文件传输助手\n已置顶\n最后一条消息\n22:25")],
|
||||
"文件传输助手");
|
||||
|
||||
var session = Assert.Single(sessions);
|
||||
Assert.Equal("文件传输助手", session.Name);
|
||||
Assert.True(session.IsCurrent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesSearchResultsAndMarksOnlyExactLines()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using WxAgent.Core;
|
||||
|
||||
var baseUrl = Required("--base-url");
|
||||
var nodeId = Required("--node-id");
|
||||
var nodeToken = Required("--node-token");
|
||||
var webUser = Required("--web-user");
|
||||
var webPassword = Required("--web-password");
|
||||
var accountId = Get("--account-id") ?? "account-a";
|
||||
var allowedChatId = Get("--allowed-chat-id") ?? "allowed-chat";
|
||||
var blockedChatId = Get("--blocked-chat-id") ?? "blocked-chat";
|
||||
|
||||
using var http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/") };
|
||||
var webToken = await LoginAsync(http, webUser, webPassword);
|
||||
var config = new ReportingConfig
|
||||
{
|
||||
Enabled = true,
|
||||
ConfigVersion = 1,
|
||||
Accounts = [new AccountReportingConfig
|
||||
{
|
||||
AccountId = accountId,
|
||||
Enabled = true,
|
||||
AllowedChats = [new AllowedChat { Type = ReportingChatType.Group, ChatId = allowedChatId, Enabled = true, IdentityVerified = true }]
|
||||
}]
|
||||
};
|
||||
using var node = new RemoteControlClient(new RemoteAgentOptions
|
||||
{
|
||||
AuthAddress = baseUrl,
|
||||
Token = nodeToken,
|
||||
NodeId = nodeId,
|
||||
ActiveAccountId = accountId
|
||||
}, http);
|
||||
await node.RegisterAsync(new RemoteNodeRegistration(nodeId, "integration", RemoteProtocol.Version,
|
||||
["heartbeat", "poll-tasks", "send-text", "report-message"], config.ConfigVersion,
|
||||
[new RemoteAccountSummary(accountId, true, true, 1, 0)]));
|
||||
await node.HeartbeatAsync(new RemoteHeartbeat(nodeId, "integration", RemoteProtocol.Version, RemoteNodeStatus.Online,
|
||||
true, true, false, accountId, 0, config.ConfigVersion, "integration-heartbeat"));
|
||||
|
||||
var payload = JsonDocument.Parse("{\"target_id\":\"target-chat\",\"text\":\"integration task\",\"confirmed\":true}").RootElement.Clone();
|
||||
var taskResponse = await WebPostAsync<RemoteTaskSubmissionResponse>(http, "/v1/tasks", webToken, new RemoteTaskSubmission(
|
||||
nodeId, accountId, "send-text", "integration-task-1", payload));
|
||||
var task = (await node.PollTasksAsync(accountId)).Single(item => item.TaskId == taskResponse.TaskId);
|
||||
var accepted = await node.AcknowledgeTaskAsync(task);
|
||||
var started = await node.StartTaskAsync(accepted);
|
||||
await node.SendTaskResultAsync(new RemoteTaskResult(started.TaskId, accountId, started.LeaseGeneration,
|
||||
RemoteTaskStatus.Succeeded, null, null, true, null, "integration-result"), config);
|
||||
|
||||
var readResponse = await WebPostAsync<RemoteTaskSubmissionResponse>(http, "/v1/reads/sessions", webToken,
|
||||
new { node_id = nodeId, account_id = accountId, idempotency_key = "integration-read-1", limit = 20, offset = 0 });
|
||||
var readTask = (await node.PollTasksAsync(accountId)).Single(item => item.TaskId == readResponse.TaskId);
|
||||
var readAccepted = await node.AcknowledgeTaskAsync(readTask);
|
||||
var readStarted = await node.StartTaskAsync(readAccepted);
|
||||
using var readContentDocument = JsonDocument.Parse("{\"items\":[{\"automationId\":\"allowed-chat\"}],\"limit\":20,\"offset\":0,\"hasMore\":false}");
|
||||
await node.SendTaskResultAsync(new RemoteTaskResult(readStarted.TaskId, accountId, readStarted.LeaseGeneration,
|
||||
RemoteTaskStatus.Succeeded, null, null, false, readContentDocument.RootElement.Clone(), "integration-read-result"), config,
|
||||
chatScopes: [new RemoteReportingScope(allowedChatId, ReportingChatType.Group)]);
|
||||
var storedRead = await WebGetAsync<JsonElement>(http, "/v1/tasks/" + readResponse.TaskId, webToken);
|
||||
if (!storedRead.TryGetProperty("result", out var readResult)
|
||||
|| !readResult.TryGetProperty("content", out var readContent)
|
||||
|| readContent.ValueKind != JsonValueKind.Object)
|
||||
throw new InvalidOperationException("Remote read result content was not retained.");
|
||||
|
||||
var denied = await node.SubmitEventAsync(config, new RemoteMessageEvent(nodeId, accountId, blockedChatId,
|
||||
ReportingChatType.Private, 1, "message", DateTimeOffset.UtcNow, "blocked-content", config.ConfigVersion,
|
||||
config.ConfigVersion, "integration-denied"));
|
||||
var acceptedEvent = await node.SubmitEventAsync(config, new RemoteMessageEvent(nodeId, accountId, allowedChatId,
|
||||
ReportingChatType.Group, 1, "message", DateTimeOffset.UtcNow, "allowed-content", config.ConfigVersion,
|
||||
config.ConfigVersion, "integration-allowed"));
|
||||
var events = await WebGetAsync<EventList>(http, "/v1/events?limit=20", webToken);
|
||||
if (denied.Accepted || !acceptedEvent.Accepted || events.Events.Count != 1 || events.Events[0].ChatId != allowedChatId)
|
||||
throw new InvalidOperationException("Remote whitelist integration assertion failed.");
|
||||
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
status = "ok",
|
||||
node = nodeId,
|
||||
task = taskResponse.TaskId,
|
||||
taskStatus = RemoteTaskStatus.Succeeded.ToString(),
|
||||
readTask = readResponse.TaskId,
|
||||
readContentRetained = true,
|
||||
deniedEventAccepted = denied.Accepted,
|
||||
acceptedEvent = acceptedEvent.EventId,
|
||||
storedEventCount = events.Events.Count,
|
||||
privacy = "blocked event content was not sent"
|
||||
}, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
async Task<T> WebPostAsync<T>(HttpClient client, string path, string token, object value)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, path);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
request.Content = new StringContent(JsonSerializer.Serialize(value, RemoteJson.Options), Encoding.UTF8, "application/json");
|
||||
using var response = await client.SendAsync(request);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"HTTP {(int)response.StatusCode}: {content}");
|
||||
return JsonSerializer.Deserialize<T>(content, RemoteJson.Options) ?? throw new InvalidOperationException("Empty response.");
|
||||
}
|
||||
|
||||
async Task<T> WebGetAsync<T>(HttpClient client, string path, string token)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, path);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
using var response = await client.SendAsync(request);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"HTTP {(int)response.StatusCode}: {content}");
|
||||
return JsonSerializer.Deserialize<T>(content, RemoteJson.Options) ?? throw new InvalidOperationException("Empty response.");
|
||||
}
|
||||
|
||||
async Task<string> LoginAsync(HttpClient client, string username, string password)
|
||||
{
|
||||
var result = await WebPostAsync<LoginResponse>(client, "/v1/auth/login", "", new { username, password });
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
string Required(string name) => Get(name) ?? throw new ArgumentException($"Missing {name}.");
|
||||
string? Get(string name)
|
||||
{
|
||||
var index = Array.IndexOf(args, name);
|
||||
return index >= 0 && index + 1 < args.Length ? args[index + 1] : null;
|
||||
}
|
||||
|
||||
sealed record LoginResponse(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("access_token")] string AccessToken,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("token_type")] string TokenType,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("expires_in")] int ExpiresIn,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("correlation_id")] string CorrelationId);
|
||||
sealed record EventList([property: System.Text.Json.Serialization.JsonPropertyName("events")] IReadOnlyList<EventView> Events);
|
||||
sealed record EventView(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("event_id")] string EventId,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("node_id")] string NodeId,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("account_id")] string AccountId,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("chat_id")] string ChatId,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("chat_type")] string ChatType,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("event_seq")] long EventSeq,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("event_type")] string EventType,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("occurred_at")] DateTimeOffset OccurredAt,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("content")] string? Content,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("config_version")] long ConfigVersion,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("authorization_version")] long AuthorizationVersion,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("correlation_id")] string CorrelationId,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("authorized")] bool Authorized,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("content_hash")] string ContentHash,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("received_at")] DateTimeOffset ReceivedAt);
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../node-agent/WxAgent.Core/WxAgent.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -21,6 +21,25 @@ public sealed class EventHubTests
|
||||
Assert.True(await subscription.Channel.Reader.WaitToReadAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplayNeverCrossesPrincipalBoundary()
|
||||
{
|
||||
var hub = new EventHub();
|
||||
hub.Publish("alice", new AgentEvent("alice-1", "account-a", "chat-a", "message", "summary", DateTimeOffset.UtcNow));
|
||||
hub.Publish("bob", new AgentEvent("bob-1", "account-b", "chat-b", "message", "summary", DateTimeOffset.UtcNow));
|
||||
|
||||
var alice = hub.Subscribe("alice", "alice-1");
|
||||
var bob = hub.Subscribe("bob", "bob-1");
|
||||
Assert.Empty(alice.Replay);
|
||||
Assert.Empty(bob.Replay);
|
||||
Assert.False(alice.Gap);
|
||||
Assert.False(bob.Gap);
|
||||
|
||||
var aliceAfterBob = hub.Subscribe("alice", "bob-1");
|
||||
Assert.Empty(aliceAfterBob.Replay);
|
||||
Assert.True(aliceAfterBob.Gap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowConsumerGetsAnExplicitGapInsteadOfSilentDrop()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user