test(api): 更新API客户端契约测试以符合YAML规范
- 更新登录响应模拟数据以匹配真实的AppLoginVo结构 - 添加认证客户端租户和授权字段验证 - 增加操作码枚举验证测试用例 - 移除对旧token别名的兼容性测试 - 修复测试用例中的短信验证码长度一致性问题 - 更新区域接口路径为PC专用路径 - 调整分片上传接口参数以符合新契约定义 refactor(api): 重构API客户端实现以严格遵循YAML契约 - 添加认证操作码和短信操作码枚举验证 - 实现严格的token响应解析只接受access_token字段 - 使用pickDefined函数过滤请求体中未定义的字段 - 重构认证接口参数映射以符合契约定义 - 更新区域接口路径为PC专用路径/genealogy/pc/region/* - 优化分片上传接口参数结构与契约保持一致 - 添加操作码枚举验证函数toRequiredOperationCode - 实现请求体字段选择性提取功能 feat(auth): 优化认证页面的验证码处理流程 - 添加takeCaptchaToken函数用于一次性获取验证码票据 - 更新短信验证码长度验证从4-6位改为精确4位 - 在登录和密码重置流程中集成验证码票据处理 - 修复验证码发送后票据清理逻辑 - 更新HTML模板中的验证码输入字段属性 chore(config): 提取常量配置并扩展配置对象结构 - 将客户端ID、租户ID和令牌键提取为常量 - 扩展配置对象返回客户端配置信息 - 更新配置测试用例以验证新增配置项 docs(planning): 更新PC接口对接规划文档 - 更新契约源说明以反映YAML冻结契约 - 添加YAML与在线Apifox复核对比内容 - 更新阻断项状态表格 - 修订登录响应token字段处理规范 - 更新文件上传和行政区划接口规范说明 style(profile): 优化相册管理页面的文件上传交互 - 将封面和照片OSS ID输入改为隐藏字段 - 添加文件选择标签以改善用户体验 - 移除手动输入OSS ID的选项保持界面简洁
This commit is contained in:
@@ -49,7 +49,7 @@ test('API client exposes latest document-defined PC operations', () => {
|
||||
assert.deepEqual(Object.keys(client).sort(), allowed);
|
||||
});
|
||||
|
||||
test('password login stores only the returned token', async () => {
|
||||
test('password login stores access_token from the real AppLoginVo response', async () => {
|
||||
let stored = '';
|
||||
const client = GenealogyApi.createClient({
|
||||
baseUrl: 'https://api.example.test',
|
||||
@@ -63,7 +63,38 @@ test('password login stores only the returned token', async () => {
|
||||
assert.equal(config.method, 'post');
|
||||
assert.equal(config.url, '/genealogy/pc/auth/login');
|
||||
assert.equal(config.headers.Authorization, undefined);
|
||||
return Promise.resolve({ data: { code: 200, data: { accessToken: 'access-token' } } });
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
data: {
|
||||
clientKey: 'web_pc',
|
||||
deviceType: 'pc',
|
||||
userType: 'app_user',
|
||||
profile: {
|
||||
userId: '2062179707935264769',
|
||||
tenantId: '000000',
|
||||
userNo: 'U2062179707910225920',
|
||||
phone: '19181970173',
|
||||
nickName: '叶子',
|
||||
realName: '',
|
||||
avatar: null,
|
||||
sex: '2',
|
||||
birthday: null,
|
||||
email: '',
|
||||
registerSource: 'h5',
|
||||
loginIp: '112.45.165.24',
|
||||
loginDate: '2026-07-28 11:06:09',
|
||||
status: '0',
|
||||
clientKey: 'web_pc',
|
||||
deviceType: 'pc'
|
||||
},
|
||||
access_token: 'access-token',
|
||||
expire_in: 604800,
|
||||
client_id: 'ced7e5f0498645c6ec642dcf450b036f'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -73,6 +104,95 @@ test('password login stores only the returned token', async () => {
|
||||
assert.equal(client.getToken(), 'access-token');
|
||||
});
|
||||
|
||||
test('authentication client owns tenant and grant fields and drops fields outside YAML DTOs', async () => {
|
||||
const calls = [];
|
||||
const client = GenealogyApi.createClient({
|
||||
baseUrl: 'https://api.example.test',
|
||||
tenantId: 'tenant-from-config',
|
||||
tokenStore: { getItem() { return null; }, setItem() {}, removeItem() {} },
|
||||
axiosInstance: {
|
||||
request(config) {
|
||||
calls.push(config);
|
||||
if (config.url.endsWith('/login')) {
|
||||
return Promise.resolve({ data: { code: 200, data: { access_token: 'login-token' } } });
|
||||
}
|
||||
return Promise.resolve({ data: { code: 200, data: {} } });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await client.register({
|
||||
grantType: 'legacy',
|
||||
tenantId: 'caller-tenant',
|
||||
phone: '13800000000',
|
||||
password: 'password-md5',
|
||||
smsCode: '1234',
|
||||
nickName: '小李',
|
||||
registerSource: 'APP',
|
||||
clientId: 'legacy-client',
|
||||
sceneCode: 'legacy-scene',
|
||||
validToken: 'legacy-ticket'
|
||||
});
|
||||
await client.login({
|
||||
grantType: 'legacy',
|
||||
tenantId: 'caller-tenant',
|
||||
phone: '13800000000',
|
||||
password: 'password-md5',
|
||||
validToken: 'captcha-ticket',
|
||||
clientId: 'legacy-client',
|
||||
sceneCode: 'legacy-scene'
|
||||
});
|
||||
|
||||
assert.deepEqual(calls[0].data, {
|
||||
grantType: 'password',
|
||||
tenantId: 'tenant-from-config',
|
||||
phone: '13800000000',
|
||||
password: 'password-md5',
|
||||
nickName: '小李',
|
||||
registerSource: 'PC',
|
||||
smsCode: '1234'
|
||||
});
|
||||
assert.deepEqual(calls[1].data, {
|
||||
grantType: 'password',
|
||||
tenantId: 'tenant-from-config',
|
||||
phone: '13800000000',
|
||||
password: 'password-md5',
|
||||
validToken: 'captcha-ticket'
|
||||
});
|
||||
});
|
||||
|
||||
test('authentication operationCode rejects values outside the YAML enum', async () => {
|
||||
const client = createClient();
|
||||
|
||||
await assert.rejects(
|
||||
async () => client.captchaRequirement('legacy-login', { subject: '13800000000' }),
|
||||
/不支持的PC 认证动作/
|
||||
);
|
||||
await assert.rejects(
|
||||
async () => client.sendSmsCode('password-login', { phone: '13800000000' }),
|
||||
/不支持的PC 短信认证动作/
|
||||
);
|
||||
});
|
||||
|
||||
test('login rejects legacy token aliases outside AppLoginVo', async () => {
|
||||
for (const legacyField of ['token', 'accessToken', 'tokenValue']) {
|
||||
const client = GenealogyApi.createClient({
|
||||
baseUrl: 'https://api.example.test',
|
||||
tokenStore: { getItem() { return null; }, setItem() {}, removeItem() {} },
|
||||
axiosInstance: {
|
||||
request() {
|
||||
return Promise.resolve({ data: { code: 200, data: { [legacyField]: 'legacy-token' } } });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
client.login({ phone: '13800000000', password: 'password-md5' }),
|
||||
/登录响应缺少 token/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('sending an SMS code uses the PC operation path and documented request body', async () => {
|
||||
const client = GenealogyApi.createClient({
|
||||
baseUrl: 'https://api.example.test',
|
||||
@@ -192,11 +312,11 @@ test('registration and SMS login use the PC auth endpoints with SMS fields', asy
|
||||
|
||||
await client.register({
|
||||
phone: '13800000000',
|
||||
smsCode: '123456',
|
||||
smsCode: '1234',
|
||||
nickName: '小李',
|
||||
password: 'password-md5'
|
||||
});
|
||||
await client.loginBySms({ phone: '13800000000', smsCode: '123456' });
|
||||
await client.loginBySms({ phone: '13800000000', smsCode: '1234' });
|
||||
|
||||
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
|
||||
['post', '/genealogy/pc/auth/register', {
|
||||
@@ -204,7 +324,7 @@ test('registration and SMS login use the PC auth endpoints with SMS fields', asy
|
||||
registerSource: 'PC',
|
||||
tenantId: '000000',
|
||||
phone: '13800000000',
|
||||
smsCode: '123456',
|
||||
smsCode: '1234',
|
||||
nickName: '小李',
|
||||
password: 'password-md5'
|
||||
}],
|
||||
@@ -212,7 +332,7 @@ test('registration and SMS login use the PC auth endpoints with SMS fields', asy
|
||||
grantType: 'sms',
|
||||
tenantId: '000000',
|
||||
phone: '13800000000',
|
||||
smsCode: '123456'
|
||||
smsCode: '1234'
|
||||
}]
|
||||
]);
|
||||
calls.forEach((config) => {
|
||||
@@ -282,10 +402,10 @@ test('region methods use PC paths and retain the logged-in authorization header'
|
||||
config.params,
|
||||
config.headers.Authorization
|
||||
]), [
|
||||
['get', '/genealogy/region/children', { parentCode: '0' }, 'Bearer access-token'],
|
||||
['get', '/genealogy/region/path/11', undefined, 'Bearer access-token'],
|
||||
['get', '/genealogy/region/search', { keyword: '北京', limit: 20 }, 'Bearer access-token'],
|
||||
['get', '/genealogy/region/110101', undefined, 'Bearer access-token']
|
||||
['get', '/genealogy/pc/region/children', { parentCode: '0' }, 'Bearer access-token'],
|
||||
['get', '/genealogy/pc/region/path/11', undefined, 'Bearer access-token'],
|
||||
['get', '/genealogy/pc/region/search', { keyword: '北京', limit: 20 }, 'Bearer access-token'],
|
||||
['get', '/genealogy/pc/region/110101', undefined, 'Bearer access-token']
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -304,14 +424,13 @@ test('current PC file methods use the three documented resumable paths and reque
|
||||
const chunk = new Blob(['part'], { type: 'application/octet-stream' });
|
||||
|
||||
await client.initResumableUpload({
|
||||
uploadId: 'UPLOAD202607240001',
|
||||
fileName: 'video.mp4',
|
||||
fileSize: 8388608,
|
||||
fileMd5: 'file-md5',
|
||||
chunkSize: 4194304,
|
||||
fileMd5: '0123456789abcdef0123456789abcdef',
|
||||
totalSize: 8388608,
|
||||
totalChunks: 2,
|
||||
contentType: 'video/mp4',
|
||||
bizType: 'video',
|
||||
usageScene: 'family_video'
|
||||
chunkSize: 4194304,
|
||||
contentType: 'video/mp4'
|
||||
});
|
||||
await client.uploadResumableChunk({
|
||||
uploadId: 'UPLOAD202607240001',
|
||||
@@ -321,8 +440,10 @@ test('current PC file methods use the three documented resumable paths and reque
|
||||
});
|
||||
await client.completeResumableUpload({
|
||||
uploadId: 'UPLOAD202607240001',
|
||||
fileMd5: 'file-md5',
|
||||
fileSize: 8388608
|
||||
fileName: 'video.mp4',
|
||||
fileMd5: '0123456789abcdef0123456789abcdef',
|
||||
totalSize: 8388608,
|
||||
totalChunks: 2
|
||||
});
|
||||
assert.deepEqual(calls.map((config) => [
|
||||
config.method,
|
||||
@@ -335,14 +456,13 @@ test('current PC file methods use the three documented resumable paths and reque
|
||||
['post', '/genealogy/pc/files/resumable/complete', undefined, 'Bearer access-token']
|
||||
]);
|
||||
assert.deepEqual(calls[0].data, {
|
||||
uploadId: 'UPLOAD202607240001',
|
||||
fileName: 'video.mp4',
|
||||
fileSize: 8388608,
|
||||
fileMd5: 'file-md5',
|
||||
chunkSize: 4194304,
|
||||
fileMd5: '0123456789abcdef0123456789abcdef',
|
||||
totalSize: 8388608,
|
||||
totalChunks: 2,
|
||||
contentType: 'video/mp4',
|
||||
bizType: 'video',
|
||||
usageScene: 'family_video'
|
||||
chunkSize: 4194304,
|
||||
contentType: 'video/mp4'
|
||||
});
|
||||
assert.equal(calls[1].data.get('uploadId'), 'UPLOAD202607240001');
|
||||
assert.equal(calls[1].data.get('chunkIndex'), '0');
|
||||
@@ -350,8 +470,10 @@ test('current PC file methods use the three documented resumable paths and reque
|
||||
assert.equal(calls[1].data.get('file').name, 'blob');
|
||||
assert.deepEqual(calls[2].data, {
|
||||
uploadId: 'UPLOAD202607240001',
|
||||
fileMd5: 'file-md5',
|
||||
fileSize: 8388608
|
||||
fileName: 'video.mp4',
|
||||
fileMd5: '0123456789abcdef0123456789abcdef',
|
||||
totalSize: 8388608,
|
||||
totalChunks: 2
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ test('authentication forms use the PC verification operation codes defined by Ap
|
||||
test('registration validates SMS code and password confirmation before submission', () => {
|
||||
const base = {
|
||||
phone: '13800000000',
|
||||
smsCode: '123456',
|
||||
smsCode: '1234',
|
||||
password: 'password',
|
||||
confirmPassword: 'password'
|
||||
};
|
||||
@@ -79,6 +79,19 @@ test('registration validates SMS code and password confirmation before submissio
|
||||
assert.equal(AuthPages.validateAuthValues('register', { ...base, confirmPassword: 'different' }), '两次输入的密码不一致');
|
||||
});
|
||||
|
||||
test('authentication forms reject SMS codes that are not exactly four digits', () => {
|
||||
const smsLogin = { phone: '13800000000', smsCode: '123456' };
|
||||
const register = {
|
||||
phone: '13800000000',
|
||||
smsCode: '123456',
|
||||
password: 'password',
|
||||
confirmPassword: 'password'
|
||||
};
|
||||
|
||||
assert.equal(AuthPages.validateAuthValues('sms-login', smsLogin), '请输入正确的短信验证码');
|
||||
assert.equal(AuthPages.validateAuthValues('register', register), '请输入正确的短信验证码');
|
||||
});
|
||||
|
||||
test('registration page exposes the documented SMS and confirmation inputs', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'register.html'), 'utf8');
|
||||
|
||||
@@ -92,3 +105,16 @@ test('password login page retains a verification token field for the PC captcha
|
||||
|
||||
assert.match(source, /id="login-password-form"[\s\S]*name="validToken"/);
|
||||
});
|
||||
|
||||
test('authentication captcha ticket is removed from the form when consumed', () => {
|
||||
const field = { value: 'one-time-ticket' };
|
||||
const form = {
|
||||
querySelector(selector) {
|
||||
return selector === 'input[name="validToken"]' ? field : null;
|
||||
}
|
||||
};
|
||||
|
||||
assert.equal(AuthPages.takeCaptchaToken(form), 'one-time-ticket');
|
||||
assert.equal(field.value, '');
|
||||
assert.equal(AuthPages.takeCaptchaToken(form), '');
|
||||
});
|
||||
|
||||
@@ -18,12 +18,19 @@ test('配置始终使用后端提供的 PC 接口地址', () => {
|
||||
assert.equal(production.getEnvironment(), 'production');
|
||||
assert.equal(development.getApiBaseUrl(), 'https://backend-api.ddxcjp.cn/');
|
||||
assert.equal(production.getApiBaseUrl(), 'https://backend-api.ddxcjp.cn/');
|
||||
assert.deepEqual(production.getConfig(), {
|
||||
environment: 'production',
|
||||
apiBaseUrl: 'https://backend-api.ddxcjp.cn/',
|
||||
clientId: 'ced7e5f0498645c6ec642dcf450b036f',
|
||||
tenantId: '000000',
|
||||
tokenKey: 'genealogy_auth_token'
|
||||
});
|
||||
});
|
||||
|
||||
test('PC 对接规划明确以 Apifox 目录而非导出快照为契约源', () => {
|
||||
test('PC 对接规划记录本轮由用户指定的 YAML 冻结契约', () => {
|
||||
const plan = read('docs/PC接口对接规划.md');
|
||||
|
||||
assert.match(plan, /Apifox 的 PC 目录是 PC 前端接口的唯一正式契约源/);
|
||||
assert.match(plan, /genealogy-pc-openapi\.yaml` 对接,因此该 YAML 是本轮冻结契约/);
|
||||
assert.match(plan, /旧 OpenAPI 文件不再作为新增功能依据/);
|
||||
});
|
||||
|
||||
|
||||
+25
-14
@@ -38,25 +38,28 @@ test('profile view reads only the documented profile fields', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('profile update preserves int64 OSS IDs as text', () => {
|
||||
test('profile update emits only the six fields defined by ProfileUpdateBody', () => {
|
||||
assert.deepEqual(ProfilePages.buildProfileUpdateBody({
|
||||
nickName: '小林',
|
||||
avatarOssId: '2060000000000000000',
|
||||
realName: '林某',
|
||||
avatar: '',
|
||||
sex: '0',
|
||||
birthday: '1990-01-01',
|
||||
provinceCode: '11',
|
||||
cityCode: '1101',
|
||||
districtCode: '110101'
|
||||
email: 'lin@example.com',
|
||||
avatarOssId: 'legacy-avatar',
|
||||
provinceCode: '11'
|
||||
}), {
|
||||
nickName: '小林',
|
||||
avatarOssId: '2060000000000000000',
|
||||
realName: '林某',
|
||||
sex: '0',
|
||||
birthday: '1990-01-01',
|
||||
provinceCode: '11',
|
||||
cityCode: '1101',
|
||||
districtCode: '110101'
|
||||
email: 'lin@example.com'
|
||||
});
|
||||
assert.equal(typeof ProfilePages.buildProfileUpdateBody({ avatarOssId: '2060000000000000000' }).avatarOssId, 'string');
|
||||
assert.deepEqual(ProfilePages.buildProfileUpdateBody({ avatar: '123' }), { avatar: '123' });
|
||||
assert.deepEqual(
|
||||
ProfilePages.buildProfileUpdateBody({ avatar: '2060000000000000001' }),
|
||||
{ avatar: '2060000000000000001' }
|
||||
);
|
||||
assert.equal(UploadPages.normalizeUploadResult({ ossId: '2060000000000000000' }).ossId, '2060000000000000000');
|
||||
});
|
||||
|
||||
@@ -77,13 +80,21 @@ test('region selector accepts only documented region fields and levels', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('profile data page contains PC profile, upload and region flow markers', () => {
|
||||
test('profile data page exposes every editable ProfileUpdateBody field without a manual OSS ID input', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'profile-data.html'), 'utf8');
|
||||
|
||||
assert.match(source, /data-profile-form/);
|
||||
assert.match(source, /name="avatarOssId"/);
|
||||
assert.match(source, /data-region-profile-form/);
|
||||
assert.match(source, /data-region-profile-status/);
|
||||
assert.match(source, /name="nickName"/);
|
||||
assert.match(source, /name="realName"/);
|
||||
assert.match(source, /name="avatar"/);
|
||||
assert.match(source, /name="sex"[\s\S]*<option value="2">未知<\/option>/);
|
||||
assert.match(source, /name="birthday"/);
|
||||
assert.match(source, /name="email"/);
|
||||
assert.doesNotMatch(source, /name="avatarOssId"/);
|
||||
assert.match(source, /name="avatar"[\s\S]*type="hidden"|type="hidden"[\s\S]*name="avatar"/);
|
||||
assert.doesNotMatch(source, /data-region-profile-form/);
|
||||
assert.doesNotMatch(source, /data-region-profile-status/);
|
||||
assert.match(source, /data-region-search-form/);
|
||||
assert.match(source, /src="public\/js\/upload-pages\.js"/);
|
||||
assert.match(source, /src="public\/js\/region-pages\.js"/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const GenealogyApi = require('../utils/ApiClient.js');
|
||||
|
||||
function createFailingClient(status) {
|
||||
let storedToken = 'access-token';
|
||||
const client = GenealogyApi.createClient({
|
||||
baseUrl: 'https://api.example.test',
|
||||
tokenStore: {
|
||||
getItem() {
|
||||
return storedToken;
|
||||
},
|
||||
setItem(_, value) {
|
||||
storedToken = value;
|
||||
},
|
||||
removeItem() {
|
||||
storedToken = '';
|
||||
}
|
||||
},
|
||||
axiosInstance: {
|
||||
request() {
|
||||
return Promise.reject({
|
||||
message: 'Request failed',
|
||||
response: {
|
||||
status,
|
||||
data: {
|
||||
code: status,
|
||||
msg: status === 401 ? '登录已失效' : '无权访问'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
test('HTTP 401 clears the stored login token', async () => {
|
||||
const client = createFailingClient(401);
|
||||
|
||||
await assert.rejects(client.currentProfile(), (error) => {
|
||||
assert.equal(error.status, 401);
|
||||
assert.equal(error.message, '登录已失效');
|
||||
return true;
|
||||
});
|
||||
|
||||
assert.equal(client.getToken(), '');
|
||||
});
|
||||
|
||||
test('HTTP 403 preserves the stored login token', async () => {
|
||||
const client = createFailingClient(403);
|
||||
|
||||
await assert.rejects(client.currentProfile(), (error) => {
|
||||
assert.equal(error.status, 403);
|
||||
assert.equal(error.message, '无权访问');
|
||||
return true;
|
||||
});
|
||||
|
||||
assert.equal(client.getToken(), 'access-token');
|
||||
});
|
||||
|
||||
test('business response code 401 clears the stored login token', async () => {
|
||||
let storedToken = 'access-token';
|
||||
const client = GenealogyApi.createClient({
|
||||
baseUrl: 'https://api.example.test',
|
||||
tokenStore: {
|
||||
getItem() {
|
||||
return storedToken;
|
||||
},
|
||||
setItem(_, value) {
|
||||
storedToken = value;
|
||||
},
|
||||
removeItem() {
|
||||
storedToken = '';
|
||||
}
|
||||
},
|
||||
axiosInstance: {
|
||||
request() {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
code: 401,
|
||||
msg: '登录已失效'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await assert.rejects(client.currentProfile(), (error) => {
|
||||
assert.equal(error.code, 401);
|
||||
assert.equal(error.message, '登录已失效');
|
||||
return true;
|
||||
});
|
||||
|
||||
assert.equal(client.getToken(), '');
|
||||
});
|
||||
@@ -34,14 +34,151 @@ test('security forms use PC verification operation codes and submit only documen
|
||||
assert.deepEqual(SecurityPages.buildDeactivateBody({ smsCode: '654321' }), { smsCode: '654321' });
|
||||
});
|
||||
|
||||
test('upload helper keeps the avatar flow blocked until the current PC resumable response is defined', async () => {
|
||||
assert.equal(UploadPages.getUploadMode(5 * 1024 * 1024), 'resumable');
|
||||
await assert.rejects(
|
||||
UploadPages.uploadFileForPage({ initResumableUpload() {} }, { name: 'avatar.png' }),
|
||||
/当前 PC 文件上传响应未定义头像回填字段/
|
||||
test('security forms accept only the four-digit SMS code defined by YAML', () => {
|
||||
assert.equal(SecurityPages.isSmsCode('1234'), true);
|
||||
assert.equal(SecurityPages.isSmsCode('123456'), false);
|
||||
});
|
||||
|
||||
test('security captcha ticket is removed from the form when consumed', () => {
|
||||
const field = { value: 'one-time-ticket' };
|
||||
const form = {
|
||||
querySelector(selector) {
|
||||
return selector === 'input[name="validToken"]' ? field : null;
|
||||
}
|
||||
};
|
||||
|
||||
assert.equal(SecurityPages.takeCaptchaToken(form), 'one-time-ticket');
|
||||
assert.equal(field.value, '');
|
||||
assert.equal(SecurityPages.takeCaptchaToken(form), '');
|
||||
});
|
||||
|
||||
test('resumable upload returns the instant-upload OSS result without sending chunks', async () => {
|
||||
let chunkCalls = 0;
|
||||
const file = { name: 'avatar.png', size: 8, type: 'image/png', lastModified: 1 };
|
||||
const api = {
|
||||
async initResumableUpload(body) {
|
||||
assert.deepEqual(body, {
|
||||
uploadId: 'upload-fixed',
|
||||
fileName: 'avatar.png',
|
||||
fileMd5: 'file-md5',
|
||||
totalSize: 8,
|
||||
totalChunks: 2,
|
||||
chunkSize: 4,
|
||||
contentType: 'image/png'
|
||||
});
|
||||
return { instant: true, ossId: '2062179707935264769', url: '/avatar.png', fileName: 'avatar.png', uploadedChunks: [] };
|
||||
},
|
||||
async uploadResumableChunk() { chunkCalls += 1; },
|
||||
async completeResumableUpload() { throw new Error('秒传不应调用完成接口'); }
|
||||
};
|
||||
|
||||
const result = await UploadPages.uploadFileForPage(api, file, {
|
||||
uploadId: 'upload-fixed',
|
||||
chunkSize: 4,
|
||||
hashBlob: async () => 'file-md5'
|
||||
});
|
||||
|
||||
assert.equal(result.ossId, '2062179707935264769');
|
||||
assert.equal(chunkCalls, 0);
|
||||
});
|
||||
|
||||
test('resumable upload skips uploaded chunks, retries failures and completes with the init contract', async () => {
|
||||
const uploadedIndexes = [];
|
||||
const progress = [];
|
||||
let secondChunkAttempts = 0;
|
||||
const file = {
|
||||
name: 'video.mp4',
|
||||
size: 10,
|
||||
type: 'video/mp4',
|
||||
lastModified: 2,
|
||||
slice(start, end) {
|
||||
return { size: end - start, start: start };
|
||||
}
|
||||
};
|
||||
const api = {
|
||||
async initResumableUpload() {
|
||||
return { uploadId: 'server-upload', instant: false, uploadedChunks: [0] };
|
||||
},
|
||||
async uploadResumableChunk(body) {
|
||||
uploadedIndexes.push(body.chunkIndex);
|
||||
if (body.chunkIndex === 1 && secondChunkAttempts++ === 0) throw new Error('temporary');
|
||||
},
|
||||
async completeResumableUpload(body) {
|
||||
assert.deepEqual(body, {
|
||||
uploadId: 'server-upload',
|
||||
fileName: 'video.mp4',
|
||||
fileMd5: 'hash-full',
|
||||
totalSize: 10,
|
||||
totalChunks: 3
|
||||
});
|
||||
return { ossId: '2062179707935264770', fileName: 'video.mp4', url: '/video.mp4' };
|
||||
}
|
||||
};
|
||||
|
||||
const result = await UploadPages.uploadFileForPage(api, file, {
|
||||
uploadId: 'client-upload',
|
||||
chunkSize: 4,
|
||||
maxRetries: 1,
|
||||
hashBlob: async (blob) => blob === file ? 'hash-full' : 'hash-' + blob.start,
|
||||
onProgress: (value) => progress.push(value.uploadedChunks)
|
||||
});
|
||||
|
||||
assert.deepEqual(uploadedIndexes, [1, 1, 2]);
|
||||
assert.deepEqual(progress, [1, 2, 3]);
|
||||
assert.equal(result.ossId, '2062179707935264770');
|
||||
});
|
||||
|
||||
test('upload target values preserve string IDs and append multiple attachments', () => {
|
||||
assert.equal(UploadPages.mergeUploadTargetValue('', '2062179707935264769', false), '2062179707935264769');
|
||||
assert.equal(
|
||||
UploadPages.mergeUploadTargetValue('2062179707935264769', '2062179707935264770', true),
|
||||
'2062179707935264769,2062179707935264770'
|
||||
);
|
||||
});
|
||||
|
||||
test('duplicate file submissions share one active upload', async () => {
|
||||
let initCalls = 0;
|
||||
let releaseInit;
|
||||
const pendingInit = new Promise((resolve) => { releaseInit = resolve; });
|
||||
const file = { name: 'same.png', size: 4, type: 'image/png', lastModified: 3 };
|
||||
const api = {
|
||||
async initResumableUpload() {
|
||||
initCalls += 1;
|
||||
await pendingInit;
|
||||
return { instant: true, ossId: '2062179707935264771', uploadedChunks: [] };
|
||||
}
|
||||
};
|
||||
const options = { uploadId: 'same-upload', chunkSize: 4, hashBlob: async () => 'same-hash' };
|
||||
const first = UploadPages.uploadFileForPage(api, file, options);
|
||||
const second = UploadPages.uploadFileForPage(api, file, options);
|
||||
|
||||
assert.strictEqual(first, second);
|
||||
releaseInit();
|
||||
await first;
|
||||
assert.equal(initCalls, 1);
|
||||
});
|
||||
|
||||
test('business upload pages never expose editable OSS ID fields', () => {
|
||||
const pages = [
|
||||
'profile-data.html',
|
||||
'profile-article-edit.html',
|
||||
'profile-album.html',
|
||||
'profile-gift-edit.html',
|
||||
'profile-growth-edit.html',
|
||||
'profile-memo-edit.html',
|
||||
'profile-relative-edit.html'
|
||||
];
|
||||
|
||||
pages.forEach((file) => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
||||
|
||||
assert.doesNotMatch(source, /name="(?:avatar|coverOssId|ossId|mediaOssIds)"[^>]*type="text"/);
|
||||
assert.match(source, /data-upload-target=/);
|
||||
assert.match(source, /public\/js\/md5\.js/);
|
||||
assert.match(source, /public\/js\/upload-pages\.js/);
|
||||
});
|
||||
});
|
||||
|
||||
test('security page field names match phone-change and account-deactivation DTOs', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'profile-security.html'), 'utf8');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user