feat: 完成现有小程序接口的全部迁移至新后端

This commit is contained in:
R524809
2026-10-19 13:17:51 +08:00
parent 8827110f2f
commit d30cf25890
148 changed files with 13287 additions and 34 deletions
+94
View File
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import * as qiniu from 'qiniu';
import { imageInput } from '../src/assets/image-input';
import { bufferEtag, QiniuGateway } from '../src/assets/qiniu.gateway';
import { testConfig } from './test-config';
import { ApiException } from '../src/common/api-exception';
test('图片上传按内容头校验格式/完整性/像素限制,拒绝 SVG 与超限文件', () => {
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+kWQAAAABJRU5ErkJggg==',
'base64',
);
assert.equal(imageInput(png).mimeType, 'image/png');
assert.throws(() =>
imageInput(Buffer.from('<svg><script>x</script></svg>')),
);
assert.throws(() => imageInput(png.subarray(0, 30)));
assert.throws(() => imageInput(Buffer.alloc(2 * 1024 * 1024 + 1)));
const huge = Buffer.from(png);
huge.writeUInt32BE(100000, 16);
assert.throws(() => imageInput(huge));
assert.equal(
bufferEtag(Buffer.from('hello')),
'Fqr0xh3cxeii2r7eDztILNmuqUNN',
);
});
test('七牛 SDK 接口适配:正确处理 wrapper 612、新增和同内容恢复,不覆盖冲突', async (context) => {
const prototype = qiniu.rs.BucketManager.prototype;
const uploader = qiniu.form_up.FormUploader.prototype;
const originals = {
info: prototype.getBucketInfo,
stat: prototype.stat,
remove: prototype.delete,
put: uploader.put,
};
context.after(() => {
prototype.getBucketInfo = originals.info;
prototype.stat = originals.stat;
prototype.delete = originals.remove;
uploader.put = originals.put;
});
const bytes = Buffer.from('hello');
let stored: { hash: string; fsize: number } | null = null;
let putCount = 0;
prototype.getBucketInfo = (async () => ({
resp: { statusCode: 200 },
data: { private: 0 },
})) as unknown as typeof prototype.getBucketInfo;
prototype.stat = (async () =>
stored
? { resp: { statusCode: 200 }, data: stored }
: {
resp: { statusCode: 612 },
data: { error: 'missing' },
}) as unknown as typeof prototype.stat;
prototype.delete = (async () => ({
resp: { statusCode: 612 },
data: { error: 'missing' },
})) as unknown as typeof prototype.delete;
uploader.put = (async () => {
putCount++;
stored = { hash: bufferEtag(bytes), fsize: bytes.length };
return { resp: { statusCode: 200 }, data: stored };
}) as unknown as typeof uploader.put;
const gateway = new QiniuGateway({
...testConfig,
qiniuAk: 'test-ak',
qiniuSk: 'test-sk',
qiniuBucket: 'test-public',
qiniuPublicDomain: 'https://cdn.test',
});
await gateway.put('test/a.png', bytes, 'image/png');
await gateway.put('test/a.png', bytes, 'image/png');
assert.equal(putCount, 1);
await assert.rejects(
gateway.put('test/a.png', Buffer.from('different'), 'image/png'),
(error: unknown) =>
error instanceof ApiException &&
error.reason === 'ASSET_UPSTREAM_UNAVAILABLE',
);
assert.equal(putCount, 1);
await gateway.remove('test-public', 'test/missing.png');
prototype.getBucketInfo = (async () => ({
resp: { statusCode: 403 },
data: { error: 'private-message' },
})) as unknown as typeof prototype.getBucketInfo;
await assert.rejects(
gateway.put('new.png', bytes, 'image/png'),
(error: unknown) =>
error instanceof ApiException && error.getStatus() === 503,
);
});
+1
View File
@@ -135,6 +135,7 @@ test(
'id',
'nickName',
'avatarUrl',
'avatarAssetId',
'avatarUrlExpiresAt',
'createdAt',
].sort(),
+4
View File
@@ -12,6 +12,8 @@ test('日志为单行 JSON,递归脱敏并安全处理循环对象与 bigint',
nested.self = nested;
logger.log('test_event', {
authorization: 'Bearer private-token',
qiniuAk: 'private-ak',
QINIU_SK: 'private-sk',
nested,
body: { code: 'login-secret' },
});
@@ -23,6 +25,8 @@ test('日志为单行 JSON,递归脱敏并安全处理循环对象与 bigint',
assert.equal(log.context.nested.self, '[Circular]');
assert.equal(log.context.nested.amount, '9');
assert.equal(log.context.body, '[REDACTED]');
assert.equal(log.context.qiniuAk, '[REDACTED]');
assert.equal(log.context.QINIU_SK, '[REDACTED]');
assert.ok(!output[0]!.includes('private-'));
assert.ok(!output[0]!.includes('login-secret'));
});
@@ -0,0 +1,739 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { test } from 'node:test';
import { Test } from '@nestjs/testing';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from '../src/app.module';
import { configureApplication } from '../src/app.setup';
import { AppLogger } from '../src/logging/app-logger';
import { PrismaService } from '../src/database/prisma.service';
import { JwtTokenService } from '../src/auth/jwt-token.service';
import { ASSET_STORAGE } from '../src/assets/qiniu.gateway';
import { AssetService } from '../src/assets/asset.service';
import { SiteService } from '../src/site/site.service';
import { testConfig } from './test-config';
const databaseUrl = process.env.TEST_DATABASE_URL;
test(
'真实 PostgreSQL:本人业务、七牛上传、管理写入与首页推荐完整迁移',
{ skip: !databaseUrl },
async (context) => {
const url = new URL(databaseUrl!);
assert.match(url.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/);
assert.ok(['127.0.0.1', 'localhost'].includes(url.hostname));
let holdUpload: Promise<void> | undefined;
let uploadStarted: (() => void) | undefined;
const writes: string[] = [];
const removals: string[] = [];
const module = await Test.createTestingModule({
imports: [
AppModule.forRoot({
...testConfig,
databaseUrl: databaseUrl!,
qiniuBucket: 'test-public',
qiniuPublicDomain: 'https://cdn.example.test',
}),
],
})
.overrideProvider(ASSET_STORAGE)
.useValue({
async put(key: string) {
writes.push(key);
uploadStarted?.();
if (holdUpload) await holdUpload;
},
async remove(_bucket: string, key: string) {
removals.push(key);
},
})
.compile();
const logger = new AppLogger('error', () => {});
const app = module.createNestApplication<NestExpressApplication>({
bodyParser: false,
logger,
});
context.after(() => app.close());
configureApplication(
app,
{ ...testConfig, databaseUrl: databaseUrl! },
logger,
);
await app.listen(0, '127.0.0.1');
const origin = await app.getUrl();
const db = app.get(PrismaService).client;
const tokenService = app.get(JwtTokenService);
const ids = {
admin: `m-admin-${randomUUID()}`,
a: `m-a-${randomUUID()}`,
b: `m-b-${randomUUID()}`,
};
const tokens: Record<string, string> = {};
for (const [name, id] of Object.entries(ids)) {
await db.user.create({
data: {
id,
openid: id,
role: name === 'admin' ? 'admin' : 'user',
},
});
tokens[name] = (
await tokenService.issueAccessToken(id, 1)
).accessToken;
}
const categoryId = `m-cat-${randomUUID()}`;
await db.category.create({
data: {
id: categoryId,
name: '测试分类',
icon: '📘',
color: '#fff',
status: 'active',
},
});
const previewId = `m-preview-${randomUUID()}`;
await db.asset.create({
data: {
id: previewId,
provider: 'external',
externalUrl: 'https://cdn.example.test/a.jpg',
purpose: 'preview',
visibility: 'public',
status: 'ready',
mimeType: 'image/jpeg',
},
});
const worksheetId = `m-worksheet-${randomUUID()}`;
await db.worksheet.create({
data: {
id: worksheetId,
title: '测试题型',
subtitle: '',
categoryId,
path: '/mathPages/mathDraw/mathDraw',
ageMin: 3,
ageMax: 8,
difficulty: 1,
previewAssetId: previewId,
status: 'active',
likesSeed: 100,
downloadsSeed: 100,
},
});
// JSON 类型仅用于测试响应解析,生产路径始终使用 DTO 与显式白名单。
async function request(
path: string,
token = '',
method = 'GET',
data?: unknown,
) {
const response = await fetch(`${origin}/api/v1${path}`, {
method,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(data === undefined
? {}
: { 'Content-Type': 'application/json' }),
},
...(data === undefined ? {} : { body: JSON.stringify(data) }),
});
return {
status: response.status,
body: (await response.json()) as {
code: number;
data: Record<string, unknown>;
reason?: string;
},
};
}
await context.test(
'本人接口拒绝匿名、越权字段;管理员接口拒绝普通用户',
async () => {
assert.equal((await request('/me/favorites')).status, 401);
assert.equal(
(
await request('/me/profile', tokens.a, 'PUT', {
role: 'admin',
})
).status,
400,
);
assert.equal(
(await request('/me/favorites?userId=other', tokens.a))
.status,
400,
);
assert.equal(
(await request('/admin/site/config', tokens.a)).status,
403,
);
assert.equal(
(
await request(
'/admin/worksheets/batch',
tokens.a,
'POST',
{ patches: [{ id: worksheetId, likes: 100 }] },
)
).status,
403,
);
},
);
await context.test(
'并发收藏幂等,真实计数只变一次;列表与状态仅返回本人数据',
async () => {
const replies = await Promise.all(
Array.from({ length: 8 }, () =>
request(
`/me/favorites/${worksheetId}`,
tokens.a,
'PUT',
),
),
);
assert.ok(replies.every((reply) => reply.status === 200));
assert.equal(
await db.favorite.count({
where: { userId: ids.a, worksheetId },
}),
1,
);
assert.equal(
(
await db.worksheet.findUniqueOrThrow({
where: { id: worksheetId },
})
).likes,
1n,
);
const own = await request('/me/favorites', tokens.a);
assert.equal((own.body.data.items as Array<unknown>).length, 1);
assert.equal(
(await request('/me/favorites', tokens.b)).body.data.total,
0,
);
assert.deepEqual(
(
await request('/me/favorites/check', tokens.b, 'POST', {
worksheetIds: [worksheetId],
})
).body.data,
{},
);
assert.equal(
(
await request('/me/favorites/check', tokens.a, 'POST', {
worksheetIds: [worksheetId],
})
).body.data[worksheetId],
true,
);
},
);
const exportId = randomUUID();
await context.test(
'并发成功导出只计一次下载,冲突与未发布题型拒绝写入',
async () => {
const replies = await Promise.all(
Array.from({ length: 8 }, () =>
request('/me/downloads', tokens.a, 'POST', {
worksheetId,
exportId,
}),
),
);
assert.ok(replies.every((reply) => reply.status === 201));
assert.equal(
await db.downloadLog.count({ where: { userId: ids.a } }),
1,
);
assert.equal(
(
await db.worksheet.findUniqueOrThrow({
where: { id: worksheetId },
})
).downloads,
1n,
);
assert.equal(
(await db.user.findUniqueOrThrow({ where: { id: ids.a } }))
.totalDownloads,
1n,
);
assert.equal(
(
await request('/me/downloads', tokens.a, 'POST', {
worksheetId: 'other',
exportId,
})
).status,
409,
);
assert.equal(
(
await request('/me/downloads', tokens.a, 'POST', {
worksheetId: 'missing',
exportId: randomUUID(),
})
).status,
404,
);
assert.equal(
(await request('/me/stats', tokens.a)).body.data
.downloadCount,
1,
);
},
);
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+kWQAAAABJRU5ErkJggg==',
'base64',
);
async function upload(
path: string,
token: string,
id = randomUUID(),
bytes = png,
) {
const data = new FormData();
data.append('uploadId', id);
data.append(
'file',
new Blob([new Uint8Array(bytes)], { type: 'image/png' }),
'a.png',
);
const response = await fetch(`${origin}/api/v1${path}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: data,
});
return {
status: response.status,
body: (await response.json()) as {
data: { id: string; url: string };
},
};
}
let avatarId = '';
await context.test(
'上传头像后仅可绑定本人资源,GET /me 返回新域名;清除资料安排对象删除',
async () => {
const uploadId = randomUUID();
const avatar = await upload(
'/me/avatar-upload',
tokens.a!,
uploadId,
);
assert.equal(avatar.status, 201);
avatarId = avatar.body.data.id;
assert.match(
avatar.body.data.url,
/^https:\/\/cdn.example.test\/doodle-assets\/user-avatars\//,
);
assert.equal(
(await upload('/me/avatar-upload', tokens.a!, uploadId))
.body.data.id,
avatarId,
);
assert.equal(
(
await request('/me/profile', tokens.b, 'PUT', {
avatarAssetId: avatarId,
})
).status,
400,
);
const profile = await request('/me/profile', tokens.a, 'PUT', {
nickName: '新昵称',
avatarAssetId: avatarId,
});
assert.equal(profile.status, 200);
assert.equal(profile.body.data.avatarUrl, avatar.body.data.url);
assert.equal(profile.body.data.nickName, '新昵称');
assert.equal(
(await request('/me', tokens.a)).body.data.avatarAssetId,
avatarId,
);
assert.equal(
(await upload('/admin/assets/preview-upload', tokens.a!))
.status,
403,
);
assert.equal(
(
await upload(
'/me/avatar-upload',
tokens.a!,
randomUUID(),
Buffer.from('<svg>not an image</svg>'),
)
).status,
400,
);
assert.equal(
(await request('/me/profile', tokens.a, 'DELETE')).status,
200,
);
assert.equal(
(
await db.asset.findUniqueOrThrow({
where: { id: avatarId },
})
).status,
'deleting',
);
await app.get(AssetService).cleanup();
assert.equal(
(
await db.asset.findUniqueOrThrow({
where: { id: avatarId },
})
).status,
'deleted',
);
assert.equal(removals.length, 1);
},
);
await context.test(
'管理员发布、PUT 分类更新和批次预演;失败批次完整回滚',
async () => {
assert.equal(
(
await request(
`/admin/categories/${categoryId}`,
tokens.admin,
'PUT',
{ name: '新分类名' },
)
).status,
200,
);
const preview = await upload(
'/admin/assets/preview-upload',
tokens.admin!,
);
assert.equal(preview.status, 201);
const id = `m-publish-${randomUUID()}`;
const input = {
id,
title: '新题型',
subtitle: '',
categoryId,
path: '/mathPages/mathDraw/mathDraw',
ageMin: 3,
ageMax: 8,
difficulty: 1,
previewAssetId: preview.body.data.id,
tags: ['测试'],
status: 'active',
grades: [1, 3],
};
assert.equal(
(
await request(
'/admin/worksheets/publish',
tokens.admin,
'POST',
input,
)
).status,
201,
);
assert.match(
String(
(await request(`/worksheets/${id}`)).body.data
.previewImg,
),
/^https:\/\/cdn.example.test\//,
);
await request(
'/admin/worksheets/publish',
tokens.admin,
'POST',
{ ...input, id: worksheetId },
);
assert.equal(
(
await db.worksheet.findUniqueOrThrow({
where: { id: worksheetId },
})
).firstPublishedAt,
null,
);
await request(
'/admin/worksheets/publish',
tokens.admin,
'POST',
{ ...input, id: worksheetId, status: 'draft' },
);
await request(
'/admin/worksheets/publish',
tokens.admin,
'POST',
{ ...input, id: worksheetId, status: 'active' },
);
assert.equal(
(
await db.worksheet.findUniqueOrThrow({
where: { id: worksheetId },
})
).firstPublishedAt,
null,
);
const before = (
await db.worksheet.findUniqueOrThrow({
where: { id: worksheetId },
})
).likes;
assert.equal(
(
await request(
'/admin/worksheets/batch',
tokens.admin,
'POST',
{
patches: [{ id: worksheetId, likes: 77 }],
dryRun: true,
},
)
).status,
201,
);
assert.equal(
(
await db.worksheet.findUniqueOrThrow({
where: { id: worksheetId },
})
).likes,
before,
);
assert.equal(
(
await request(
'/admin/worksheets/batch',
tokens.admin,
'POST',
{
patches: [
{ id: worksheetId, likes: 77 },
{ id: 'zzz-missing', likes: 1 },
],
},
)
).status,
404,
);
assert.equal(
(
await db.worksheet.findUniqueOrThrow({
where: { id: worksheetId },
})
).likes,
before,
);
assert.equal(
(
await request(
'/admin/worksheets/batch',
tokens.admin,
'POST',
{
patches: [
{
id: worksheetId,
tags: ['新的标签'],
likesSeed: '123',
grades: [1, 5],
},
],
},
)
).status,
201,
);
},
);
await context.test(
'分龄覆盖、首页重建和持久开关;旧预拉取地址不受影响',
async () => {
const age = await request('/recommendations/age?ageBand=5-6');
assert.equal(age.status, 200);
assert.ok(
(age.body.data.items as Array<{ id: string }>).some(
(row) => row.id === worksheetId,
),
);
const rebuilt = await request(
'/admin/site/rebuild',
tokens.admin,
'POST',
{
page: 'home',
featuredIds: [worksheetId],
hotIds: [],
sections: [],
},
);
assert.equal(rebuilt.status, 201);
const config = await request('/page-config');
assert.equal(
(
config.body.data.home as {
featured: Array<{ id: string }>;
}
).featured[0]?.id,
worksheetId,
);
assert.equal(
(
await request(
'/admin/site/timer',
tokens.admin,
'PUT',
{ enabled: false },
)
).status,
200,
);
assert.equal(
(await request('/admin/site/timer', tokens.admin)).body.data
.enabled,
false,
);
assert.ok(
writes.every((key) => !key.includes('page-config.json')),
);
assert.equal(
(
await request(
`/admin/worksheets/${worksheetId}/status`,
tokens.admin,
'PUT',
{ status: 'hidden' },
)
).status,
200,
);
const updated = await request('/page-config');
assert.equal(
(updated.body.data.home as { featured: unknown[] }).featured
.length,
0,
);
const records = await request('/me/favorites', tokens.a);
assert.equal(
(
records.body.data.items as Array<{ worksheet: unknown }>
)[0]?.worksheet,
null,
);
},
);
await context.test(
'定时任务通过持久 jobRun 去重,不重复执行已成功日期',
async () => {
await request('/admin/site/timer', tokens.admin, 'PUT', {
enabled: true,
});
const site = app.get(SiteService);
const date = new Date('2031-01-01T22:00:00Z');
await Promise.all([site.tick(date), site.tick(date)]);
await site.tick(date);
const job = await db.jobRun.findUniqueOrThrow({
where: {
jobName_runKey: {
jobName: 'home-auto-refresh',
runKey: '2031-01-01',
},
},
});
assert.equal(job.status, 'succeeded');
assert.equal(job.attempt, 1);
},
);
await context.test(
'注销与慢上传并发时,迟到文件仍会重新进入删除队列',
async () => {
const id = `m-race-${randomUUID()}`;
await db.user.create({ data: { id, openid: id } });
const token = (await tokenService.issueAccessToken(id, 1))
.accessToken;
let release!: () => void;
holdUpload = new Promise<void>((resolve) => {
release = resolve;
});
const started = new Promise<void>((resolve) => {
uploadStarted = resolve;
});
const pending = upload('/me/avatar-upload', token);
await started;
const asset = await db.asset.findFirstOrThrow({
where: { ownerUserId: id, purpose: 'avatar' },
});
assert.equal(
(await request('/me', token, 'DELETE')).status,
200,
);
await app.get(AssetService).cleanup();
assert.equal(
(
await db.asset.findUniqueOrThrow({
where: { id: asset.id },
})
).status,
'deleted',
);
release();
assert.equal((await pending).status, 401);
assert.equal(
(
await db.asset.findUniqueOrThrow({
where: { id: asset.id },
})
).status,
'deleting',
);
holdUpload = undefined;
uploadStarted = undefined;
await app.get(AssetService).cleanup();
assert.equal(
(
await db.asset.findUniqueOrThrow({
where: { id: asset.id },
})
).status,
'deleted',
);
},
);
await context.test(
'注销删除本人明细、撤销旧 JWT,其余用户不受影响',
async () => {
assert.equal(
(await request('/me', tokens.a, 'DELETE')).status,
200,
);
assert.equal(
await db.favorite.count({ where: { userId: ids.a } }),
0,
);
assert.equal(
await db.downloadLog.count({ where: { userId: ids.a } }),
0,
);
assert.equal(
await db.user.findUnique({ where: { id: ids.a } }),
null,
);
assert.equal((await request('/me', tokens.a)).status, 401);
assert.equal((await request('/me', tokens.b)).status, 200);
assert.ok(
await db.adminAuditLog.count({
where: { action: 'user.delete', resourceId: ids.a },
}),
);
},
);
},
);
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
publicObjectUrl,
publicResourceUrl,
} from '../src/content/public-resource-url';
import { previewUrl, type PreviewAssetRow } from '../src/content/preview-url';
const config = {
qiniuPublicDomain: 'https://cdn.joeyone.cn',
qiniuKeyPrefix: 'doodle-assets/',
};
test('公开资源:字体相对路径与完整对象 key 生成相同地址,中文按段编码', () => {
const path = 'assets/fonts/ToneOZ-Pinyin-Regular.ttf';
const expected =
'https://cdn.joeyone.cn/doodle-assets/assets/fonts/ToneOZ-Pinyin-Regular.ttf';
assert.equal(publicResourceUrl(config, path), expected);
assert.equal(
publicObjectUrl(config.qiniuPublicDomain, `doodle-assets/${path}`),
expected,
);
assert.equal(
publicResourceUrl({ ...config, qiniuKeyPrefix: '' }, path),
`${config.qiniuPublicDomain}/${path}`,
);
assert.equal(
publicResourceUrl(config, 'user-avatars/中文 图.png'),
`${config.qiniuPublicDomain}/doodle-assets/user-avatars/${encodeURIComponent('中文 图.png')}`,
);
for (const path of [
'',
'/absolute',
'../escape',
'a/../b',
'a//b',
'a\\b',
'a\n',
'a/',
]) {
assert.equal(publicResourceUrl(config, path), null);
assert.equal(publicObjectUrl(config.qiniuPublicDomain, path), null);
}
assert.equal(
publicResourceUrl({ ...config, qiniuPublicDomain: null }, path),
null,
);
});
test('预览图:完整 key 不重复加前缀,pending 或 private 资产仍留空', () => {
const row: PreviewAssetRow = {
assetStatus: 'ready',
assetVisibility: 'public',
assetPurpose: 'preview',
assetOwnerId: null,
assetProvider: 'qiniu',
assetKey: 'doodle-assets/assets/previews/math/example.jpg',
assetExternalUrl: null,
assetMimeType: 'image/jpeg',
};
assert.equal(
previewUrl(row, config.qiniuPublicDomain),
`${config.qiniuPublicDomain}/${row.assetKey}`,
);
assert.equal(
previewUrl(
{ ...row, assetStatus: 'pending' },
config.qiniuPublicDomain,
),
null,
);
assert.equal(
previewUrl(
{ ...row, assetVisibility: 'private' },
config.qiniuPublicDomain,
),
null,
);
});
+52
View File
@@ -164,3 +164,55 @@ test('题型新品窗口与公共资源域名校验,缺省域名不构造七
/QINIU_PUBLIC_DOMAIN/,
);
});
test('七牛空间与路径前缀校验,允许空前缀且不回显非法配置', () => {
const config = getServerConfig({
...env,
QINIU_KEY_PREFIX: 'doodle-assets///',
QINIU_BUCKET: 'doodle-cn',
});
assert.equal(config.qiniuKeyPrefix, 'doodle-assets/');
assert.equal(config.qiniuBucket, 'doodle-cn');
assert.equal(
getServerConfig({ ...env, QINIU_KEY_PREFIX: '' }).qiniuKeyPrefix,
'',
);
for (const value of [
'/absolute',
'/',
'../escape',
'a/../b',
'a//b',
'a\\b',
'a\n',
])
assert.throws(
() => getServerConfig({ ...env, QINIU_KEY_PREFIX: value }),
/QINIU_KEY_PREFIX/,
);
assert.throws(
() => getServerConfig({ ...env, QINIU_BUCKET: 'bad/bucket' }),
/QINIU_BUCKET/,
);
});
test('七牛上传凭据成对校验且不泄漏值,刷新小时限制为 UTC 0~23', () => {
assert.equal(getServerConfig(env).qiniuAk, '');
for (const fields of [
{ QINIU_AK: 'private-ak' },
{ QINIU_SK: 'private-sk' },
{ QINIU_AK: 'a b', QINIU_SK: 'private-sk' },
{ QINIU_REGION: '../escape' },
{ SITE_REFRESH_HOUR_UTC: '24' },
])
assert.throws(
() => getServerConfig({ ...env, ...fields }),
(error: unknown) =>
error instanceof Error && !error.message.includes('private-'),
);
assert.equal(
getServerConfig({ ...env, SITE_REFRESH_HOUR_UTC: '8' })
.siteRefreshHourUtc,
8,
);
});
+57
View File
@@ -106,3 +106,60 @@ test('微信错误、畸形响应和网络失败映射为有限安全错误,
error.reason === 'AUTH_UPSTREAM_UNAVAILABLE',
);
});
test('微信白名单拒绝记录有限错误码和出口 IP,可按 requestId 排查且不泄漏原文', async () => {
const diagnostics: unknown[] = [];
const http = responding({
errcode: 40164,
errmsg: 'invalid ip 98.98.125.169, not in whitelist secret=private-secret',
session_key: 'private-session-key',
});
await assert.rejects(
new WechatClient(testConfig, http, (entry) =>
diagnostics.push(entry),
).exchangeCode('private-once-code', 'req_diagnostic'),
(error: unknown) =>
error instanceof ApiException &&
error.getStatus() === 503 &&
error.reason === 'AUTH_UPSTREAM_UNAVAILABLE',
);
assert.deepEqual(diagnostics, [
{
request_id: 'req_diagnostic',
failure_type: 'wechat_error',
http_status: 200,
wechat_errcode: 40164,
egress_ip: '98.98.125.169',
},
]);
const output = JSON.stringify(diagnostics);
for (const value of [
'private-secret',
'private-session-key',
'private-once-code',
testConfig.wechatMiniAppSecret,
])
assert.ok(!output.includes(value));
});
test('网络、HTTP 和响应解析诊断不携带原异常或微信响应', async () => {
const diagnostics: unknown[] = [];
for (const http of [
(async () => {
throw new Error('https://api.weixin.qq.com?secret=private-secret');
}) as WechatFetch,
responding({}, 502),
(async () => new Response('private-invalid-json')) as WechatFetch,
])
await assert.rejects(
new WechatClient(testConfig, http, (entry) =>
diagnostics.push(entry),
).exchangeCode('private-code'),
);
assert.deepEqual(diagnostics, [
{ request_id: undefined, failure_type: 'network' },
{ request_id: undefined, failure_type: 'http', http_status: 502 },
{ request_id: undefined, failure_type: 'json', http_status: 200 },
]);
assert.ok(!JSON.stringify(diagnostics).includes('private-'));
});