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