refactor: split node agent and Go control plane
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class AccountBindingMatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public void PrefersExactWechatIdOverNickname()
|
||||
{
|
||||
var result = AccountBindingMatcher.Match(new UiAccountIdentity("wxid-a", "Same"),
|
||||
[new("db-1", "wxid-a", "Other"), new("db-2", "wxid-b", "Same")]);
|
||||
Assert.True(result.IsMatched); Assert.Equal("db-1", result.AccountId); Assert.Equal("wechatId", result.Strategy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UsesUniqueNicknameOnlyWhenWechatIdIsUnavailable()
|
||||
{
|
||||
var result = AccountBindingMatcher.Match(new UiAccountIdentity(null, " Alice "),
|
||||
[new("db-1", null, "Alice"), new("db-2", null, "Bob")]);
|
||||
Assert.True(result.IsMatched); Assert.Equal("db-1", result.AccountId); Assert.Equal("nickname", result.Strategy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsDuplicateNicknameAndConflictingDuplicateWechatId()
|
||||
{
|
||||
var duplicateName = AccountBindingMatcher.Match(new UiAccountIdentity(null, "Alice"),
|
||||
[new("db-1", null, "Alice"), new("db-2", null, "Alice")]);
|
||||
var duplicateId = AccountBindingMatcher.Match(new UiAccountIdentity("wxid-a", "Alice"),
|
||||
[new("db-1", "wxid-a", "One"), new("db-2", "wxid-a", "Two")]);
|
||||
Assert.False(duplicateName.IsMatched); Assert.False(duplicateId.IsMatched);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WcdbKeyCandidateScannerTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParsesChunkSplitsLengthsDuplicatesAndBoundaries()
|
||||
{
|
||||
var key = string.Concat(Enumerable.Repeat("01", 32));
|
||||
var salt = string.Concat(Enumerable.Repeat("ab", 16));
|
||||
var longRun = key + "cafe" + salt;
|
||||
var bytes = System.Text.Encoding.ASCII.GetBytes("z" + new string('f', 63) + "!" + key + " " + key + salt + "-" + longRun + ".");
|
||||
var scanner = new WcdbKeyCandidateScanner();
|
||||
|
||||
var found = new List<WcdbKeyCandidate>();
|
||||
found.AddRange(scanner.Feed(bytes.AsSpan(0, 31)));
|
||||
found.AddRange(scanner.Feed(bytes.AsSpan(31, 80)));
|
||||
found.AddRange(scanner.Feed(bytes.AsSpan(111), finalBlock: true));
|
||||
|
||||
Assert.Equal(2, found.Count);
|
||||
Assert.Contains(found, candidate => candidate.EncKey == key && candidate.SaltHint is null);
|
||||
Assert.Single(found, candidate => candidate.EncKey == key && candidate.SaltHint == salt);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(3)]
|
||||
[InlineData(127)]
|
||||
public void ParsesUtf16AcrossOddChunkBoundaries(int chunkSize)
|
||||
{
|
||||
var key = new string('a', 64);
|
||||
var salt = new string('b', 32);
|
||||
var bytes = System.Text.Encoding.Unicode.GetBytes("x'" + key + salt + "' " + key + salt);
|
||||
var scanner = new WcdbKeyCandidateScanner(utf16: true);
|
||||
var found = new List<WcdbKeyCandidate>();
|
||||
for (var offset = 0; offset < bytes.Length; offset += chunkSize)
|
||||
found.AddRange(scanner.Feed(bytes.AsSpan(offset, Math.Min(chunkSize, bytes.Length - offset))));
|
||||
found.AddRange(scanner.Feed([], finalBlock: true));
|
||||
Assert.Equal(new WcdbKeyCandidate(key, salt), Assert.Single(found));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Utf16GapsDoNotJoinFragments()
|
||||
{
|
||||
var scanner = new WcdbKeyCandidateScanner(utf16: true);
|
||||
Assert.Empty(scanner.Feed(System.Text.Encoding.Unicode.GetBytes(new string('a', 40))));
|
||||
Assert.Empty(scanner.Feed([0x61], finalBlock: true));
|
||||
Assert.Empty(scanner.Feed(System.Text.Encoding.Unicode.GetBytes(new string('a', 24)), finalBlock: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BoundsLongRunsAndDoesNotJoinAcrossExplicitGaps()
|
||||
{
|
||||
var key = string.Concat(Enumerable.Repeat("01", 32));
|
||||
var salt = string.Concat(Enumerable.Repeat("ab", 16));
|
||||
var scanner = new WcdbKeyCandidateScanner();
|
||||
var longRun = System.Text.Encoding.ASCII.GetBytes(key + new string('c', 1_000_000) + salt);
|
||||
|
||||
var found = new List<WcdbKeyCandidate>();
|
||||
for (var offset = 0; offset < longRun.Length; offset += 4093)
|
||||
{
|
||||
found.AddRange(scanner.Feed(longRun.AsSpan(offset, Math.Min(4093, longRun.Length - offset))));
|
||||
}
|
||||
found.AddRange(scanner.Feed([], finalBlock: true));
|
||||
|
||||
var candidate = Assert.Single(found);
|
||||
Assert.Equal(key, candidate.EncKey);
|
||||
Assert.Equal(salt, candidate.SaltHint);
|
||||
|
||||
var gapScanner = new WcdbKeyCandidateScanner();
|
||||
Assert.Empty(gapScanner.Feed(System.Text.Encoding.ASCII.GetBytes(key[..40])));
|
||||
Assert.Empty(gapScanner.Feed([], finalBlock: true));
|
||||
Assert.Empty(gapScanner.Feed(System.Text.Encoding.ASCII.GetBytes(key[40..]), finalBlock: true));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SqlCipherPageVerifierTests
|
||||
{
|
||||
private const string ExpectedMac = "61b73efa9571819b9efd9a8424bd0e3882bb2c35baf5603262a05c630fab78a7c35ffc4041b589a09c9ed8df25457a131608bbfe67af87ebcba5268c8ddee055";
|
||||
|
||||
[Fact]
|
||||
public void MatchesIndependentKnownAnswerAndRejectsMutations()
|
||||
{
|
||||
var key = Enumerable.Range(0, 32).Select(value => (byte)value).ToArray();
|
||||
var page = CreateKnownAnswerPage();
|
||||
|
||||
Assert.True(SqlCipherPageVerifier.Verify(page, key));
|
||||
Assert.True(SqlCipherPageVerifier.VerifyHexKey(page, Convert.ToHexString(key)));
|
||||
|
||||
AssertRejected(page, key, 0);
|
||||
AssertRejected(page, key, 16);
|
||||
AssertRejected(page, key, page.Length - 1);
|
||||
|
||||
var wrongKey = key.ToArray();
|
||||
wrongKey[0] ^= 1;
|
||||
Assert.False(SqlCipherPageVerifier.Verify(page, wrongKey));
|
||||
Assert.False(SqlCipherPageVerifier.Verify(page, key, 2));
|
||||
}
|
||||
|
||||
private static byte[] CreateKnownAnswerPage()
|
||||
{
|
||||
var page = new byte[SqlCipherPageVerifier.PageSize];
|
||||
for (var index = 0; index < SqlCipherPageVerifier.SaltSize; index++)
|
||||
{
|
||||
page[index] = (byte)(index + 16);
|
||||
}
|
||||
|
||||
for (var index = SqlCipherPageVerifier.SaltSize; index < page.Length - SqlCipherPageVerifier.HmacSize; index++)
|
||||
{
|
||||
page[index] = (byte)(index * 31 + 7);
|
||||
}
|
||||
|
||||
Convert.FromHexString(ExpectedMac).CopyTo(page, page.Length - SqlCipherPageVerifier.HmacSize);
|
||||
return page;
|
||||
}
|
||||
|
||||
private static void AssertRejected(byte[] page, byte[] key, int index)
|
||||
{
|
||||
var changed = page.ToArray();
|
||||
changed[index] ^= 1;
|
||||
Assert.False(SqlCipherPageVerifier.Verify(changed, key));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ChatMessageTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParsesDuplicateVisibleMessagesWithStableDistinctFingerprints()
|
||||
{
|
||||
var first = VisibleMessageParser.Parse(["same", "same", "other"]);
|
||||
var second = VisibleMessageParser.Parse(["same", "same", "other"]);
|
||||
|
||||
Assert.Equal(3, first.Count);
|
||||
Assert.Equal(3, first.Select(message => message.Fingerprint).Distinct().Count());
|
||||
Assert.Equal(first.Select(message => message.Fingerprint), second.Select(message => message.Fingerprint));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifiesBasicVisibleMessageTypes()
|
||||
{
|
||||
Assert.Equal(ChatMessageType.Text, VisibleMessageParser.Classify("hello"));
|
||||
Assert.Equal(ChatMessageType.Image, VisibleMessageParser.Classify("[图片]"));
|
||||
Assert.Equal(ChatMessageType.File, VisibleMessageParser.Classify("report.pdf"));
|
||||
Assert.Equal(ChatMessageType.Video, VisibleMessageParser.Classify("[视频]"));
|
||||
Assert.Equal(ChatMessageType.Voice, VisibleMessageParser.Classify("语音\n3秒"));
|
||||
Assert.Equal(ChatMessageType.Link, VisibleMessageParser.Classify("https://example.com"));
|
||||
Assert.Equal(ChatMessageType.System, VisibleMessageParser.Classify("[系统消息]"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesQuotedReplyAndKeepsFingerprintStableAcrossRuntimeIdChanges()
|
||||
{
|
||||
var parsed = VisibleMessageParser.Parse([new ChatMessageSource("引用:Alice\noriginal text\nreply text", "runtime-42")]);
|
||||
var repeated = VisibleMessageParser.Parse([new ChatMessageSource("引用:Alice\noriginal text\nreply text", "runtime-99")]);
|
||||
|
||||
var message = Assert.Single(parsed);
|
||||
Assert.Equal("reply text", message.Text);
|
||||
Assert.Equal("Alice", message.Quote?.Sender);
|
||||
Assert.Equal("original text", message.Quote?.Text);
|
||||
Assert.Equal(message.Fingerprint, Assert.Single(repeated).Fingerprint);
|
||||
|
||||
var actualWechat = Assert.Single(VisibleMessageParser.Parse([
|
||||
new ChatMessageSource("reply text\n引用 Rogee 的消息 : 图片", "runtime-43")
|
||||
]));
|
||||
Assert.Equal("reply text", actualWechat.Text);
|
||||
Assert.Equal("Rogee", actualWechat.Quote?.Sender);
|
||||
Assert.Equal("图片", actualWechat.Quote?.Text);
|
||||
Assert.Equal(ChatMessageType.Quote, actualWechat.Type);
|
||||
|
||||
var duplicates = VisibleMessageParser.Parse([new ChatMessageSource("same"), new ChatMessageSource("same")]);
|
||||
Assert.NotEqual(duplicates[0].Fingerprint, duplicates[1].Fingerprint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeduperIsBounded()
|
||||
{
|
||||
var deduper = new BoundedMessageDeduper(2);
|
||||
Assert.True(deduper.TryAdd("a"));
|
||||
Assert.False(deduper.TryAdd("a"));
|
||||
Assert.True(deduper.TryAdd("b"));
|
||||
Assert.True(deduper.TryAdd("c"));
|
||||
Assert.True(deduper.TryAdd("a"));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MessageEventTests
|
||||
{
|
||||
[Fact]
|
||||
public void CheckpointSuppressesDuplicatesAndRecoversUnseenMessages()
|
||||
{
|
||||
var oldMessage = Assert.Single(VisibleMessageParser.Parse([new ChatMessageSource("old", "runtime-old")]));
|
||||
var newMessage = Assert.Single(VisibleMessageParser.Parse([new ChatMessageSource("new", "runtime-new")]));
|
||||
var initial = new MessageEventState(4);
|
||||
Assert.NotNull(initial.TryCreateMessage("文件传输助手", oldMessage, DateTimeOffset.UnixEpoch, false));
|
||||
var checkpoint = initial.CreateCheckpoint("文件传输助手", DateTimeOffset.UnixEpoch);
|
||||
Assert.Equal(2, checkpoint.Version);
|
||||
|
||||
var resumed = new MessageEventState(4, checkpoint);
|
||||
Assert.Null(resumed.TryCreateMessage("文件传输助手", oldMessage, DateTimeOffset.UnixEpoch, true));
|
||||
var recovered = resumed.TryCreateMessage("文件传输助手", newMessage, DateTimeOffset.UnixEpoch, true);
|
||||
Assert.NotNull(recovered);
|
||||
Assert.True(recovered!.Recovered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallbackFailureDoesNotStopLaterCallbacks()
|
||||
{
|
||||
var called = false;
|
||||
var messageEvent = new MessageEvent("id", MessageEventKind.Reconnected, "文件传输助手", null, DateTimeOffset.UnixEpoch, true);
|
||||
var failures = await MessageCallbackDispatcher.DispatchAsync(
|
||||
[
|
||||
(_, _) => throw new InvalidOperationException("boom"),
|
||||
(_, _) => { called = true; return Task.CompletedTask; }
|
||||
],
|
||||
messageEvent,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(called);
|
||||
Assert.Single(failures);
|
||||
Assert.Equal(0, failures[0].CallbackIndex);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SessionTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParsesVisibleSessionsAndMarksCurrent()
|
||||
{
|
||||
var sessions = WechatSessionParser.Parse(
|
||||
[("session_item_文件传输助手", "文件传输助手"), ("noise", "ignored"), ("session_item_文件传输助手", "duplicate")],
|
||||
"文件传输助手");
|
||||
|
||||
var session = Assert.Single(sessions);
|
||||
Assert.True(session.IsCurrent);
|
||||
Assert.Equal("文件传输助手", session.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesSearchResultsAndMarksOnlyExactLines()
|
||||
{
|
||||
var results = WechatSessionParser.ParseSearchResults(
|
||||
[("search_item_function_文件传输助手", "功能\n文件传输助手"), ("search_item_friend_1", "联系人\n文件传输助手测试")],
|
||||
"文件传输助手");
|
||||
|
||||
Assert.Equal(2, results.Count);
|
||||
var exact = Assert.Single(results, result => result.IsExactMatch);
|
||||
Assert.Equal("文件传输助手", exact.Name);
|
||||
Assert.Equal("功能", exact.Category);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class IdentityAndSanitizationTests
|
||||
{
|
||||
[Fact]
|
||||
public void AccountFingerprintUsesStableWindowsPathNormalization()
|
||||
{
|
||||
Assert.Equal(
|
||||
AccountRootFingerprint.Create(@"c:/Users/Rogee/Documents/xwechat_files/account/db_storage/"),
|
||||
AccountRootFingerprint.Create(@"C:\Users\Rogee\Documents\xwechat_files\account\db_storage"));
|
||||
Assert.NotEqual(AccountRootFingerprint.Create(@"C:\one"), AccountRootFingerprint.Create(@"C:\two"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotSanitizerKeepsStructureAndRedactsContent()
|
||||
{
|
||||
Assert.Equal("session_list", UiSnapshotSanitizer.SanitizeAutomationId("session_list"));
|
||||
Assert.NotEqual("发送", UiSnapshotSanitizer.SanitizeName("发送"));
|
||||
Assert.StartsWith("[text:2:", UiSnapshotSanitizer.SanitizeName("发送"));
|
||||
Assert.DoesNotContain("Alice", UiSnapshotSanitizer.SanitizeAutomationId("session_item_Alice"));
|
||||
Assert.DoesNotContain("secret message", UiSnapshotSanitizer.SanitizeName("secret message"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifiesLoggedOutAndChangedUiWithoutGuessingActiveAccount()
|
||||
{
|
||||
var controls = WechatLocators.RequiredAutomationIds.ToDictionary(id => id, _ => false);
|
||||
Assert.Equal(WxAgentErrorCode.WechatNotLoggedIn, WechatLocators.ClassifyFoundWindow(controls));
|
||||
|
||||
controls[WechatLocators.MainView] = true;
|
||||
Assert.Equal(WxAgentErrorCode.UiStructureChanged, WechatLocators.ClassifyFoundWindow(controls));
|
||||
|
||||
controls[WechatLocators.SessionList] = true;
|
||||
Assert.Null(WechatLocators.ClassifyFoundWindow(controls));
|
||||
|
||||
controls[WechatLocators.ChatPage] = true;
|
||||
Assert.Equal(WxAgentErrorCode.UiStructureChanged, WechatLocators.ClassifyFoundWindow(controls));
|
||||
|
||||
foreach (var id in controls.Keys.ToArray())
|
||||
{
|
||||
controls[id] = true;
|
||||
}
|
||||
Assert.Null(WechatLocators.ClassifyFoundWindow(controls));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyStoreMergePreservesHistoricalAccountsAndDatabases()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var oldDatabase = new DatabaseKeyEvidence("message/msg_0.db", "00", new string('1', 64), 1, now.AddDays(-1), KeyBindingConfidence.PageHmacVerified);
|
||||
var newDatabase = new DatabaseKeyEvidence("contact/contact.db", "11", new string('2', 64), 2, now, KeyBindingConfidence.PageHmacVerified);
|
||||
var existing = new[]
|
||||
{
|
||||
new AccountKeySet("account-a", @"C:\\a", "4.1.0", now.AddDays(-1), [oldDatabase]),
|
||||
new AccountKeySet("account-history", @"C:\\history", "4.0.0", now.AddDays(-2), [])
|
||||
};
|
||||
var incoming = new[]
|
||||
{
|
||||
new AccountKeySet("account-a", @"C:\\a", "4.1.8", now, [newDatabase])
|
||||
};
|
||||
|
||||
var merged = AccountKeySetMerge.Merge(existing, incoming);
|
||||
|
||||
Assert.Equal(2, merged.Count);
|
||||
var account = Assert.Single(merged, item => item.AccountRootFingerprint == "account-a");
|
||||
Assert.Equal("4.1.8", account.WechatVersion);
|
||||
Assert.Equal(2, account.Databases.Count);
|
||||
Assert.Contains(merged, item => item.AccountRootFingerprint == "account-history");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class InterprocessCommandGateTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SeparateInstancesShareAnExclusiveLease()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "wx-gate-" + Guid.NewGuid(), "commands.lock");
|
||||
try
|
||||
{
|
||||
using var first = new InterprocessCommandGate(path);
|
||||
using var second = new InterprocessCommandGate(path);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await first.WaitAsync(timeout.Token);
|
||||
var waiting = second.WaitAsync(timeout.Token);
|
||||
Assert.False(waiting.IsCompleted);
|
||||
first.Release();
|
||||
await waiting;
|
||||
second.Release();
|
||||
}
|
||||
finally { Directory.Delete(Path.GetDirectoryName(path)!, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelledContentionDoesNotPoisonTheLocalQueue()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "wx-gate-" + Guid.NewGuid(), "commands.lock");
|
||||
try
|
||||
{
|
||||
using var first = new InterprocessCommandGate(path);
|
||||
using var second = new InterprocessCommandGate(path);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await first.WaitAsync(timeout.Token);
|
||||
using var cancelled = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => second.WaitAsync(cancelled.Token));
|
||||
first.Release();
|
||||
await second.WaitAsync(timeout.Token);
|
||||
second.Release();
|
||||
}
|
||||
finally { Directory.Delete(Path.GetDirectoryName(path)!, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonContentionIoErrorsAreNotRetried()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "wx-gate-" + Guid.NewGuid(), "commands.lock");
|
||||
Directory.CreateDirectory(path); // A directory cannot be opened as the lock file.
|
||||
try
|
||||
{
|
||||
using var gate = new InterprocessCommandGate(path);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var error = await Record.ExceptionAsync(() => gate.WaitAsync(timeout.Token));
|
||||
Assert.True(error is IOException or UnauthorizedAccessException);
|
||||
Directory.Delete(path);
|
||||
await gate.WaitAsync(timeout.Token);
|
||||
gate.Release();
|
||||
}
|
||||
finally { Directory.Delete(Path.GetDirectoryName(path)!, recursive: true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class KeyCachePlannerTests
|
||||
{
|
||||
private static AccountKeySet CachedAccount(string fingerprint, params string[] paths) => new(
|
||||
fingerprint, "C:\\xwechat_files\\" + fingerprint, "4.1.13.63", DateTimeOffset.UtcNow,
|
||||
paths.Select(path => new DatabaseKeyEvidence(path, "salt", "key", 42, DateTimeOffset.UtcNow, KeyBindingConfidence.PageHmacVerified)).ToArray());
|
||||
|
||||
private static AccountDatabaseEntry Entry(string fingerprint, string path) => new(fingerprint, path);
|
||||
|
||||
[Fact]
|
||||
public void VerifiedKeysCoversEveryCachedDatabasePath()
|
||||
{
|
||||
var cached = new[] { CachedAccount("a", "contact.db", "message_0.db"), CachedAccount("b", "message_0.db") };
|
||||
var verified = KeyCachePlanner.VerifiedKeys(cached);
|
||||
Assert.Contains(KeyCachePlanner.Key("a", "contact.db"), verified);
|
||||
Assert.Contains(KeyCachePlanner.Key("a", "message_0.db"), verified);
|
||||
Assert.Contains(KeyCachePlanner.Key("b", "message_0.db"), verified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPendingIsFalseWhenAllDiscoveredDatabasesAreCached()
|
||||
{
|
||||
var cached = new[] { CachedAccount("a", "contact.db", "message_0.db") };
|
||||
var verified = KeyCachePlanner.VerifiedKeys(cached);
|
||||
var discovered = new[] { Entry("a", "contact.db"), Entry("a", "message_0.db") };
|
||||
Assert.False(KeyCachePlanner.HasPending(discovered, verified));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPendingIsTrueForNewDatabaseOrNewAccount()
|
||||
{
|
||||
var cached = new[] { CachedAccount("a", "contact.db") };
|
||||
var verified = KeyCachePlanner.VerifiedKeys(cached);
|
||||
Assert.True(KeyCachePlanner.HasPending([Entry("a", "message_0.db")], verified));
|
||||
Assert.True(KeyCachePlanner.HasPending([Entry("b", "contact.db")], verified));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheKeyIsCaseInsensitiveForWindowsPaths()
|
||||
{
|
||||
var verified = KeyCachePlanner.VerifiedKeys([CachedAccount("a", "Contact.DB")]);
|
||||
Assert.True(verified.Contains(KeyCachePlanner.Key("a", "contact.db")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class MemoryScanningTests
|
||||
{
|
||||
private static byte[] EncodedLiteral(string literal)
|
||||
{
|
||||
var bytes = Encoding.ASCII.GetBytes(literal);
|
||||
var mask = Convert.FromHexString("d2c7442458020000004889442450488b450048844c2448488944254048584c24");
|
||||
for (var i = 0; i < bytes.Length; i++) bytes[i] ^= mask[i % mask.Length];
|
||||
return bytes;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigCipherDecodeFindsKeyAndEmbeddedSalt()
|
||||
{
|
||||
var key = "000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f";
|
||||
var salt = "101112131415161718191a1b1c1d1e1f";
|
||||
var decoded = WcdbConfigCipher.Decode(EncodedLiteral($"x'{key}{salt}'"));
|
||||
Assert.Equal(new WcdbKeyCandidate(key, salt), Assert.Single(decoded));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigCipherDecodeWindowsOnLongRuns()
|
||||
{
|
||||
var key = "000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f";
|
||||
var salt = "101112131415161718191a1b1c1d1e1f";
|
||||
var run = key + salt + key + salt;
|
||||
var decoded = WcdbConfigCipher.Decode(EncodedLiteral($"x'{run}'"));
|
||||
Assert.Contains(decoded, item => item.EncKey == key && item.SaltHint == salt);
|
||||
Assert.Equal(4, decoded.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigCipherDecodeRejectsBoundsAndUniformKeys()
|
||||
{
|
||||
Assert.Empty(WcdbConfigCipher.Decode([]));
|
||||
Assert.Empty(WcdbConfigCipher.Decode(EncodedLiteral("x'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'" + "00000000000000000000000000000000")));
|
||||
Assert.Empty(WcdbConfigCipher.Decode(EncodedLiteral("plain")));
|
||||
Assert.Empty(WcdbConfigCipher.Decode(EncodedLiteral("x''")));
|
||||
var tooLong = EncodedLiteral($"x'{new string('e', 200)}'");
|
||||
Assert.Empty(WcdbConfigCipher.Decode(tooLong));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigCipherDecodeRejectsBlobsOverLimit()
|
||||
{
|
||||
var literal = $"x'{new string('f', 64)}{new string('1', 32)}'";
|
||||
var blob = new List<byte>(EncodedLiteral(literal));
|
||||
while (blob.Count <= WcdbConfigCipher.MaximumBlobLength) blob.Insert(0, 0x00);
|
||||
Assert.Empty(WcdbConfigCipher.Decode(blob.ToArray()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScannerMatchesAcrossChunkBoundaries()
|
||||
{
|
||||
var needle = Encoding.ASCII.GetBytes("com.Tencent.WCDB.Config.Cipher");
|
||||
var content = Encoding.ASCII.GetBytes("prefix " + Encoding.ASCII.GetString(needle) + " suffix");
|
||||
var split = 10;
|
||||
var scanner = new MemoryPatternScanner(needle);
|
||||
var matches = scanner.Feed(content.AsSpan(0, split), 0x1000).ToList();
|
||||
matches.AddRange(scanner.Feed(content.AsSpan(split), 0x1000 + (ulong)split));
|
||||
Assert.Equal([0x1000 + 7UL], matches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScannerReportsMatchesInSecondChunk()
|
||||
{
|
||||
var needle = Encoding.ASCII.GetBytes("abc");
|
||||
var content = Encoding.ASCII.GetBytes("xxabc");
|
||||
var scanner = new MemoryPatternScanner(needle);
|
||||
var matches = scanner.Feed(content.AsSpan(0, 3), 0x2000).ToList();
|
||||
matches.AddRange(scanner.Feed(content.AsSpan(3), 0x2003));
|
||||
Assert.Equal([0x2002UL], matches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScannerDoesNotJoinAcrossGaps()
|
||||
{
|
||||
var needle = Encoding.ASCII.GetBytes("abc");
|
||||
var scanner = new MemoryPatternScanner(needle);
|
||||
var matches = scanner.Feed("a"u8[..1], 0x3000).ToList();
|
||||
matches.AddRange(scanner.Feed("b"u8[..1], 0x4000)); // Gap: end was 0x3001, next feed at 0x4000.
|
||||
matches.AddRange(scanner.Feed("c"u8[..1], 0x4001));
|
||||
Assert.Empty(matches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatternScannerFindsMatchSpanningThreeChunks()
|
||||
{
|
||||
var needle = Encoding.ASCII.GetBytes("abcde");
|
||||
var content = Encoding.ASCII.GetBytes("xxabcde");
|
||||
var scanner = new MemoryPatternScanner(needle);
|
||||
var matches = scanner.Feed(content.AsSpan(0, 3), 0x5000).ToList();
|
||||
matches.AddRange(scanner.Feed(content.AsSpan(3, 3), 0x5003));
|
||||
matches.AddRange(scanner.Feed(content.AsSpan(6), 0x5006));
|
||||
Assert.Equal([0x5002UL], matches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeProducesNoMatchAfterPatternError()
|
||||
{
|
||||
// Simulates the reference's own fail-open behavior after a failed read.
|
||||
var decoded = WcdbConfigCipher.Decode(Encoding.ASCII.GetBytes("x'abc'"));
|
||||
Assert.Empty(decoded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class UiDiagnosticsTests
|
||||
{
|
||||
[Fact]
|
||||
public void TraversalGuard_StopsCyclesBeforeRecursing()
|
||||
{
|
||||
var guard = new UiTraversalGuard();
|
||||
Assert.True(guard.TryVisit("root", 0));
|
||||
Assert.True(guard.TryVisit("child", 1));
|
||||
Assert.False(guard.TryVisit("root", 2));
|
||||
Assert.True(guard.CycleDetected);
|
||||
Assert.Equal(2, guard.NodeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TraversalGuard_BoundsDepthAndNodesEvenWithMissingRuntimeIds()
|
||||
{
|
||||
var depth = new UiTraversalGuard(maxDepth: 2);
|
||||
Assert.True(depth.TryVisit("", 0));
|
||||
Assert.False(depth.TryVisit("", 3));
|
||||
Assert.True(depth.Truncated);
|
||||
var count = new UiTraversalGuard(maxNodes: 2);
|
||||
Assert.True(count.TryVisit("", 0));
|
||||
Assert.True(count.TryVisit("", 1));
|
||||
Assert.False(count.TryVisit("", 2));
|
||||
Assert.Equal(2, count.NodeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotComparison_DetectsRequiredControlRegressionWithoutLeakingDynamicIds()
|
||||
{
|
||||
var baseline = Root(WechatLocators.RequiredAutomationIds);
|
||||
var missing = WechatLocators.MessageList;
|
||||
var current = Root(WechatLocators.RequiredAutomationIds.Where(id => id != missing).Append("[id:private-hash]").Append("new_static_control"));
|
||||
var report = UiSnapshotComparison.Compare(baseline, current);
|
||||
Assert.Equal([missing], report.MissingRequiredControls);
|
||||
Assert.Equal([missing], report.RemovedAutomationIds);
|
||||
Assert.Equal(["new_static_control"], report.AddedAutomationIds);
|
||||
}
|
||||
|
||||
private static UiNodeSnapshot Root(IEnumerable<string> ids) =>
|
||||
new("Window", "", "", true, "0,0,800,600", ids.Select(id => new UiNodeSnapshot("Pane", "", id, true, "", [])).ToArray());
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatContactQueryTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParametersAreBoundAndFetchOneExtraRow()
|
||||
{
|
||||
const string filter = "O'Brien_%'; DROP TABLE contact;--";
|
||||
var parameters = WechatContactQuery.Parameters(2, 4, filter, true).ToDictionary(pair => pair.Key, pair => pair.Value);
|
||||
Assert.Equal(3, parameters["$limit"]);
|
||||
Assert.Equal(4, parameters["$offset"]);
|
||||
Assert.Equal(1, parameters["$groups"]);
|
||||
Assert.Equal(filter, parameters["$contains"]);
|
||||
Assert.DoesNotContain(filter, WechatContactQuery.Sql);
|
||||
Assert.Contains("ORDER BY username COLLATE BINARY", WechatContactQuery.Sql);
|
||||
Assert.Equal(0, WechatContactQuery.Parameters(2, 0, null, false).Last().Value);
|
||||
Assert.Null(WechatContactQuery.Parameters(2, 0, null, null).Last().Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0)]
|
||||
[InlineData(10001, 0)]
|
||||
[InlineData(1, -1)]
|
||||
[InlineData(2, int.MaxValue)]
|
||||
public void InvalidBoundsAreRejected(int limit, int offset) =>
|
||||
Assert.Equal(WxAgentErrorCode.InvalidArgument,
|
||||
Assert.Throws<WxAgentException>(() => WechatContactQuery.Parameters(limit, offset, null, null)).Code);
|
||||
|
||||
[Fact]
|
||||
public void PageSignalsTruncationWithoutCollapsingDuplicateDisplayNames()
|
||||
{
|
||||
WechatDatabaseContact[] contacts = [new("a", "same", null, null), new("b", "same", null, null), new("c", null, "remark", null)];
|
||||
var page = WechatContactQuery.Page(contacts, 2, 4);
|
||||
Assert.True(page.HasMore);
|
||||
Assert.Equal(6, page.NextOffset);
|
||||
Assert.Equal(new[] { "a", "b" }, page.Contacts.Select(contact => contact.Username));
|
||||
var last = WechatContactQuery.Page(contacts[2..], 2, 6);
|
||||
Assert.False(last.HasMore);
|
||||
Assert.Equal("remark", last.Contacts[0].DisplayName);
|
||||
Assert.False(WechatContactQuery.Page([], 2, 0).HasMore);
|
||||
Assert.Equal("wxid", new WechatDatabaseContact("wxid", "", " ", null).DisplayName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Text;
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
using ZstdSharp;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatDbMessageTests
|
||||
{
|
||||
[Fact]
|
||||
public void TableNameForMatchesMd5HexOfChatId()
|
||||
{
|
||||
Assert.Equal("Msg_" + Md5Hex("wxid_test"), WechatDbMessage.TableNameFor("wxid_test"));
|
||||
Assert.Equal("Msg_" + Md5Hex("测试群组"), WechatDbMessage.TableNameFor("测试群组"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeContentHandlesPlainHexUtf8()
|
||||
{
|
||||
Assert.Equal("hello", WechatDbMessage.DecodeContent(Convert.ToHexString(Encoding.UTF8.GetBytes("hello")), compressed: false));
|
||||
Assert.Empty(WechatDbMessage.DecodeContent("", compressed: false));
|
||||
Assert.Empty(WechatDbMessage.DecodeContent("not-hex!", compressed: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeContentDecompressesZstdPayloads()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("wx-agent-zstd-roundtrip-消息测试");
|
||||
using var compressor = new CompressionStream(new MemoryStream());
|
||||
// ZstdSharp.CompressionStream writes on dispose; wrap in a memory stream instead.
|
||||
var compressed = Compress(payload);
|
||||
var hex = Convert.ToHexString(compressed);
|
||||
var decoded = WechatDbMessage.DecodeContent(hex, compressed: true);
|
||||
Assert.Equal("wx-agent-zstd-roundtrip-消息测试", decoded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeContentTreatsCompressedFlagWithoutMagicAsUndecodable()
|
||||
{
|
||||
var hex = Convert.ToHexString(Encoding.UTF8.GetBytes("plain"));
|
||||
Assert.Empty(WechatDbMessage.DecodeContent(hex, compressed: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSelfUsesWxIdPrefixOfAccountDirectory()
|
||||
{
|
||||
Assert.True(WechatDbMessage.IsSelf("wxid_abc123", "wxid_abc123_def456"));
|
||||
Assert.False(WechatDbMessage.IsSelf("wxid_other", "wxid_abc123_def456"));
|
||||
Assert.Null(WechatDbMessage.IsSelf(null, "wxid_abc123_def456"));
|
||||
Assert.Null(WechatDbMessage.IsSelf("wxid_abc123", null));
|
||||
}
|
||||
|
||||
private static string Md5Hex(string value) =>
|
||||
Convert.ToHexString(System.Security.Cryptography.MD5.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
|
||||
private static byte[] Compress(byte[] payload)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
using (var compressor = new CompressionStream(output))
|
||||
{
|
||||
compressor.Write(payload);
|
||||
}
|
||||
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatDerivedTextTests
|
||||
{
|
||||
[Fact]
|
||||
public void OnlyAddedTextIsReturnedAndDuplicateLinesArePreserved()
|
||||
{
|
||||
Assert.Equal($"result{Environment.NewLine}result",
|
||||
WechatDerivedText.AddedText(["original", "timestamp"], ["original", "timestamp", "result", "result"]));
|
||||
Assert.Equal("same", WechatDerivedText.AddedText(["same"], ["same", "same"]));
|
||||
Assert.Null(WechatDerivedText.AddedText(["original"], ["original"]));
|
||||
Assert.Null(WechatDerivedText.AddedText(["original"], ["unrelated replacement"]));
|
||||
Assert.Null(WechatDerivedText.AddedText(["same", "same"], ["same", "new"]));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("识别中")]
|
||||
[InlineData("转换中…")]
|
||||
[InlineData("语音转换失败")]
|
||||
[InlineData("识别失败,请重试")]
|
||||
[InlineData("未识别到文字")]
|
||||
[InlineData("无法转换")]
|
||||
[InlineData(" ")]
|
||||
public void TransientOrFailedResultsAreNotReturned(string status) =>
|
||||
Assert.Null(WechatDerivedText.AddedText(["original"], ["original", status]));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatGroupMemberQueryTests
|
||||
{
|
||||
[Fact]
|
||||
public void QueryUsesStableIdsAndBoundParameters()
|
||||
{
|
||||
var parameters = WechatGroupMemberQuery.Parameters("123@chatroom", 2, 4).ToDictionary(pair => pair.Key, pair => pair.Value);
|
||||
Assert.Equal("123@chatroom", parameters["$group"]);
|
||||
Assert.Equal(3, parameters["$limit"]);
|
||||
Assert.Equal(4, parameters["$offset"]);
|
||||
Assert.Contains("c.id = m.member_id", WechatGroupMemberQuery.Sql);
|
||||
Assert.Contains("ORDER BY m.member_id", WechatGroupMemberQuery.Sql);
|
||||
Assert.DoesNotContain("123@chatroom", WechatGroupMemberQuery.Sql);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("group")]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void StableGroupUsernameIsRequired(string group) =>
|
||||
Assert.Equal(WxAgentErrorCode.InvalidArgument,
|
||||
Assert.Throws<WxAgentException>(() => WechatGroupMemberQuery.Parameters(group, 1, 0)).Code);
|
||||
|
||||
[Fact]
|
||||
public void PagePreservesDuplicateDisplayNamesAndSignalsNextOffset()
|
||||
{
|
||||
WechatDatabaseGroupMember[] rows =
|
||||
[
|
||||
new(1, "one", "same", true), new(2, "two", "same", false), new(3, "three", "three", false)
|
||||
];
|
||||
var page = WechatGroupMemberQuery.Page("123@chatroom", rows, 2, 4);
|
||||
Assert.True(page.HasMore);
|
||||
Assert.Equal(6, page.NextOffset);
|
||||
Assert.Equal(new[] { "one", "two" }, page.Members.Select(member => member.Username));
|
||||
Assert.True(page.Members[0].IsOwner);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatM5Tests
|
||||
{
|
||||
[Fact]
|
||||
public void GetUrl_ExtractsFirstHttpUrlAndTrailingPunctuation()
|
||||
{
|
||||
var message = Message("文档:https://example.com/a?q=1。", ChatMessageType.Link);
|
||||
Assert.Equal("https://example.com/a?q=1", WechatMessageContentParser.GetUrl(message)?.AbsoluteUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToMarkdown_FormatsLinkAndQuote()
|
||||
{
|
||||
var message = Message(
|
||||
"项目主页 https://example.com",
|
||||
ChatMessageType.Link,
|
||||
new QuotedMessageSnapshot("甲", "原消息"));
|
||||
|
||||
Assert.Equal("> 原消息\n\n[项目主页](https://example.com/)", WechatMessageContentParser.ToMarkdown(message).Replace("\r\n", "\n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetContent_PreservesTypedMessageData()
|
||||
{
|
||||
var message = Message("[图片]", ChatMessageType.Image);
|
||||
var content = WechatMessageContentParser.GetContent(message);
|
||||
Assert.Equal(ChatMessageType.Image, content.Type);
|
||||
Assert.Equal("[图片]", content.Text);
|
||||
Assert.Null(content.Url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MomentFingerprint_IsStableAndSensitiveToContent()
|
||||
{
|
||||
var first = WechatMessageContentParser.MomentFingerprint("甲", "内容", "昨天");
|
||||
Assert.Equal(first, WechatMessageContentParser.MomentFingerprint("甲", "内容", "昨天"));
|
||||
Assert.NotEqual(first, WechatMessageContentParser.MomentFingerprint("甲", "其他", "昨天"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[位置]", ChatMessageType.Location)]
|
||||
[InlineData("[动画表情]", ChatMessageType.Emotion)]
|
||||
[InlineData("三条聊天记录", ChatMessageType.Merge)]
|
||||
[InlineData("[名片]", ChatMessageType.PersonalCard)]
|
||||
[InlineData("[笔记]", ChatMessageType.Note)]
|
||||
public void Parser_ClassifiesM5SpecialMessages(string text, ChatMessageType expected)
|
||||
{
|
||||
var parsed = VisibleMessageParser.Parse([new ChatMessageSource(text, "id")]);
|
||||
Assert.Equal(expected, Assert.Single(parsed).Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoteContent_ToMarkdown_PreservesOrderAndFiles()
|
||||
{
|
||||
var content = new WechatNoteContent([
|
||||
new WechatNoteContentPart("text", "标题"),
|
||||
new WechatNoteContentPart("file", "/tmp/a.png")
|
||||
]);
|
||||
Assert.Equal("标题\n\n[a.png](/tmp/a.png)", content.ToMarkdown().Replace("\r\n", "\n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MomentPrivacy_RequiresTagsForRestrictedVisibility()
|
||||
{
|
||||
new WechatMomentPublishOptions().Validate();
|
||||
Assert.Throws<WxAgentException>(() => new WechatMomentPublishOptions(WechatMomentPrivacy.TagWhitelist).Validate());
|
||||
new WechatMomentPublishOptions(WechatMomentPrivacy.TagBlacklist, ["测试标签"]).Validate();
|
||||
}
|
||||
|
||||
private static ChatMessageSnapshot Message(string text, ChatMessageType type, QuotedMessageSnapshot? quote = null) =>
|
||||
new(text, "fingerprint", 0, type, quote);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatManagementTests
|
||||
{
|
||||
[Fact]
|
||||
public void RequireConfirmation_RejectsMissingOrWrongToken()
|
||||
{
|
||||
var missing = Assert.Throws<WxAgentException>(() => WechatOperationPolicy.RequireConfirmation(null, "delete session"));
|
||||
var wrong = Assert.Throws<WxAgentException>(() => WechatOperationPolicy.RequireConfirmation("confirm", "delete session"));
|
||||
|
||||
Assert.Equal(WxAgentErrorCode.InvalidArgument, missing.Code);
|
||||
Assert.Equal(WxAgentErrorCode.InvalidArgument, wrong.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequireConfirmation_AcceptsExactToken()
|
||||
{
|
||||
WechatOperationPolicy.RequireConfirmation(WechatOperationPolicy.Confirmation, "delete session");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateNames_RequiresAtLeastOneUsableName()
|
||||
{
|
||||
Assert.Throws<WxAgentException>(() => WechatOperationPolicy.ValidateNames([], "members"));
|
||||
Assert.Throws<WxAgentException>(() => WechatOperationPolicy.ValidateNames(["ok", ""], "members"));
|
||||
Assert.Throws<WxAgentException>(() => WechatOperationPolicy.ValidateNames(["same", "same"], "members"));
|
||||
WechatOperationPolicy.ValidateNames(["one", "two"], "members");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequireUnique_RejectsMissingAndAmbiguousTargets()
|
||||
{
|
||||
Assert.Equal("one", WechatOperationPolicy.RequireUnique(new[] { "one" }, "target"));
|
||||
Assert.Equal(WxAgentErrorCode.ControlNotFound,
|
||||
Assert.Throws<WxAgentException>(() => WechatOperationPolicy.RequireUnique(Array.Empty<string>(), "target")).Code);
|
||||
Assert.Equal(WxAgentErrorCode.ControlNotFound,
|
||||
Assert.Throws<WxAgentException>(() => WechatOperationPolicy.RequireUnique(new[] { "one", "two" }, "target")).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OperationResult_UnconfirmedIsNotSuccessAndWarnsAgainstRetry()
|
||||
{
|
||||
var result = WechatOperationResult.Unconfirmed("Submission");
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(WxAgentErrorCode.ResultUnconfirmed, result.Code);
|
||||
Assert.Contains("Do not retry", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OperationResult_OkHasNoErrorCode()
|
||||
{
|
||||
var result = WechatOperationResult.Ok("done");
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.Code);
|
||||
Assert.Equal("done", result.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatMentionConfirmationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("\uFFFC", true)]
|
||||
[InlineData("\uFFFC\u2005", true)]
|
||||
[InlineData("@Hao 豪", false)]
|
||||
[InlineData("@", false)]
|
||||
[InlineData("\uFFFCunexpected", false)]
|
||||
[InlineData("\uFFFC\uFFFC", false)]
|
||||
public void RequiresExactlyOneStructuredMentionToken(string value, bool expected) =>
|
||||
Assert.Equal(expected, WechatSendConfirmation.IsMentionToken(value));
|
||||
|
||||
[Theory]
|
||||
[InlineData("@Hao 豪 marker", "Hao 豪", true)]
|
||||
[InlineData("@Hao 豪\u2005marker", "Hao 豪", true)]
|
||||
[InlineData("@所有人 marker", "所有人", true)]
|
||||
[InlineData("@Hao 豪 other", "Hao 豪", false)]
|
||||
[InlineData("@Other marker", "Hao 豪", false)]
|
||||
public void RequiresFreshExactMentionAndBody(string text, string member, bool expected)
|
||||
{
|
||||
var message = new ChatMessageSnapshot(text, "fresh", 0, ChatMessageType.Text);
|
||||
Assert.Equal(expected, WechatSendConfirmation.MatchesMention(message, new HashSet<string>(), member, "marker"));
|
||||
Assert.False(WechatSendConfirmation.MatchesMention(message, new HashSet<string> { "fresh" }, member, "marker"));
|
||||
Assert.False(WechatSendConfirmation.MatchesMention(message with { Type = ChatMessageType.Quote }, new HashSet<string>(), member, "marker"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Xml.Linq;
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
using ZstdSharp;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatMergedChatTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParsesEmbeddedTextMetadataWithoutDeduplicatingRepeatedMessages()
|
||||
{
|
||||
var result = WechatMergedChatParser.Parse(Wrap(Record(Item("same\n\ntext"), Item("same\n\ntext"))));
|
||||
Assert.Equal(2, result.Messages.Count);
|
||||
Assert.Equal("0", result.Messages[0].Path);
|
||||
Assert.Equal("1", result.Messages[1].Path);
|
||||
Assert.Equal("same\n\ntext", result.Messages[0].Text);
|
||||
Assert.Equal("Synthetic sender", result.Messages[0].SenderName);
|
||||
Assert.Equal("opaque-hash", result.Messages[0].SenderHash);
|
||||
Assert.Equal("18446744073709551615", result.Messages[0].SourceServerId);
|
||||
Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1700000000), result.Messages[0].Timestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SupportsNestedRecordsAndExplicitAttachmentMetadata()
|
||||
{
|
||||
var attachment = new XElement("dataitem", new XAttribute("datatype", 8),
|
||||
new XElement("datatitle", "example.txt"), new XElement("datafmt", "txt"), new XElement("datasize", 42),
|
||||
new XElement("cdn_dataurl", "must-not-be-exported"), new XElement("aeskey", "must-not-be-exported"));
|
||||
var nested = new XElement("dataitem", new XAttribute("datatype", 17), Record(Item("nested")));
|
||||
var result = WechatMergedChatParser.Parse(Record(attachment, nested).ToString());
|
||||
Assert.Null(result.Messages[0].Text);
|
||||
Assert.Equal(42, result.Messages[0].SizeBytes);
|
||||
Assert.Equal("1/0", result.Messages[1].NestedRecord!.Messages[0].Path);
|
||||
Assert.DoesNotContain("must-not-be-exported", JsonSerializer.Serialize(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsTruncationMissingPayloadAndInvalidTimestamp()
|
||||
{
|
||||
var record = Record(Item("hello"));
|
||||
record.Element("datalist")!.SetAttributeValue("count", 2);
|
||||
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(record.ToString()));
|
||||
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(Record(new XElement("dataitem", new XAttribute("datatype", 1))).ToString()));
|
||||
var item = Item("hello");
|
||||
item.Element("srcMsgCreateTime")!.Value = long.MaxValue.ToString();
|
||||
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(Record(item).ToString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsDtdAndBoundViolations()
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse("<!DOCTYPE recordinfo [<!ENTITY x SYSTEM 'file:///not-read'>]><recordinfo>&x;</recordinfo>"));
|
||||
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(new string('x', WechatMergedChatParser.MaxXmlCharacters + 1)));
|
||||
var nested = Record(Item("innermost"));
|
||||
for (var i = 0; i < WechatMergedChatParser.MaxNesting; i++)
|
||||
nested = Record(new XElement("dataitem", new XAttribute("datatype", 17), nested));
|
||||
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(nested.ToString()));
|
||||
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(Record(Enumerable.Range(0, WechatMergedChatParser.MaxMessages + 1).Select(_ => Item("x")).ToArray()).ToString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreservesComposite64BitMessageTypeAndBoundsZstdDecoding()
|
||||
{
|
||||
var message = new DbMessage(149, 123, "filehelper", null, null, null, 81604378673L, "xml", DateTimeOffset.UnixEpoch, null);
|
||||
using var json = JsonDocument.Parse(JsonSerializer.Serialize(message));
|
||||
Assert.Equal(81604378673L, json.RootElement.GetProperty("Type").GetInt64());
|
||||
using var output = new MemoryStream();
|
||||
using (var compressed = new CompressionStream(output))
|
||||
compressed.Write(Encoding.UTF8.GetBytes(new string('x', WechatMergedChatParser.MaxXmlCharacters + 1)));
|
||||
Assert.Throws<InvalidDataException>(() => WechatDbMessage.DecodeContent(Convert.ToHexString(output.ToArray()), true));
|
||||
}
|
||||
|
||||
private static XElement Item(string text) => new("dataitem", new XAttribute("datatype", 1), new XAttribute("dataid", "repeated-id"),
|
||||
new XElement("datadesc", text), new XElement("sourcename", "Synthetic sender"), new XElement("srcMsgLocalId", "7"),
|
||||
new XElement("srcMsgCreateTime", "1700000000"), new XElement("fromnewmsgid", "18446744073709551615"),
|
||||
new XElement("dataitemsource", new XElement("hashusername", "opaque-hash")));
|
||||
private static XElement Record(params XElement[] items) => new("recordinfo", new XElement("title", "Synthetic record"),
|
||||
new XElement("datalist", new XAttribute("count", items.Length), items));
|
||||
private static string Wrap(XElement record) => new XElement("msg", new XElement("appmsg", new XElement("type", 19),
|
||||
new XElement("recorditem", new XCData(record.ToString(SaveOptions.DisableFormatting))))).ToString();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatNavigationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(101)]
|
||||
public void RejectsUnboundedScrollRequests(int pages) =>
|
||||
Assert.Throws<WxAgentException>(() => WechatNavigationRules.ValidateScrollLimit(pages));
|
||||
|
||||
[Fact]
|
||||
public void MatchesExactVisibleFingerprintAndRejectsAmbiguity()
|
||||
{
|
||||
var message = new ChatMessageSnapshot("test", new string('a', 64), 0, ChatMessageType.Text);
|
||||
Assert.Same(message, WechatNavigationRules.FindMessage([message], new string('A', 64)));
|
||||
Assert.Null(WechatNavigationRules.FindMessage([message], new string('b', 64)));
|
||||
Assert.Throws<WxAgentException>(() => WechatNavigationRules.FindMessage([message, message], message.Fingerprint));
|
||||
Assert.Throws<WxAgentException>(() => WechatNavigationRules.FindMessage([message], "not-a-fingerprint"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatNoteWindowTests
|
||||
{
|
||||
[Fact]
|
||||
public void OnlyAFreshVisibleNoteInTheExpectedProcessIsSelected()
|
||||
{
|
||||
WechatNoteWindowCandidate[] windows = [
|
||||
new(1, 10, "笔记", true, false), // Existing note must not be exported instead of the selected message.
|
||||
new(2, 10, "笔记交流群", true, true),
|
||||
new(3, 20, "笔记", true, false),
|
||||
new(4, 10, "笔记", false, false),
|
||||
new(5, 10, "微信", true, false),
|
||||
new(6, 10, "笔记", true, false)];
|
||||
Assert.Equal(6, WechatNoteWindow.FindOpened(windows, 10, new HashSet<long> { 1 }));
|
||||
Assert.Null(WechatNoteWindow.FindOpened(windows[..5], 10, new HashSet<long> { 1 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmbiguousNewNotesAreRejected()
|
||||
{
|
||||
var error = Assert.Throws<WxAgentException>(() => WechatNoteWindow.FindOpened([
|
||||
new(1, 10, "笔记", true, false), new(2, 10, "笔记", true, false)], 10, new HashSet<long>()));
|
||||
Assert.Equal(WxAgentErrorCode.UiStructureChanged, error.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedNoteParagraphsMustRemainRepeated()
|
||||
{
|
||||
var content = new WechatNoteContent([
|
||||
new("text", "重复段落"), new("text", "重复段落")]);
|
||||
Assert.Equal("重复段落\n\n重复段落", content.ToMarkdown().ReplaceLineEndings("\n"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatSearchRulesTests
|
||||
{
|
||||
[Fact]
|
||||
public void CollapsedGroupsExposeALocalExpansion_NotANetworkKeyword()
|
||||
{
|
||||
var page = WechatSearchRules.Parse([
|
||||
("", "群聊"), ("search_item_技术交流群", "群聊\n技术交流群"), ("", "查看全部(10)"),
|
||||
("", "网络搜索"), ("search_item_web_交流", "网络搜索\n交流"), ("", "交流")], "交流");
|
||||
Assert.Single(page.Results);
|
||||
Assert.Equal("技术交流群", page.Results[0].Name);
|
||||
Assert.False(page.Results[0].IsExactMatch);
|
||||
Assert.Equal("技术交流群", WechatSearchRules.RequireExact(page.Results, "技术交流群").Name);
|
||||
Assert.Equal(new WechatSearchExpansion("群聊", 2, 10), Assert.Single(page.Expansions));
|
||||
Assert.Throws<WxAgentException>(() => WechatSearchRules.RequireExact(page.Results, "交流"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupsAndContactsAreBothLocal_AndDuplicateNamesRemainAmbiguous()
|
||||
{
|
||||
var page = WechatSearchRules.Parse([
|
||||
("", "联系人"), ("search_item_交流", "联系人\n交流"),
|
||||
("", "群聊"), ("search_item_交流", "群聊\n交流")], "交流");
|
||||
Assert.Equal(2, page.Results.Count);
|
||||
Assert.Equal(WxAgentErrorCode.InvalidOperationState,
|
||||
Assert.Throws<WxAgentException>(() => WechatSearchRules.RequireExact(page.Results, "交流")).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExpandedLocalPageStillExcludesNetworkAndHistory()
|
||||
{
|
||||
var page = WechatSearchRules.Parse([
|
||||
("search_item_技术交流群", "技术交流群"), ("search_item_web_交流", "交流"),
|
||||
("", "聊天记录"), ("search_item_历史交流", "交流")], "交流", "群聊");
|
||||
Assert.Equal("技术交流群", Assert.Single(page.Results).Name);
|
||||
Assert.Empty(page.Expansions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExpandedPageDoesNotTrustUnknownSectionsOrTheirSearchIds()
|
||||
{
|
||||
var page = WechatSearchRules.Parse([
|
||||
("search_item_技术交流群", "技术交流群"),
|
||||
("", "未知类别"), ("search_item_new_kind_交流", "交流")], "交流", "群聊");
|
||||
Assert.Equal("技术交流群", Assert.Single(page.Results).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromotedLocalConversationRemainsSelectableButUnknownFamiliesDoNot()
|
||||
{
|
||||
var page = WechatSearchRules.Parse([
|
||||
("", "最常使用"), ("search_item_技术交流群", "技术交流群"),
|
||||
("search_item_function_交流", "交流"), ("search_item_online_交流", "交流"),
|
||||
("search_item_future_交流", "交流")], "交流");
|
||||
Assert.Equal("技术交流群", WechatSearchRules.RequireExact(page.Results, "技术交流群").Name);
|
||||
Assert.Single(page.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACategoryCaptionIsNotTheConversationName()
|
||||
{
|
||||
var page = WechatSearchRules.Parse([("", "群聊"), ("search_item_技术群", "群聊\n技术群")], "群聊");
|
||||
Assert.Equal("技术群", Assert.Single(page.Results).Name);
|
||||
Assert.False(page.Results[0].IsExactMatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebExpansionAndUnclassifiedSearchItemsAreNeverTargets()
|
||||
{
|
||||
var page = WechatSearchRules.Parse([
|
||||
("search_item_交流", "交流"), ("", "网络搜索"), ("", "查看全部(99)")], "交流");
|
||||
Assert.Empty(page.Results);
|
||||
Assert.Empty(page.Expansions);
|
||||
Assert.Throws<WxAgentException>(() => WechatSearchRules.Parse([], "交流", "网络搜索"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatSendConfirmationTests
|
||||
{
|
||||
[Fact]
|
||||
public void RequiresFreshMatchingTypeAndExactReplyText()
|
||||
{
|
||||
var baseline = new HashSet<string> { "old" };
|
||||
var reply = new ChatMessageSnapshot("reply", "new", 0, ChatMessageType.Quote);
|
||||
Assert.True(WechatSendConfirmation.Matches(reply, baseline, ChatMessageType.Quote, "reply"));
|
||||
Assert.False(WechatSendConfirmation.Matches(reply with { Fingerprint = "old" }, baseline, ChatMessageType.Quote, "reply"));
|
||||
Assert.False(WechatSendConfirmation.Matches(reply with { Type = ChatMessageType.Text }, baseline, ChatMessageType.Quote, "reply"));
|
||||
Assert.False(WechatSendConfirmation.Matches(reply with { Text = "another reply" }, baseline, ChatMessageType.Quote, "reply"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("report.txt", true)]
|
||||
[InlineData("文件\nreport.txt\n1 KB", true)]
|
||||
[InlineData("文件\r\nreport.txt\r\n1 KB", true)]
|
||||
[InlineData("other-report.txt", false)]
|
||||
[InlineData("report.txt.backup", false)]
|
||||
public void FileConfirmationRequiresExactFilenameLine(string text, bool expected)
|
||||
{
|
||||
var message = new ChatMessageSnapshot(text, "new", 0, ChatMessageType.File);
|
||||
Assert.Equal(expected, WechatSendConfirmation.Matches(message, new HashSet<string>(), ChatMessageType.File, "report.txt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImageConfirmationRejectsOtherTypesAndOldRows()
|
||||
{
|
||||
var message = new ChatMessageSnapshot("[图片]", "new", 0, ChatMessageType.Image);
|
||||
Assert.True(WechatSendConfirmation.Matches(message, new HashSet<string>(), ChatMessageType.Image, null));
|
||||
Assert.False(WechatSendConfirmation.Matches(message, new HashSet<string> { "new" }, ChatMessageType.Image, null));
|
||||
Assert.False(WechatSendConfirmation.Matches(message with { Type = ChatMessageType.Text }, new HashSet<string>(), ChatMessageType.Image, null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatStabilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void Deduper_HandlesTenThousandMessagesAndEvictsOldEntries()
|
||||
{
|
||||
var deduper = new BoundedMessageDeduper(2048);
|
||||
for (var index = 0; index < 10_000; index++)
|
||||
Assert.True(deduper.TryAdd($"message-{index}"));
|
||||
|
||||
Assert.False(deduper.TryAdd("message-9999"));
|
||||
Assert.True(deduper.TryAdd("message-0"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Qt51514QWindowIcon", "微信", true)]
|
||||
[InlineData("Qt6QWindowIcon", "微信", true)]
|
||||
[InlineData("Qt51514QWindowIcon", "文件传输助手", false)]
|
||||
[InlineData("Qt51514WxTrayIconMessageWindowClass", "微信", false)]
|
||||
[InlineData("Notepad", "微信", false)]
|
||||
public void NativeWindowMatching_RejectsSubwindowsAndOtherApplications(string className, string title, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, WechatLocators.IsNativeMainWindow(className, title));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("微信", true)]
|
||||
[InlineData("微信\n收到新消息", true)]
|
||||
[InlineData("WeChat", true)]
|
||||
[InlineData("企业微信", false)]
|
||||
[InlineData("微信测试脚本", false)]
|
||||
public void TrayMatching_IsExplicit(string name, bool expected) =>
|
||||
Assert.Equal(expected, WechatLocators.IsWechatTrayName(name));
|
||||
|
||||
[Fact]
|
||||
public void StabilityAnalyzer_DetectsAgentLeakAndFinalError()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var first = Sample(now, 100, 10, 1);
|
||||
var last = first with { Timestamp = now.AddHours(1), AgentHandleCount = 101, Errors = [WxAgentErrorCode.SessionLocked] };
|
||||
var report = WechatStabilityAnalyzer.Analyze(now, last.Timestamp, [first, last], 0, 0);
|
||||
Assert.False(report.Healthy);
|
||||
Assert.Contains("agent-handle-growth-over-100", report.Findings);
|
||||
Assert.Contains("final-error:SessionLocked", report.Findings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deduper_RejectsInvalidCapacity()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new BoundedMessageDeduper(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StabilityAnalyzer_ReportsHealthyBoundedGrowth()
|
||||
{
|
||||
var started = DateTimeOffset.UtcNow;
|
||||
var samples = new[]
|
||||
{
|
||||
Sample(started, 100_000_000, 100, 20),
|
||||
Sample(started.AddMinutes(1), 110_000_000, 120, 22)
|
||||
};
|
||||
|
||||
var report = WechatStabilityAnalyzer.Analyze(started, started.AddMinutes(1), samples, 5, 1);
|
||||
|
||||
Assert.True(report.Healthy);
|
||||
Assert.Equal(10_000_000, report.WorkingSetGrowthBytes);
|
||||
Assert.Empty(report.Findings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StabilityAnalyzer_AllowsTransientWindowLossAfterReconnect()
|
||||
{
|
||||
var started = DateTimeOffset.UtcNow;
|
||||
var samples = new[]
|
||||
{
|
||||
Sample(started, 100_000_000, 100, 20),
|
||||
Sample(started.AddMinutes(1), 0, 0, 0, windowFound: false),
|
||||
Sample(started.AddMinutes(2), 105_000_000, 110, 21)
|
||||
};
|
||||
|
||||
var report = WechatStabilityAnalyzer.Analyze(started, started.AddMinutes(2), samples, 0, 1);
|
||||
|
||||
Assert.True(report.Healthy);
|
||||
Assert.DoesNotContain("wechat-window-unavailable", report.Findings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StabilityAnalyzer_FlagsWindowLossAndResourceGrowth()
|
||||
{
|
||||
var started = DateTimeOffset.UtcNow;
|
||||
var samples = new[]
|
||||
{
|
||||
Sample(started, 100_000_000, 100, 20),
|
||||
Sample(started.AddHours(1), 400_000_000, 700, 150, windowFound: false)
|
||||
};
|
||||
|
||||
var report = WechatStabilityAnalyzer.Analyze(started, started.AddHours(1), samples, 0, 0);
|
||||
|
||||
Assert.False(report.Healthy);
|
||||
Assert.Contains("wechat-window-unavailable", report.Findings);
|
||||
Assert.Contains("working-set-growth-over-256mb", report.Findings);
|
||||
Assert.Contains("handle-growth-over-500", report.Findings);
|
||||
Assert.Contains("thread-growth-over-100", report.Findings);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(30, false, 0, true)]
|
||||
[InlineData(15, false, 0, false)]
|
||||
[InlineData(30, true, 0, false)]
|
||||
[InlineData(30, false, 11, false)]
|
||||
public void RunChecks_ApplyDurationCancellationAndHeartbeatToEveryReport(int seconds, bool cancelled, int snapshotAge, bool healthy)
|
||||
{
|
||||
var finish = DateTimeOffset.UtcNow;
|
||||
var last = Sample(finish, 100, 10, 1) with { LastSnapshotAt = finish.AddSeconds(-snapshotAge) };
|
||||
var report = WechatStabilityAnalyzer.Analyze(finish.AddSeconds(-seconds), finish, [last], 0, 0);
|
||||
var checkedReport = WechatStabilityAnalyzer.CheckRun(report, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(seconds), cancelled);
|
||||
Assert.Equal(healthy, checkedReport.Healthy);
|
||||
Assert.Equal(seconds >= 29 && !cancelled, checkedReport.CompletedDuration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunChecks_RejectMissingHeartbeatAndBackgroundFailures()
|
||||
{
|
||||
var finish = DateTimeOffset.UtcNow;
|
||||
var report = WechatStabilityAnalyzer.Analyze(finish.AddHours(-8), finish, [Sample(finish, 0, 0, 0)], 0, 0);
|
||||
report = WechatStabilityAnalyzer.CheckRun(report, TimeSpan.FromHours(8), TimeSpan.FromHours(8), false,
|
||||
"InvalidOperationException", "IOException");
|
||||
Assert.False(report.Healthy);
|
||||
Assert.Contains("listener-snapshot-stale", report.Findings);
|
||||
Assert.Contains("listener-failed:InvalidOperationException", report.Findings);
|
||||
Assert.Contains("sampling-failed:IOException", report.Findings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StabilityAnalyzer_DoesNotHideMissingHeartbeatBeforeRecovery()
|
||||
{
|
||||
var started = DateTimeOffset.UtcNow;
|
||||
var missing = Sample(started.AddMinutes(6), 100, 10, 1);
|
||||
var recovered = missing with { Timestamp = started.AddMinutes(7), LastSnapshotAt = started.AddMinutes(7) };
|
||||
var report = WechatStabilityAnalyzer.Analyze(started, recovered.Timestamp, [missing, recovered], 0, 1);
|
||||
Assert.Contains("listener-snapshot-gap-over-5min", report.Findings);
|
||||
Assert.False(report.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportWrite_PreservesExistingEvidenceWhenCancelled()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "wx-stability-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(directory, "report.json");
|
||||
await File.WriteAllTextAsync(path, "previous evidence");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var report = WechatStabilityAnalyzer.Analyze(now, now, [Sample(now, 0, 0, 0)], 0, 0);
|
||||
using var cancelled = new CancellationTokenSource();
|
||||
cancelled.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
WechatStabilityAnalyzer.WriteJsonAsync(report, path, cancelled.Token));
|
||||
Assert.Equal("previous evidence", await File.ReadAllTextAsync(path));
|
||||
Assert.False(File.Exists(path + ".tmp"));
|
||||
}
|
||||
finally { Directory.Delete(directory, recursive: true); }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Shell_TrayWnd", true)]
|
||||
[InlineData("Shell_SecondaryTrayWnd", true)]
|
||||
[InlineData("NotifyIconOverflowWindow", true)]
|
||||
[InlineData("Qt51514QWindowIcon", false)]
|
||||
[InlineData("Chrome_WidgetWin_1", false)]
|
||||
[InlineData("", false)]
|
||||
public void TrayClicks_RequireANotificationAreaHitTarget(string className, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, WechatLocators.IsNotificationAreaRoot(className));
|
||||
}
|
||||
|
||||
private static WechatStabilitySample Sample(
|
||||
DateTimeOffset timestamp,
|
||||
long memory,
|
||||
int handles,
|
||||
int threads,
|
||||
bool windowFound = true) =>
|
||||
new(timestamp, 1, memory, handles, threads, windowFound, [], 0, 0);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatTextInputTests
|
||||
{
|
||||
[Fact]
|
||||
public void PreservesMultilineWhitespaceWithCanonicalLineEndings()
|
||||
{
|
||||
Assert.Equal("first\n\n second\t\nthird", WechatTextInput.Prepare("first\r\n\r\n second\t\rthird"));
|
||||
Assert.Equal(new string('x', 4000), WechatTextInput.Prepare(new string('x', 4000)));
|
||||
Assert.Throws<WxAgentException>(() => WechatTextInput.Prepare(new string('x', 4001)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" \r\n\t")]
|
||||
[InlineData("before\0after")]
|
||||
[InlineData("before\u001bafter")]
|
||||
public void RejectsEmptyOrUnsafeInput(string input) =>
|
||||
Assert.Throws<WxAgentException>(() => WechatTextInput.Prepare(input));
|
||||
|
||||
[Fact]
|
||||
public void QuoteParserPreservesBlankLinesAndIndentation()
|
||||
{
|
||||
const string reply = "first\n\n second ";
|
||||
var parsed = Assert.Single(VisibleMessageParser.Parse([reply + "\n引用 Alice 的消息 : original"]));
|
||||
Assert.Equal(reply, parsed.Text);
|
||||
Assert.Equal(ChatMessageType.Quote, parsed.Type);
|
||||
Assert.True(WechatSendConfirmation.Matches(parsed, new HashSet<string>(), ChatMessageType.Quote, reply));
|
||||
Assert.Equal("original", parsed.Quote!.Text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\node-agent\WxAgent.Core\WxAgent.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,30 @@
|
||||
using WxAgent.Service;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Service.Tests;
|
||||
|
||||
public sealed class AccountBindingStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReplacesByAccountAndRemovesExplicitBinding()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
var store = new AccountBindingStore(new ServiceOptions { DataDirectory = directory, CredentialFile = Path.Combine(directory, "credentials.json") });
|
||||
store.Replace([
|
||||
new AccountBinding("a", 10, 20, "wxid-a", "Alice", DateTimeOffset.UtcNow),
|
||||
new AccountBinding("a", 11, 21, "wxid-a", "Alice", DateTimeOffset.UtcNow),
|
||||
new AccountBinding("b", 12, 22, "wxid-b", "Bob", DateTimeOffset.UtcNow)
|
||||
]);
|
||||
|
||||
Assert.Equal(11, store.Get("a")!.ProcessId);
|
||||
Assert.Equal(2, store.ReadAll().Count);
|
||||
store.Remove("a");
|
||||
Assert.Null(store.Get("a"));
|
||||
Assert.Single(store.ReadAll());
|
||||
}
|
||||
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using WxAgent.Service;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Service.Tests;
|
||||
|
||||
public sealed class ArtifactStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ArtifactIdsAreOpaqueOwnedBoundedAndPathSafe()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
|
||||
var options = new ServiceOptions { DataDirectory = dir, CredentialFile = "unused" };
|
||||
try
|
||||
{
|
||||
var store = new ArtifactStore(options);
|
||||
await using var body = new MemoryStream("safe"u8.ToArray());
|
||||
var file = new FormFile(body, 0, body.Length, "file", "note.txt") { Headers = new HeaderDictionary(), ContentType = "text/plain" };
|
||||
var info = await store.SaveAsync("alice", file, default);
|
||||
Assert.Matches("^[0-9a-f]{32}$", info.ArtifactId);
|
||||
Assert.Equal("alice", store.Get("alice", info.ArtifactId).PrincipalId);
|
||||
Assert.Throws<ServiceException>(() => store.Get("bob", info.ArtifactId));
|
||||
await using var opened = store.Open("alice", info.ArtifactId); using var reader = new StreamReader(opened);
|
||||
Assert.Equal("safe", await reader.ReadToEndAsync());
|
||||
Assert.Throws<ServiceException>(() => store.Get("alice", "../secrets"));
|
||||
await using var badBody = new MemoryStream("x"u8.ToArray());
|
||||
var bad = new FormFile(badBody, 0, badBody.Length, "file", "payload.exe") { Headers = new HeaderDictionary(), ContentType = "application/octet-stream" };
|
||||
Assert.Equal("FileTypeRejected", (await Assert.ThrowsAsync<ServiceException>(() => store.SaveAsync("alice", bad, default))).Code);
|
||||
}
|
||||
finally { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using WxAgent.Service;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Service.Tests;
|
||||
|
||||
public sealed class EventHubTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReplayAndGapAreExplicitAndSubscriptionsAreOwnerScoped()
|
||||
{
|
||||
var hub = new EventHub();
|
||||
var first = new AgentEvent("one", "account", "chat", "message", "summary", DateTimeOffset.UtcNow);
|
||||
hub.Publish("alice", first);
|
||||
var replay = hub.Subscribe("alice", "one");
|
||||
Assert.Empty(replay.Replay); Assert.False(replay.Gap);
|
||||
var gap = hub.Subscribe("alice", "gone");
|
||||
Assert.True(gap.Gap);
|
||||
Assert.False(hub.TryGet(replay.SubscriptionId, "bob", out _));
|
||||
hub.Publish("alice", new AgentEvent("two", "account", "chat", "message", "summary", DateTimeOffset.UtcNow));
|
||||
Assert.True(hub.TryGet(replay.SubscriptionId, "alice", out var subscription));
|
||||
Assert.True(await subscription.Channel.Reader.WaitToReadAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowConsumerGetsAnExplicitGapInsteadOfSilentDrop()
|
||||
{
|
||||
var hub = new EventHub();
|
||||
var subscription = hub.Subscribe("alice", null);
|
||||
for (var i = 0; i < 102; i++) hub.Publish("alice", new AgentEvent(i.ToString(), "account", "chat", "message", "summary", DateTimeOffset.UtcNow));
|
||||
Assert.True(hub.TryGet(subscription.SubscriptionId, "alice", out var current));
|
||||
var foundGap = false;
|
||||
while (current.Channel.Reader.TryRead(out var item)) foundGap |= item.Kind == "gap";
|
||||
Assert.True(foundGap);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using WxAgent.Service;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Service.Tests;
|
||||
|
||||
public sealed class EventPumpTests
|
||||
{
|
||||
private sealed class SourceBackend : IAgentBackend, IAgentEventSource
|
||||
{
|
||||
public IReadOnlyList<AgentCapability> Capabilities => [new("listener-events", true, true, true, true, false, "read", false, 30, null, [])];
|
||||
public Task<object> StatusAsync(CancellationToken ct) => Task.FromResult<object>(new { ok = true });
|
||||
public async IAsyncEnumerable<AgentEvent> ListenAsync([EnumeratorCancellation] CancellationToken ct)
|
||||
{ yield return new AgentEvent("evt", "ui-current", "session", "message", "summary", DateTimeOffset.UtcNow); await Task.Delay(Timeout.Infinite, ct); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PumpForwardsToTheConfiguredPrincipal()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
|
||||
var options = new ServiceOptions { DataDirectory = dir, CredentialFile = Path.Combine(dir, "credentials.json") };
|
||||
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new ServiceCredential("alice", new string('A', 64), ["read"], [])
|
||||
}));
|
||||
try
|
||||
{
|
||||
var hub = new EventHub(); var subscription = hub.Subscribe("alice", null);
|
||||
using var pump = new EventPump(new SourceBackend(), hub, options);
|
||||
await pump.StartAsync(default);
|
||||
var ct = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!hub.TryGet(subscription.SubscriptionId, "alice", out var current) || !current.Channel.Reader.TryRead(out _))
|
||||
await Task.Delay(10, ct.Token);
|
||||
await pump.StopAsync(default);
|
||||
}
|
||||
finally { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json;
|
||||
using WxAgent.Service;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Service.Tests;
|
||||
|
||||
public sealed class OperationQueueTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QueueCancelsBeforeExecutionRevokesAtExecutionAndNeverOverlapsTimedOutAction()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
var options = new ServiceOptions { DataDirectory = directory, CredentialFile = Path.Combine(directory, "auth.json") };
|
||||
var hash = ServiceOptions.HashToken(new string('A', 43));
|
||||
var identity = new ServiceIdentity("alice", hash, ["read", "write"], ["account"]);
|
||||
void Credentials(string value) => File.WriteAllText(options.CredentialFile,
|
||||
JsonSerializer.Serialize(new[] { new ServiceCredential("alice", value, identity.Permissions, identity.AccountIds) }));
|
||||
Credentials(hash);
|
||||
var capability = new AgentCapability("send", true, true, true, true, true, "write", false, 30, null, []);
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
try
|
||||
{
|
||||
using var store = new OperationStore(options);
|
||||
using var queue = new OperationQueue(store, new ServiceSecurity(options));
|
||||
await queue.StartAsync(default);
|
||||
var first = queue.Submit(identity, "account", capability, "first", "one", async _ => { entered.SetResult(); await release.Task; });
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
var calls = 0;
|
||||
var second = queue.Submit(identity, "account", capability, "second", "two", _ => { Interlocked.Increment(ref calls); return Task.CompletedTask; });
|
||||
Assert.Equal("Cancelled", queue.Cancel(identity, second.Id).State);
|
||||
var third = queue.Submit(identity, "account", capability, "third", "three", _ => { Interlocked.Increment(ref calls); return Task.CompletedTask; });
|
||||
queue.Cancel(identity, first.Id);
|
||||
// Cancel is cooperative: the first action still owns the lane until it really exits.
|
||||
Assert.Equal("Running", store.Get(first.Id, "alice").State);
|
||||
Assert.Equal("Queued", store.Get(third.Id, "alice").State);
|
||||
Credentials(ServiceOptions.HashToken(new string('B', 43)));
|
||||
release.SetResult();
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
while (store.Get(third.Id, "alice").State == "Queued") await Task.Delay(10, deadline.Token);
|
||||
Assert.Equal(0, calls);
|
||||
Assert.Equal("Unconfirmed", store.Get(first.Id, "alice").State);
|
||||
Assert.Equal("AuthorizationRevoked", store.Get(third.Id, "alice").ErrorCode);
|
||||
await queue.StopAsync(default);
|
||||
}
|
||||
finally { release.TrySetResult(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(directory, true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using WxAgent.Service;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Service.Tests;
|
||||
|
||||
public sealed class OperationStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void DurableIdempotencyOwnershipCapacityAndRestartNeverReplay()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
var options = new ServiceOptions { DataDirectory = directory, CredentialFile = "unused" };
|
||||
try
|
||||
{
|
||||
string runningId;
|
||||
string queuedId;
|
||||
using (var store = new OperationStore(options))
|
||||
{
|
||||
var first = store.Enqueue("alice", "account", "send-text", "one", "digest", true, TimeSpan.FromMinutes(1), 2);
|
||||
runningId = first.Record.Id;
|
||||
Assert.True(first.Created);
|
||||
var again = store.Enqueue("alice", "account", "send-text", "one", "digest", true, TimeSpan.FromMinutes(1), 2);
|
||||
Assert.False(again.Created);
|
||||
Assert.Equal(runningId, again.Record.Id);
|
||||
Assert.Equal("IdempotencyConflict", Assert.Throws<ServiceException>(() =>
|
||||
store.Enqueue("alice", "account", "send-text", "one", "different", true, TimeSpan.FromMinutes(1), 2)).Code);
|
||||
Assert.Equal("NotFound", Assert.Throws<ServiceException>(() => store.Get(runningId, "bob")).Code);
|
||||
queuedId = store.Enqueue("bob", "account", "send-text", "one", "digest", true, TimeSpan.FromMinutes(1), 2).Record.Id;
|
||||
Assert.Equal("QueueFull", Assert.Throws<ServiceException>(() =>
|
||||
store.Enqueue("alice", "account", "send-text", "two", "digest", true, TimeSpan.FromMinutes(1), 2)).Code);
|
||||
store.Transition(runningId, "Running", "executing");
|
||||
}
|
||||
using (var restarted = new OperationStore(options))
|
||||
{
|
||||
Assert.Equal("Unconfirmed", restarted.Get(runningId, "alice").State);
|
||||
Assert.Equal("Cancelled", restarted.Get(queuedId, "bob").State);
|
||||
var again = restarted.Enqueue("alice", "account", "send-text", "one", "digest", true, TimeSpan.FromMinutes(1), 2);
|
||||
Assert.False(again.Created);
|
||||
Assert.Equal("Unconfirmed", again.Record.State);
|
||||
restarted.Transition(runningId, "Succeeded", "late-completion");
|
||||
Assert.Equal("Unconfirmed", restarted.Get(runningId, "alice").State);
|
||||
}
|
||||
}
|
||||
finally { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(directory, true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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.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 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); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using WxAgent.Service;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Service.Tests;
|
||||
|
||||
public sealed class ServiceTests
|
||||
{
|
||||
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, false, 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("listener-events", true, true, true, true, false, "read", false, 30, null, [])
|
||||
];
|
||||
public Task<object> StatusAsync(CancellationToken cancellationToken) => Task.FromResult<object>(new { online = true, wechatAvailable = false });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RealRoutesAndMcpShareCredentialsAndRejectRevokedBrowserSession()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
var token = new string('A', 43);
|
||||
var options = new ServiceOptions { CredentialFile = Path.Combine(directory, "credentials.json"), DataDirectory = directory };
|
||||
void Rotate(string value) => File.WriteAllText(options.CredentialFile,
|
||||
JsonSerializer.Serialize(new[] { new ServiceCredential("alice", ServiceOptions.HashToken(value), ["read"], []) }));
|
||||
Rotate(token);
|
||||
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");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/v1/status")).StatusCode);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode);
|
||||
client.DefaultRequestHeaders.Add("Origin", "http://evil.invalid");
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/v1/status")).StatusCode);
|
||||
client.DefaultRequestHeaders.Remove("Origin");
|
||||
client.DefaultRequestHeaders.Host = "evil.invalid";
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/v1/status")).StatusCode);
|
||||
client.DefaultRequestHeaders.Host = null;
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("text/event-stream");
|
||||
using var initialize = await client.PostAsJsonAsync("/mcp", new { jsonrpc = "2.0", id = 1, method = "initialize", @params = new { protocolVersion = "2025-11-25", capabilities = new { }, clientInfo = new { name = "wxagent-integration", version = "1" } } });
|
||||
Assert.Equal(HttpStatusCode.OK, initialize.StatusCode);
|
||||
using var list = await client.PostAsJsonAsync("/mcp", new { jsonrpc = "2.0", id = 2, method = "tools/list" });
|
||||
var schema = await list.Content.ReadAsStringAsync();
|
||||
Assert.Equal(HttpStatusCode.OK, list.StatusCode);
|
||||
Assert.Contains("agent_status", schema);
|
||||
Assert.Contains("inputSchema", schema);
|
||||
using var call = await client.PostAsJsonAsync("/mcp", new { jsonrpc = "2.0", id = 3, method = "tools/call", @params = new { name = "agent_status", arguments = new { } } });
|
||||
Assert.Equal(HttpStatusCode.OK, call.StatusCode);
|
||||
Assert.Contains("wechatAvailable", await call.Content.ReadAsStringAsync());
|
||||
client.DefaultRequestHeaders.Authorization = null;
|
||||
client.DefaultRequestHeaders.Add("Origin", "http://localhost:5088");
|
||||
using var login = await client.PostAsJsonAsync("/api/v1/login", new { token });
|
||||
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
||||
var cookie = login.Headers.GetValues("Set-Cookie").Single().Split(';')[0];
|
||||
var result = await login.Content.ReadFromJsonAsync<JsonElement>();
|
||||
client.DefaultRequestHeaders.Add("Cookie", cookie);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsync("/api/v1/logout", null)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode);
|
||||
Rotate(new string('B', 43));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/v1/status")).StatusCode);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await client.PostAsJsonAsync("/mcp", new { jsonrpc = "2.0", id = 4, method = "tools/list" })).StatusCode);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", new string('B', 43));
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode);
|
||||
}
|
||||
finally { await app.StopAsync(); Directory.Delete(directory, true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using WxAgent.Service;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Service.Tests;
|
||||
|
||||
public sealed class SessionTests
|
||||
{
|
||||
private sealed class Backend : IAgentBackend
|
||||
{
|
||||
public IReadOnlyList<AgentCapability> Capabilities =>
|
||||
[
|
||||
new("sessions-search", true, true, true, true, false, "read", false, 30, null, []),
|
||||
new("session-current", true, true, true, true, false, "read", false, 30, null, []),
|
||||
new("sessions-scroll", true, false, false, true, false, "manage", false, 30, "not validated", [])
|
||||
];
|
||||
public Task<object> StatusAsync(CancellationToken ct) => Task.FromResult<object>(new { ok = true });
|
||||
public Task<IReadOnlyList<SessionSearchInfo>> SearchSessionsAsync(string query, bool exactOnly, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<SessionSearchInfo>>([new("测试", "stable-1", true)]);
|
||||
public Task<SessionInfo?> CurrentSessionAsync(CancellationToken ct) => Task.FromResult<SessionInfo?>(new("测试", "stable-1", true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchCurrentAndDisabledNavigationHaveExplicitContracts()
|
||||
{
|
||||
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"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
var current = await client.GetFromJsonAsync<SessionInfo>("/api/v1/sessions/current"); Assert.Equal("stable-1", current!.AutomationId);
|
||||
var search = await client.GetFromJsonAsync<JsonElement>("/api/v1/sessions/search?query=x&exactOnly=true"); Assert.Equal("stable-1", search.GetProperty("items")[0].GetProperty("automationId").GetString());
|
||||
var scroll = await client.PostAsJsonAsync("/api/v1/sessions/scroll", new { direction = "down", pages = 1 }); Assert.Equal(HttpStatusCode.Forbidden, scroll.StatusCode);
|
||||
}
|
||||
finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using WxAgent.Service;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Service.Tests;
|
||||
|
||||
public sealed class SseTests
|
||||
{
|
||||
private sealed class Backend : IAgentBackend
|
||||
{
|
||||
public IReadOnlyList<AgentCapability> Capabilities => [new("listener-events", true, true, true, true, false, "read", false, 30, null, [])];
|
||||
public Task<object> StatusAsync(CancellationToken ct) => Task.FromResult<object>(new { ok = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EventStreamUsesRealSseFramesAndRedactsMessageContent()
|
||||
{
|
||||
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("alice", 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");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
using var response = await client.GetAsync("/api/v1/events", HttpCompletionOption.ResponseHeadersRead);
|
||||
Assert.Equal(200, (int)response.StatusCode);
|
||||
var hub = app.Services.GetRequiredService<EventHub>();
|
||||
hub.Publish("alice", new AgentEvent("evt-1", "ui-current", "test", "message", "Text", DateTimeOffset.UtcNow));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
using var reader = new StreamReader(await response.Content.ReadAsStreamAsync(timeout.Token));
|
||||
Assert.Equal("id: evt-1", await reader.ReadLineAsync(timeout.Token));
|
||||
Assert.Equal("event: message", await reader.ReadLineAsync(timeout.Token));
|
||||
var data = await reader.ReadLineAsync(timeout.Token);
|
||||
Assert.StartsWith("data: ", data);
|
||||
Assert.DoesNotContain("content", data, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(string.Empty, await reader.ReadLineAsync(timeout.Token));
|
||||
}
|
||||
finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>net8.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings><IsPackable>false</IsPackable><IsTestProject>true</IsTestProject></PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="8.0.28" />
|
||||
<ProjectReference Include="../../../node-agent/WxAgent.Service/WxAgent.Service.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Offline SQLite check for the production contact SQL; no WeChat database is read."""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
source = (
|
||||
Path(__file__).resolve().parents[2] / "node-agent/WxAgent.Core/WechatContactQuery.cs"
|
||||
).read_text()
|
||||
match = re.search(r'public const string Sql = """\s*(.*?)\s*""";', source, re.S)
|
||||
assert match is not None
|
||||
sql = match.group(1)
|
||||
|
||||
with sqlite3.connect(":memory:") as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE contact(username TEXT PRIMARY KEY, nick_name TEXT, remark TEXT, small_head_url TEXT)"
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, NULL)",
|
||||
[(f"wxid_{i:04}", "same display name", None) for i in range(501)]
|
||||
+ [("room@chatroom", "group", None), ("literal", "O'Brien_%", None)],
|
||||
)
|
||||
|
||||
def query(limit=201, offset=0, contains=None, groups=None):
|
||||
return connection.execute(
|
||||
sql, {"limit": limit, "offset": offset, "contains": contains, "groups": groups}
|
||||
).fetchall()
|
||||
|
||||
first = query()
|
||||
assert len(first) == 201
|
||||
second = query(offset=200)
|
||||
third = query(offset=400)
|
||||
ids = [row[0] for row in first[:200] + second[:200] + third]
|
||||
assert len(ids) == 503 and len(set(ids)) == 503 and ids == sorted(ids)
|
||||
assert [row[0] for row in query(contains="_%")] == ["literal"]
|
||||
assert query(contains="'; DROP TABLE contact;--") == []
|
||||
assert [row[0] for row in query(groups=1)] == ["room@chatroom"]
|
||||
assert len(query(limit=10000, groups=0)) == 502
|
||||
assert len(query(limit=10000, contains="same display name")) == 501
|
||||
assert query(offset=503) == []
|
||||
assert connection.execute("SELECT count(*) FROM contact").fetchone()[0] == 503
|
||||
|
||||
print(
|
||||
"contact SQL: pagination, duplicate names, literal filtering and group filtering passed"
|
||||
)
|
||||
Reference in New Issue
Block a user