732 lines
28 KiB
TypeScript
732 lines
28 KiB
TypeScript
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',
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|