162 lines
5.2 KiB
TypeScript
162 lines
5.2 KiB
TypeScript
/** @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...')
|
||
})
|
||
})
|