v4.3 安全修复+代码质量+测试体系+护城河验证
## 安全修复 (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 启动验证通过 + 编译通过
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user