feat: H0 milestone — iOS integration blocking fixes
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 44s
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:
parent
23988a1add
commit
6a13edc7fb
30
src/common/guards/internal-auth.guard.ts
Normal file
30
src/common/guards/internal-auth.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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('登录已过期,请重新登录');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
30
src/modules/document-import/dto/create-import.dto.ts
Normal file
30
src/modules/document-import/dto/create-import.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
44
src/modules/knowledge-source/dto/add-source.dto.ts
Normal file
44
src/modules/knowledge-source/dto/add-source.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/modules/learning-session/dto/start-session.dto.ts
Normal file
26
src/modules/learning-session/dto/start-session.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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')
|
||||||
|
|||||||
@ -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
389
test/h0.e2e-spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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') }
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user