api-server/test/h0.e2e-spec.ts
WangDL 6a13edc7fb
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 44s
feat: H0 milestone — iOS integration blocking fixes
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>
2026-05-25 16:55:04 +08:00

390 lines
14 KiB
TypeScript

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