feat: 完成分类、worksheet 接口开发,资源迁移到七牛
This commit is contained in:
@@ -264,7 +264,7 @@ test('HTTP 中间件、校验、日志、Swagger 与 JWT 全局守卫', async (c
|
||||
assert.ok(!logs.join('\n').includes(userToken));
|
||||
});
|
||||
|
||||
test('生产默认不暴露 Swagger;账户查询未接入时不放行有效 JWT', async (context) => {
|
||||
test('生产默认不暴露 Swagger;真实账户数据库不可用时不放行有效 JWT', async (context) => {
|
||||
const config = {
|
||||
...testConfig,
|
||||
nodeEnv: 'production' as const,
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import 'reflect-metadata';
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import type { ApiSuccess, WechatLoginResult } from '@doodle/contracts';
|
||||
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 { WECHAT_FETCH, type WechatFetch } from '../src/auth/wechat-client';
|
||||
import { Roles } from '../src/auth/auth.decorators';
|
||||
import { JwtTokenService } from '../src/auth/jwt-token.service';
|
||||
import { AccountRepository } from '../src/auth/account.repository';
|
||||
import { testConfig } from './test-config';
|
||||
|
||||
@Controller('test-admin')
|
||||
class AdminProbe {
|
||||
@Get()
|
||||
@Roles('admin')
|
||||
get() {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.TEST_DATABASE_URL;
|
||||
test(
|
||||
'真实 PostgreSQL:微信登录、并发、本人隔离及账户状态鉴权',
|
||||
{ skip: !databaseUrl },
|
||||
async (context) => {
|
||||
// 即使手工运行测试,也只允许本机脚本创建的短期库,禁止误用开发/生产数据库。
|
||||
const parsed = new URL(databaseUrl!);
|
||||
assert.ok(['127.0.0.1', 'localhost'].includes(parsed.hostname));
|
||||
assert.match(parsed.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/);
|
||||
const config = { ...testConfig, databaseUrl: databaseUrl! };
|
||||
const logs: string[] = [];
|
||||
const logger = new AppLogger('info', (line) => logs.push(line));
|
||||
const requests: string[] = [];
|
||||
const http: WechatFetch = async (input) => {
|
||||
const code = new URL(String(input)).searchParams.get('js_code')!;
|
||||
requests.push(code);
|
||||
if (code === 'bad-code')
|
||||
return Response.json({
|
||||
errcode: 40029,
|
||||
errmsg: 'private-upstream-message',
|
||||
});
|
||||
if (code === 'risk-code') return Response.json({ errcode: 40226 });
|
||||
if (code === 'quota-code') return Response.json({ errcode: 45011 });
|
||||
if (code === 'network-code')
|
||||
throw new Error('private-network-secret');
|
||||
return Response.json({
|
||||
openid: code.startsWith('concurrent-')
|
||||
? 'ConcurrentOpenID'
|
||||
: code,
|
||||
session_key: 'private-session-key',
|
||||
...(code === 'LegacyOpenID' ? { unionid: 'FreshUnion' } : {}),
|
||||
});
|
||||
};
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [AppModule.forRoot(config)],
|
||||
controllers: [AdminProbe],
|
||||
})
|
||||
.overrideProvider(WECHAT_FETCH)
|
||||
.useValue(http)
|
||||
.compile();
|
||||
const app = module.createNestApplication<NestExpressApplication>({
|
||||
logger,
|
||||
bodyParser: false,
|
||||
});
|
||||
context.after(() => app.close());
|
||||
configureApplication(app, config, logger);
|
||||
await app.listen(0, '127.0.0.1');
|
||||
const origin = await app.getUrl();
|
||||
const prisma = app.get(PrismaService).client;
|
||||
const tokens = app.get(JwtTokenService);
|
||||
const repository = app.get(AccountRepository);
|
||||
const request = (path: string, token?: string) =>
|
||||
fetch(`${origin}/api/v1/${path}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const login = (code: string, mode = 'start', extra: object = {}) =>
|
||||
fetch(`${origin}/api/v1/auth/wechat/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Forwarded-For': 'untrusted-client-ip',
|
||||
},
|
||||
body: JSON.stringify({ code, mode, ...extra }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
const reason = async (
|
||||
response: Response,
|
||||
status: number,
|
||||
expected: string,
|
||||
) => {
|
||||
assert.equal(response.status, status);
|
||||
const body = (await response.json()) as {
|
||||
reason: string;
|
||||
requestId: string;
|
||||
};
|
||||
assert.equal(body.reason, expected);
|
||||
assert.ok(body.requestId);
|
||||
};
|
||||
const loginData = async (response: Response) => {
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||
return ((await response.json()) as ApiSuccess<WechatLoginResult>)
|
||||
.data;
|
||||
};
|
||||
|
||||
await context.test('初始配置和只读就绪检查可用', async () => {
|
||||
assert.equal((await request('health/ready')).status, 200);
|
||||
assert.equal(await prisma.pageConfig.count(), 1);
|
||||
assert.equal(await prisma.setting.count(), 1);
|
||||
assert.equal(await prisma.user.count(), 0);
|
||||
await reason(await request('me'), 401, 'AUTH_REQUIRED');
|
||||
});
|
||||
|
||||
await context.test(
|
||||
'首次静默登录默认普通用户,续期不改变版本,响应只含白名单',
|
||||
async () => {
|
||||
const data = await loginData(await login('FirstOpenID'));
|
||||
const claims = await tokens.verifyAccessToken(data.accessToken);
|
||||
assert.equal(data.user.id, claims.sub);
|
||||
assert.equal(data.expiresIn, 86400);
|
||||
assert.equal(
|
||||
data.expiresAt,
|
||||
new Date(claims.exp * 1000).toISOString(),
|
||||
);
|
||||
assert.deepEqual(
|
||||
Object.keys(data.user).sort(),
|
||||
[
|
||||
'id',
|
||||
'nickName',
|
||||
'avatarUrl',
|
||||
'avatarUrlExpiresAt',
|
||||
'createdAt',
|
||||
].sort(),
|
||||
);
|
||||
const stored = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: data.user.id },
|
||||
});
|
||||
assert.equal(stored.role, 'user');
|
||||
assert.equal(stored.status, 'active');
|
||||
assert.equal(stored.authVersion, 1);
|
||||
const second = await loginData(
|
||||
await login('FirstOpenID', 'resume'),
|
||||
);
|
||||
assert.equal(second.user.id, data.user.id);
|
||||
const me = await request(
|
||||
'me?userId=somebody-else',
|
||||
data.accessToken,
|
||||
);
|
||||
assert.equal(me.status, 200);
|
||||
assert.equal(me.headers.get('cache-control'), 'no-store');
|
||||
assert.equal(
|
||||
((await me.json()) as { data: { id: string } }).data.id,
|
||||
data.user.id,
|
||||
);
|
||||
assert.equal(
|
||||
(await request('test-admin', data.accessToken)).status,
|
||||
403,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'老 ID、资料、版本及时间保留,UnionID 只补空不覆盖',
|
||||
async () => {
|
||||
const createdAt = new Date('2025-01-01T00:00:00Z');
|
||||
const updatedAt = new Date('2025-01-02T00:00:00Z');
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: 'legacy_cloud_id',
|
||||
openid: 'LegacyOpenID',
|
||||
nickName: '老用户',
|
||||
authVersion: 3,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
},
|
||||
});
|
||||
const data = await loginData(
|
||||
await login('LegacyOpenID', 'resume'),
|
||||
);
|
||||
assert.equal(data.user.id, 'legacy_cloud_id');
|
||||
assert.equal(data.user.nickName, '老用户');
|
||||
const stored = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: 'legacy_cloud_id' },
|
||||
});
|
||||
assert.equal(stored.unionid, 'FreshUnion');
|
||||
assert.equal(stored.authVersion, 3);
|
||||
assert.equal(
|
||||
stored.createdAt.toISOString(),
|
||||
createdAt.toISOString(),
|
||||
);
|
||||
await repository.login(
|
||||
{ openid: 'LegacyOpenID', unionid: 'ConflictingUnion' },
|
||||
'resume',
|
||||
);
|
||||
await repository.login(
|
||||
{ openid: 'LegacyOpenID', unionid: null },
|
||||
'resume',
|
||||
);
|
||||
const after = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: 'legacy_cloud_id' },
|
||||
});
|
||||
assert.equal(after.unionid, 'FreshUnion');
|
||||
assert.equal(
|
||||
after.updatedAt.toISOString(),
|
||||
stored.updatedAt.toISOString(),
|
||||
);
|
||||
assert.ok(after.lastActiveAt > createdAt);
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'并发首次登录只生成一个账户,OpenID 区分大小写',
|
||||
async () => {
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: 8 }, (_, i) =>
|
||||
login(`concurrent-${i}`),
|
||||
),
|
||||
);
|
||||
const users = await Promise.all(
|
||||
responses.map(async (r) => (await loginData(r)).user.id),
|
||||
);
|
||||
assert.equal(new Set(users).size, 1);
|
||||
assert.equal(
|
||||
await prisma.user.count({
|
||||
where: { openid: 'ConcurrentOpenID' },
|
||||
}),
|
||||
1,
|
||||
);
|
||||
const lower = await repository.login(
|
||||
{ openid: 'concurrentopenid', unionid: null },
|
||||
'start',
|
||||
);
|
||||
assert.notEqual(lower.account.id, users[0]);
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'resume 不建号,禁用不能被 start 重置,撤销/降权/删除即时生效',
|
||||
async () => {
|
||||
await reason(
|
||||
await login('MissingOpenID', 'resume'),
|
||||
401,
|
||||
'ACCOUNT_NOT_FOUND',
|
||||
);
|
||||
assert.equal(
|
||||
await prisma.user.count({
|
||||
where: { openid: 'MissingOpenID' },
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const data = await loginData(await login('LifecycleOpenID'));
|
||||
const id = data.user.id;
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { role: 'admin' },
|
||||
});
|
||||
assert.equal(
|
||||
(await request('test-admin', data.accessToken)).status,
|
||||
200,
|
||||
);
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { role: 'user' },
|
||||
});
|
||||
assert.equal(
|
||||
(await request('test-admin', data.accessToken)).status,
|
||||
403,
|
||||
);
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { authVersion: { increment: 1 } },
|
||||
});
|
||||
await reason(
|
||||
await request('me', data.accessToken),
|
||||
401,
|
||||
'AUTH_REVOKED',
|
||||
);
|
||||
const renewed = await loginData(
|
||||
await login('LifecycleOpenID', 'resume'),
|
||||
);
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { status: 'disabled' },
|
||||
});
|
||||
await reason(
|
||||
await request('me', renewed.accessToken),
|
||||
401,
|
||||
'ACCOUNT_DISABLED',
|
||||
);
|
||||
await reason(
|
||||
await login('LifecycleOpenID'),
|
||||
401,
|
||||
'ACCOUNT_DISABLED',
|
||||
);
|
||||
await prisma.user.delete({ where: { id } });
|
||||
await reason(
|
||||
await request('me', renewed.accessToken),
|
||||
401,
|
||||
'ACCOUNT_NOT_FOUND',
|
||||
);
|
||||
await reason(
|
||||
await login('LifecycleOpenID', 'resume'),
|
||||
401,
|
||||
'ACCOUNT_NOT_FOUND',
|
||||
);
|
||||
const fresh = await loginData(await login('LifecycleOpenID'));
|
||||
assert.notEqual(fresh.user.id, id);
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'拒绝额外身份参数和坏 code,错误不变成成功响应',
|
||||
async () => {
|
||||
const before = requests.length;
|
||||
for (const input of [
|
||||
{ openid: 'forged' },
|
||||
{ appid: 'forged' },
|
||||
{ userId: 'forged' },
|
||||
{ role: 'admin' },
|
||||
])
|
||||
assert.equal(
|
||||
(await login('valid', 'start', input)).status,
|
||||
400,
|
||||
);
|
||||
assert.equal((await login(' ', 'start')).status, 400);
|
||||
assert.equal((await login('valid', 'unknown')).status, 400);
|
||||
assert.equal(requests.length, before);
|
||||
await reason(
|
||||
await login('bad-code'),
|
||||
400,
|
||||
'WECHAT_CODE_INVALID',
|
||||
);
|
||||
await reason(
|
||||
await login('risk-code'),
|
||||
403,
|
||||
'WECHAT_LOGIN_BLOCKED',
|
||||
);
|
||||
const quota = await login('quota-code');
|
||||
assert.equal(quota.headers.get('retry-after'), '60');
|
||||
await reason(quota, 429, 'RATE_LIMITED');
|
||||
await reason(
|
||||
await login('network-code'),
|
||||
503,
|
||||
'AUTH_UPSTREAM_UNAVAILABLE',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'认证失败原因及日志不泄露凭据,伪造转发头不能绕过限流',
|
||||
async () => {
|
||||
await reason(
|
||||
await request('me', 'invalid-token'),
|
||||
401,
|
||||
'AUTH_INVALID',
|
||||
);
|
||||
const jwt = app.get(JwtTokenService);
|
||||
const expired = await app
|
||||
.get((await import('@nestjs/jwt')).JwtService)
|
||||
.signAsync(
|
||||
{ sub: 'legacy_cloud_id', authVersion: 3 },
|
||||
{
|
||||
secret: config.jwtSecret,
|
||||
issuer: config.jwtIssuer,
|
||||
audience: config.jwtAudience,
|
||||
expiresIn: -1,
|
||||
},
|
||||
);
|
||||
await reason(await request('me', expired), 401, 'AUTH_EXPIRED');
|
||||
const token = await jwt.signAccessToken('legacy_cloud_id', 3);
|
||||
assert.equal((await request('me', token)).status, 200);
|
||||
let limited = false;
|
||||
for (let i = 0; i < 35; i++) {
|
||||
const result = await login(`rate-${i}`, 'resume');
|
||||
if (result.status === 429) {
|
||||
assert.ok(result.headers.get('retry-after'));
|
||||
limited = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(limited);
|
||||
const allLogs = logs.join('\n');
|
||||
for (const secret of [
|
||||
config.wechatMiniAppSecret,
|
||||
'private-session-key',
|
||||
'private-upstream-message',
|
||||
'private-network-secret',
|
||||
token,
|
||||
'LegacyOpenID',
|
||||
])
|
||||
assert.ok(!allLogs.includes(secret));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,731 @@
|
||||
import 'reflect-metadata';
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import type { ApiSuccess, Category } from '@doodle/contracts';
|
||||
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 { CategoryService } from '../src/categories/category.service';
|
||||
import { testConfig } from './test-config';
|
||||
|
||||
const databaseUrl = process.env.TEST_DATABASE_URL;
|
||||
const item = {
|
||||
status: 'active' as const,
|
||||
id: 'math',
|
||||
name: '数感启蒙',
|
||||
icon: '📐',
|
||||
color: '#CDB4DB',
|
||||
sortOrder: 0,
|
||||
parentId: null,
|
||||
};
|
||||
|
||||
test(
|
||||
'真实 PostgreSQL:分类接口、并发树校验、审计及幂等导入',
|
||||
{ skip: !databaseUrl },
|
||||
async (context) => {
|
||||
const parsed = new URL(databaseUrl!);
|
||||
assert.ok(['127.0.0.1', 'localhost'].includes(parsed.hostname));
|
||||
assert.match(parsed.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/);
|
||||
const config = { ...testConfig, databaseUrl: databaseUrl! };
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [AppModule.forRoot(config)],
|
||||
}).compile();
|
||||
const logger = new AppLogger('error', () => {});
|
||||
const app = module.createNestApplication<NestExpressApplication>({
|
||||
logger,
|
||||
bodyParser: false,
|
||||
});
|
||||
context.after(() => app.close());
|
||||
configureApplication(app, config, logger);
|
||||
await app.listen(0, '127.0.0.1');
|
||||
const origin = await app.getUrl();
|
||||
const prisma = app.get(PrismaService).client;
|
||||
const service = app.get(CategoryService);
|
||||
const tokens = app.get(JwtTokenService);
|
||||
await prisma.user.createMany({
|
||||
data: [
|
||||
{
|
||||
id: 'category-admin',
|
||||
openid: 'category-admin',
|
||||
role: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'category-admin-two',
|
||||
openid: 'category-admin-two',
|
||||
role: 'admin',
|
||||
},
|
||||
{ id: 'category-user', openid: 'category-user' },
|
||||
],
|
||||
});
|
||||
const admin = await tokens.signAccessToken('category-admin', 1);
|
||||
const secondAdmin = await tokens.signAccessToken(
|
||||
'category-admin-two',
|
||||
1,
|
||||
);
|
||||
const user = await tokens.signAccessToken('category-user', 1);
|
||||
const call = (
|
||||
path: string,
|
||||
method = 'GET',
|
||||
body?: unknown,
|
||||
token?: string,
|
||||
) =>
|
||||
fetch(`${origin}/api/v1/${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Request-Id': 'category-test-request',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
const data = async (response: Response, status = 200) => {
|
||||
assert.equal(response.status, status);
|
||||
const body = (await response.json()) as ApiSuccess<Category>;
|
||||
assert.equal(body.code, 0);
|
||||
return body.data;
|
||||
};
|
||||
|
||||
await context.test(
|
||||
'公开列表无需登录,写入限定管理员,校验失败不写库',
|
||||
async () => {
|
||||
const initial = await call('categories');
|
||||
assert.equal(initial.status, 200);
|
||||
assert.deepEqual(
|
||||
((await initial.json()) as ApiSuccess<Category[]>).data,
|
||||
[],
|
||||
);
|
||||
for (const [path, method, body] of [
|
||||
['admin/categories', 'POST', item],
|
||||
['admin/categories/math', 'PATCH', { name: '无权限修改' }],
|
||||
['admin/categories/math', 'DELETE', undefined],
|
||||
] as const) {
|
||||
assert.equal((await call(path, method, body)).status, 401);
|
||||
assert.equal(
|
||||
(await call(path, method, body, user)).status,
|
||||
403,
|
||||
);
|
||||
}
|
||||
for (const input of [
|
||||
{ ...item, name: null },
|
||||
{ ...item, sortOrder: '0' },
|
||||
{ ...item, unexpected: true },
|
||||
{ ...item, parentId: 'missing' },
|
||||
])
|
||||
assert.equal(
|
||||
(await call('admin/categories', 'POST', input, admin))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
assert.equal(await prisma.category.count(), 0);
|
||||
assert.equal(
|
||||
await prisma.adminAuditLog.count({
|
||||
where: { resourceType: 'categories' },
|
||||
}),
|
||||
0,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'创建、公开详情、稳定排序、局部更新及同事务审计',
|
||||
async () => {
|
||||
const created = await data(
|
||||
await call('admin/categories', 'POST', item, admin),
|
||||
201,
|
||||
);
|
||||
assert.equal(created.id, 'math');
|
||||
assert.equal(created.icon, '📐');
|
||||
assert.equal(created.parentId, null);
|
||||
assert.deepEqual(
|
||||
Object.keys(created).sort(),
|
||||
[
|
||||
'status',
|
||||
'id',
|
||||
'name',
|
||||
'icon',
|
||||
'color',
|
||||
'sortOrder',
|
||||
'parentId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
].sort(),
|
||||
);
|
||||
assert.equal(
|
||||
(await call('admin/categories', 'POST', item, admin))
|
||||
.status,
|
||||
409,
|
||||
);
|
||||
assert.equal((await call('categories/missing')).status, 404);
|
||||
assert.equal(
|
||||
(await data(await call('categories/math'))).name,
|
||||
item.name,
|
||||
);
|
||||
await data(
|
||||
await call(
|
||||
'admin/categories',
|
||||
'POST',
|
||||
{ ...item, id: 'Math' },
|
||||
admin,
|
||||
),
|
||||
201,
|
||||
);
|
||||
await data(
|
||||
await call(
|
||||
'admin/categories',
|
||||
'POST',
|
||||
{
|
||||
...item,
|
||||
id: 'child',
|
||||
parentId: 'math',
|
||||
sortOrder: 2,
|
||||
},
|
||||
admin,
|
||||
),
|
||||
201,
|
||||
);
|
||||
const list = await call('categories');
|
||||
assert.deepEqual(
|
||||
((await list.json()) as ApiSuccess<Category[]>).data.map(
|
||||
(row) => row.id,
|
||||
),
|
||||
['Math', 'math', 'child'],
|
||||
);
|
||||
const updated = await data(
|
||||
await call(
|
||||
'admin/categories/math',
|
||||
'PATCH',
|
||||
{ name: '数学启蒙' },
|
||||
admin,
|
||||
),
|
||||
);
|
||||
assert.equal(updated.name, '数学启蒙');
|
||||
assert.equal(updated.icon, item.icon);
|
||||
assert.equal(updated.createdAt, created.createdAt);
|
||||
const audit = await prisma.adminAuditLog.findFirstOrThrow({
|
||||
where: {
|
||||
resourceType: 'categories',
|
||||
resourceId: 'math',
|
||||
action: 'category.update',
|
||||
},
|
||||
});
|
||||
assert.equal(audit.actorUserId, 'category-admin');
|
||||
assert.equal(audit.requestId, 'category-test-request');
|
||||
assert.equal(
|
||||
(audit.beforeData as { name: string }).name,
|
||||
item.name,
|
||||
);
|
||||
assert.equal(
|
||||
(audit.afterData as { name: string }).name,
|
||||
updated.name,
|
||||
);
|
||||
const count = await prisma.adminAuditLog.count();
|
||||
const unchanged = await data(
|
||||
await call(
|
||||
'admin/categories/math',
|
||||
'PATCH',
|
||||
{ name: updated.name },
|
||||
admin,
|
||||
),
|
||||
);
|
||||
assert.equal(unchanged.updatedAt, updated.updatedAt);
|
||||
assert.equal(await prisma.adminAuditLog.count(), count);
|
||||
for (const patch of [
|
||||
{},
|
||||
{ id: 'new-id' },
|
||||
{ icon: null },
|
||||
{ parentId: 'child' },
|
||||
{ parentId: 'math' },
|
||||
])
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'admin/categories/math',
|
||||
'PATCH',
|
||||
patch,
|
||||
admin,
|
||||
)
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
await data(
|
||||
await call(
|
||||
'admin/categories/child',
|
||||
'PATCH',
|
||||
{ parentId: '' },
|
||||
admin,
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
(await data(await call('categories/child'))).parentId,
|
||||
null,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'不同管理员并发互设父级只能成功一个,事务内再次检查账户',
|
||||
async () => {
|
||||
const results = await Promise.all([
|
||||
call(
|
||||
'admin/categories/math',
|
||||
'PATCH',
|
||||
{ parentId: 'Math' },
|
||||
admin,
|
||||
),
|
||||
call(
|
||||
'admin/categories/Math',
|
||||
'PATCH',
|
||||
{ parentId: 'math' },
|
||||
secondAdmin,
|
||||
),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
results.map((r) => r.status).sort(),
|
||||
[200, 400],
|
||||
);
|
||||
await prisma.user.update({
|
||||
where: { id: 'category-admin' },
|
||||
data: { role: 'user' },
|
||||
});
|
||||
await assert.rejects(
|
||||
service.create(
|
||||
{ ...item, id: 'forged' },
|
||||
{
|
||||
userId: 'category-admin',
|
||||
authVersion: 1,
|
||||
role: 'admin',
|
||||
},
|
||||
'recheck',
|
||||
),
|
||||
);
|
||||
const staleActor = {
|
||||
userId: 'category-admin',
|
||||
authVersion: 1,
|
||||
role: 'admin' as const,
|
||||
};
|
||||
await assert.rejects(
|
||||
service.update(
|
||||
'math',
|
||||
{ name: '无权限修改' },
|
||||
staleActor,
|
||||
'recheck',
|
||||
),
|
||||
{ status: 403 },
|
||||
);
|
||||
await assert.rejects(
|
||||
service.remove('math', staleActor, 'recheck'),
|
||||
{ status: 403 },
|
||||
);
|
||||
assert.equal(
|
||||
await prisma.category.count({ where: { id: 'forged' } }),
|
||||
0,
|
||||
);
|
||||
await prisma.user.update({
|
||||
where: { id: 'category-admin' },
|
||||
data: { role: 'admin' },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'管理员删除无引用分类,审计保留删除前快照;重复删除返回 404',
|
||||
async () => {
|
||||
const id = 'delete-leaf';
|
||||
const before = await data(
|
||||
await call(
|
||||
'admin/categories',
|
||||
'POST',
|
||||
{ ...item, id },
|
||||
admin,
|
||||
),
|
||||
201,
|
||||
);
|
||||
const response = await call(
|
||||
`admin/categories/${id}`,
|
||||
'DELETE',
|
||||
undefined,
|
||||
admin,
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: { id },
|
||||
});
|
||||
assert.equal((await call(`categories/${id}`)).status, 404);
|
||||
const audit = await prisma.adminAuditLog.findFirstOrThrow({
|
||||
where: { resourceId: id, action: 'category.delete' },
|
||||
});
|
||||
assert.equal(audit.actorUserId, 'category-admin');
|
||||
assert.equal(audit.actorType, 'admin');
|
||||
assert.equal(audit.requestId, 'category-test-request');
|
||||
assert.equal(
|
||||
(audit.beforeData as { name: string }).name,
|
||||
before.name,
|
||||
);
|
||||
assert.equal(audit.afterData, null);
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
`admin/categories/${id}`,
|
||||
'DELETE',
|
||||
undefined,
|
||||
admin,
|
||||
)
|
||||
).status,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
await prisma.adminAuditLog.count({
|
||||
where: { resourceId: id, action: 'category.delete' },
|
||||
}),
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'有子分类或任何状态题型引用时返回 409,不删除关联内容或记录成功审计',
|
||||
async () => {
|
||||
await prisma.category.createMany({
|
||||
data: [
|
||||
{ ...item, id: 'delete-parent' },
|
||||
{ ...item, id: 'delete-used' },
|
||||
],
|
||||
});
|
||||
await prisma.category.create({
|
||||
data: {
|
||||
...item,
|
||||
id: 'delete-child',
|
||||
parentId: 'delete-parent',
|
||||
},
|
||||
});
|
||||
await prisma.asset.create({
|
||||
data: {
|
||||
id: 'delete-test-preview',
|
||||
purpose: 'preview',
|
||||
visibility: 'public',
|
||||
},
|
||||
});
|
||||
await prisma.worksheet.create({
|
||||
data: {
|
||||
id: 'delete-test-worksheet',
|
||||
title: '删除保护测试',
|
||||
subtitle: '',
|
||||
categoryId: 'delete-used',
|
||||
path: '/test',
|
||||
ageMin: 3,
|
||||
ageMax: 6,
|
||||
difficulty: 1,
|
||||
previewAssetId: 'delete-test-preview',
|
||||
rendererKey: 'test',
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'admin/categories/delete-parent',
|
||||
'DELETE',
|
||||
undefined,
|
||||
admin,
|
||||
)
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
for (const status of ['draft', 'active', 'hidden']) {
|
||||
await prisma.worksheet.update({
|
||||
where: { id: 'delete-test-worksheet' },
|
||||
data: { status },
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'admin/categories/delete-used',
|
||||
'DELETE',
|
||||
undefined,
|
||||
admin,
|
||||
)
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
await prisma.category.count({
|
||||
where: {
|
||||
id: {
|
||||
in: [
|
||||
'delete-parent',
|
||||
'delete-child',
|
||||
'delete-used',
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
3,
|
||||
);
|
||||
assert.equal(
|
||||
await prisma.worksheet.count({
|
||||
where: { id: 'delete-test-worksheet' },
|
||||
}),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
await prisma.adminAuditLog.count({
|
||||
where: {
|
||||
resourceId: {
|
||||
in: ['delete-parent', 'delete-used'],
|
||||
},
|
||||
action: 'category.delete',
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'审计失败会回滚删除,并发删除只成功一次',
|
||||
async () => {
|
||||
await prisma.category.createMany({
|
||||
data: [
|
||||
{ ...item, id: 'rollback-delete' },
|
||||
{ ...item, id: 'concurrent-delete' },
|
||||
],
|
||||
});
|
||||
// 在隔离测试库制造审计失败,验证删行与审计确实处在同一事务。
|
||||
await prisma.$executeRaw`ALTER TABLE public.admin_audit_logs ADD CONSTRAINT category_delete_audit_test CHECK (action <> 'category.delete' OR resource_id <> 'rollback-delete')`;
|
||||
try {
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'admin/categories/rollback-delete',
|
||||
'DELETE',
|
||||
undefined,
|
||||
admin,
|
||||
)
|
||||
).status,
|
||||
503,
|
||||
);
|
||||
assert.equal(
|
||||
await prisma.category.count({
|
||||
where: { id: 'rollback-delete' },
|
||||
}),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
await prisma.adminAuditLog.count({
|
||||
where: {
|
||||
resourceId: 'rollback-delete',
|
||||
action: 'category.delete',
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
await prisma.$executeRaw`ALTER TABLE public.admin_audit_logs DROP CONSTRAINT category_delete_audit_test`;
|
||||
}
|
||||
const responses = await Promise.all([
|
||||
call(
|
||||
'admin/categories/concurrent-delete',
|
||||
'DELETE',
|
||||
undefined,
|
||||
admin,
|
||||
),
|
||||
call(
|
||||
'admin/categories/concurrent-delete',
|
||||
'DELETE',
|
||||
undefined,
|
||||
secondAdmin,
|
||||
),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
responses.map((response) => response.status).sort(),
|
||||
[200, 404],
|
||||
);
|
||||
assert.equal(
|
||||
await prisma.adminAuditLog.count({
|
||||
where: {
|
||||
resourceId: 'concurrent-delete',
|
||||
action: 'category.delete',
|
||||
},
|
||||
}),
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'禁用、降权、凭证撤销后,新增/修改/删除都立即失去权限',
|
||||
async () => {
|
||||
const actions = [
|
||||
[
|
||||
'admin/categories',
|
||||
'POST',
|
||||
{ ...item, id: 'forbidden-create' },
|
||||
],
|
||||
['admin/categories/math', 'PATCH', { name: '无权限修改' }],
|
||||
['admin/categories/math', 'DELETE', undefined],
|
||||
] as const;
|
||||
const check = async (status: number) => {
|
||||
for (const [path, method, body] of actions)
|
||||
assert.equal(
|
||||
(await call(path, method, body, secondAdmin))
|
||||
.status,
|
||||
status,
|
||||
);
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: { id: 'category-admin-two' },
|
||||
data: { status: 'disabled' },
|
||||
});
|
||||
await check(401);
|
||||
await prisma.user.update({
|
||||
where: { id: 'category-admin-two' },
|
||||
data: { status: 'active', role: 'user' },
|
||||
});
|
||||
await check(403);
|
||||
await prisma.user.update({
|
||||
where: { id: 'category-admin-two' },
|
||||
data: { role: 'admin', authVersion: { increment: 1 } },
|
||||
});
|
||||
await check(401);
|
||||
assert.equal(
|
||||
await prisma.category.count({
|
||||
where: { id: 'forbidden-create' },
|
||||
}),
|
||||
0,
|
||||
);
|
||||
assert.ok(
|
||||
await prisma.category.findUnique({ where: { id: 'math' } }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'子先父后导入、预演不写库、缺时报告、重导不刷新时间/审计',
|
||||
async () => {
|
||||
const rows = [
|
||||
{
|
||||
...item,
|
||||
_id: 'import-child',
|
||||
id: 'import-child',
|
||||
parentId: 'import-root',
|
||||
},
|
||||
{ ...item, id: 'import-root' },
|
||||
];
|
||||
const source = rows
|
||||
.map((row) => JSON.stringify(row))
|
||||
.join('\n');
|
||||
const count = await prisma.category.count();
|
||||
const audits = await prisma.adminAuditLog.count();
|
||||
const preview = await service.importExport(
|
||||
source,
|
||||
true,
|
||||
'dry-run',
|
||||
'test',
|
||||
);
|
||||
assert.equal(preview.inserted, 2);
|
||||
assert.equal(preview.missingCreatedAt, 2);
|
||||
assert.equal(await prisma.category.count(), count);
|
||||
assert.equal(await prisma.adminAuditLog.count(), audits);
|
||||
const applied = await service.importExport(
|
||||
source,
|
||||
false,
|
||||
'import-test',
|
||||
'sha256=test',
|
||||
);
|
||||
assert.equal(applied.inserted, 2);
|
||||
const before = await prisma.category.findUniqueOrThrow({
|
||||
where: { id: 'import-root' },
|
||||
});
|
||||
assert.equal(before.createdAt.toISOString(), applied.batchAt);
|
||||
const repeated = await service.importExport(
|
||||
source,
|
||||
false,
|
||||
'repeat',
|
||||
'test',
|
||||
);
|
||||
assert.equal(repeated.unchanged, 2);
|
||||
assert.equal(repeated.inserted + repeated.updated, 0);
|
||||
assert.deepEqual(
|
||||
await prisma.category.findUniqueOrThrow({
|
||||
where: { id: 'import-root' },
|
||||
}),
|
||||
before,
|
||||
);
|
||||
assert.equal(await prisma.adminAuditLog.count(), audits + 2);
|
||||
const updated = await service.importExport(
|
||||
JSON.stringify([
|
||||
{ ...item, id: 'import-root', name: '新名称' },
|
||||
]),
|
||||
false,
|
||||
'import-update',
|
||||
'test',
|
||||
);
|
||||
assert.equal(updated.updated, 1);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.category.findUniqueOrThrow({
|
||||
where: { id: 'import-root' },
|
||||
})
|
||||
).createdAt.toISOString(),
|
||||
before.createdAt.toISOString(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'导入后半批失败会回滚已写行及审计,且校验现有树中的循环',
|
||||
async () => {
|
||||
const count = await prisma.category.count();
|
||||
const audits = await prisma.adminAuditLog.count();
|
||||
await assert.rejects(
|
||||
service.importExport(
|
||||
JSON.stringify([
|
||||
{ ...item, id: 'rollback-first' },
|
||||
{
|
||||
...item,
|
||||
id: 'rollback-second',
|
||||
createdAt: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
]),
|
||||
false,
|
||||
'rollback',
|
||||
'test',
|
||||
),
|
||||
);
|
||||
assert.equal(await prisma.category.count(), count);
|
||||
assert.equal(await prisma.adminAuditLog.count(), audits);
|
||||
await assert.rejects(
|
||||
service.importExport(
|
||||
JSON.stringify([
|
||||
{
|
||||
...item,
|
||||
id: 'import-root',
|
||||
parentId: 'import-child',
|
||||
},
|
||||
]),
|
||||
false,
|
||||
'cycle',
|
||||
'test',
|
||||
),
|
||||
);
|
||||
await assert.rejects(
|
||||
service.importExport(
|
||||
JSON.stringify([
|
||||
{ ...item, id: 'orphan', parentId: 'not-found' },
|
||||
]),
|
||||
false,
|
||||
'orphan',
|
||||
'test',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { categoryTreeOrder } from '../src/categories/category-input';
|
||||
import { parseCategoryExport } from '../src/categories/category-import';
|
||||
|
||||
const root = {
|
||||
_id: 'math',
|
||||
name: '数感启蒙',
|
||||
icon: '📐',
|
||||
color: '#CDB4DB',
|
||||
sortOrder: 0,
|
||||
parentId: null,
|
||||
};
|
||||
|
||||
test('分类导入:兼容 JSON Lines/数组,保留原 ID、Emoji 和带时区时间', () => {
|
||||
const child = {
|
||||
...root,
|
||||
_id: 'Math',
|
||||
parentId: 'math',
|
||||
createdAt: '2025-01-01T08:00:00+08:00',
|
||||
};
|
||||
const lines = `\uFEFF${JSON.stringify(child)}\n\n${JSON.stringify(root)}\n`;
|
||||
const rows = parseCategoryExport(lines);
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.id),
|
||||
['Math', 'math'],
|
||||
);
|
||||
assert.equal(rows[0]?.createdAt?.toISOString(), '2025-01-01T00:00:00.000Z');
|
||||
assert.equal(rows[1]?.createdAt, undefined);
|
||||
assert.equal(rows[1]?.icon, '📐');
|
||||
assert.deepEqual(categoryTreeOrder(rows), ['math', 'Math']);
|
||||
assert.equal(
|
||||
parseCategoryExport(JSON.stringify([{ ...root, parentId: '' }]))[0]
|
||||
?.parentId,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('分类导入:拒绝重复、未知字段、类型转换、越界及未迁移文件图标', () => {
|
||||
const bad = [
|
||||
[{ ...root, id: 'other' }],
|
||||
[root, root],
|
||||
[{ ...root, role: 'admin' }],
|
||||
[{ ...root, name: 42 }],
|
||||
[{ ...root, icon: 'https://private.example/icon.png' }],
|
||||
[{ ...root, icon: 'cloud://secret/icon' }],
|
||||
[{ ...root, _id: ' math' }],
|
||||
[{ ...root, sortOrder: '2' }],
|
||||
[{ ...root, sortOrder: 0.5 }],
|
||||
[{ ...root, sortOrder: 2147483648 }],
|
||||
[{ ...root, parentId: false }],
|
||||
[{ ...root, createdAt: '2025-01-01T00:00:00' }],
|
||||
[{ ...root, createdAt: { $date: '2025-01-01' } }],
|
||||
[
|
||||
{
|
||||
...root,
|
||||
createdAt: '2025-01-02T00:00:00Z',
|
||||
updatedAt: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
];
|
||||
for (const value of bad)
|
||||
assert.throws(() => parseCategoryExport(JSON.stringify(value)));
|
||||
for (const value of ['', '{}', '[]', '{broken'])
|
||||
assert.throws(() => parseCategoryExport(value));
|
||||
});
|
||||
|
||||
test('分类树:缺失父级、自引用和多层循环均拒绝,深层树不递归', () => {
|
||||
for (const rows of [
|
||||
[{ id: 'a', parentId: 'missing' }],
|
||||
[{ id: 'a', parentId: 'a' }],
|
||||
[
|
||||
{ id: 'a', parentId: 'b' },
|
||||
{ id: 'b', parentId: 'c' },
|
||||
{ id: 'c', parentId: 'a' },
|
||||
],
|
||||
])
|
||||
assert.throws(() => categoryTreeOrder(rows));
|
||||
const deep = Array.from({ length: 10_000 }, (_, i) => ({
|
||||
id: String(i),
|
||||
parentId: i ? String(i - 1) : null,
|
||||
})).reverse();
|
||||
assert.equal(categoryTreeOrder(deep).length, 10_000);
|
||||
});
|
||||
@@ -0,0 +1,725 @@
|
||||
import 'reflect-metadata';
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import type {
|
||||
AdminWorksheet,
|
||||
AdminWorksheetResource,
|
||||
ContentPublication,
|
||||
} from '@doodle/contracts';
|
||||
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 { PublicationService } from '../src/content/publication.service';
|
||||
import { CategoryService } from '../src/categories/category.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.ok(['localhost', '127.0.0.1'].includes(url.hostname));
|
||||
assert.match(url.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/);
|
||||
const config = { ...testConfig, databaseUrl: databaseUrl! };
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [AppModule.forRoot(config)],
|
||||
}).compile();
|
||||
const logger = new AppLogger('error', () => {});
|
||||
const app = module.createNestApplication<NestExpressApplication>({
|
||||
logger,
|
||||
bodyParser: false,
|
||||
});
|
||||
context.after(() => app.close());
|
||||
configureApplication(app, config, logger);
|
||||
await app.listen(0, '127.0.0.1');
|
||||
const origin = await app.getUrl();
|
||||
const prisma = app.get(PrismaService).client;
|
||||
const tokens = app.get(JwtTokenService);
|
||||
await prisma.user.createMany({
|
||||
data: [
|
||||
{
|
||||
id: 'publication-admin',
|
||||
openid: 'publication-admin',
|
||||
role: 'admin',
|
||||
},
|
||||
{ id: 'publication-user', openid: 'publication-user' },
|
||||
],
|
||||
});
|
||||
const admin = await tokens.signAccessToken('publication-admin', 1);
|
||||
const user = await tokens.signAccessToken('publication-user', 1);
|
||||
const actor = {
|
||||
userId: 'publication-admin',
|
||||
authVersion: 1,
|
||||
role: 'admin' as const,
|
||||
};
|
||||
const call = (
|
||||
path: string,
|
||||
method = 'GET',
|
||||
body?: unknown,
|
||||
token?: string,
|
||||
) =>
|
||||
fetch(`${origin}/api/v1/${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Request-Id': 'publication-test',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
async function data<T>(path: string, token?: string): Promise<T> {
|
||||
const response = await call(path, 'GET', undefined, token);
|
||||
assert.equal(
|
||||
response.status,
|
||||
200,
|
||||
`${path}: ${await (response.status === 200 ? Promise.resolve('') : response.text())}`,
|
||||
);
|
||||
return ((await response.json()) as { data: T }).data;
|
||||
}
|
||||
async function patch<T = ContentPublication>(
|
||||
path: string,
|
||||
status: string,
|
||||
): Promise<T> {
|
||||
const response = await call(path, 'PATCH', { status }, admin);
|
||||
assert.equal(response.status, 200);
|
||||
return ((await response.json()) as { data: T }).data;
|
||||
}
|
||||
const category = { name: '发布样本', icon: '📄', color: '#fff' };
|
||||
await prisma.category.create({
|
||||
data: { ...category, id: 'pub-root', status: 'active' },
|
||||
});
|
||||
await prisma.category.create({
|
||||
data: {
|
||||
...category,
|
||||
id: 'pub-child',
|
||||
parentId: 'pub-root',
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
await prisma.category.create({
|
||||
data: {
|
||||
...category,
|
||||
id: 'pub-grandchild',
|
||||
parentId: 'pub-child',
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
await prisma.category.create({
|
||||
data: { ...category, id: 'pub-draft-category' },
|
||||
});
|
||||
await prisma.asset.create({
|
||||
data: {
|
||||
id: 'pub-asset',
|
||||
purpose: 'preview',
|
||||
visibility: 'public',
|
||||
sourceRef: 'cloud://never-public/source',
|
||||
},
|
||||
});
|
||||
const old = new Date('2024-01-01T00:00:00Z');
|
||||
for (const [id, status, categoryId] of [
|
||||
['pub-live', 'active', 'pub-grandchild'],
|
||||
['pub-draft', 'draft', 'pub-grandchild'],
|
||||
['pub-unready', 'draft', 'pub-draft-category'],
|
||||
['pub-old', 'hidden', 'pub-grandchild'],
|
||||
['pub-old-hidden', 'hidden', 'pub-grandchild'],
|
||||
['pub-never-published', 'draft', 'pub-grandchild'],
|
||||
['pub-rollback', 'draft', 'pub-grandchild'],
|
||||
]) {
|
||||
await prisma.worksheet.create({
|
||||
data: {
|
||||
id: id!,
|
||||
title: id!,
|
||||
subtitle: '',
|
||||
categoryId: categoryId!,
|
||||
status: status!,
|
||||
path: '/test',
|
||||
ageMin: 3,
|
||||
ageMax: 6,
|
||||
difficulty: 1,
|
||||
previewAssetId: 'pub-asset',
|
||||
downloads: 1000000n,
|
||||
tags: ['publication'],
|
||||
createdAt: old,
|
||||
},
|
||||
});
|
||||
}
|
||||
await prisma.worksheetResource.createMany({
|
||||
data: [
|
||||
{
|
||||
id: 'pub-resource',
|
||||
worksheetId: 'pub-live',
|
||||
title: '资源草稿',
|
||||
previewAssetId: 'pub-asset',
|
||||
},
|
||||
{
|
||||
id: 'pub-resource-unready',
|
||||
worksheetId: 'pub-draft',
|
||||
title: '上级未发布',
|
||||
previewAssetId: 'pub-asset',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await context.test(
|
||||
'新分类默认草稿,公开列表/详情隐藏;管理读取仅限当前管理员',
|
||||
async () => {
|
||||
const response = await call(
|
||||
'admin/categories',
|
||||
'POST',
|
||||
{ ...category, id: 'pub-new' },
|
||||
admin,
|
||||
);
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(
|
||||
((await response.json()) as { data: { status: string } })
|
||||
.data.status,
|
||||
'draft',
|
||||
);
|
||||
assert.equal((await call('categories/pub-new')).status, 404);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.category.findUniqueOrThrow({
|
||||
where: { id: 'pub-draft-category' },
|
||||
})
|
||||
).status,
|
||||
'draft',
|
||||
);
|
||||
const rows = await data<Array<{ id: string }>>('categories');
|
||||
assert.ok(!rows.some((row) => row.id === 'pub-new'));
|
||||
for (const path of [
|
||||
'admin/categories',
|
||||
'admin/categories/pub-new',
|
||||
'admin/worksheets?status=draft',
|
||||
'admin/worksheets/pub-draft',
|
||||
'admin/worksheet-resources',
|
||||
'admin/worksheet-resources/pub-resource',
|
||||
]) {
|
||||
assert.equal((await call(path)).status, 401);
|
||||
assert.equal(
|
||||
(await call(path, 'GET', undefined, user)).status,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(await call(path, 'GET', undefined, admin)).status,
|
||||
200,
|
||||
);
|
||||
}
|
||||
const preview = await data<AdminWorksheet>(
|
||||
'admin/worksheets/pub-draft',
|
||||
admin,
|
||||
);
|
||||
assert.equal(preview.status, 'draft');
|
||||
assert.equal(preview.visible, false);
|
||||
assert.equal(preview.previewImg, null);
|
||||
for (const field of [
|
||||
'renderConfig',
|
||||
'assetKey',
|
||||
'sourceRef',
|
||||
'downloadsSeed',
|
||||
])
|
||||
assert.ok(!(field in preview));
|
||||
const drafts = await data<{ items: AdminWorksheet[] }>(
|
||||
'admin/worksheets?status=draft&tag=publication',
|
||||
admin,
|
||||
);
|
||||
assert.ok(drafts.items.some((row) => row.id === 'pub-draft'));
|
||||
assert.ok(drafts.items.every((row) => row.status === 'draft'));
|
||||
for (const bad of [null, 'testing', true, 'ACTIVE']) {
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'admin/categories/pub-new',
|
||||
'PATCH',
|
||||
{ status: bad },
|
||||
admin,
|
||||
)
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'admin/worksheets/pub-draft/status',
|
||||
'PATCH',
|
||||
{ status: bad },
|
||||
admin,
|
||||
)
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'admin/worksheet-resources?status=testing',
|
||||
'GET',
|
||||
undefined,
|
||||
admin,
|
||||
)
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'admin/worksheet-resources?grade=1',
|
||||
'GET',
|
||||
undefined,
|
||||
admin,
|
||||
)
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'多层祖先下架影响全部/分类/热门/筛选/详情,恢复不改写下属状态',
|
||||
async () => {
|
||||
assert.equal((await call('worksheets/pub-live')).status, 200);
|
||||
await patch('admin/categories/pub-root', 'hidden');
|
||||
for (const id of ['pub-root', 'pub-child', 'pub-grandchild'])
|
||||
assert.equal((await call(`categories/${id}`)).status, 404);
|
||||
for (const query of [
|
||||
'tag=publication',
|
||||
'categoryId=pub-grandchild',
|
||||
'tag=publication&sort=hot',
|
||||
'tag=publication&sort=latest',
|
||||
'tag=publication&age=4',
|
||||
'tag=publication&ageBand=3-4',
|
||||
]) {
|
||||
const result = await data<{
|
||||
total: number;
|
||||
items: unknown[];
|
||||
}>(`worksheets?${query}`);
|
||||
assert.equal(result.total, 0);
|
||||
assert.deepEqual(result.items, []);
|
||||
}
|
||||
assert.equal((await call('worksheets/pub-live')).status, 404);
|
||||
assert.equal(
|
||||
(
|
||||
await data<AdminWorksheet>(
|
||||
'admin/worksheets/pub-live',
|
||||
admin,
|
||||
)
|
||||
).visible,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.worksheet.findUniqueOrThrow({
|
||||
where: { id: 'pub-live' },
|
||||
})
|
||||
).status,
|
||||
'active',
|
||||
);
|
||||
await patch('admin/categories/pub-root', 'active');
|
||||
assert.equal((await call('worksheets/pub-live')).status, 200);
|
||||
assert.equal((await call('worksheets/pub-draft')).status, 404);
|
||||
assert.equal(
|
||||
(await call('worksheets?status=draft')).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(await call('worksheets?preview=true')).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(await call('categories/pub-draft-category?preview=true'))
|
||||
.status,
|
||||
404,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'状态修改鉴权、上级发布前置条件与资源有效可见性',
|
||||
async () => {
|
||||
for (const path of [
|
||||
'admin/worksheets/pub-draft/status',
|
||||
'admin/worksheet-resources/pub-resource/status',
|
||||
]) {
|
||||
assert.equal(
|
||||
(await call(path, 'PATCH', { status: 'active' }))
|
||||
.status,
|
||||
401,
|
||||
);
|
||||
assert.equal(
|
||||
(await call(path, 'PATCH', { status: 'active' }, user))
|
||||
.status,
|
||||
403,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'admin/worksheets/pub-unready/status',
|
||||
'PATCH',
|
||||
{ status: 'active' },
|
||||
admin,
|
||||
)
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'admin/worksheet-resources/pub-resource-unready/status',
|
||||
'PATCH',
|
||||
{ status: 'active' },
|
||||
admin,
|
||||
)
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
const published = await patch(
|
||||
'admin/worksheet-resources/pub-resource/status',
|
||||
'active',
|
||||
);
|
||||
assert.ok(published.firstPublishedAt);
|
||||
assert.equal(
|
||||
(
|
||||
await data<AdminWorksheetResource>(
|
||||
'admin/worksheet-resources/pub-resource',
|
||||
admin,
|
||||
)
|
||||
).visible,
|
||||
true,
|
||||
);
|
||||
await patch('admin/worksheets/pub-live/status', 'hidden');
|
||||
assert.equal(
|
||||
(
|
||||
await data<AdminWorksheetResource>(
|
||||
'admin/worksheet-resources/pub-resource',
|
||||
admin,
|
||||
)
|
||||
).visible,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.worksheetResource.findUniqueOrThrow({
|
||||
where: { id: 'pub-resource' },
|
||||
})
|
||||
).status,
|
||||
'active',
|
||||
);
|
||||
await patch('admin/worksheets/pub-live/status', 'active');
|
||||
assert.equal(
|
||||
(
|
||||
await data<AdminWorksheetResource>(
|
||||
'admin/worksheet-resources/pub-resource',
|
||||
admin,
|
||||
)
|
||||
).visible,
|
||||
true,
|
||||
);
|
||||
const resources = await data<{
|
||||
total: number;
|
||||
items: AdminWorksheetResource[];
|
||||
}>(
|
||||
'admin/worksheet-resources?worksheetId=pub-live&status=active&pageSize=1',
|
||||
admin,
|
||||
);
|
||||
assert.equal(resources.total, 1);
|
||||
assert.equal(resources.items[0]?.id, 'pub-resource');
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'首次发布登记、重发保持时间、未知旧首发不伪造,并发重复仅一份审计',
|
||||
async () => {
|
||||
const paths = [
|
||||
'admin/worksheets/pub-draft/status',
|
||||
'admin/worksheet-resources/pub-resource/status',
|
||||
];
|
||||
const first = await patch(paths[0]!, 'active');
|
||||
assert.ok(first.firstPublishedAt);
|
||||
for (const path of paths) {
|
||||
const before = await data<ContentPublication>(
|
||||
path.replace('/status', ''),
|
||||
admin,
|
||||
);
|
||||
await patch(path, 'hidden');
|
||||
const after = await patch(path, 'active');
|
||||
assert.equal(
|
||||
after.firstPublishedAt,
|
||||
before.firstPublishedAt,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
(await patch('admin/worksheets/pub-old/status', 'active'))
|
||||
.firstPublishedAt,
|
||||
null,
|
||||
);
|
||||
await patch('admin/worksheets/pub-old-hidden/status', 'draft');
|
||||
assert.equal(
|
||||
(
|
||||
await patch(
|
||||
'admin/worksheets/pub-old-hidden/status',
|
||||
'active',
|
||||
)
|
||||
).firstPublishedAt,
|
||||
null,
|
||||
);
|
||||
await patch('admin/worksheets/pub-old/status', 'draft');
|
||||
assert.equal(
|
||||
(await patch('admin/worksheets/pub-old/status', 'active'))
|
||||
.firstPublishedAt,
|
||||
null,
|
||||
);
|
||||
await patch(
|
||||
'admin/worksheets/pub-never-published/status',
|
||||
'hidden',
|
||||
);
|
||||
await patch(
|
||||
'admin/worksheets/pub-never-published/status',
|
||||
'draft',
|
||||
);
|
||||
assert.ok(
|
||||
(
|
||||
await patch(
|
||||
'admin/worksheets/pub-never-published/status',
|
||||
'active',
|
||||
)
|
||||
).firstPublishedAt,
|
||||
);
|
||||
const audits = await prisma.adminAuditLog.count({
|
||||
where: {
|
||||
resourceId: 'pub-draft',
|
||||
action: 'worksheet.status',
|
||||
},
|
||||
});
|
||||
const results = await Promise.all([
|
||||
patch(paths[0]!, 'hidden'),
|
||||
patch(paths[0]!, 'hidden'),
|
||||
]);
|
||||
assert.deepEqual(results[0], results[1]);
|
||||
assert.equal(
|
||||
await prisma.adminAuditLog.count({
|
||||
where: {
|
||||
resourceId: 'pub-draft',
|
||||
action: 'worksheet.status',
|
||||
},
|
||||
}),
|
||||
audits + 1,
|
||||
);
|
||||
const stored = await prisma.worksheet.findUniqueOrThrow({
|
||||
where: { id: 'pub-draft' },
|
||||
});
|
||||
await patch(paths[0]!, 'hidden');
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.worksheet.findUniqueOrThrow({
|
||||
where: { id: 'pub-draft' },
|
||||
})
|
||||
).updatedAt.toISOString(),
|
||||
stored.updatedAt.toISOString(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'状态变更同事务撤销页面快照;无变更不增加版本',
|
||||
async () => {
|
||||
await prisma.pageConfig.create({
|
||||
data: {
|
||||
id: 'publication-snapshot',
|
||||
home: { featured: [{ id: 'pub-live' }] },
|
||||
category: { test: true },
|
||||
age: { test: true },
|
||||
version: 9n,
|
||||
publishedVersion: 9n,
|
||||
publishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await patch('admin/categories/pub-root', 'hidden');
|
||||
const snapshot = await prisma.pageConfig.findUniqueOrThrow({
|
||||
where: { id: 'publication-snapshot' },
|
||||
});
|
||||
assert.equal(snapshot.version, 10n);
|
||||
for (const key of [
|
||||
'home',
|
||||
'category',
|
||||
'age',
|
||||
'publishedVersion',
|
||||
'publishedAt',
|
||||
] as const)
|
||||
assert.equal(snapshot[key], null);
|
||||
await patch('admin/categories/pub-root', 'hidden');
|
||||
assert.deepEqual(
|
||||
await prisma.pageConfig.findUniqueOrThrow({
|
||||
where: { id: 'publication-snapshot' },
|
||||
}),
|
||||
snapshot,
|
||||
);
|
||||
await patch('admin/categories/pub-root', 'active');
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'旧分类导出省略 status 时重导保留下架,新导入分类默认草稿',
|
||||
async () => {
|
||||
const service = app.get(CategoryService);
|
||||
await patch('admin/categories/pub-child', 'hidden');
|
||||
const before = await prisma.category.findUniqueOrThrow({
|
||||
where: { id: 'pub-child' },
|
||||
});
|
||||
const report = await service.importExport(
|
||||
JSON.stringify([
|
||||
{
|
||||
...category,
|
||||
id: 'pub-child',
|
||||
parentId: 'pub-root',
|
||||
sortOrder: 0,
|
||||
},
|
||||
]),
|
||||
false,
|
||||
'publication-import',
|
||||
'test',
|
||||
);
|
||||
assert.equal(report.unchanged, 1);
|
||||
assert.deepEqual(
|
||||
await prisma.category.findUniqueOrThrow({
|
||||
where: { id: 'pub-child' },
|
||||
}),
|
||||
before,
|
||||
);
|
||||
await service.importExport(
|
||||
JSON.stringify([{ ...category, id: 'pub-import-draft' }]),
|
||||
false,
|
||||
'publication-import',
|
||||
'test',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.category.findUniqueOrThrow({
|
||||
where: { id: 'pub-import-draft' },
|
||||
})
|
||||
).status,
|
||||
'draft',
|
||||
);
|
||||
await patch('admin/categories/pub-child', 'active');
|
||||
},
|
||||
);
|
||||
|
||||
await context.test(
|
||||
'审计失败回滚状态、首发时间与快照;服务层复核禁用、降权和版本失效',
|
||||
async () => {
|
||||
const snapshots = await prisma.pageConfig.findMany();
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE FUNCTION fail_publication_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.resource_id = 'pub-rollback' THEN RAISE EXCEPTION 'test rollback'; END IF; RETURN NEW; END $$`,
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
'CREATE TRIGGER fail_publication_audit BEFORE INSERT ON public.admin_audit_logs FOR EACH ROW EXECUTE FUNCTION fail_publication_audit()',
|
||||
);
|
||||
try {
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'admin/worksheets/pub-rollback/status',
|
||||
'PATCH',
|
||||
{ status: 'active' },
|
||||
admin,
|
||||
)
|
||||
).status,
|
||||
503,
|
||||
);
|
||||
const row = await prisma.worksheet.findUniqueOrThrow({
|
||||
where: { id: 'pub-rollback' },
|
||||
});
|
||||
assert.equal(row.status, 'draft');
|
||||
assert.equal(row.firstPublishedAt, null);
|
||||
assert.deepEqual(
|
||||
await prisma.pageConfig.findMany(),
|
||||
snapshots,
|
||||
);
|
||||
assert.equal(
|
||||
await prisma.adminAuditLog.count({
|
||||
where: { resourceId: 'pub-rollback' },
|
||||
}),
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
await prisma.$executeRawUnsafe(
|
||||
'DROP TRIGGER fail_publication_audit ON public.admin_audit_logs',
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
'DROP FUNCTION fail_publication_audit()',
|
||||
);
|
||||
}
|
||||
const service = app.get(PublicationService);
|
||||
await prisma.user.update({
|
||||
where: { id: actor.userId },
|
||||
data: { role: 'user' },
|
||||
});
|
||||
await assert.rejects(
|
||||
service.update(
|
||||
'worksheet',
|
||||
'pub-rollback',
|
||||
{ status: 'active' },
|
||||
actor,
|
||||
'stale',
|
||||
),
|
||||
{ status: 403 },
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'admin/worksheets/pub-live',
|
||||
'GET',
|
||||
undefined,
|
||||
admin,
|
||||
)
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
await prisma.user.update({
|
||||
where: { id: actor.userId },
|
||||
data: { role: 'admin', status: 'disabled' },
|
||||
});
|
||||
await assert.rejects(
|
||||
service.update(
|
||||
'worksheet',
|
||||
'pub-rollback',
|
||||
{ status: 'active' },
|
||||
actor,
|
||||
'stale',
|
||||
),
|
||||
{ status: 401 },
|
||||
);
|
||||
await prisma.user.update({
|
||||
where: { id: actor.userId },
|
||||
data: { status: 'active', authVersion: 2 },
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'admin/worksheet-resources',
|
||||
'GET',
|
||||
undefined,
|
||||
admin,
|
||||
)
|
||||
).status,
|
||||
401,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.update(
|
||||
'resource',
|
||||
'pub-resource',
|
||||
{ status: 'hidden' },
|
||||
actor,
|
||||
'stale',
|
||||
),
|
||||
{ status: 401 },
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { LoginRateLimiter } from '../src/auth/login-rate-limiter';
|
||||
import { ApiException } from '../src/common/api-exception';
|
||||
|
||||
test('登录分别限制 IP 与可信身份,窗口过期后恢复且返回重试期限', (context) => {
|
||||
context.mock.method(Date, 'now', () => 1000);
|
||||
const limiter = new LoginRateLimiter();
|
||||
for (let i = 0; i < 30; i++) limiter.checkIp('127.0.0.1');
|
||||
assert.throws(
|
||||
() => limiter.checkIp('127.0.0.1'),
|
||||
(error: unknown) =>
|
||||
error instanceof ApiException &&
|
||||
error.reason === 'RATE_LIMITED' &&
|
||||
error.retryAfterSeconds === 60,
|
||||
);
|
||||
limiter.checkIp('127.0.0.2');
|
||||
for (let i = 0; i < 10; i++) limiter.checkIdentity('openid-a');
|
||||
assert.throws(() => limiter.checkIdentity('openid-a'), ApiException);
|
||||
limiter.checkIdentity('openid-b');
|
||||
context.mock.method(Date, 'now', () => 61_000);
|
||||
limiter.checkIp('127.0.0.1');
|
||||
limiter.checkIdentity('openid-a');
|
||||
});
|
||||
|
||||
test('大量来源不会无限增长内存,过期后可以回收容量', (context) => {
|
||||
context.mock.method(Date, 'now', () => 0);
|
||||
const limiter = new LoginRateLimiter();
|
||||
for (let i = 0; i < 10_000; i++) limiter.checkIp(`ip-${i}`);
|
||||
assert.throws(() => limiter.checkIp('new-ip'), ApiException);
|
||||
limiter.checkIp('ip-0');
|
||||
context.mock.method(Date, 'now', () => 60_000);
|
||||
limiter.checkIp('new-ip');
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { getRuntimeOptions } from '../src/common/runtime-options';
|
||||
test('使用本地默认 host 和 port', () => {
|
||||
assert.deepEqual(getRuntimeOptions({}), {
|
||||
host: '127.0.0.1',
|
||||
port: 3000,
|
||||
port: 8800,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@ import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { getServerConfig } from '../src/config/server-config';
|
||||
|
||||
const env = { JWT_SECRET: 'test-configuration-secret-12345678901234567890' };
|
||||
const env = {
|
||||
JWT_SECRET: 'test-configuration-secret-12345678901234567890',
|
||||
DATABASE_URL:
|
||||
'postgresql://test:test@127.0.0.1:1/doodle_test?schema=public',
|
||||
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
|
||||
WECHAT_MINI_APP_SECRET: 'test-only-wechat-secret',
|
||||
};
|
||||
|
||||
test('启动配置必须提供有效 JWT 密钥,不使用共享默认密钥', () => {
|
||||
for (const secret of [
|
||||
@@ -33,6 +39,82 @@ test('Swagger 开发默认开启,生产默认关闭,支持显式开关', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('真实登录接入后微信配置必须完整,缺省或留空都拒绝启动', () => {
|
||||
for (const entry of [
|
||||
{ WECHAT_MINI_APP_ID: undefined, WECHAT_MINI_APP_SECRET: undefined },
|
||||
{ WECHAT_MINI_APP_ID: '', WECHAT_MINI_APP_SECRET: '' },
|
||||
{ WECHAT_MINI_APP_ID: ' ', WECHAT_MINI_APP_SECRET: '\t' },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => getServerConfig({ ...env, ...entry }),
|
||||
/WECHAT_MINI_APP/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('成对读取微信配置并去除首尾空白', () => {
|
||||
const config = getServerConfig({
|
||||
...env,
|
||||
WECHAT_MINI_APP_ID: ' wx0123456789abcdef ',
|
||||
WECHAT_MINI_APP_SECRET: ' test-only-wechat-secret ',
|
||||
});
|
||||
assert.equal(config.wechatMiniAppId, 'wx0123456789abcdef');
|
||||
assert.equal(config.wechatMiniAppSecret, 'test-only-wechat-secret');
|
||||
});
|
||||
|
||||
test('拒绝不完整或含内部空白的微信配置,错误不回显配置值', () => {
|
||||
for (const entry of [
|
||||
{
|
||||
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
|
||||
WECHAT_MINI_APP_SECRET: undefined,
|
||||
},
|
||||
{
|
||||
WECHAT_MINI_APP_ID: undefined,
|
||||
WECHAT_MINI_APP_SECRET: 'test-only-wechat-secret',
|
||||
},
|
||||
{
|
||||
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
|
||||
WECHAT_MINI_APP_SECRET: ' ',
|
||||
},
|
||||
{
|
||||
WECHAT_MINI_APP_ID: 'wx0123 456789abcdef',
|
||||
WECHAT_MINI_APP_SECRET: 'test-only-wechat-secret',
|
||||
},
|
||||
{
|
||||
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
|
||||
WECHAT_MINI_APP_SECRET: 'test-only\nwechat-secret',
|
||||
},
|
||||
]) {
|
||||
assert.throws(
|
||||
() => getServerConfig({ ...env, ...entry }),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, /WECHAT_MINI_APP/);
|
||||
for (const value of Object.values(entry)) {
|
||||
if (value?.trim())
|
||||
assert.ok(!error.message.includes(value));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('数据库连接必须为 PostgreSQL public schema,校验不回显含密码的 URL', () => {
|
||||
for (const databaseUrl of [
|
||||
'',
|
||||
'mysql://user:private-password@localhost/db',
|
||||
'postgresql:///db',
|
||||
'postgresql://localhost',
|
||||
'postgresql://localhost/db?schema=other',
|
||||
])
|
||||
assert.throws(
|
||||
() => getServerConfig({ ...env, DATABASE_URL: databaseUrl }),
|
||||
/DATABASE_URL/,
|
||||
);
|
||||
assert.equal(getServerConfig(env).databaseUrl, env.DATABASE_URL);
|
||||
});
|
||||
|
||||
test('拒绝非法 body、JWT 有效期、日志级别、布尔开关和跨域配置', () => {
|
||||
for (const entry of [
|
||||
{ API_BODY_LIMIT_BYTES: '0' },
|
||||
@@ -55,3 +137,30 @@ test('拒绝非法 body、JWT 有效期、日志级别、布尔开关和跨域
|
||||
['https://example.com', 'http://localhost:3001'],
|
||||
);
|
||||
});
|
||||
|
||||
test('题型新品窗口与公共资源域名校验,缺省域名不构造七牛地址', () => {
|
||||
assert.equal(getServerConfig(env).worksheetNewWindowDays, 30);
|
||||
assert.equal(getServerConfig(env).qiniuPublicDomain, null);
|
||||
assert.equal(
|
||||
getServerConfig({
|
||||
...env,
|
||||
QINIU_PUBLIC_DOMAIN: 'https://assets.example.test/',
|
||||
}).qiniuPublicDomain,
|
||||
'https://assets.example.test',
|
||||
);
|
||||
for (const value of ['0', '366', '1e2', ''])
|
||||
assert.throws(() =>
|
||||
getServerConfig({ ...env, WORKSHEET_NEW_WINDOW_DAYS: value }),
|
||||
);
|
||||
for (const value of [
|
||||
'http://assets.example.test',
|
||||
'https://user:secret@example.test',
|
||||
'https://example.test/path',
|
||||
'https://example.test?sign=private',
|
||||
'https://example.test#hash',
|
||||
])
|
||||
assert.throws(
|
||||
() => getServerConfig({ ...env, QINIU_PUBLIC_DOMAIN: value }),
|
||||
/QINIU_PUBLIC_DOMAIN/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,4 +4,8 @@ import { getServerConfig } from '../src/config/server-config';
|
||||
export const testConfig = getServerConfig({
|
||||
NODE_ENV: 'test',
|
||||
JWT_SECRET: 'test-only-secret-123456789012345678901234567890',
|
||||
DATABASE_URL:
|
||||
'postgresql://test:test@127.0.0.1:1/doodle_test?schema=public',
|
||||
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
|
||||
WECHAT_MINI_APP_SECRET: 'test-only-wechat-secret',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { WechatClient, type WechatFetch } from '../src/auth/wechat-client';
|
||||
import { ApiException } from '../src/common/api-exception';
|
||||
import { testConfig } from './test-config';
|
||||
|
||||
const responding =
|
||||
(payload: unknown, status = 200): WechatFetch =>
|
||||
async () =>
|
||||
new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
test('微信兑换只向固定地址发送服务端凭据,忽略 session_key,不重试一次性 code', async () => {
|
||||
let calls = 0;
|
||||
const http: WechatFetch = async (input, init) => {
|
||||
calls += 1;
|
||||
const url = new URL(String(input));
|
||||
assert.equal(url.origin, 'https://api.weixin.qq.com');
|
||||
assert.equal(url.pathname, '/sns/jscode2session');
|
||||
assert.equal(url.searchParams.get('appid'), testConfig.wechatMiniAppId);
|
||||
assert.equal(
|
||||
url.searchParams.get('secret'),
|
||||
testConfig.wechatMiniAppSecret,
|
||||
);
|
||||
assert.equal(url.searchParams.get('js_code'), 'once-code');
|
||||
assert.equal(init?.redirect, 'error');
|
||||
assert.ok(init?.signal);
|
||||
return responding({
|
||||
openid: 'OpenIdA',
|
||||
unionid: 'UnionA',
|
||||
session_key: 'private-session-key',
|
||||
})(input, init);
|
||||
};
|
||||
assert.deepEqual(
|
||||
await new WechatClient(testConfig, http).exchangeCode('once-code'),
|
||||
{ openid: 'OpenIdA', unionid: 'UnionA' },
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
for (const unionid of [undefined, null, '']) {
|
||||
const result = await new WechatClient(
|
||||
testConfig,
|
||||
responding({ openid: 'A', unionid, errcode: 0 }),
|
||||
).exchangeCode('code');
|
||||
assert.equal(result.unionid, null);
|
||||
}
|
||||
});
|
||||
|
||||
test('微信错误、畸形响应和网络失败映射为有限安全错误,不传播原始消息', async () => {
|
||||
for (const [payload, status, reason] of [
|
||||
[{ errcode: 40029 }, 400, 'WECHAT_CODE_INVALID'],
|
||||
[{ errcode: 40163 }, 400, 'WECHAT_CODE_INVALID'],
|
||||
[{ errcode: 40226 }, 403, 'WECHAT_LOGIN_BLOCKED'],
|
||||
[{ errcode: 45011 }, 429, 'RATE_LIMITED'],
|
||||
[{ errcode: -1 }, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
|
||||
[
|
||||
{ errcode: 40013, errmsg: 'private-secret' },
|
||||
503,
|
||||
'AUTH_UPSTREAM_UNAVAILABLE',
|
||||
],
|
||||
[{ openid: '' }, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
|
||||
[{ openid: 'A', unionid: 123 }, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
|
||||
[{ openid: 'x'.repeat(129) }, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
|
||||
[{ openid: ' A' }, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
|
||||
[null, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
|
||||
[[], 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
|
||||
] as const) {
|
||||
await assert.rejects(
|
||||
new WechatClient(testConfig, responding(payload)).exchangeCode(
|
||||
'code',
|
||||
),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiException);
|
||||
assert.equal(error.getStatus(), status);
|
||||
assert.equal(error.reason, reason);
|
||||
assert.ok(
|
||||
!JSON.stringify(error.getResponse()).includes(
|
||||
'private-secret',
|
||||
),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
let calls = 0;
|
||||
const failed: WechatFetch = async () => {
|
||||
calls += 1;
|
||||
throw new Error('https://api.weixin.qq.com?secret=private-secret');
|
||||
};
|
||||
await assert.rejects(
|
||||
new WechatClient(testConfig, failed).exchangeCode('code'),
|
||||
(error: unknown) =>
|
||||
error instanceof ApiException &&
|
||||
error.reason === 'AUTH_UPSTREAM_UNAVAILABLE',
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
for (const http of [
|
||||
responding({}, 502),
|
||||
(async () => new Response('not-json')) as WechatFetch,
|
||||
])
|
||||
await assert.rejects(
|
||||
new WechatClient(testConfig, http).exchangeCode('code'),
|
||||
(error: unknown) =>
|
||||
error instanceof ApiException &&
|
||||
error.reason === 'AUTH_UPSTREAM_UNAVAILABLE',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,523 @@
|
||||
import 'reflect-metadata';
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import type {
|
||||
ApiSuccess,
|
||||
PublicWorksheet,
|
||||
WorksheetPage,
|
||||
} from '@doodle/contracts';
|
||||
import type { Prisma } from '../src/generated/prisma/client';
|
||||
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 { testConfig } from './test-config';
|
||||
|
||||
const databaseUrl = process.env.TEST_DATABASE_URL;
|
||||
test(
|
||||
'真实 PostgreSQL:公开题型组合筛选、分页、最新/热门和数据隔离',
|
||||
{ skip: !databaseUrl },
|
||||
async (context) => {
|
||||
const url = new URL(databaseUrl!);
|
||||
assert.ok(['127.0.0.1', 'localhost'].includes(url.hostname));
|
||||
assert.match(url.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/);
|
||||
const config = {
|
||||
...testConfig,
|
||||
databaseUrl: databaseUrl!,
|
||||
qiniuPublicDomain: 'https://assets.example.test',
|
||||
};
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [AppModule.forRoot(config)],
|
||||
}).compile();
|
||||
const logger = new AppLogger('error', () => {});
|
||||
const app = module.createNestApplication<NestExpressApplication>({
|
||||
logger,
|
||||
bodyParser: false,
|
||||
});
|
||||
context.after(() => app.close());
|
||||
configureApplication(app, config, logger);
|
||||
await app.listen(0, '127.0.0.1');
|
||||
const origin = await app.getUrl();
|
||||
const prisma = app.get(PrismaService).client;
|
||||
const baseActive = await prisma.worksheet.count({
|
||||
where: { status: 'active' },
|
||||
});
|
||||
await prisma.category.createMany({
|
||||
data: [
|
||||
{
|
||||
status: 'active',
|
||||
id: 'query-cat',
|
||||
name: '查询样本',
|
||||
icon: '📐',
|
||||
color: '#fff',
|
||||
},
|
||||
{
|
||||
status: 'active',
|
||||
id: 'query-pages',
|
||||
name: '分页样本',
|
||||
icon: '📐',
|
||||
color: '#fff',
|
||||
},
|
||||
],
|
||||
});
|
||||
await prisma.category.create({
|
||||
data: {
|
||||
status: 'active',
|
||||
id: 'query-child',
|
||||
name: '子分类',
|
||||
icon: '📐',
|
||||
color: '#fff',
|
||||
parentId: 'query-cat',
|
||||
},
|
||||
});
|
||||
await prisma.asset.createMany({
|
||||
data: [
|
||||
{
|
||||
id: 'query-pending',
|
||||
purpose: 'preview',
|
||||
visibility: 'public',
|
||||
sourceRef: 'cloud://source/never-expose.jpg',
|
||||
},
|
||||
{
|
||||
id: 'query-external',
|
||||
purpose: 'preview',
|
||||
visibility: 'public',
|
||||
provider: 'external',
|
||||
status: 'ready',
|
||||
mimeType: 'image/jpeg',
|
||||
externalUrl: 'https://external.example.test/preview.jpg',
|
||||
},
|
||||
{
|
||||
id: 'query-private',
|
||||
purpose: 'preview',
|
||||
visibility: 'private',
|
||||
provider: 'external',
|
||||
status: 'ready',
|
||||
mimeType: 'image/jpeg',
|
||||
externalUrl: 'https://private.example.test/secret.jpg',
|
||||
},
|
||||
{
|
||||
id: 'query-signed',
|
||||
purpose: 'preview',
|
||||
visibility: 'public',
|
||||
provider: 'external',
|
||||
status: 'ready',
|
||||
mimeType: 'image/jpeg',
|
||||
externalUrl:
|
||||
'https://external.example.test/preview.jpg?sign=secret',
|
||||
},
|
||||
{
|
||||
id: 'query-qiniu',
|
||||
purpose: 'preview',
|
||||
visibility: 'public',
|
||||
provider: 'qiniu',
|
||||
status: 'ready',
|
||||
mimeType: 'image/jpeg',
|
||||
bucket: 'test-public',
|
||||
objectKey: 'previews/中文 图片.jpg',
|
||||
objectRefHash: 'a'.repeat(64),
|
||||
checksum: 'b'.repeat(64),
|
||||
sizeBytes: 123n,
|
||||
},
|
||||
{
|
||||
id: 'query-not-image',
|
||||
purpose: 'preview',
|
||||
visibility: 'public',
|
||||
provider: 'external',
|
||||
status: 'ready',
|
||||
mimeType: 'text/html',
|
||||
externalUrl: 'https://external.example.test/error.html',
|
||||
},
|
||||
],
|
||||
});
|
||||
const old = new Date('2025-01-01T00:00:00Z');
|
||||
const now = new Date();
|
||||
const dayAgo = new Date(now.getTime() - 86400000);
|
||||
const fortyDaysAgo = new Date(now.getTime() - 40 * 86400000);
|
||||
const future = new Date(now.getTime() + 86400000);
|
||||
const create = (
|
||||
id: string,
|
||||
extra: Partial<Prisma.WorksheetUncheckedCreateInput> = {},
|
||||
) =>
|
||||
prisma.worksheet.create({
|
||||
data: {
|
||||
id,
|
||||
title: id,
|
||||
subtitle: '',
|
||||
categoryId: 'query-cat',
|
||||
path: `/test?id=${id}`,
|
||||
ageMin: 3,
|
||||
ageMax: 8,
|
||||
difficulty: 1,
|
||||
previewAssetId: 'query-pending',
|
||||
tags: ['数学'],
|
||||
status: 'active',
|
||||
createdAt: old,
|
||||
updatedAt: old,
|
||||
renderConfig: { privateGenerator: 'never-expose-config' },
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
await create('query-a', {
|
||||
grade: 23,
|
||||
isNew: true,
|
||||
isHot: true,
|
||||
sortOrder: 1,
|
||||
downloads: 10n,
|
||||
downloadsSeed: 90n,
|
||||
likes: 1n,
|
||||
updatedAt: now,
|
||||
tags: ['数学', '描红', 'chronology'],
|
||||
});
|
||||
await create('query-b', {
|
||||
grade: 0,
|
||||
sortOrder: 1,
|
||||
firstPublishedAt: dayAgo,
|
||||
ageMax: 4,
|
||||
downloads: 40n,
|
||||
likes: 400n,
|
||||
previewAssetId: 'query-private',
|
||||
tags: ['数学', 'Math', 'chronology'],
|
||||
});
|
||||
await create('query-c', {
|
||||
isHot: true,
|
||||
sortOrder: 2,
|
||||
firstPublishedAt: fortyDaysAgo,
|
||||
ageMin: 4,
|
||||
downloads: 50n,
|
||||
downloadsSeed: 60n,
|
||||
previewAssetId: 'query-external',
|
||||
tags: ['描红', 'chronology'],
|
||||
});
|
||||
await create('query-future', {
|
||||
firstPublishedAt: future,
|
||||
previewAssetId: 'query-signed',
|
||||
});
|
||||
await create('query-qiniu-item', {
|
||||
previewAssetId: 'query-qiniu',
|
||||
tags: ['image'],
|
||||
});
|
||||
await create('query-html-item', {
|
||||
previewAssetId: 'query-not-image',
|
||||
tags: ['image'],
|
||||
});
|
||||
await create('query-hidden', {
|
||||
status: 'hidden',
|
||||
downloads: 1000000n,
|
||||
firstPublishedAt: now,
|
||||
});
|
||||
await create('query-draft', { status: 'draft' });
|
||||
await create('query-child-item', { categoryId: 'query-child' });
|
||||
await create('query-huge-one', {
|
||||
tags: ['precision'],
|
||||
downloads: 9223372036854775807n,
|
||||
});
|
||||
await create('query-huge-two', {
|
||||
tags: ['precision'],
|
||||
downloads: 9223372036854775806n,
|
||||
downloadsSeed: 100n,
|
||||
});
|
||||
await create('query-tie-a', { tags: ['tie'] });
|
||||
await create('query-tie-b', { tags: ['tie'] });
|
||||
await prisma.worksheetGrade.createMany({
|
||||
data: [
|
||||
{ worksheetId: 'query-b', grade: 0 },
|
||||
{ worksheetId: 'query-b', grade: 1 },
|
||||
{ worksheetId: 'query-c', grade: 1 },
|
||||
{ worksheetId: 'query-c', grade: 2 },
|
||||
{ worksheetId: 'query-hidden', grade: 1 },
|
||||
],
|
||||
});
|
||||
const response = (path: string) =>
|
||||
fetch(`${origin}/api/v1/${path}`, {
|
||||
headers: { Authorization: 'Bearer invalid-public-token' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const list = async (query = ''): Promise<WorksheetPage> => {
|
||||
const result = await response(
|
||||
`worksheets${query ? `?${query}` : ''}`,
|
||||
);
|
||||
assert.equal(result.status, 200);
|
||||
const body = (await result.json()) as ApiSuccess<WorksheetPage>;
|
||||
assert.equal(body.code, 0);
|
||||
return body.data;
|
||||
};
|
||||
const ids = (data: WorksheetPage) => data.items.map((row) => row.id);
|
||||
const detail = async (id: string): Promise<PublicWorksheet> => {
|
||||
const result = await response(`worksheets/${id}`);
|
||||
assert.equal(result.status, 200);
|
||||
return ((await result.json()) as ApiSuccess<PublicWorksheet>).data;
|
||||
};
|
||||
|
||||
await context.test(
|
||||
'无需登录可查全部,固定 active,分类不自动包含子级,详情使用公开白名单',
|
||||
async () => {
|
||||
assert.equal((await list()).total, baseActive + 11);
|
||||
const filtered = await list(
|
||||
'categoryId=query-cat&pageSize=100',
|
||||
);
|
||||
assert.equal(filtered.total, 10);
|
||||
assert.equal(filtered.items.length, 10);
|
||||
assert.ok(!ids(filtered).includes('query-child-item'));
|
||||
assert.equal((await list('categoryId=not-found')).total, 0);
|
||||
for (const id of ['query-hidden', 'query-draft', 'missing'])
|
||||
assert.equal(
|
||||
(await response(`worksheets/${id}`)).status,
|
||||
404,
|
||||
);
|
||||
const row = await detail('query-a');
|
||||
assert.equal(row.downloads, '100');
|
||||
assert.equal(row.previewImg, null);
|
||||
assert.deepEqual(row.grades, []);
|
||||
assert.equal(row.isNew, false);
|
||||
for (const field of [
|
||||
'renderConfig',
|
||||
'rendererKey',
|
||||
'generationMode',
|
||||
'previewAssetId',
|
||||
'sourceRef',
|
||||
'grade',
|
||||
'downloadsSeed',
|
||||
'assetKey',
|
||||
'assetOwnerId',
|
||||
])
|
||||
assert.ok(!(field in row));
|
||||
assert.ok(!JSON.stringify(filtered).includes('never-expose'));
|
||||
},
|
||||
);
|
||||
await context.test(
|
||||
'单/多年级使用审核关系,0 不丢失,重复年级与多重命中不产生重复行',
|
||||
async () => {
|
||||
assert.deepEqual(
|
||||
ids(await list('categoryId=query-cat&grade=0')),
|
||||
['query-b'],
|
||||
);
|
||||
const result = await list('categoryId=query-cat&grades=0,1,1');
|
||||
assert.equal(result.total, 2);
|
||||
assert.deepEqual(
|
||||
new Set(ids(result)),
|
||||
new Set(['query-b', 'query-c']),
|
||||
);
|
||||
assert.equal(
|
||||
(await list('categoryId=query-cat&grade=-4')).total,
|
||||
0,
|
||||
);
|
||||
assert.deepEqual((await detail('query-c')).grades, [1, 2]);
|
||||
},
|
||||
);
|
||||
await context.test(
|
||||
'年龄段要求完整覆盖且含边界,单年龄范围匹配;Tag 精确匹配并可组合其他条件',
|
||||
async () => {
|
||||
const band = await list(
|
||||
'categoryId=query-cat&ageBand=3-4&pageSize=100',
|
||||
);
|
||||
assert.ok(
|
||||
ids(band).includes('query-a') &&
|
||||
ids(band).includes('query-b'),
|
||||
);
|
||||
assert.ok(!ids(band).includes('query-c'));
|
||||
// 3~8 岁覆盖中间及最高年龄段;3~4 岁与 4~5 岁仅相交,不应命中。
|
||||
for (const ageBand of ['4-5', '5-6', '7-8']) {
|
||||
const covered = await list(
|
||||
`categoryId=query-cat&ageBand=${ageBand}&pageSize=100`,
|
||||
);
|
||||
assert.ok(ids(covered).includes('query-a'));
|
||||
assert.ok(ids(covered).includes('query-c'));
|
||||
assert.ok(!ids(covered).includes('query-b'));
|
||||
assert.equal(covered.total, covered.items.length);
|
||||
}
|
||||
assert.ok(
|
||||
ids(
|
||||
await list('categoryId=query-cat&age=4&pageSize=100'),
|
||||
).includes('query-c'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
ids(
|
||||
await list(
|
||||
'categoryId=query-cat&grade=1&ageBand=3-4&tag=数学',
|
||||
),
|
||||
),
|
||||
['query-b'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
ids(await list('categoryId=query-cat&tag=Math')),
|
||||
['query-b'],
|
||||
);
|
||||
assert.equal(
|
||||
(await list('categoryId=query-cat&tag=math')).total,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await list(
|
||||
`categoryId=query-cat&tag=${encodeURIComponent("' OR TRUE --")}`,
|
||||
)
|
||||
).total,
|
||||
0,
|
||||
);
|
||||
},
|
||||
);
|
||||
await context.test(
|
||||
'最新按首发/创建时间,热门含 seed,运营推荐与真实新品筛选分开',
|
||||
async () => {
|
||||
assert.deepEqual(
|
||||
ids(
|
||||
await list(
|
||||
'categoryId=query-cat&tag=chronology&sort=latest',
|
||||
),
|
||||
),
|
||||
['query-b', 'query-c', 'query-a'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
ids(
|
||||
await list(
|
||||
'categoryId=query-cat&tag=chronology&sort=hot',
|
||||
),
|
||||
),
|
||||
['query-c', 'query-a', 'query-b'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
ids(await list('categoryId=query-cat&isHot=true')).sort(),
|
||||
['query-a', 'query-c'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
ids(await list('categoryId=query-cat&isNew=true')),
|
||||
['query-b'],
|
||||
);
|
||||
const oldItems = ids(
|
||||
await list('categoryId=query-cat&isNew=false&pageSize=100'),
|
||||
);
|
||||
assert.ok(
|
||||
oldItems.includes('query-a') &&
|
||||
oldItems.includes('query-future'),
|
||||
);
|
||||
assert.ok(!oldItems.includes('query-b'));
|
||||
const precise = await list(
|
||||
'categoryId=query-cat&tag=precision&sort=hot',
|
||||
);
|
||||
assert.deepEqual(ids(precise), [
|
||||
'query-huge-two',
|
||||
'query-huge-one',
|
||||
]);
|
||||
assert.equal(
|
||||
precise.items[0]?.downloads,
|
||||
'9223372036854775906',
|
||||
);
|
||||
},
|
||||
);
|
||||
await context.test(
|
||||
'预览只解析 ready/public/image,无效、私有、签名或非图片不泄漏;七牛 key 正确编码',
|
||||
async () => {
|
||||
assert.equal((await detail('query-b')).previewImg, null);
|
||||
assert.equal((await detail('query-future')).previewImg, null);
|
||||
assert.equal(
|
||||
(await detail('query-html-item')).previewImg,
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
(await detail('query-c')).previewImg,
|
||||
'https://external.example.test/preview.jpg',
|
||||
);
|
||||
assert.equal(
|
||||
(await detail('query-qiniu-item')).previewImg,
|
||||
'https://assets.example.test/previews/%E4%B8%AD%E6%96%87%20%E5%9B%BE%E7%89%87.jpg',
|
||||
);
|
||||
},
|
||||
);
|
||||
await context.test(
|
||||
'稳定分页可取超过 100 条,总数与筛选一致,越页为空;非法/管理参数被拒绝',
|
||||
async () => {
|
||||
const first = await list(
|
||||
'categoryId=query-cat&tag=tie&pageSize=1',
|
||||
);
|
||||
const second = await list(
|
||||
'categoryId=query-cat&tag=tie&pageSize=1&page=2',
|
||||
);
|
||||
assert.deepEqual(ids(first), ['query-tie-a']);
|
||||
assert.deepEqual(ids(second), ['query-tie-b']);
|
||||
assert.equal(first.hasNext, true);
|
||||
assert.equal(second.hasNext, false);
|
||||
await prisma.worksheet.createMany({
|
||||
data: Array.from({ length: 121 }, (_, i) => ({
|
||||
id: `query-page-${String(i).padStart(3, '0')}`,
|
||||
title: '分页题型',
|
||||
subtitle: '',
|
||||
categoryId: 'query-pages',
|
||||
path: '/test',
|
||||
ageMin: 3,
|
||||
ageMax: 5,
|
||||
difficulty: 1,
|
||||
previewAssetId: 'query-pending',
|
||||
status: 'active',
|
||||
createdAt: old,
|
||||
updatedAt: old,
|
||||
})),
|
||||
});
|
||||
const pageOne = await list(
|
||||
'categoryId=query-pages&pageSize=100',
|
||||
);
|
||||
const pageTwo = await list(
|
||||
'categoryId=query-pages&pageSize=100&page=2',
|
||||
);
|
||||
assert.equal(pageOne.total, 121);
|
||||
assert.equal(pageOne.items.length, 100);
|
||||
assert.equal(pageTwo.items.length, 21);
|
||||
assert.equal(
|
||||
new Set([...ids(pageOne), ...ids(pageTwo)]).size,
|
||||
121,
|
||||
);
|
||||
assert.equal(pageTwo.hasNext, false);
|
||||
const beyond = await list('categoryId=query-pages&page=9');
|
||||
assert.equal(beyond.total, 121);
|
||||
assert.deepEqual(beyond.items, []);
|
||||
for (const query of [
|
||||
'status=hidden',
|
||||
'rawStats=true',
|
||||
'grade=23',
|
||||
'pageSize=101',
|
||||
'ageBand=3-5',
|
||||
'page=1&page=2',
|
||||
'isHot=1',
|
||||
'grades=1,,2',
|
||||
])
|
||||
assert.equal(
|
||||
(await response(`worksheets?${query}`)).status,
|
||||
400,
|
||||
);
|
||||
},
|
||||
);
|
||||
await context.test('Swagger 公共声明与查询参数已注册', async () => {
|
||||
const result = await fetch(`${origin}/api-docs-json`);
|
||||
const doc = (await result.json()) as {
|
||||
paths: Record<
|
||||
string,
|
||||
{
|
||||
get: {
|
||||
security: unknown[];
|
||||
parameters: Array<{ name: string }>;
|
||||
};
|
||||
}
|
||||
>;
|
||||
};
|
||||
const route = doc.paths['/api/v1/worksheets']!.get;
|
||||
assert.deepEqual(route.security, []);
|
||||
for (const name of [
|
||||
'categoryId',
|
||||
'grade',
|
||||
'grades',
|
||||
'ageBand',
|
||||
'tag',
|
||||
'sort',
|
||||
'isHot',
|
||||
'isNew',
|
||||
'page',
|
||||
'pageSize',
|
||||
])
|
||||
assert.ok(
|
||||
route.parameters.some((param) => param.name === name),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,194 @@
|
||||
import 'reflect-metadata';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { test } from 'node:test';
|
||||
import { PrismaService } from '../src/database/prisma.service';
|
||||
import { WorksheetImporter } from '../src/imports/worksheet-import';
|
||||
import { testConfig } from './test-config';
|
||||
import { worksheetFixture as fixture } from './worksheet-import.fixture';
|
||||
|
||||
const databaseUrl = process.env.TEST_DATABASE_URL;
|
||||
test(
|
||||
'真实 PostgreSQL:题型元数据导入、待迁移资产、对账、幂等与整批回滚',
|
||||
{ skip: !databaseUrl },
|
||||
async (context) => {
|
||||
const url = new URL(databaseUrl!);
|
||||
assert.ok(['127.0.0.1', 'localhost'].includes(url.hostname));
|
||||
assert.match(url.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/);
|
||||
const db = new PrismaService({
|
||||
...testConfig,
|
||||
databaseUrl: databaseUrl!,
|
||||
});
|
||||
context.after(() => db.onModuleDestroy());
|
||||
const prisma = db.client;
|
||||
const importer = new WorksheetImporter(db);
|
||||
await prisma.category.create({
|
||||
data: {
|
||||
status: 'active',
|
||||
id: fixture.category,
|
||||
name: '导入验证',
|
||||
icon: '📐',
|
||||
color: '#fff',
|
||||
},
|
||||
});
|
||||
const run = (rows: unknown[], dryRun = false, metadataOnly = true) => {
|
||||
const source = rows.map((row) => JSON.stringify(row)).join('\n');
|
||||
return importer.importExport(source, {
|
||||
dryRun,
|
||||
metadataOnly,
|
||||
requestId: randomUUID(),
|
||||
sourceSha256: createHash('sha256').update(source).digest('hex'),
|
||||
});
|
||||
};
|
||||
const counts = async () => ({
|
||||
assets: await prisma.asset.count(),
|
||||
worksheets: await prisma.worksheet.count(),
|
||||
audits: await prisma.adminAuditLog.count(),
|
||||
});
|
||||
|
||||
await context.test(
|
||||
'默认拒绝未就绪预览;预演及无效分类不写任何数据',
|
||||
async () => {
|
||||
const before = await counts();
|
||||
await assert.rejects(run([fixture], false, false), {
|
||||
status: 409,
|
||||
});
|
||||
await assert.rejects(
|
||||
run([{ ...fixture, category: 'missing-category' }]),
|
||||
{ status: 400 },
|
||||
);
|
||||
const preview = await run([fixture], true);
|
||||
assert.equal(preview.inserted, 1);
|
||||
assert.equal(preview.pendingPreviews, 1);
|
||||
assert.equal(preview.readyForPublicUse, false);
|
||||
assert.deepEqual(await counts(), before);
|
||||
},
|
||||
);
|
||||
await context.test(
|
||||
'同批保存原 ID、原始计数、时间、旧年级及 pending 资产,不伪造资源或年级关系',
|
||||
async () => {
|
||||
const report = await run([{ ...fixture, grade: 23 }]);
|
||||
assert.equal(report.inserted, 1);
|
||||
assert.equal(report.assetsInserted, 1);
|
||||
assert.equal(report.gradeRelationsInserted, 0);
|
||||
assert.equal(report.warnings[0]?.originalValue, 23);
|
||||
const row = await prisma.worksheet.findUniqueOrThrow({
|
||||
where: { id: fixture._id },
|
||||
});
|
||||
assert.equal(row.grade, 23);
|
||||
assert.equal(row.downloads, 23n);
|
||||
assert.equal(row.downloadsSeed, 12n);
|
||||
assert.equal(
|
||||
row.createdAt.toISOString(),
|
||||
fixture.createdAt.$date,
|
||||
);
|
||||
assert.equal(
|
||||
row.updatedAt.toISOString(),
|
||||
fixture.updatedAt.$date,
|
||||
);
|
||||
assert.equal(row.firstPublishedAt, null);
|
||||
assert.equal(row.contentUpdatedAt, null);
|
||||
assert.equal(row.status, 'active');
|
||||
const asset = await prisma.asset.findUniqueOrThrow({
|
||||
where: { id: row.previewAssetId },
|
||||
});
|
||||
assert.equal(asset.sourceRef, fixture.previewImg);
|
||||
assert.equal(
|
||||
asset.sourceRefHash,
|
||||
createHash('sha256')
|
||||
.update(fixture.previewImg)
|
||||
.digest('hex'),
|
||||
);
|
||||
assert.equal(asset.status, 'pending');
|
||||
assert.equal(asset.objectKey, null);
|
||||
assert.equal(asset.bucket, null);
|
||||
assert.equal(asset.mimeType, null);
|
||||
assert.equal(
|
||||
await prisma.worksheetGrade.count({
|
||||
where: { worksheetId: fixture._id },
|
||||
}),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
await prisma.worksheetResource.count({
|
||||
where: { worksheetId: fixture._id },
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const audit = await prisma.adminAuditLog.findFirstOrThrow({
|
||||
where: {
|
||||
resourceId: fixture._id,
|
||||
action: 'worksheet.import',
|
||||
},
|
||||
});
|
||||
assert.equal(audit.actorType, 'migration');
|
||||
assert.equal(
|
||||
(audit.afterData as { downloads: string }).downloads,
|
||||
'23',
|
||||
);
|
||||
},
|
||||
);
|
||||
await context.test(
|
||||
'重导不新增或更新时间,共享预览复用资产;不同旧快照禁止覆盖',
|
||||
async () => {
|
||||
const before = await counts();
|
||||
const stored = await prisma.worksheet.findUniqueOrThrow({
|
||||
where: { id: fixture._id },
|
||||
});
|
||||
const repeated = await run([{ ...fixture, grade: 23 }]);
|
||||
assert.equal(repeated.unchanged, 1);
|
||||
assert.equal(repeated.assetsInserted, 0);
|
||||
assert.deepEqual(await counts(), before);
|
||||
assert.deepEqual(
|
||||
await prisma.worksheet.findUniqueOrThrow({
|
||||
where: { id: fixture._id },
|
||||
}),
|
||||
stored,
|
||||
);
|
||||
const shared = await run([
|
||||
{ ...fixture, _id: 'worksheet-import-shared' },
|
||||
]);
|
||||
assert.equal(shared.assetsInserted, 0);
|
||||
assert.equal(shared.assetsReused, 1);
|
||||
assert.equal(await prisma.asset.count(), before.assets);
|
||||
await assert.rejects(
|
||||
run([{ ...fixture, grade: 23, downloads: 24 }]),
|
||||
{ status: 409 },
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.worksheet.findUniqueOrThrow({
|
||||
where: { id: fixture._id },
|
||||
})
|
||||
).downloads,
|
||||
23n,
|
||||
);
|
||||
},
|
||||
);
|
||||
await context.test(
|
||||
'后半批冲突会回滚前面新增题型、资产及审计',
|
||||
async () => {
|
||||
const before = await counts();
|
||||
await assert.rejects(
|
||||
run([
|
||||
{
|
||||
...fixture,
|
||||
_id: 'worksheet-import-rollback',
|
||||
previewImg:
|
||||
'cloud://fixture-env/assets/previews/rollback.jpg',
|
||||
},
|
||||
{ ...fixture, grade: 23, title: '禁止覆盖' },
|
||||
]),
|
||||
{ status: 409 },
|
||||
);
|
||||
assert.deepEqual(await counts(), before);
|
||||
assert.equal(
|
||||
await prisma.worksheet.count({
|
||||
where: { id: 'worksheet-import-rollback' },
|
||||
}),
|
||||
0,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
/** 最小旧格式样本,不依赖附件路径或线上文件。 */
|
||||
export const worksheetFixture = {
|
||||
_id: 'worksheet-import-one',
|
||||
title: '导入测试',
|
||||
subtitle: '',
|
||||
category: 'worksheet-import-test',
|
||||
subcategory: '',
|
||||
path: '/mathPages/mathDraw/mathDraw?id=worksheet-import-one',
|
||||
ageMin: 3,
|
||||
ageMax: 6,
|
||||
grade: 0,
|
||||
difficulty: 1,
|
||||
previewImg: 'cloud://fixture-env/assets/previews/one.jpg',
|
||||
tags: ['数学'],
|
||||
isNew: false,
|
||||
isHot: true,
|
||||
sortOrder: 5,
|
||||
downloads: 23,
|
||||
likes: 2,
|
||||
status: 'active',
|
||||
createdAt: { $date: '2026-04-24T05:26:16.507Z' },
|
||||
updatedAt: { $date: '2026-09-07T05:43:03.511Z' },
|
||||
downloads_seed: 12,
|
||||
likes_seed: 14,
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { parseWorksheetExport } from '../src/imports/worksheet-import';
|
||||
import { worksheetFixture as fixture } from './worksheet-import.fixture';
|
||||
|
||||
test('题型导入:JSON Lines、BSON 日期、计数和缺省字段准确保留', () => {
|
||||
const row = {
|
||||
...fixture,
|
||||
grade: 23,
|
||||
downloads_seed: undefined,
|
||||
contentUpdatedAt: { $date: '2026-06-01T08:00:00+08:00' },
|
||||
};
|
||||
const result = parseWorksheetExport(`\uFEFF${JSON.stringify(row)}\n`);
|
||||
const stored = result.rows[0]!.data;
|
||||
assert.equal(stored.downloads, 23n);
|
||||
assert.equal(stored.downloadsSeed, 0n);
|
||||
assert.equal(stored.likesSeed, 14n);
|
||||
assert.equal(stored.grade, 23);
|
||||
assert.equal(stored.subcategory, null);
|
||||
assert.equal(stored.createdAt.toISOString(), fixture.createdAt.$date);
|
||||
assert.equal(
|
||||
stored.contentUpdatedAt?.toISOString(),
|
||||
'2026-06-01T00:00:00.000Z',
|
||||
);
|
||||
assert.equal(stored.firstPublishedAt, null);
|
||||
assert.equal(stored.rendererKey, null);
|
||||
assert.equal(result.warnings.length, 2);
|
||||
assert.equal(
|
||||
parseWorksheetExport(JSON.stringify([fixture])).rows[0]?.data.path,
|
||||
fixture.path,
|
||||
);
|
||||
});
|
||||
|
||||
test('题型导入:拒绝未知字段、重复 ID、非法日期、类型及计数精度丢失', () => {
|
||||
for (const patch of [
|
||||
{ unknown: true },
|
||||
{ title: 42 },
|
||||
{ category: null },
|
||||
{ difficulty: 0 },
|
||||
{ ageMax: 1 },
|
||||
{ tags: ['a', 1] },
|
||||
{ isNew: 'false' },
|
||||
{ downloads: -1 },
|
||||
{ downloads: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ grade: 32768 },
|
||||
{ status: 'deleted' },
|
||||
{ previewImg: 'https://example.com/a.jpg' },
|
||||
{ createdAt: { $date: '2026-02-30T00:00:00Z' } },
|
||||
{ createdAt: { $date: '2026-01-01' } },
|
||||
{ createdAt: null },
|
||||
{ createdAt: { $date: fixture.createdAt.$date, injected: true } },
|
||||
{ updatedAt: { $date: '2025-01-01T00:00:00Z' } },
|
||||
])
|
||||
assert.throws(() =>
|
||||
parseWorksheetExport(JSON.stringify([{ ...fixture, ...patch }])),
|
||||
);
|
||||
assert.throws(() =>
|
||||
parseWorksheetExport(JSON.stringify([fixture, fixture])),
|
||||
);
|
||||
for (const value of ['', '[]', '{}', '{broken'])
|
||||
assert.throws(() => parseWorksheetExport(value));
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
parseWorksheetQuery,
|
||||
WorksheetQueryDto,
|
||||
} from '../src/worksheets/worksheet-query.dto';
|
||||
|
||||
test('题型查询参数:零年级、多个年级去重、false 与组合筛选可准确解析', () => {
|
||||
const query = parseWorksheetQuery({
|
||||
grade: '0',
|
||||
ageBand: '3-4',
|
||||
tag: ' 数学 ',
|
||||
isHot: 'false',
|
||||
});
|
||||
assert.deepEqual(query.grades, [0]);
|
||||
assert.equal(query.isHot, false);
|
||||
assert.equal(query.page, 1);
|
||||
assert.equal(query.pageSize, 20);
|
||||
assert.equal(query.tag, '数学');
|
||||
assert.deepEqual(
|
||||
parseWorksheetQuery({ grades: '-4,0,6,0' }).grades,
|
||||
[-4, 0, 6],
|
||||
);
|
||||
assert.equal(parseWorksheetQuery({ age: '0' }).age, 0);
|
||||
});
|
||||
|
||||
test('题型查询参数:非法值、重复参数和越权状态字段不能被静默忽略', () => {
|
||||
const bad: unknown[] = [
|
||||
{ page: '0' },
|
||||
{ page: '1e2' },
|
||||
{ pageSize: '101' },
|
||||
{ page: ['1', '2'] },
|
||||
{ grade: '23' },
|
||||
{ grade: '0', grades: '1' },
|
||||
{ grades: '0,,1' },
|
||||
{ grades: '' },
|
||||
{ ageBand: '3-5' },
|
||||
{ age: '4', ageBand: '3-4' },
|
||||
{ isHot: '0' },
|
||||
{ isNew: 'False' },
|
||||
{ sort: 'random' },
|
||||
{ tag: ['a', 'b'] },
|
||||
{ tag: '' },
|
||||
{ status: 'draft' },
|
||||
{ rawStats: 'true' },
|
||||
];
|
||||
for (const query of bad)
|
||||
assert.throws(() => parseWorksheetQuery(query as WorksheetQueryDto));
|
||||
});
|
||||
Reference in New Issue
Block a user