mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 08:22:34 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user