gongxiangp2/lib/__tests__/external-api-key.test.ts

162 lines
5.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @jest-environment node */
jest.mock('@/lib/prisma', () => ({
prisma: {
$transaction: jest.fn(async (operations: Array<Promise<unknown>>) => Promise.all(operations)),
apiKey: {
findUnique: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
create: jest.fn(),
},
apiKeyRevealAudit: {
create: jest.fn(),
},
},
}))
import { prisma } from '@/lib/prisma'
import {
createApiKey,
getApiKeyFromRequest,
getApiKeyPreview,
ApiKeyNotRecoverableError,
revealApiKey,
validateApiKey,
} from '@/lib/external-api'
const apiKeyDb = prisma.apiKey as unknown as {
findUnique: jest.Mock
update: jest.Mock
create: jest.Mock
}
const revealAuditDb = prisma.apiKeyRevealAudit as unknown as {
create: jest.Mock
}
const originalEncryptionKey = process.env.API_KEY_ENCRYPTION_KEY
function createRequest(url: string, headers?: Record<string, string>) {
return {
nextUrl: new URL(url),
headers: new Headers(headers),
} as any
}
describe('getApiKeyFromRequest', () => {
test('默认只读取 X-API-Key 请求头', () => {
const request = createRequest('https://example.com/api/external/demo?apiKey=query-key', {
'X-API-Key': 'header-key',
})
expect(getApiKeyFromRequest(request)).toBe('header-key')
})
test('默认不读取 URL 参数,避免所有外部接口无意开放 query key', () => {
const request = createRequest('https://example.com/api/external/demo?apiKey=query-key')
expect(getApiKeyFromRequest(request)).toBeNull()
})
test('显式允许时可读取钉钉固定 URL 参数 apiKey', () => {
const request = createRequest('https://example.com/api/external/demo?apiKey=query-key')
expect(getApiKeyFromRequest(request, { allowQuery: true })).toBe('query-key')
})
test('请求头优先于 URL 参数', () => {
const request = createRequest('https://example.com/api/external/demo?apiKey=query-key', {
'X-API-Key': 'header-key',
})
expect(getApiKeyFromRequest(request, { allowQuery: true })).toBe('header-key')
})
})
describe('API Key 哈希存储', () => {
beforeEach(() => {
jest.clearAllMocks()
process.env.API_KEY_ENCRYPTION_KEY = 'test-api-key-encryption-secret-32-bytes-minimum'
})
afterAll(() => {
if (originalEncryptionKey === undefined) {
delete process.env.API_KEY_ENCRYPTION_KEY
} else {
process.env.API_KEY_ENCRYPTION_KEY = originalEncryptionKey
}
})
test('新建 Key 同时保存鉴权哈希和 AES-GCM 密文,不把原文直接落库', async () => {
apiKeyDb.create.mockImplementation(async ({ data }: any) => ({
id: 'key-1',
name: data.name,
key: data.key,
createdAt: new Date('2026-07-10T00:00:00Z'),
}))
const created = await createApiKey('产品平台', 'admin-1')
const storedKey = apiKeyDb.create.mock.calls[0][0].data.key
expect(created.key).toMatch(/^[a-f0-9]{32}$/)
expect(storedKey).not.toBe(created.key)
expect(storedKey).toMatch(/^v1:[a-f0-9]{8}:[a-f0-9]{64}$/)
expect(apiKeyDb.create.mock.calls[0][0].data.secretCiphertext).toEqual(expect.any(String))
expect(apiKeyDb.create.mock.calls[0][0].data.secretNonce).toEqual(expect.any(String))
expect(apiKeyDb.create.mock.calls[0][0].data.secretAuthTag).toEqual(expect.any(String))
expect(JSON.stringify(apiKeyDb.create.mock.calls[0][0].data)).not.toContain(created.key)
})
test('已有明文 Key 首次验证成功后迁移成哈希', async () => {
apiKeyDb.findUnique
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'legacy-1', name: '旧 Key', isActive: true, createdBy: 'admin-1' })
apiKeyDb.update.mockResolvedValue({})
const result = await validateApiKey('legacy-api-key')
expect(result?.id).toBe('legacy-1')
expect(apiKeyDb.update.mock.calls[0][0].data.key).toMatch(/^v1:legacy-a:[a-f0-9]{64}$/)
expect(apiKeyDb.update.mock.calls[0][0].data.secretCiphertext).toEqual(expect.any(String))
})
test('超级管理员可解密新 Key,且每次查看都会写入审计', async () => {
let stored: any
apiKeyDb.create.mockImplementation(async ({ data }: any) => {
stored = data
return {
id: 'key-1',
name: data.name,
createdAt: new Date('2026-07-10T00:00:00Z'),
}
})
revealAuditDb.create.mockResolvedValue({ id: 'audit-1' })
const created = await createApiKey('产品平台', 'admin-1')
apiKeyDb.findUnique.mockResolvedValue({ id: 'key-1', ...stored })
await expect(revealApiKey('key-1', 'viewer-1')).resolves.toBe(created.key)
expect(revealAuditDb.create).toHaveBeenCalledWith({
data: { apiKeyId: 'key-1', viewerUserId: 'viewer-1' },
})
})
test('历史纯哈希 Key 无法伪造明文,明确要求重新创建', async () => {
apiKeyDb.findUnique.mockResolvedValue({
id: 'legacy-hash',
key: `v1:12345678:${'a'.repeat(64)}`,
secretCiphertext: null,
secretNonce: null,
secretAuthTag: null,
})
await expect(revealApiKey('legacy-hash', 'viewer-1')).rejects.toBeInstanceOf(ApiKeyNotRecoverableError)
expect(revealAuditDb.create).not.toHaveBeenCalled()
})
test('后台只展示原始 Key 前缀', () => {
expect(getApiKeyPreview(`v1:12345678:${'a'.repeat(64)}`)).toBe('12345678...')
})
})