390 lines
14 KiB
TypeScript
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);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|