diff --git a/src/common/guards/internal-auth.guard.ts b/src/common/guards/internal-auth.guard.ts new file mode 100644 index 0000000..cd00432 --- /dev/null +++ b/src/common/guards/internal-auth.guard.ts @@ -0,0 +1,30 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { Request } from 'express'; + +@Injectable() +export class InternalAuthGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const key = + request.headers['x-internal-api-key'] as string | undefined; + + const expected = + process.env.INTERNAL_API_KEY || + process.env.RAG_WORKER_SECRET; + + if (!expected) { + throw new UnauthorizedException('内部服务未配置 API Key'); + } + + if (!key || key !== expected) { + throw new UnauthorizedException('内部服务认证失败'); + } + + return true; + } +} diff --git a/src/common/guards/jwt-auth.guard.ts b/src/common/guards/jwt-auth.guard.ts index 9b29cc6..4ba17da 100644 --- a/src/common/guards/jwt-auth.guard.ts +++ b/src/common/guards/jwt-auth.guard.ts @@ -7,6 +7,7 @@ import { import { Reflector } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; import { Request } from 'express'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; @@ -16,6 +17,7 @@ export class JwtAuthGuard implements CanActivate { private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly reflector: Reflector, + private readonly prisma: PrismaService, ) {} async canActivate(context: ExecutionContext): Promise { @@ -42,9 +44,29 @@ export class JwtAuthGuard implements CanActivate { const payload = await this.jwtService.verifyAsync(token, { secret: this.configService.get('jwt.secret'), }); - request.user = { id: String(payload.sub), email: payload.email, role: payload.role }; + + // Reject admin tokens on user endpoints + if (payload.type === 'admin') { + throw new UnauthorizedException('无效的访问令牌'); + } + + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + select: { id: true, status: true, deletedAt: true, role: true, email: true }, + }); + + if (!user || user.deletedAt) { + throw new UnauthorizedException('账号不存在或已注销'); + } + + if (user.status !== 'active') { + throw new UnauthorizedException('账号已被禁用'); + } + + request.user = { id: user.id, email: user.email, role: user.role }; return true; - } catch { + } catch (err) { + if (err instanceof UnauthorizedException) throw err; throw new UnauthorizedException('登录已过期,请重新登录'); } } diff --git a/src/config/jwt.config.ts b/src/config/jwt.config.ts index 5ac611c..58b80cd 100644 --- a/src/config/jwt.config.ts +++ b/src/config/jwt.config.ts @@ -20,6 +20,13 @@ export default registerAs('jwt', () => { ); } + // Enforce admin JWT secret isolation in production + if (process.env.NODE_ENV === 'production' && !process.env.ADMIN_JWT_ACCESS_SECRET) { + throw new Error( + '生产环境必须设置 ADMIN_JWT_ACCESS_SECRET 环境变量,不允许与用户 JWT 共用密钥', + ); + } + return { secret: accessSecret || 'change_me_in_production', accessSecret: accessSecret || 'change_me_in_production', diff --git a/src/modules/auth/apple-auth.service.ts b/src/modules/auth/apple-auth.service.ts index ee01a3f..0a77a8a 100644 --- a/src/modules/auth/apple-auth.service.ts +++ b/src/modules/auth/apple-auth.service.ts @@ -26,6 +26,9 @@ export class AppleAuthService { emailVerified?: boolean; }> { if (!this.appleBundleId) { + if (process.env.NODE_ENV === 'production') { + throw new UnauthorizedException('Apple 登录未配置,请联系管理员'); + } return this.verifyMock(identityToken); } return this.verifyReal(identityToken); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 4c53839..f6695bd 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -106,6 +106,16 @@ export class AuthService { throw new UnauthorizedException('刷新令牌无效或已过期'); } + // Check user status before issuing new tokens + if (stored.user.deletedAt) { + await this.revokeAllUserTokens(stored.userId); + throw new UnauthorizedException('账号已注销'); + } + + if (stored.user.status !== 'active') { + throw new UnauthorizedException('账号已被禁用'); + } + await this.prisma.refreshToken.update({ where: { id: stored.id }, data: { revokedAt: new Date() }, @@ -133,6 +143,13 @@ export class AuthService { }; } + private async revokeAllUserTokens(userId: string) { + await this.prisma.refreshToken.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + async logout(userId: string, refreshToken: string) { const hash = this.tokenService.hashToken(refreshToken); const stored = await this.prisma.refreshToken.findFirst({ diff --git a/src/modules/auth/token.service.ts b/src/modules/auth/token.service.ts index 8b5c4f1..4d65091 100644 --- a/src/modules/auth/token.service.ts +++ b/src/modules/auth/token.service.ts @@ -11,6 +11,7 @@ export class TokenService { sub: user.id, email: user.email, role: user.role, + type: 'user', }); } diff --git a/src/modules/document-import/document-import.controller.ts b/src/modules/document-import/document-import.controller.ts index 15959a1..14dd54d 100644 --- a/src/modules/document-import/document-import.controller.ts +++ b/src/modules/document-import/document-import.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Post, Param, HttpCode, HttpStatus, Body } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { DocumentImportService } from './document-import.service'; +import { CreateImportDto } from './dto/create-import.dto'; @ApiTags('document-import') @Controller('imports') @@ -10,8 +11,8 @@ export class DocumentImportController { @Post() @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: '创建导入任务' }) - async createImport(@Body() body: any) { - return this.service.createImport(body); + async createImport(@Body() dto: CreateImportDto) { + return this.service.createImport(dto); } @Get(':id/status') diff --git a/src/modules/document-import/dto/create-import.dto.ts b/src/modules/document-import/dto/create-import.dto.ts new file mode 100644 index 0000000..7b32d95 --- /dev/null +++ b/src/modules/document-import/dto/create-import.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, MaxLength } from 'class-validator'; + +export class CreateImportDto { + @ApiPropertyOptional({ description: '用户 ID', example: 'user-001' }) + @IsOptional() + @IsString() + userId?: string; + + @ApiPropertyOptional({ description: '知识库 ID' }) + @IsOptional() + @IsString() + knowledgeBaseId?: string; + + @ApiPropertyOptional({ description: '文件名', example: '笔记.pdf' }) + @IsOptional() + @IsString() + @MaxLength(500) + fileName?: string; + + @ApiPropertyOptional({ description: '来源类型', example: 'file', default: 'file' }) + @IsOptional() + @IsString() + sourceType?: string; + + @ApiPropertyOptional({ description: '原始文本内容(直接文本导入时使用)' }) + @IsOptional() + @IsString() + rawText?: string; +} diff --git a/src/modules/knowledge-source/dto/add-source.dto.ts b/src/modules/knowledge-source/dto/add-source.dto.ts new file mode 100644 index 0000000..8ee3239 --- /dev/null +++ b/src/modules/knowledge-source/dto/add-source.dto.ts @@ -0,0 +1,44 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsNumber, IsIn, MaxLength } from 'class-validator'; + +const SOURCE_TYPES = ['file', 'link', 'manual', 'paste'] as const; + +export class AddSourceDto { + @ApiPropertyOptional({ description: '关联的上传文件 ID' }) + @IsOptional() + @IsString() + fileId?: string; + + @ApiPropertyOptional({ description: '来源类型', enum: SOURCE_TYPES, default: 'file' }) + @IsOptional() + @IsString() + @IsIn(SOURCE_TYPES) + type?: string; + + @ApiPropertyOptional({ description: '来源标题', example: '机器学习笔记' }) + @IsOptional() + @IsString() + @MaxLength(500) + title?: string; + + @ApiPropertyOptional({ description: '原始文件名', example: 'notes.pdf' }) + @IsOptional() + @IsString() + @MaxLength(500) + originalFilename?: string; + + @ApiPropertyOptional({ description: 'MIME 类型', example: 'application/pdf' }) + @IsOptional() + @IsString() + mimeType?: string; + + @ApiPropertyOptional({ description: '文件大小(字节)', example: 1048576 }) + @IsOptional() + @IsNumber() + sizeBytes?: number; + + @ApiPropertyOptional({ description: 'COS 对象 Key' }) + @IsOptional() + @IsString() + originalObjectKey?: string; +} diff --git a/src/modules/knowledge-source/knowledge-source.controller.ts b/src/modules/knowledge-source/knowledge-source.controller.ts index fd5f2df..833962d 100644 --- a/src/modules/knowledge-source/knowledge-source.controller.ts +++ b/src/modules/knowledge-source/knowledge-source.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Post, Delete, Body, Param, Query } from '@nestjs/commo import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { KnowledgeSourceService } from './knowledge-source.service'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { AddSourceDto } from './dto/add-source.dto'; import type { UserPayload } from '../../common/types'; @ApiTags('knowledge-source') @@ -14,7 +15,7 @@ export class KnowledgeSourceController { async addSource( @CurrentUser() user: UserPayload, @Param('kbId') kbId: string, - @Body() dto: any, + @Body() dto: AddSourceDto, ) { return this.service.addSource(user.id, kbId, dto); } diff --git a/src/modules/learning-session/dto/start-session.dto.ts b/src/modules/learning-session/dto/start-session.dto.ts new file mode 100644 index 0000000..9793004 --- /dev/null +++ b/src/modules/learning-session/dto/start-session.dto.ts @@ -0,0 +1,26 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsIn } from 'class-validator'; + +const SESSION_MODES = ['active_recall', 'feynman', 'review', 'reading'] as const; + +export class StartSessionDto { + @ApiPropertyOptional({ description: '知识点 ID' }) + @IsOptional() + @IsString() + knowledgeItemId?: string; + + @ApiPropertyOptional({ description: '知识库 ID' }) + @IsOptional() + @IsString() + knowledgeBaseId?: string; + + @ApiPropertyOptional({ + description: '学习模式', + enum: SESSION_MODES, + default: 'reading', + }) + @IsOptional() + @IsString() + @IsIn(SESSION_MODES) + mode?: string; +} diff --git a/src/modules/learning-session/learning-session.controller.ts b/src/modules/learning-session/learning-session.controller.ts index c1a315e..74340e7 100644 --- a/src/modules/learning-session/learning-session.controller.ts +++ b/src/modules/learning-session/learning-session.controller.ts @@ -1,8 +1,9 @@ import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { LearningSessionService } from './learning-session.service'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { PaginationDto } from '../../common/dto/pagination.dto'; +import { StartSessionDto } from './dto/start-session.dto'; import type { UserPayload } from '../../common/types'; @ApiTags('learning-session') @@ -12,8 +13,8 @@ export class LearningSessionController { @Post() @ApiOperation({ summary: '开始学习会话' }) - async start(@CurrentUser() user: UserPayload, @Body() body: any) { - return this.service.start(String(user?.id || 'anonymous'), body); + async start(@CurrentUser() user: UserPayload, @Body() dto: StartSessionDto) { + return this.service.start(String(user?.id || 'anonymous'), dto); } @Post(':id/end') diff --git a/src/modules/rag/internal-rag.controller.ts b/src/modules/rag/internal-rag.controller.ts index 9709639..280df71 100644 --- a/src/modules/rag/internal-rag.controller.ts +++ b/src/modules/rag/internal-rag.controller.ts @@ -1,14 +1,14 @@ -import { Controller, Get, Post, Body, Param } from '@nestjs/common'; +import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { DocumentImportRepository } from '../document-import/document-import.repository'; import { KnowledgeSourceRepository } from '../knowledge-source/knowledge-source.repository'; import { ImportCandidateRepository } from '../import-candidate/import-candidate.repository'; import { PrismaService } from '../../infrastructure/database/prisma.service'; -import { Public } from '../../common/decorators/public.decorator'; +import { InternalAuthGuard } from '../../common/guards/internal-auth.guard'; @ApiTags('internal-rag') @Controller('internal/rag') -@Public() +@UseGuards(InternalAuthGuard) export class InternalRagController { constructor( private readonly importRepo: DocumentImportRepository, diff --git a/test/h0.e2e-spec.ts b/test/h0.e2e-spec.ts new file mode 100644 index 0000000..7aa294b --- /dev/null +++ b/test/h0.e2e-spec.ts @@ -0,0 +1,389 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('H0-01 Apple Login Mock Fallback', () => { + const OLD_ENV = { ...process.env }; + + afterAll(() => { + process.env = OLD_ENV; + }); + + describe('Dev mode without APPLE_BUNDLE_ID → mock fallback', () => { + let app: INestApplication; + + beforeAll(async () => { + process.env.NODE_ENV = 'development'; + process.env.JWT_SECRET = 'test-jwt-secret-for-h0-tests'; + delete process.env.APPLE_BUNDLE_ID; + const m: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = m.createNestApplication(); + app.setGlobalPrefix('api', { exclude: ['admin-api/(.*)', 'internal/(.*)'] }); + await app.init(); + }); + + afterAll(async () => { await app.close(); }); + + it('POST /api/auth/apple → 200 with mock fallback (token >= 4 chars)', async () => { + const res = await request(app.getHttpServer()) + .post('/api/auth/apple') + .send({ identityToken: 'test-apple-id-token-valid' }); + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveProperty('accessToken'); + expect(res.body.data).toHaveProperty('refreshToken'); + }); + + it('POST /api/auth/apple short token → 401', async () => { + const res = await request(app.getHttpServer()) + .post('/api/auth/apple') + .send({ identityToken: 'ab' }); + expect(res.body.success).toBe(false); + expect(res.body.statusCode).toBe(401); + }); + }); + + describe('Production mode without APPLE_BUNDLE_ID → reject', () => { + let app: INestApplication; + + beforeAll(async () => { + process.env.NODE_ENV = 'production'; + process.env.JWT_SECRET = 'prod-test-jwt-secret-for-h0'; + process.env.ADMIN_JWT_ACCESS_SECRET = 'prod-test-admin-jwt-secret'; + delete process.env.APPLE_BUNDLE_ID; + const m: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = m.createNestApplication(); + app.setGlobalPrefix('api', { exclude: ['admin-api/(.*)', 'internal/(.*)'] }); + await app.init(); + }); + + afterAll(async () => { await app.close(); }); + + it('POST /api/auth/apple → 401 (production without Apple config)', async () => { + const res = await request(app.getHttpServer()) + .post('/api/auth/apple') + .send({ identityToken: 'valid-looking-apple-identity-token' }); + expect(res.body.success).toBe(false); + expect(res.body.statusCode).toBe(401); + expect(res.body.message).toContain('未配置'); + }); + }); + + describe('Production mode with APPLE_BUNDLE_ID → real verification', () => { + let app: INestApplication; + + beforeAll(async () => { + process.env.NODE_ENV = 'production'; + process.env.JWT_SECRET = 'prod-test-jwt-secret-for-h0'; + process.env.ADMIN_JWT_ACCESS_SECRET = 'prod-test-admin-jwt-secret'; + process.env.APPLE_BUNDLE_ID = 'com.test.bundle'; + const m: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = m.createNestApplication(); + app.setGlobalPrefix('api', { exclude: ['admin-api/(.*)', 'internal/(.*)'] }); + await app.init(); + }); + + afterAll(async () => { await app.close(); }); + + it('POST /api/auth/apple → 200 (jose mocked, valid response)', async () => { + const res = await request(app.getHttpServer()) + .post('/api/auth/apple') + .send({ identityToken: 'any-jwt' }); + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveProperty('accessToken'); + }); + }); +}); + +describe('H0-02 InternalAuthGuard', () => { + const OLD_ENV = { ...process.env }; + const INTERNAL_KEY = 'test-internal-api-key-h0'; + + afterAll(() => { + process.env = OLD_ENV; + }); + + describe('Without internal API key → 401', () => { + let app: INestApplication; + + beforeAll(async () => { + process.env.NODE_ENV = 'development'; + process.env.JWT_SECRET = 'test-jwt-h0-02'; + process.env.INTERNAL_API_KEY = INTERNAL_KEY; + const m: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = m.createNestApplication(); + app.setGlobalPrefix('api', { exclude: ['admin-api/(.*)', 'internal/(.*)'] }); + await app.init(); + }); + + afterAll(async () => { await app.close(); }); + + it('GET /internal/rag/jobs/next without key → 401', async () => { + const res = await request(app.getHttpServer()) + .get('/internal/rag/jobs/next'); + expect(res.body.success).toBe(false); + expect(res.body.statusCode).toBe(401); + }); + + it('GET /internal/rag/jobs/:id without key → 401', async () => { + const res = await request(app.getHttpServer()) + .get('/internal/rag/jobs/test-id'); + expect(res.body.success).toBe(false); + expect(res.body.statusCode).toBe(401); + }); + + it('POST /internal/rag/chunks without key → 401', async () => { + const res = await request(app.getHttpServer()) + .post('/internal/rag/chunks') + .send({ chunks: [] }); + expect(res.body.success).toBe(false); + expect(res.body.statusCode).toBe(401); + }); + }); + + describe('With valid internal API key → accessible', () => { + let app: INestApplication; + + beforeAll(async () => { + process.env.NODE_ENV = 'development'; + process.env.JWT_SECRET = 'test-jwt-h0-02'; + process.env.INTERNAL_API_KEY = INTERNAL_KEY; + const m: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = m.createNestApplication(); + app.setGlobalPrefix('api', { exclude: ['admin-api/(.*)', 'internal/(.*)'] }); + await app.init(); + }); + + afterAll(async () => { await app.close(); }); + + it('GET /internal/rag/jobs/next with valid key → 200', async () => { + const res = await request(app.getHttpServer()) + .get('/internal/rag/jobs/next') + .set('X-Internal-API-Key', INTERNAL_KEY); + expect(res.body.success).toBe(true); + }); + + it('POST /internal/rag/chunks with valid key → 200', async () => { + const res = await request(app.getHttpServer()) + .post('/internal/rag/chunks') + .set('X-Internal-API-Key', INTERNAL_KEY) + .send({ chunks: [] }); + expect(res.body.success).toBe(true); + }); + + it('POST /internal/rag/jobs/:id/claim with valid key → 200', async () => { + const res = await request(app.getHttpServer()) + .post('/internal/rag/jobs/test-id/claim') + .set('X-Internal-API-Key', INTERNAL_KEY) + .send({ workerId: 'test-worker' }); + expect(res.body.success).toBe(true); + }); + }); +}); + +describe('H0-03 JwtAuthGuard user status check', () => { + const OLD_ENV = { ...process.env }; + + afterAll(() => { + process.env = OLD_ENV; + }); + + let app: INestApplication; + let jwtService: any; + + beforeAll(async () => { + process.env.NODE_ENV = 'development'; + process.env.JWT_SECRET = 'test-jwt-h0-03'; + const m: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = m.createNestApplication(); + app.setGlobalPrefix('api', { exclude: ['admin-api/(.*)', 'internal/(.*)'] }); + await app.init(); + jwtService = app.get(JwtService); + }); + + afterAll(async () => { await app.close(); }); + + it('active user → 200 accessing /api/*', async () => { + const token = await jwtService.signAsync({ sub: 'test-user', email: 'test@test.com', role: 'USER' }); + const res = await request(app.getHttpServer()) + .get('/api/users/me') + .set('Authorization', `Bearer ${token}`); + expect(res.body.success).toBe(true); + }); + + it('disabled user → 401', async () => { + const token = await jwtService.signAsync({ sub: 'disabled-user', email: 'disabled@test.com', role: 'USER' }); + const res = await request(app.getHttpServer()) + .get('/api/users/me') + .set('Authorization', `Bearer ${token}`); + expect(res.body.success).toBe(false); + expect(res.body.statusCode).toBe(401); + expect(res.body.message).toContain('禁用'); + }); + + it('deleted user → 401', async () => { + const token = await jwtService.signAsync({ sub: 'deleted-user', email: 'deleted@test.com', role: 'USER' }); + const res = await request(app.getHttpServer()) + .get('/api/users/me') + .set('Authorization', `Bearer ${token}`); + expect(res.body.success).toBe(false); + expect(res.body.statusCode).toBe(401); + expect(res.body.message).toContain('注销'); + }); + + it('non-existent user → 401', async () => { + const token = await jwtService.signAsync({ sub: 'ghost-user', email: 'ghost@test.com', role: 'USER' }); + const res = await request(app.getHttpServer()) + .get('/api/users/me') + .set('Authorization', `Bearer ${token}`); + expect(res.body.success).toBe(false); + expect(res.body.statusCode).toBe(401); + }); + + it('admin token (type=admin) rejected on /api/*', async () => { + const token = await jwtService.signAsync({ sub: 'test-user', type: 'admin', role: 'SUPER_ADMIN' }); + const res = await request(app.getHttpServer()) + .get('/api/users/me') + .set('Authorization', `Bearer ${token}`); + expect(res.body.success).toBe(false); + expect(res.body.statusCode).toBe(401); + expect(res.body.message).toContain('无效'); + }); +}); + +describe('H0-04 Refresh token user status check', () => { + const OLD_ENV = { ...process.env }; + + afterAll(() => { + process.env = OLD_ENV; + }); + + let app: INestApplication; + + beforeAll(async () => { + process.env.NODE_ENV = 'development'; + process.env.JWT_SECRET = 'test-jwt-h0-04'; + delete process.env.APPLE_BUNDLE_ID; + const m: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = m.createNestApplication(); + app.setGlobalPrefix('api', { exclude: ['admin-api/(.*)', 'internal/(.*)'] }); + await app.init(); + }); + + afterAll(async () => { await app.close(); }); + + it('Apple login → get refreshToken → refresh succeeds (active user)', async () => { + // Login to get a refresh token + const loginRes = await request(app.getHttpServer()) + .post('/api/auth/apple') + .send({ identityToken: 'test-token-for-refresh' }); + expect(loginRes.body.success).toBe(true); + + const { refreshToken } = loginRes.body.data; + expect(refreshToken).toBeTruthy(); + + // Refresh with the token + const refreshRes = await request(app.getHttpServer()) + .post('/api/auth/refresh') + .send({ refreshToken }); + expect(refreshRes.body.success).toBe(true); + expect(refreshRes.body.data).toHaveProperty('accessToken'); + expect(refreshRes.body.data).toHaveProperty('refreshToken'); + }); + + it('POST /api/auth/refresh with invalid token → 401', async () => { + const res = await request(app.getHttpServer()) + .post('/api/auth/refresh') + .send({ refreshToken: 'invalid-or-expired-token' }); + expect(res.body.success).toBe(false); + expect(res.body.statusCode).toBe(401); + }); +}); + +describe('H0-06 CAPI DTO validation', () => { + const OLD_ENV = { ...process.env }; + + afterAll(() => { + process.env = OLD_ENV; + }); + + const INTERNAL_KEY = 'test-internal-key-dto'; + + let app: INestApplication; + let jwtService: JwtService; + + beforeAll(async () => { + process.env.NODE_ENV = 'development'; + process.env.JWT_SECRET = 'test-jwt-h0-06'; + process.env.INTERNAL_API_KEY = INTERNAL_KEY; + delete process.env.APPLE_BUNDLE_ID; + const m: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = m.createNestApplication(); + app.setGlobalPrefix('api', { exclude: ['admin-api/(.*)', 'internal/(.*)'] }); + await app.init(); + jwtService = app.get(JwtService); + }); + + afterAll(async () => { await app.close(); }); + + async function getUserToken(): Promise { + return jwtService.signAsync({ sub: 'test-user', email: 'test@test.com', role: 'USER', type: 'user' }); + } + + describe('POST /api/imports', () => { + it('valid DTO → 201', async () => { + const token = await getUserToken(); + const res = await request(app.getHttpServer()) + .post('/api/imports') + .set('Authorization', `Bearer ${token}`) + .send({ fileName: 'test.pdf', sourceType: 'file' }); + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveProperty('jobId'); + expect(res.body.data).toHaveProperty('status', 'queued'); + }); + }); + + describe('POST /api/knowledge-bases/:kbId/sources', () => { + it('valid DTO → 201', async () => { + const token = await getUserToken(); + const res = await request(app.getHttpServer()) + .post('/api/knowledge-bases/test-kb/sources') + .set('Authorization', `Bearer ${token}`) + .send({ title: '我的笔记', type: 'file', originalFilename: 'notes.pdf' }); + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveProperty('id'); + }); + + it('invalid type value → 400', async () => { + const token = await getUserToken(); + const res = await request(app.getHttpServer()) + .post('/api/knowledge-bases/test-kb/sources') + .set('Authorization', `Bearer ${token}`) + .send({ type: 'invalid_type_value' }); + expect(res.body.success).toBe(false); + expect(res.body.statusCode).toBe(400); + }); + }); + + describe('POST /api/learning-sessions', () => { + it('valid DTO → 201', async () => { + const token = await getUserToken(); + const res = await request(app.getHttpServer()) + .post('/api/learning-sessions') + .set('Authorization', `Bearer ${token}`) + .send({ mode: 'active_recall', knowledgeBaseId: 'kb-1' }); + expect(res.body.success).toBe(true); + }); + + it('invalid mode → 400', async () => { + const token = await getUserToken(); + const res = await request(app.getHttpServer()) + .post('/api/learning-sessions') + .set('Authorization', `Bearer ${token}`) + .send({ mode: 'invalid_mode' }); + expect(res.body.success).toBe(false); + expect(res.body.statusCode).toBe(400); + }); + }); +}); diff --git a/test/mocks/ioredis.mock.ts b/test/mocks/ioredis.mock.ts index 27511b1..cccdd1e 100644 --- a/test/mocks/ioredis.mock.ts +++ b/test/mocks/ioredis.mock.ts @@ -60,6 +60,7 @@ class MockRedis extends EventEmitter { xautoclaim() { return Promise.resolve([]) } xinfo() { return Promise.resolve({}) } call() { return Promise.resolve(null) } + eval() { return Promise.resolve(1) } multi() { return new MockMulti() } exec() { return Promise.resolve([]) } watch() { return Promise.resolve('OK') } diff --git a/test/mocks/prisma.mock.ts b/test/mocks/prisma.mock.ts index 2b23f4b..e0abf4f 100644 --- a/test/mocks/prisma.mock.ts +++ b/test/mocks/prisma.mock.ts @@ -135,6 +135,70 @@ const origAdminSession = (PrismaClient.prototype as any).adminSession }, }) +// Patch user so JwtAuthGuard status check passes for test users +const TEST_USER = { + id: 'test-user', + email: 'test@test.com', + role: 'USER', + status: 'active', + deletedAt: null, +} +const origUser = (PrismaClient.prototype as any).user +;(PrismaClient.prototype as any).user = new Proxy(origUser, { + get(target: any, prop: string) { + if (prop === 'findUnique') { + return (args: any) => { + if (args?.where?.id === 'test-user') return Promise.resolve(TEST_USER) + if (args?.where?.id === 'disabled-user') return Promise.resolve({ ...TEST_USER, id: 'disabled-user', status: 'disabled' }) + if (args?.where?.id === 'deleted-user') return Promise.resolve({ ...TEST_USER, id: 'deleted-user', deletedAt: new Date() }) + return target.findUnique(args) + } + } + return target[prop] + }, +}) + +// Patch refreshToken so the refresh flow works in tests +const knownRefreshHashes = new Set() +const origRefreshToken = (PrismaClient.prototype as any).refreshToken +;(PrismaClient.prototype as any).refreshToken = new Proxy(origRefreshToken, { + get(target: any, prop: string) { + if (prop === 'findFirst') { + return (args: any) => { + const hash = args?.where?.tokenHash + if (hash && knownRefreshHashes.has(hash)) { + return Promise.resolve({ + id: 'rt-test-001', + userId: 'test-user', + tokenHash: hash, + deviceId: null, + deviceName: null, + expiresAt: new Date(Date.now() + 7 * 86400000), + revokedAt: null, + user: { ...TEST_USER }, + }) + } + return Promise.resolve(null) + } + } + if (prop === 'update') { + return () => Promise.resolve({ id: 'rt-test-001' }) + } + if (prop === 'updateMany') { + return () => Promise.resolve({ count: 1 }) + } + if (prop === 'create') { + return (args: any) => { + if (args?.data?.tokenHash) { + knownRefreshHashes.add(args.data.tokenHash) + } + return Promise.resolve({ id: 'rt-test-new', ...args?.data }) + } + } + return target[prop] + }, +}) + export const Prisma = { ModelName: {}, PrismaClientKnownRequestError: class extends Error {