feat: 完成现有小程序接口的全部迁移至新后端
This commit is contained in:
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -135,6 +135,7 @@ test(
|
||||
'id',
|
||||
'nickName',
|
||||
'avatarUrl',
|
||||
'avatarAssetId',
|
||||
'avatarUrlExpiresAt',
|
||||
'createdAt',
|
||||
].sort(),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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-'));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user