个人扫码绑定实现前检查点;补充权限与账户隔离回归用例,业务实机验收尚未执行。
This commit is contained in:
parent
39ac7d2d2c
commit
2a532b2224
@ -43,6 +43,74 @@ func TestConfigCredentialsAreEncryptedAtRest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationSystemConfigurationRequiresSuperAdmin(t *testing.T) {
|
||||
for _, id := range []string{"telegram_push", "wechat_push"} {
|
||||
t.Run(id, func(t *testing.T) {
|
||||
original := PluginInstance{Enabled: true, Settings: json.RawMessage(`{"recipient":"original","enabled":true}`)}
|
||||
current := RuntimeConfig{Plugins: PluginsSectionConfig{Plugins: map[string]PluginInstance{id: original}}}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
modify func(*PluginInstance)
|
||||
}{
|
||||
{"enabled", func(p *PluginInstance) { p.Enabled = false }},
|
||||
{"settings", func(p *PluginInstance) { p.Settings = json.RawMessage(`{"recipient":"changed"}`) }},
|
||||
{"secret", func(p *PluginInstance) { p.Secret.Ciphertext = "changed" }},
|
||||
{"executable", func(p *PluginInstance) { p.Executable = "changed" }},
|
||||
{"capabilities", func(p *PluginInstance) { p.Permissions.Capabilities = []string{"network"} }},
|
||||
{"network", func(p *PluginInstance) {
|
||||
p.Permissions.Network = []PluginNetworkTarget{{Scheme: "https", Host: "example.com", Port: 443}}
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
next := original
|
||||
tc.modify(&next)
|
||||
candidate := RuntimeConfig{Plugins: PluginsSectionConfig{Plugins: map[string]PluginInstance{id: next}}}
|
||||
for _, role := range []string{"super_admin", "admin", "user"} {
|
||||
err := AuthorizePluginChanges(AuditActor{UserID: "1", Role: role}, current, candidate)
|
||||
if (err == nil) != (role == "super_admin") {
|
||||
t.Fatalf("角色=%s,修改=%s,授权结果=%v", role, tc.name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, role := range []string{"super_admin", "admin", "user"} {
|
||||
actor := AuditActor{UserID: "1", Role: role}
|
||||
if err := AuthorizePluginChanges(actor, RuntimeConfig{}, current); (err == nil) != (role == "super_admin") {
|
||||
t.Fatalf("角色=%s,首次配置授权结果=%v", role, err)
|
||||
}
|
||||
if err := AuthorizePluginChanges(actor, current, RuntimeConfig{}); (err == nil) != (role == "super_admin") {
|
||||
t.Fatalf("角色=%s,移除配置授权结果=%v", role, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCanCarryUnchangedNotificationConfiguration(t *testing.T) {
|
||||
for _, id := range []string{"telegram_push", "wechat_push"} {
|
||||
current := RuntimeConfig{Plugins: PluginsSectionConfig{Plugins: map[string]PluginInstance{
|
||||
id: {Enabled: true, Settings: json.RawMessage(`{"recipient":"original","enabled":true}`)},
|
||||
"spam_block": {Settings: json.RawMessage(`{"score":1}`)},
|
||||
}}}
|
||||
candidate := RuntimeConfig{Plugins: PluginsSectionConfig{Plugins: map[string]PluginInstance{
|
||||
id: {Enabled: true, Settings: json.RawMessage(`{ "enabled": true, "recipient": "original" }`)},
|
||||
"spam_block": {Settings: json.RawMessage(`{"score":2}`)},
|
||||
}}}
|
||||
if err := AuthorizePluginChanges(AuditActor{UserID: "2", Role: "admin"}, current, candidate); err != nil {
|
||||
t.Fatalf("修改垃圾邮件参数不应误拦截未变化的渠道 %s:%v", id, err)
|
||||
}
|
||||
if err := AuthorizePluginChanges(AuditActor{UserID: "3", Role: "user"}, current, candidate); err == nil {
|
||||
t.Fatal("普通用户不应修改系统插件配置")
|
||||
}
|
||||
invalid := candidate.Plugins.Plugins[id]
|
||||
invalid.Settings = append(invalid.Settings, []byte(" {}")...)
|
||||
candidate.Plugins.Plugins[id] = invalid
|
||||
if err := AuthorizePluginChanges(AuditActor{UserID: "2", Role: "admin"}, current, candidate); err == nil {
|
||||
t.Fatal("尾随 JSON 不应视为未变化的系统配置")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPrefixCredentialRoundTrip(t *testing.T) {
|
||||
oldRoot := ROOT_PATH
|
||||
root := t.TempDir()
|
||||
|
||||
@ -182,7 +182,7 @@ func DefaultConfigAuthorization(_ context.Context, actor AuditActor, operation C
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthorizePluginChanges 限制管理员只能修改已有插件参数,不允许安装、替换程序或提升权限。
|
||||
// AuthorizePluginChanges 限制管理员只能修改普通插件参数;公众号和机器人始终由超级管理员配置。
|
||||
func AuthorizePluginChanges(actor AuditActor, current, candidate RuntimeConfig) error {
|
||||
if actor.Role == string(access.RoleSuperAdmin) {
|
||||
return nil
|
||||
@ -195,10 +195,35 @@ func AuthorizePluginChanges(actor AuditActor, current, candidate RuntimeConfig)
|
||||
if !ok || next.Executable != previous.Executable || !reflect.DeepEqual(next.Permissions, previous.Permissions) {
|
||||
return ErrAuthorizationRequired
|
||||
}
|
||||
if id == "wechat_push" || id == "telegram_push" {
|
||||
// 允许普通插件保存时原样携带共享配置,但不允许借整个分区绕过权限。
|
||||
if !sameNotificationPlugin(previous, next) {
|
||||
return ErrAuthorizationRequired
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameNotificationPlugin(previous, next PluginInstance) bool {
|
||||
var settings [2]any
|
||||
for index, data := range []json.RawMessage{previous.Settings, next.Settings} {
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
if !json.Valid(data) {
|
||||
return false
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&settings[index]); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
previous.Settings, next.Settings = nil, nil
|
||||
return reflect.DeepEqual(previous, next) && reflect.DeepEqual(settings[0], settings[1])
|
||||
}
|
||||
|
||||
func DefaultConfigConfirmation(_ context.Context, _ AuditActor, confirmation StepUpConfirmation, operation ConfigOperation, _ Section) error {
|
||||
if operation != ConfigOperationRollback && !strings.EqualFold(confirmation.Method, "webauthn") && !strings.EqualFold(confirmation.Method, "recent_auth") {
|
||||
return nil
|
||||
|
||||
@ -25,7 +25,8 @@ func PluginSecret(ctx *appcontext.Context, w http.ResponseWriter, req *http.Requ
|
||||
if req.Method == http.MethodPut {
|
||||
capability = access.CapabilityPluginWriteGlobal
|
||||
}
|
||||
if ctx == nil || ctx.UserID <= 0 {
|
||||
// 共享公众号和机器人密钥不属于分组管理员权限,首次配置和后续维护使用同一边界。
|
||||
if ctx == nil || ctx.UserID <= 0 || ctx.EffectiveRole() != access.RoleSuperAdmin {
|
||||
writeConfigAccessError(w, &access.Denial{Code: access.CodeAccessDenied})
|
||||
return
|
||||
}
|
||||
|
||||
@ -32,13 +32,16 @@ func TestPluginSecretAuthorization(t *testing.T) {
|
||||
{"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"},
|
||||
{"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) {
|
||||
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())
|
||||
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())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -46,10 +49,18 @@ func TestPluginSecretAuthorization(t *testing.T) {
|
||||
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())
|
||||
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", "")
|
||||
@ -63,7 +74,7 @@ func TestPluginSecretEncryptedLifecycleAndValidation(t *testing.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}
|
||||
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{
|
||||
|
||||
@ -91,6 +91,36 @@ func TestConfigServiceAllowsEmptyTelegramChatIDToDisablePush(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryRoleKeepsIndependentOwnTelegramSettings(t *testing.T) {
|
||||
service, engine := newPluginConfigService(t)
|
||||
var users []models.User
|
||||
for _, role := range []access.Role{access.RoleSuperAdmin, access.RoleAdmin, access.RoleUser} {
|
||||
user := insertPluginTestUser(t, engine, "own-"+string(role), role)
|
||||
users = append(users, user)
|
||||
status, err := service.GetOwnTelegramSettings(user.ID)
|
||||
if err != nil || status.Settings.ChatID != "" || status.Version != 0 {
|
||||
t.Fatalf("角色=%s,不应自动绑定系统接收身份:%v", role, err)
|
||||
}
|
||||
}
|
||||
chatIDs := []string{"10101", "20202", "30303"}
|
||||
for index, user := range users {
|
||||
status, err := service.UpdateOwnTelegramSettings(user.ID, TelegramUserSettings{ChatID: chatIDs[index]}, 0)
|
||||
if err != nil || status.Settings.ChatID != chatIDs[index] || status.Version != 1 {
|
||||
t.Fatalf("角色=%s,本人通知设置保存失败:%v", user.EffectiveRole(), err)
|
||||
}
|
||||
}
|
||||
if _, err := service.UpdateOwnTelegramSettings(users[0].ID, TelegramUserSettings{ChatID: "40404"}, 1); err != nil {
|
||||
t.Fatalf("超级管理员不能修改本人的通知设置:%v", err)
|
||||
}
|
||||
chatIDs[0] = "40404"
|
||||
for index, user := range users {
|
||||
status, err := service.GetOwnTelegramSettings(user.ID)
|
||||
if err != nil || status.Settings.ChatID != chatIDs[index] {
|
||||
t.Fatalf("角色=%s,个人通知设置发生跨账户串用:%v", user.EffectiveRole(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigServiceRejectsTrailingTelegramSettingsJSON(t *testing.T) {
|
||||
service, engine := newPluginConfigService(t)
|
||||
user := insertPluginTestUser(t, engine, "trailing-json", access.RoleUser)
|
||||
|
||||
@ -151,7 +151,10 @@ export function usePage(pageKey = 'base') {
|
||||
const hasChanges = computed(() => editorText.value !== loadedText.value || !!secretValue.value || !!savedGeneration.value)
|
||||
const hasConfigChanges = computed(() => editorText.value !== loadedText.value || (!pluginSecretPage.value && !!secretValue.value) || !!savedGeneration.value)
|
||||
|
||||
const canWrite = computed(() => globalStatus.hasCapability(apiSection() === 'plugins' ? 'plugin.write_global' : 'config.write_global'))
|
||||
const canWrite = computed(() => {
|
||||
if (pluginSecretPage.value && globalStatus.role !== 'super_admin') return false
|
||||
return globalStatus.hasCapability(apiSection() === 'plugins' ? 'plugin.write_global' : 'config.write_global')
|
||||
})
|
||||
const canRollback = computed(() => globalStatus.hasCapability('config.rollback'))
|
||||
const canMigrate = computed(() => globalStatus.hasCapability('database.migrate'))
|
||||
const canRestart = computed(() => globalStatus.hasCapability('service.restart'))
|
||||
|
||||
@ -16,8 +16,10 @@ export function usePage() {
|
||||
// 全局配置不依赖正在运行的插件列表,未启用的插件也必须可以配置。
|
||||
const sectionOptions = computed(() => store.hasCapability('plugin.read_global') ? [
|
||||
{ value: 'spam_block', label: '垃圾邮件拦截', kind: 'config' },
|
||||
{ value: 'telegram_push', label: 'Telegram 推送', kind: 'config' },
|
||||
{ value: 'wechat_push', label: '微信推送', kind: 'config' }
|
||||
...(store.role === 'super_admin' ? [
|
||||
{ value: 'telegram_push', label: '系统 Telegram 推送', kind: 'config' },
|
||||
{ value: 'wechat_push', label: '系统微信推送', kind: 'config' }
|
||||
] : [])
|
||||
] : [])
|
||||
const accountOptions = computed(() => store.hasCapability('plugin.read') && store.hasCapability('plugin.write_own')
|
||||
? [{ value: 'telegram-user', label: 'Telegram 通知', kind: 'account' }]
|
||||
|
||||
@ -55,10 +55,19 @@ function fixture(get, pageKey = 'base', options = {}) {
|
||||
get: path => path.endsWith('/pending') && !options.pendingGet ? Promise.resolve({ errorNo: 0, data: null }) : get(path),
|
||||
...options.http
|
||||
},
|
||||
() => ({ hasCapability: options.hasCapability || (() => true) }), pageKey, callback => { dispose = callback })
|
||||
() => ({ role: options.role || 'super_admin', hasCapability: options.hasCapability || (() => true) }), pageKey, callback => { dispose = callback })
|
||||
return { page, messages, successes, dispose: () => dispose() }
|
||||
}
|
||||
|
||||
for (const pageKey of ['telegram_push', 'wechat_push']) {
|
||||
for (const role of ['super_admin', 'admin', 'user']) {
|
||||
test(`${role} 的 ${pageKey} 系统配置写权限只对超级管理员开放`, () => {
|
||||
const { page } = fixture(async () => ({ errorNo: 0, data: {} }), pageKey, { role })
|
||||
assert.equal(page.canWrite.value, role === 'super_admin')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve
|
||||
let reject
|
||||
|
||||
@ -11,16 +11,44 @@ const createPage = compileFunction(
|
||||
['computed', 'nextTick', 'onMounted', 'onBeforeUnmount', 'ref', 'http', 'useGlobalStatusStore']
|
||||
)
|
||||
|
||||
function fixture(capabilities = ['config.read', 'plugin.read_global', 'plugin.read', 'plugin.write_own'], get = async () => ({ errorNo: 0, data: [] })) {
|
||||
function fixture(capabilities = ['config.read', 'plugin.read_global', 'plugin.read', 'plugin.write_own'], get = async () => ({ errorNo: 0, data: [] }), role = 'super_admin') {
|
||||
const allowed = reactive(new Set(capabilities))
|
||||
const store = reactive({ role, hasCapability: value => allowed.has(value) })
|
||||
const requests = []
|
||||
let mount, dispose
|
||||
const page = createPage(computed, async () => {}, fn => { mount = fn }, fn => { dispose = fn }, ref,
|
||||
{ get: async url => { requests.push(url); return get(url) } },
|
||||
() => ({ hasCapability: value => allowed.has(value) }))
|
||||
return { page, allowed, requests, mount, dispose }
|
||||
() => store)
|
||||
return { page, allowed, requests, mount, dispose, store }
|
||||
}
|
||||
|
||||
for (const role of ['super_admin', 'admin', 'user']) {
|
||||
test(`${role} 保留本人通知入口,只有超级管理员可进入系统公众号和机器人配置`, async () => {
|
||||
const capabilities = role === 'user'
|
||||
? ['plugin.read', 'plugin.write_own']
|
||||
: ['plugin.read_global', 'plugin.read', 'plugin.write_own']
|
||||
const { page } = fixture(capabilities, undefined, role)
|
||||
assert.deepEqual(page.sectionOptions.value.map(item => item.value), role === 'super_admin'
|
||||
? ['spam_block', 'telegram_push', 'wechat_push']
|
||||
: role === 'admin' ? ['spam_block'] : [])
|
||||
assert.deepEqual(page.accountOptions.value.map(item => item.value), ['telegram-user'])
|
||||
await page.selectSection('telegram-user')
|
||||
assert.equal(page.activePage.value.kind, 'account')
|
||||
await page.selectSection('wechat_push')
|
||||
assert.equal(page.selectedSection.value, role === 'super_admin' ? 'wechat_push' : 'telegram-user')
|
||||
})
|
||||
}
|
||||
|
||||
test('超级管理员降级后隐藏系统通知配置但保留本人通知入口', async () => {
|
||||
const { page, store } = fixture()
|
||||
await page.selectSection('telegram_push')
|
||||
store.role = 'admin'
|
||||
assert.equal(page.activePage.value, undefined)
|
||||
assert.deepEqual(page.sectionOptions.value.map(item => item.value), ['spam_block'])
|
||||
await page.selectSection('telegram-user')
|
||||
assert.equal(page.activePage.value.kind, 'account')
|
||||
})
|
||||
|
||||
test('独立插件配置包含三个运行配置页,停用插件不依赖运行列表也能配置', async () => {
|
||||
const { page, mount } = fixture()
|
||||
assert.deepEqual(page.sectionOptions.value.map(item => item.value), ['spam_block', 'telegram_push', 'wechat_push'])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user