gongxiangp2/lib/__tests__/ai-model-security.test.ts

156 lines
10 KiB
TypeScript

/** @jest-environment node */
import { EventEmitter } from 'node:events'
import { lookup } from 'node:dns/promises'
import { request as httpsRequest } from 'node:https'
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'
import { isSuperAdmin } from '@/lib/department-permission'
import { AiModelError, assertAiModelAdmin } from '@/lib/ai-model-access'
import { decryptAiModelKey, encryptAiModelKey, isAiModelEncryptionConfigured } from '@/lib/ai-model-crypto'
import { normalizeAiModelBaseUrl, requestAiModel, requestAiModelCapabilityCatalog, resolveAiModelPublicAddresses } from '@/lib/ai-model-http'
import { GET } from '@/app/api/admin/ai-models/route'
import { POST } from '@/app/api/admin/ai-models/providers/route'
test('inactive account with an active status string cannot manage or test models', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: true, status: 'active', isActive: false, archivedAt: null })
await expect(assertAiModelAdmin('admin')).rejects.toMatchObject({ status: 403, code: 'account_inactive' })
})
jest.mock('node:dns/promises', () => ({ lookup: jest.fn() }))
jest.mock('node:https', () => ({ request: jest.fn() }))
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() }))
jest.mock('@/lib/prisma', () => ({ prisma: { user: { findUnique: jest.fn() } } }))
jest.mock('@/lib/ai-model-service', () => ({ getAiModelAdminState: jest.fn().mockResolvedValue({ providers: [] }), createAiModelProvider: jest.fn() }))
const originalKey = process.env.API_KEY_ENCRYPTION_KEY
beforeEach(() => {
jest.clearAllMocks()
process.env.API_KEY_ENCRYPTION_KEY = 'synthetic-ai-encryption-key-32-bytes-minimum'
;(lookup as jest.Mock).mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'admin', role: 'USER', isSuperAdmin: true } })
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: true, status: 'active', isActive: true, archivedAt: null })
})
afterAll(() => { if (originalKey === undefined) delete process.env.API_KEY_ENCRYPTION_KEY; else process.env.API_KEY_ENCRYPTION_KEY = originalKey })
test.each([
'http://example.com/v1', 'https://user:password@example.com/v1', 'https://example.com:8443/v1',
'https://example.com/v1?token=hidden', 'https://example.com/v1#hidden', 'https://localhost/v1',
'https://127.0.0.1/v1', 'https://2130706433/v1', 'https://[::1]/v1', 'https://[::ffff:127.0.0.1]/v1',
'https://example.local/v1', 'https://example.internal/v1',
])('rejects unsafe provider URL %s', value => { expect(() => normalizeAiModelBaseUrl(value)).toThrow(AiModelError) })
test.each(['10.0.0.1', '169.254.169.254', '192.168.58.20', '100.64.0.1', '::ffff:7f00:1', 'fe80::1', '2001:db8::1'])('rejects mixed DNS including %s before any socket', async address => {
const family = address.includes(':') ? 6 : 4
;(lookup as jest.Mock).mockResolvedValue([{ address: '93.184.216.34', family: 4 }, { address, family }])
await expect(requestAiModel('https://models.example.com/v1', 'synthetic-provider-key', '/models')).rejects.toMatchObject({ code: 'unsafe_provider_url' })
expect(httpsRequest).not.toHaveBeenCalled()
})
test('DNS is resolved once and the HTTPS lookup uses only validated addresses', async () => {
let requestOptions: any
;(httpsRequest as jest.Mock).mockImplementation((_target, options, callback) => {
requestOptions = options
const request = new EventEmitter() as any
request.end = () => queueMicrotask(() => {
const response = new EventEmitter() as any; response.statusCode = 200; response.headers = {}; response.destroy = jest.fn()
callback(response); response.emit('data', Buffer.from('{"data":[]}')); response.emit('end')
})
request.destroy = jest.fn()
return request
})
const response = await requestAiModel('https://models.example.com', 'synthetic-provider-key', '/models')
expect(await response.json()).toEqual({ data: [] })
expect(lookup).toHaveBeenCalledTimes(1)
const resolved = jest.fn(); requestOptions.lookup('models.example.com', { all: false }, resolved)
expect(resolved).toHaveBeenCalledWith(null, '93.184.216.34', 4)
expect(requestOptions.agent).toBe(false)
})
test('revocation during DNS wait runs the fresh guard and creates zero sockets', async () => {
;(lookup as jest.Mock).mockImplementation(async () => { ;(isSuperAdmin as jest.Mock).mockResolvedValue(false); return [{ address: '93.184.216.34', family: 4 }] })
await expect(requestAiModel('https://models.example.com', 'synthetic-provider-key', '/models', undefined, undefined, () => assertAiModelAdmin('admin').then(() => undefined))).rejects.toMatchObject({ status: 403 })
expect(httpsRequest).not.toHaveBeenCalled()
})
test.each([204, 302, 200])('handles empty response / redirect / excessive body safely: HTTP %s', async status => {
;(httpsRequest as jest.Mock).mockImplementation((_target, _options, callback) => {
const request = new EventEmitter() as any; request.destroy = jest.fn()
request.end = () => queueMicrotask(() => {
const response = new EventEmitter() as any; response.statusCode = status; response.headers = {}; response.destroy = jest.fn()
callback(response)
if (status === 200) response.emit('data', Buffer.alloc(2 * 1024 * 1024 + 1))
response.emit('end')
})
return request
})
const promise = requestAiModel('https://models.example.com/v1', 'synthetic-provider-key', '/models')
if (status === 204) expect((await promise).status).toBe(204)
else await expect(promise).rejects.toThrow(status === 302 ? '重定向' : 'exceeds limit')
})
test('credential encryption is randomized, authenticated and requires a dedicated master key', () => {
const first = encryptAiModelKey('synthetic-upstream-secret'); const second = encryptAiModelKey('synthetic-upstream-secret')
expect(first.secretCiphertext).not.toBe(second.secretCiphertext)
expect(JSON.stringify(first)).not.toContain('synthetic-upstream-secret')
expect(decryptAiModelKey(first)).toBe('synthetic-upstream-secret')
const tampered = { ...first, secretAuthTag: Buffer.alloc(16).toString('base64') }
expect(() => decryptAiModelKey(tampered)).toThrow('无法解密')
delete process.env.API_KEY_ENCRYPTION_KEY
expect(isAiModelEncryptionConfigured()).toBe(false)
expect(() => encryptAiModelKey('test')).toThrow('API_KEY_ENCRYPTION_KEY')
})
test('the final user-state lookup rejects revocation even if the earlier role read allowed it', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: false, status: 'active', isActive: true, archivedAt: null })
await expect(assertAiModelAdmin('admin')).rejects.toMatchObject({ status: 403, code: 'forbidden' })
})
test.each([
{ isSuperAdmin: true, status: 'suspended', archivedAt: null },
{ isSuperAdmin: true, status: 'active', archivedAt: new Date() },
{ isSuperAdmin: true, status: 'pending', archivedAt: null },
])('rejects inactive super administrators before reading configuration: %o', async state => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(state)
expect((await GET()).status).toBe(403)
expect(httpsRequest).not.toHaveBeenCalled()
})
test('unauthenticated and ordinary-admin requests fail before parsing bodies or network access', async () => {
;(getServerSession as jest.Mock).mockResolvedValue(null)
expect((await GET()).status).toBe(401)
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'admin', role: 'ADMIN', isSuperAdmin: true } })
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
const request = new Request('https://platform.test/api/admin/ai-models/providers', { method: 'POST', body: 'not-json' })
expect((await POST(request)).status).toBe(403)
expect(httpsRequest).not.toHaveBeenCalled()
})
test('public DNS resolver refuses an empty result', async () => {
await expect(resolveAiModelPublicAddresses('https://models.example.com', async () => [])).rejects.toMatchObject({ code: 'unsafe_provider_url' })
})
test('public capability discovery has a fixed endpoint and carries no private Authorization header', async () => {
let targetUrl = '', sentHeaders: any
;(httpsRequest as jest.Mock).mockImplementation((target, options, callback) => {
targetUrl = String(target); sentHeaders = options.headers
const request = new EventEmitter() as any; request.destroy = jest.fn()
request.end = () => queueMicrotask(() => { const response = new EventEmitter() as any; response.statusCode = 200; response.headers = {}; response.destroy = jest.fn(); callback(response); response.emit('data', Buffer.from('{}')); response.emit('end') })
return request
})
await requestAiModelCapabilityCatalog('https://api.vip98k.com/v1')
expect(targetUrl).toBe('https://api.vip98k.com/api/v2/catalog/models')
expect(sentHeaders).toEqual({})
await expect(requestAiModelCapabilityCatalog('https://other.example.com/v1')).rejects.toMatchObject({ code: 'capability_catalog_unavailable' })
expect(httpsRequest).toHaveBeenCalledTimes(1)
})
test('public capability discovery rejects protected DNS and excessive response bodies', async () => {
;(lookup as jest.Mock).mockResolvedValueOnce([{ address: '192.168.1.1', family: 4 }])
await expect(requestAiModelCapabilityCatalog('https://api.vip98k.com/v1')).rejects.toMatchObject({ code: 'unsafe_provider_url' })
expect(httpsRequest).not.toHaveBeenCalled()
;(httpsRequest as jest.Mock).mockImplementation((_target, _options, callback) => {
const request = new EventEmitter() as any; request.destroy = jest.fn()
request.end = () => queueMicrotask(() => { const response = new EventEmitter() as any; response.statusCode = 200; response.headers = { 'content-length': String(8 * 1024 * 1024 + 1) }; response.destroy = jest.fn(); callback(response) })
return request
})
await expect(requestAiModelCapabilityCatalog('https://api.vip98k.com/v1')).rejects.toThrow('exceeds limit')
})