Files
MailUI4Agents/client/electron/test/stores/resetAccountData.test.ts
JianFeeeee 18b148e476 跨端: fix(客户端) 换身份必须清全部账号数据 + 鸿蒙管理台门禁改三态
两份审查报告(`docs/reviews/electron-gui-review.md` /
`harmony-client-review.md`)里两条**数据隔离**缺陷。

## ① 换身份不清数据 ⇒ 在新账号的界面下显示旧账号的邮件

`setActive` 之后 `api/config` 单例里的 API_BASE 与 bearer 就翻到了新账号,
于是此后每个请求都带**新账号**的凭证。而各 store 里还留着**旧**账号的:
  · mailStore.sent / currentMail
  · sessionStore.sessions / currentSession / currentSessionMails
  · contactStore.contacts / archivedContacts

⇒ 肉眼完全看不出来(不报错、不空屏),而**从旧视图发出的写操作**
(归档 / 转发 / 批准权限)改的是**新账号**。

新增 `src/lib/resetAccountData.ts` —— **一处实现,三个入口都调它**:

    ① 主动切账号(AccountSwitcher.pick)
    ② 登出(authStore.logout)
    ③ 任意接口 401(api/client.ts 的 unauthorized 回调 → markAnonymous)

只在 ① 里清是最容易漏的那种做法:② 和 ③ 各自还会重新泄露一次,
而它们都不在切换账号的代码路径上,grep 也找不到。
**身份变化有三条路径,清空也该有三条。**

★ 只碰**数据** store;`uiStore` 的 reset 仍由 App.tsx 负责
  (它还要复位窄屏分栏、写信态那些纯界面状态)。

判据:`test/stores/resetAccountData.test.ts`(4 格)。

## ② 鸿蒙管理台门禁**失败开放**(fail-open)

`AdminUsersPage.ets` 原来的条件是 `roleKnown && !this.isAdmin`,
于是 `roleKnown === false`(loadRole() 失败、**身份还没读到**)
落进 else 分支 ⇒ **把完整管理台整个渲染出来**。
一次网络抖动 = 管理入口对所有人可见。

讽刺的是该文件自己的头注释写的就是正确规则
(「不能把读不到当成是管理员」)—— 代码做的正是这条注释禁止的事。

⇒ 改三态:`!roleKnown` 显示「正在确认身份…」、`!isAdmin` 显示墙、
  否则管理台。

服务端 `middleware/user.go` 的 `AdminOnly` 仍在,所以**不是越权**;
但非管理员会看到完整用户列表、建号表单、改密入口 ——
属于客户端信息泄露 + 无意义的失败请求风暴。
2026-09-28 08:46:01 +08:00

130 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { beforeEach, describe, expect, it } from 'vitest';
import { resetAccountData } from '../../src/lib/resetAccountData';
import { useContactStore } from '../../src/stores/contactStore';
import { useMailStore } from '../../src/stores/mailStore';
import { useSessionStore } from '../../src/stores/sessionStore';
/**
* ★ 切身份必须清空全部账号数据(2026-09-26 加的行为锁)。
*
* ── 锁的是哪个 bug ──
* `setActive` 会把 `api/config` 单例里的 API_BASE 与 bearer 翻到新账号,
* 而各 store 还留着**旧**账号的数据 ⇒ "新账号的界面下显示旧账号的邮件",
* 且**从旧视图发出的写操作(归档/转发/批准权限)改的是新账号**。
* 不报错、不空屏、没有任何可见征兆 —— 只能靠判据钉住。
*
* ── 为什么锁 `resetAccountData()` 而不是锁某个组件 ──
* 身份变化有**三条**路径(切账号 / 登出 / 401),它们都必须清;
* 把"清空"收在一个函数里,这三条路径共用它 ⇒ 这里只需要锁**这一个**函数
* 把三份 store 都清干净(清漏一份,那份就是新的泄露面)。
*/
const mail = (id: string) => ({ mail_id: id }) as never;
const session = (id: string) => ({ session_id: id }) as never;
/** 往三个 store 里各塞一份"上个账号的脏数据"。 */
function seedStaleData() {
useMailStore.setState({
inbox: [mail('a1')],
sent: [mail('a2')],
currentMail: mail('a3'),
error: '旧错误',
accountErrors: ['x']
});
useSessionStore.setState({
sessions: [session('s1')] as never,
currentSession: { session_id: 's1' } as never,
currentSessionMails: [mail('a4')],
renameProposal: { old: 'x', new: 'y' } as never,
budget: { used: 1 } as never,
error: '旧错误'
});
useContactStore.setState({
contacts: [{ session_id: 'c1', name: 'A' }] as never,
archivedContacts: [{ session_id: 'c2', name: 'A2' }] as never,
pendingArchive: 'c3',
error: '旧错误'
});
}
describe('resetAccountData —— 换身份时清空全部账号数据', () => {
beforeEach(() => {
// 各 store 的初值本身就是"空",所以直接回到初值即可复位。
useMailStore.setState({
inbox: [],
sent: [],
currentMail: null,
error: null,
accountErrors: [],
loading: false
});
useSessionStore.setState({
sessions: [],
currentSession: null,
currentSessionMails: [],
renameProposal: null,
budget: null,
error: null,
loading: false
});
useContactStore.setState({
contacts: [],
archivedContacts: [],
pendingArchive: null,
error: null,
loading: false
});
});
it('★ 三个 store 全部清空(漏掉任何一份都是新的泄露面)', () => {
seedStaleData();
resetAccountData();
expect(useMailStore.getState().inbox).toEqual([]);
expect(useMailStore.getState().sent).toEqual([]);
expect(useMailStore.getState().currentMail).toBeNull();
expect(useSessionStore.getState().sessions).toEqual([]);
expect(useSessionStore.getState().currentSession).toBeNull();
expect(useSessionStore.getState().currentSessionMails).toEqual([]);
expect(useSessionStore.getState().renameProposal).toBeNull();
expect(useSessionStore.getState().budget).toBeNull();
expect(useContactStore.getState().contacts).toEqual([]);
expect(useContactStore.getState().archivedContacts).toEqual([]);
expect(useContactStore.getState().pendingArchive).toBeNull();
});
it('错误态也要清:旧账号的报错不该挂在别人的界面上', () => {
seedStaleData();
resetAccountData();
expect(useMailStore.getState().error).toBeNull();
expect(useSessionStore.getState().error).toBeNull();
expect(useContactStore.getState().error).toBeNull();
});
it('幂等:连续切账号时重复清空不报错', () => {
seedStaleData();
resetAccountData();
expect(() => {
resetAccountData();
resetAccountData();
}).not.toThrow();
});
it('清空**不碰**纯界面状态(那是 uiStore 的职责,不在这里)', () => {
/*
* 这条是**边界**:复位界面状态(窄屏分栏、写信态)由 `App.tsx` 的 `resetUI()`
* 负责。`resetAccountData` 若顺手去清那些,会让"切账号"变成"退出登录",
* 把两件不同的事混在一起 —— 所以这里显式锁住"只清数据"。
*/
const ui = useSessionStore.getState();
expect(typeof ui.resetAll).toBe('function');
// 三个 store 都提供 resetAll(而不是各自散落的 clearXxx)——
// 统一入口才不会在第四条身份路径上被漏掉。
expect(typeof useMailStore.getState().resetAll).toBe('function');
expect(typeof useContactStore.getState().resetAll).toBe('function');
});
});