diff --git a/.gitignore b/.gitignore index 95b9a02..62239be 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ # Local Go build output. /server/server +/.codex-gocache/ /.github/ diff --git a/docs/test/im_frontend_backend_1_11_unit_test_cases.md b/docs/test/im_frontend_backend_1_11_unit_test_cases.md new file mode 100644 index 0000000..7b3e164 --- /dev/null +++ b/docs/test/im_frontend_backend_1_11_unit_test_cases.md @@ -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 搜索。 diff --git a/server/auth/token/auth_token_test.go b/server/auth/token/auth_token_test.go index 1d4be77..eccd23b 100644 --- a/server/auth/token/auth_token_test.go +++ b/server/auth/token/auth_token_test.go @@ -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 } diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index 46c2fda..df7ccbc 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -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) )`, diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 6d47558..e897ec7 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -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) { diff --git a/server/hdl_files.go b/server/hdl_files.go index 357d5f1..015c81e 100644 --- a/server/hdl_files.go +++ b/server/hdl_files.go @@ -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 } diff --git a/server/hdl_files_test.go b/server/hdl_files_test.go index 4b851e7..e70c0bb 100644 --- a/server/hdl_files_test.go +++ b/server/hdl_files_test.go @@ -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) +} diff --git a/server/im_http.go b/server/im_http.go index 394b8e9..8da592a 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -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() { diff --git a/server/im_http_test.go b/server/im_http_test.go index 4cb913d..2112c5c 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -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() diff --git a/server/user_verification_test.go b/server/user_verification_test.go index 531ab50..e750b02 100644 --- a/server/user_verification_test.go +++ b/server/user_verification_test.go @@ -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 {