feat: add OAuth outbound transport plugin system

This commit is contained in:
shaw
2026-08-24 09:03:37 +08:00
parent d45135d87d
commit 40ea3aebad
98 changed files with 7681 additions and 141 deletions
+671
View File
@@ -0,0 +1,671 @@
<template>
<AppLayout>
<div class="space-y-6">
<section
class="flex flex-col gap-4 border-b border-gray-200 pb-5 dark:border-dark-700 sm:flex-row sm:items-end sm:justify-between"
>
<div class="min-w-0">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t("admin.plugins.title") }}
</h2>
<p class="mt-1 max-w-3xl text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.plugins.description") }}
</p>
<div
class="mt-3 flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300"
>
<span class="rounded bg-gray-100 px-2 py-1 dark:bg-dark-700">{{
t("admin.plugins.onlyOpenAI")
}}</span>
<span class="rounded bg-gray-100 px-2 py-1 dark:bg-dark-700">{{
t("admin.plugins.noAccountCoupling")
}}</span>
</div>
</div>
<div class="flex flex-shrink-0 items-center gap-2">
<input
ref="fileInput"
class="hidden"
type="file"
accept=".s2plugin,application/zip"
@change="handleFileSelected"
/>
<button
type="button"
class="btn btn-primary"
:disabled="uploading"
@click="fileInput?.click()"
>
<Icon name="upload" size="sm" />
{{ uploading ? t("common.processing") : t("admin.plugins.upload") }}
</button>
<button
type="button"
class="btn btn-secondary"
:disabled="loading"
:title="t('common.refresh')"
@click="loadPlugins"
>
<Icon name="refresh" size="sm" />
<span class="sr-only">{{ t("common.refresh") }}</span>
</button>
</div>
</section>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.plugins.uploadHint") }}
</p>
<div
class="border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-200"
>
<p>{{ t("admin.plugins.runtimeNotice") }}</p>
<p class="mt-1">{{ t("admin.plugins.menuNotice") }}</p>
</div>
<div
v-if="loading"
class="flex min-h-48 items-center justify-center text-sm text-gray-500"
>
{{ t("common.loading") }}
</div>
<div
v-else-if="plugins.length === 0"
class="flex min-h-56 flex-col items-center justify-center border border-dashed border-gray-300 px-6 text-center dark:border-dark-600"
>
<Icon name="cube" size="xl" class="text-gray-400" />
<p class="mt-3 font-medium text-gray-800 dark:text-gray-200">
{{ t("admin.plugins.empty") }}
</p>
<p class="mt-1 max-w-lg text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.plugins.emptyHint") }}
</p>
</div>
<div v-else class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<article
v-for="plugin in plugins"
:key="plugin.id"
class="card overflow-hidden border border-gray-200 dark:border-dark-700"
>
<div
class="flex flex-wrap items-start justify-between gap-3 border-b border-gray-100 p-5 dark:border-dark-700"
>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h3
class="truncate text-base font-semibold text-gray-900 dark:text-white"
>
{{ plugin.name }}
</h3>
<span class="font-mono text-xs text-gray-500"
>v{{ plugin.version }}</span
>
<span
class="rounded px-2 py-0.5 text-xs font-medium"
:class="stateClass(plugin.state)"
>
{{ t(`admin.plugins.${plugin.state}`) }}
</span>
</div>
<p class="mt-1 text-xs text-gray-500">
{{ plugin.plugin_key
}}<span v-if="plugin.author"> · {{ plugin.author }}</span>
</p>
<p
v-if="plugin.description"
class="mt-2 text-sm text-gray-600 dark:text-gray-300"
>
{{ plugin.description }}
</p>
</div>
<button
type="button"
class="btn btn-secondary btn-sm"
@click="openConfiguration(plugin)"
>
<Icon name="cog" size="sm" />
{{ t("admin.plugins.configure") }}
</button>
</div>
<div class="grid grid-cols-1 gap-x-6 gap-y-4 p-5 md:grid-cols-2">
<div>
<p class="text-xs font-medium uppercase text-gray-500">
{{ t("admin.plugins.compatibility") }}
</p>
<div class="mt-2 flex items-center gap-2">
<span
class="rounded px-2 py-0.5 text-xs font-medium"
:class="compatibilityClass(plugin.compatibility.status)"
>
{{ t(`admin.plugins.${plugin.compatibility.status}`) }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{
plugin.compatibility.message
}}</span>
</div>
<dl
class="mt-3 grid grid-cols-[auto,1fr] gap-x-3 gap-y-1 text-xs"
>
<dt class="text-gray-500">
{{ t("admin.plugins.currentVersion") }}
</dt>
<dd class="font-mono text-gray-800 dark:text-gray-200">
{{ plugin.compatibility.current_sub2api_version }}
</dd>
<dt class="text-gray-500">
{{ t("admin.plugins.requiredVersion") }}
</dt>
<dd class="font-mono text-gray-800 dark:text-gray-200">
{{ plugin.compatibility.required_sub2api_version }}
</dd>
<dt class="text-gray-500">
{{ t("admin.plugins.recommendedVersion") }}
</dt>
<dd class="font-mono text-gray-800 dark:text-gray-200">
{{ plugin.compatibility.recommended_sub2api_version || "-" }}
</dd>
</dl>
</div>
<div>
<p class="text-xs font-medium uppercase text-gray-500">
{{ t("admin.plugins.runtime") }}
</p>
<div class="mt-2 flex flex-wrap gap-2 text-xs">
<span
class="rounded px-2 py-0.5"
:class="
plugin.runtime_healthy
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300'
"
>
{{
plugin.runtime_healthy
? t("admin.plugins.healthy")
: t("admin.plugins.unhealthy")
}}
</span>
<span
class="rounded bg-gray-100 px-2 py-0.5 text-gray-600 dark:bg-dark-700 dark:text-gray-300"
>
{{ t("admin.plugins.signature") }}:
{{ t(`admin.plugins.${plugin.signature_status}`) }}
</span>
</div>
<p
v-if="plugin.last_error"
class="mt-3 break-words text-xs text-red-600 dark:text-red-400"
>
{{ plugin.last_error }}
</p>
<p
v-else-if="plugin.runtime_message"
class="mt-3 break-words text-xs text-gray-500"
>
{{ plugin.runtime_message }}
</p>
</div>
<div class="md:col-span-2">
<label
class="flex items-center justify-between gap-4 text-xs font-medium text-gray-600 dark:text-gray-300"
>
<span>{{ t("admin.plugins.rollout") }}</span>
<span class="w-11 text-right font-mono"
>{{
rolloutValues[plugin.id] ?? currentRollout(plugin)
}}%</span
>
</label>
<input
:value="rolloutValues[plugin.id] ?? currentRollout(plugin)"
type="range"
min="1"
max="100"
step="1"
class="mt-2 w-full accent-primary-600"
:disabled="hasEnabledBinding(plugin)"
@input="setRollout(plugin.id, $event)"
/>
</div>
</div>
<div
class="flex flex-wrap justify-end gap-2 border-t border-gray-100 px-5 py-4 dark:border-dark-700"
>
<button
type="button"
class="btn btn-secondary btn-sm"
:disabled="busyID === plugin.id"
@click="testPlugin(plugin)"
>
<Icon name="beaker" size="sm" />
{{ t("admin.plugins.test") }}
</button>
<button
v-if="hasEnabledBinding(plugin)"
type="button"
class="btn btn-secondary btn-sm"
:disabled="busyID === plugin.id"
@click="disablePlugin(plugin)"
>
<Icon name="ban" size="sm" />
{{ t("admin.plugins.disable") }}
</button>
<button
v-else
type="button"
class="btn btn-primary btn-sm"
:disabled="
busyID === plugin.id ||
plugin.state === 'starting' ||
!plugin.compatibility.compatible
"
@click="enablePlugin(plugin)"
>
<Icon name="play" size="sm" />
{{ t("admin.plugins.enable") }}
</button>
<button
type="button"
class="btn btn-danger btn-sm"
:disabled="busyID === plugin.id || hasEnabledBinding(plugin)"
@click="uninstallPlugin(plugin)"
>
<Icon name="trash" size="sm" />
{{ t("admin.plugins.uninstall") }}
</button>
</div>
</article>
</div>
<BaseDialog
:show="configPlugin !== null"
:title="
t('admin.plugins.configTitle', { name: configPlugin?.name || '' })
"
width="full"
@close="closeConfiguration"
>
<div
class="relative min-h-[520px] overflow-hidden bg-gray-50 dark:bg-dark-900"
:style="{ height: `${iframeHeight}px` }"
>
<div
v-if="uiLoading"
class="absolute inset-0 z-10 flex items-center justify-center text-sm text-gray-500"
>
{{ t("admin.plugins.loadingUI") }}
</div>
<div
v-if="uiError"
class="absolute inset-0 z-20 flex flex-col items-center justify-center p-8 text-center"
>
<Icon name="exclamationTriangle" size="xl" class="text-amber-500" />
<p class="mt-3 font-medium text-gray-800 dark:text-gray-200">
{{ t("admin.plugins.uiUnavailable") }}
</p>
<p class="mt-1 max-w-xl text-sm text-gray-500">{{ uiError }}</p>
</div>
<iframe
v-if="uiSession"
ref="pluginFrame"
:src="uiSession.url"
sandbox="allow-scripts"
referrerpolicy="no-referrer"
class="h-full w-full border-0 bg-white dark:bg-dark-900"
:title="
t('admin.plugins.configTitle', { name: configPlugin?.name || '' })
"
@load="uiLoading = false"
/>
</div>
</BaseDialog>
<TotpStepUpDialog :controller="pluginStepUp" />
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import {
adminAPI,
type PluginInstallation,
type PluginUISession,
} from "@/api/admin";
import { useAppStore } from "@/stores";
import AppLayout from "@/components/layout/AppLayout.vue";
import BaseDialog from "@/components/common/BaseDialog.vue";
import Icon from "@/components/icons/Icon.vue";
import TotpStepUpDialog from "@/components/auth/TotpStepUpDialog.vue";
import {
isStepUpBlocked,
isStepUpCancelled,
stepUpBlockReason,
useStepUp,
} from "@/composables/useStepUp";
interface PluginBridgeMessage {
source?: string;
bridge_token?: string;
type?: string;
request_id?: string;
config?: unknown;
height?: unknown;
level?: unknown;
message?: unknown;
}
const { t } = useI18n();
const appStore = useAppStore();
const pluginStepUp = useStepUp();
const plugins = ref<PluginInstallation[]>([]);
const loading = ref(false);
const uploading = ref(false);
const busyID = ref<number | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const rolloutValues = ref<Record<number, number>>({});
const configPlugin = ref<PluginInstallation | null>(null);
const uiSession = ref<PluginUISession | null>(null);
const pluginFrame = ref<HTMLIFrameElement | null>(null);
const uiLoading = ref(false);
const uiError = ref("");
const iframeHeight = ref(640);
function errorMessage(error: unknown): string {
if (typeof error === "object" && error !== null && "message" in error) {
return String(
(error as { message?: unknown }).message || t("common.unknownError"),
);
}
return t("common.unknownError");
}
function reportSensitiveActionError(error: unknown): void {
if (isStepUpCancelled(error)) return;
if (isStepUpBlocked(error)) {
appStore.showError(
stepUpBlockReason(error) === "STEP_UP_ADMIN_API_KEY_FORBIDDEN"
? t("stepUp.adminApiKeyForbidden")
: t("stepUp.notEnabled"),
);
return;
}
appStore.showError(errorMessage(error));
}
async function loadPlugins(): Promise<void> {
loading.value = true;
try {
plugins.value = await adminAPI.plugins.list();
for (const plugin of plugins.value) {
rolloutValues.value[plugin.id] = currentRollout(plugin);
}
} catch (error: unknown) {
appStore.showError(errorMessage(error));
} finally {
loading.value = false;
}
}
async function handleFileSelected(event: Event): Promise<void> {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
target.value = "";
if (!file || !file.name.toLowerCase().endsWith(".s2plugin")) {
appStore.showError(t("admin.plugins.fileRequired"));
return;
}
uploading.value = true;
try {
await pluginStepUp.run(() => adminAPI.plugins.upload(file));
appStore.showSuccess(t("admin.plugins.uploadSuccess"));
await loadPlugins();
} catch (error: unknown) {
reportSensitiveActionError(error);
} finally {
uploading.value = false;
}
}
function currentRollout(plugin: PluginInstallation): number {
return (
plugin.bindings.find(
(binding) => binding.capability === "openai.oauth.outbound_transport.v1",
)?.rollout_percent || 100
);
}
function hasEnabledBinding(plugin: PluginInstallation): boolean {
return plugin.bindings.some((binding) => binding.enabled);
}
function setRollout(id: number, event: Event): void {
const value = Number((event.target as HTMLInputElement).value);
rolloutValues.value[id] = Math.min(100, Math.max(1, value));
}
async function enablePlugin(plugin: PluginInstallation): Promise<void> {
let acceptUntested = false;
if (!plugin.compatibility.tested) {
acceptUntested = window.confirm(t("admin.plugins.confirmUntested"));
if (!acceptUntested) return;
}
busyID.value = plugin.id;
try {
await pluginStepUp.run(() =>
adminAPI.plugins.enable(
plugin.id,
rolloutValues.value[plugin.id] || 100,
acceptUntested,
),
);
appStore.showSuccess(t("admin.plugins.enableSuccess"));
await loadPlugins();
} catch (error: unknown) {
reportSensitiveActionError(error);
} finally {
busyID.value = null;
}
}
async function disablePlugin(plugin: PluginInstallation): Promise<void> {
if (!window.confirm(t("admin.plugins.confirmDisable"))) return;
busyID.value = plugin.id;
try {
await pluginStepUp.run(() => adminAPI.plugins.disable(plugin.id));
appStore.showSuccess(t("admin.plugins.disableSuccess"));
await loadPlugins();
} catch (error: unknown) {
reportSensitiveActionError(error);
} finally {
busyID.value = null;
}
}
async function uninstallPlugin(plugin: PluginInstallation): Promise<void> {
if (!window.confirm(t("admin.plugins.confirmUninstall"))) return;
busyID.value = plugin.id;
try {
await pluginStepUp.run(() => adminAPI.plugins.remove(plugin.id));
appStore.showSuccess(t("admin.plugins.uninstallSuccess"));
await loadPlugins();
} catch (error: unknown) {
reportSensitiveActionError(error);
} finally {
busyID.value = null;
}
}
async function testPlugin(plugin: PluginInstallation): Promise<void> {
busyID.value = plugin.id;
try {
const result = await pluginStepUp.run(() =>
adminAPI.plugins.test(plugin.id),
);
if (result.success)
appStore.showSuccess(result.message || t("admin.plugins.testSuccess"));
else appStore.showError(result.message || t("common.error"));
} catch (error: unknown) {
reportSensitiveActionError(error);
} finally {
busyID.value = null;
}
}
async function openConfiguration(plugin: PluginInstallation): Promise<void> {
configPlugin.value = plugin;
uiSession.value = null;
uiLoading.value = true;
uiError.value = "";
iframeHeight.value = 640;
try {
uiSession.value = await adminAPI.plugins.createUISession(plugin.id);
} catch (error: unknown) {
uiLoading.value = false;
uiError.value = errorMessage(error);
}
}
function closeConfiguration(): void {
configPlugin.value = null;
uiSession.value = null;
uiLoading.value = false;
uiError.value = "";
}
function postBridgeResult(
request: PluginBridgeMessage,
payload: Record<string, unknown>,
): void {
if (!pluginFrame.value?.contentWindow || !uiSession.value) return;
pluginFrame.value.contentWindow.postMessage(
{
source: "sub2api-plugin-host",
bridge_token: uiSession.value.bridge_token,
type: `${request.type}.result`,
request_id: request.request_id,
...payload,
},
"*",
);
}
async function handleBridgeMessage(event: MessageEvent): Promise<void> {
if (
!uiSession.value ||
!configPlugin.value ||
event.source !== pluginFrame.value?.contentWindow
)
return;
const message = event.data as PluginBridgeMessage;
if (
!message ||
message.source !== "sub2api-plugin-ui" ||
message.bridge_token !== uiSession.value.bridge_token
)
return;
try {
switch (message.type) {
case "sub2api.plugin.ready":
uiLoading.value = false;
break;
case "config.load": {
const config = await adminAPI.plugins.getConfig(configPlugin.value.id);
postBridgeResult(message, { ok: true, config });
break;
}
case "config.save": {
if (
!message.config ||
typeof message.config !== "object" ||
Array.isArray(message.config)
) {
throw new Error(t("admin.plugins.bridgeRejected"));
}
const config = await pluginStepUp.run(() =>
adminAPI.plugins.saveConfig(
configPlugin.value!.id,
message.config as Record<string, unknown>,
),
);
postBridgeResult(message, { ok: true, config });
appStore.showSuccess(t("common.saved"));
break;
}
case "config.test": {
const result = await pluginStepUp.run(() =>
adminAPI.plugins.test(configPlugin.value!.id),
);
postBridgeResult(message, { ok: result.success, result });
if (result.success)
appStore.showSuccess(
result.message || t("admin.plugins.testSuccess"),
);
else appStore.showError(result.message || t("common.error"));
break;
}
case "ui.resize": {
const height = Number(message.height);
if (Number.isFinite(height))
iframeHeight.value = Math.min(960, Math.max(520, Math.round(height)));
break;
}
case "ui.notify": {
const text =
typeof message.message === "string"
? message.message.slice(0, 500)
: "";
if (!text) break;
if (message.level === "error") appStore.showError(text);
else if (message.level === "success") appStore.showSuccess(text);
else appStore.showInfo(text);
break;
}
}
} catch (error: unknown) {
if (isStepUpBlocked(error)) reportSensitiveActionError(error);
postBridgeResult(message, {
ok: false,
error: isStepUpCancelled(error) ? t("common.cancel") : errorMessage(error),
});
}
}
function stateClass(state: PluginInstallation["state"]): string {
if (state === "enabled")
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
if (state === "error" || state === "incompatible")
return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300";
if (state === "starting")
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300";
return "bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300";
}
function compatibilityClass(
status: PluginInstallation["compatibility"]["status"],
): string {
if (status === "compatible")
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
if (status === "untested")
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300";
return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300";
}
onMounted(() => {
window.addEventListener("message", handleBridgeMessage);
void loadPlugins();
});
onBeforeUnmount(() => {
window.removeEventListener("message", handleBridgeMessage);
});
</script>
+27
View File
@@ -7188,6 +7188,30 @@
</div>
</div>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.pluginManagement.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.pluginManagement.description') }}
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between gap-4">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.pluginManagement.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.pluginManagement.enabledHint') }}
</p>
</div>
<Toggle v-model="form.plugin_management_enabled" />
</div>
</div>
</div>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
@@ -9752,6 +9776,8 @@ const form = reactive<SettingsForm>({
model_plaza_enabled: false,
model_plaza_require_auth: false,
model_plaza_description: '',
// Plugin management menu visibility; plugin runtime is unaffected.
plugin_management_enabled: false,
// Affiliate (邀请返利) feature switch
affiliate_enabled: false,
// Allow user view error requests
@@ -11408,6 +11434,7 @@ async function saveSettings() {
model_plaza_enabled: form.model_plaza_enabled,
model_plaza_require_auth: form.model_plaza_require_auth,
model_plaza_description: form.model_plaza_description,
plugin_management_enabled: form.plugin_management_enabled,
// Affiliate (邀请返利) feature switch
affiliate_enabled: form.affiliate_enabled,
allow_user_view_error_requests: form.allow_user_view_error_requests,
@@ -0,0 +1,169 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PluginsView from '../PluginsView.vue'
const {
listPlugins,
uploadPlugin,
enablePlugin,
savePluginConfig,
createUISession,
stepUpRun,
} = vi.hoisted(() => ({
listPlugins: vi.fn(),
uploadPlugin: vi.fn(),
enablePlugin: vi.fn(),
savePluginConfig: vi.fn(),
createUISession: vi.fn(),
stepUpRun: vi.fn((action: () => Promise<unknown>) => action()),
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
plugins: {
list: listPlugins,
upload: uploadPlugin,
enable: enablePlugin,
disable: vi.fn(),
remove: vi.fn(),
getConfig: vi.fn().mockResolvedValue({}),
saveConfig: savePluginConfig,
test: vi.fn().mockResolvedValue({ success: true, message: 'ok', latency_ms: 1 }),
createUISession,
},
},
}))
vi.mock('@/stores', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showInfo: vi.fn(),
}),
}))
vi.mock('@/composables/useStepUp', () => ({
useStepUp: () => ({ run: stepUpRun }),
isStepUpBlocked: () => false,
isStepUpCancelled: () => false,
stepUpBlockReason: () => '',
}))
vi.mock('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('vue-i18n')>()),
useI18n: () => ({ t: (key: string) => key }),
}))
const plugin = {
id: 7,
plugin_key: 'local.test.transport',
name: 'Test Transport',
version: '1.0.0',
description: '',
author: 'test',
manifest: {
schema_version: 1,
id: 'local.test.transport',
name: 'Test Transport',
version: '1.0.0',
requires: {
sub2api: '>=0.1.0',
plugin_protocol: 1,
transport_api: 1,
ui_bridge: 1,
},
capabilities: [],
ui: { entrypoint: 'ui/index.html' },
},
binary_sha256: 'a'.repeat(64),
signature_status: 'trusted' as const,
state: 'disabled' as const,
last_error: '',
installed_at: '2026-08-22T00:00:00Z',
updated_at: '2026-08-22T00:00:00Z',
bindings: [
{
id: 1,
plugin_id: 7,
capability: 'openai.oauth.outbound_transport.v1',
platform: 'openai',
account_type: 'oauth',
enabled: false,
rollout_percent: 100,
},
],
compatibility: {
compatible: true,
tested: true,
status: 'compatible' as const,
message: '',
current_sub2api_version: '0.1.0',
required_sub2api_version: '>=0.1.0',
recommended_sub2api_version: '0.1.0',
plugin_protocol: 1,
transport_api: 1,
ui_bridge: 1,
},
runtime_healthy: false,
runtime_message: '',
}
function mountView() {
return mount(PluginsView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
BaseDialog: { template: '<div><slot /></div>' },
Icon: true,
TotpStepUpDialog: true,
},
},
})
}
describe('管理员插件页二次验证', () => {
beforeEach(() => {
vi.clearAllMocks()
stepUpRun.mockImplementation((action: () => Promise<unknown>) => action())
listPlugins.mockResolvedValue([plugin])
uploadPlugin.mockResolvedValue(plugin)
enablePlugin.mockResolvedValue(plugin)
savePluginConfig.mockResolvedValue({ enabled: true })
createUISession.mockResolvedValue({
url: '/api/v1/plugin-ui/token/index.html#bridge_token=bridge',
bridge_token: 'bridge',
ui_bridge_version: 1,
expires_at: '2026-08-22T01:00:00Z',
})
})
it('启用插件通过 step-up 控制器执行', async () => {
const wrapper = mountView()
await flushPromises()
const button = wrapper.findAll('button').find((item) => item.text().includes('admin.plugins.enable'))
expect(button).toBeDefined()
await button!.trigger('click')
await flushPromises()
expect(stepUpRun).toHaveBeenCalledTimes(1)
expect(enablePlugin).toHaveBeenCalledWith(7, 100, false)
})
it('上传插件通过 step-up 控制器执行', async () => {
const wrapper = mountView()
await flushPromises()
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', {
configurable: true,
value: [new File(['plugin'], 'transport.s2plugin', { type: 'application/zip' })],
})
await input.trigger('change')
await flushPromises()
expect(stepUpRun).toHaveBeenCalledTimes(1)
expect(uploadPlugin).toHaveBeenCalledTimes(1)
})
})