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:
wangdl 2026-06-21 17:46:14 +08:00
parent 8987598eb8
commit b9be87e805

View File

@ -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') // Fixture:
// → snapshotBuilder.build({ userId: 'u-B', knowledgeItemId: 'u-A的KI' }) // User A (userId) → JWT userToken
// → knowledgeItem.userId ('u-A') !== input.userId ('u-B') // User B (userId2) → JWT userToken2
// → throw ForbiddenException → NestJS 全局异常过滤器 → HTTP 403 // KnowledgeItem A → testKnowledgeItemId, userId=userId (User A)
// //
// SnapshotBuilder 在 createJob 之前调用ForbiddenException 在 DB 写入前传播 // 请求:
// 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);
}); });
}); });