-
Notifications
You must be signed in to change notification settings - Fork 32
Expand file tree
/
Copy pathcheck_channel_access_test.go
More file actions
231 lines (208 loc) · 11 KB
/
Copy pathcheck_channel_access_test.go
File metadata and controls
231 lines (208 loc) · 11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
package message
// =============================================================================
// Issue #353(PR #345 review P1-test)— checkChannelAccess requireActive split
// 的 CI 内(非 integration tag)回归覆盖。
//
// AuthorizeThreadFollow(FollowThread,子区/CommunityTopic 分支)要求父群「活跃
// 成员」(ExistMemberActive,排除被拉黑);AuthorizeChannelFollow(FollowChannel,
// GROUP 分支)保留 permissive ExistMember。此前该 split 仅由
// thread_follow_blacklist_e2e_test.go 覆盖,但该文件带 //go:build integration、
// CI 的 go test 永不编译——把 AuthorizeThreadFollow 翻回 permissive 的回归会
// 静默漏过。本文件不带 tag,直接吃 CI 已就绪的 MySQL service,跑生产同款
// newThreadAuthChecker(真实 group/group_member/thread 表)。
//
// 测试基建约定(PR #356 round-1 CI 红 + round-2 review 的教训):
// 1. 不跑 sql-migrate——本包非 integration-tag 测试一律不经 module.Setup(既有
// testutil.NewTestServer 用例全部 t.Skip;手建表用例与迁移在 -shuffle 下互撞
// Error 1050)。
// 2. 不碰共享 test 库——本文件要 DROP+CREATE group/group_member/thread,而
// go test ./... 默认跨包并行、modules/group、modules/thread 等包用
// testutil.NewTestServer 连同一个 test 库,破坏性 DDL 会撞别包的查询。
// 因此先在 MySQL 实例上建独立库再连入,所有 DDL/数据只落在隔离库里。
// 写法照搬 e2e helper:手建最小表 + 裸 INSERT 种子,ctx 显式 Migration=false。
// =============================================================================
import (
"errors"
"os"
"strings"
"testing"
"github.com/Mininglamp-OSS/octo-lib/common"
"github.com/Mininglamp-OSS/octo-lib/config"
"github.com/Mininglamp-OSS/octo-lib/pkg/util"
convext "github.com/Mininglamp-OSS/octo-server/modules/conversation_ext"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const ccaSpaceID = "s_cca"
// ccaDBName 是本文件专用的隔离库名:与共享 test 库(testutil.NewTestServer /
// 其它包的迁移与查询)完全隔离,跨包并行的 go test ./... 下破坏性 DDL 不会外溢。
// 与 thread_ext_blacklist_filter_test.go(PR #356)同名复用一个隔离库——两文件的
// 每个用例都自建 schema,包内测试顺序执行,互不踩踏。
const ccaDBName = "octo_msg_blacklist_test"
// ccaNewCtx 构造指向隔离库的 *config.Context:先用 config.New 默认 DSN(与 CI
// MySQL service 同源的 root:demo@…/test)建隔离库,再把 DSN 的库名换成隔离库连入。
// 显式 Migration=false——不经 module.Setup。可用 MSG_BLACKLIST_TEST_MYSQL_ADDR
// 覆盖隔离库 DSN(与 newSidebarIntegCtx 的 env 覆盖风格一致)。
func ccaNewCtx(t *testing.T) *config.Context {
t.Helper()
bootCfg := config.New()
bootCfg.Test = true
bootCfg.DB.Migration = false
boot := config.NewContext(bootCfg)
_, err := boot.DB().Exec(
"CREATE DATABASE IF NOT EXISTS " + ccaDBName +
" CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci")
require.NoError(t, err, "create isolated db")
addr := os.Getenv("MSG_BLACKLIST_TEST_MYSQL_ADDR")
if addr == "" {
addr = "root:demo@tcp(127.0.0.1:3306)/" + ccaDBName + "?charset=utf8mb4&parseTime=true"
}
cfg := config.New()
cfg.Test = true
cfg.DB.Migration = false
cfg.DB.MySQLAddr = addr
return config.NewContext(cfg)
}
// ccaEnsureTables 在隔离库里手建 checkChannelAccess / AuthorizeThreadFollow 触达的最小表
// (DDL 与 modules/group、modules/thread 迁移中本测试触达的列对齐;写法照搬
// default_followed_group_guard_e2e_test.go / thread_follow_blacklist_e2e_test.go
// 的同名 helper)。DROP + CREATE 保证每个用例拿到本文件期望的 schema;隔离库
// 只被本包顺序执行的测试使用,破坏性 DDL 安全。
func ccaEnsureTables(t *testing.T, ctx *config.Context) {
t.Helper()
for _, tbl := range []string{"thread", "group_member", "`group`"} {
_, err := ctx.DB().Exec("DROP TABLE IF EXISTS " + tbl)
require.NoError(t, err, "drop %s", tbl)
}
stmts := []string{
"CREATE TABLE `group` (" +
" `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY," +
" `group_no` VARCHAR(40) NOT NULL DEFAULT ''," +
" `name` VARCHAR(40) NOT NULL DEFAULT ''," +
" `creator` VARCHAR(40) NOT NULL DEFAULT ''," +
" `status` SMALLINT NOT NULL DEFAULT 0," +
" `version` BIGINT NOT NULL DEFAULT 0," +
" `group_type` SMALLINT NOT NULL DEFAULT 0," +
" `space_id` VARCHAR(40) DEFAULT ''," +
" `is_external_group` SMALLINT NOT NULL DEFAULT 0," +
" `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," +
" `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," +
" UNIQUE KEY `group_groupNo` (`group_no`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE `group_member` (" +
" `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY," +
" `group_no` VARCHAR(40) NOT NULL DEFAULT ''," +
" `uid` VARCHAR(40) NOT NULL DEFAULT ''," +
" `role` SMALLINT NOT NULL DEFAULT 0," +
" `version` BIGINT NOT NULL DEFAULT 0," +
" `is_deleted` SMALLINT NOT NULL DEFAULT 0," +
" `status` SMALLINT NOT NULL DEFAULT 1," +
" `vercode` VARCHAR(100) NOT NULL DEFAULT ''," +
" `robot` SMALLINT NOT NULL DEFAULT 0," +
" `is_external` SMALLINT NOT NULL DEFAULT 0," +
" `source_space_id` VARCHAR(40) NOT NULL DEFAULT ''," +
" `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," +
" `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," +
" UNIQUE KEY `group_no_uid` (`group_no`, `uid`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE `thread` (" +
" `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY," +
" `group_no` VARCHAR(40) NOT NULL DEFAULT ''," +
" `short_id` VARCHAR(40) NOT NULL DEFAULT ''," +
" `name` VARCHAR(100) NOT NULL DEFAULT ''," +
" `creator_uid` VARCHAR(40) NOT NULL DEFAULT ''," +
" `status` INT NOT NULL DEFAULT 1," +
// QueryActiveByGroupShortIDs(AuthorizeThreadFollow 路径)显式 SELECT
// last_message_at,最小表也必须带上(与 ensureThreadE2ETable 一致)。
" `last_message_at` TIMESTAMP NULL DEFAULT NULL," +
" `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," +
" `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," +
" UNIQUE KEY `uk_short` (`short_id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
}
for _, s := range stmts {
_, err := ctx.DB().Exec(s)
require.NoError(t, err, "ccaEnsureTables: %s", s[:40])
}
}
// setupCheckChannelAccessData 建父群(space_id 空 → legacy wildcard 可见)+
// 一个正常成员 + 一个 active 子区,返回生产同款 threadAuthChecker。
func setupCheckChannelAccessData(t *testing.T) (*config.Context, *threadAuthChecker, string, string, string) {
t.Helper()
ctx := ccaNewCtx(t)
ccaEnsureTables(t, ctx)
groupNo := strings.ReplaceAll(util.GenerUUID(), "-", "")
memberUID := "u_cca_" + util.GenerUUID()[:8]
shortID := "1489104291682713604"
_, err := ctx.DB().Exec(
"INSERT INTO `group` (group_no, name, creator, status, version, space_id) VALUES (?, '父群', ?, 1, 1, '')",
groupNo, memberUID,
)
require.NoError(t, err, "seed group")
_, err = ctx.DB().Exec(
"INSERT INTO group_member (group_no, uid, vercode, is_deleted, status, version) VALUES (?, ?, ?, 0, ?, 1)",
groupNo, memberUID, util.GenerUUID(), int(common.GroupMemberStatusNormal),
)
require.NoError(t, err, "seed member")
_, err = ctx.DB().Exec(
"INSERT INTO thread (group_no, short_id, name, creator_uid, status) VALUES (?, ?, 'topic', ?, 1)",
groupNo, shortID, memberUID,
)
require.NoError(t, err, "seed thread")
return ctx, newThreadAuthChecker(ctx), groupNo, memberUID, shortID
}
func ccaSetMemberStatus(t *testing.T, ctx *config.Context, groupNo, uid string, status common.GroupMemberStatus) {
t.Helper()
_, err := ctx.DB().Exec(
"UPDATE group_member SET status=? WHERE group_no=? AND uid=?",
int(status), groupNo, uid,
)
require.NoError(t, err)
}
// TestCheckChannelAccess_RequireActiveSplit 钉住 requireActive 两档语义:
// true(子区分支)排除被拉黑成员,false(GROUP 分支)保持 permissive。
func TestCheckChannelAccess_RequireActiveSplit(t *testing.T) {
ctx, checker, groupNo, memberUID, _ := setupCheckChannelAccessData(t)
t.Run("normal member passes both", func(t *testing.T) {
assert.NoError(t, checker.checkChannelAccess(memberUID, ccaSpaceID, groupNo, true),
"正常成员 requireActive=true 应放行")
assert.NoError(t, checker.checkChannelAccess(memberUID, ccaSpaceID, groupNo, false),
"正常成员 requireActive=false 应放行")
})
ccaSetMemberStatus(t, ctx, groupNo, memberUID, common.GroupMemberStatusBlacklist)
t.Run("blacklisted member denied only when requireActive", func(t *testing.T) {
err := checker.checkChannelAccess(memberUID, ccaSpaceID, groupNo, true)
require.Error(t, err, "被拉黑成员 requireActive=true 必须被拒(#345 split)")
assert.True(t, errors.Is(err, convext.ErrChannelForbidden),
"应返回 ErrChannelForbidden,got %v", err)
assert.NoError(t, checker.checkChannelAccess(memberUID, ccaSpaceID, groupNo, false),
"GROUP 分支保持 permissive,不应 over-block 被拉黑成员")
})
t.Run("removed member denied on both", func(t *testing.T) {
_, err := ctx.DB().Exec(
"UPDATE group_member SET is_deleted=1 WHERE group_no=? AND uid=?", groupNo, memberUID)
require.NoError(t, err)
assert.Error(t, checker.checkChannelAccess(memberUID, ccaSpaceID, groupNo, true),
"被移出成员 requireActive=true 必须被拒")
assert.Error(t, checker.checkChannelAccess(memberUID, ccaSpaceID, groupNo, false),
"被移出成员 requireActive=false 也被拒(is_deleted 在两档都生效)")
})
}
// TestAuthorizeFollow_BlacklistSplit 钉住对外入口的接线:AuthorizeThreadFollow
// 必须走 requireActive=true(被拉黑 → ErrThreadForbidden),AuthorizeChannelFollow
// 走 false(被拉黑放行)。如果有人把 AuthorizeThreadFollow 翻回 permissive,
// 这里的 deny 断言会在 CI 直接红——这正是 #353 指出的静默回归缺口。
func TestAuthorizeFollow_BlacklistSplit(t *testing.T) {
ctx, checker, groupNo, memberUID, shortID := setupCheckChannelAccessData(t)
require.NoError(t, checker.AuthorizeThreadFollow(memberUID, ccaSpaceID, groupNo, shortID),
"正常成员 follow 子区不应被拦")
require.NoError(t, checker.AuthorizeChannelFollow(memberUID, ccaSpaceID, groupNo),
"正常成员 follow GROUP 不应被拦")
ccaSetMemberStatus(t, ctx, groupNo, memberUID, common.GroupMemberStatusBlacklist)
err := checker.AuthorizeThreadFollow(memberUID, ccaSpaceID, groupNo, shortID)
require.Error(t, err, "被拉黑父群成员 follow 子区必须被拒")
assert.True(t, errors.Is(err, convext.ErrThreadForbidden),
"应翻译为 ErrThreadForbidden(handler 走 403 路径),got %v", err)
assert.NoError(t, checker.AuthorizeChannelFollow(memberUID, ccaSpaceID, groupNo),
"GROUP 分支保留 permissive ExistMember,被拉黑成员 channel follow 不被 over-block")
}