diff --git a/client/electron/test/harmony-push.test.mjs b/client/electron/test/harmony-push.test.mjs index ca1545f..0ad3381 100644 --- a/client/electron/test/harmony-push.test.mjs +++ b/client/electron/test/harmony-push.test.mjs @@ -71,3 +71,57 @@ test('★ 契约层必须保持"无 @ohos 依赖"(否则这些判据跑不了 '**正确修法**:把平台调用留在 PushService.ets,纯决策留在这里(与 Calendar/Wallpaper 同模式)。' + '**最常见的错误修法**:把这条断言删掉,让契约层的判据跟着一起失效。'); }); + +/* +★ 线上形状(pi 2026-09-14,从 handler/push.go 读的):请求体字段名必须逐字一致 +(**未知字段直接 400**,不是静默忽略),provider 只做形状校验、**没有白名单**, +错误是 `{"error"}` 不是 `{"message"}`。这些都能在纯逻辑侧判。 +*/ +test('★ 请求体只放已知键(未知字段服务端直接 400,拼错会立刻可见)', () => { + const b = C.buildTokenBody('hms', 'tok123', '我的手机', 's1'); + assert.deepEqual(Object.keys(b).sort(), ['device_name', 'provider', 'session_id', 'token']); + for (const k of Object.keys(b)) assert.ok(C.PUSH_BODY_KEYS.includes(k), `不许出现未登记的键:${k}`); + const minimal = C.buildTokenBody('hms', 'tok123', '', ''); + assert.deepEqual(Object.keys(minimal).sort(), ['provider', 'token'], '可选字段为空就不放(空串虽合法,但不放更不容易踩校验)'); +}); + +test('★ provider 只做形状校验、没有白名单 —— 不许硬编码"只有 hms 合法"', () => { + assert.equal(C.isValidProvider('hms'), true); + assert.equal(C.isValidProvider('apns'), true, '服务端没实现的通道也不该变成客户端的 400'); + assert.equal(C.isValidProvider('fcm'), true); + assert.equal(C.isValidProvider('HMS'), false, '大写非法(形状规则是小写字母/数字/下划线/连字符)'); + assert.equal(C.isValidProvider(''), false, '空非法'); + assert.equal(C.isValidProvider('a'.repeat(33)), false, '最长 32'); + assert.equal(C.isValidProvider('a'.repeat(32)), true); + assert.equal(C.isValidProvider('has space'), false); +}); + +test('★ token 形状:空非法、512 上限;形状不合法就不去打注定 400 的请求', () => { + assert.equal(C.isValidToken(''), false); + assert.equal(C.isValidToken('x'.repeat(512)), true); + assert.equal(C.isValidToken('x'.repeat(513)), false); + assert.equal(C.buildTokenBody('hms', '', '', ''), undefined, '空 token ⇒ 组不出请求体(调用侧静默跳过)'); + assert.equal(C.buildTokenBody('HMS', 'tok', '', ''), undefined, 'provider 形状非法 ⇒ 同样组不出'); +}); + +test('★ 错误体是 {"error"} 不是 {"message"}(形状不对就不当错误消息用)', () => { + assert.equal(C.parseErrorBody('{"error":"token 非法"}'), 'token 非法'); + assert.equal(C.parseErrorBody('{"message":"x"}'), undefined, '{"message"} 是另一种形状 —— 不能当错误消息'); + assert.equal(C.parseErrorBody('not json'), undefined); + assert.equal(C.parseErrorBody(''), undefined); + assert.equal(C.parseErrorBody('{"error":""}'), undefined, '空消息等于没有消息'); +}); + +test('★ 注销:deleted:false(本来没登记)不是失败;deleted 不参与分类', () => { + assert.equal(C.classifyUnregister(false, false), 'ok-disabled', '没配凭证 + 没登记过 ⇒ 正常态'); + assert.equal(C.classifyUnregister(true, false), 'ok-enabled'); + assert.equal(C.classifyUnregister(false, true), 'silent-skip', '真失败才归 silent-skip'); + assert.equal(C.classifyUnregister.length, 2, 'deleted 不影响结果 ⇒ 它不该是入参(否则读代码的人会以为它影响结果)'); +}); + +test('★ DELETE 也带 JSON body(不是 query / 不是 path 参数)—— ApiClient 必须支持', () => { + const api = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'api', 'ApiClient.ets')); + assert.match(api, /async del\(path: string, bodyObj\?: Object\)/, + 'DELETE 端点要 JSON body,而`del`只有 path ⇒ 推送的注销调用会 400/无效。' + + '**正确修法**:给 del 加可选 body(向后兼容)。**最常见的错误修法**:把 token 拼进 URL(那不是线上形状)。'); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index bf009d8..76dcc76 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -79,7 +79,7 @@ const SUITE = [ ['test/build-stamp.test.mjs', [], 7], ['test/packaging.test.mjs', [], 5], ['test/align-refs.test.mjs', [], 3], - ['test/harmony-push.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6], + ['test/harmony-push.test.mjs', ['--experimental-strip-types', '--no-warnings'], 12], ['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 10], ['test/debt-visibility.test.mjs', [], 1], ['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2], @@ -270,6 +270,8 @@ for (const [file, flags, expected] of SUITE) { const marker = /RESULT pass=(\d+) fail=(\d+)/.exec(out); const nodeTest = /^# pass (\d+)/m.exec(out); const ran = marker ? Number(marker[1]) : (nodeTest ? Number(nodeTest[1]) : null); + // 累计总数(见汇总末尾的 `RESULT files=…`)—— 解析成功就加,无论该文件红绿 + if (ran !== null) totalTests += ran; if (ran === null) { /* * 报错**自带修法**(pi 2026-09-14):这条契约的受众不只是读过规范的人 —— diff --git a/client/harmony/entry/src/main/ets/api/ApiClient.ets b/client/harmony/entry/src/main/ets/api/ApiClient.ets index 2eead02..d92b1b6 100644 --- a/client/harmony/entry/src/main/ets/api/ApiClient.ets +++ b/client/harmony/entry/src/main/ets/api/ApiClient.ets @@ -242,11 +242,18 @@ export class ApiClient { return this.request(opts); } - /** DELETE 便捷 */ - async del(path: string): Promise { + /** + * DELETE 便捷。**带可选 JSON body** —— 服务端 `/me/devices/push-token` 的 DELETE + * 就是"JSON body"形状(不是 query、不是 path 参数,pi 2026-09-14 从 handler 读的形状)。 + * 不传 body 时与旧行为完全一致(向后兼容)。 + */ + async del(path: string, bodyObj?: Object): Promise { const opts = new RequestOptions(); opts.method = 'DELETE'; opts.path = path; + if (bodyObj !== undefined) { + opts.body = JSON.stringify(bodyObj); + } return this.request(opts); } diff --git a/client/harmony/entry/src/main/ets/model/PushContract.ts b/client/harmony/entry/src/main/ets/model/PushContract.ts index ae412ec..04520c5 100644 --- a/client/harmony/entry/src/main/ets/model/PushContract.ts +++ b/client/harmony/entry/src/main/ets/model/PushContract.ts @@ -149,3 +149,92 @@ export class NotificationLedger { return this.seen.length; } } + +// ───────────────────────────────────────────────────────────────────────────── +// 线上形状(pi 2026-09-14,从 handler/push.go + cmd/server/main.go 读的,不是猜的) +// +// 三条端点(POST / DELETE / GET)**都挂在 `/me/*` 组下**,同一套 Bearer 鉴权; +// **DELETE 也是 JSON body**(不是 query、不是 path 参数); +// 错误一律 `{"error":"…"}`(不是 `{"message":…}`); +// **严格 JSON:未知字段直接 400**(不是静默忽略)。 +// ───────────────────────────────────────────────────────────────────────────── + +/** 三个端点共用的请求体(`GET` 不用)。字段名**必须逐字一致** —— 拼错会 400。 */ +export interface PushTokenBody { + provider: string; + token: string; + device_name?: string; + session_id?: string; +} + +/** 允许出现在请求体里的键(未知字段服务端直接 400) */ +export const PUSH_BODY_KEYS: string[] = ['provider', 'token', 'device_name', 'session_id']; + +/** + * provider 只做**形状**校验:`^[a-z0-9_-]{1,32}$`,**没有白名单**。 + * + * ⇒ 客户端**不许**硬编码"只有 `hms` 合法"去先拦一道:服务端没实现的通道 + * 也不该变成客户端的 400(`hms` 只是我要发的那个值,不是唯一合法的值)。 + * 这条正是"判据/校验的范围必须等于它真正知道的事"的又一落点。 + */ +export function isValidProvider(provider: string): boolean { + return new RegExp('^[a-z0-9_-]{1,32}$').test(provider); +} + +/** token:不能为空、最长 512 */ +export function isValidToken(token: string): boolean { + if (token.length === 0) { + return false; + } + return token.length <= 512; +} + +/** + * 组请求体:**只放已知键**,可选字段为空则**不放**(空串虽合法,但不放更不容易踩校验)。 + * provider/token 形状不合法时返回 undefined ⇒ 调用侧**静默跳过**,不必去打一次注定 400 的请求。 + */ +export function buildTokenBody(provider: string, token: string, + deviceName: string, sessionId: string): PushTokenBody | undefined { + if (!isValidProvider(provider) || !isValidToken(token)) { + return undefined; + } + const body: PushTokenBody = { provider: provider, token: token }; + if (deviceName.length > 0) { + body.device_name = deviceName; + } + if (sessionId.length > 0) { + body.session_id = sessionId; + } + return body; +} + +/** 错误体是 `{"error":"…"}` —— 不是 `{"message":…}`;解析不出就返回 undefined */ +export function parseErrorBody(raw: string): string | undefined { + if (raw.length === 0) { + return undefined; + } + let msg: string | undefined = undefined; + try { + const obj: Record = JSON.parse(raw) as Record; + const v: string = obj.error; + if (v !== undefined && v.length > 0) { + msg = v; + } + } catch (e) { + return undefined; + } + return msg; +} + +/** + * 注销结果的分类:`ok-enabled` / `ok-disabled` / `silent-skip`。 + * + * **`deleted:false`(本来就没登记)不是失败** ⇒ 它**不改变分类**,所以它**不是这个函数的参数** —— + * 一个不影响结果的入参只会让人误以为它影响结果。(响应里仍带 `deleted`,供日志/调试用。) + */ +export function classifyUnregister(enabled: boolean, failed: boolean): string { + if (failed) { + return 'silent-skip'; + } + return enabled ? 'ok-enabled' : 'ok-disabled'; +}