From 54396abeb71c1257a2d0604e19c5f0bf28b98b5c Mon Sep 17 00:00:00 2001 From: smail Date: Sun, 20 Sep 2026 08:12:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=8F=92=E4=BB=B6=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E5=AF=86=E9=92=A5=E4=BF=9D=E5=AD=98=E6=9D=83=E9=99=90?= =?UTF-8?q?=E4=B8=8E=E5=8A=A0=E5=AF=86=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/plugin_secret_test.go | 124 ++++++++++++++++++ backend/listen/http_server/operation_log.go | 2 + .../ConfigSettings/ConfigSettings.js | 5 +- test/backend/operation_log_test.go | 10 +- test/frontend/config-settings.test.mjs | 108 +++++++++++---- 5 files changed, 222 insertions(+), 27 deletions(-) create mode 100644 backend/controllers/plugin_secret_test.go diff --git a/backend/controllers/plugin_secret_test.go b/backend/controllers/plugin_secret_test.go new file mode 100644 index 0000000..5c99541 --- /dev/null +++ b/backend/controllers/plugin_secret_test.go @@ -0,0 +1,124 @@ +package controllers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.pcux.cn/xuans/Smail/hooks" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" + appcontext "git.pcux.cn/xuans/Smail/utils/context" +) + +func callPluginSecret(ctx *appcontext.Context, method, pluginID, body string) *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + PluginSecret(ctx, recorder, httptest.NewRequest(method, "/api/v1/plugin/secrets/"+pluginID, strings.NewReader(body))) + return recorder +} + +func TestPluginSecretAuthorization(t *testing.T) { + service, engine := newTelegramControllerConfigService(t) + previous := hooks.PluginConfigService() + hooks.SetPluginConfigService(service) + t.Cleanup(func() { hooks.SetPluginConfigService(previous) }) + for _, tc := range []struct { + name string + ctx *appcontext.Context + code string + }{ + {"anonymous", nil, "ACCESS_DENIED"}, + {"no session user", &appcontext.Context{Role: access.RoleSuperAdmin, RecentAuth: true, RecoveryCodeConfigured: true}, "ACCESS_DENIED"}, + {"ordinary user", &appcontext.Context{UserID: 3, Role: access.RoleUser, RecentAuth: true, RecoveryCodeConfigured: true}, "ACCESS_DENIED"}, + {"missing recent authentication", &appcontext.Context{UserID: 2, Role: access.RoleAdmin, RecoveryCodeConfigured: true}, "RECENT_AUTH_REQUIRED"}, + {"missing recovery code", &appcontext.Context{UserID: 2, Role: access.RoleAdmin, RecentAuth: true}, "RECOVERY_CODE_REQUIRED"}, + } { + t.Run(tc.name, func(t *testing.T) { + recorder := callPluginSecret(tc.ctx, http.MethodPut, "telegram_push", `{"version":0,"change":{"operation":"replace","value":{"plaintext":"test-only-secret"}}}`) + if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), tc.code) { + t.Fatalf("status=%d response=%s", recorder.Code, recorder.Body.String()) + } + }) + } + count, err := engine.Count(new(models.PluginSecret)) + if err != nil || count != 0 { + t.Fatalf("denied writes changed secrets: count=%d err=%v", count, err) + } + for _, role := range []access.Role{access.RoleAdmin, access.RoleSuperAdmin} { + recorder := callPluginSecret(&appcontext.Context{UserID: 2, Role: role}, http.MethodGet, "telegram_push", "") + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"configured":false`) { + t.Fatalf("read role=%s status=%d body=%s", role, recorder.Code, recorder.Body.String()) + } + } + recorder := callPluginSecret(&appcontext.Context{UserID: 3, Role: access.RoleUser}, http.MethodGet, "telegram_push", "") + if recorder.Code != http.StatusForbidden { + t.Fatalf("ordinary user read status=%d", recorder.Code) + } +} + +func TestPluginSecretEncryptedLifecycleAndValidation(t *testing.T) { + service, engine := newTelegramControllerConfigService(t) + previous := hooks.PluginConfigService() + hooks.SetPluginConfigService(service) + t.Cleanup(func() { hooks.SetPluginConfigService(previous) }) + ctx := &appcontext.Context{UserID: 2, Role: access.RoleAdmin, RecentAuth: true, RecoveryCodeConfigured: true} + for _, id := range []string{"telegram_push", "wechat_push"} { + t.Run(id, func(t *testing.T) { + for _, body := range []string{ + `{"version":0,"change":{"operation":"replace","value":{"plaintext":"test-only-secret"}},"userId":99}`, + `{"version":0,"change":{"operation":"replace","value":{"plaintext":"test-only-secret","extra":true}}}`, + `{"version":0,"change":{"operation":"replace","value":{"plaintext":"test-only-secret"}}} {}`, + `{"version":0,"change":{"operation":"replace","value":{"plaintext":""}}}`, + `{"version":0,"change":{"operation":"replace","value":{"plaintext":"` + strings.Repeat("x", 17000) + `"}}}`, + } { + recorder := callPluginSecret(ctx, http.MethodPut, id, body) + if strings.Contains(recorder.Body.String(), `"errorNo":0`) { + t.Fatal("invalid request accepted") + } + } + body := `{"version":0,"change":{"operation":"replace","value":{"plaintext":"test-only-secret"}}}` + recorder := callPluginSecret(ctx, http.MethodPut, id, body) + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"version":1`) { + t.Fatalf("save status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if recorder.Header().Get("Cache-Control") != "no-store" || strings.Contains(recorder.Body.String(), "test-only-secret") { + t.Fatal("unsafe secret response") + } + record := models.PluginSecret{} + found, err := engine.Where("plugin_id = ?", id).Get(&record) + if err != nil || !found || record.Ciphertext == "" || record.Ciphertext == "test-only-secret" || record.KeyVersion == 0 { + t.Fatalf("secret was not encrypted: found=%v err=%v", found, err) + } + recorder = callPluginSecret(ctx, http.MethodGet, id, "") + var payload struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil || len(payload.Data) != 4 || payload.Data["configured"] != true || payload.Data["version"] != float64(1) { + t.Fatalf("unexpected public status: %s", recorder.Body.String()) + } + if strings.Contains(recorder.Body.String(), record.Ciphertext) || strings.Contains(recorder.Body.String(), "test-only-secret") { + t.Fatal("secret leaked in status") + } + recorder = callPluginSecret(ctx, http.MethodPut, id, body) + if recorder.Code != http.StatusConflict { + t.Fatalf("stale version accepted: %s", recorder.Body.String()) + } + recorder = callPluginSecret(ctx, http.MethodPut, id, `{"version":1,"change":{"operation":"clear"}}`) + if strings.Contains(recorder.Body.String(), `"errorNo":0`) { + t.Fatal("unconfirmed clear accepted") + } + recorder = callPluginSecret(ctx, http.MethodPut, id, `{"version":1,"change":{"operation":"clear","confirmed":true}}`) + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"configured":false`) { + t.Fatalf("clear failed: %s", recorder.Body.String()) + } + }) + } + if recorder := callPluginSecret(ctx, http.MethodDelete, "telegram_push", ""); recorder.Code != http.StatusMethodNotAllowed { + t.Fatalf("unsupported method status=%d", recorder.Code) + } + if recorder := callPluginSecret(ctx, http.MethodPut, "unknown", `{}`); recorder.Code != http.StatusNotFound { + t.Fatalf("unknown plugin status=%d", recorder.Code) + } +} diff --git a/backend/listen/http_server/operation_log.go b/backend/listen/http_server/operation_log.go index b91389a..861c63a 100644 --- a/backend/listen/http_server/operation_log.go +++ b/backend/listen/http_server/operation_log.go @@ -76,6 +76,8 @@ var loggedOperations = map[string]string{ "/api/setup": "执行系统初始化配置", "/api/plugin/settings/telegram_push/user": "更新电报通知设置", "/api/plugin/settings/telegram_push/test": "测试电报消息推送", + "/api/plugin/secrets/telegram_push": "更新电报插件密钥", + "/api/plugin/secrets/wechat_push": "更新微信插件密钥", } // readonlyPostEndpoints 明确排除属于查询性质的 POST 接口,防止误记操作记录。 diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.js b/frontend/src/views/components/ConfigSettings/ConfigSettings.js index eeb909c..9c68941 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.js +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.js @@ -699,7 +699,7 @@ export function usePage(pageKey = 'base') { } const savePluginSecret = async () => { - if (!canEdit() || !pluginSecretPage.value || !pluginSecretStatus.value || !secretValue.value) return false + if (saveFlow.value || !canEdit() || !pluginSecretPage.value || !pluginSecretStatus.value || !secretValue.value) return false working.value = 'plugin-secret' saveError.value = '' try { @@ -707,6 +707,9 @@ export function usePage(pageKey = 'base') { const response = await requestWithRecentAuthentication(() => http.put(`/api/v1/plugin/secrets/${selectedSection.value}`, request)) notifyResponseError(response, '插件密钥保存失败') if (disposed) return false + if (typeof response.data?.configured !== 'boolean' || !Number.isSafeInteger(response.data.version) || response.data.version < 0) { + throw new Error('插件密钥保存结果不完整,请刷新后核对') + } secretValue.value = '' pluginSecretStatus.value = response.data ElMessage.success('密钥已保存,下次启动插件时生效') diff --git a/test/backend/operation_log_test.go b/test/backend/operation_log_test.go index d3755b1..c780697 100644 --- a/test/backend/operation_log_test.go +++ b/test/backend/operation_log_test.go @@ -53,15 +53,19 @@ func TestOperationLogUsesCompletedIdentityWithoutSensitivePayload(t *testing.T) } runtime := logstore.Start(store) defer runtime.Stop() - for _, path := range []string{"/api/v1/login", "/api/v1/security/login/verify", "/api/v1/security/webauthn/login/finish"} { + for _, path := range []string{"/api/v1/login", "/api/v1/security/login/verify", "/api/v1/security/webauthn/login/finish", "/api/v1/plugin/secrets/telegram_push", "/api/v1/plugin/secrets/wechat_push"} { ctx := &appcontext.Context{} - writer, finish := operationLog(ctx, httptest.NewRequest("POST", path, nil), httptest.NewRecorder()) + method := http.MethodPost + if strings.Contains(path, "/plugin/secrets/") { + method = http.MethodPut + } + writer, finish := operationLog(ctx, httptest.NewRequest(method, path, strings.NewReader(`{"plaintext":"must-not-store"}`)), httptest.NewRecorder()) ctx.UserID, ctx.UserAccount, ctx.Role = 42, "audit-probe", access.RoleSuperAdmin response.NewSuccessResponse(map[string]string{"token": "must-not-store"}).FPrint(writer) finish() } entries, err := store.Query(context.Background(), logstore.Query{Kind: "operation", ActorID: "42"}) - if err != nil || len(entries) != 3 { + if err != nil || len(entries) != 5 { t.Fatalf("missing completed login records: %d, %v", len(entries), err) } for _, entry := range entries { diff --git a/test/frontend/config-settings.test.mjs b/test/frontend/config-settings.test.mjs index 7fe3847..486df38 100644 --- a/test/frontend/config-settings.test.mjs +++ b/test/frontend/config-settings.test.mjs @@ -1149,28 +1149,26 @@ test('插件读取状态字段不传回严格解析接口,未展示字段和 for (const key of ['telegram_push', 'wechat_push']) { test(`${key} 密钥独立提交、留空保留,保存回读与离开后清空`, async () => { - let active = { meta: { generation: 1 }, plugins: { + const active = { meta: { generation: 1 }, plugins: { telegram_push: { enabled: false, settings: { timeout: 30 }, secretStatus: { configured: false } }, wechat_push: { enabled: false, settings: { appId: 'test-only', future: 'preserved' }, secretStatus: { configured: false } } } } const original = structuredClone(active) - let pending = null + let secretStatus = { configured: false, version: 0 } const requests = [] const http = { get: async path => ({ errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: active.meta.generation } - : path.endsWith('/pending') ? pending : active }), + : path.endsWith('/pending') ? null : path.includes('/plugin/secrets/') ? secretStatus : active }), put: async (path, body) => { requests.push({ path, body }) - pending = { generation: 2, section: 'plugins', sameSession: true, candidateId: 'secret-test', payload: body.payload } - return { errorNo: 0, data: pending } + assert.equal(path, `/api/v1/plugin/secrets/${key}`) + assert.deepEqual(body, { version: 0, change: { operation: 'replace', value: { plaintext: ' test-only-plugin-secret ' } } }) + secretStatus = { configured: true, version: 1 } + return { errorNo: 0, data: secretStatus } }, post: async (path, body) => { requests.push({ path, body }) - if (path.endsWith('/apply')) { - active = { ...pending.payload, meta: { generation: 2 } } - active.plugins[key].secretStatus = { configured: true } - pending = null - } + assert.equal(path, '/api/v1/config/plugins/validate') return { errorNo: 0, data: { valid: true } } } } @@ -1181,25 +1179,22 @@ for (const key of ['telegram_push', 'wechat_push']) { assert.deepEqual(requests.at(-1).body.secretChanges, {}) page.updateSecret(' test-only-plugin-secret ') assert.equal(page.hasChanges.value, true) + assert.equal(page.hasConfigChanges.value, false) assert.equal(page.editorText.value.includes('test-only-plugin-secret'), false) await page.validateConfig() assert.equal(requests.at(-1).path, '/api/v1/config/plugins/validate') - assert.deepEqual(requests.at(-1).body.secretChanges, { - [`plugins.${key}.secret`]: { operation: 'replace', value: { plaintext: ' test-only-plugin-secret ' } } - }) + assert.deepEqual(requests.at(-1).body.secretChanges, {}) for (const id of Object.keys(original.plugins)) { assert.deepEqual(requests.at(-1).body.payload.plugins[id].settings, original.plugins[id].settings) assert.equal(requests.at(-1).body.payload.plugins[id].secretStatus, undefined) assert.equal(requests.at(-1).body.payload.plugins[id].enabled, false) } - assert.equal(await page.saveConfig(), true) - assert.equal(page.secretValue.value, '') - const saved = requests.find(item => item.path === '/api/v1/config/plugins') - assert.equal(saved.body.secretChanges[`plugins.${key}.secret`].value.plaintext, ' test-only-plugin-secret ') - page.updateSecret('blocked-pending-edit') - assert.equal(page.secretValue.value, '') - assert.equal(await page.applyConfig(), true) + assert.equal(await page.saveAndApply(), false) + assert.equal(await page.savePluginSecret(), true) assert.equal(page.secretValue.value, '') + assert.equal(page.hasChanges.value, false) + assert.equal(page.pendingCandidate.value, null) + assert.deepEqual(active, original) assert.match(page.secretField.value.description, /^已设置/) await page.validateConfig() assert.deepEqual(requests.at(-1).body.secretChanges, {}) @@ -1211,12 +1206,78 @@ for (const key of ['telegram_push', 'wechat_push']) { await readonly.loadAll() const previousRequests = requests.length readonly.updateSecret('forbidden') - await readonly.saveConfig() + await readonly.savePluginSecret() assert.equal(readonly.secretValue.value, '') assert.equal(requests.length, previousRequests) }) } +test('插件密钥状态读取失败禁止保存,冲突后保留输入并显示错误', async () => { + let available = false + let calls = 0 + const { page } = fixture(async path => { + if (path.includes('/plugin/secrets/')) { + if (!available) throw new Error('状态读取失败') + return { errorNo: 0, data: { configured: true, version: 3 } } + } + return { errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: 1 } : { plugins: { telegram_push: { enabled: false, settings: {} } } } } + }, 'telegram_push', { http: { put: async () => { + calls++ + return { errorNo: 100, errorMsg: '配置已被其他操作更新,请刷新后重试' } + } } }) + await page.loadAll() + page.updateSecret('blocked') + assert.equal(await page.savePluginSecret(), false) + assert.equal(calls, 0) + assert.equal(page.secretValue.value, '') + available = true + await page.loadAll() + page.updateSecret('test-only-secret') + assert.equal(await page.savePluginSecret(), false) + assert.equal(calls, 1) + assert.equal(page.secretValue.value, 'test-only-secret') + assert.equal(page.pluginSecretStatus.value.version, 3) + assert.match(page.saveError.value, /配置已被其他操作更新/) + assert.equal(page.busy.value, false) +}) + +test('插件密钥保存近期认证后重试一次,并阻止并发保存与离页', async () => { + const waiting = deferred() + let writes = 0 + let reauth = 0 + const { page } = fixture(async path => ({ errorNo: 0, data: path.includes('/plugin/secrets/') + ? { configured: false, version: 0 } + : path.endsWith('/status') ? { activeGeneration: 1 } : { plugins: { telegram_push: { enabled: false, settings: {} } } } }), 'telegram_push', { + prompt: () => waiting.promise, + http: { + put: async () => { + writes++ + return writes === 1 ? { errorNo: 405, data: { code: 'RECENT_AUTH_REQUIRED' } } + : { errorNo: 0, data: { configured: true, version: 1 } } + }, + post: async path => { + assert.equal(path, '/api/v1/security/reauth') + reauth++ + return { errorNo: 0 } + } + } + }) + await page.loadAll() + page.updateSecret('test-only-secret') + const save = page.savePluginSecret() + assert.equal(page.busy.value, true) + assert.equal(await page.savePluginSecret(), false) + assert.equal(await page.beforeLeave(), false) + page.updateSecret('concurrent-edit') + assert.equal(page.secretValue.value, 'test-only-secret') + waiting.resolve({ value: 'test-only-password' }) + assert.equal(await save, true) + assert.equal(writes, 2) + assert.equal(reauth, 1) + assert.equal(page.secretValue.value, '') + assert.equal(page.busy.value, false) +}) + test('SQLite 不显示或发送密码,网络数据库和中继密码留空保留且不裁剪空格', async () => { for (const type of ['sqlite', 'mysql', 'postgresql']) { let request @@ -1329,7 +1390,7 @@ for (const key of ['spam_block', 'telegram_push', 'wechat_push']) { get: async path => { requests.push(path) return { errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: active.meta.generation } - : path.endsWith('/pending') ? pending : active } + : path.endsWith('/pending') ? pending : path.includes('/plugin/secrets/') ? { configured: false, version: 0 } : active } }, put: async (path, body) => { assert.equal(path, '/api/v1/config/plugins') @@ -1358,7 +1419,8 @@ for (const key of ['spam_block', 'telegram_push', 'wechat_push']) { } const { page, messages, dispose } = fixture(() => {}, key, { http }) await page.loadAll() - assert.deepEqual(requests, ['/api/v1/config/status', '/api/v1/config/plugins', '/api/v1/config/pending']) + assert.deepEqual(requests, ['/api/v1/config/status', '/api/v1/config/plugins', + ...(key === 'spam_block' ? [] : [`/api/v1/plugin/secrets/${key}`]), '/api/v1/config/pending']) assert.equal(page.selectedSection.value, key) page.updateField(['plugins', key, 'enabled'], true) await page.validateConfig()