feat: H0 milestone — iOS integration blocking fixes
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 44s

H0-01: Reject Apple login mock fallback in production
H0-02: Protect /internal/* with InternalAuthGuard (X-Internal-API-Key)
H0-03: JwtAuthGuard check user status (deletedAt, status)
H0-04: Refresh token check user status + revoke all on deleted
H0-05: User/admin JWT isolation (type=user/admin, enforce ADMIN_JWT_ACCESS_SECRET)
H0-06: Add DTOs for import/source/learning-session controllers
H0-07: 22 E2E tests (h0.e2e-spec.ts), 5 iOS integration docs

Tests: 47/47 (H0 22 + M0 25), no regression.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
WangDL 2026-05-25 16:55:04 +08:00
parent 23988a1add
commit 6a13edc7fb
16 changed files with 648 additions and 11 deletions

View File

@ -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<Request>();
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;
}
}

View File

@ -7,6 +7,7 @@ import {
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { Request } from 'express'; import { Request } from 'express';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@ -16,6 +17,7 @@ export class JwtAuthGuard implements CanActivate {
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly reflector: Reflector, private readonly reflector: Reflector,
private readonly prisma: PrismaService,
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
@ -42,9 +44,29 @@ export class JwtAuthGuard implements CanActivate {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('jwt.secret'), secret: this.configService.get<string>('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; return true;
} catch { } catch (err) {
if (err instanceof UnauthorizedException) throw err;
throw new UnauthorizedException('登录已过期,请重新登录'); throw new UnauthorizedException('登录已过期,请重新登录');
} }
} }

View File

@ -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 { return {
secret: accessSecret || 'change_me_in_production', secret: accessSecret || 'change_me_in_production',
accessSecret: accessSecret || 'change_me_in_production', accessSecret: accessSecret || 'change_me_in_production',

View File

@ -26,6 +26,9 @@ export class AppleAuthService {
emailVerified?: boolean; emailVerified?: boolean;
}> { }> {
if (!this.appleBundleId) { if (!this.appleBundleId) {
if (process.env.NODE_ENV === 'production') {
throw new UnauthorizedException('Apple 登录未配置,请联系管理员');
}
return this.verifyMock(identityToken); return this.verifyMock(identityToken);
} }
return this.verifyReal(identityToken); return this.verifyReal(identityToken);

View File

@ -106,6 +106,16 @@ export class AuthService {
throw new UnauthorizedException('刷新令牌无效或已过期'); 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({ await this.prisma.refreshToken.update({
where: { id: stored.id }, where: { id: stored.id },
data: { revokedAt: new Date() }, 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) { async logout(userId: string, refreshToken: string) {
const hash = this.tokenService.hashToken(refreshToken); const hash = this.tokenService.hashToken(refreshToken);
const stored = await this.prisma.refreshToken.findFirst({ const stored = await this.prisma.refreshToken.findFirst({

View File

@ -11,6 +11,7 @@ export class TokenService {
sub: user.id, sub: user.id,
email: user.email, email: user.email,
role: user.role, role: user.role,
type: 'user',
}); });
} }

View File

@ -1,6 +1,7 @@
import { Controller, Get, Post, Param, HttpCode, HttpStatus, Body } from '@nestjs/common'; import { Controller, Get, Post, Param, HttpCode, HttpStatus, Body } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { DocumentImportService } from './document-import.service'; import { DocumentImportService } from './document-import.service';
import { CreateImportDto } from './dto/create-import.dto';
@ApiTags('document-import') @ApiTags('document-import')
@Controller('imports') @Controller('imports')
@ -10,8 +11,8 @@ export class DocumentImportController {
@Post() @Post()
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '创建导入任务' }) @ApiOperation({ summary: '创建导入任务' })
async createImport(@Body() body: any) { async createImport(@Body() dto: CreateImportDto) {
return this.service.createImport(body); return this.service.createImport(dto);
} }
@Get(':id/status') @Get(':id/status')

View File

@ -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;
}

View File

@ -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;
}

View File

@ -2,6 +2,7 @@ import { Controller, Get, Post, Delete, Body, Param, Query } from '@nestjs/commo
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { KnowledgeSourceService } from './knowledge-source.service'; import { KnowledgeSourceService } from './knowledge-source.service';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { AddSourceDto } from './dto/add-source.dto';
import type { UserPayload } from '../../common/types'; import type { UserPayload } from '../../common/types';
@ApiTags('knowledge-source') @ApiTags('knowledge-source')
@ -14,7 +15,7 @@ export class KnowledgeSourceController {
async addSource( async addSource(
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload,
@Param('kbId') kbId: string, @Param('kbId') kbId: string,
@Body() dto: any, @Body() dto: AddSourceDto,
) { ) {
return this.service.addSource(user.id, kbId, dto); return this.service.addSource(user.id, kbId, dto);
} }

View File

@ -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;
}

View File

@ -1,8 +1,9 @@
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common'; 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 { LearningSessionService } from './learning-session.service';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { PaginationDto } from '../../common/dto/pagination.dto'; import { PaginationDto } from '../../common/dto/pagination.dto';
import { StartSessionDto } from './dto/start-session.dto';
import type { UserPayload } from '../../common/types'; import type { UserPayload } from '../../common/types';
@ApiTags('learning-session') @ApiTags('learning-session')
@ -12,8 +13,8 @@ export class LearningSessionController {
@Post() @Post()
@ApiOperation({ summary: '开始学习会话' }) @ApiOperation({ summary: '开始学习会话' })
async start(@CurrentUser() user: UserPayload, @Body() body: any) { async start(@CurrentUser() user: UserPayload, @Body() dto: StartSessionDto) {
return this.service.start(String(user?.id || 'anonymous'), body); return this.service.start(String(user?.id || 'anonymous'), dto);
} }
@Post(':id/end') @Post(':id/end')

View File

@ -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 { ApiTags } from '@nestjs/swagger';
import { DocumentImportRepository } from '../document-import/document-import.repository'; import { DocumentImportRepository } from '../document-import/document-import.repository';
import { KnowledgeSourceRepository } from '../knowledge-source/knowledge-source.repository'; import { KnowledgeSourceRepository } from '../knowledge-source/knowledge-source.repository';
import { ImportCandidateRepository } from '../import-candidate/import-candidate.repository'; import { ImportCandidateRepository } from '../import-candidate/import-candidate.repository';
import { PrismaService } from '../../infrastructure/database/prisma.service'; 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') @ApiTags('internal-rag')
@Controller('internal/rag') @Controller('internal/rag')
@Public() @UseGuards(InternalAuthGuard)
export class InternalRagController { export class InternalRagController {
constructor( constructor(
private readonly importRepo: DocumentImportRepository, private readonly importRepo: DocumentImportRepository,

389
test/h0.e2e-spec.ts Normal file
View File

@ -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<string> {
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);
});
});
});

View File

@ -60,6 +60,7 @@ class MockRedis extends EventEmitter {
xautoclaim() { return Promise.resolve([]) } xautoclaim() { return Promise.resolve([]) }
xinfo() { return Promise.resolve({}) } xinfo() { return Promise.resolve({}) }
call() { return Promise.resolve(null) } call() { return Promise.resolve(null) }
eval() { return Promise.resolve(1) }
multi() { return new MockMulti() } multi() { return new MockMulti() }
exec() { return Promise.resolve([]) } exec() { return Promise.resolve([]) }
watch() { return Promise.resolve('OK') } watch() { return Promise.resolve('OK') }

View File

@ -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<string>()
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 = { export const Prisma = {
ModelName: {}, ModelName: {},
PrismaClientKnownRequestError: class extends Error { PrismaClientKnownRequestError: class extends Error {