mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 00:24:38 +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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user