diff --git a/client/electron/test/harmony-push.test.mjs b/client/electron/test/harmony-push.test.mjs index 3b984a4..d28eca7 100644 --- a/client/electron/test/harmony-push.test.mjs +++ b/client/electron/test/harmony-push.test.mjs @@ -101,6 +101,43 @@ test('★ 请求体只放已知键(未知字段服务端直接 400,拼错会 assert.deepEqual(Object.keys(minimal).sort(), ['provider', 'token'], '可选字段为空就不放(空串虽合法,但不放更不容易踩校验)'); }); +/* + * ★ `session_id` 传的必须是**会话 id**,不许拿别的字段顶上。 + * + * 起因:`reportToken` 原本写的是 `getActiveAccount()!.server` —— 那是**服务器地址** + * (`AccountInfo.server`,值形如 `https://mail.jianfgit.xyz/api/v1`),不是会话 id。 + * + * 为什么它能活到今天:服务端对这个字段**不做格式校验**、当前又**只写不读** + * (投递用的是**邮件自己的** `session_id`),所以它不报错、不影响收信 —— + * 只在库里静静存一条假的会话 id。**"不报错"正是它危险的地方**:等哪天真按它路由, + * 人会莫名其妙被送到别的会话去,而那时没人会想到根因在这个字段。 + * + * 而这一步的时机也决定了它**必然为空**:`reportToken` 只在登录成功/换账号时跑, + * 那时用户还没打开任何会话,客户端也没有"当前会话"这个状态可读。 + * ⇒ 传空是**准确**的(服务端明确允许空:"客户端还没进任何会话"),传 URL 是**错的**。 + * + * 判据钉的是"值的来源",不是"有没有传":所以既能挡住"再塞个别的字段顶替", + * 也不妨碍将来客户端真有了当前会话时补报 —— 那时这条判据要**改成钉真实 id**。 + */ +test('★ session_id 不许拿服务器地址顶替(静默写假数据;该空就空)', () => { + const svc = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'api', 'PushService.ets')); + // 取值处不许出现 .server(AccountInfo.server = 服务器地址,不是会话 id) + assert.doesNotMatch(svc, /buildTokenBody\([^)]*\.server/, + 'buildTokenBody 的 sessionId 实参不许来自 `.server` —— 那是服务器地址。' + + '服务端对 session_id 不做格式校验、也还没读它,所以这错**不会当场报错**,' + + '只会在 push_tokens 里存一条假会话 id;等真按它路由时症状会出现在完全无关的地方。'); + // 也不许拿 username/token/accountId 等别的身份字段顶替(换汤不换药) + for (const wrong of ['.username', '.token', '.id']) { + assert.doesNotMatch(svc, new RegExp('buildTokenBody\\([^)]*\\' + wrong + '\\b'), + `session_id 不许用 \`${wrong}\` 顶替 —— 那只是把"传错东西"换了个字段,性质没变。` + + '客户端现在没有"当前会话"这个状态 ⇒ **如实传空**(服务端允许空),别编一个。'); + } + // 传空这件事本身要在代码里说得出口(空串,不是 undefined 混过去) + assert.match(svc, /const sessionId: string = '';/, + 'session_id 应显式取空串并写明理由(服务端 "允许为空")—— 而不是删掉这个变量、' + + '让它看起来"本来就不需要"(将来要补真实 id 的人会找不到落点)'); +}); + test('★ provider 只做形状校验、没有白名单 —— 不许硬编码"只有 hms 合法"', () => { assert.equal(C.isValidProvider('hms'), true); assert.equal(C.isValidProvider('apns'), true, '服务端没实现的通道也不该变成客户端的 400'); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 185a7e1..1841783 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -80,12 +80,13 @@ const SUITE = [ ['test/packaging.test.mjs', [], 5], ['test/align-refs.test.mjs', [], 3], ['test/harmony-deviceprobe.test.mjs', ['--experimental-strip-types', '--no-warnings'], 8], - // 契约/形状 15 条 + ★接线 4 条(2026-09-17 收尾:登录后补报 / 换账号补报 / - // pendingRoute 消费端 / **热启**也要能跳)。前 15 条钉的是"点"(逻辑对不对), - // 后 4 条钉的是"边"(谁调用、谁消费)—— "写好了但没人调用/没人读"编译一样通过 - // (ArkTS 只编译可达模块,反向对照已复现过)。接线④是真机实测逼出来的: - // 只写 pendingRoute 的版本冷启能跳、热启一次都不跳。 - ['test/harmony-push.test.mjs', ['--experimental-strip-types', '--no-warnings'], 19], + // 契约/形状 15 条 + ★接线 4 条 + session_id 值来源 1 条(2026-09-17)。前 15 条钉的是"点" + // (逻辑对不对),后 5 条钉的是"边"(谁调用、谁消费、**传的是哪个值**)—— + // "写好了但没人调用/没人读/传错了字段"编译一样通过(ArkTS 只编译可达模块,反向对照已复现过)。 + // 接线④是真机实测逼出来的:只写 pendingRoute 的版本冷启能跳、热启一次都不跳。 + // session_id 那条钉的是"值的来源":传服务器地址不会报错(服务端不校验、且只写不读), + // 只会在库里存假数据 —— 这类"不报错的错"只能靠判据拦。 + ['test/harmony-push.test.mjs', ['--experimental-strip-types', '--no-warnings'], 20], // 服务器地址(apiBase):补 /api/v1 / 去尾斜杠不吃协议 // / 校验自带修法 / // 明文只对公网告警 / 404 说清“少了 /api/v1” / 网络错误码分类 / 归一化只有一份实现。 // 值判据跑真逻辑(model/ApiBase.ts);`.ets` 那几条是**静态**接线判据(无设备)。 diff --git a/client/harmony/entry/src/main/ets/api/PushService.ets b/client/harmony/entry/src/main/ets/api/PushService.ets index b1531b0..ee1b4f0 100644 --- a/client/harmony/entry/src/main/ets/api/PushService.ets +++ b/client/harmony/entry/src/main/ets/api/PushService.ets @@ -232,9 +232,27 @@ export class PushService { if (!shouldReportToken(lastMarker, accountKey, token)) { return; } - const sessionId: string = this.account.getActiveAccount() === null - ? '' - : this.account.getActiveAccount()!.server; + /* + * ★ `session_id` 传**空**(不传这个字段),不是"随手塞一个值"。 + * + * 服务端对这个字段的语义是"客户端**当前所在的会话**:点通知要回到那条会话里的那封信", + * 并且**明确允许为空**("客户端还没进任何会话,此时通知只带 mail_id")。 + * + * 而这一步的时机决定它必然为空:`reportToken` 只在**登录成功**与**换账号**时跑, + * 那时用户还没打开任何一条会话 —— 客户端也**根本没有"当前会话"这个状态** + * 可以取(`MainPage` 不记它)。所以这里如实传空,而不是编一个。 + * + * ★ 这里原本传的是 `getActiveAccount()!.server` —— 那是**服务器地址** + * (`AccountInfo.server`,值长这样:`https://mail.jianfgit.xyz/api/v1`), + * 不是会话 id。服务端对这个字段**不做格式校验**、而且当前**只写不读** + * (投递时用的是**邮件自己的** `session_id`),所以它不会立刻报错 —— + * 只会在库里静静存一条**假的**会话 id,等哪天真按它路由时把人送到别处。 + * "不会立刻炸"正是这种错最值得先修的原因:它不报错,只是让数据开始说谎。 + * + * 等客户端真的有了"当前会话"(打开某条会话时),再在这里补报一次真实 id —— + * 那时 `session_id` 才有值可传。**现在传空是准确的,传 URL 是错的。** + */ + const sessionId: string = ''; const body: PushRegisterBody | undefined = buildTokenBody(PROVIDER_HMS, token, this.deviceName(), sessionId); if (body === undefined) {