两份审查报告(`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` 仍在,所以**不是越权**;
但非管理员会看到完整用户列表、建号表单、改密入口 ——
属于客户端信息泄露 + 无意义的失败请求风暴。
130 lines
4.8 KiB
TypeScript
130 lines
4.8 KiB
TypeScript
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');
|
||
});
|
||
});
|