im 服务端

This commit is contained in:
richjohnrambo
2026-08-15 01:33:23 +08:00
parent 7c360167b3
commit 5af396c5f5
10 changed files with 669 additions and 60 deletions
+1
View File
@@ -10,4 +10,5 @@
# Local Go build output.
/server/server
/.codex-gocache/
/.github/
@@ -0,0 +1,183 @@
# IM 前后端对接文档 1-11 章单元测试用例
本文基于 `IM-前后端对接文档.md` 与当前实现代码整理,默认按 `httptest` + mock store + fake auth/media/push store 的方式落单元测试。
## 0. 验收准备
### 0.1 基础环境
| [ ] 项目 | 准备项 | 说明 |
|---|---|---|
| [ ] ENV-01 | 测试租户 A/B | A、B 都是 `active`,且准备一个 `suspended` 租户用于负向验证 |
| [ ] ENV-02 | 统一 API Key | 使用测试 API Key,覆盖 WS/HTTP 两类入口 |
| [ ] ENV-03 | 固定时间 | 对 Token、验证码、重置票据、过期时间使用可冻结时钟 |
| [ ] ENV-04 | Mock 存储 | `store.Users`、`store.Tenants`、`store.Verifications`、`store.Security`、`store.Files`、`store.Store` 可注入 mock/fake |
| [ ] ENV-05 | 文件沙箱 | 文件下载/上传路径使用临时目录,避免污染真实对象存储 |
| [ ] ENV-06 | 多设备上下文 | 至少准备同一用户的两个 session,验证退出/踢出/读回执聚合 |
| [ ] ENV-07 | 负向数据 | 同名 `alice`、同手机号、同群名、同 tag、同 file id 的 A/B 对照数据 |
### 0.2 章节映射
| [ ] 章 | 主要入口/能力 | 代码证据 | 现有测试证据 |
|---|---|---|---|
| [ ] MAP-01 | 第1章 账号注册、登录与身份安全 | [server/main.go](/Users/superysh/im/im_server/server/main.go:780), [server/session.go](/Users/superysh/im/im_server/server/session.go:755), [server/user_verification_test.go](/Users/superysh/im/im_server/server/user_verification_test.go:19) | [server/session_test.go](/Users/superysh/im/im_server/server/session_test.go:38), [server/user_verification_test.go](/Users/superysh/im/im_server/server/user_verification_test.go:19), [server/auth/token/auth_token_test.go](/Users/superysh/im/im_server/server/auth/token/auth_token_test.go:1) |
| [ ] MAP-02 | 第2章 消息首页、全局导航与系统通知 | [server/main.go](/Users/superysh/im/im_server/server/main.go:791), [server/http.go](/Users/superysh/im/im_server/server/http.go:1) | 以 bootstrap / system notification handler 单测补齐 |
| [ ] MAP-03 | 第3章 单聊会话、会话建立与消息状态 | [server/main.go](/Users/superysh/im/im_server/server/main.go:821), [server/topic.go](/Users/superysh/im/im_server/server/topic.go:1) | [server/hub_tenant_test.go](/Users/superysh/im/im_server/server/hub_tenant_test.go:10), [server/topic_test.go](/Users/superysh/im/im_server/server/topic_test.go:1) |
| [ ] MAP-04 | 第4章 消息输入、媒体内容与消息操作 | [server/main.go](/Users/superysh/im/im_server/server/main.go:827), [server/datamodel.go](/Users/superysh/im/im_server/server/datamodel.go:1) | 需补 `pub/get/set/del/note` 行为单测 |
| [ ] MAP-05 | 第5章 通讯录、好友关系与黑名单 | [server/main.go](/Users/superysh/im/im_server/server/main.go:833), [server/main.go](/Users/superysh/im/im_server/server/main.go:835) | 需补 friend/block/qrcode 单测 |
| [ ] MAP-06 | 第6章 群聊创建、资料维护与成员治理 | [server/main.go](/Users/superysh/im/im_server/server/main.go:829), [server/topic.go](/Users/superysh/im/im_server/server/topic.go:1) | [server/hub_tenant_test.go](/Users/superysh/im/im_server/server/hub_tenant_test.go:10) |
| [ ] MAP-07 | 第7章 文件下载、收藏管理与聊天记录检索 | [server/main.go](/Users/superysh/im/im_server/server/main.go:824), [server/hdl_files.go](/Users/superysh/im/im_server/server/hdl_files.go:38) | [server/hdl_files_test.go](/Users/superysh/im/im_server/server/hdl_files_test.go:11), [server/media/fs/filesys_tenant_test.go](/Users/superysh/im/im_server/server/media/fs/filesys_tenant_test.go:11) |
| [ ] MAP-08 | 第8章 实时语音与视频通话 | [server/main.go](/Users/superysh/im/im_server/server/main.go:826), [server/calls.go](/Users/superysh/im/im_server/server/calls.go:1) | 需补 call 信令状态机单测 |
| [ ] MAP-09 | 第9章 企业工作台与办公协同 | [server/main.go](/Users/superysh/im/im_server/server/main.go:792), [server/main.go](/Users/superysh/im/im_server/server/main.go:796) | 需补 workbench/org/meeting/report/mailbox 单测 |
| [ ] MAP-10 | 第10章 个人资料、通用设置、隐私与青少年模式 | [server/main.go](/Users/superysh/im/im_server/server/main.go:812), [server/main.go](/Users/superysh/im/im_server/server/main.go:815) | [server/user_verification_test.go](/Users/superysh/im/im_server/server/user_verification_test.go:104) |
| [ ] MAP-11 | 第11章 反诈安全、举报与意见反馈 | [server/main.go](/Users/superysh/im/im_server/server/main.go:785), [server/main.go](/Users/superysh/im/im_server/server/main.go:798) | 需补 risk/abuse/feedback 单测 |
### 0.3 现有护栏证据
| [ ] 证据 | 已覆盖的护栏 | 说明 |
|---|---|---|
| [ ] EVD-01 | Tenant 0 直接拒绝 | `store.ForTenant(0)` 已有测试 |
| [ ] EVD-02 | 同名 topic 按 tenant 隔离 | Hub tenant 测试已覆盖同名 `sys` 分离 |
| [ ] EVD-03 | 文件路径按 tenant 命名空间化 | tenant 文件路径测试已覆盖 |
| [ ] EVD-04 | Push channel 按 tenant 命名空间化 | tenant channel 测试已覆盖 |
| [ ] EVD-05 | 文件 HTTP 认证返回 Principal | 文件认证测试已覆盖 tenant/uid 绑定 |
| [ ] EVD-06 | Verification / reset 流程按 tenant 处理 | `user_verification_test.go` 已覆盖验证与重置主流程 |
## 1. 第1章 账号注册、登录与身份安全
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] T01-01 | 企业码握手成功 | `hi.tenant=acme`,租户为 `active` | Session 只绑定一次 tenant;返回公开 tenant 信息,不暴露内部 `tenant_id` |
| [ ] T01-02 | 企业码缺失或非法 | `hi` 不带 tenant / 空白 / 超长 | 返回 400 类错误,Session 不绑定 tenant |
| [ ] T01-03 | 已绑定 Session 再发另一个 tenant | 先绑定 A,再发 B | 返回冲突类错误,tenant 不变化 |
| [ ] T01-04 | basic 登录同名跨租户 | A/B 都有 `alice`,分别用各自密码登录 | 只命中当前 tenant 的 auth 记录,token 携带当前 tenant |
| [ ] T01-05 | token 登录 tenant 不匹配 | A token 放进 B session | 认证失败,不写入 uid/authLvl |
| [ ] T01-06 | 注册验证码 tenant 隔离 | A、B 同手机号注册 | 只消费当前 tenant 的验证码记录,不串租户 |
| [ ] T01-07 | 找回密码 tenant 隔离 | A、B 同手机号触发重置 | 仅更新当前 tenant 用户,auth version 按当前 tenant 递增 |
| [ ] T01-08 | 实名/青少年/独立密码入口 | 无 tenant 或 tenant 不活跃 | 统一拒绝,不进入后续敏感流程 |
## 2. 第2章 消息首页、全局导航与系统通知
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C02-01 | bootstrap 只返回本租户菜单 | 调用 `bootstrap` | 功能开关、未读数、模块列表只来自当前 tenant |
| [ ] C02-02 | system notification 列表 | 查询/翻页已读未读 | 只返回当前 tenant 通知,分页游标稳定 |
| [ ] C02-03 | system notification read | 标记单条已读 | 只改当前 tenant 当前用户的 read state |
| [ ] C02-04 | conversation search | A 搜索 B 的联系人/会话 | 返回空或通用 NotFound,不泄露 B 的存在性 |
| [ ] C02-05 | 无网络/失效 token 恢复 | 模拟 token 失效后重连 | 进入统一错误分支,不误切 tenant |
## 3. 第3章 单聊会话、会话建立与消息状态
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] T03-01 | P2P context 正常返回 | 查询同租户双方会话上下文 | `relationship`、`can_send`、`topic` 与当前 tenant 一致 |
| [ ] T03-02 | 非好友/黑名单发消息 | 在 `can_send=false` 下调用 pub | 服务端拒绝,不产生消息 seq |
| [ ] T03-03 | read/recv 回执推进 | 连续提交 `note read/recv` | 只允许单调推进,不回退读游标 |
| [ ] T03-04 | 删除好友后历史保留 | 删除好友后再查历史消息 | 历史还在,但后续发送被拒绝 |
| [ ] T03-05 | 跨租户 topic 访问 | 已知 B topic 订阅/读/写 | 统一 NotFound/Denied,不进入 Hub 缓存 |
| [ ] T03-06 | 主题状态切换 | attach/leave/reattach | session/topic 状态机不被错误 tenant 影响 |
## 4. 第4章 消息输入、媒体内容与消息操作
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C04-01 | 文本消息 schema 校验 | `x-im-schema=1`、`x-im-type=text`、`x-im-client-mid` | 通过并保持 client-mid 幂等 |
| [ ] C04-02 | 媒体消息字段校验 | image/video/voice/file 的 `content` | 必填字段缺失或超长时拒绝 |
| [ ] C04-03 | @成员与 @all | 指定群成员 / `mention_all` | 成员过滤正确,越权 `@all` 被拒绝 |
| [ ] C04-04 | 编辑/撤回/删除 | 对同一消息进行修改或撤回 | 只允许符合时限和角色的操作 |
| [ ] C04-05 | 合并转发结构 | bundle/逐条转发 | 快照字段稳定,源消息与目标消息分离 |
| [ ] C04-06 | 媒体消息重复提交 | 重试同一 `client_mid` | 返回首次写入结果,不重复落库 |
## 5. 第5章 通讯录、好友关系与黑名单
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C05-01 | 用户搜索 tenant 隔离 | A 搜索 B 仅有的账号/tag | 返回空结果,不提示跨租户命中 |
| [ ] C05-02 | 二维码解析 | 用 add_friend token 解析 | 只返回当前 tenant 可见的公开资料 |
| [ ] C05-03 | 好友申请幂等 | 同一请求重复提交 | 返回原申请记录,不重复创建 |
| [ ] C05-04 | 好友同意/拒绝 | 审批 inbound request | 双方关系和 P2P topic 同步更新 |
| [ ] C05-05 | 拉黑/解除拉黑 | `PUT/DELETE /blocks/{user_id}` | 只影响当前 tenant 当前用户关系 |
| [ ] C05-06 | 删除好友 | `DELETE /friends/{user_id}` | 历史消息不删,后续会话发送被拒绝 |
## 6. 第6章 群聊创建、资料维护与成员治理
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C06-01 | 创建群 | 创建新 group topic | topic、group settings、owner、subscription 同 tenant 落库 |
| [ ] C06-02 | 邀请/审批入群 | 邀请 B tenant 用户进 A 群 | 统一拒绝,不产生部分写入 |
| [ ] C06-03 | 群管理员变更 | 提升/降级成员角色 | 仅允许本群本 tenant 成员,ACL 一致 |
| [ ] C06-04 | 群主转让 | 指定新 owner | 事务内同步业务真源和 Topic Owner |
| [ ] C06-05 | 群禁言/全员禁言 | muted member 发消息 | 服务端拒绝,其他成员不可见 |
| [ ] C06-06 | 群文件/成员列表 | 查询群成员、群文件 | 只返回当前 tenant 当前群成员 |
## 7. 第7章 文件下载、收藏管理与聊天记录检索
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C07-01 | 文件上传 principal | HTTP/gRPC 文件上传 | `RequestPrincipal` 必须带 tenant/uid,落库 tenant 正确 |
| [ ] C07-02 | 文件下载授权 | 用 A 用户访问 B file id | 返回 403/404,不暴露 location |
| [ ] C07-03 | 收藏去重 | 同一 `topic+seq` 重复收藏 | 只新增一条,返回 existing 计数 |
| [ ] C07-04 | 收藏转发 | 对已收藏消息做转发 | 保持 source snapshot,不越权扩散 |
| [ ] C07-05 | 会话/消息搜索 | 搜索关键字、类型、文件 | 只返回当前 tenant 且有读权限的结果 |
| [ ] C07-06 | 垃圾清理 | 过期未使用文件清理 | 只清当前 tenant,不误删其他 tenant 文件 |
## 8. 第8章 实时语音与视频通话
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] T08-01 | 发起通话 | `call` invite / note started | 生成稳定 `callId`,同 tenant 双方可见 |
| [ ] T08-02 | 接听/拒绝/挂断 | accept / reject / hang-up | 状态转换正确,结束原因稳定 |
| [ ] T08-03 | 超时结束 | 不接听直到超时 | 服务端替换为 missed/timeout 结果 |
| [ ] T08-04 | 多设备回放 | 同用户多个 session | 只向合法 session 广播,不串 tenant |
| [ ] T08-05 | 跨租户呼叫 | A 呼叫 B tenant 用户 | 统一拒绝,不创建有效通话记录 |
## 9. 第9章 企业工作台与办公协同
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] B09-01 | workbench 模块列表 | 查询工作台 | 模块、权限、未读数只取当前 tenant |
| [ ] B09-02 | 组织树查询 | 查部门根节点/子节点/成员 | 只返回当前 tenant 组织结构 |
| [ ] B09-03 | 会议创建/变更/取消 | 创建会议并更新参会人 | 幂等、通知、审计都在当前 tenant |
| [ ] B09-04 | 日报/月报草稿与统计 | 创建、提交、统计、提醒 | 统计范围受 tenant 与管理权限约束 |
| [ ] B09-05 | 总经理信箱/工作台入口 | 查询配置与提交 | 匿名/必填字段/附件策略按 tenant 配置 |
## 10. 第10章 个人资料、通用设置、隐私与青少年模式
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] T10-01 | me/profile 读取与更新 | 查询并 patch 个人资料 | 只允许当前用户当前 tenant 数据更新 |
| [ ] T10-02 | privacy 更新 | 修改隐私项 | 版本控制正确,其他设备能收到变更 |
| [ ] T10-03 | youth mode 开关 | 启用/关闭青少年模式 | 需要 action token / 独立密码,tenant 一致 |
| [ ] T10-04 | independent password | 设置/验证独立密码 | 明文不落库,不跨租户复用 |
| [ ] T10-05 | push token 绑定 | 更新设备 push token | `(tenant_id, uid, device_id)` 维度唯一 |
| [ ] T10-06 | app release / settings menu | 查询版本与设置菜单 | 返回当前 tenant 配置,不混入别的企业开关 |
## 11. 第11章 反诈安全、举报与意见反馈
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] B11-01 | URL 风险检查 | 检查外链 | 返回 allow/warn/block,带租户上下文 |
| [ ] B11-02 | 风险挑战确认 | `continue/cancel` | 只消费当前租户当前 challenge |
| [ ] B11-03 | 举报原因与提交 | 读取原因并提交举报 | reason、证据、目标对象按 tenant 存档 |
| [ ] B11-04 | 反馈配置与提交 | 查询反馈配置并提交反馈 | 附件、联系方式、诊断信息按 tenant 策略处理 |
| [ ] B11-05 | 系统通知联动 | 风险/举报结果产生通知 | 只推当前 tenant 相关用户,不跨租户扩散 |
## 12. 高风险迁移确认点
| [ ] ID | 确认点 | 为什么要测 | 预期 |
|---|---|---|---|
| [ ] RISK-01 | Token / Session tenant 一致性 | 这是全链路身份根 | 任意篡改都拒绝 |
| [ ] RISK-02 | 文件认证 Principal | 文件链路最容易只剩 UID | 没 tenant 直接失败 |
| [ ] RISK-03 | 同名 `sys` / Topic 隔离 | 最容易发生缓存串租户 | 不同 tenant 命中不同对象 |
| [ ] RISK-04 | 事务内幂等 | 验证码、好友、群审批、会议、举报都依赖 | 重试不重复写 |
| [ ] RISK-05 | 搜索二次 ACL | 索引命中不代表可见 | 返回前再做权限过滤 |
| [ ] RISK-06 | Push / 回调 / 外部服务 | 外部 side-effect 最易丢 tenant | 出站上下文保持 tenant |
| [ ] RISK-07 | 旧请求重放 | 防止用旧参数跨 tenant 复用 | 统一按当前 tenant 拒绝 |
| [ ] RISK-08 | 失败回滚 | 群主转让、入群、文件关联等 | 只要一处失败就全回滚 |
## 13. 建议补测顺序
1. 先补第 1 章和第 7 章,因为它们是登录态与文件安全的根。
2. 再补第 3、5、6 章,把联系人、群和消息主链路闭环。
3. 然后补第 8、9、10、11 章,覆盖通话、工作台、设置和安全合规模块。
4. 最后做跨章回归:同名数据双租户、同 token 重放、同 file id 访问、同 topic 搜索。
+8
View File
@@ -51,6 +51,14 @@ func (s testSecurityStore) UpdateIndependentPasswordAttempts(types.TenantID, typ
return s.err
}
func (s testSecurityStore) GetYouthMode(types.TenantID, types.Uid) (*types.YouthModeSettings, error) {
return nil, types.ErrNotFound
}
func (s testSecurityStore) SetYouthMode(types.TenantID, types.Uid, bool, int, time.Time) (*types.YouthModeSettings, error) {
return nil, s.err
}
func (s testSecurityStore) CreateSystemNotification(types.TenantID, *types.SystemNotification) error {
return s.err
}
+1 -1
View File
@@ -1452,7 +1452,7 @@ func createIMRelationTables(exec sqlExecutor) error {
PRIMARY KEY(id),
UNIQUE INDEX uk_im_favorites_favorite(favorite_id),
UNIQUE INDEX uk_im_favorites_source(user_id, source_topic, source_seq),
UNIQUE INDEX uk_im_favorites_idempotency(user_id, idempotency_key),
UNIQUE INDEX uk_im_favorites_idempotency(user_id, ((NULLIF(idempotency_key, '')))),
INDEX idx_im_favorites_inbox(user_id, id DESC),
INDEX idx_im_favorites_type(user_id, item_type, id DESC)
)`,
+32 -1
View File
@@ -4420,7 +4420,38 @@ func (a *adapter) TenantFavoriteCreate(id t.TenantID, fav *t.FavoriteItem) (*t.F
if err != nil {
return nil, err
}
return a.TenantFavoriteGet(id, fav.UserID, fav.FavoriteID)
item, err := a.TenantFavoriteGet(id, fav.UserID, fav.FavoriteID)
if err == nil {
return item, nil
}
if err != t.ErrNotFound {
return nil, err
}
return a.tenantFavoriteFindExisting(ctx, id, fav)
}
func (a *adapter) tenantFavoriteFindExisting(ctx context.Context, id t.TenantID, fav *t.FavoriteItem) (*t.FavoriteItem, error) {
where := []string{"favorite_id=?", "(source_topic=? AND source_seq=?)"}
args := []any{fav.FavoriteID, fav.SourceTopic, fav.SourceSeq}
if fav.IdempotencyKey != "" {
where = append(where, "idempotency_key=?")
args = append(args, fav.IdempotencyKey)
}
args = append([]any{decodeUID(fav.UserID)}, args...)
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_favorites WHERE user_id=? AND ("+strings.Join(where, " OR ")+") ORDER BY id DESC LIMIT 1", args...)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, t.ErrNotFound
}
item, err := scanFavorite(rows)
if err != nil {
return nil, err
}
item.TenantID = id
return &item, nil
}
func (a *adapter) TenantFavoriteList(id t.TenantID, uid t.Uid, itemType, queryText, cursor string, limit int) ([]t.FavoriteItem, string, error) {
+29 -20
View File
@@ -645,11 +645,7 @@ func largeFileRunGarbageCollection(period time.Duration, blockSize int) chan<- b
for {
select {
case <-gcTicker:
for _, tenantID := range store.KnownTenantIDs() {
if err := store.Files.DeleteUnused(tenantID, time.Now().Add(-time.Hour), blockSize); err != nil {
logs.Warn.Println("media gc:", tenantID, err)
}
}
largeFileGarbageCollectTenants(store.KnownTenantIDs(), time.Now().Add(-time.Hour), blockSize)
case <-stop:
return
}
@@ -659,42 +655,55 @@ func largeFileRunGarbageCollection(period time.Duration, blockSize int) chan<- b
return stop
}
func largeFileGarbageCollectTenants(tenantIDs []types.TenantID, olderThan time.Time, blockSize int) {
for _, tenantID := range tenantIDs {
if err := store.Files.DeleteUnused(tenantID, olderThan, blockSize); err != nil {
logs.Warn.Println("media gc:", tenantID, err)
}
}
}
// Authenticate non-websocket HTTP request
func authFileRequest(authMethod, secret, sid, remoteAddr string) (types.TenantID, types.Uid, []byte, error) {
var tenantID types.TenantID
var uid types.Uid
func authFileRequest(authMethod, secret, sid, remoteAddr string) (RequestPrincipal, []byte, error) {
var principal RequestPrincipal
if authMethod != "" {
decodedSecret := make([]byte, base64.StdEncoding.DecodedLen(len(secret)))
n, err := base64.StdEncoding.Decode(decodedSecret, []byte(secret))
if err != nil {
logs.Info.Println("media: invalid auth secret", authMethod, "'"+secret+"'")
return tenantID, uid, nil, types.ErrMalformed
return principal, nil, types.ErrMalformed
}
if authhdl := store.Store.GetLogicalAuthHandler(authMethod); authhdl != nil {
rec, challenge, err := authhdl.Authenticate(auth.AuthContext{RemoteAddr: remoteAddr}, decodedSecret[:n])
if err != nil {
return tenantID, uid, nil, err
return principal, nil, err
}
if challenge != nil {
return tenantID, uid, challenge, nil
return principal, challenge, nil
}
if rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() {
return tenantID, uid, nil, types.ErrFailed
return principal, nil, types.ErrFailed
}
tenantID = rec.TenantID
uid = rec.Uid
principal.TenantID = rec.TenantID
principal.UID = rec.Uid
} else {
logs.Info.Println("media: unknown auth method", authMethod)
return tenantID, uid, nil, types.ErrMalformed
return principal, nil, types.ErrMalformed
}
} else {
// Find the session, make sure it's appropriately authenticated.
sess := globals.sessionStore.Get(sid)
if sess != nil {
tenantID = sess.tenantID
uid = sess.uid
if globals.sessionStore != nil {
sess := globals.sessionStore.Get(sid)
if sess == nil {
return principal, nil, nil
}
if sess.tenantID.IsZero() || sess.uid.IsZero() {
return principal, nil, types.ErrFailed
}
principal.TenantID = sess.tenantID
principal.UID = sess.uid
}
}
return tenantID, uid, nil, nil
return principal, nil, nil
}
+19 -5
View File
@@ -22,12 +22,12 @@ func TestAuthFileRequestFromSessionRequiresTenant(t *testing.T) {
sess, _ := globals.sessionStore.NewSession(httptest.NewRecorder(), "file-test-sid")
sess.uid = types.Uid(42)
if _, _, _, err := authFileRequest("", "", sess.sid, ""); err != types.ErrFailed {
if _, _, err := authFileRequest("", "", sess.sid, ""); err != types.ErrFailed {
t.Fatalf("session without tenant: got %v, want %v", err, types.ErrFailed)
}
sess.tenantID = types.TenantID(7)
principal, challenge, _, err := authFileRequest("", "", sess.sid, "")
principal, challenge, err := authFileRequest("", "", sess.sid, "")
if err != nil {
t.Fatal(err)
}
@@ -49,12 +49,11 @@ func TestAuthFileRequestFromTokenReturnsPrincipal(t *testing.T) {
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
tokenAuth.EXPECT().IsInitialized().Return(true)
tokenAuth.EXPECT().Authenticate(auth.AuthContext{RemoteAddr: "203.0.113.10"}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil)
store.Store = mockStore
principal, challenge, _, err := authFileRequest("token", base64.StdEncoding.EncodeToString([]byte("token-1")), "", "203.0.113.10")
principal, challenge, err := authFileRequest("token", base64.StdEncoding.EncodeToString([]byte("token-1")), "", "203.0.113.10")
if err != nil {
t.Fatal(err)
}
@@ -67,7 +66,7 @@ func TestAuthFileRequestFromTokenReturnsPrincipal(t *testing.T) {
}
func TestAuthFileRequestAllowsUnauthenticatedNewAccountPath(t *testing.T) {
principal, challenge, _, err := authFileRequest("", "", "", "")
principal, challenge, err := authFileRequest("", "", "", "")
if err != nil {
t.Fatal(err)
}
@@ -123,3 +122,18 @@ func TestContentDispositionAttachmentSanitizesName(t *testing.T) {
t.Fatalf("unexpected content disposition: %s", got)
}
}
func TestLargeFileGarbageCollectTenantsScopesDeletes(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldFiles := store.Files
files := mock_store.NewMockFilePersistenceInterface(ctrl)
store.Files = files
defer func() { store.Files = oldFiles }()
olderThan := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
files.EXPECT().DeleteUnused(types.TenantID(7), olderThan, 25).Return(nil)
files.EXPECT().DeleteUnused(types.TenantID(8), olderThan, 25).Return(nil)
largeFileGarbageCollectTenants([]types.TenantID{7, 8}, olderThan, 25)
}
+13 -1
View File
@@ -1062,6 +1062,16 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
var modules []types.WorkbenchModule
workbenchVersion := 1
if store.Workbench != nil {
var err error
modules, workbenchVersion, err = store.Workbench.Get(principal.TenantID, principal.Uid)
if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
writeIMRelationError(wrt, req, err)
return
}
}
writeIMSuccess(wrt, req, map[string]any{
"features": map[string]any{
"create_group": true,
@@ -1076,6 +1086,8 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) {
"user_search": true,
"scan_qr": true,
},
"modules": workbenchModulePayloads(modules),
"workbench_version": workbenchVersion,
"system_topic": map[string]any{
"topic": "sys",
"type": "system",
@@ -5968,7 +5980,7 @@ func authenticateTopicReader(wrt http.ResponseWriter, req *http.Request, topic s
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
writeIMRelationError(wrt, req, err)
return nil, "", false
}
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
+362 -28
View File
@@ -44,6 +44,9 @@ type testRelationsStore struct {
friendRequests []types.FriendRequestListItem
searchResults []types.UserSearchItem
blocks []types.BlockListItem
deleteTenant types.TenantID
deleteUser types.Uid
deletePeer types.Uid
}
type testFavoritesStore struct {
@@ -53,13 +56,22 @@ type testFavoritesStore struct {
}
type testCallsStore struct {
items []types.CallRecord
topic string
items []types.CallRecord
topic string
listed bool
tenant types.TenantID
user types.Uid
limit int
cursor string
next string
}
type testWorkbenchStore struct {
modules []types.WorkbenchModule
version int
modules []types.WorkbenchModule
version int
tenantID types.TenantID
uid types.Uid
calledOnce bool
}
type testOrganizationStore struct {
@@ -109,7 +121,8 @@ type testAppReleaseStore struct {
}
type testDevicePushTokenStore struct {
token *types.DevicePushToken
token *types.DevicePushToken
tenantID types.TenantID
}
type testSafetyStore struct {
@@ -117,9 +130,18 @@ type testSafetyStore struct {
reasons []types.AbuseReason
abuseReport *types.AbuseReport
feedback *types.FeedbackSubmission
tenantID types.TenantID
uid types.Uid
idempotency string
decision string
actionTokenID string
targetType string
}
func (s *testWorkbenchStore) Get(_ types.TenantID, _ types.Uid) ([]types.WorkbenchModule, int, error) {
func (s *testWorkbenchStore) Get(tenantID types.TenantID, uid types.Uid) ([]types.WorkbenchModule, int, error) {
s.tenantID = tenantID
s.uid = uid
s.calledOnce = true
if s.version == 0 {
s.version = 1
}
@@ -369,23 +391,29 @@ func (s *testAppReleaseStore) Latest(_ types.TenantID, platform, channel string)
return s.release, nil
}
func (s *testDevicePushTokenStore) Upsert(_ types.TenantID, token *types.DevicePushToken) (*types.DevicePushToken, error) {
func (s *testDevicePushTokenStore) Upsert(tenantID types.TenantID, token *types.DevicePushToken) (*types.DevicePushToken, error) {
now := time.Now().UTC().Round(time.Millisecond)
token.CreatedAt = now
token.UpdatedAt = now
s.token = token
s.tenantID = tenantID
return token, nil
}
func (s *testSafetyStore) CreateRiskChallenge(_ types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error) {
func (s *testSafetyStore) CreateRiskChallenge(tenantID types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error) {
now := time.Now().UTC().Round(time.Millisecond)
challenge.CreatedAt = now
challenge.UpdatedAt = now
s.riskChallenge = challenge
s.tenantID = tenantID
return challenge, nil
}
func (s *testSafetyStore) ConfirmRiskChallenge(_ types.TenantID, uid types.Uid, challengeID, decision, actionTokenID string, now time.Time) (*types.RiskChallenge, error) {
func (s *testSafetyStore) ConfirmRiskChallenge(tenantID types.TenantID, uid types.Uid, challengeID, decision, actionTokenID string, now time.Time) (*types.RiskChallenge, error) {
s.tenantID = tenantID
s.uid = uid
s.decision = decision
s.actionTokenID = actionTokenID
if s.riskChallenge == nil {
s.riskChallenge = &types.RiskChallenge{ChallengeID: challengeID, UserID: uid, Action: "open_url", Level: "high", NoticeCode: "url_high", ExpiresAt: now.Add(time.Minute)}
}
@@ -399,23 +427,29 @@ func (s *testSafetyStore) ConfirmRiskChallenge(_ types.TenantID, uid types.Uid,
return s.riskChallenge, nil
}
func (s *testSafetyStore) AbuseReasons(_ types.TenantID, targetType string) ([]types.AbuseReason, error) {
func (s *testSafetyStore) AbuseReasons(tenantID types.TenantID, targetType string) ([]types.AbuseReason, error) {
s.tenantID = tenantID
s.targetType = targetType
if len(s.reasons) == 0 {
s.reasons = []types.AbuseReason{{TargetType: targetType, ReasonCode: "fraud", Label: "诈骗欺诈", AllowsEvidence: true}}
}
return s.reasons, nil
}
func (s *testSafetyStore) CreateAbuseReport(_ types.TenantID, report *types.AbuseReport, _ string) (*types.AbuseReport, error) {
func (s *testSafetyStore) CreateAbuseReport(tenantID types.TenantID, report *types.AbuseReport, idempotencyKey string) (*types.AbuseReport, error) {
now := time.Now().UTC().Round(time.Millisecond)
report.Status = types.AbuseReportStatusReceived
report.CreatedAt = now
report.UpdatedAt = now
s.abuseReport = report
s.tenantID = tenantID
s.idempotency = idempotencyKey
return report, nil
}
func (s *testSafetyStore) FeedbackConfig(types.TenantID, types.Uid) (*types.FeedbackConfig, error) {
func (s *testSafetyStore) FeedbackConfig(tenantID types.TenantID, uid types.Uid) (*types.FeedbackConfig, error) {
s.tenantID = tenantID
s.uid = uid
now := time.Now().UTC().Round(time.Millisecond)
return &types.FeedbackConfig{
Categories: []string{"bug", "suggestion"},
@@ -427,12 +461,14 @@ func (s *testSafetyStore) FeedbackConfig(types.TenantID, types.Uid) (*types.Feed
}, nil
}
func (s *testSafetyStore) CreateFeedback(_ types.TenantID, feedback *types.FeedbackSubmission, _ string) (*types.FeedbackSubmission, error) {
func (s *testSafetyStore) CreateFeedback(tenantID types.TenantID, feedback *types.FeedbackSubmission, idempotencyKey string) (*types.FeedbackSubmission, error) {
now := time.Now().UTC().Round(time.Millisecond)
feedback.Status = types.FeedbackStatusReceived
feedback.CreatedAt = now
feedback.UpdatedAt = now
s.feedback = feedback
s.tenantID = tenantID
s.idempotency = idempotencyKey
return feedback, nil
}
@@ -448,12 +484,26 @@ func (s *testCallsStore) Finish(_ types.TenantID, _ string, _ int, _ string, _ i
return nil
}
func (s *testCallsStore) List(_ types.TenantID, _ types.Uid, topic, _ string, _ int) ([]types.CallRecord, string, error) {
func (s *testCallsStore) List(tenantID types.TenantID, uid types.Uid, topic, cursor string, limit int) ([]types.CallRecord, string, error) {
s.listed = true
s.tenant = tenantID
s.user = uid
s.topic = topic
return s.items, "", nil
s.cursor = cursor
s.limit = limit
return s.items, s.next, nil
}
func (s *testFavoritesStore) Create(_ types.TenantID, item *types.FavoriteItem) (*types.FavoriteItem, error) {
for i := range s.items {
existing := &s.items[i]
if existing.UserID == item.UserID && existing.SourceTopic == item.SourceTopic && existing.SourceSeq == item.SourceSeq {
return existing, nil
}
if item.IdempotencyKey != "" && existing.UserID == item.UserID && existing.IdempotencyKey == item.IdempotencyKey {
return existing, nil
}
}
s.created = item
s.items = append(s.items, *item)
return item, nil
@@ -535,8 +585,11 @@ func (s *testRelationsStore) UnblockUser(_ types.TenantID, _, _ types.Uid) error
return nil
}
func (s *testRelationsStore) DeleteFriend(_ types.TenantID, _, _ types.Uid, _ time.Time) error {
func (s *testRelationsStore) DeleteFriend(tenantID types.TenantID, userID, peerID types.Uid, _ time.Time) error {
s.deleted = true
s.deleteTenant = tenantID
s.deleteUser = userID
s.deletePeer = peerID
return nil
}
@@ -1125,8 +1178,18 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) {
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSecurity := store.Security
store.Security = &testSecurityStore{}
defer func() { store.Security = oldSecurity }()
oldWorkbench := store.Workbench
security := &testSecurityStore{realName: &types.RealNameVerification{Status: "verified"}}
workbench := &testWorkbenchStore{version: 9, modules: []types.WorkbenchModule{
{Code: "organization", Name: "组织架构", Route: "/organization", Enabled: true, Permission: "enabled"},
{Code: "external", Name: "外链", Route: "https://example.com", Enabled: true, Permission: "enabled"},
}}
store.Security = security
store.Workbench = workbench
defer func() {
store.Security = oldSecurity
store.Workbench = oldWorkbench
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
req.Header.Set("Authorization", "Bearer token-1")
@@ -1141,6 +1204,23 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) {
if data["features"] == nil || data["permissions"] == nil || data["system_topic"] == nil || data["message_policy"] == nil {
t.Fatalf("bootstrap missing home capabilities: %#v", data)
}
if security.realNameTenant != 7 || security.realNameUser != 42 {
t.Fatalf("bootstrap realname lookup scope: tenant=%d uid=%s", security.realNameTenant, security.realNameUser)
}
if !workbench.calledOnce || workbench.tenantID != 7 || workbench.uid != 42 {
t.Fatalf("bootstrap workbench lookup scope: store=%#v", workbench)
}
if data["workbench_version"].(float64) != 9 {
t.Fatalf("unexpected bootstrap workbench version: %#v", data)
}
modules := data["modules"].([]any)
if len(modules) != 1 || modules[0].(map[string]any)["code"] != "organization" {
t.Fatalf("unexpected bootstrap modules: %#v", modules)
}
realname := data["security"].(map[string]any)["realname"].(map[string]any)
if realname["status"] != "verified" {
t.Fatalf("unexpected bootstrap realname status: %#v", realname)
}
}
func TestServeIMWorkbench(t *testing.T) {
@@ -1706,7 +1786,7 @@ func TestServeIMDevicePushToken(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if tokenStore.token == nil || tokenStore.token.DeviceID != "device-1" || tokenStore.token.Token != "push-token-1" {
if tokenStore.tenantID != 7 || tokenStore.token == nil || tokenStore.token.UserID != 42 || tokenStore.token.DeviceID != "device-1" || tokenStore.token.Token != "push-token-1" {
t.Fatalf("unexpected stored push token: %#v", tokenStore.token)
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
@@ -1763,10 +1843,76 @@ func TestServeIMConversationSearch(t *testing.T) {
}
}
func TestServeIMConversationSearchNoDisclosureOnNoMatch(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldUsers := store.Users
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
store.Users = users
defer func() { store.Users = oldUsers }()
now := time.Now().UTC().Round(time.Millisecond)
bob := types.Subscription{
ObjHeader: types.ObjHeader{UpdatedAt: now},
Topic: "grpBob",
ReadSeqId: 9,
}
bob.SetPublic(map[string]any{"fn": "Bob Group"})
bob.SetSeqId(9)
bob.SetTouchedAt(now)
users.EXPECT().GetTopics(types.TenantID(7), types.Uid(42), gomock.Any()).Return([]types.Subscription{bob}, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/im/search/conversations?q=alice", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMConversationSearch(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if len(data["items"].([]any)) != 0 {
t.Fatalf("conversation search leaked unmatched topic: %#v", data)
}
}
func TestServeIMConversationSearchExpiredTokenDoesNotQueryTopics(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
oldUsers := store.Users
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
store.Store = mockStore
store.Users = users
defer func() {
store.Store = oldStore
store.Users = oldUsers
}()
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
tokenAuth.EXPECT().IsInitialized().Return(true)
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("expired-token")).Return(nil, nil, types.ErrExpired)
req := httptest.NewRequest(http.MethodGet, "/v1/im/search/conversations?q=alice", nil)
req.Header.Set("Authorization", "Bearer expired-token")
rec := httptest.NewRecorder()
serveIMConversationSearch(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String())
}
}
func TestServeIMSystemNotificationsListAndRead(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldSecurity := store.Security
readAt := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
security := &testSecurityStore{notifications: []types.SystemNotification{{
TenantID: 7,
UserID: 42,
@@ -1776,12 +1922,21 @@ func TestServeIMSystemNotificationsListAndRead(t *testing.T) {
Summary: "iPhone 登录",
Action: types.KVMap{"type": "security_center"},
CreatedAt: time.Now().UTC().Round(time.Millisecond),
}}}
}, {
TenantID: 7,
UserID: 42,
EventID: "evt-2",
EventType: "system.notice",
Title: "系统公告",
Summary: "已读公告",
ReadAt: &readAt,
CreatedAt: readAt,
}}, nextCursor: "cursor-2"}
store.Security = security
defer func() { store.Security = oldSecurity }()
expectIMBearer(t, ctrl, "token-list", 7, 42)
req := httptest.NewRequest(http.MethodGet, "/v1/im/system-notifications", nil)
req := httptest.NewRequest(http.MethodGet, "/v1/im/system-notifications?cursor=cursor-1&limit=2", nil)
req.Header.Set("Authorization", "Bearer token-list")
rec := httptest.NewRecorder()
@@ -1791,9 +1946,19 @@ func TestServeIMSystemNotificationsListAndRead(t *testing.T) {
t.Fatalf("list status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if len(data["items"].([]any)) != 1 {
items := data["items"].([]any)
if len(items) != 2 {
t.Fatalf("unexpected list response: %#v", data)
}
if _, ok := items[0].(map[string]any)["read_at"]; ok {
t.Fatalf("unread notification unexpectedly has read_at: %#v", items[0])
}
if items[1].(map[string]any)["read_at"] == nil {
t.Fatalf("read notification missing read_at: %#v", items[1])
}
if data["next_cursor"] != "cursor-2" || security.listTenant != 7 || security.listUser != 42 || security.listCursor != "cursor-1" || security.listLimit != 2 {
t.Fatalf("notification list scope/paging mismatch: data=%#v store=%#v", data, security)
}
expectIMBearer(t, ctrl, "token-read", 7, 42)
req = httptest.NewRequest(http.MethodPost, "/v1/im/system-notifications/evt-1/read", bytes.NewBufferString(`{}`))
@@ -1806,7 +1971,7 @@ func TestServeIMSystemNotificationsListAndRead(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("read status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if security.readNotificationID != "evt-1" || security.notifications[0].ReadAt == nil {
if security.readTenant != 7 || security.readUser != 42 || security.readNotificationID != "evt-1" || security.notifications[0].ReadAt == nil {
t.Fatalf("notification was not marked read: %#v", security)
}
}
@@ -2446,6 +2611,58 @@ func TestServeIMFavoritesListDetailBatchDelete(t *testing.T) {
}
}
func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
mockStore.EXPECT().GetUidString().Return("new-fav")
oldFavorites := store.Favorites
oldMessages := store.Messages
now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{
TenantID: 7,
FavoriteID: "fav_existing",
UserID: 42,
ItemType: "text",
SourceTopic: "grpAlpha",
SourceSeq: 3,
Snapshot: map[string]any{"text": "hello"},
Availability: "available",
CollectedAt: now,
}}}
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Messages = messages
t.Cleanup(func() {
store.Favorites = oldFavorites
store.Messages = oldMessages
})
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", 3).Return(&types.Message{
TenantID: 7,
Topic: "grpAlpha",
SeqId: 3,
From: types.Uid(42).String(),
Head: types.KVMap{"type": "text"},
Content: map[string]any{"text": "hello"},
ObjHeader: types.ObjHeader{
CreatedAt: now,
},
}, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites", bytes.NewBufferString(`{"messages":[{"topic":"grpAlpha","seq":"3"}]}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMFavorites(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("create status: got %d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["created_count"].(float64) != 0 || data["existing_count"].(float64) != 1 {
t.Fatalf("unexpected favorite counts: %#v", data)
}
}
func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -2517,6 +2734,51 @@ func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
}
}
func TestServeIMFavoriteForwardRejectsUnauthorizedTarget(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldFavorites := store.Favorites
oldSubs := store.Subs
oldMessages := store.Messages
now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{
TenantID: 7,
FavoriteID: "fav_1",
UserID: 42,
ItemType: "text",
SourceTopic: "grpAlpha",
SourceSeq: 3,
Snapshot: map[string]any{"text": "hello"},
Availability: "available",
CollectedAt: now,
}}}
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Subs = subs
store.Messages = messages
t.Cleanup(func() {
store.Favorites = oldFavorites
store.Subs = oldSubs
store.Messages = oldMessages
})
subs.EXPECT().Get(types.TenantID(7), "grpDenied", types.Uid(42), false).Return(nil, types.ErrPermissionDenied)
req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites/forward", bytes.NewBufferString(`{"favorite_ids":["fav_1"],"target_topics":["grpDenied"]}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMFavoriteResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("forward status: got %d body=%s", rec.Code, rec.Body.String())
}
items := decodeIMHTTPResponse(t, rec).Data.(map[string]any)["items"].([]any)
if got := items[0].(map[string]any); got["status"] != "failed" || got["reason"] != imReasonPermissionDenied {
t.Fatalf("unexpected forward response: %#v", got)
}
}
func TestServeIMSecurityURLCheck(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -2587,6 +2849,9 @@ func TestServeIMRiskChallengeConfirm(t *testing.T) {
if data["state"] != types.RiskChallengeStateContinued || data["risk_action_token"] == "" {
t.Fatalf("unexpected risk confirm response: %#v", data)
}
if safety := store.Safety.(*testSafetyStore); safety.tenantID != 7 || safety.uid != 42 || safety.decision != "continue" || safety.actionTokenID == "" {
t.Fatalf("risk challenge scope mismatch: %#v", safety)
}
}
func TestServeIMAbuseReportsAndReasons(t *testing.T) {
@@ -2612,6 +2877,9 @@ func TestServeIMAbuseReportsAndReasons(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("reasons status: got %d body=%s", rec.Code, rec.Body.String())
}
if safety.tenantID != 7 || safety.targetType != "user" {
t.Fatalf("abuse reason scope mismatch: %#v", safety)
}
req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/abuse",
bytes.NewBufferString(`{"target":{"type":"user","user_id":"usrKwAAAAAAAAA"},"reason_code":"fraud","description":"疑似诈骗","evidence_file_ids":["file1"]}`))
@@ -2622,7 +2890,7 @@ func TestServeIMAbuseReportsAndReasons(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("report status: got %d body=%s", rec.Code, rec.Body.String())
}
if safety.abuseReport == nil || safety.abuseReport.TargetUserID != 43 || safety.abuseReport.ReasonCode != "fraud" {
if safety.tenantID != 7 || safety.idempotency != "abuse-1" || safety.abuseReport == nil || safety.abuseReport.ReporterID != 42 || safety.abuseReport.TargetUserID != 43 || safety.abuseReport.ReasonCode != "fraud" {
t.Fatalf("unexpected abuse report: %#v", safety.abuseReport)
}
}
@@ -2643,6 +2911,9 @@ func TestServeIMFeedback(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("config status: got %d body=%s", rec.Code, rec.Body.String())
}
if safety.tenantID != 7 || safety.uid != 42 {
t.Fatalf("feedback config scope mismatch: %#v", safety)
}
req = httptest.NewRequest(http.MethodPost, "/v1/im/feedback",
bytes.NewBufferString(`{"category":"bug","content":"页面显示异常","contact":"me@example.com","attachment_file_ids":["file1"],"client_context":{"platform":"android"},"include_diagnostics":true}`))
@@ -2653,7 +2924,7 @@ func TestServeIMFeedback(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("submit status: got %d body=%s", rec.Code, rec.Body.String())
}
if safety.feedback == nil || safety.feedback.Category != "bug" || !safety.feedback.IncludeDiagnostics {
if safety.tenantID != 7 || safety.idempotency != "feedback-1" || safety.feedback == nil || safety.feedback.UserID != 42 || safety.feedback.Category != "bug" || !safety.feedback.IncludeDiagnostics {
t.Fatalf("unexpected feedback: %#v", safety.feedback)
}
}
@@ -2752,15 +3023,15 @@ func TestServeIMTopicCalls(t *testing.T) {
TenantID: 7, User: types.Uid(42).String(), Topic: "p2pTopic", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic,
}, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/im/topics/p2pTopic/calls", nil)
req := httptest.NewRequest(http.MethodGet, "/v1/im/topics/p2pTopic/calls?cursor=c1&limit=1", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMTopicMessageAction(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("calls status: got %d body=%s", rec.Code, rec.Body.String())
}
if calls.topic != "p2pTopic" {
t.Fatalf("calls topic: got %q", calls.topic)
if !calls.listed || calls.tenant != 7 || calls.user != 42 || calls.topic != "p2pTopic" || calls.cursor != "c1" || calls.limit != 1 {
t.Fatalf("calls list scope mismatch: %#v", calls)
}
var resp imHTTPResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
@@ -2777,6 +3048,34 @@ func TestServeIMTopicCalls(t *testing.T) {
}
}
func TestServeIMTopicCallsRejectsUnauthorizedWithoutListing(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSubs := store.Subs
oldCalls := store.Calls
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
calls := &testCallsStore{}
store.Subs = subs
store.Calls = calls
defer func() {
store.Subs = oldSubs
store.Calls = oldCalls
}()
subs.EXPECT().Get(types.TenantID(7), "p2pDenied", types.Uid(42), false).Return(nil, types.ErrPermissionDenied)
req := httptest.NewRequest(http.MethodGet, "/v1/im/topics/p2pDenied/calls", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMTopicMessageAction(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("calls status: got %d body=%s", rec.Code, rec.Body.String())
}
if calls.listed {
t.Fatalf("calls were listed despite missing read permission: %#v", calls)
}
}
func TestServeIMMeQRCodesAndResolve(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -3128,6 +3427,41 @@ func TestServeIMFriendRemark(t *testing.T) {
}
}
func TestServeIMFriendDeleteReturnsCannotSendContext(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldRelations := store.Relations
peer := types.Uid(2)
relations := &testRelationsStore{context: &types.RelationshipContext{
TenantID: 7,
UserID: 1,
PeerID: peer,
Topic: types.Uid(1).P2PName(peer),
Relationship: types.RelationshipNone,
CanSend: false,
}}
store.Relations = relations
defer func() { store.Relations = oldRelations }()
req := httptest.NewRequest(http.MethodDelete, "/v1/im/friends/"+peer.UserId(), nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMFriendResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !relations.deleted || relations.deleteTenant != 7 || relations.deleteUser != 1 || relations.deletePeer != peer {
t.Fatalf("friend delete scope mismatch: %#v", relations)
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["relationship"] != types.RelationshipNone || data["can_send"] != false {
t.Fatalf("friend delete did not return blocked send context: %#v", data)
}
}
func TestServeIMUserSearch(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
+21 -4
View File
@@ -219,6 +219,15 @@ type testSecurityStore struct {
lockedUntil *time.Time
notifications []types.SystemNotification
readNotificationID string
realNameTenant types.TenantID
realNameUser types.Uid
listTenant types.TenantID
listUser types.Uid
listCursor string
listLimit int
nextCursor string
readTenant types.TenantID
readUser types.Uid
}
func (s *testSecurityStore) GetAuthVersion(types.TenantID, types.Uid) (int, error) {
@@ -238,7 +247,9 @@ func (s *testSecurityStore) BumpAuthVersion(_ types.TenantID, uid types.Uid, _ t
return s.version, s.err
}
func (s *testSecurityStore) GetRealName(types.TenantID, types.Uid) (*types.RealNameVerification, error) {
func (s *testSecurityStore) GetRealName(tenantID types.TenantID, uid types.Uid) (*types.RealNameVerification, error) {
s.realNameTenant = tenantID
s.realNameUser = uid
if s.err != nil {
return nil, s.err
}
@@ -340,11 +351,17 @@ func (s *testSecurityStore) CreateSystemNotification(_ types.TenantID, notificat
return s.err
}
func (s *testSecurityStore) ListSystemNotifications(types.TenantID, types.Uid, string, int) ([]types.SystemNotification, string, error) {
return s.notifications, "", s.err
func (s *testSecurityStore) ListSystemNotifications(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.SystemNotification, string, error) {
s.listTenant = tenantID
s.listUser = uid
s.listCursor = cursor
s.listLimit = limit
return s.notifications, s.nextCursor, s.err
}
func (s *testSecurityStore) MarkSystemNotificationRead(_ types.TenantID, _ types.Uid, eventID string, readAt time.Time) error {
func (s *testSecurityStore) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error {
s.readTenant = tenantID
s.readUser = uid
s.readNotificationID = eventID
for i := range s.notifications {
if s.notifications[i].EventID == eventID {