Make repeated channel-monitor setup safer

Admins often recreate monitors with the same endpoint, model, and request settings. A server-side duplicate keeps the stored API key out of the browser, creates a disabled copy for review, and uses stable operation identity to recover ambiguous retries without creating extra rows.

Constraint: Stored monitor API keys must never be returned to the browser
Constraint: Applying a request template must preserve internal duplicate recovery metadata
Rejected: Rebuild the monitor from list data | list responses only contain a masked API key
Rejected: Copy runtime state and history | a duplicate should start as an unverified configuration
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep duplicated monitors disabled until an administrator reviews and enables them
Tested: Go unit tests for repository, service, and admin handler; integration-tag compile; go vet; golangci-lint v2.9; frontend Vitest, ESLint, typecheck, production build; Playwright duplicate flow
Not-tested: PostgreSQL container integration locally because Docker is unavailable; CI will execute the database-backed suite
This commit is contained in:
yan9651688
2026-07-16 16:30:19 +08:00
parent 393a8fe56a
commit e2e375d694
19 changed files with 1512 additions and 29 deletions
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { post } = vi.hoisted(() => ({
post: vi.fn(),
}))
vi.mock('@/api/client', () => ({
apiClient: { post },
}))
import { duplicate } from '@/api/admin/channelMonitor'
describe('admin channel monitor duplicate API', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('auth_user', JSON.stringify({ id: 7 }))
post.mockReset()
post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)' } })
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
})
afterEach(() => {
vi.restoreAllMocks()
})
it('sends a stable idempotency key with the duplicate request', async () => {
const monitor = await duplicate(42)
expect(post).toHaveBeenCalledWith('/admin/channel-monitors/42/duplicate', undefined, {
headers: {
'Idempotency-Key': 'channel-monitor-duplicate-7-42-11111111-1111-4111-8111-111111111111',
},
})
expect(monitor).toEqual({ id: 43, name: 'primary (Copy)' })
expect(sessionStorage.length).toBe(0)
})
it('reuses the operation key after an ambiguous failed request', async () => {
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(99)).rejects.toThrow('network timeout')
post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } })
await duplicate(99)
expect(post).toHaveBeenCalledTimes(2)
expect(post.mock.calls[1][2].headers).toEqual(post.mock.calls[0][2].headers)
expect(sessionStorage.length).toBe(0)
})
it('reuses the operation key after a page reload', async () => {
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(77)).rejects.toThrow('network timeout')
const firstHeaders = post.mock.calls[0][2].headers
vi.resetModules()
post.mockResolvedValueOnce({ data: { id: 78, name: 'reload (Copy)' } })
const { duplicate: duplicateAfterReload } = await import('@/api/admin/channelMonitor')
await duplicateAfterReload(77)
expect(post).toHaveBeenCalledTimes(2)
expect(post.mock.calls[1][2].headers).toEqual(firstHeaders)
expect(sessionStorage.length).toBe(0)
})
it('does not reuse an operation key across administrators for the same monitor', async () => {
post.mockRejectedValueOnce(new Error('first admin timeout'))
await expect(duplicate(55)).rejects.toThrow('first admin timeout')
const firstAdminHeaders = post.mock.calls[0][2].headers
localStorage.setItem('auth_user', JSON.stringify({ id: 8 }))
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
'22222222-2222-4222-8222-222222222222'
)
post.mockResolvedValueOnce({ data: { id: 56, name: 'second admin copy' } })
await duplicate(55)
expect(post.mock.calls[1][2].headers).not.toEqual(firstAdminHeaders)
expect(post.mock.calls[1][2].headers).toEqual({
'Idempotency-Key': 'channel-monitor-duplicate-8-55-22222222-2222-4222-8222-222222222222',
})
expect(sessionStorage.getItem('sub2api:admin:channel-monitor-duplicate:7:55')).toBe(
firstAdminHeaders['Idempotency-Key']
)
expect(sessionStorage.getItem('sub2api:admin:channel-monitor-duplicate:8:55')).toBeNull()
})
it('does not persist or reuse keys when the current user cannot be parsed', async () => {
localStorage.setItem('auth_user', '{invalid json')
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(66)).rejects.toThrow('network timeout')
const firstHeaders = post.mock.calls[0][2].headers
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
'33333333-3333-4333-8333-333333333333'
)
post.mockResolvedValueOnce({ data: { id: 67, name: 'fallback copy' } })
await duplicate(66)
expect(post.mock.calls[1][2].headers).not.toEqual(firstHeaders)
expect(sessionStorage.length).toBe(0)
})
})
+83
View File
@@ -156,6 +156,88 @@ export async function create(params: CreateParams): Promise<ChannelMonitor> {
return data
}
/**
* Duplicate a monitor without exposing its stored API key to the browser.
* Keep the operation key after ambiguous failures so a retry replays the
* original server-side operation instead of creating another monitor.
*/
const duplicateOperationKeys = new Map<string, string>()
interface DuplicateOperationScope {
adminID: string
key: string
}
function getCurrentAdminID(): string | null {
try {
const rawUser = globalThis.localStorage?.getItem('auth_user')
if (!rawUser) return null
const user: unknown = JSON.parse(rawUser)
if (typeof user !== 'object' || user === null) return null
const id = (user as { id?: unknown }).id
if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) return null
return String(id)
} catch {
return null
}
}
function duplicateOperationScope(id: number): DuplicateOperationScope | null {
const adminID = getCurrentAdminID()
if (!adminID) return null
return {
adminID,
key: `sub2api:admin:channel-monitor-duplicate:${adminID}:${id}`,
}
}
function getStoredDuplicateOperationKey(storageKey: string): string | null {
try {
return globalThis.sessionStorage?.getItem(storageKey) ?? null
} catch {
return null
}
}
function storeDuplicateOperationKey(storageKey: string, key: string | null): void {
try {
if (key) globalThis.sessionStorage?.setItem(storageKey, key)
else globalThis.sessionStorage?.removeItem(storageKey)
} catch {
// In-memory retry protection still works when browser storage is unavailable.
}
}
export async function duplicate(id: number): Promise<ChannelMonitor> {
const scope = duplicateOperationScope(id)
let idempotencyKey = scope
? duplicateOperationKeys.get(scope.key) ?? getStoredDuplicateOperationKey(scope.key)
: null
if (!idempotencyKey) {
const requestID = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
idempotencyKey = `channel-monitor-duplicate-${scope?.adminID ?? 'unknown-admin'}-${id}-${requestID}`
}
if (scope) {
duplicateOperationKeys.set(scope.key, idempotencyKey)
storeDuplicateOperationKey(scope.key, idempotencyKey)
}
const { data } = await apiClient.post<ChannelMonitor>(
`/admin/channel-monitors/${id}/duplicate`,
undefined,
{ headers: { 'Idempotency-Key': idempotencyKey } }
)
if (scope) {
duplicateOperationKeys.delete(scope.key)
storeDuplicateOperationKey(scope.key, null)
}
return data
}
/**
* Update an existing channel monitor.
* api_key field: empty string means "do not modify".
@@ -199,6 +281,7 @@ export const channelMonitorAPI = {
list,
get,
create,
duplicate,
update,
del,
runNow,
@@ -0,0 +1,77 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { ChannelMonitor } from '@/api/admin/channelMonitor'
import MonitorActionsCell from '@/components/admin/monitor/MonitorActionsCell.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
function makeMonitor(overrides: Partial<ChannelMonitor> = {}): ChannelMonitor {
return {
id: 42,
name: 'primary',
provider: 'openai',
api_mode: 'chat_completions',
endpoint: 'https://api.example.com',
api_key_masked: 'sk-t***',
primary_model: 'gpt-4o-mini',
extra_models: [],
group_name: '',
enabled: true,
interval_seconds: 60,
jitter_seconds: 0,
last_checked_at: null,
created_by: 1,
created_at: '2026-07-16T00:00:00Z',
updated_at: '2026-07-16T00:00:00Z',
primary_status: '',
primary_latency_ms: null,
availability_7d: 0,
extra_models_status: [],
template_id: null,
extra_headers: {},
body_override_mode: 'off',
body_override: null,
...overrides,
}
}
describe('MonitorActionsCell duplicate action', () => {
it('emits the selected monitor when duplicate is clicked', async () => {
const row = makeMonitor()
const wrapper = mount(MonitorActionsCell, {
props: { row, running: false, duplicating: false },
})
await wrapper.get('[data-testid="monitor-duplicate"]').trigger('click')
expect(wrapper.emitted('duplicate')).toEqual([[row]])
})
it('disables the action while the same monitor is being duplicated', () => {
const wrapper = mount(MonitorActionsCell, {
props: { row: makeMonitor(), running: false, duplicating: true },
})
const button = wrapper.get('[data-testid="monitor-duplicate"]')
expect(button.attributes('disabled')).toBeDefined()
expect(button.attributes('title')).toBe('admin.channelMonitor.duplicating')
expect(button.text()).toContain('admin.channelMonitor.duplicating')
})
it('disables the action when the stored API key cannot be decrypted', () => {
const wrapper = mount(MonitorActionsCell, {
props: {
row: makeMonitor({ api_key_decrypt_failed: true }),
running: false,
duplicating: false,
},
})
const button = wrapper.get('[data-testid="monitor-duplicate"]')
expect(button.attributes('disabled')).toBeDefined()
expect(button.attributes('title')).toBe('admin.channelMonitor.duplicateKeyUnavailable')
})
})
@@ -8,6 +8,18 @@
<Icon name="refresh" size="sm" :class="running ? 'animate-spin' : ''" />
<span class="text-xs">{{ t('admin.channelMonitor.runNow') }}</span>
</button>
<button
data-testid="monitor-duplicate"
:title="duplicateTitle"
:disabled="duplicating || Boolean(row.api_key_decrypt_failed)"
@click="$emit('duplicate', row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-dark-700 dark:hover:text-primary-400"
>
<Icon name="copy" size="sm" />
<span class="text-xs">
{{ duplicating ? t('admin.channelMonitor.duplicating') : t('admin.channelMonitor.duplicate') }}
</span>
</button>
<button
@click="$emit('edit', row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
@@ -26,20 +38,28 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ChannelMonitor } from '@/api/admin/channelMonitor'
import Icon from '@/components/icons/Icon.vue'
defineProps<{
const props = defineProps<{
row: ChannelMonitor
running: boolean
duplicating: boolean
}>()
defineEmits<{
(e: 'run', row: ChannelMonitor): void
(e: 'duplicate', row: ChannelMonitor): void
(e: 'edit', row: ChannelMonitor): void
(e: 'delete', row: ChannelMonitor): void
}>()
const { t } = useI18n()
const duplicateTitle = computed(() => {
if (props.row.api_key_decrypt_failed) return t('admin.channelMonitor.duplicateKeyUnavailable')
if (props.duplicating) return t('admin.channelMonitor.duplicating')
return t('admin.channelMonitor.duplicate')
})
</script>
@@ -479,6 +479,11 @@ export default {
runNow: 'Run Now',
runSuccess: 'Check completed',
runFailed: 'Check failed',
duplicate: 'Duplicate',
duplicating: 'Duplicating',
duplicateSuccess: 'Monitor duplicated as "{name}" and disabled. Review its configuration before enabling it.',
duplicateFailed: 'Failed to duplicate monitor',
duplicateKeyUnavailable: 'The API key cannot be decrypted. Re-enter it before duplicating this monitor.',
apiKeyDecryptFailed: 'API Key decryption failed. Please re-edit this monitor with a fresh key.',
createSuccess: 'Monitor created',
updateSuccess: 'Monitor updated',
@@ -479,6 +479,11 @@ export default {
runNow: '立即检测',
runSuccess: '检测完成',
runFailed: '检测失败',
duplicate: '复制',
duplicating: '复制中',
duplicateSuccess: '监控已复制为「{name}」,已默认停用,请确认配置后再启用',
duplicateFailed: '复制监控失败',
duplicateKeyUnavailable: 'API Key 无法解密,请先编辑并重新填写 Key 后再复制',
apiKeyDecryptFailed: 'API Key 解密失败,请重新编辑该监控并填入新的 Key',
createSuccess: '监控创建成功',
updateSuccess: '监控更新成功',
@@ -51,7 +51,9 @@
<MonitorActionsCell
:row="row"
:running="runningId === row.id"
:duplicating="duplicatingIds.has(row.id)"
@run="handleRunNow"
@duplicate="handleDuplicate"
@edit="openEditDialog"
@delete="handleDelete"
/>
@@ -167,6 +169,7 @@ const showDeleteDialog = ref(false)
const deleting = ref<ChannelMonitor | null>(null)
const showRunResult = ref(false)
const runResults = ref<CheckResult[]>([])
const duplicatingIds = reactive(new Set<number>())
let abortController: AbortController | null = null
let searchTimeout: ReturnType<typeof setTimeout> | null = null
@@ -278,6 +281,25 @@ async function handleRunNow(row: ChannelMonitor) {
}
}
async function handleDuplicate(row: ChannelMonitor) {
if (row.api_key_decrypt_failed) {
appStore.showError(t('admin.channelMonitor.duplicateKeyUnavailable'))
return
}
if (duplicatingIds.has(row.id)) return
duplicatingIds.add(row.id)
try {
const duplicate = await adminAPI.channelMonitor.duplicate(row.id)
appStore.showSuccess(t('admin.channelMonitor.duplicateSuccess', { name: duplicate.name }))
await reload()
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('admin.channelMonitor.duplicateFailed')))
} finally {
duplicatingIds.delete(row.id)
}
}
function handleDelete(row: ChannelMonitor) {
deleting.value = row
showDeleteDialog.value = true
@@ -0,0 +1,217 @@
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChannelMonitor } from '@/api/admin/channelMonitor'
import MonitorActionsCell from '@/components/admin/monitor/MonitorActionsCell.vue'
import ChannelMonitorView from '@/views/admin/ChannelMonitorView.vue'
const {
listMonitors,
duplicateMonitor,
showSuccess,
showError,
} = vi.hoisted(() => ({
listMonitors: vi.fn(),
duplicateMonitor: vi.fn(),
showSuccess: vi.fn(),
showError: vi.fn(),
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
channelMonitor: {
list: listMonitors,
duplicate: duplicateMonitor,
update: vi.fn(),
runNow: vi.fn(),
del: vi.fn(),
},
},
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ showSuccess, showError }),
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key }),
}
})
const AppLayoutStub = defineComponent({
template: '<main><slot /></main>',
})
const TablePageLayoutStub = defineComponent({
template: '<section><slot name="filters" /><slot name="table" /><slot name="pagination" /></section>',
})
const DataTableStub = defineComponent({
props: {
data: { type: Array, default: () => [] },
columns: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
},
template: '<div><div v-for="row in data" :key="row.id"><slot name="cell-actions" :row="row" /></div></div>',
})
function makeMonitor(overrides: Partial<ChannelMonitor> = {}): ChannelMonitor {
return {
id: 42,
name: 'primary',
provider: 'openai',
api_mode: 'chat_completions',
endpoint: 'https://api.example.com',
api_key_masked: 'sk-t***',
primary_model: 'gpt-4o-mini',
extra_models: [],
group_name: '',
enabled: true,
interval_seconds: 60,
jitter_seconds: 0,
last_checked_at: null,
created_by: 1,
created_at: '2026-07-16T00:00:00Z',
updated_at: '2026-07-16T00:00:00Z',
primary_status: '',
primary_latency_ms: null,
availability_7d: 0,
extra_models_status: [],
template_id: null,
extra_headers: {},
body_override_mode: 'off',
body_override: null,
...overrides,
}
}
const monitor = makeMonitor()
function mountView() {
return mount(ChannelMonitorView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
DataTable: DataTableStub,
MonitorFiltersBar: true,
Pagination: true,
ConfirmDialog: true,
EmptyState: true,
HelpTooltip: true,
Toggle: true,
MonitorFormDialog: true,
MonitorTemplateManagerDialog: true,
MonitorRunResultDialog: true,
MonitorPrimaryModelCell: true,
},
},
})
}
describe('ChannelMonitorView duplicate action', () => {
beforeEach(() => {
localStorage.clear()
for (const fn of [listMonitors, duplicateMonitor, showSuccess, showError]) fn.mockReset()
listMonitors.mockResolvedValue({
items: [monitor],
total: 1,
page: 1,
page_size: 20,
pages: 1,
})
duplicateMonitor.mockResolvedValue(makeMonitor({ id: 43, name: 'primary (Copy)', enabled: false }))
})
it('duplicates the selected monitor, reports success, and refreshes the list', async () => {
const wrapper = mountView()
await flushPromises()
wrapper.findComponent(MonitorActionsCell).vm.$emit('duplicate', monitor)
await flushPromises()
expect(duplicateMonitor).toHaveBeenCalledTimes(1)
expect(duplicateMonitor).toHaveBeenCalledWith(42)
expect(showSuccess).toHaveBeenCalledWith('admin.channelMonitor.duplicateSuccess')
expect(listMonitors.mock.calls.length).toBeGreaterThan(1)
wrapper.unmount()
})
it('keeps a successful duplicate successful when the follow-up refresh fails', async () => {
listMonitors
.mockResolvedValueOnce({
items: [monitor],
total: 1,
page: 1,
page_size: 20,
pages: 1,
})
.mockRejectedValueOnce(new Error('refresh failed'))
const wrapper = mountView()
await flushPromises()
wrapper.findComponent(MonitorActionsCell).vm.$emit('duplicate', monitor)
await flushPromises()
expect(showSuccess).toHaveBeenCalledWith('admin.channelMonitor.duplicateSuccess')
expect(showError).toHaveBeenCalledWith('refresh failed')
expect(showError).not.toHaveBeenCalledWith('admin.channelMonitor.duplicateFailed')
wrapper.unmount()
})
it('ignores repeated clicks while a duplicate request is in flight', async () => {
let resolveDuplicate!: (value: ChannelMonitor) => void
duplicateMonitor.mockImplementationOnce(() => new Promise(resolve => { resolveDuplicate = resolve }))
const wrapper = mountView()
await flushPromises()
const actions = wrapper.findComponent(MonitorActionsCell)
actions.vm.$emit('duplicate', monitor)
actions.vm.$emit('duplicate', monitor)
await wrapper.vm.$nextTick()
expect(duplicateMonitor).toHaveBeenCalledTimes(1)
expect(actions.props('duplicating')).toBe(true)
resolveDuplicate(makeMonitor({ id: 43, name: 'primary (Copy)', enabled: false }))
await flushPromises()
expect(wrapper.findComponent(MonitorActionsCell).props('duplicating')).toBe(false)
wrapper.unmount()
})
it('shows the API error when duplication fails', async () => {
duplicateMonitor.mockRejectedValueOnce(new Error('duplicate failed'))
const wrapper = mountView()
await flushPromises()
wrapper.findComponent(MonitorActionsCell).vm.$emit('duplicate', monitor)
await flushPromises()
expect(showError).toHaveBeenCalledWith('duplicate failed')
wrapper.unmount()
})
it('rejects a defensive duplicate event when the API key is unavailable', async () => {
const unavailable = makeMonitor({ id: 99, api_key_decrypt_failed: true })
listMonitors.mockResolvedValueOnce({
items: [unavailable],
total: 1,
page: 1,
page_size: 20,
pages: 1,
})
const wrapper = mountView()
await flushPromises()
wrapper.findComponent(MonitorActionsCell).vm.$emit('duplicate', unavailable)
await flushPromises()
expect(duplicateMonitor).not.toHaveBeenCalled()
expect(showError).toHaveBeenCalledWith('admin.channelMonitor.duplicateKeyUnavailable')
wrapper.unmount()
})
})