Files
MailUI4Agents/client/electron/test/harmony-apibase.test.mjs
JianFeeeee 9c6e9c66ad 跨端: 修复: 鸿蒙客户端"连不上服务器"—— 地址补 /api/v1 + 失败分类成人话(含网络白名单固化)
症状
  鸿蒙客户端(client/harmony)连不上服务端,界面只显示"无法连接",用户无法自助;
  而服务端 HTTPS 完全正常:https://mail.jianfgit.xyz/health → 200、
  /api/v1/auth/me → 401、/api/v1/events/stream → 401(Let's Encrypt *.jianfgit.xyz,
  TLS 校验通过;代理与直连 http://127.0.0.1:8180 行为一致)。

根因(两条,逐条核实过,其中一条**推翻了原判断**)
  ① apiBase 是"API 前缀本身"(ApiClient 里拼的是 '/auth/login' 这类相对路径),
     而 Ui 只做 trim+去尾斜杠:用户若只填 `https://mail.jianfgit.xyz/`,请求就打到
     `https://mail.jianfgit.xyz/auth/login` ⇒ 404,客户端再把它压成"无法连接"。
     **这是本次故障最可能的直接原因。**
  ② 默认值 `http://192.168.2.60:8180/api/v1` 是明文 + 写死内网 IP:手机不在同网段
     就永远不通(mail.jianfgit.xyz 解析到的就是这台内网机)。
     ★ 但"鸿蒙默认禁止明文 HTTP"这条**不成立**,已按本机离线官方文档核实:
     `devecocli docs read .../使用HTTP访问网络/http-request` 的《明文HTTP访问权限配置说明》
     写明 cleartextTrafficPermitted "默认为 true",Network Kit 默认允许明文;
     另有 FAQ《Stage模型如何配置支持http明文传输》:"无需配置,支持HTTP明文传输数据"。
     ⇒ network_config.json 按"显式固化意图"处理(照文档形状写对,但**不冒充**它是修复)。
     本机 SDK @ohos.net.http.d.ts 里确实有 2300997 Cleartext traffic not permitted(since 18),
     所以那条错误码在客户端被建成一条可读提示,而不是被忽略。

改法
  · 新增 model/ApiBase.ts(纯逻辑,无 @ohos,判据能用 node 直接跑):
    normalizeApiBase(去尾斜杠**但不咬协议 //**、末尾没有 /api/v1 就补、已有的一字不动)、
    validateApiBase(自带修法的中文提示 + "公网明文才告警、内网明文不误报")、
    describeFailure(404 自己写文案并点名 /api/v1;其它状态码让服务端文案说话;
    网络层按 2300006/2300007/2300028/2300997/2300998/2300058-60-77 分类成人话)。
  · Config.ets:DEFAULT_API_BASE → https://mail.jianfgit.xyz/api/v1。
  · ApiClient.ets:setBase/init **都**过 normalizeApiBase(唯一闸口 ⇒ 老装机里已经存下的
    坏地址在读回时就治好,光改默认值救不了它);ApiError 带 nativeCode;错误路径改走
    describeFailure;404 的提示指向"地址少了 /api/v1"。
  · LoginPage.ets:地址先校验后持久化(不合法**不落库**、给可执行提示),明文警告常驻渲染;
    SettingsPage.ets:添加账号同样校验(多账号库直接喂 SseService,坏地址会让该账号的实时
    通道永久连不上);AccountManager/SseService 落库与建连时各自再归一化一次。
  · 新增 resources/base/profile/network_config.json:按官方文档形状把内网明文
    (192.168.2.60 / 10.0.2.2 / localhost)显式列进 domain-config 白名单。
    文档给的就是这个**固定路径与文件名**,不需要在 module.json5 里写引用
    (仓库里既有的 HomeAgent 工程同样是这么放的)。
  · 新增判据 test/harmony-apibase.test.mjs(13 条)并接进 run-all.mjs 的 SUITE:
    值判据**直接跑** model/ApiBase.ts;.ets 那几条是**静态**接线判据(本机无设备)。

验证(都真跑过)
  · cd client/harmony && devecocli build clean && devecocli build
    → BUILD SUCCESSFUL in 7 s 186 ms;entry/build/default/outputs/default/entry-default-signed.hap
    存在(1214592 B,15:14)。
  · 解包 HAP:resources/base/profile/network_config.json 在包里、JSON 可解析、
    cleartextTrafficPermitted=true 且白名单含 192.168.2.60/10.0.2.2/localhost;
    bundleName 仍是 com.jianf.agentmail,module.json 没有多余的 metadata/securityConfiguration。
  · devecocli check lint → 0 error;我改过的文件**零发现**(总工性从 8 降到 7,
    顺带修掉 LoginPage 一条既有的 await-thenable)。
  · node --experimental-strip-types --no-warnings --test test/harmony-apibase.test.mjs
    → ℹ tests 13 / pass 13 / fail 0。
  · 变异验证 11/11 全红且**红在对应那条**(在 /tmp 的独立 worktree 里做的,不碰共享树):
    归一化不补后缀、去斜杠咬掉协议、404 用通用文案、401 拿通用文案顶掉服务端原因、
    网络码不再分类、默认地址退回明文内网、setBase 直接赋值、登录页去掉守卫、
    内网白名单关掉明文、出现第二处自己拼 /api/v1、守卫变成空壳。
    (M8 第一次是**假绿**——只判了方法声明、没判调用点;改成切出 doLogin 方法体后再判才红。)

未验到(别把"编译过了"读成"连通了")
  · **没有**在真机/模拟器上点过一次登录:本机当前无设备在线,所以"真的连上了服务端"
    这件事本轮**未被验证**;已验证的只是"地址会被补成带 /api/v1 的形态""构建产物正确"。
  · network_config.json 的**实际效果**未验:按官方文档明文默认就允许,这份文件是显式固化,
    没有做"关掉它再对比"的实验(也无法在无设备时做)。
  · DNS/超时/证书这几条分类的文案是按 SDK 错误码写的,**没有构造真人故障去实测**
    (即没有真的把 DNS 打坏、把证书换成自签来看提示)。
  · 登录页那条"未 /api/v1 会 404"的因果链是从服务端路由 + 客户端拼接方式推出的,
    没有用 curl 对 `https://mail.jianfgit.xyz/auth/login` 实打一次取证。
  · test/run-all.mjs 在本机(node v24.14.1)**本来就是红的**:它只认 `# pass N`,
    而 node 24 打的是 `ℹ pass N` ⇒ 25 个文件里 21 个被记成"没自报条数"。
    这是既有环境漂移(已在 HEAD 的 worktree 里复现同样的红),**本次没有动它**,
    所以新判据虽然已登记进 SUITE,要等 runner 的 marker 解析修好才会被套件真正计数。
2026-09-15 15:15:17 +08:00

305 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 鸿蒙客户端「连不上服务器」那一族(apiBase 归一化/校验 + 失败说人话)的判据。
*
* ── 背景(2026-09-15)──
*
* 用户报"鸿蒙客户端连不上服务器",而服务端 `https://mail.jianfgit.xyz/health` 是 200
* (证书 Let's Encrypt、TLS 校验通过)。两个坑叠在一起:
* ① 默认地址是 `http://192.168.2.60:8180/api/v1` —— 明文 + 写死内网 IP,
* 手机不在这个网段就永远连不上,而报错只说"无法连接";
* ② `apiBase` 是**完整 API 前缀**(`ApiClient` 拼的是 `'/auth/login'` 这类相对路径),
* 用户只填 `https://mail.jianfgit.xyz/` 时请求变成 `https://…/auth/login`
* ⇒ 服务端 **404**,界面依旧只显示"无法连接" —— **本次最可能的直接原因**。
*
* ── 判据怎么分层(按 `test/CRITERIA.md` §6.7.0 的分流)──
*
* · **值**:`model/ApiBase.ts` 是纯逻辑(无 `@ohos` 依赖,类型可擦除),
* 用 node 的 `--experimental-strip-types` **直接执行它** —— 补后缀、协议保护、
* 校验、失败分类全部是**行为判据**(跑的是客户端真正引用的那一份,不是复制品)。
* · **来源**:`.ets` 在本机没有运行时(要编译要设备 ⇒ 见 §6.8 的欠账机制),
* 所以"页面真的调了归一化""坏地址真的没被存下来"只能是**静态**判据。
* ⚠️ **它证明形状,不证明值** —— 别把它读成"用户点一下就好了"。
*
* 与「hdc / 真机」的关系:本机 `hdc list targets` 可用时,这一族应该升级成
* 真机点一次登录(那才是 §7 说的"用户真正会点的那一层")。当前没有设备,
* 所以这里如实标注为静态欠账,不冒充端到端验证。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { code } from './lib/read.mjs';
/*
* ★ 仓库根从**本文件的位置**推,不许硬编码绝对路径 —— worktree 复核时硬编码会
* 静默读另一棵树并报绿(本仓 2026-09-15 真发生过)。
*/
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
const PROFILE_DIR = join(ROOT, 'client/harmony/entry/src/main/resources/base/profile');
const NET_CFG = join(PROFILE_DIR, 'network_config.json');
/** 被测对象:客户端真正引用的那份逻辑(不是复制品) */
const A = await import(pathToFileURL(join(ETS, 'model/ApiBase.ts')).href);
const read = (rel) => code(join(ETS, rel));
/** 递归列出 ets 树下的 .ets/.ts(扫目录的判据必须能自证"扫到了东西",见 §6) */
function sourceFiles(dir = ETS) {
const out = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) { out.push(...sourceFiles(p)); continue; }
if (e.name.endsWith('.ets') || e.name.endsWith('.ts')) out.push(p);
}
return out;
}
const rel = (p) => p.slice(ETS.length + 1);
/* ───────────────────────── 值判据:跑真逻辑 ───────────────────────── */
test('★ 归一化:末尾没有 /api/v1 就补上(用户踩的就是这个坑),已有的一字不动(不重复补)', () => {
assert.equal(A.normalizeApiBase('https://mail.jianfgit.xyz'), 'https://mail.jianfgit.xyz/api/v1',
'少了 /api/v1 就会请求到 /auth/login ⇒ 404。**正确修法**:归一化时补上。'
+ '**最常见的错误修法**:在页面里各写一遍 trim/拼接(两处口径必然漂移)。');
assert.equal(A.normalizeApiBase('https://mail.jianfgit.xyz/'), 'https://mail.jianfgit.xyz/api/v1',
'尾斜杠要先去掉再补,否则拼出 `//api/v1`');
assert.equal(A.normalizeApiBase('http://192.168.2.60:8180'), 'http://192.168.2.60:8180/api/v1',
'带端口的形态同样要补');
assert.equal(A.normalizeApiBase(' https://mail.jianfgit.xyz '), 'https://mail.jianfgit.xyz/api/v1',
'首尾空白(粘贴常见)要去掉');
// 反向:已经带 /api/v1 的不许再补一层
for (const v of ['https://mail.jianfgit.xyz/api/v1', 'https://mail.jianfgit.xyz/api/v1/',
'http://10.0.2.2:8180/api/v1']) {
const got = A.normalizeApiBase(v);
assert.ok(!got.includes('/api/v1/api/v1'),
`★ ${v} 被补成了 ${got} —— 重复补前缀是 WebUI 侧真发生过的 bug(请求全 404)`);
assert.equal(got, A.stripTrailingSlashes(v), `${v} 已经带 /api/v1 ⇒ 只允许去尾斜杠,内容不动`);
}
assert.equal(A.normalizeApiBase(''), '', '空串保持空("没填"与"填错"要能分开)');
});
test('★ 去尾斜杠不许吃掉协议里的 //(朴素的 while(endsWith("/")) 会把 https:// 咬成 https:)', () => {
assert.equal(A.stripTrailingSlashes('https://'), 'https://',
'协议分隔符的两个斜杠不是"尾部斜杠" —— 咬掉一个就变成 `https:`,请求直接报 URL 非法');
assert.equal(A.stripTrailingSlashes('https://host///'), 'https://host', '路径尾斜杠该去干净');
assert.equal(A.stripTrailingSlashes(' https://host/ '), 'https://host', '先 trim 再去斜杠');
// 下游守卫:`https://` 这种"协议有了、域名没了"的输入必须被判非法,不能悄悄放过去
assert.equal(A.validateApiBase('https://').ok, false, '`https://` 没有域名 ⇒ 必须判非法');
});
test('★ 校验:合法/非法分得开,非法提示**自带修法**(用户照着那一行就能改对)', () => {
assert.equal(A.validateApiBase('https://mail.jianfgit.xyz/api/v1').ok, true, '标准地址要判合法');
assert.equal(A.validateApiBase('http://192.168.2.60:8180/api/v1').ok, true,
'局域网明文直连是**有意保留的合法通道** ⇒ 不许判非法');
const okOne = A.validateApiBase('https://mail.jianfgit.xyz');
assert.equal(okOne.ok, true, '缺 /api/v1 只是要**补**,不是"非法"');
assert.equal(okOne.base, 'https://mail.jianfgit.xyz/api/v1', 'ok 时 base 必须是归一化后的地址');
assert.equal(okOne.error, '', 'ok 时不该有错误文案');
const badOnes = ['', ' ', 'mail.jianfgit.xyz', 'https://', 'https://mail.jianfgit.xyz /api/v1'];
for (const v of badOnes) {
const r = A.validateApiBase(v);
assert.equal(r.ok, false, `★ ${JSON.stringify(v)} 该判非法`);
assert.ok(r.error.length > 0, `★ ${JSON.stringify(v)} 判非法却没给文案 ⇒ 用户只看到"登录失败"`);
assert.ok(r.error.includes('https://') && r.error.includes('api/v1'),
`★ 提示要**自带修法**(指出目标形状 https://域名/api/v1),实际:${r.error}`);
}
});
test('★ 明文 http 只对"公网"告警;内网/本机地址不许误报(否则局域网直连被吓回去)', () => {
const pub = A.validateApiBase('http://mail.example.com/api/v1');
assert.equal(pub.ok, true, '明文不是非法值(局域网直连要用),只是要提醒');
assert.ok(pub.warning.length > 0, '★ 公网明文 http 必须告警:登录凭据会明文发出去,而用户不会自己想到');
for (const v of ['http://192.168.2.60:8180/api/v1', 'http://10.0.2.2:8180/api/v1',
'http://localhost:8180/api/v1', 'http://127.0.0.1:8180/api/v1', 'https://mail.example.com/api/v1']) {
assert.equal(A.validateApiBase(v).warning, '',
`★ ${v} 不该告警 —— 内网明文 / 已经 https,告警多了就没人看了(这也是登录页那条提示被忽略的方式)`);
}
assert.equal(A.isPrivateHost('172.31.0.1'), true, '172.16-31 段也是内网');
assert.equal(A.isPrivateHost('172.32.0.1'), false, '172.32 已经出了内网段,别把公网当内网');
});
test('★ 404 必须自己写文案(服务端只会回 404 page not found),且提示里点名 /api/v1', () => {
const url = 'https://mail.jianfgit.xyz/auth/login';
const d = A.describeFailure(404, 0, '404 page not found', url);
assert.equal(d.kind, 'http404', '404 要单独分类 —— 它不是"网络不通"');
assert.ok(d.message.includes('/api/v1'),
`★ 404 的真实含义几乎总是"地址少了 /api/v1",文案必须点出来,实际:${d.message}`);
assert.ok(d.message.includes(url), '要把实际请求的 URL 打出来,用户才知道自己填的地址拼成了什么');
const bare = A.describeFailure(404, 0, '', url);
assert.ok(bare.message.includes('/api/v1'), '服务端没给文案时也要说清(不能退化成"HTTP 404")');
});
test('★ 其它 HTTP 状态让服务端的文案说话(回归保护:登录失败的原因不能被通用文案顶掉)', () => {
const d = A.describeFailure(401, 0, '用户名或密码错误', 'u');
assert.equal(d.message, '用户名或密码错误',
'★ 服务端说"用户名或密码错误"比客户端编的任何话都准;用通用文案回退它 = 用户再也看不到失败原因');
assert.equal(d.kind, 'http401', '分类仍要能区分 401');
assert.ok(A.describeFailure(403, 0, '', 'u').message.includes('403'), '没有服务端文案时要给出状态码');
assert.ok(A.describeFailure(500, 0, '', 'u').message.includes('500'), '5xx 同样');
});
test('★ 网络层失败按 SDK 错误码分类(码取自本机 @ohos.net.http.d.ts,不是猜的)', () => {
const cases = [
[2300005, 'dns'], [2300006, 'dns'],
[2300007, 'refused'], [2300028, 'timeout'],
[2300058, 'tls'], [2300059, 'tls'], [2300060, 'tls'], [2300077, 'tls'],
[2300997, 'cleartext'], [2300998, 'blocked-domain'],
[2300003, 'bad-url'], [2300094, 'native-auth']
];
for (const [code, kind] of cases) {
const d = A.describeFailure(0, code, 'raw english', 'https://mail.jianfgit.xyz/api/v1');
assert.equal(d.kind, kind, `错误码 ${code} 该归到 ${kind}`);
assert.ok(d.message.length > 6, `错误码 ${code} 的文案太短:${d.message}`);
assert.ok(!d.message.includes('raw english'),
`★ 错误码 ${code} 把英文原文当人话给出了(用户看不懂):${d.message}`);
}
// 域名解析失败要能指名是哪个域名 —— 否则用户不知道该去查哪一条
const dns = A.describeFailure(0, 2300006, 'Couldn\'t resolve host name', 'https://nope.example/api/v1');
assert.ok(dns.message.includes('nope.example'), '★ 要说清"解析不出哪个域名",否则用户只能猜');
// 明文被禁 / 证书不受信要给可执行的下一步(这两条是自建部署最常见的两个坑)
assert.ok(A.describeFailure(0, 2300997, '', 'http://a/api/v1').message.includes('https://'),
'明文被禁 ⇒ 提示里要有"改用 https://"这个动作');
assert.ok(A.describeFailure(0, 2300060, '', 'https://a/api/v1').message.includes('证书'),
'证书不受信 ⇒ 提示要指向证书,而不是笼统的"网络错误"');
// 未知码:不许把原始信息吞掉(日志与用户看到的应该是同一件事)
const unknown = A.describeFailure(0, 12345, 'weird failure', 'https://a/api/v1');
assert.equal(unknown.kind, 'network', '未知码归到 network');
assert.ok(unknown.message.includes('12345') && unknown.message.includes('weird failure'),
'★ 未知错误码要把码与原文带出来 —— 否则现场没线索,只能让用户复现');
});
test('★ hostOf:去协议/路径/端口(失败文案要靠它指名地址)', () => {
assert.equal(A.hostOf('https://mail.jianfgit.xyz/api/v1'), 'mail.jianfgit.xyz');
assert.equal(A.hostOf('http://192.168.2.60:8180/api/v1'), '192.168.2.60', '端口不是主机名的一部分');
assert.equal(A.hostOf('http://[::1]:8080/x'), '[::1]', '方括号 IPv6 不能按第一个冒号切');
});
/* ───────────────────────── 来源判据:形状 + 接线 ───────────────────────── */
test('★ 默认地址自己就必须是"能通的那一个":https + 完整 /api/v1(把源码里的值喂给真逻辑判)', () => {
const cfg = read('common/Config.ets');
const m = /DEFAULT_API_BASE: string = '([^']+)'/.exec(cfg);
assert.ok(m, 'Config.ets 里要能找到 DEFAULT_API_BASE 的字面量');
const value = m[1];
assert.ok(value.startsWith('https://'),
`★ 默认地址是 ${value} —— 明文默认会让"连不上"变成一个用户无从判断的状态;`
+ '公网用 https(域名解析到同一台机,内网走 https 也到得了)');
assert.equal(A.normalizeApiBase(value), value,
'★ 默认地址必须**已经**是归一化后的形态(少了 /api/v1 的默认值 = 开箱即 404)');
const emu = /EMULATOR_HOST_BASE: string = '([^']+)'/.exec(cfg);
assert.ok(emu && A.normalizeApiBase(emu[1]) === emu[1],
'模拟器备用地址也要带 /api/v1(它走的同一个拼接逻辑)');
});
test('★ 归一化的唯一闸口:ApiClient 的 init 与 setBase 都经过 normalizeApiBase', () => {
const src = read('api/ApiClient.ets');
const setBaseIdx = src.indexOf('setBase(base: string)');
assert.ok(setBaseIdx > 0, 'ApiClient 要有 setBase');
const setBaseBody = src.slice(setBaseIdx, src.indexOf('getToken()', setBaseIdx) > 0
? src.indexOf('getToken()', setBaseIdx) : setBaseIdx + 400);
assert.ok(/this\.apiBase = normalizeApiBase\(/.test(setBaseBody),
'★ setBase 直接赋值 = 登录页/设置页/多账号切换三条路各自漏;'
+ '**正确修法**:在这里归一化(一个闸口)。');
assert.ok(/this\.apiBase = normalizeApiBase\(stored\)/.test(src),
'★ init 读 preferences 时也要归一化 —— 老装机里已经躺着一条少了 /api/v1 的坏地址,'
+ '光改默认值救不了它(**最常见的错误修法**:只改 DEFAULT_API_BASE 就交差)');
});
test('★ 两个"用户手填地址"的入口都校验,且坏地址**不落库**(顺序即含义)', () => {
const login = read('pages/LoginPage.ets');
/*
* ★ 必须切出**方法体**再判(变异验证抓出来的):
* 第一版我只判了整个文件里有 `applyServerAddr()` —— 而**删掉 doLogin 里的那句调用**
* 依然全绿(方法**声明**里就有这个字串)。那正是 §6.7 说的"判了形状、没判路径":
* 校验方法写得好好的、就是没人调,等于没做。
*/
const doLoginIdx = login.indexOf('async doLogin()');
assert.ok(doLoginIdx > 0, 'LoginPage 要有 doLogin');
const nextMember = login.indexOf('applyServerAddr(): boolean', doLoginIdx);
const doLoginBody = login.slice(doLoginIdx, nextMember > 0 ? nextMember : login.indexOf('build()', doLoginIdx));
assert.ok(/if \(!this\.applyServerAddr\(\)\)/.test(doLoginBody),
'★ doLogin 里没有"地址非法就 return"的守卫 ⇒ 用户填的坏地址会直接拿去发请求,'
+ '而且**还会被持久化**(**正确修法**:在 setBase/persistBase 之前先 applyServerAddr() 并早退)');
const guardIdx = doLoginBody.indexOf('applyServerAddr()');
const persistIdx = doLoginBody.indexOf('persistBase');
assert.ok(persistIdx > 0, '登录页仍要持久化地址');
assert.ok(guardIdx < persistIdx,
'★ 校验必须在 persist 之前 —— 顺序反了就是把坏地址存进去,下次启动带着它"无法连接"把用户锁在外面');
assert.ok(!/while\s*\(v\.length > 0 && v\.endsWith\('\/'\)\)/.test(login),
'★ 登录页不许再自己写一份"去尾斜杠"(同一个事实两份实现必然漂移)');
// 闸口本身:applyServerAddr 必须真的调 validateApiBase(否则守卫是个空动作、永远放行)
const applyIdx = login.indexOf('applyServerAddr(): boolean');
const applyBody = login.slice(applyIdx, login.indexOf('build()', applyIdx));
assert.ok(/validateApiBase\(/.test(applyBody),
'★ applyServerAddr 里没有 validateApiBase ⇒ 那个守卫是空的(**最常见的错误修法**:'
+ '为了让判据过而只保留一个同名方法,里面什么都不验)');
const settings = read('pages/SettingsPage.ets');
const sValidateIdx = settings.indexOf('validateApiBase');
const addIdx = settings.indexOf('manager.addAccount(');
assert.ok(sValidateIdx > 0, '★ 添加账号对话框同样要校验(它是第二个手填地址的入口)');
assert.ok(addIdx > sValidateIdx,
'★ 校验必须在 addAccount 之前 —— 多账号库直接喂 SseService(base + /events/stream),'
+ '坏地址存进去等于这条账号的实时通道永久连不上,界面上还看不出来');
assert.ok(!/normalizeServer\(/.test(settings), '设置页自己的 normalizeServer 应已删除(改走共用逻辑)');
const client = read('api/ApiClient.ets');
assert.ok(/nativeCode: number = 0/.test(client) && /ApiError\(0, failure\.message, nativeCode\)/.test(client),
'★ 网络层失败要把 BusinessError.code 带进 ApiError(没有它就无法分类成人话)');
});
test('★ 内网明文通道:network_config.json 就在文档规定的位置、按文档结构开明文白名单', () => {
assert.ok(existsSync(NET_CFG),
`★ 缺 ${NET_CFG}。**正确修法**:放在 resources/base/profile/network_config.json`
+ '(官方文档《使用HTTP访问网络·明文HTTP访问权限配置说明》给的就是这个固定位置与文件名,'
+ '**不需要**在 module.json5 里写引用)。');
let cfg = null;
try {
cfg = JSON.parse(readFileSync(NET_CFG, 'utf8'));
} catch (e) {
assert.fail(`network_config.json 不是合法 JSON(会被打进包里但读不出来):${e.message}`);
}
const sec = cfg['network-security-config'];
assert.ok(sec && Array.isArray(sec['domain-config']) && sec['domain-config'].length > 0,
'★ 结构要是 network-security-config.domain-config[](文档给的那一套键;'
+ 'Android 那套 cleartextTrafficPermitted 在 base-config 下的写法也支持,但域名白名单用的就是这个形状)');
const allowed = sec['domain-config'][0].cleartextTrafficPermitted;
assert.equal(allowed, true,
'★ 内网白名单必须真的允许明文,否则局域网直连会被系统直接拒掉(错误码 2300997)');
const names = (sec['domain-config'][0].domains || []).map((d) => d.name);
assert.ok(names.includes('192.168.2.60'),
`★ 白名单里要有服务端所在的内网地址(当前:${JSON.stringify(names)})`);
assert.ok(names.includes('10.0.2.2'), '模拟器 NAT 地址也要在里面(联调那条通道)');
});
test('★ 归一化只有一份实现:全仓没有第二处自己拼 /api/v1,也没有第二处去尾斜杠', () => {
const files = sourceFiles();
// §6:扫目录的判据要能自证"扫到了东西"(改名/只扫一个子目录会让它变成空判据而全绿)
assert.ok(files.length >= 15, `只扫到 ${files.length} 个源文件,扫描范围不对(应当 ≥15)`);
for (const sub of ['pages', 'common', 'model', 'api']) {
assert.ok(files.some((f) => rel(f).startsWith(sub + '/')), `扫描范围漏了 ${sub}/`);
}
const ALLOW = [{ file: 'model/ApiBase.ts', why: '归一化的唯一实现(其他文件只许引用它)' }];
const appendRe = /(\+\s*['"]\/api\/v1['"])|(['"]\/api\/v1['"]\s*\+)/;
const trimRe = /while\s*\([^)]*endsWith\(\s*'\/'\s*\)/;
const bad = [];
for (const f of files) {
if (ALLOW.some((a) => rel(f) === a.file)) continue;
const src = code(f);
if (appendRe.test(src)) bad.push(`${rel(f)}:自己拼接 '/api/v1'`);
if (trimRe.test(src)) bad.push(`${rel(f)}:自己写"去尾斜杠"循环`);
}
assert.deepEqual(bad, [],
`★ 归一化出现了第二实现(漂移的起点):\n ${bad.join('\n ')}\n`
+ ' **正确修法**:import { normalizeApiBase } from \'…/model/ApiBase\' 用它;'
+ ' **最常见的错误修法**:把这条判据的 ALLOW 加上自己的文件(那等于把"只有一份"废掉)。');
});