跨端: 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:
@ -181,7 +181,9 @@ test('A|系统拥有的维度,鸿蒙侧的唯一来源是系统资源(不
|
||||
*/
|
||||
const sysOwned = {
|
||||
pageBg: 'color', surface: 'color', surfaceMuted: 'color', border: 'color',
|
||||
textPrimary: 'color', textMuted: 'color', textSubtle: 'color',
|
||||
// textSubtle 已移除:系统三级色浅色下 2.85:1,且本机二级色同值
|
||||
// (见 SELF_OWNED_COLORS 里 textSubtleLight/Dark 那段),小字走 textSubtleFor()。
|
||||
textPrimary: 'color', textMuted: 'color',
|
||||
overlay: 'color', navFg: 'color',
|
||||
radiusCard: 'float', radiusControl: 'float'
|
||||
};
|
||||
@ -297,6 +299,28 @@ const SELF_OWNED_COLORS = [
|
||||
'chipNeutralBgDark', 'chipNeutralFgDark', 'chipSpentFgDark', 'chipWarnFgDark',
|
||||
// 权限档位与预算档位的胶囊配色(档位是产品语义,系统不认识"plan/workspace/full")
|
||||
'chipNeutralBg', 'chipNeutralFg', 'chipSpentBg', 'chipSpentFg', 'chipWarnBg', 'chipWarnFg',
|
||||
/*
|
||||
* ★★ 2026-09-26 补登记:小字那一档(`textSubtleLight` / `textSubtleDark`)。
|
||||
*
|
||||
* 为什么必须是**自己写**的色,而不是 `$r('sys.color.ohos_id_color_text_*')`:
|
||||
*
|
||||
* 2026-09-19 这条设备判据已经抓到过「浅色三级色 = rgb(153,153,153) 压白底
|
||||
* = 2.85:1」,当时的修法是让 `textSubtleFor()` 两个主题都返回 `textMuted`
|
||||
* (`ohos_id_color_text_secondary`),理由是"二级色语义对得上"。
|
||||
*
|
||||
* ★★ 但 2026-09-26 复扫证明**那个前提不成立**:本机 HarmonyOS 6.1.1 上
|
||||
* `ohos_id_color_text_secondary` 实测**也是 rgb(153,153,153)**,与三级色同值
|
||||
* ⇒ 换过去之后**一点没变**,判据继续红。
|
||||
*
|
||||
* 系统二级/三级色只保证「比一级淡」这层**层次关系**,不保证 WCAG 下限;
|
||||
* 同一个坑在本文件已经犯过三次(`surface` 当前景色、accent 深色没调亮、
|
||||
* 这一次)。而 WebUI 那边 gray-400/gray-500 是**两套主题各自适配过**的值
|
||||
* (浅 4.83:1 / 深 5.51:1),不是同一个色翻个主题。
|
||||
*
|
||||
* ⇒ 这一档的"跨端身份"就是 WebUI 的 gray-400,所以按 gray-400 逐字对齐,
|
||||
* 并给出两个主题各自实测 ≥3:1 的值。
|
||||
*/
|
||||
'textSubtleLight', 'textSubtleDark',
|
||||
/*
|
||||
* ★★ 2026-09-19 补登记(对应用户「底栏数字为什么显示在图标下面?」与
|
||||
* 用户「你写的app和webui大面积不符」那两轮改动)。
|
||||
|
||||
@ -522,8 +522,18 @@ test('★ 设备:真实壁纸在设备上的尺寸/体积与压缩决策的输
|
||||
}
|
||||
|
||||
/* 素材在设备上吗?不在就显式跳过(不假装通过) */
|
||||
const listed = (D.shellOn(hdc, 'ls -la /data/local/tmp/real-wallpaper.jpg').stdout || '').trim();
|
||||
if (!listed.includes('real-wallpaper.jpg')) {
|
||||
const probe = D.shellOn(hdc, 'ls -la /data/local/tmp/real-wallpaper.jpg');
|
||||
const listed = (probe.stdout || '').trim();
|
||||
/*
|
||||
* ★ 判"在不在"必须看**退出码**,不能只看输出里有没有那个文件名。
|
||||
*
|
||||
* `hdc shell` 会把 stderr 并进 stdout,于是文件不存在时 stdout 是
|
||||
* `ls: /data/local/tmp/real-wallpaper.jpg: No such file or directory` ——
|
||||
* **里面仍然含有文件名**,于是原来的 `listed.includes('real-wallpaper.jpg')`
|
||||
* 判成"在",本该跳过的判据继续往下跑,撞在 `/bin/file` 上**报错**。
|
||||
* 设备上没有素材是正常状态(没传过壁纸的设备),不该让整条判据变红。
|
||||
*/
|
||||
if (probe.status !== 0 || !/^-.*\breal-wallpaper\.jpg$/m.test(listed)) {
|
||||
return t.skip('设备上没有真实素材 /data/local/tmp/real-wallpaper.jpg —— 本次不跑');
|
||||
}
|
||||
|
||||
|
||||
@ -276,22 +276,34 @@ export function foregroundBundle(hdc) {
|
||||
/**
|
||||
* 拉一份当前 UI 树(`uitest dumpLayout` → 设备上生成 JSON → `cat` 回来 → 解析)。
|
||||
*
|
||||
* dumpLayout 的输出形如 `DumpLayout saved to:/data/local/tmp/layout_<ts>.json`,
|
||||
* 路径是确定的(不是"猜最新的文件"——那样会和别的会话的 dump 抢)。拿到路径
|
||||
* 后 cat 回来 JSON.parse。根是 `{ attributes, children }`,children 递归同形。
|
||||
* ★★ 2026-09-26:**必须显式给输出路径**。
|
||||
*
|
||||
* 原来跑的是裸 `uitest dumpLayout`(不带 `-p`),期望它自己在 stdout 打一行
|
||||
* `DumpLayout saved to:…`。在 HarmonyPhone(HarmonyOS 6.1.1)上实测:
|
||||
* 裸调用 → `DumpLayout failed:Wait for subscribe uitest.broadcast.command.reply timeout`
|
||||
* (非 0 退出、stdout 空 ⇒ 这条判据直接**报错**而不是跳过)
|
||||
* 加 `-p` → `DumpLayout saved to:/data/local/tmp/d.json`(正常)
|
||||
* ⇒ 裸调用依赖的是**自动选名**那条路径,它要等 broadcast 回复;
|
||||
* 给了确定路径就不用等。
|
||||
*
|
||||
* 这不是"设备不在"那种环境缺失,而是**判据自己跑不成** —— 所以必须修,
|
||||
* 否则日历手势那类判据在真机上会一直红。
|
||||
*
|
||||
* 路径自己定 ⇒ 不再从 stdout 反解,也不存在"和别的会话的 dump 抢文件"。
|
||||
*
|
||||
* 抛异常(不返回 null):调用方该知道"dump 跑不成"和"dump 出来是空的"是两回事
|
||||
* —— 前者是环境问题,后者才是断言该管的。判据的 broken 分类靠这个区分。
|
||||
*/
|
||||
export function dumpLayout(hdc) {
|
||||
if (!hdc) throw new Error('hdc 没找到(findHdc() 返回 null)');
|
||||
const r = sh(hdc, ['shell', 'uitest', 'dumpLayout'], 30000);
|
||||
// 每次换一个名字:同一路径反复写会让并发跑的两个判据互相读到对方的中间态。
|
||||
const path = `/data/local/tmp/dsh_dump_${process.pid}_${Date.now()}.json`;
|
||||
const r = sh(hdc, ['shell', 'uitest', 'dumpLayout', '-p', path], 45000);
|
||||
if (r.status !== 0) throw new Error(`dumpLayout 失败:${(r.stderr || r.stdout || '').trim()}`);
|
||||
const m = (r.stdout || '').match(/saved to:(\S+)/);
|
||||
if (!m) throw new Error(`dumpLayout 没返回文件路径:${(r.stdout || '').trim()}`);
|
||||
const path = m[1].trim();
|
||||
const cat = sh(hdc, ['shell', 'cat', path], 20000);
|
||||
if (cat.status !== 0) throw new Error(`cat ${path} 失败:${(cat.stderr || '').trim()}`);
|
||||
// 设备上的临时文件不留(几十次判据跑下来 /data/local/tmp 会攒一堆)。
|
||||
sh(hdc, ['shell', 'rm', '-f', path], 10000);
|
||||
return JSON.parse(cat.stdout);
|
||||
}
|
||||
|
||||
|
||||
129
client/electron/test/stores/resetAccountData.test.ts
Normal file
129
client/electron/test/stores/resetAccountData.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user