Smail/backend/controllers/plugin_secret_test.go
smail 2a532b2224
Some checks are pending
Security Checks / security (push) Waiting to run
隔离系统通知配置权限并保留各角色个人设置
个人扫码绑定实现前检查点;补充权限与账户隔离回归用例,业务实机验收尚未执行。
2026-09-28 20:57:56 +08:00

136 lines
6.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"},
{"authenticated administrator", &appcontext.Context{UserID: 2, Role: access.RoleAdmin, RecentAuth: true, RecoveryCodeConfigured: true}, "ACCESS_DENIED"},
{"missing recent authentication", &appcontext.Context{UserID: 1, Role: access.RoleSuperAdmin, RecoveryCodeConfigured: true}, "RECENT_AUTH_REQUIRED"},
{"administrator without recovery code", &appcontext.Context{UserID: 2, Role: access.RoleAdmin, RecentAuth: true}, "ACCESS_DENIED"},
} {
t.Run(tc.name, func(t *testing.T) {
for _, id := range []string{"telegram_push", "wechat_push"} {
recorder := callPluginSecret(tc.ctx, http.MethodPut, id, `{"version":0,"change":{"operation":"replace","value":{"plaintext":"test-only-secret"}}}`)
if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), tc.code) {
t.Fatalf("渠道=%s,状态=%d,响应=%s", id, 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 _, id := range []string{"telegram_push", "wechat_push"} {
for _, role := range []access.Role{access.RoleUser, access.RoleAdmin, access.RoleSuperAdmin} {
recorder := callPluginSecret(&appcontext.Context{UserID: 2, Role: role}, http.MethodGet, id, "")
if role != access.RoleSuperAdmin {
if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "ACCESS_DENIED") {
t.Fatalf("角色=%s 不应读取系统渠道 %s 的密钥状态", role, id)
}
continue
}
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"configured":false`) {
t.Fatalf("超级管理员读取渠道=%s,状态=%d", id, recorder.Code)
}
}
}
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: 1, Role: access.RoleSuperAdmin, 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)
}
}