test(M-AI-05): enforce cross-user Feynman authorization
Scenario 7 E2E fix: - Asset correct HTTP 403 (was 201 in earlier draft) - Add before/after zero-side-effect verification (7 tables) - Add model-call-count = 0 verification (via UsageLog) - Add stable error structure assertion (no stack/Prisma/DATABASE_URL) - Add Legacy path behavior documentation (known limitation: Legacy does not accept knowledgeItemId, no ownership check) Real call chain: FeynmanExecutionRouter.evaluateFeynman():121 → FeynmanSnapshotBuilder.build():102-110 → knowledgeItem.userId !== input.userId → ForbiddenException → before AiJobCreationService.createJob():124 → NestJS → HTTP 403 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
8987598eb8
commit
b9be87e805
@ -331,35 +331,134 @@ describe('M-AI-05 Feynman E2E (real infra)', () => {
|
|||||||
// 场景 7: 其他用户知识点请求被拒绝(权限)
|
// 场景 7: 其他用户知识点请求被拒绝(权限)
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
describe('场景 7: 跨用户权限拒绝', () => {
|
// ═══════════════════════════════════════════════════════════
|
||||||
it('用户 2 尝试使用用户 1 的知识点 → 403 Forbidden', async () => {
|
// 场景 7: P0 跨用户权限拒绝
|
||||||
// 调用链:
|
|
||||||
// Router.evaluateFeynman(userId='u-B')
|
|
||||||
// → snapshotBuilder.build({ userId: 'u-B', knowledgeItemId: 'u-A的KI' })
|
|
||||||
// → knowledgeItem.userId ('u-A') !== input.userId ('u-B')
|
|
||||||
// → throw ForbiddenException → NestJS 全局异常过滤器 → HTTP 403
|
|
||||||
//
|
//
|
||||||
// SnapshotBuilder 在 createJob 之前调用,ForbiddenException 在 DB 写入前传播
|
// Fixture:
|
||||||
|
// User A (userId) → JWT userToken
|
||||||
|
// User B (userId2) → JWT userToken2
|
||||||
|
// KnowledgeItem A → testKnowledgeItemId, userId=userId (User A)
|
||||||
|
//
|
||||||
|
// 请求:
|
||||||
|
// User B (userToken2) + KnowledgeItem A (testKnowledgeItemId)
|
||||||
|
//
|
||||||
|
// 预期:
|
||||||
|
// HTTP 403 Forbidden
|
||||||
|
// SnapshotBuilder 检测 knowledgeItem.userId !== input.userId
|
||||||
|
// 异常在 AiJobCreationService.createJob() 之前传播
|
||||||
|
// 零数据库副作用 / 零模型调用
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
describe('场景 7: 跨用户权限拒绝', () => {
|
||||||
|
it('User B 提交 User A 的知识点 → 403 + 零副作用', async () => {
|
||||||
|
// ── 请求前计数 ──
|
||||||
|
const countsBefore = {
|
||||||
|
aiJob: await prisma.aiJob.count({ where: { userId: userId2 } }),
|
||||||
|
aiJobSnapshot: await prisma.aiJobSnapshot.count(),
|
||||||
|
outboxEvent: await (prisma as any).outboxEvent.count(),
|
||||||
|
aiUsageLog: await (prisma as any).aiUsageLog.count({ where: { userId: userId2 } }),
|
||||||
|
aiAnalysisResult: await prisma.aiAnalysisResult.count({ where: { userId: userId2 } }),
|
||||||
|
focusItem: await prisma.focusItem.count({ where: { userId: userId2 } }),
|
||||||
|
aiJobArtifact: await prisma.aiJobArtifact.count(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 跨用户请求 ──
|
||||||
|
// User B (userToken2) 使用 User A 的 KnowledgeItem ID
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
.post('/api/ai-analysis/feynman')
|
.post('/api/ai-analysis/feynman')
|
||||||
.set('Authorization', `Bearer ${userToken2}`)
|
.set('Authorization', `Bearer ${userToken2}`)
|
||||||
.send({
|
.send({
|
||||||
knowledgeItemTitle: '光合作用',
|
knowledgeItemTitle: '光合作用',
|
||||||
knowledgeItemContent: '植物利用光能的过程',
|
knowledgeItemContent: '植物利用光能的过程',
|
||||||
userExplanation: '测试解释',
|
userExplanation: 'User B 尝试评估 User A 的知识点',
|
||||||
knowledgeItemId: testKnowledgeItemId, // 属于 user1
|
knowledgeItemId: testKnowledgeItemId, // 属于 User A
|
||||||
|
sessionId: 'e2e-cross-user-session',
|
||||||
|
answerId: 'e2e-cross-user-answer',
|
||||||
})
|
})
|
||||||
.expect(403);
|
.expect(403);
|
||||||
|
|
||||||
// 验证没有为 user2 创建使用 user1 知识点的 Job
|
// ── 错误响应结构 ──
|
||||||
const jobs = await prisma.aiJob.findMany({
|
// NestJS ForbiddenException → { statusCode: 403, message: 'Forbidden' }
|
||||||
where: {
|
expect(res.body).toBeDefined();
|
||||||
userId: userId2,
|
expect(res.body.statusCode || res.body.status).toBe(403);
|
||||||
targetId: testKnowledgeItemId,
|
// 不含内部堆栈
|
||||||
},
|
if (res.body.message) {
|
||||||
|
expect(typeof res.body.message).toBe('string');
|
||||||
|
expect(res.body.message).not.toContain('at ');
|
||||||
|
expect(res.body.message).not.toContain('node_modules');
|
||||||
|
expect(res.body.message).not.toContain('Prisma');
|
||||||
|
expect(res.body.message).not.toContain('DATABASE_URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 请求后计数 ──
|
||||||
|
const countsAfter = {
|
||||||
|
aiJob: await prisma.aiJob.count({ where: { userId: userId2 } }),
|
||||||
|
aiJobSnapshot: await prisma.aiJobSnapshot.count(),
|
||||||
|
outboxEvent: await (prisma as any).outboxEvent.count(),
|
||||||
|
aiUsageLog: await (prisma as any).aiUsageLog.count({ where: { userId: userId2 } }),
|
||||||
|
aiAnalysisResult: await prisma.aiAnalysisResult.count({ where: { userId: userId2 } }),
|
||||||
|
focusItem: await prisma.focusItem.count({ where: { userId: userId2 } }),
|
||||||
|
aiJobArtifact: await prisma.aiJobArtifact.count(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 零副作用验证 ──
|
||||||
|
expect(countsAfter.aiJob).toBe(countsBefore.aiJob); // 0 new AiJob
|
||||||
|
expect(countsAfter.aiJobSnapshot).toBe(countsBefore.aiJobSnapshot); // 0 new Snapshot
|
||||||
|
expect(countsAfter.outboxEvent).toBe(countsBefore.outboxEvent); // 0 new Outbox
|
||||||
|
expect(countsAfter.aiUsageLog).toBe(countsBefore.aiUsageLog); // 0 new UsageLog (≡ 0 model calls)
|
||||||
|
expect(countsAfter.aiAnalysisResult).toBe(countsBefore.aiAnalysisResult); // 0 new Result
|
||||||
|
expect(countsAfter.focusItem).toBe(countsBefore.focusItem); // 0 new FocusItem
|
||||||
|
expect(countsAfter.aiJobArtifact).toBe(countsBefore.aiJobArtifact); // 0 new Artifact
|
||||||
|
|
||||||
|
// ── 验证 User B 下绝对没有使用 User A 知识点的 Job ──
|
||||||
|
const crossJobs = await prisma.aiJob.findMany({
|
||||||
|
where: { userId: userId2, targetId: testKnowledgeItemId },
|
||||||
|
});
|
||||||
|
expect(crossJobs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Legacy 路径:User B 提交时 Router 回退到 Legacy(无 knowledgeItemId)', async () => {
|
||||||
|
// Legacy 路径不接受 knowledgeItemId,不执行所有权校验。
|
||||||
|
// 这是已知的 Legacy 设计限制 — 记录现状,不在本批修复。
|
||||||
|
// 生产默认保持 Legacy 时,依赖客户端不会伪造 knowledgeItemTitle/content。
|
||||||
|
|
||||||
|
// 临时关闭 Unified FeatureFlag
|
||||||
|
await prisma.featureFlag.update({
|
||||||
|
where: { name: 'FEYNMAN_ENGINE_MODE' },
|
||||||
|
data: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const countsBefore = await prisma.aiJob.count({ where: { userId: userId2 } });
|
||||||
|
|
||||||
|
// Legacy 请求(不含 knowledgeItemId — Legacy 不支持此字段)
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/api/ai-analysis/feynman')
|
||||||
|
.set('Authorization', `Bearer ${userToken2}`)
|
||||||
|
.send({
|
||||||
|
knowledgeItemTitle: '光合作用',
|
||||||
|
knowledgeItemContent: '植物利用光能的过程',
|
||||||
|
userExplanation: 'Legacy 跨用户测试',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
// Legacy 接受请求(无所有权校验)
|
||||||
|
expect(res.body.jobId).toBeTruthy();
|
||||||
|
expect(res.body.engineMode).toBeUndefined(); // Legacy 不含此字段
|
||||||
|
|
||||||
|
const countsAfter = await prisma.aiJob.count({ where: { userId: userId2 } });
|
||||||
|
// Legacy 创建了 Job — 已知限制,不在本批修复
|
||||||
|
expect(countsAfter).toBeGreaterThan(countsBefore);
|
||||||
|
|
||||||
|
// 清理 Legacy 创建的 Job
|
||||||
|
if (res.body.jobId) {
|
||||||
|
try { await prisma.aiJob.delete({ where: { id: res.body.jobId } }); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复 FeatureFlag
|
||||||
|
await prisma.featureFlag.update({
|
||||||
|
where: { name: 'FEYNMAN_ENGINE_MODE' },
|
||||||
|
data: { enabled: true, whitelist: userId },
|
||||||
});
|
});
|
||||||
expect(jobs).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user