Files
rogee 13c31fc902
Build web service image / build (push) Successful in 1m53s
feat: add remote control plane and whitelist reads
2026-09-12 09:46:05 +08:00

334 lines
14 KiB
C#

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 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");
}
}