Files
sub2api/frontend/src/views/admin/__tests__/RiskControlView.spec.ts
T

468 lines
16 KiB
TypeScript
Raw 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.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import type { DOMWrapper, VueWrapper } from '@vue/test-utils'
import RiskControlView from '../RiskControlView.vue'
import type { ContentModerationConfig, UpdateContentModerationConfig } from '@/api/admin/riskControl'
const {
getConfig,
updateConfig,
getStatus,
listLogs,
getGroups,
getProxies,
testAPIKeys,
showError,
showSuccess,
} = vi.hoisted(() => ({
getConfig: vi.fn(),
updateConfig: vi.fn(),
getStatus: vi.fn(),
listLogs: vi.fn(),
getGroups: vi.fn(),
getProxies: vi.fn(),
testAPIKeys: vi.fn(),
showError: vi.fn(),
showSuccess: vi.fn(),
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
riskControl: {
getConfig,
updateConfig,
getStatus,
listLogs,
testAPIKeys,
deleteFlaggedHash: vi.fn(),
clearFlaggedHashes: vi.fn(),
unbanUser: vi.fn(),
},
groups: {
getAll: getGroups,
},
proxies: {
getAll: getProxies,
},
},
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError,
showSuccess,
}),
}))
vi.mock('@/utils/apiError', () => ({
extractApiErrorMessage: (_err: unknown, fallback: string) => fallback,
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string, params?: Record<string, string | number>) => {
if (key === 'admin.riskControl.preBlockAPIKeyLoadSummary') {
return `同步并发 ${params?.active} / 可用 Key ${params?.available},累计 ${params?.total} 次,worker:${params?.workerActive} / ${params?.workerTotal}`
}
return key.replace(/\{(\w+)\}/g, (_, token) => String(params?.[token] ?? `{${token}}`))
},
}),
}
})
const baseConfig = (): ContentModerationConfig => ({
enabled: true,
mode: 'pre_block',
base_url: 'https://api.openai.com',
model: 'omni-moderation-latest',
proxy_id: null,
api_key_configured: false,
api_key_masked: '',
api_key_count: 0,
api_key_masks: [],
api_key_statuses: [],
timeout_ms: 3000,
sample_rate: 100,
all_groups: true,
group_ids: [],
record_non_hits: false,
worker_count: 4,
queue_size: 32768,
block_status: 403,
block_message: '内容审计命中风险规则,请调整输入后重试',
email_on_hit: true,
auto_ban_enabled: true,
ban_threshold: 10,
violation_window_hours: 720,
retry_count: 2,
hit_retention_days: 180,
non_hit_retention_days: 3,
pre_hash_check_enabled: false,
blocked_keywords: [],
keyword_blocking_mode: 'keyword_and_api',
thresholds: {
harassment: 0.98,
sexual: 0.65,
},
model_filter: {
type: 'all',
models: [],
},
})
const runtimeStatus = () => ({
enabled: true,
risk_control_enabled: true,
mode: 'pre_block',
worker_count: 4,
max_workers: 32,
active_workers: 0,
idle_workers: 4,
queue_size: 32768,
queue_length: 0,
queue_usage_percent: 0,
enqueued: 0,
dropped: 0,
processed: 0,
errors: 0,
pre_block_active: 0,
pre_block_checked: 0,
pre_block_allowed: 0,
pre_block_blocked: 0,
pre_block_errors: 0,
pre_block_avg_latency_ms: 0,
pre_block_api_key_active: 0,
pre_block_api_key_available_count: 0,
pre_block_api_key_total_calls: 0,
pre_block_api_key_loads: [],
api_key_statuses: [],
flagged_hash_count: 0,
last_cleanup_deleted_hit: 0,
last_cleanup_deleted_non_hit: 0,
})
const AppLayoutStub = { template: '<div><slot /></div>' }
const BaseDialogStub = defineComponent({
props: {
show: {
type: Boolean,
default: false,
},
},
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
})
const ModelWhitelistSelectorStub = defineComponent({
props: {
modelValue: {
type: Array,
default: () => [],
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const onInput = (event: Event) => {
const value = (event.target as HTMLInputElement).value
emit(
'update:modelValue',
value
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean)
)
}
return () =>
h('input', {
'data-test': 'model-filter-input',
value: (props.modelValue as string[]).join('\n'),
onInput,
})
},
})
function findButtonByText(wrapper: VueWrapper, text: string): DOMWrapper<HTMLButtonElement> {
const button = wrapper.findAll<HTMLButtonElement>('button').find((item) => item.text().includes(text))
if (!button) {
throw new Error(`button not found: ${text}`)
}
return button
}
describe('admin RiskControlView', () => {
beforeEach(() => {
getConfig.mockReset()
updateConfig.mockReset()
getStatus.mockReset()
listLogs.mockReset()
getGroups.mockReset()
showError.mockReset()
showSuccess.mockReset()
testAPIKeys.mockReset()
getConfig.mockResolvedValue(baseConfig())
getStatus.mockResolvedValue(runtimeStatus())
listLogs.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 1 })
getGroups.mockResolvedValue([])
getProxies.mockResolvedValue([])
updateConfig.mockImplementation(async (payload: UpdateContentModerationConfig) => ({
...baseConfig(),
...payload,
model_filter: payload.model_filter ?? baseConfig().model_filter,
api_key_configured: false,
api_key_masked: '',
api_key_count: 0,
api_key_masks: [],
api_key_statuses: [],
}))
})
it('preserves both engine drafts and saves thresholds independently', async () => {
const wrapper = mount(RiskControlView, { global: { stubs: { AppLayout: AppLayoutStub, BaseDialog: BaseDialogStub, Icon: true, Select: true, Toggle: true, Pagination: true, ModelWhitelistSelector: ModelWhitelistSelectorStub, ProxySelector: true } } })
await flushPromises()
await findButtonByText(wrapper, 'admin.riskControl.openSettings').trigger('click')
await wrapper.get('[data-test="audit-base-url"]').setValue('https://openai-edited.example')
await findButtonByText(wrapper, 'admin.riskControl.tabs.riskThresholds').trigger('click')
await wrapper.get('[data-test="risk-threshold-sexual"]').setValue('73')
await findButtonByText(wrapper, 'admin.riskControl.tabs.basic').trigger('click')
const engine = () => wrapper.findComponent('[data-test="audit-engine-select"]')
engine().vm.$emit('update:modelValue', 'typesafe')
await flushPromises()
expect((wrapper.get('[data-test="audit-model"]').element as HTMLInputElement).value).toBe('jev-latest')
await wrapper.get('[data-test="audit-base-url"]').setValue('https://typesafe-edited.example')
await findButtonByText(wrapper, 'admin.riskControl.tabs.riskThresholds').trigger('click')
expect((wrapper.get('[data-test="risk-threshold-sexual"]').element as HTMLInputElement).value).toBe('80')
await wrapper.get('[data-test="risk-threshold-sexual"]').setValue('91')
await findButtonByText(wrapper, 'admin.riskControl.tabs.basic').trigger('click')
engine().vm.$emit('update:modelValue', 'openai')
await flushPromises()
expect((wrapper.get('[data-test="audit-base-url"]').element as HTMLInputElement).value).toBe('https://openai-edited.example')
await findButtonByText(wrapper, 'admin.riskControl.saveConfig').trigger('click')
await flushPromises()
expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({ engine: 'openai', engine_configs: {
openai: expect.objectContaining({ base_url: 'https://openai-edited.example', thresholds: expect.objectContaining({ sexual: 0.73 }) }),
typesafe: expect.objectContaining({ base_url: 'https://typesafe-edited.example', thresholds: expect.objectContaining({ sexual: 0.91 }) }),
} }))
expect(showError).not.toHaveBeenCalled()
wrapper.unmount()
})
it('tests the draft engine without switching the active engine', async () => {
testAPIKeys.mockResolvedValue({ items: [{ status: 'ok', configured: false }], image_count: 0 })
const wrapper = mount(RiskControlView, { global: { stubs: { AppLayout: AppLayoutStub, BaseDialog: BaseDialogStub, Icon: true, Select: true, Toggle: true, Pagination: true, ModelWhitelistSelector: ModelWhitelistSelectorStub, ProxySelector: true } } })
await flushPromises()
await findButtonByText(wrapper, 'admin.riskControl.openSettings').trigger('click')
wrapper.findComponent('[data-test="audit-engine-select"]').vm.$emit('update:modelValue', 'typesafe')
await flushPromises()
await wrapper.get('textarea[autocomplete="new-password"]').setValue('test-only-key')
await findButtonByText(wrapper, 'admin.riskControl.testInputApiKeys').trigger('click')
await flushPromises()
expect(testAPIKeys).toHaveBeenCalledWith(expect.objectContaining({ engine: 'typesafe', api_keys: ['test-only-key'], model: 'jev-latest', thresholds: expect.objectContaining({ sexual: 0.8 }) }))
expect(updateConfig).not.toHaveBeenCalled()
expect(wrapper.get('[data-test="active-audit-engine"]').text()).not.toContain('TypeSafe AI')
wrapper.unmount()
})
it('saves the selected model filter mode and models', async () => {
const wrapper = mount(RiskControlView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
BaseDialog: BaseDialogStub,
Icon: true,
Select: true,
Toggle: true,
Pagination: true,
ModelWhitelistSelector: ModelWhitelistSelectorStub,
ProxySelector: true,
},
},
})
await flushPromises()
await findButtonByText(wrapper, 'admin.riskControl.openSettings').trigger('click')
await findButtonByText(wrapper, 'admin.riskControl.tabs.scope').trigger('click')
await findButtonByText(wrapper, 'admin.riskControl.modelFilterInclude').trigger('click')
await wrapper.get('[data-test="model-filter-input"]').setValue('gpt-5.5, gpt-5.4')
await findButtonByText(wrapper, 'admin.riskControl.saveConfig').trigger('click')
await flushPromises()
expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({
model_filter: {
type: 'include',
models: ['gpt-5.5', 'gpt-5.4'],
},
}))
expect(showError).not.toHaveBeenCalled()
})
it('submits edited risk control thresholds when saving moderation config', async () => {
const wrapper = mount(RiskControlView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
BaseDialog: BaseDialogStub,
Icon: true,
Select: true,
Toggle: true,
Pagination: true,
ModelWhitelistSelector: ModelWhitelistSelectorStub,
ProxySelector: true,
},
},
})
await flushPromises()
await findButtonByText(wrapper, 'admin.riskControl.openSettings').trigger('click')
await findButtonByText(wrapper, 'admin.riskControl.tabs.riskThresholds').trigger('click')
await wrapper.get('[data-test="risk-threshold-sexual"]').setValue('72')
await wrapper.get('[data-test="risk-threshold-harassment"]').setValue('99')
await findButtonByText(wrapper, 'admin.riskControl.saveConfig').trigger('click')
await flushPromises()
expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({
thresholds: expect.objectContaining({
sexual: 0.72,
harassment: 0.99,
}),
}))
expect(showError).not.toHaveBeenCalled()
})
it('describes worker runtime as async audit and pre-block record processing', async () => {
getStatus.mockResolvedValue({
...runtimeStatus(),
mode: 'observe',
processed: 12,
queue_length: 2,
})
const wrapper = mount(RiskControlView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
BaseDialog: BaseDialogStub,
Icon: true,
Select: true,
Toggle: true,
Pagination: true,
ModelWhitelistSelector: ModelWhitelistSelectorStub,
ProxySelector: true,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('admin.riskControl.workerStatusHint')
expect(wrapper.text()).not.toContain('admin.riskControl.preBlockSyncStatus')
expect(wrapper.text()).toContain('admin.riskControl.records')
expect(wrapper.text()).toContain('12')
expect(wrapper.text()).toContain('2 / 32,768')
})
it('shows pre-block synchronous moderation metrics separately from worker queue', async () => {
getStatus.mockResolvedValue({
...runtimeStatus(),
pre_block_active: 2,
pre_block_checked: 128,
pre_block_allowed: 120,
pre_block_blocked: 8,
pre_block_errors: 1,
pre_block_avg_latency_ms: 86,
pre_block_api_key_active: 2,
pre_block_api_key_available_count: 2,
pre_block_api_key_total_calls: 128,
active_workers: 3,
worker_count: 7,
pre_block_api_key_loads: [
{
index: 0,
key_hash: 'hash-one',
masked: 'sk-...one',
status: 'ok',
active: 1,
total: 72,
success: 70,
errors: 2,
avg_latency_ms: 84,
last_latency_ms: 80,
last_http_status: 200,
},
{
index: 1,
key_hash: 'hash-two',
masked: 'sk-...two',
status: 'ok',
active: 1,
total: 56,
success: 56,
errors: 0,
avg_latency_ms: 90,
last_latency_ms: 92,
last_http_status: 200,
},
],
})
const wrapper = mount(RiskControlView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
BaseDialog: BaseDialogStub,
Icon: true,
Select: true,
Toggle: true,
Pagination: true,
ModelWhitelistSelector: ModelWhitelistSelectorStub,
ProxySelector: true,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('admin.riskControl.preBlockSyncStatus')
expect(wrapper.text()).toContain('admin.riskControl.preBlockSyncHint')
expect(wrapper.text()).not.toContain('admin.riskControl.workerStatus')
expect(wrapper.text()).toContain('admin.riskControl.records')
expect(wrapper.text()).toContain('128')
expect(wrapper.text()).toContain('120')
expect(wrapper.text()).toContain('8')
expect(wrapper.text()).toContain('86 ms')
expect(wrapper.text()).toContain('admin.riskControl.preBlockAPIKeyLoad')
expect(wrapper.text()).toContain('sk-...one')
expect(wrapper.text()).toContain('sk-...two')
expect(wrapper.text()).toContain('72')
expect(wrapper.text()).toContain('56')
expect(wrapper.text()).toContain('同步并发 2 / 可用 Key 2,累计 128 次,worker:3 / 7')
const runtimeCards = wrapper.get('[data-test="pre-block-runtime-cards"]')
const syncCard = wrapper.get('[data-test="pre-block-sync-card"]')
const apiKeyLoadCard = wrapper.get('[data-test="pre-block-api-key-load-card"]')
expect(runtimeCards.classes()).toEqual(expect.arrayContaining([
'grid',
'grid-cols-1',
'xl:grid-cols-[minmax(0,520px)_minmax(0,1fr)]',
]))
expect(syncCard.element.parentElement).toBe(runtimeCards.element)
expect(apiKeyLoadCard.element.parentElement).toBe(runtimeCards.element)
expect(syncCard.classes()).toContain('card')
expect(apiKeyLoadCard.classes()).toContain('card')
expect(syncCard.get('h2').text()).toBe('admin.riskControl.preBlockSyncStatus')
expect(syncCard.text()).toContain('admin.riskControl.preBlockSyncHint')
expect(apiKeyLoadCard.get('h2').text()).toBe('admin.riskControl.preBlockAPIKeyLoad')
expect(apiKeyLoadCard.text()).toContain('admin.riskControl.preBlockAPIKeyLoadHint')
expect(wrapper.get('[data-test="pre-block-api-key-load-list"]').classes()).toEqual(expect.arrayContaining([
'max-h-[280px]',
'overflow-y-auto',
]))
})
})