feat(密钥): 用户 API 密钥服务端化——V115 按账号绑定存储 + 后台密钥管理页 + 桌面端全站拦截与配置引导
- 新增 usersecret 模块:外观专利/货源查询密钥从本地 localStorage 迁移至 biz_user_api_secret(AES 加密、按 uid 绑定) - 后台「密钥管理」页:脱敏展示、立即检测、清空;每日 04:30 分布式锁定时巡检 - 桌面端:密钥设置面板走服务端、未配齐引导 /setup-secrets、代理余量展示 - 删除专利汇令牌全链路与密钥保留时长选择器
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { http } from '../src/shared/api/http.ts'
|
||||
import {
|
||||
clearApiSecretCache,
|
||||
getStoredApiSecret,
|
||||
getStoredApiSecretSnapshot,
|
||||
loadApiSecrets,
|
||||
saveApiSecret,
|
||||
} from '../src/shared/utils/api-secret-store.ts'
|
||||
|
||||
type RequestConfig = { url?: string; method?: string; data?: unknown }
|
||||
type MockTest = Parameters<typeof test>[1] extends (t: infer T) => unknown ? T : never
|
||||
|
||||
function createStorage() {
|
||||
const store = new Map<string, string>()
|
||||
return {
|
||||
getItem: (key: string) => (store.has(key) ? (store.get(key) as string) : null),
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, String(value))
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function setupWindow() {
|
||||
const localStorage = createStorage()
|
||||
const sessionStorage = createStorage()
|
||||
// 密钥按登录用户隔离:uid 必须存在(与真实前端 localStorage.uid 一致)
|
||||
localStorage.setItem('uid', '42')
|
||||
;(globalThis as Record<string, unknown>).window = {
|
||||
localStorage,
|
||||
sessionStorage,
|
||||
location: { origin: 'http://localhost' },
|
||||
}
|
||||
return { localStorage, sessionStorage }
|
||||
}
|
||||
|
||||
function mockRequest(t: MockTest, impl: (config: RequestConfig) => Promise<unknown>) {
|
||||
t.mock.method(http, 'request', impl as never)
|
||||
}
|
||||
|
||||
const okResponse = (data: unknown) => Promise.resolve({ data: { success: true, message: 'ok', data } })
|
||||
|
||||
function serverItem(moduleKey: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
moduleKey,
|
||||
moduleLabel: moduleKey === 'appearance-patent' ? '外观专利密钥' : '货源查询密钥',
|
||||
masked: '',
|
||||
exists: false,
|
||||
checkStatus: 'unknown',
|
||||
checkCode: '',
|
||||
checkMessage: '',
|
||||
checkLatencyMs: null,
|
||||
checkedAt: null,
|
||||
updatedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function bundleResponse(items: unknown[]) {
|
||||
return okResponse({
|
||||
items,
|
||||
requiredModules: ['appearance-patent', 'similar-asin'],
|
||||
complete: false,
|
||||
})
|
||||
}
|
||||
|
||||
const LEGACY_MIRROR_KEY = 'brand:api-secret:42:appearance-patent'
|
||||
|
||||
test('test_secret_store_reads_legacy_value_before_load', () => {
|
||||
const { localStorage } = setupWindow()
|
||||
clearApiSecretCache()
|
||||
localStorage.setItem(LEGACY_MIRROR_KEY, JSON.stringify({ value: 'legacy-key-1', retention: 'forever' }))
|
||||
|
||||
assert.equal(getStoredApiSecret('appearance-patent'), 'legacy-key-1')
|
||||
})
|
||||
|
||||
test('test_secret_store_load_failure_returns_unknown_and_keeps_cache', async (t) => {
|
||||
const { localStorage } = setupWindow()
|
||||
clearApiSecretCache()
|
||||
localStorage.setItem(LEGACY_MIRROR_KEY, JSON.stringify({ value: 'legacy-key-2' }))
|
||||
mockRequest(t, () => Promise.reject(new Error('服务不可用')))
|
||||
|
||||
const state = await loadApiSecrets({ force: true })
|
||||
|
||||
assert.equal(state, 'unknown')
|
||||
assert.equal(getStoredApiSecret('appearance-patent'), 'legacy-key-2')
|
||||
})
|
||||
|
||||
test('test_secret_store_migrates_legacy_value_once_and_clears_local', async (t) => {
|
||||
const { localStorage } = setupWindow()
|
||||
clearApiSecretCache()
|
||||
localStorage.setItem(LEGACY_MIRROR_KEY, JSON.stringify({ value: 'legacy-key-3' }))
|
||||
const calls: string[] = []
|
||||
mockRequest(t, (config) => {
|
||||
calls.push(config.url || '')
|
||||
if ((config.url || '').includes('/migrate')) {
|
||||
return okResponse({ migrated: 1 })
|
||||
}
|
||||
return bundleResponse([serverItem('appearance-patent'), serverItem('similar-asin')])
|
||||
})
|
||||
|
||||
const state = await loadApiSecrets({ force: true })
|
||||
|
||||
assert.equal(state, 'incomplete')
|
||||
assert.ok(calls.some((url) => url.includes('/api/user-secrets/migrate')), '应调用迁移接口')
|
||||
assert.equal(localStorage.getItem(LEGACY_MIRROR_KEY), null, '迁移后应清理旧记录')
|
||||
})
|
||||
|
||||
test('test_secret_store_save_keeps_plain_value_locally', async (t) => {
|
||||
setupWindow()
|
||||
clearApiSecretCache()
|
||||
mockRequest(t, () =>
|
||||
okResponse(serverItem('appearance-patent', { masked: 'sk-a****1234', exists: true })),
|
||||
)
|
||||
|
||||
await saveApiSecret('appearance-patent', 'sk-abc123456')
|
||||
|
||||
assert.equal(getStoredApiSecret('appearance-patent'), 'sk-abc123456')
|
||||
const snapshot = getStoredApiSecretSnapshot('appearance-patent')
|
||||
assert.equal(snapshot.masked, 'sk-a****1234')
|
||||
assert.equal(snapshot.exists, true)
|
||||
})
|
||||
|
||||
test('test_secret_store_save_rejects_empty_value', async () => {
|
||||
setupWindow()
|
||||
clearApiSecretCache()
|
||||
await assert.rejects(() => saveApiSecret('similar-asin', ' '), /密钥不能为空/)
|
||||
})
|
||||
Reference in New Issue
Block a user