完成80%

This commit is contained in:
2026-07-27 06:50:07 +08:00
parent 8475bbd19a
commit 1eae3bbef4
63 changed files with 13664 additions and 280 deletions
+1 -1
View File
@@ -223,7 +223,7 @@ foreach ($contract in @(
'const togglePasswordVisibility = () =>',
'<AppToast :visible="feedbackVisible" :message="feedbackMessage" />',
'<TacVerification',
'AUTH_TAC_SCENE.SMS_LOGIN',
'AUTH_VERIFICATION_OPERATION.SMS_LOGIN',
'normalizeTacSuccess',
'appApi.loginWithPassword',
'appApi.loginWithSms',
+25 -6
View File
@@ -20,10 +20,12 @@ const createHarnessFactory = (
goRootResult = true,
goRootError = null,
initialSessionToken = "",
captchaRequired = true,
) => new Function(
"goRootResult",
"goRootError",
"initialSessionToken",
"captchaRequired",
`
"use strict";
const calls = [];
@@ -56,10 +58,10 @@ const createHarnessFactory = (
async getCaptchaRequirement(payload) {
calls.push({ type: "require", payload: { ...payload } });
return {
required: true,
required: captchaRequired,
providerCode: "TIANAI",
captchaType: "SLIDER",
sceneCode: payload.sceneCode,
sceneCode: "APP_PASSWORD_LOGIN",
ttlSeconds: 300,
};
},
@@ -76,11 +78,14 @@ const createHarnessFactory = (
return { access_token: "token-sms" };
},
};
const AUTH_TAC_SCENE = Object.freeze({ SMS_LOGIN: "APP_SMS_LOGIN" });
const AUTH_VERIFICATION_OPERATION = Object.freeze({
PASSWORD_LOGIN: "password-login",
SMS_LOGIN: "sms-login",
});
const createTacRenderContext = (value) => ({ ...value });
const isAuthPhone = (value) => /^1[3-9]\\d{9}$/.test(value);
const normalizeCaptchaRequirement = (value, expectedScene) => {
if (value?.sceneCode !== expectedScene || value?.required !== true) {
const normalizeCaptchaRequirement = (value) => {
if (typeof value?.required !== "boolean") {
throw new Error("requirement invalid");
}
return value;
@@ -141,13 +146,14 @@ const createHarnessFactory = (
},
};
`,
)(goRootResult, goRootError, initialSessionToken);
)(goRootResult, goRootError, initialSessionToken, captchaRequired);
const createHarness = (options = {}) =>
createHarnessFactory(
options.goRootResult ?? true,
options.goRootError ?? null,
options.initialSessionToken ?? "",
options.captchaRequired ?? true,
);
const countCalls = (harness, type) =>
@@ -193,6 +199,7 @@ const run = async () => {
assert.deepStrictEqual(success.calls[1].payload, {
phone: "13800138000",
passwordHash: "md5:secret-1",
validToken: "ticket-1",
});
assert.strictEqual(success.calls[2].pageId, "G01");
assert.strictEqual(countCalls(success, "require"), 1);
@@ -200,6 +207,18 @@ const run = async () => {
assert.strictEqual(success.tacVisible.value, false);
assert.strictEqual(success.tacContext.value, null);
const noCaptcha = createHarness({ captchaRequired: false });
noCaptcha.phone.value = "13800138000";
noCaptcha.password.value = "secret-1";
noCaptcha.agreed.value = true;
await noCaptcha.submitLogin();
assert.deepStrictEqual(
noCaptcha.calls.map((call) => call.type),
["require", "password-login", "go-root"],
"服务端关闭 password-login TAC 时必须直接走已发布密码登录请求",
);
assert.strictEqual(noCaptcha.tacVisible.value, false);
const navigationRejected = createHarness({ goRootResult: false });
await preparePassword(navigationRejected);
await completePasswordTac(navigationRejected);
+1 -1
View File
@@ -47,7 +47,7 @@ foreach ($required in @(
'const submitRegister = async () =>',
'<AppToast :visible="feedbackVisible" :message="feedbackMessage" />',
'<TacVerification',
'AUTH_TAC_SCENE.REGISTER',
'AUTH_VERIFICATION_OPERATION.REGISTER',
'appApi.registerWithPassword',
'if (!/^\d{4}$/.test(verificationCode.value))',
'if (!isAuthPhone(phone.value))',
+1 -1
View File
@@ -46,7 +46,7 @@ foreach ($required in @(
'if (!isAuthPhone(phone.value))',
'if (!/^\d{4}$/.test(verificationCode.value))',
'<TacVerification',
'AUTH_TAC_SCENE.FORGOT_PASSWORD',
'AUTH_VERIFICATION_OPERATION.FORGOT_PASSWORD',
'await appApi.resetPassword',
'validatePassword(password.value)',
'PASSWORD_POLICY_MESSAGE',
+16 -5
View File
@@ -16,7 +16,7 @@ const routeEnd = Number(process.env.PAGE_AUDIT_END || 0);
const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const pageExpectations = {
"pages/auth/a01-entry": { title: "", noReload: true, skipTitle: true },
"pages/auth/a01-entry": { title: "", noReload: true, skipTitle: true, allowAuthenticatedRedirect: true },
"pages/auth/a04-register": { title: "注册账号" },
"pages/auth/a05-reset-password": { title: "重设密码" },
"pages/genealogy/g01-my-genealogies": { title: "我的家谱", capture: "01-genealogies.png" },
@@ -47,7 +47,7 @@ const pageExpectations = {
"pages/family/f10-video-list": { title: "家族视频", query: `genealogyId=${genealogyId}` },
"pages/records/r01-people-list": { title: "人物录", query: `genealogyId=${genealogyId}` },
"pages/records/r02-person-detail": { title: "人物详情", query: `genealogyId=${genealogyId}&state=error` },
"pages/records/r03-gift-list": { title: "亲友往来", query: `genealogyId=${genealogyId}`, capture: "05-gifts.png" },
"pages/records/r03-gift-list": { title: "贺礼簿", query: `genealogyId=${genealogyId}`, capture: "05-gifts.png" },
"pages/records/r04-gift-editor": { title: "新建往来记录", query: `genealogyId=${genealogyId}&mode=create` },
"pages/records/r05-ritual-list": { title: "礼仪活动", query: `genealogyId=${genealogyId}` },
"pages/records/r06-ritual-detail": { title: "礼仪详情", query: `genealogyId=${genealogyId}&ceremonyId=0` },
@@ -111,13 +111,24 @@ const waitFor = async (send, expression, message) => {
};
const open = async (send, route, noReload) => {
const expectation = pageExpectations[route];
const url = `${origin}/#/${route}${pageExpectations[route].query ? `?${pageExpectations[route].query}` : ""}`;
await send("Page.navigate", { url });
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `navigation failed: ${route}`);
const authenticatedRedirect = `${origin}/#/pages/genealogy/g01-my-genealogies`;
const expectedLocation = pageExpectations[route].allowAuthenticatedRedirect
? `location.href === ${JSON.stringify(url)} || location.href === ${JSON.stringify(authenticatedRedirect)}`
: `location.href === ${JSON.stringify(url)}`;
await waitFor(send, expectedLocation, `navigation failed: ${route}`);
if (!expectation.skipTitle) {
await waitFor(send, `document.body?.innerText.includes(${JSON.stringify(expectation.title)})`, `title did not render: ${route}`);
}
if (!noReload) {
const previousTimeOrigin = await evaluate(send, "performance.timeOrigin");
await send("Page.reload");
send("Page.reload").catch(() => {});
await waitFor(send, `performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`, `reload failed: ${route}`);
if (!expectation.skipTitle) {
await waitFor(send, `document.body?.innerText.includes(${JSON.stringify(expectation.title)})`, `title did not render after reload: ${route}`);
}
}
await waitFor(send, "document.body && document.body.innerText.length > 0", `page did not render: ${route}`);
await wait(700);
@@ -131,7 +142,7 @@ const capture = async (send, filename) => {
const run = async () => {
const allRoutes = require("../pages.json").pages.map((page) => page.path);
assert.deepStrictEqual(allRoutes.sort(), Object.keys(pageExpectations).sort(), "route coverage drifted from pages.json");
assert.deepStrictEqual([...allRoutes].sort(), Object.keys(pageExpectations).sort(), "route coverage drifted from pages.json");
const routes = routeEnd > routeStart ? allRoutes.slice(routeStart, routeEnd) : allRoutes;
const { socket, send } = await connect();
const results = [];
+49 -7
View File
@@ -34,10 +34,11 @@ const run = async () => {
};
const hasRemoteConfig = () => true;
const resolveRuntimeMode = () => "remote";
const AUTH_TAC_SCENE = Object.freeze({
SMS_LOGIN: "APP_SMS_LOGIN",
REGISTER: "APP_REGISTER",
FORGOT_PASSWORD: "APP_FORGOT_PASSWORD",
const AUTH_VERIFICATION_OPERATION = Object.freeze({
PASSWORD_LOGIN: "password-login",
SMS_LOGIN: "sms-login",
REGISTER: "register",
FORGOT_PASSWORD: "forgot-password",
});
const assertSmsCode = (value) => {
if (typeof value !== "string" || !/^\\d{4}$/.test(value)) throw new Error("请输入 4 位短信验证码");
@@ -84,7 +85,7 @@ const run = async () => {
nextResponse = response;
};
const sendSms = () => appApi.sendSmsCode({
sceneCode: "APP_REGISTER",
operationCode: "register",
phone: "13800138000",
validToken: "ticket-1",
});
@@ -114,6 +115,26 @@ const run = async () => {
assert.strictEqual(await sendSms(), null, "RVoid 未声明 data 必填,省略 data 仍必须解析为 null");
assert.strictEqual(requests.at(-1).header.clientid, "client-1");
assert.strictEqual(requests.at(-1).header.tenantId, "000000");
assert.strictEqual(
requests.at(-1).url,
"https://backend-api.ddxcjp.cn/genealogy/app/auth/sms/register/code",
);
assert.deepStrictEqual(requests.at(-1).data, {
tenantId: "000000",
grantType: "sms",
phone: "13800138000",
validToken: "ticket-1",
});
respond({ statusCode: 200, data: { code: 200 } });
await appApi.sendSmsCode({
operationCode: "register",
phone: "13800138000",
});
assert.deepStrictEqual(requests.at(-1).data, {
tenantId: "000000",
grantType: "sms",
phone: "13800138000",
}, "策略关闭时发码请求不得伪造 validToken");
// 已鉴权读取收到业务 401 时,只清理失效的本地会话;请求仍向调用方失败返回。
respond({ statusCode: 200, data: { code: 401, msg: "认证失败", data: null } });
@@ -144,6 +165,26 @@ const run = async () => {
"找回密码的 RVoid 省略 data 时仍必须解析为 null",
);
respond({
statusCode: 200,
data: { code: 200, msg: "操作成功", data: { access_token: "token-1" } },
});
respond({ statusCode: 200, data: { code: 200 } });
await appApi.sendLegacySmsCode({
phone: "13800138000",
validToken: "ticket-legacy",
});
assert.strictEqual(
requests.at(-1).url,
"https://backend-api.ddxcjp.cn/genealogy/app/auth/sms/code",
);
assert.deepStrictEqual(requests.at(-1).data, {
tenantId: "000000",
grantType: "sms",
phone: "13800138000",
validToken: "ticket-legacy",
});
respond({
statusCode: 200,
data: { code: 200, msg: "操作成功", data: { access_token: "token-1" } },
@@ -159,6 +200,7 @@ const run = async () => {
const passwordLogin = await appApi.loginWithPassword({
phone: "13800138000",
passwordHash: "a".repeat(32),
validToken: "ticket-password",
});
assert.strictEqual(passwordLogin.access_token, "token-password");
const passwordRequest = requests.at(-1);
@@ -167,11 +209,11 @@ const run = async () => {
assert.strictEqual(passwordRequest.header.clientid, "client-1");
assert.strictEqual(passwordRequest.header.Authorization, undefined);
assert.deepStrictEqual(passwordRequest.data, {
clientId: "client-1",
tenantId: "000000",
grantType: "password",
phone: "13800138000",
password: "a".repeat(32),
validToken: "ticket-password",
});
assert.deepStrictEqual(savedTokens, ["token-1", "token-password"]);
@@ -224,7 +266,7 @@ const run = async () => {
const requestController = createRequestController();
const cancelled = appApi.sendSmsCode(
{
sceneCode: "APP_REGISTER",
operationCode: "register",
phone: "13800138000",
validToken: "ticket-1",
},
+5 -5
View File
@@ -26,7 +26,7 @@ const run = async () => {
const firstValues = [];
const first = createAuthSmsCooldown({
sceneCode: "APP_SMS_LOGIN",
operationCode: "sms-login",
onChange: (value) => firstValues.push(value),
now: () => now,
setIntervalFn,
@@ -43,7 +43,7 @@ const run = async () => {
const restoredValues = [];
const restored = createAuthSmsCooldown({
sceneCode: "APP_SMS_LOGIN",
operationCode: "sms-login",
onChange: (value) => restoredValues.push(value),
now: () => now,
setIntervalFn,
@@ -56,7 +56,7 @@ const run = async () => {
);
const other = createAuthSmsCooldown({
sceneCode: "APP_REGISTER",
operationCode: "register",
onChange: () => {},
now: () => now,
setIntervalFn,
@@ -72,10 +72,10 @@ const run = async () => {
assert.throws(
() =>
createAuthSmsCooldown({
sceneCode: "",
operationCode: "",
onChange: () => {},
}),
/sceneCode/,
/operationCode/,
);
process.stdout.write("AUTH-SMS-COOLDOWN-RUNTIME-SMOKE PASS\n");
+4 -4
View File
@@ -63,7 +63,7 @@ $authApiMatch = [regex]::Match($api, '(?s)async getCaptchaRequirement.*?(?=\s+as
if (-not $authApiMatch.Success) { throw '无法定位唯一认证 API 区段' }
Reject-Text -Content $authApiMatch.Value -Text "return { success: true }" -Label '认证短信 mock'
Reject-Text -Content $authApiMatch.Value -Text "mock-session-token" -Label '认证会话 mock'
Require-Text -Content $api -Text 'sceneCode, phone, validToken' -Label '短信票据请求'
Require-Text -Content $api -Text 'operationCode, phone, validToken' -Label '短信票据请求'
foreach ($token in @('const requestAuth =', 'strictEnvelope: true', 'expectedStatus: 200', "hasOwnProperty.call(data, 'data')")) {
Require-Text -Content $api -Text $token -Label '认证严格响应 owner'
}
@@ -115,17 +115,17 @@ foreach ($token in @('const blockBusyAction = () =>', 'if (blockBusyAction()) re
Require-Text -Content $a01 -Text $token -Label 'A01 忙碌动作门禁'
}
foreach ($token in @('AUTH_TAC_SCENE.SMS_LOGIN', 'appApi.loginWithPassword', 'appApi.loginWithSms', 'calcMD5(password.value)', 'const preparePasswordLogin = async () =>', '/^\d{4}$/')) {
foreach ($token in @('AUTH_VERIFICATION_OPERATION.PASSWORD_LOGIN', 'AUTH_VERIFICATION_OPERATION.SMS_LOGIN', 'appApi.loginWithPassword', 'appApi.loginWithSms', 'calcMD5(password.value)', 'const preparePasswordLogin = async () =>', '/^\d{4}$/')) {
Require-Text -Content $a01 -Text $token -Label 'A01'
}
Reject-Text -Content $a01 -Text 'PASSWORD_TAC_BLOCKED_MESSAGE' -Label 'A01 旧密码登录硬关闭'
Reject-Text -Content $a01 -Text '/^\d{6}$/' -Label 'A01 六位短信码'
foreach ($token in @('AUTH_TAC_SCENE.REGISTER', 'v-model.trim="verificationCode"', 'appApi.registerWithPassword', 'calcMD5(password.value)', '/^\d{4}$/', 'goRoot("G01")')) {
foreach ($token in @('AUTH_VERIFICATION_OPERATION.REGISTER', 'v-model.trim="verificationCode"', 'appApi.registerWithPassword', 'calcMD5(password.value)', '/^\d{4}$/', 'goRoot("G01")')) {
Require-Text -Content $a04 -Text $token -Label 'A04'
}
foreach ($token in @('AUTH_TAC_SCENE.FORGOT_PASSWORD', 'appApi.resetPassword', 'calcMD5(password.value)', '/^\d{4}$/', 'await appApi.resetPassword')) {
foreach ($token in @('AUTH_VERIFICATION_OPERATION.FORGOT_PASSWORD', 'appApi.resetPassword', 'calcMD5(password.value)', '/^\d{4}$/', 'await appApi.resetPassword')) {
Require-Text -Content $a05 -Text $token -Label 'A05'
}
Reject-Text -Content $a05 -Text '/^\d{6}$/' -Label 'A05 六位短信码'
+8 -8
View File
@@ -15,23 +15,23 @@ foreach ($path in @($apiPath, $a01Path, $runtimePath)) {
if ($issues.Count -eq 0) {
$api = Get-Content -Raw -Encoding UTF8 -LiteralPath $apiPath
$a01 = Get-Content -Raw -Encoding UTF8 -LiteralPath $a01Path
$passwordOwner = [regex]::Match($api, '(?s)async loginWithPassword\(\{ phone, passwordHash \}.*?(?=\s+async loginWithSms)')
$passwordOwner = [regex]::Match($api, '(?s)async loginWithPassword\(\{ phone, passwordHash, validToken \}.*?(?=\s+async loginWithSms)')
if (-not $passwordOwner.Success) {
$issues.Add('missing bounded password login owner')
} else {
foreach ($required in @("url: '/genealogy/app/auth/login'", "grantType: 'password'", 'password: assertPasswordHash(passwordHash)')) {
foreach ($required in @("url: '/genealogy/app/auth/login'", "tenantId: runtimeConfig.tenantId", "grantType: 'password'", 'password: assertPasswordHash(passwordHash)', 'const normalizedValidToken = normalizeOptionalValidToken(validToken)', '...(normalizedValidToken ? { validToken: normalizedValidToken } : {})')) {
if (-not $passwordOwner.Value.Contains($required)) { $issues.Add("password login owner missing: $required") }
}
if ($passwordOwner.Value.Contains('validToken')) {
$issues.Add('password login must not upload validToken')
if ($passwordOwner.Value.Contains('authPayload(')) {
$issues.Add('password login must not put clientId in the body')
}
}
foreach ($required in @('const preparePasswordLogin = async () =>', 'appApi.loginWithPassword', 'TAC')) {
if (-not $a01.Contains($required)) { $issues.Add("A01 missing password TAC precondition: $required") }
}
$smsOwner = [regex]::Match($api, '(?s)async sendSmsCode\(\{ sceneCode, phone, validToken \}.*?(?=\s+async loginWithPassword)')
if (-not $smsOwner.Success -or -not $smsOwner.Value.Contains('validToken: assertValidToken(validToken)')) {
$issues.Add('SMS owner must remain the sole validToken consumer')
$smsOwner = [regex]::Match($api, '(?s)async sendSmsCode\(\{ operationCode, phone, validToken \}.*?(?=\s+async loginWithPassword)')
if (-not $smsOwner.Success -or -not $smsOwner.Value.Contains('const normalizedValidToken = normalizeOptionalValidToken(validToken)') -or -not $smsOwner.Value.Contains('...(normalizedValidToken ? { validToken: normalizedValidToken } : {})')) {
$issues.Add('SMS owner must remain the sole validToken consumer and omit it only when the server policy closes TAC')
}
}
@@ -45,7 +45,7 @@ if ($issues.Count -eq 0) {
if ($issues.Count -gt 0) {
Write-Output 'AUTH-TAC-OPENAPI-CONTRACT BLOCKED'
foreach ($issue in $issues) { Write-Output "- $issue" }
Write-Output '- Password login is gated by native TAC on the client and must not send validToken. SMS operations consume their own validToken only.'
Write-Output '- Password login and SMS operations send a validToken only after the server marks that operation as verification-required.'
exit 1
}
+27 -22
View File
@@ -13,7 +13,7 @@ const run = async () => {
"utf8",
);
const {
AUTH_TAC_SCENE,
AUTH_VERIFICATION_OPERATION,
isAuthPhone,
assertSmsCode,
normalizeCaptchaRequirement,
@@ -23,13 +23,14 @@ const run = async () => {
} = await import(toDataModuleUrl(source));
assert.deepStrictEqual(
{ ...AUTH_TAC_SCENE },
{ ...AUTH_VERIFICATION_OPERATION },
{
SMS_LOGIN: "APP_SMS_LOGIN",
REGISTER: "APP_REGISTER",
FORGOT_PASSWORD: "APP_FORGOT_PASSWORD",
PASSWORD_LOGIN: "password-login",
SMS_LOGIN: "sms-login",
REGISTER: "register",
FORGOT_PASSWORD: "forgot-password",
},
"认证 TAC 场景必须由受保护 OpenAPI 的唯一枚举拥有",
"认证动作必须由受保护 OpenAPI 的唯一枚举拥有",
);
assert.strictEqual(isAuthPhone("13800138000"), true);
@@ -47,32 +48,28 @@ const run = async () => {
required: true,
providerCode: "TIANAI",
captchaType: "SLIDER",
sceneCode: AUTH_TAC_SCENE.REGISTER,
sceneCode: "APP_REGISTER",
ttlSeconds: 300,
},
AUTH_TAC_SCENE.REGISTER,
);
assert.deepStrictEqual(requirement, {
required: true,
providerCode: "TIANAI",
captchaType: "SLIDER",
sceneCode: AUTH_TAC_SCENE.REGISTER,
sceneCode: "APP_REGISTER",
ttlSeconds: 300,
});
assert.throws(
() => normalizeCaptchaRequirement({ ...requirement, required: false }, AUTH_TAC_SCENE.REGISTER),
/未要求行为验证|票据/,
assert.deepStrictEqual(
normalizeCaptchaRequirement({ ...requirement, required: false }),
{ required: false, sceneCode: "APP_REGISTER" },
"策略关闭时必须保留服务端绑定场景并跳过 TAC 渲染",
);
assert.throws(
() => normalizeCaptchaRequirement({ ...requirement, sceneCode: AUTH_TAC_SCENE.SMS_LOGIN }, AUTH_TAC_SCENE.REGISTER),
/场景/,
);
assert.throws(
() => normalizeCaptchaRequirement({ ...requirement, providerCode: "OTHER" }, AUTH_TAC_SCENE.REGISTER),
() => normalizeCaptchaRequirement({ ...requirement, providerCode: "OTHER" }),
/TIANAI/,
);
assert.throws(
() => normalizeCaptchaRequirement({ ...requirement, captchaType: "math" }, AUTH_TAC_SCENE.REGISTER),
() => normalizeCaptchaRequirement({ ...requirement, captchaType: "math" }),
/验证码类型/,
);
@@ -81,18 +78,18 @@ const run = async () => {
baseUrl: "https://backend-api.ddxcjp.cn/",
clientId: "client-1",
tenantId: "000000",
sceneCode: AUTH_TAC_SCENE.REGISTER,
operationCode: AUTH_VERIFICATION_OPERATION.REGISTER,
subject: "13800138000",
requirement,
});
assert.deepStrictEqual(context, {
requestId: "register-1",
baseUrl: "https://backend-api.ddxcjp.cn",
challengeUrl: "https://backend-api.ddxcjp.cn/captcha/challenge",
verifyUrl: "https://backend-api.ddxcjp.cn/captcha/verify",
challengeUrl: "https://backend-api.ddxcjp.cn/genealogy/app/auth/verification/register/challenge",
verifyUrl: "https://backend-api.ddxcjp.cn/genealogy/app/auth/verification/register/verify",
clientId: "client-1",
tenantId: "000000",
sceneCode: AUTH_TAC_SCENE.REGISTER,
operationCode: AUTH_VERIFICATION_OPERATION.REGISTER,
subject: "13800138000",
providerCode: "TIANAI",
captchaType: "SLIDER",
@@ -105,6 +102,14 @@ const run = async () => {
() => createTacRenderContext({ ...context, subject: "1380013800", requirement }),
/手机号/,
);
assert.throws(
() => createTacRenderContext({ ...context, operationCode: "APP_REGISTER", requirement }),
/认证动作/,
);
assert.throws(
() => createTacRenderContext({ ...context, requirement: { ...requirement, required: false } }),
/无需行为验证/,
);
assert.deepStrictEqual(
normalizeTacSuccess(
@@ -0,0 +1,82 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const prelude = `
const currentUser = {};
const genealogies = [];
const publicGenealogies = [];
const treeMembers = [];
const notifications = [];
const joinApplications = [];
const listFamilyFeedFixtures = () => [];
const listFamilyArticleFixtures = () => [];
const listFamilyAlbumFixtures = () => [];
const listCeremonyFixtures = () => [];
const listGrowthRecordFixtures = () => [];
const listNotificationFixtures = () => [];
const runtimeConfig = { mode: "remote", baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
const hasRemoteConfig = () => true;
const resolveRuntimeMode = () => "remote";
const AUTH_VERIFICATION_OPERATION = Object.freeze({});
const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const fromApiGenealogyAccess = () => null;
const session = { getToken: () => "session-1", saveToken() {} };
`;
const requests = [];
let nextResponse;
globalThis.uni = {
request(options) {
requests.push(options);
queueMicrotask(() => options.success(nextResponse));
return { abort() {} };
},
};
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
nextResponse = { statusCode: 200, data: { code: 200, data: [{ invitationId: "9001", inviteStatus: "PENDING" }] } };
assert.deepStrictEqual(await appApi.getCeremonyInvitations("1001", "2001"), [{ invitationId: "9001", inviteStatus: "PENDING" }]);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/ceremonies/2001/invitations");
assert.strictEqual(requests.at(-1).method, "GET");
nextResponse = { statusCode: 200, data: { code: 200, data: { invitationId: "9001", inviteStatus: "ACCEPTED" } } };
await appApi.respondToCeremonyInvitation("1001", "2001", { inviteStatus: "ACCEPTED" });
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/ceremonies/2001/invitations/me");
assert.strictEqual(requests.at(-1).method, "PUT");
assert.deepStrictEqual(requests.at(-1).data, { inviteStatus: "ACCEPTED" });
nextResponse = { statusCode: 200, data: { code: 200, data: [] } };
await appApi.replaceCeremonyInvitees("1001", "2001", { inviteeUserIds: [] });
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/ceremonies/2001/invitees");
assert.deepStrictEqual(requests.at(-1).data, { inviteeUserIds: [] });
await assert.rejects(
appApi.replaceCeremonyInvitees("1001", "2001", { inviteeUserIds: [7, 7] }),
/重复标识/,
);
await assert.rejects(
appApi.replaceCeremonyInvitees("1001", "2001", { inviteeUserIds: ["2081232520259612673"] }),
(error) => error?.code === "CEREMONY_INVITEE_ID_UNSAFE",
);
nextResponse = { statusCode: 200, data: { code: 200, data: [] } };
await appApi.getMyCeremonyInvitations();
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/ceremony-invitations/mine");
assert.strictEqual(requests.at(-1).method, "GET");
delete globalThis.uni;
process.stdout.write("CEREMONY-INVITATIONS-API-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+10 -4
View File
@@ -26,7 +26,7 @@ const run = async () => {
const runtimeConfig = { baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
const hasRemoteConfig = () => true;
const resolveRuntimeMode = () => "remote";
const AUTH_TAC_SCENE = Object.freeze({});
const AUTH_VERIFICATION_OPERATION = Object.freeze({});
const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const fromApiGenealogyAccess = () => null;
@@ -79,16 +79,16 @@ const run = async () => {
contentType: "image/png",
};
const completePayload = {
uploadId: basePayload.uploadId,
uploadId: "upload-1",
fileName: basePayload.fileName,
fileMd5: basePayload.fileMd5,
totalSize: basePayload.totalSize,
totalChunks: basePayload.totalChunks,
};
response = { statusCode: 200, data: { code: 200, data: { uploadId: "upload-1", instant: true, ossId: 900001 } } };
response = { statusCode: 200, data: { code: 200, data: { uploadId: null, instant: true, ossId: 900001 } } };
assert.deepStrictEqual(await appApi.initializeResumableUpload(basePayload), {
uploadId: "upload-1",
uploadId: null,
instant: true,
ossId: "900001",
url: "",
@@ -97,6 +97,12 @@ const run = async () => {
assert.deepStrictEqual(requests.at(-1).data, basePayload);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/init");
response = { statusCode: 200, data: { code: 200, data: { uploadId: null, instant: false, ossId: null } } };
await assert.rejects(
appApi.initializeResumableUpload(basePayload),
/uploadId/,
);
await appApi.uploadResumableChunk({
uploadId: "upload-1",
chunkIndex: 0,
+48
View File
@@ -197,6 +197,54 @@ const run = async () => {
(error) => error?.code === "GENEALOGY_RESPONSE_INVALID",
);
nextResponse = {
statusCode: 200,
data: { code: 200, data: 7 },
};
assert.strictEqual(await appApi.getUnreadNotificationCount(), 7);
assert.strictEqual(
requests.at(-1).url,
"https://backend-api.ddxcjp.cn/genealogy/app/notifications/unread-count",
);
nextResponse = {
statusCode: 200,
data: { code: 200, data: -1 },
};
await assert.rejects(
appApi.getUnreadNotificationCount(),
(error) => error?.code === "NOTIFICATION_COUNT_RESPONSE_INVALID",
);
const quota = {
createUsed: 1,
createLimit: 3,
createRemaining: 2,
canCreate: true,
joinUsed: 2,
joinLimit: 10,
joinRemaining: 8,
canJoin: true,
};
nextResponse = {
statusCode: 200,
data: { code: 200, data: quota },
};
assert.deepStrictEqual(await appApi.getGenealogyQuota(), quota);
assert.strictEqual(
requests.at(-1).url,
"https://backend-api.ddxcjp.cn/genealogy/app/genealogies/quota",
);
nextResponse = {
statusCode: 200,
data: { code: 200, data: { ...quota, canJoin: undefined } },
};
await assert.rejects(
appApi.getGenealogyQuota(),
(error) => error?.code === "GENEALOGY_QUOTA_RESPONSE_INVALID",
);
process.stdout.write("G01-MY-GENEALOGIES-RUNTIME-SMOKE PASS\n");
};
+18 -2
View File
@@ -1,7 +1,8 @@
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const cdpPort = process.env.CDP_PORT || "9222";
const connect = async () => {
const pages = await (await fetch("http://127.0.0.1:9222/json/list")).json();
const pages = await (await fetch(`http://127.0.0.1:${cdpPort}/json/list`)).json();
const page = pages.find((item) => item.type === "page" && item.url.startsWith("http://localhost:5173"));
if (!page) throw new Error("Chrome debugging has no localhost:5173 page");
@@ -49,7 +50,7 @@ const waitFor = async (send, expression, message) => {
const origin = process.argv[2] || "http://localhost:5173";
const openG03 = async (send, suffix = "") => {
const url = `${origin}/?g03CreateAudit=${Date.now()}#${"/pages/genealogy/g03-create-genealogy"}${suffix}`;
const url = `${origin}/?g03CreateAudit=${Date.now()}${suffix}#${"/pages/genealogy/g03-create-genealogy"}`;
await send("Page.navigate", { url });
await waitFor(send, `location.href === ${JSON.stringify(url)}`, "G03 navigation failed");
await waitFor(send, "Boolean(document.querySelector('.create-card'))", "G03 create form did not render");
@@ -77,6 +78,21 @@ const run = async () => {
const fakeControl = await valueOf(send, "Boolean(document.querySelector('.flow-success-dialog'))");
if (fakeControl) throw new Error("G03 must not expose the retired local bootstrap flow");
await valueOf(send, `(() => {
const input = document.querySelector('.create-card input');
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
setter.call(input, 'discard-check');
input.dispatchEvent(new Event('input', { bubbles: true }));
document.querySelector('.header-back')?.click();
return true;
})()`);
await waitFor(send, "Boolean(document.querySelector('.app-dialog-layer'))", "G03 dirty back did not require discard confirmation");
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button:first-child')?.click()");
await waitFor(send, "!document.querySelector('.app-dialog-layer')", "G03 discard cancellation did not close the dialog");
if ((await valueOf(send, "document.querySelector('.create-card input')?.value")) !== "discard-check") {
throw new Error("G03 discard cancellation lost the draft");
}
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await send("Emulation.setDeviceMetricsOverride", { ...size, deviceScaleFactor: 1, mobile: true });
await openG03(send, `&width=${size.width}`);
+69
View File
@@ -0,0 +1,69 @@
/*
* Captures the current native MuMu rendering of every app route. It uses the
* installed app's WebView and ADB; no browser emulation, fixture, or mock data.
*
* Usage:
* $env:ADB_PATH='...\\adb.exe'; node tests/mumu-page-visual-audit.js
*/
const { execFileSync } = require("node:child_process");
const { mkdirSync } = require("node:fs");
const { join } = require("node:path");
const adb = process.env.ADB_PATH || "adb";
const device = process.env.MUMU_DEVICE || "emulator-5554";
const outputDir = process.env.MUMU_AUDIT_DIR || join(process.cwd(), "tmp", "mumu-visual-audit");
const genealogyId = process.env.MUMU_GENEALOGY_ID || "2081191846772518914";
const start = Number(process.env.MUMU_AUDIT_START || 0);
const end = Number(process.env.MUMU_AUDIT_END || 52);
const routes = [
["a01", "/pages/auth/a01-entry"], ["a04", "/pages/auth/a04-register"], ["a05", "/pages/auth/a05-reset-password"],
["g01", "/pages/genealogy/g01-my-genealogies"], ["g03", "/pages/genealogy/g03-create-genealogy"], ["g05", `/pages/genealogy/g05-genealogy-overview?genealogyId=${genealogyId}`], ["g06", "/pages/genealogy/g06-search-genealogies"], ["g08", `/pages/genealogy/g08-join-application?genealogyId=${genealogyId}`], ["g09", "/pages/genealogy/g09-my-applications"], ["g10", `/pages/genealogy/g10-application-review?genealogyId=${genealogyId}`], ["g11", `/pages/genealogy/g11-genealogy-settings?genealogyId=${genealogyId}`], ["g12", `/pages/genealogy/g12-generation-poems?genealogyId=${genealogyId}`],
["t01", `/pages/tree/t01-tree-overview?genealogyId=${genealogyId}`], ["t03", `/pages/tree/t03-member-profile?genealogyId=${genealogyId}`], ["t04", `/pages/tree/t04-add-relative?genealogyId=${genealogyId}`], ["t05", `/pages/tree/t05-edit-member?genealogyId=${genealogyId}`], ["t06", `/pages/tree/t06-edit-relationship?genealogyId=${genealogyId}`], ["t07", `/pages/tree/t07-member-directory?genealogyId=${genealogyId}`], ["t08", `/pages/tree/t08-member-states?genealogyId=${genealogyId}`],
["f01", `/pages/family/f01-family-feed?genealogyId=${genealogyId}`], ["f02", `/pages/family/f02-publish-feed?genealogyId=${genealogyId}`], ["f03", `/pages/family/f03-feed-detail?genealogyId=${genealogyId}&feedId=2081197770778128385`], ["f04", `/pages/family/f04-article-list?genealogyId=${genealogyId}`], ["f05", `/pages/family/f05-article-detail?genealogyId=${genealogyId}&articleId=2081200381052891138`], ["f06", `/pages/family/f06-article-editor?genealogyId=${genealogyId}`], ["f07", `/pages/family/f07-album-list?genealogyId=${genealogyId}`], ["f08", `/pages/family/f08-album-detail?genealogyId=${genealogyId}&albumId=2081202236751392769`], ["f09", `/pages/family/f09-media-upload?genealogyId=${genealogyId}&albumId=2081202236751392769`], ["f10", `/pages/family/f10-video-list?genealogyId=${genealogyId}`],
["r01", `/pages/records/r01-people-list?genealogyId=${genealogyId}`], ["r02", `/pages/records/r02-person-detail?genealogyId=${genealogyId}`], ["r03", `/pages/records/r03-gift-list?genealogyId=${genealogyId}`], ["r04", `/pages/records/r04-gift-editor?genealogyId=${genealogyId}`], ["r05", `/pages/records/r05-ritual-list?genealogyId=${genealogyId}`], ["r06", `/pages/records/r06-ritual-detail?genealogyId=${genealogyId}`], ["r07", `/pages/records/r07-ritual-editor?genealogyId=${genealogyId}`], ["r08", `/pages/records/r08-growth-journal?genealogyId=${genealogyId}`], ["r09", `/pages/records/r09-life-events?genealogyId=${genealogyId}`], ["r10", `/pages/records/r10-memo-list?genealogyId=${genealogyId}`], ["r11", `/pages/records/r11-merit-records?genealogyId=${genealogyId}`],
["n01", "/pages/notification/n01-message-center"], ["n02", "/pages/notification/n02-message-detail"],
["m01", "/pages/profile/m01-profile-home"], ["m02", "/pages/profile/m02-edit-profile"], ["m03", "/pages/profile/m03-security-settings"], ["m04", "/pages/profile/m04-change-password"], ["m05", "/pages/profile/m05-change-phone"], ["m06", "/pages/profile/m06-help-center"], ["m07", "/pages/profile/m07-feedback"], ["m08", "/pages/profile/m08-promotion"], ["m09", "/pages/profile/m09-vip-orders"], ["m10", "/pages/profile/m10-about-settings"],
];
const run = (...args) => execFileSync(adb, ["-s", device, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function currentTarget() {
const targets = await (await fetch("http://127.0.0.1:9223/json/list")).json();
const pages = targets.filter((item) => item.type === "page" && item.title !== "View");
if (!pages.length) throw new Error("MuMu 中没有可操作的应用页面 WebView");
return pages.at(-1);
}
async function evaluate(expression) {
const target = await currentTarget();
return new Promise((resolve, reject) => {
const socket = new WebSocket(target.webSocketDebuggerUrl);
socket.onopen = () => socket.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression, awaitPromise: true, returnByValue: true } }));
socket.onmessage = (event) => {
const result = JSON.parse(event.data);
socket.close();
if (result.error || result.result?.exceptionDetails) reject(new Error(JSON.stringify(result.error || result.result.exceptionDetails)));
else resolve(result.result?.result?.value);
};
socket.onerror = () => reject(new Error("无法连接 MuMu WebView 调试端口"));
});
}
async function main() {
mkdirSync(outputDir, { recursive: true });
run("forward", "tcp:9223", "localabstract:webview_devtools_remote_2728");
try {
for (const [name, route] of routes.slice(start, end)) {
await evaluate(`uni.reLaunch({ url: ${JSON.stringify(route)} }); true`);
await delay(1800);
run("shell", "screencap", "-p", "/sdcard/jiapu-visual-audit.png");
run("pull", "/sdcard/jiapu-visual-audit.png", join(outputDir, `${name}.png`));
process.stdout.write(`captured ${name}\n`);
}
} finally {
try { run("forward", "--remove", "tcp:9223"); } catch {}
}
}
main().catch((error) => { console.error(error.stack || error.message); process.exitCode = 1; });
+1 -1
View File
@@ -547,7 +547,7 @@ foreach ($contract in @(
@{ Key = 'M04'; ParamToken = 'kind: "flow"'; SourceToken = 'allowedSources: ["M03"]' },
@{ Key = 'M05'; ParamToken = 'kind: "flow"'; SourceToken = 'allowedSources: ["M03"]' },
@{ Key = 'M06'; ParamToken = 'kind: "page"'; SourceToken = 'allowedSources: ["M01"]' },
@{ Key = 'M07'; ParamToken = 'kind: "flow"'; SourceToken = 'allowedSources: ["M06"]' },
@{ Key = 'M07'; ParamToken = 'kind: "flow"'; SourceToken = 'allowedSources: ["M01", "M06"]' },
@{ Key = 'M08'; ParamToken = 'kind: "page"'; SourceToken = 'allowedSources: ["M01"]' },
@{ Key = 'M09'; ParamToken = 'kind: "page"'; SourceToken = 'allowedSources: ["M01"]' },
@{ Key = 'M10'; ParamToken = 'kind: "page"'; SourceToken = 'allowedSources: ["M01"]' }
+33
View File
@@ -1411,6 +1411,39 @@ process.exitCode = 1;
assert.strictEqual(harness.calls[0].payload.delta, 2, "必须返回最近的既有目标实例");
}
{
const harness = await createHarness({
routes: ROUTES,
stack: [
createPage(ROUTES, "F01"),
createPage(ROUTES, "F03", { genealogyId: "g1", feedId: "feed-1" }),
],
});
assert.strictEqual(
await harness.navigation.returnTo("F01", { genealogyId: "g1" }),
true,
"根页未在 URL 保留可选上下文时,子页可按自身同一上下文返回",
);
assert.strictEqual(harness.calls[0].method, "navigateBack");
assert.strictEqual(harness.calls[0].payload.delta, 1);
}
{
const harness = await createHarness({
routes: ROUTES,
stack: [
createPage(ROUTES, "F01"),
createPage(ROUTES, "F03", { genealogyId: "g1", feedId: "feed-1" }),
],
});
await assert.rejects(
harness.navigation.returnTo("F01", { genealogyId: "g2" }),
/genealogyId.*不一致|最近实例/,
"根页隐式上下文仍不得接受与当前子页不同的家谱",
);
assert.strictEqual(harness.calls.length, 0);
}
{
const harness = await createHarness({
routes: ROUTES,
+20 -2
View File
@@ -17,13 +17,31 @@ const run = async () => {
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" }); const fromApiGenealogyAccess = () => null; const session = { getToken: () => "session-1", saveToken() {} };
`;
const requests = [];
globalThis.uni = { request(options) { requests.push(options); queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: [{ regionCode: "11", label: "Beijing", parentCode: "0", leaf: false, regionLevel: 1 }] } })); return { abort() {} }; } };
let responseData = [{ regionCode: "11", label: "Beijing", parentCode: "0", leaf: false, regionLevel: 1 }];
globalThis.uni = { request(options) { requests.push(options); queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: responseData } })); return { abort() {} }; } };
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
assert.deepStrictEqual(await appApi.getRegionChildren(), [{ regionCode: "11", label: "Beijing", parentCode: "0", leaf: false, regionLevel: 1 }]);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/region/children");
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/region/children");
assert.strictEqual(requests.at(-1).method, "GET");
assert.deepStrictEqual(requests.at(-1).data, { parentCode: "0" });
await assert.rejects(appApi.getRegionChildren(" "), /地区父级/);
responseData = [{ regionCode: "110000", label: "Beijing" }];
assert.deepStrictEqual(await appApi.getRegionPath("110000"), responseData);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/region/path/110000");
await assert.rejects(appApi.getRegionPath(" "), /行政区划编码/);
assert.deepStrictEqual(
await appApi.searchRegions({ keyword: " Beijing ", level: 2, limit: 20 }),
responseData,
);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/region/search");
assert.deepStrictEqual(requests.at(-1).data, { keyword: "Beijing", level: 2, limit: 20 });
await assert.rejects(appApi.searchRegions({ keyword: "" }), /行政区划搜索关键词/);
await assert.rejects(appApi.searchRegions({ keyword: "Beijing", level: 6 }), /行政区划级别/);
responseData = { regionCode: "110000", label: "Beijing" };
assert.deepStrictEqual(await appApi.getRegion("110000"), responseData);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/region/110000");
delete globalThis.uni;
process.stdout.write("REGION-API-RUNTIME-SMOKE PASS\n");
};
+1 -4
View File
@@ -15,8 +15,7 @@ assert(adapter, "TAC 供应商适配器必须暴露唯一全局 owner");
const context = {
tenantId: "000000",
clientId: "client-1",
sceneCode: "APP_REGISTER",
operationCode: "register",
subject: "13800138000",
providerCode: "TIANAI",
captchaType: "SLIDER",
@@ -72,8 +71,6 @@ const verifyBody = adapter.buildVerifyBody(
);
assert.deepStrictEqual(JSON.parse(JSON.stringify(verifyBody)), {
tenantId: "000000",
clientId: "client-1",
sceneCode: "APP_REGISTER",
subject: "13800138000",
challengeId: "challenge-1",
providerCode: "TIANAI",
+4 -4
View File
@@ -149,7 +149,7 @@ for (const [name, method] of Object.entries(definition.methods)) {
const strictRequest = () => instance.sendStrictRequest({
method: "POST",
url: "https://backend-api.ddxcjp.cn/captcha/verify",
url: "https://backend-api.ddxcjp.cn/genealogy/app/auth/verification/register/verify",
headers: { clientid: "client-1" },
data: { challengeId: "challenge-1" },
});
@@ -180,11 +180,11 @@ const run = async () => {
const context = {
visible: true,
requestId: "request-1",
challengeUrl: "https://backend-api.ddxcjp.cn/captcha/challenge",
verifyUrl: "https://backend-api.ddxcjp.cn/captcha/verify",
challengeUrl: "https://backend-api.ddxcjp.cn/genealogy/app/auth/verification/register/challenge",
verifyUrl: "https://backend-api.ddxcjp.cn/genealogy/app/auth/verification/register/verify",
clientId: "client-1",
tenantId: "000000",
sceneCode: "APP_REGISTER",
operationCode: "register",
subject: "13800138000",
};
instance.requestContext = context;