Files
MailUI4Agents/server/internal/handler/admin_scope_test.go
JianFeeeee 7b0207b621 feat(线索树)★★: 卡片视图接线 + C 层 admin 全量邮件
## ① 卡片视图(WorkCard)

上一轮只接了列表视图 —— 但用户原话是「形成/展示为树结构」,
只在一个视图里成立不算:**切一下视图,线索树就没了**。

缩进比列表更紧(8px/级、上限 3 级):卡片本来就有三块内容
(发件人行 / 主题 / 摘要 + 预算条),400px 侧栏里每级 12px
挤掉的是**摘要本身** —— 摘要没了,卡片就只剩一个标题。

判据加一格专门盯「两个视图都接了」,否则这格缺口会一直没人看。

## ② C 层:admin 全量邮件(scope=all)

    GET /api/v1/me/mail/inbox?scope=all    admin 才有效
    GET /api/v1/me/mail/inbox              默认,仍是自己收件箱

三条边界,两个变异都转红:

| | 行为 | 变异后 |
|---|---|---|
| 非 admin 要 scope=all | **403** | 静默降级 200 ⇒ 转红 |
| 默认(无 scope) | 自己的,一封不多 | 身份隐式全给 ⇒ 转红 |
| 非法 scope | 400 | — |

**静默降级是最危险的那个**:调用方会以为拿到了全量(实际没有)——
「看起来能用的错答案」,比报错难查得多。

**默认不给 admin 全量**:全看必须显式要求,不能靠身份隐式获得。

### 为什么单独写 ListAllMails,没给 ListInboxScoped 加参数

`ListInboxScoped` 的第一个参数 `agentName` 兼任两职:
  ① SQL 里的 reader 过滤
  ② `readStateFor("$1")` 算 status(已读/未读是**按读者**记的)
两者都必须有值 —— `requireReader` 就是为此存在。

若把「全量」做成「reader 传空」,那个非法状态看起来就合法了;
一旦放进去,**status 会静默变成未读** —— 一个没人会注意到、
却让「已读/未读」全面失真的坑。

同理,admin 全量视图里 `status=unread` **明确报错**而不是返回全部 ——
后者会让前端把整箱染成"未读"。admin 不是任何一封信的读者。

### scanMailRows 提取(repo.go +16/-0,纯新增)

第二份手写扫描副本的第一个分叉点必然是「admin 视图少算一个派生字段」,
而那在前端表现为某个徽标不见了,极难归因。⇒ 两处共用一份,
`ListInboxScoped` 行为一行未改。

## ★ 这不触碰 Agent 侧的收窄

`ListInboxScoped` 里 `to_name = reader OR cc` 那条是 **AgentAuth** 用的,
是 15e4fe9 / 095213b 修出来的越权防护。`scope=all` 是**人类登录态**下
admin 的显式全量视图,两条通道互不相干 —— 语义不同,不要混谈。

## 判据自己错了一次

`TestAdminScopeAllSeesEverything` 红在 500,报
`Scan: invalid UUID length: 7` —— 看着像 SQL/扫描代码坏了,
其实是判据自己的数据不对(`mail_id` 是 UUID,我塞了 `"m-admin"`)。
**判据数据错了会伪装成被测代码坏了。**

14 包全绿;Electron 带改动 fail 9 / 基线 fail 12(减少 3 红、无新红,
剩下的是其他会话 harmony 设备判据的红)。
2026-10-04 12:03:58 +08:00

162 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
/*
C 层:admin 全量邮件可见性的判据(2026-10-04)。
用户要求:「admin 查看全部邮件,普通用户仅查看与自己相关或显式分享的邮件」。
★ 盯的是**分层可见性**,不是「scope 参数存在」:
1. 非 admin 显式请求 scope=all ⇒ **403**,不是静默回退到自己的收件箱。
静默降级会让调用方以为拿到了全量 —— 那是「看起来能用的错答案」。
2. 默认(无 scope)⇒ 仍是自己<E887AA><E5B7B1>收件箱,一封都不多给。
★ 这条与 Agent 侧的收窄(15e4fe9 / 095213b 修的越权防护)无关 ——
那是 AgentAuth 通道;本文件是人<E698AF><E4BABA><EFBFBD>登录态。别拿「admin 要全看」
当理由去放宽 ListInboxScoped。
3. 非法 scope ⇒ 400,不是当成没给。
*/
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
func setupAdminScopeDB(t *testing.T) {
t.Helper()
db.Close()
path := filepath.Join(t.TempDir(), "admin-scope.db")
if err := db.Connect(context.Background(), "sqlite://"+path); err != nil {
t.Fatalf("连接测试库: %v", err)
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("迁移: %v", err)
}
t.Cleanup(db.Close)
}
// seed 两封信:一封给 admin,一封只给普通用户。
func seedScopeMails(t *testing.T) (adminMailID, userMailID string) {
t.Helper()
ctx := context.Background()
for _, u := range []string{"boss", "intern"} {
if _, err := db.DB.ExecContext(ctx,
`INSERT INTO users (username, password_hash, role) VALUES ($1,'x',$2)`,
u, map[string]string{"boss": "admin", "intern": "user"}[u]); err != nil {
t.Fatalf("建用户 %s: %v", u, err)
}
}
var sid string
if err := db.DB.QueryRowContext(ctx,
`INSERT INTO sessions (session_alias, subject, status, workspace, from_agent)
VALUES ('s','x','active','/tmp','pi') RETURNING session_id`).Scan(&sid); err != nil {
t.Fatalf("建会话: %v", err)
}
// ★ mail_id 是 **UUID**:初版这里塞了 "m-admin" 这种字符串,
// 报出来是 `Scan: invalid UUID length: 7` —— 看着像 SQL/扫描代码坏了,
// 其实是判据自己的数据不对。判据数据错了会伪装成被测代码坏了。
for _, spec := range []struct{ tag, to, subj, at string }{
{"admin", "boss", "给管理员的信", "2026-01-02T00:00:00Z"},
{"intern", "intern", "只给普通用户的信", "2026-01-01T00:00:00Z"},
} {
mid := uuid.New()
if _, err := db.DB.ExecContext(ctx,
`INSERT INTO mails (mail_id, session_id, from_name, from_workspace,
to_name, to_workspace, subject, body, mail_type, created_at)
VALUES ($1,$2,'pi','/tmp',$3,'/tmp',$4,'b','normal',$5)`,
mid, sid, spec.to, spec.subj, spec.at,
); err != nil {
t.Fatalf("建邮件 %s: %v", spec.tag, err)
}
if spec.tag == "admin" {
adminMailID = mid.String()
} else {
userMailID = mid.String()
}
}
return adminMailID, userMailID
}
func reqAsUser(t *testing.T, u models.User, url string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, url, nil)
req = req.WithContext(context.WithValue(req.Context(),
middleware.UserKey, &u))
MeGetInbox(rec, req)
return rec
}
func TestAdminScopeAllSeesEverything(t *testing.T) {
setupAdminScopeDB(t)
_, userMailID := seedScopeMails(t)
admin := models.User{Username: "boss", Role: "admin"}
rec := reqAsUser(t, admin, "/api/v1/me/mail/inbox?scope=all")
if rec.Code != http.StatusOK {
t.Fatalf("admin scope=all 应 200,实际 %d:%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "只给普通用户的信") {
t.Errorf("★ admin scope=all 应看到别人的信,实际:%s", rec.Body.String())
}
_ = userMailID
}
func TestNonAdminScopeAllIs403NotSilentlyNarrowed(t *testing.T) {
setupAdminScopeDB(t)
adminMailID, _ := seedScopeMails(t)
intern := models.User{Username: "intern", Role: "user"}
rec := reqAsUser(t, intern, "/api/v1/me/mail/inbox?scope=all")
// ★ 403 而不是 200:静默降级会让调用方以为拿到了全量
if rec.Code != http.StatusForbidden {
t.Fatalf("★ 普通用户 scope=all 应 403,实际 %d:%s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "给管理员的信") {
t.Errorf("★ 403 的响应体里不该带任何邮件内容:%s", rec.Body.String())
}
_ = adminMailID
}
func TestDefaultScopeStaysOwnInbox(t *testing.T) {
setupAdminScopeDB(t)
adminMailID, _ := seedScopeMails(t)
// ★ 连 admin 在**默认**(无 scope)下也只能看自己的 ——
// 「admin 全看」必须显式要求,不能靠身份隐式获得。
admin := models.User{Username: "boss", Role: "admin"}
rec := reqAsUser(t, admin, "/api/v1/me/mail/inbox")
if rec.Code != http.StatusOK {
t.Fatalf("默认应 200,实际 %d:%s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "只给普通用户的信") {
t.Errorf("★ 默认 scope 下 admin 也不该看到别人的信(须显式 scope=all):%s",
rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "给管理员的信") {
t.Errorf("自己的信反而没了:%s", rec.Body.String())
}
_ = adminMailID
}
func TestBadScopeIs400(t *testing.T) {
setupAdminScopeDB(t)
seedScopeMails(t)
admin := models.User{Username: "boss", Role: "admin"}
rec := reqAsUser(t, admin, "/api/v1/me/mail/inbox?scope=bogus")
if rec.Code != http.StatusBadRequest {
t.Errorf("非法 scope 应 400(不是当成没给),实际 %d:%s", rec.Code, rec.Body.String())
}
}