e6b79ddb21
## 安全修复 (5项) - CRITICAL JWT 硬编码 fallback(jwt.strategy / app.module / user.module) - HIGH seed_admin.js MongoDB 凭据泄漏 - MEDIUM 邮箱验证码泄漏 - MEDIUM 支付订单查询 IDOR - MEDIUM 管理后台 NoSQL 注入 ## 代码质量 (14处) - console.log→Logger(user.service.ts) - as any 类型化(11处跨7个文件) - Schema 联合类型修复(progress.schema) - Module 依赖缺失修复(progress.module) ## 测试体系 (61项) - 后端单元测试 Jest(43项):BenchmarkService/UserService/PaymentController - 后端集成测试 Supertest(11项):API 认证/支付/进度/管理 - 前端单元测试 Vitest(7项):配置文件/API端点 - 浏览器自动化 Playwright(7项):API smoke test - 覆盖率报告 + e2e 配置 ## 护城河 P0-P5 启动验证通过 + 编译通过
188 lines
6.7 KiB
TypeScript
188 lines
6.7 KiB
TypeScript
import request from 'supertest'
|
|
import { Test, TestingModule } from '@nestjs/testing'
|
|
import { INestApplication, ValidationPipe } from '@nestjs/common'
|
|
import { getModelToken } from '@nestjs/mongoose'
|
|
import { JwtService } from '@nestjs/jwt'
|
|
import { WechatPayService } from '../src/modules/payment/wechat-pay.service'
|
|
import { AppModule } from '../src/app.module'
|
|
|
|
function makeChain(execValue: any = null) {
|
|
const chain: Record<string, jest.Mock> = {}
|
|
const methods = ['find', 'findOne', 'findById', 'findByIdAndUpdate', 'sort', 'skip', 'limit', 'select', 'lean', 'countDocuments']
|
|
for (const m of methods) {
|
|
chain[m] = jest.fn(() => chain)
|
|
}
|
|
chain.exec = jest.fn().mockResolvedValue(execValue)
|
|
chain.save = jest.fn().mockResolvedValue(true)
|
|
return chain
|
|
}
|
|
|
|
function mockModel(extra: Record<string, any> = {}, defaultExecValue: any = null) {
|
|
const chain = makeChain(defaultExecValue)
|
|
return {
|
|
find: jest.fn(() => chain),
|
|
findOne: jest.fn(() => chain),
|
|
findById: jest.fn(() => chain),
|
|
findByIdAndUpdate: jest.fn(() => chain),
|
|
create: jest.fn(),
|
|
countDocuments: jest.fn(() => chain),
|
|
...extra,
|
|
}
|
|
}
|
|
|
|
describe('API Integration (e2e)', () => {
|
|
let app: INestApplication
|
|
let authToken: string
|
|
|
|
beforeAll(async () => {
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
})
|
|
.overrideProvider(WechatPayService).useValue({
|
|
nativePay: jest.fn().mockResolvedValue({ codeUrl: 'weixin://pay/test' }),
|
|
jsapiPay: jest.fn().mockResolvedValue({ paySign: 'test', nonceStr: 'test', package: 'test', timeStamp: '0', signType: 'RSA' }),
|
|
queryOrder: jest.fn().mockResolvedValue({ trade_state: 'SUCCESS' }),
|
|
verifyAndDecrypt: jest.fn().mockReturnValue({ out_trade_no: 'TEST', transaction_id: 'wx_test' }),
|
|
})
|
|
.overrideProvider(getModelToken('User')).useValue(mockModel({ countDocuments: jest.fn().mockResolvedValue(0) }))
|
|
.overrideProvider(getModelToken('Interview')).useValue(mockModel({}, []))
|
|
.overrideProvider(getModelToken('PaymentOrder')).useValue(mockModel({}, null))
|
|
.overrideProvider(getModelToken('Progress')).useValue(mockModel({}, null))
|
|
.overrideProvider(getModelToken('CompanyBank')).useValue(mockModel())
|
|
.overrideProvider(getModelToken('Contribution')).useValue(mockModel())
|
|
.overrideProvider(getModelToken('SiteConfig')).useValue(mockModel())
|
|
.overrideProvider(getModelToken('Resume')).useValue(mockModel())
|
|
.compile()
|
|
|
|
app = moduleFixture.createNestApplication()
|
|
app.setGlobalPrefix('api')
|
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
|
|
await app.init()
|
|
|
|
const jwtService = app.get(JwtService)
|
|
authToken = jwtService.sign({ userId: '507f1f77bcf86cd799439011', phone: '13800138000' })
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await app.close()
|
|
})
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks()
|
|
})
|
|
|
|
describe('Unauthenticated access', () => {
|
|
it('GET /api/user/info should return 401', async () => {
|
|
const res = await request(app.getHttpServer()).get('/api/user/info')
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('POST /api/user/send-code should be public', async () => {
|
|
const res = await request(app.getHttpServer())
|
|
.post('/api/user/send-code')
|
|
.send({ phone: '13800138000' })
|
|
expect(res.status).toBe(201)
|
|
})
|
|
|
|
it('POST /api/payment/create should return 401', async () => {
|
|
const res = await request(app.getHttpServer())
|
|
.post('/api/payment/create')
|
|
.send({ plan: 'growth' })
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('GET /api/progress should return 401', async () => {
|
|
const res = await request(app.getHttpServer()).get('/api/progress')
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('POST /api/progress/checkin should return 401', async () => {
|
|
const res = await request(app.getHttpServer()).post('/api/progress/checkin')
|
|
expect(res.status).toBe(401)
|
|
})
|
|
})
|
|
|
|
describe('Authenticated access', () => {
|
|
const headers = () => ({ Authorization: `Bearer ${authToken}` })
|
|
|
|
it('POST /api/payment/create should validate plan', async () => {
|
|
const res = await request(app.getHttpServer())
|
|
.post('/api/payment/create')
|
|
.set(headers())
|
|
.send({ plan: 'invalid' })
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('POST /api/payment/jsapi should handle missing wxOpenid', async () => {
|
|
const userModel = app.get(getModelToken('User'))
|
|
const chain = makeChain({ plan: 'free', wxOpenid: null })
|
|
userModel.findById.mockReturnValueOnce(chain)
|
|
|
|
const res = await request(app.getHttpServer())
|
|
.post('/api/payment/jsapi')
|
|
.set(headers())
|
|
.send({ plan: 'growth' })
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('GET /api/admin/check should check admin status', async () => {
|
|
const userModel = app.get(getModelToken('User'))
|
|
const chain = makeChain({ role: 'user' })
|
|
userModel.findById.mockReturnValueOnce(chain)
|
|
|
|
const res = await request(app.getHttpServer())
|
|
.get('/api/admin/check')
|
|
.set(headers())
|
|
expect(res.status).toBe(200)
|
|
expect(res.body.isAdmin).toBe(false)
|
|
})
|
|
|
|
it('POST /api/progress/redeem should validate item', async () => {
|
|
const res = await request(app.getHttpServer())
|
|
.post('/api/progress/redeem')
|
|
.set(headers())
|
|
.send({ item: 'nonexistent' })
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('GET /api/progress should return default progress for new user', async () => {
|
|
const progressModel = app.get(getModelToken('Progress'))
|
|
const nullChain = makeChain(null)
|
|
progressModel.findOne.mockReturnValueOnce(nullChain)
|
|
progressModel.create.mockResolvedValue({
|
|
userId: '507f1f77bcf86cd799439011',
|
|
totalInterviews: 0,
|
|
completedInterviews: 0,
|
|
streak: 0,
|
|
points: 0,
|
|
totalCheckins: 0,
|
|
recentScores: [],
|
|
streakHistory: [],
|
|
lastCheckinDate: null,
|
|
})
|
|
|
|
const res = await request(app.getHttpServer())
|
|
.get('/api/progress')
|
|
.set(headers())
|
|
expect(res.status).toBe(200)
|
|
expect(res.body).toHaveProperty('totalInterviews', 0)
|
|
expect(res.body).toHaveProperty('points')
|
|
})
|
|
})
|
|
|
|
describe('Payment flow', () => {
|
|
const headers = () => ({ Authorization: `Bearer ${authToken}` })
|
|
|
|
it('POST /api/payment/check should verify order ownership', async () => {
|
|
const orderModel = app.get(getModelToken('PaymentOrder'))
|
|
const chain = makeChain(null)
|
|
orderModel.findOne.mockReturnValueOnce(chain)
|
|
|
|
const res = await request(app.getHttpServer())
|
|
.get('/api/payment/check/ORD123')
|
|
.set(headers())
|
|
expect(res.status).toBe(404)
|
|
})
|
|
})
|
|
})
|