From b9be87e805bfefcbfcb42de645cd15a2a6f1baba Mon Sep 17 00:00:00 2001 From: wangdl Date: Sun, 21 Jun 2026 17:46:14 +0800 Subject: [PATCH] test(M-AI-05): enforce cross-user Feynman authorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- test/m-ai-05-feynman.e2e-spec.ts | 137 ++++++++++++++++++++++++++----- 1 file changed, 118 insertions(+), 19 deletions(-) diff --git a/test/m-ai-05-feynman.e2e-spec.ts b/test/m-ai-05-feynman.e2e-spec.ts index 788a49b..459d7b9 100644 --- a/test/m-ai-05-feynman.e2e-spec.ts +++ b/test/m-ai-05-feynman.e2e-spec.ts @@ -331,35 +331,134 @@ describe('M-AI-05 Feynman E2E (real infra)', () => { // 场景 7: 其他用户知识点请求被拒绝(权限) // ═══════════════════════════════════════════════════════════ - describe('场景 7: 跨用户权限拒绝', () => { - it('用户 2 尝试使用用户 1 的知识点 → 403 Forbidden', async () => { - // 调用链: - // 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 写入前传播 + // ═══════════════════════════════════════════════════════════ + // 场景 7: P0 跨用户权限拒绝 + // + // 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') .set('Authorization', `Bearer ${userToken2}`) .send({ knowledgeItemTitle: '光合作用', knowledgeItemContent: '植物利用光能的过程', - userExplanation: '测试解释', - knowledgeItemId: testKnowledgeItemId, // 属于 user1 + userExplanation: 'User B 尝试评估 User A 的知识点', + knowledgeItemId: testKnowledgeItemId, // 属于 User A + sessionId: 'e2e-cross-user-session', + answerId: 'e2e-cross-user-answer', }) .expect(403); - // 验证没有为 user2 创建使用 user1 知识点的 Job - const jobs = await prisma.aiJob.findMany({ - where: { - userId: userId2, - targetId: testKnowledgeItemId, - }, + // ── 错误响应结构 ── + // NestJS ForbiddenException → { statusCode: 403, message: 'Forbidden' } + expect(res.body).toBeDefined(); + expect(res.body.statusCode || res.body.status).toBe(403); + // 不含内部堆栈 + 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); }); });