diff --git a/control-plane/data_routes.go b/control-plane/data_routes.go index a3c8337..aca514c 100644 --- a/control-plane/data_routes.go +++ b/control-plane/data_routes.go @@ -172,7 +172,8 @@ func (s *Server) nodeDataRoute(w http.ResponseWriter, r *http.Request, nodeID st if !ok { return requestError{status: http.StatusConflict, code: "NodeNotRegistered", message: "Register the node before reading sync status."} } - authorized = nodeHasAccount(node, accountID) + // Offline database sync needs a verified identity, not an active UI session. + authorized = nodeHasVerifiedAccount(node, accountID) return nil }); err != nil { return err diff --git a/control-plane/data_routes_test.go b/control-plane/data_routes_test.go index 43e1100..05713e3 100644 --- a/control-plane/data_routes_test.go +++ b/control-plane/data_routes_test.go @@ -120,6 +120,44 @@ func TestDataBatchRoundTripAndWebQueriesUseAccountShard(t *testing.T) { } } +func TestDataSyncStatusAllowsVerifiedInactiveAccount(t *testing.T) { + server, err := NewServer(ServerConfig{ + DataFile: filepath.Join(t.TempDir(), "control-plane.json"), + NodeTokens: map[string]string{"node-a": "secret-a"}, + WebUsers: map[string]string{"admin": "web-secret"}, + HeartbeatTimeout: time.Minute, + LeaseTTL: time.Minute, + }) + if err != nil { + t.Fatal(err) + } + defer server.Close() + httpServer := httptest.NewServer(server.Handler()) + defer httpServer.Close() + client := httpServer.Client() + + account := AccountSummary{ + AccountID: "account-a", Active: false, Verified: true, + AllowedChats: []AllowedChatSummary{{ChatID: "chat-a", ChatType: ChatPrivate}}, + } + register := NodeRegistration{ + NodeID: "node-a", ConnectionID: "connection-a", AgentVersion: "test", ProtocolVersion: ProtocolVersion, + Capabilities: []string{"heartbeat", "sync-data"}, Accounts: []AccountSummary{account}, + } + if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/register", "Bearer secret-a", register); response.Code != http.StatusOK { + t.Fatalf("register: %d %s", response.Code, response.Body.String()) + } + + response := doJSON(t, client, http.MethodGet, + httpServer.URL+"/v1/nodes/node-a/data/accounts/account-a/sync-status?stream_key=messages", "Bearer secret-a", nil) + if response.Code != http.StatusOK { + t.Fatalf("inactive verified account sync status = %d: %s", response.Code, response.Body.String()) + } + if nodeHasAccount(Node{Accounts: []AccountSummary{account}}, "account-a") { + t.Fatal("inactive account unexpectedly became eligible for UI tasks") + } +} + func TestDataBatchCannotCrossReportingScope(t *testing.T) { server, err := NewServer(ServerConfig{ DataFile: filepath.Join(t.TempDir(), "control-plane.json"), NodeTokens: map[string]string{"node-a": "secret-a"}, WebUsers: map[string]string{"admin": "web-secret"}, diff --git a/control-plane/server.go b/control-plane/server.go index 55fcaf8..cbc909c 100644 --- a/control-plane/server.go +++ b/control-plane/server.go @@ -1620,6 +1620,15 @@ func nodeHasAccount(node Node, accountID string) bool { return false } +func nodeHasVerifiedAccount(node Node, accountID string) bool { + for _, account := range node.Accounts { + if account.AccountID == accountID && account.Verified { + return true + } + } + return false +} + func nodeAvailable(node Node, now time.Time, heartbeatTimeout time.Duration) bool { return node.Status != NodeOffline && node.LastHeartbeatAt != nil && now.Sub(*node.LastHeartbeatAt) <= heartbeatTimeout } diff --git a/docs/PENDING.md b/docs/PENDING.md index 141a8ec..24bcf63 100644 --- a/docs/PENDING.md +++ b/docs/PENDING.md @@ -2,12 +2,17 @@ 本文件记录全局 review 后的用户决定。用户随后确认同时完善数据库读取链路和四项代码问题;以下明确暂缓的扩展与真机验收仍不执行。暂缓不等于通过,安全修复也不等于端到端功能已验收。 -## 当前正常功能复核(2026-09-29) +## 当前正常功能复核(更新至 2026-09-30) - 长文本分段、2–10 个附件批量入口、提及重名 fail-closed 保护和合并聊天混合元数据解析已补代码及离线测试;TaskNotFound 修复保留。 -- 已通过 Core/Service .NET 测试、Go 测试与 vet、WebUI 测试/构建及完整 Release solution build。Windows 新版本已部署,`service.json` 未变。 +- 已通过 Core 193/193、Service 26/26、Go 测试与 vet、WebUI 7/7 与生产构建及完整 Release solution build(0 warnings/errors)。Windows Host/Tray 新版本已部署,`service.json` 未变。 - Windows `inspect-ui` 成功,但 `doctor`/`smoke` 返回 `WechatNotLoggedIn`,必需聊天控件缺失;本轮未发送任何消息/附件,也未执行任何 M4/M5 可见写操作。上述功能的真机验收保持未完成,不得勾选为通过。 - 可脱离聊天 UI 的只读检查通过:批准的测试群成员页返回 2 个成员、0 空显示名、0 重名且无续页;`chat send-files` 重复 `--path` 解析成功,并在随机缺失路径预校验返回 `InvalidArgument`,未触及 UI。只保留聚合计数,未记录成员姓名。 +- Weixin 关闭后再次验证本地数据库能力:`db status` 返回 1 个账号/20 个数据库,`filehelper` 消息读取返回 20 条且不输出正文,批准群成员分页仍返回 2 个唯一显示名,`Hao 豪` 联系人查询返回 1 个精确结果。 +- 生产 `DatabaseMessageSyncCollector` 使用现存检查点(序列 4,223、427 个会话游标)离线采集到 1 条新消息,结果 complete、无 unavailable source,消息正文只以布尔值存在性确认;采集期间 Weixin 进程始终为 0,检查点和 `service.json` 均未改变。 +- 上述结果只证明离线读取/收集,不等同远程新批次确认。2026-09-30 修复了控制面 sync-status 将“活动 UI 账号”误用于只读数据授权的问题:数据状态查询现在仅要求注册账号身份 verified;UI 任务仍要求 active + verified,并有 Go 回归测试。更新控制面后健康检查 HTTP 200,已验证但无活动 UI 会话的账号可读取消息流状态(complete、确认序列 4,223,与本地 source generation 匹配)。远程最后确认时间仍约 25 小时前。生产全范围 wildcard 离线采集返回 460 个会话、426 条消息、complete、0 unavailable、`hasNewItems=false`;队列为空,没有产生新批次或新的远程 ACK。此前显式 `filehelper` 范围探针报告 1 条候选记录,但这不是全范围会话快照的新增项,也不能证明已上传。`contacts` 流状态仍为 `unknown`,未验证联系人快照上传。保持“新批次上传/确认未复验”,不要把本地探针结果或旧检查点标记为本轮远程同步完成。 +- 离线身份验证修复后的 Host SHA-256:`3C7A5628C37CA33AF96944A950E7B81002437010AE34AC9BC81461EFFE7373CA`;Tray SHA-256:`CFABF0BFB3AAB595DF240E75481F51A5DC96B8E9A6A0094A047F83B2BB35CC6D`。控制面更新二进制 SHA-256:`1cb05554056256f12d5ce5a8c6824095d7ca37dbbff282e4661d71b854e24b87`。`service.json` SHA-256 仍为 `5F4F40E040A7D69060CC09F1A390277286FA982D6EFBEF3D825DBCF29B6A56BB`,Windows 备份为 `backup-offline-sync-20260930-122015`。 +- Windows MCP 桌面窗口列表未发现 Weixin;Weixin 进程保持 0。新部署后由 SSH 发起的 doctor/inspect-ui/read-only smoke 未形成有效交互会话验收;没有启动客户端或执行写操作,UI 相关项继续 pending。 - 详细范围、哈希、证据及未闭环项见[正常功能复核与验收记录](validation/Normal-Functional-Acceptance-2026-09-29.md)。M6 仍保持未完成。 ## 已确定的功能边界 diff --git a/docs/validation/Normal-Functional-Acceptance-2026-09-29.md b/docs/validation/Normal-Functional-Acceptance-2026-09-29.md index 5ebb17b..18330ab 100644 --- a/docs/validation/Normal-Functional-Acceptance-2026-09-29.md +++ b/docs/validation/Normal-Functional-Acceptance-2026-09-29.md @@ -10,7 +10,7 @@ ## 代码与自动化检查 -- Core:188/188 测试通过。覆盖长文本 4,000 字符分段、组合字符边界、20,000 字符总上限、附件路径预校验、同名提及候选拒绝、重复 fingerprint 的新增出现计数,以及合并聊天图片/视频/文件/嵌套元数据。 +- Core:193/193 测试通过。覆盖长文本 4,000 字符分段、组合字符边界、20,000 字符总上限、附件路径预校验、同名提及候选拒绝、重复 fingerprint 的新增出现计数、合并聊天图片/视频/文件/嵌套元数据,以及离线账号身份/活动 UI 账号选择规则。 - Service:26/26 测试通过。新增验证长 `send-text` 可进入分段发送路径,同时 `broadcast-text` 仍保持单条消息上限。 - Control plane:`gofmt`、`go test ./...`、`go vet ./...` 通过。发送文本按最多 20,000 UTF-16 code units 校验,并拒绝无效控制字符。 - WebUI:7/7 测试通过;`npm run build` 成功。Vite 对大 bundle 的提示不是构建失败。 @@ -23,10 +23,24 @@ - `service.json` SHA-256 部署前后均为 `5F4F40E040A7D69060CC09F1A390277286FA982D6EFBEF3D825DBCF29B6A56BB`,文件未修改。回滚备份:`C:\Users\Rogee\wx-agent01\backup-functional-20260929-163843`。 - Tray 已在交互式 Session 1 重启。通过 Session 1 临时验证任务运行了项目要求的 `doctor`、`inspect-ui`、`smoke`:`inspect-ui` exit 0;`doctor` 和 `smoke` exit 2,错误码 `WechatNotLoggedIn`。`UserInteractive=true`、`InputDesktopAvailable=true`、`WindowFound=true`,但 `MainView`、`session_list`、`chat_message_page`、`chat_message_list`、`chat_input_field`、`tool_bar_accessible` 均未找到。 - 因缺少可用的微信聊天控件,本轮没有发送消息、文件或提及成员,也没有继续对登录界面进行交互。UI 树仅保存于 Windows 主机的 `artifacts/ui-tree.json`,未读取或附入本记录。 -- 另对批准的“消息测试专用群组”运行 Windows Host 只读 `db group-members` 查询:2 个成员、2 个非空显示名、0 个重复显示名、`hasMore=false`。脚本只输出并保存聚合计数,没有记录成员姓名或账号标识。 +- 在关闭 Weixin 进程的情况下,使用 Windows Host 只读 `db group-members` 查询批准的“消息测试专用群组”:返回 2 个成员、2 个非空且唯一显示名、`hasMore=false`。脚本仅输出聚合计数,没有记录成员姓名或账号标识。 - Windows Host CLI `chat send-files` 用两条随机不存在的路径做无发送预校验:重复 `--path` 解析成功,按预期返回 `InvalidArgument`(附件文件不存在);此分支在进入 UI 自动化前拒绝输入,没有发送文件。 - Windows 只读 schema 探针发现 `contact` 表有 22 列,`contact_label` 有 `label_id_`/`label_name_`/`sort_order_` 三列;未确认联系人与标签的关联。`alias`、`description`、`extra_buffer` 的业务语义也未验证;探针仅读 schema,没有读取联系人行值,不映射字段、不解析不透明 BLOB。 +### Weixin 关闭后的数据库与离线收集复核 + +- Weixin 进程在检查前后均为 0。Windows Host `db status` 成功,识别到 1 个账号及 20 个数据库(7 个消息库、1 个会话库);`db messages --chat filehelper --limit 20` 返回 20 条记录,命令输出未包含消息正文。只读联系人查询 `Hao 豪` 返回 1 条精确匹配;批准测试群成员读取见上方。 +- 临时、自包含 Windows x64 探针调用生产 `DatabaseMessageSyncCollector`,读取 `service.json` 中已授权的 `filehelper` 私聊范围,并从持久化检查点继续:序列 4,223、427 个会话游标、coverage `complete`、0 个 unavailable source。采集结果为 1 个会话、1 条新消息,`hasNewItems=true`、complete、正文仅以 `messageTextPresent=true` 布尔值确认。探针未输出消息正文或密钥;Weixin 进程前后均为 0,检查点文件和 `service.json` 的哈希均未改变。 +- **范围限制:**这证明关闭客户端时数据库读取及增量收集可运行,不证明控制面已收到数据。本次没有发起远程上传;已存检查点仍是序列 4,223/427 个游标,采集/确认时间约早 22.7 小时,待发送队列为空。因此不把本地采集结果表述为新一轮远程同步完成。 + +## 补充复核 — 2026-09-30 + +- 离线同步授权修复:Core/Windows 仅用持久化 binding 与 page-1 HMAC 已验证数据库身份确认只读同步账号;没有 UI 会话时 `ActiveAccountId` 为空,消息/管理写任务仍要求 live UI binding。控制面 `nodeHasAccount` 继续要求 `Active && Verified`,仅 sync-status 使用 `nodeHasVerifiedAccount`。新增 Go 集成测试确认 inactive + verified 账号可读 sync status,但仍不符合 UI 任务活动账号门禁。`go test ./...`、`go vet ./...` 通过。 +- 新的 Windows Host/Tray 自包含单文件构建已部署:Host SHA-256 `3C7A5628C37CA33AF96944A950E7B81002437010AE34AC9BC81461EFFE7373CA`;Tray SHA-256 `CFABF0BFB3AAB595DF240E75481F51A5DC96B8E9A6A0094A047F83B2BB35CC6D`。备份目录 `C:\Users\Rogee\wx-agent01\backup-offline-sync-20260930-122015`。控制面 E2E 进程以更新二进制重启,SHA-256 `1cb05554056256f12d5ce5a8c6824095d7ca37dbbff282e4661d71b854e24b87`,`/healthz` HTTP 200;原有控制面数据文件和凭据文件未替换。`service.json` 部署前后 SHA-256 一致:`5F4F40E040A7D69060CC09F1A390277286FA982D6EFBEF3D825DBCF29B6A56BB`。 +- 远端 node details 为 `WechatNotRunning`,一条本地 verified 账号已登记但无活动 UI;修复后消息 sync-status 返回 HTTP 200、`complete`、确认序列 4,223,generation 与本地相同。`contacts` stream 仍为 `unknown`,没有联系人快照 ACK。此前的 403 authorization-revoked 警告在控制面更新后记录一次授权恢复。 +- Weixin 关闭时完整 wildcard 生产 collector 返回 460 个会话、426 条消息,complete、0 unavailable、`hasNewItems=false`;本地消息检查点 4,223/427 游标,队列为空。显式 `filehelper` 探针曾返回 1 条候选消息,但全范围 collector 未将其判为新增;该结果不视为已排队、上传或 ACK。远端最后成功仍约 25 小时前,因此本轮没有可证明的新批次上传/确认。 +- Windows MCP 桌面窗口列表只有任务栏、dummyLayeredWnd 和 Program Manager,未发现 Weixin;进程保持 0。部署后的 doctor/inspect-ui/read-only smoke 通过 SSH 启动且未形成有效交互式 UI 验收;没有发送消息或执行 UI 写操作。长文本/附件/提及、管理、朋友圈等真机验收仍 blocked/pending,需用户在 Session 1 手动登录后进行受限验证。 + ## 项目待办状态(不得视为通过) | 项目 | 代码检查 | 真机/样本验收 | diff --git a/node-agent/WxAgent.Core/AccountBindingMatcher.cs b/node-agent/WxAgent.Core/AccountBindingMatcher.cs index 07ba4b6..899bf1b 100644 --- a/node-agent/WxAgent.Core/AccountBindingMatcher.cs +++ b/node-agent/WxAgent.Core/AccountBindingMatcher.cs @@ -29,5 +29,15 @@ public static class AccountBindingMatcher : new AccountMatch(null, nicknameMatches.Length == 0 ? "none" : "nickname", false); } + public static bool IsUniquelyMatchedToAccount( + string expectedAccountId, + UiAccountIdentity boundIdentity, + IReadOnlyList databaseAccounts) + { + if (string.IsNullOrWhiteSpace(expectedAccountId)) return false; + var match = Match(boundIdentity, databaseAccounts); + return match.IsMatched && string.Equals(match.AccountId, expectedAccountId, StringComparison.OrdinalIgnoreCase); + } + private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } diff --git a/node-agent/WxAgent.Core/RemoteAccountContext.cs b/node-agent/WxAgent.Core/RemoteAccountContext.cs index 0cd210a..169006d 100644 --- a/node-agent/WxAgent.Core/RemoteAccountContext.cs +++ b/node-agent/WxAgent.Core/RemoteAccountContext.cs @@ -17,6 +17,29 @@ public sealed class RemoteAccountContext get { lock (_gate) return _snapshot; } } + public static string? SelectActiveAccountId( + bool uiSessionAvailable, + string? preferredAccountId, + IReadOnlyCollection identities, + IReadOnlyCollection liveBoundAccountIds) + { + ArgumentNullException.ThrowIfNull(identities); + ArgumentNullException.ThrowIfNull(liveBoundAccountIds); + if (!uiSessionAvailable) return null; + + var liveBoundIds = liveBoundAccountIds.ToHashSet(StringComparer.OrdinalIgnoreCase); + var eligible = identities + .Where(identity => identity.Verified && liveBoundIds.Contains(identity.AccountId)) + .ToArray(); + if (!string.IsNullOrWhiteSpace(preferredAccountId)) + { + var preferred = eligible.FirstOrDefault(identity => + string.Equals(identity.AccountId, preferredAccountId, StringComparison.OrdinalIgnoreCase)); + if (preferred is not null) return preferred.AccountId; + } + return eligible.Length == 1 ? eligible[0].AccountId : null; + } + public RemoteAccountContextSnapshot SwitchTo( string accountId, IReadOnlyCollection identities, diff --git a/node-agent/WxAgent.Host/WindowsAgentBackend.cs b/node-agent/WxAgent.Host/WindowsAgentBackend.cs index 7693eea..22e1c0a 100644 --- a/node-agent/WxAgent.Host/WindowsAgentBackend.cs +++ b/node-agent/WxAgent.Host/WindowsAgentBackend.cs @@ -98,6 +98,10 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOpt if (refreshUiIdentity) await AutoBindMatchesAsync(accounts, targets, cancellationToken); var current = bindings.ReadAll(); + var databaseIdentities = accounts + .Where(account => account.Identity is not null) + .Select(account => account.Identity!) + .ToArray(); var singleBoundTarget = !refreshUiIdentity && current.Count == 1 && targets.Count == 1; var live = current.Where(binding => targets.Any(target => (target.ProcessId == binding.ProcessId && target.WindowHandle == binding.WindowHandle && @@ -110,8 +114,17 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOpt { var binding = bindings.Get(account.AccountId); var isLive = binding is not null && live.Contains(binding.AccountId); + var isVerifiedForReadOnlySync = binding is not null && + string.Equals(binding.AccountId, account.AccountId, StringComparison.OrdinalIgnoreCase) && + AccountBindingMatcher.IsUniquelyMatchedToAccount( + account.AccountId, + new UiAccountIdentity(binding.WechatId, binding.Nickname), + databaseIdentities); return new AccountInfo(account.AccountId, account.Identity?.Nickname, account.Identity?.WechatId, null, - account.AccountId, isLive, binding, binding is null ? "Unbound" : isLive ? "Bound" : "Stale"); + account.AccountId, isLive, binding, binding is null ? "Unbound" : isLive ? "Bound" : "Stale") + { + IsVerifiedForReadOnlySync = isVerifiedForReadOnlySync + }; }).ToArray(); } diff --git a/node-agent/WxAgent.Service/ReadOnlyContracts.cs b/node-agent/WxAgent.Service/ReadOnlyContracts.cs index 61411d9..75914d3 100644 --- a/node-agent/WxAgent.Service/ReadOnlyContracts.cs +++ b/node-agent/WxAgent.Service/ReadOnlyContracts.cs @@ -20,7 +20,10 @@ public sealed record Page(IReadOnlyList Items, int Limit, int Offset, bool { public ReadCoverage? Coverage { get; init; } } -public sealed record AccountInfo(string AccountId, string? DisplayName, string? WechatId, string? Region, string DataFingerprint, bool IsUiBindingKnown, AccountBinding? Binding = null, string BindingStatus = "Unbound"); +public sealed record AccountInfo(string AccountId, string? DisplayName, string? WechatId, string? Region, string DataFingerprint, bool IsUiBindingKnown, AccountBinding? Binding = null, string BindingStatus = "Unbound") +{ + public bool IsVerifiedForReadOnlySync { get; init; } +} public sealed record SessionInfo(string Name, string AutomationId, bool IsCurrent); public sealed record MessageInfo(string Fingerprint, string Type, string? Sender, string? Summary, string? Content); public sealed record ListRequest(int Limit = 50, int Offset = 0, bool IncludeContent = false, string? AccountId = null, string? Session = null); diff --git a/node-agent/WxAgent.Service/RemoteAgentHostedService.cs b/node-agent/WxAgent.Service/RemoteAgentHostedService.cs index ace1e69..9e911c6 100644 --- a/node-agent/WxAgent.Service/RemoteAgentHostedService.cs +++ b/node-agent/WxAgent.Service/RemoteAgentHostedService.cs @@ -880,23 +880,18 @@ public sealed class RemoteAgentHostedService( var wechatRunning = GetBoolean(element, "wechatAvailable"); var sessionAvailable = GetBoolean(element, "sessionAvailable"); var sessionLocked = GetBoolean(element, "sessionLocked"); - // Heartbeats must not refresh UI identity. Binding already contains the verified identity; - // the explicit accounts API remains the only path that may inspect the profile. + // A persisted binding plus a key-verified database identity may authorize read-only sync. + // UI tasks still require a live, available WeChat session and a live window binding. var accounts = await backend.AccountsAsync(cancellationToken, refreshUiIdentity: false); var identities = accounts.Select(account => new RemoteAccountIdentity( - account.AccountId, HasLiveWeChatBinding(account))).ToArray(); - var activeAccountId = remote.ActiveAccountId; - var boundAccounts = identities - .Where(identity => identity.Verified) - .Select(identity => identity.AccountId) - .Distinct(StringComparer.OrdinalIgnoreCase) + account.AccountId, HasLiveWeChatBinding(account) || account.IsVerifiedForReadOnlySync)).ToArray(); + var liveBoundAccountIds = accounts + .Where(HasLiveWeChatBinding) + .Select(account => account.AccountId) .ToArray(); - if ((string.IsNullOrWhiteSpace(activeAccountId) || !identities.Any(identity => identity.Verified && string.Equals(identity.AccountId, activeAccountId, StringComparison.Ordinal))) - && boundAccounts.Length == 1) - { - // A single verified binding is safe to use when the optional GUI value is empty or a display name. - activeAccountId = boundAccounts[0]; - } + var hasLiveUiSession = wechatRunning && sessionAvailable && !sessionLocked; + var activeAccountId = RemoteAccountContext.SelectActiveAccountId( + hasLiveUiSession, remote.ActiveAccountId, identities, liveBoundAccountIds); var activeAccountVerified = false; if (activeAccountId is { Length: > 0 }) { @@ -904,9 +899,15 @@ public sealed class RemoteAgentHostedService( { accountContext.SwitchTo(activeAccountId, identities); activeAccountVerified = accountContext.IsConfirmedFor(activeAccountId); + if (!activeAccountVerified) + { + activeAccountId = null; + accountContext.Invalidate(); + } } catch (WxAgentException) { + activeAccountId = null; accountContext.Invalidate(); } } @@ -923,7 +924,7 @@ public sealed class RemoteAgentHostedService( } catch { - return new BackendSnapshot(RemoteNodeStatus.Degraded, false, false, false, remote.ActiveAccountId, 0, false, []); + return new BackendSnapshot(RemoteNodeStatus.Degraded, false, false, false, null, 0, false, []); } } diff --git a/tests/node-agent/WxAgent.Core.Tests/AccountBindingMatcherOfflineSyncTests.cs b/tests/node-agent/WxAgent.Core.Tests/AccountBindingMatcherOfflineSyncTests.cs new file mode 100644 index 0000000..7b3cd22 --- /dev/null +++ b/tests/node-agent/WxAgent.Core.Tests/AccountBindingMatcherOfflineSyncTests.cs @@ -0,0 +1,36 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class AccountBindingMatcherOfflineSyncTests +{ + [Fact] + public void PersistedBindingMustResolveToTheExpectedDatabaseAccount() + { + var databaseAccounts = new[] + { + new DatabaseAccountIdentity("root-a", "wxid-a", "Alice"), + new DatabaseAccountIdentity("root-b", "wxid-b", "Bob") + }; + + Assert.True(AccountBindingMatcher.IsUniquelyMatchedToAccount( + "root-a", new UiAccountIdentity("wxid-a", "Alice"), databaseAccounts)); + Assert.False(AccountBindingMatcher.IsUniquelyMatchedToAccount( + "root-b", new UiAccountIdentity("wxid-a", "Alice"), databaseAccounts)); + } + + [Fact] + public void AmbiguousOrUnavailableDatabaseIdentityFailsClosed() + { + var ambiguousAccounts = new[] + { + new DatabaseAccountIdentity("root-a", null, "Shared"), + new DatabaseAccountIdentity("root-b", null, "Shared") + }; + var boundIdentity = new UiAccountIdentity(null, "Shared"); + + Assert.False(AccountBindingMatcher.IsUniquelyMatchedToAccount("root-a", boundIdentity, ambiguousAccounts)); + Assert.False(AccountBindingMatcher.IsUniquelyMatchedToAccount("root-a", boundIdentity, [])); + } +} diff --git a/tests/node-agent/WxAgent.Core.Tests/RemoteAccountContextTests.cs b/tests/node-agent/WxAgent.Core.Tests/RemoteAccountContextTests.cs new file mode 100644 index 0000000..7150998 --- /dev/null +++ b/tests/node-agent/WxAgent.Core.Tests/RemoteAccountContextTests.cs @@ -0,0 +1,43 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class RemoteAccountContextOfflineSyncTests +{ + [Fact] + public void OfflineSessionNeverSelectsAnActiveUiAccount() + { + var identities = new[] { new RemoteAccountIdentity("root-a", true) }; + + Assert.Null(RemoteAccountContext.SelectActiveAccountId( + false, "root-a", identities, ["root-a"])); + } + + [Fact] + public void ActiveSelectionRequiresLiveAndVerifiedBinding() + { + var identities = new[] + { + new RemoteAccountIdentity("offline", true), + new RemoteAccountIdentity("unverified", false), + new RemoteAccountIdentity("live", true) + }; + + Assert.Equal("live", RemoteAccountContext.SelectActiveAccountId( + true, "offline", identities, ["unverified", "live"])); + } + + [Fact] + public void MultipleLiveBindingsRequireAnExplicitAccount() + { + var identities = new[] + { + new RemoteAccountIdentity("root-a", true), + new RemoteAccountIdentity("root-b", true) + }; + + Assert.Null(RemoteAccountContext.SelectActiveAccountId(true, null, identities, ["root-a", "root-b"])); + Assert.Equal("root-b", RemoteAccountContext.SelectActiveAccountId(true, "root-b", identities, ["root-a", "root-b"])); + } +}