|
| 1 | +package bot_api |
| 2 | + |
| 3 | +import ( |
| 4 | + "encoding/json" |
| 5 | + "net/http" |
| 6 | + "strings" |
| 7 | + "testing" |
| 8 | + |
| 9 | + "github.com/Mininglamp-OSS/octo-lib/pkg/util" |
| 10 | + "github.com/Mininglamp-OSS/octo-lib/testutil" |
| 11 | + "github.com/stretchr/testify/assert" |
| 12 | + "github.com/stretchr/testify/require" |
| 13 | +) |
| 14 | + |
| 15 | +// PR#355 review 守卫:bot_admin 与人类管理员同权,POST |
| 16 | +// /v1/bot/groups/:group_no/members/remove 不得移除群主/管理员。#354 把 |
| 17 | +// service 层 RemoveGroupMembers 的 manager 豁免下沉移除后,目标角色校验由 |
| 18 | +// 调用方负责——Web API memberRemove 有 creator/manager 守卫,这里验证 Bot |
| 19 | +// API 路径的对等守卫: |
| 20 | +// - 目标含 manager → 403 cannot_remove_privileged,且整个请求拒绝(混合 |
| 21 | +// 列表里的普通成员也不能被"顺带"移除); |
| 22 | +// - 目标含 creator → 403 cannot_remove_privileged; |
| 23 | +// - 目标全为普通成员 → 正常移除(守卫不误伤)。 |
| 24 | + |
| 25 | +const ( |
| 26 | + rmGuardGroupNo = "g_rm_guard_1" |
| 27 | + rmGuardBotID = "bot_rm_guard" |
| 28 | + rmGuardBotToken = "bf_rm_guard_token" |
| 29 | + rmGuardCreator = "u_rm_creator" |
| 30 | + rmGuardManager = "u_rm_manager" |
| 31 | + rmGuardCommon = "u_rm_common" |
| 32 | +) |
| 33 | + |
| 34 | +func setupRemoveGuardEnv(t *testing.T) http.Handler { |
| 35 | + t.Helper() |
| 36 | + s, ctx := testutil.NewTestServer() |
| 37 | + require.NoError(t, testutil.CleanAllTables(ctx)) |
| 38 | + |
| 39 | + _, err := ctx.DB().InsertBySql( |
| 40 | + "INSERT INTO robot (robot_id, status, creator_uid, bot_token) VALUES (?, 1, ?, ?)", |
| 41 | + rmGuardBotID, rmGuardCreator, rmGuardBotToken).Exec() |
| 42 | + require.NoError(t, err) |
| 43 | + |
| 44 | + _, err = ctx.DB().InsertBySql( |
| 45 | + "INSERT INTO `group` (group_no, name, status, version) VALUES (?, ?, 1, 1)", |
| 46 | + rmGuardGroupNo, "remove guard group").Exec() |
| 47 | + require.NoError(t, err) |
| 48 | + |
| 49 | + // creator(role=1) / manager(role=2) / 普通成员(role=0) / bot 管理员(bot_admin=1) |
| 50 | + for _, m := range []struct { |
| 51 | + uid string |
| 52 | + role, robot, adm int |
| 53 | + }{ |
| 54 | + {rmGuardCreator, 1, 0, 0}, |
| 55 | + {rmGuardManager, 2, 0, 0}, |
| 56 | + {rmGuardCommon, 0, 0, 0}, |
| 57 | + {rmGuardBotID, 0, 1, 1}, |
| 58 | + } { |
| 59 | + _, err = ctx.DB().InsertBySql( |
| 60 | + "INSERT INTO group_member (group_no, uid, role, robot, bot_admin, vercode, is_deleted, status, version) VALUES (?, ?, ?, ?, ?, ?, 0, 1, 1)", |
| 61 | + rmGuardGroupNo, m.uid, m.role, m.robot, m.adm, util.GenerUUID()).Exec() |
| 62 | + require.NoError(t, err) |
| 63 | + } |
| 64 | + |
| 65 | + return s.GetRoute() |
| 66 | +} |
| 67 | + |
| 68 | +func rmGuardIsActiveMember(t *testing.T, handler http.Handler, uid string) bool { |
| 69 | + t.Helper() |
| 70 | + w := doBot(handler, botReq(t, "GET", "/v1/bot/groups/"+rmGuardGroupNo+"/members", rmGuardBotToken, nil)) |
| 71 | + require.Equalf(t, http.StatusOK, w.Code, "list members body: %s", w.Body.String()) |
| 72 | + // GET /members 返回成员数组;uid 可能出现在响应的其他字段里,所以逐项 |
| 73 | + // 比对而不是对响应体做字符串包含判断。 |
| 74 | + var members []struct { |
| 75 | + UID string `json:"uid"` |
| 76 | + } |
| 77 | + require.NoErrorf(t, json.Unmarshal(w.Body.Bytes(), &members), "list members body: %s", w.Body.String()) |
| 78 | + for _, m := range members { |
| 79 | + if m.UID == uid { |
| 80 | + return true |
| 81 | + } |
| 82 | + } |
| 83 | + return false |
| 84 | +} |
| 85 | + |
| 86 | +func TestBotGroupMemberRemove_ManagerTargetForbidden(t *testing.T) { |
| 87 | + handler := setupRemoveGuardEnv(t) |
| 88 | + |
| 89 | + w := doBot(handler, botReq(t, "POST", "/v1/bot/groups/"+rmGuardGroupNo+"/members/remove", rmGuardBotToken, |
| 90 | + map[string]interface{}{"members": []string{rmGuardManager}})) |
| 91 | + assert.Equalf(t, http.StatusForbidden, w.Code, "body: %s", w.Body.String()) |
| 92 | + assert.Contains(t, w.Body.String(), "cannot be removed through the bot API") |
| 93 | + assert.True(t, rmGuardIsActiveMember(t, handler, rmGuardManager), "manager must remain a member") |
| 94 | +} |
| 95 | + |
| 96 | +func TestBotGroupMemberRemove_CreatorTargetForbidden(t *testing.T) { |
| 97 | + handler := setupRemoveGuardEnv(t) |
| 98 | + |
| 99 | + w := doBot(handler, botReq(t, "POST", "/v1/bot/groups/"+rmGuardGroupNo+"/members/remove", rmGuardBotToken, |
| 100 | + map[string]interface{}{"members": []string{rmGuardCreator}})) |
| 101 | + assert.Equalf(t, http.StatusForbidden, w.Code, "body: %s", w.Body.String()) |
| 102 | + assert.Contains(t, w.Body.String(), "cannot be removed through the bot API") |
| 103 | + assert.True(t, rmGuardIsActiveMember(t, handler, rmGuardCreator), "creator must remain a member") |
| 104 | +} |
| 105 | + |
| 106 | +func TestBotGroupMemberRemove_MixedListRejectedAtomically(t *testing.T) { |
| 107 | + handler := setupRemoveGuardEnv(t) |
| 108 | + |
| 109 | + // 混合列表(普通成员 + 管理员)→ 整个请求 403,普通成员也不能被顺带移除。 |
| 110 | + w := doBot(handler, botReq(t, "POST", "/v1/bot/groups/"+rmGuardGroupNo+"/members/remove", rmGuardBotToken, |
| 111 | + map[string]interface{}{"members": []string{rmGuardCommon, rmGuardManager}})) |
| 112 | + assert.Equalf(t, http.StatusForbidden, w.Code, "body: %s", w.Body.String()) |
| 113 | + assert.Contains(t, w.Body.String(), "cannot be removed through the bot API") |
| 114 | + assert.True(t, rmGuardIsActiveMember(t, handler, rmGuardManager), "manager must remain a member") |
| 115 | + assert.True(t, rmGuardIsActiveMember(t, handler, rmGuardCommon), "common member must not be removed when the request is rejected") |
| 116 | +} |
| 117 | + |
| 118 | +func TestBotGroupMemberRemove_ManagerTargetCaseVariantForbidden(t *testing.T) { |
| 119 | + handler := setupRemoveGuardEnv(t) |
| 120 | + |
| 121 | + // MySQL utf8mb4_*_ci collation 下 uid 匹配大小写不敏感:大小写变体在 |
| 122 | + // service 层仍会命中真实 manager 行,所以守卫必须按 DB 解析行的角色 |
| 123 | + // 拦截,而不是在 Go 里做大小写敏感的字符串比对(codex review P1 回归)。 |
| 124 | + w := doBot(handler, botReq(t, "POST", "/v1/bot/groups/"+rmGuardGroupNo+"/members/remove", rmGuardBotToken, |
| 125 | + map[string]interface{}{"members": []string{strings.ToUpper(rmGuardManager)}})) |
| 126 | + assert.Equalf(t, http.StatusForbidden, w.Code, "body: %s", w.Body.String()) |
| 127 | + assert.Contains(t, w.Body.String(), "cannot be removed through the bot API") |
| 128 | + assert.True(t, rmGuardIsActiveMember(t, handler, rmGuardManager), "manager must remain a member") |
| 129 | +} |
| 130 | + |
| 131 | +func TestBotGroupMemberRemove_CommonTargetStillWorks(t *testing.T) { |
| 132 | + handler := setupRemoveGuardEnv(t) |
| 133 | + |
| 134 | + w := doBot(handler, botReq(t, "POST", "/v1/bot/groups/"+rmGuardGroupNo+"/members/remove", rmGuardBotToken, |
| 135 | + map[string]interface{}{"members": []string{rmGuardCommon}})) |
| 136 | + require.Equalf(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) |
| 137 | + resp := decodeBody(t, w) |
| 138 | + assert.Equal(t, true, resp["ok"]) |
| 139 | + assert.Equal(t, float64(1), resp["removed"]) |
| 140 | + assert.False(t, rmGuardIsActiveMember(t, handler, rmGuardCommon), "common member should be removed") |
| 141 | +} |
0 commit comments