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(); 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(); 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(); 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 StripsPreviewMetadataFromVisibleSessionNames() { var sessions = WechatSessionParser.Parse( [("session_item_文件传输助手", "文件传输助手\n已置顶\n最后一条消息\n22:25")], "文件传输助手"); var session = Assert.Single(sessions); Assert.Equal("文件传输助手", session.Name); Assert.True(session.IsCurrent); } [Fact] public void ParsesSearchResultsAndMarksOnlyExactLines() { 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"); } }