跨端: 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` 仍在,所以**不是越权**;
但非管理员会看到完整用户列表、建号表单、改密入口 ——
属于客户端信息泄露 + 无意义的失败请求风暴。
This commit is contained in:
2026-09-28 08:27:01 +08:00
parent 044a664cc3
commit 18b148e476
28 changed files with 690 additions and 64 deletions

View File

@ -0,0 +1,129 @@
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');
});
});