262 lines
12 KiB
JavaScript
262 lines
12 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import { execFileSync } from 'node:child_process';
|
||
import { readFile } from 'node:fs/promises';
|
||
import { test } from 'node:test';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, resolve } from 'node:path';
|
||
import {
|
||
renderAndroidPrivacy,
|
||
validateAndroidPrivacyDocument,
|
||
validateLegalUrlPair
|
||
} from '../scripts/app-legal-config.mjs';
|
||
|
||
const mobileRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||
const readJson = async (path) => JSON.parse(await readFile(new URL(path, import.meta.url), 'utf8'));
|
||
const hbuilderx522Compiler = '3.0.0-alpha-5020220260725001';
|
||
|
||
test('App 图标与启动图映射均可由预检脚本验证', () => {
|
||
const output = execFileSync(process.execPath, ['scripts/verify-app-assets.mjs'], {
|
||
cwd: mobileRoot,
|
||
encoding: 'utf8'
|
||
});
|
||
assert.match(output, /App asset verification passed/);
|
||
});
|
||
|
||
// 缺少 App 平台编译器时 `uni build -p app` 不会报错,只会静默产出带 /h5/ 基路径的
|
||
// H5 空壳(无 app-service.js/app-renderjs.js),使 build:app 成为假绿,renderjs
|
||
// 实时对练代码实际从未被 App 编译器处理。
|
||
test('App 平台编译器已声明且与其他 uni 包同版本,避免 build:app 静默回落 H5', async () => {
|
||
const pkg = await readJson('../package.json');
|
||
const appPlus = pkg.dependencies?.['@dcloudio/uni-app-plus'];
|
||
|
||
assert.ok(appPlus, '缺少 @dcloudio/uni-app-plus,build:app 会静默产出 H5 空壳');
|
||
assert.equal(appPlus, hbuilderx522Compiler, '编译链必须与当前 HBuilderX 5.22 调试基座匹配');
|
||
assert.equal(appPlus, pkg.dependencies['@dcloudio/uni-app']);
|
||
assert.equal(appPlus, pkg.dependencies['@dcloudio/uni-h5']);
|
||
assert.equal(appPlus, pkg.devDependencies['@dcloudio/vite-plugin-uni']);
|
||
});
|
||
|
||
test('App 版本在 package、lockfile 与原生 manifest 间保持一致', async () => {
|
||
const [pkg, lockfile, manifest] = await Promise.all([
|
||
readJson('../package.json'),
|
||
readJson('../package-lock.json'),
|
||
readJson('../src/manifest.json')
|
||
]);
|
||
|
||
assert.equal(lockfile.version, pkg.version);
|
||
assert.equal(lockfile.packages?.['']?.version, pkg.version);
|
||
assert.equal(manifest.versionName, pkg.version);
|
||
assert.match(String(manifest.versionCode || ''), /^\d+$/);
|
||
});
|
||
|
||
test('Android 自定义基座显式面向 Android 15 权限模型', async () => {
|
||
const manifest = await readJson('../src/manifest.json');
|
||
const android = manifest['app-plus']?.distribute?.android;
|
||
|
||
assert.equal(android?.packagename, 'com.yincheng.wygj');
|
||
assert.equal(android?.targetSdkVersion, 35, '不得回退到 DCloud 默认的 targetSdkVersion 28 兼容模式');
|
||
assert.equal(android?.usesCleartextTraffic, false, '发布配置不得允许明文 HTTP');
|
||
});
|
||
|
||
test('Android 原生网络安全配置拒绝明文流量且只信任系统证书', async () => {
|
||
const androidManifest = await readFile(
|
||
new URL('../AndroidManifest.xml', import.meta.url),
|
||
'utf8'
|
||
);
|
||
const networkSecurity = await readFile(
|
||
new URL('../nativeResources/android/res/xml/network_security_config.xml', import.meta.url),
|
||
'utf8'
|
||
);
|
||
|
||
assert.match(androidManifest, /android:usesCleartextTraffic="false"/);
|
||
assert.match(androidManifest, /android:networkSecurityConfig="@xml\/network_security_config"/);
|
||
assert.match(networkSecurity, /cleartextTrafficPermitted="false"/);
|
||
assert.match(networkSecurity, /<certificates src="system" \/>/);
|
||
assert.doesNotMatch(networkSecurity, /cleartextTrafficPermitted="true"/);
|
||
assert.doesNotMatch(networkSecurity, /<certificates src="user"/);
|
||
assert.doesNotMatch(networkSecurity, /<debug-overrides\b/);
|
||
});
|
||
|
||
test('App 构建链将 Android 原生网络安全资源同步到云打包输入目录', async () => {
|
||
const pkg = await readJson('../package.json');
|
||
const syncScript = await readFile(
|
||
new URL('../scripts/sync-app-native-resources.mjs', import.meta.url),
|
||
'utf8'
|
||
);
|
||
|
||
assert.match(pkg.scripts?.['build:app'] || '', /sync-app-native-resources\.mjs/);
|
||
assert.match(syncScript, /dist\/build\/app\/AndroidManifest\.xml/);
|
||
assert.match(syncScript, /dist\/build\/app\/nativeResources\/android\/res\/xml\/network_security_config\.xml/);
|
||
});
|
||
|
||
test('Android 启动阶段不主动索取设备信息或外部存储权限', async () => {
|
||
const manifest = await readJson('../src/manifest.json');
|
||
const android = manifest['app-plus']?.distribute?.android;
|
||
const modules = manifest['app-plus']?.modules;
|
||
|
||
assert.equal(
|
||
manifest['app-plus']?.distribute?.splashscreen?.useOriginalMsgbox,
|
||
false,
|
||
'受控企业内测不展示 DCloud 原生隐私弹窗,协议同意统一放在登录页'
|
||
);
|
||
assert.equal(android?.permissionPhoneState?.request, 'none');
|
||
assert.equal(android?.permissionExternalStorage?.request, 'none');
|
||
assert.ok(Object.hasOwn(modules || {}, 'Camera'), '媒体功能仍需按用户操作动态申请相机权限');
|
||
assert.ok(Object.hasOwn(modules || {}, 'Record'), '语音功能仍需按用户操作动态申请录音权限');
|
||
});
|
||
|
||
test('正式法务地址必须使用不同的公网 HTTPS 页面', () => {
|
||
assert.throws(() => validateLegalUrlPair('', 'https://legal.example.cn/privacy'), /required/);
|
||
assert.throws(
|
||
() => validateLegalUrlPair('http://legal.example.cn/terms', 'https://legal.example.cn/privacy'),
|
||
/HTTPS/
|
||
);
|
||
assert.throws(
|
||
() => validateLegalUrlPair('https://127.0.0.1/terms', 'https://legal.example.cn/privacy'),
|
||
/public hostname/
|
||
);
|
||
assert.throws(
|
||
() => validateLegalUrlPair('https://legal.example.cn/document', 'https://legal.example.cn/document'),
|
||
/must be different/
|
||
);
|
||
assert.throws(
|
||
() => validateLegalUrlPair('https://198.18.0.1/terms', 'https://legal.example.cn/privacy'),
|
||
/public hostname/
|
||
);
|
||
assert.throws(
|
||
() => validateLegalUrlPair('https://legal.invalid/terms', 'https://legal.example.cn/privacy'),
|
||
/reserved hostname/
|
||
);
|
||
assert.doesNotThrow(() => validateLegalUrlPair(
|
||
'https://198.51.99.1/terms',
|
||
'https://fd-company.cn/privacy'
|
||
));
|
||
assert.doesNotThrow(() => validateLegalUrlPair(
|
||
'https://fd-company.cn/terms',
|
||
'https://fd-company.cn/privacy'
|
||
));
|
||
});
|
||
|
||
test('登录页公网法务地址由版本化公共环境配置供 npm 与 HBuilderX 共用', async () => {
|
||
const [publicEnv, configureScript, verifier] = await Promise.all([
|
||
readFile(new URL('../.env', import.meta.url), 'utf8'),
|
||
readFile(new URL('../scripts/configure-android-privacy.mjs', import.meta.url), 'utf8'),
|
||
readFile(new URL('../scripts/verify-app-assets.mjs', import.meta.url), 'utf8')
|
||
]);
|
||
|
||
assert.match(publicEnv, /^VITE_APP_TERMS_URL=https:\/\/peilian\.njzhmj\.top\/legal\/terms\.html$/m);
|
||
assert.match(publicEnv, /^VITE_APP_PRIVACY_URL=https:\/\/peilian\.njzhmj\.top\/legal\/privacy\.html$/m);
|
||
assert.doesNotMatch(publicEnv, /(?:TOKEN|SECRET|PASSWORD|KEY)=/i);
|
||
assert.match(configureScript, /loadEnv\(process\.env\.NODE_ENV \|\| 'production', projectRoot, 'VITE_'\)/);
|
||
assert.match(verifier, /loadEnv\(process\.env\.NODE_ENV \|\| 'production', projectRoot, 'VITE_'\)/);
|
||
});
|
||
|
||
test('Android 原生隐私配置明确关闭弹窗,法务地址仍须通过登录页构建校验', async () => {
|
||
const template = await readFile(new URL('../androidPrivacy.json.example', import.meta.url), 'utf8');
|
||
const rendered = renderAndroidPrivacy(
|
||
template,
|
||
'https://legal.company.cn/bangdao/terms',
|
||
'https://legal.company.cn/bangdao/privacy'
|
||
);
|
||
const privacy = JSON.parse(rendered);
|
||
|
||
assert.deepEqual(validateAndroidPrivacyDocument(privacy), {
|
||
prompt: 'none',
|
||
version: '20260729'
|
||
});
|
||
assert.doesNotMatch(rendered, /服务协议|隐私政策|buttonAccept|buttonRefuse|second/);
|
||
assert.throws(
|
||
() => validateAndroidPrivacyDocument({ ...privacy, prompt: 'template' }),
|
||
/prompt=none/
|
||
);
|
||
assert.throws(
|
||
() => validateAndroidPrivacyDocument({ ...privacy, message: 'unexpected native prompt' }),
|
||
/must not contain message/
|
||
);
|
||
});
|
||
|
||
test('Android 隐私配置写入 uni-app CLI 输入目录并校验编译产物', async () => {
|
||
const configureScript = await readFile(
|
||
new URL('../scripts/configure-android-privacy.mjs', import.meta.url),
|
||
'utf8'
|
||
);
|
||
const verifier = await readFile(
|
||
new URL('../scripts/verify-app-assets.mjs', import.meta.url),
|
||
'utf8'
|
||
);
|
||
|
||
assert.match(configureScript, /resolve\(projectRoot, 'src', 'androidPrivacy\.json'\)/);
|
||
assert.match(verifier, /dist\/build\/app\/androidPrivacy\.json/);
|
||
assert.match(verifier, /compiledManifest\.plus\?\.distribute\?\.splashscreen\?\.useOriginalMsgbox !== false/);
|
||
});
|
||
|
||
test('App 登录页法务地址校验不依赖浏览器 URL 构造器', async () => {
|
||
const legalService = await readFile(
|
||
new URL('../src/services/legal.ts', import.meta.url),
|
||
'utf8'
|
||
);
|
||
|
||
assert.doesNotMatch(legalService, /new URL\(/);
|
||
assert.match(legalService, /\^https:\\\/\\\/\(\[\^\/\?#\]\+\)/);
|
||
assert.match(legalService, /legalLinksReady/);
|
||
});
|
||
|
||
test('APK 门禁区分自定义调试基座与内置业务资源的测试包', async () => {
|
||
const verifier = await readFile(
|
||
new URL('../scripts/verify-android-apk.ps1', import.meta.url),
|
||
'utf8'
|
||
);
|
||
|
||
assert.match(verifier, /ValidateSet\('custom-base', 'dcloud-test', 'release'\)/);
|
||
assert.match(verifier, /assets\\apps/);
|
||
assert.match(verifier, /bundled androidPrivacy\.json/);
|
||
assert.match(verifier, /DCloud custom base APK contains only the native debug runtime/);
|
||
assert.match(verifier, /prompt=none/);
|
||
assert.match(verifier, /Packaged login flow is missing the expected service agreement URL/);
|
||
assert.match(verifier, /Packaged login flow is missing the expected privacy policy URL/);
|
||
assert.match(verifier, /Packaged login flow is missing the unchecked-consent failure message/);
|
||
assert.match(verifier, /\$legalAppServiceFiles\[0\]\.FullName/);
|
||
assert.doesNotMatch(verifier, /[^\x00-\x7F]/, 'Windows PowerShell 5.1 脚本必须保持纯 ASCII');
|
||
assert.match(verifier, /dist\\build\\app\\app-service\.js/);
|
||
assert.match(verifier, /dist\\build\\app-plus\\app-service\.js/);
|
||
assert.match(verifier, /fixed test SMS code/);
|
||
assert.match(verifier, /'mobile number'\s*=/);
|
||
assert.match(verifier, /dcloud-test.*release.*usesCleartextTraffic/s);
|
||
assert.match(verifier, /res\\xml\\network_security_config\.xml/);
|
||
assert.match(verifier, /network security config must trust system certificates only/);
|
||
assert.match(verifier, /must not contain a cleartext exception/);
|
||
assert.match(verifier, /must not trust user or bundled certificates/);
|
||
assert.match(verifier, /must not contain debug trust overrides/);
|
||
});
|
||
|
||
test('8 月 1 日 RC 脚本默认拒绝脏工作区且记录完整构建证据', async () => {
|
||
const script = await readFile(
|
||
new URL('../../scripts/prepare-aug1-rc.ps1', import.meta.url),
|
||
'utf8'
|
||
);
|
||
|
||
assert.match(script, /requires a clean worktree/);
|
||
assert.match(script, /AllowDirtyRehearsal/);
|
||
assert.match(script, /RC build changed the previously clean worktree/);
|
||
assert.match(script, /releaseEligible = \$releaseEligible/);
|
||
assert.match(script, /APK metadata, login legal links, no native privacy prompt, signature, content match and sensitive-value scan/);
|
||
});
|
||
|
||
test('认证态 UI 审计不持久化手机号或验证码', async () => {
|
||
const script = await readFile(
|
||
new URL('../scripts/ui-rendered-audit.mjs', import.meta.url),
|
||
'utf8'
|
||
);
|
||
const authRoutesStart = script.indexOf('const AUTH_DEFAULT_ROUTES');
|
||
const authRoutesEnd = script.indexOf('];', authRoutesStart);
|
||
const authRoutes = script.slice(authRoutesStart, authRoutesEnd);
|
||
|
||
assert.ok(authRoutesStart >= 0 && authRoutesEnd > authRoutesStart);
|
||
assert.match(script, /AIHR_UI_AUDIT_PHONE/);
|
||
assert.match(script, /AIHR_UI_AUDIT_CODE/);
|
||
assert.doesNotMatch(script, /argValue\('--code'\)\s*\|\|\s*['"]\d+/);
|
||
assert.doesNotMatch(script, /session\.masked|loginPhone\.slice/);
|
||
assert.doesNotMatch(authRoutes, /pages\/user\/profile\/index/);
|
||
assert.match(script, /已认证(凭据不入报告)/);
|
||
});
|