package push import ( "bytes" "context" "encoding/json" "io" "log" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "github.com/agentmail/gateway/internal/db" "github.com/agentmail/gateway/internal/repo" ) /* 推送通道的判据(2026-09-15)。 用户对这件事的要求是**可选**:「不能写死推送方式,因为我们是自部署后端」 「即推送密钥应当是可选项」。所以第一组判据钉的不是"推得出去",而是 **没配凭证时它必须彻底不存在**(不查库、不占 goroutine、不刷日志)。 第二组钉华为那条路的具体形状 —— 那些常量是拿真凭证打真接口问出来的 (见 hms.go 的注释),判据把形状钉住,避免以后"顺手改一下"就悄悄失效。 */ // ─── 夹具 ──────────────────────────────────────────────────────────────── type fakeNotifier struct { name string max int mu sync.Mutex calls [][]string last NewMail } func (f *fakeNotifier) Name() string { return f.name } func (f *fakeNotifier) MaxTokensPerRequest() int { return f.max } func (f *fakeNotifier) Send(_ context.Context, tokens []string, n NewMail) error { f.mu.Lock() defer f.mu.Unlock() f.calls = append(f.calls, append([]string(nil), tokens...)) f.last = n return nil } func (f *fakeNotifier) sizes() []int { f.mu.Lock() defer f.mu.Unlock() out := make([]int, 0, len(f.calls)) for _, c := range f.calls { out = append(out, len(c)) } return out } // withProviders 临时替换全局通道表(测试之间互不影响)。 func withProviders(t *testing.T, ps ...Notifier) { t.Helper() mu.Lock() saved := providers providers = nil mu.Unlock() for _, p := range ps { Register(p) } t.Cleanup(func() { mu.Lock() providers = saved mu.Unlock() }) } func setupPushDB(t *testing.T) { t.Helper() dir := t.TempDir() if err := db.Connect(context.Background(), filepath.Join(dir, "t.db")); err != nil { t.Fatalf("connect: %v", err) } if err := db.Migrate(context.Background()); err != nil { t.Fatalf("migrate: %v", err) } t.Cleanup(db.Close) } // ─── 一、可选性:没配凭证 = 彻底不存在 ────────────────────────────────── // 没注册任何通道时,推送必须彻底不存在:不分发、不查库、不起 goroutine。 // // 判据分两侧(只验一侧的判据是假的): // - 坏样本:没通道时 shouldDispatch 必须为 false; // - 干净样本:注了通道且有收件人时必须为 true —— 否则“永远返回 false”也能骗过上面一条。 // // 另外真的拿 nil 库调一次 NotifyNewMail 做冒烟(任何 repo 调用都会 panic)。 // 它**不是**主判据:分发在 goroutine 里,与 t.Cleanup 赛跑 —— 2026-09-15 变异验证 // 实测过:只写这一点时,去掉 push.go 的 Enabled() 早退仍然绿(判据在错误的理由上通过)。 func TestNoProvidersTouchesNothing(t *testing.T) { withProviders(t) // 一个都不注册 saved := db.DB db.DB = nil t.Cleanup(func() { db.DB = saved }) if Enabled() { t.Fatal("一个通道都没注册时 Enabled() 必须是 false") } if names := Names(); len(names) != 0 { t.Fatalf("没注册通道时不该有名字,实际 %v", names) } if shouldDispatch(NewMail{Recipients: []string{"alice"}}) { t.Fatal("没配任何推送通道时分发必须被跳过(零开销)") } if shouldDispatch(NewMail{}) { t.Fatal("没有收件人时不该分发") } // 冒烟:走完 NotifyNewMail 不该碰库 NotifyNewMail(context.Background(), NewMail{ MailID: "m1", SessionID: "s1", From: "bob", Subject: "你好", Recipients: []string{"alice"}, }) NotifyNewMail(context.Background(), NewMail{MailID: "m2"}) // 干净样本:注了通道 + 有收件人 → 必须分发 withProviders(t, &fakeNotifier{name: "fake", max: 10}) if !shouldDispatch(NewMail{Recipients: []string{"alice"}}) { t.Fatal("配了通道且有收件人时必须分发(否则这条判据挡不住“永远不分发”的实现)") } if shouldDispatch(NewMail{}) { t.Fatal("注了通道但没有收件人时仍不该分发") } } /* 配置式多厂商接入的判据(2026-09-15 用户的第二条要求)。 原话:「我们要支持多厂商配置式接入统一推送服务,推送密钥(文件)应当是配置项, 用户可为不同的推送服务配置对应的凭证,因为我们的设计中是每个用户各自部署服务器」。 所以这里钉四件事: 1. 配置能真的造出多个通道(而不是只有一个 HMS 硬编码路径); 2. **密钥文件**能作为配置项用(app_secret_file),且权限过松会提醒; 3. **单项配错不能拖垮服务**:未知类型 / 密钥读不到 / 被 disabled → 只跳过那一条; 4. 没配文件 = 没推送,且不是错误(自部署实例的默认形态)。 */ func writePushConfig(t *testing.T, body string) string { t.Helper() path := filepath.Join(t.TempDir(), "push.json") if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) } t.Setenv("PUSH_CONFIG", path) return path } func writeSecret(t *testing.T, mode os.FileMode) string { t.Helper() p := filepath.Join(t.TempDir(), "hms.secret") if err := os.WriteFile(p, []byte("sec-from-file\n"), mode); err != nil { t.Fatal(err) } return p } // 未知的 type 只跳过自己;同实例可以同时接多个厂商。 func TestConfigFileDrivesMultipleProviders(t *testing.T) { RegisterType("test-echo", func(cfg ProviderConfig) (Notifier, error) { return &fakeNotifier{name: nameOf(cfg), max: 3}, nil }) secret := writeSecret(t, 0o600) writePushConfig(t, `{ "providers": [ {"type":"hms","app_id":"app-1","app_secret_file":`+jsonStr(secret)+`}, {"type":"test-echo","name":"echo-a"}, {"type":"vendor-not-implemented","app_id":"x"}, {"type":"hms","name":"hms-disabled","enabled":false,"app_id":"a","app_secret":"s"} ] }`) ps := LoadProviders() names := map[string]Notifier{} for _, p := range ps { names[p.Name()] = p } if len(ps) != 2 { t.Fatalf("应启用 2 个通道(hms + test-echo),实际 %d: %v", len(ps), names) } if _, ok := names["hms"]; !ok { t.Fatalf("缺 hms 通道: %v", names) } if _, ok := names["echo-a"]; !ok { t.Fatalf("Name 覆盖没生效(应叫 echo-a): %v", names) } // 密钥来自**文件**(配置项),而不是内联 h, _ := names["hms"].(*HMS) if h == nil || h.AppSecret != "sec-from-file" { t.Fatalf("app_secret_file 没被读进来: %+v", h) } // 缺省 = 测试消息开(未上架应用只有这个模式收得到) if !h.TestMessage { t.Fatal("test_message 缺省必须为 true(未上架应用)") } if h.DailyLimit != 1000 { t.Fatalf("daily_limit 缺省应为 1000(华为测试消息的项目级限制),实际 %d", h.DailyLimit) } } // 密钥文件权限过松要提醒(一个 0644 的密钥文件是真实的配置错误),但不拦启动。 func TestConfigWarnsOnLooseSecretFile(t *testing.T) { secret := writeSecret(t, 0o644) writePushConfig(t, `{"providers":[{"type":"hms","app_id":"a","app_secret_file":`+jsonStr(secret)+`}]}`) var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) if ps := LoadProviders(); len(ps) != 1 { t.Fatalf("权限过松只该提醒不该拒绝,实际 %d 个通道", len(ps)) } log.SetOutput(os.Stderr) if !strings.Contains(buf.String(), "权限") { t.Fatalf("应提醒密钥文件权限过松,实际日志:%s", buf.String()) } } // 单项配错不能拖垮其他项:第一个密钥文件读不到,第二个仍必须启用。 func TestConfigBadEntryDoesNotKillOthers(t *testing.T) { RegisterType("test-echo", func(cfg ProviderConfig) (Notifier, error) { return &fakeNotifier{name: nameOf(cfg), max: 3}, nil }) writePushConfig(t, `{ "providers": [ {"type":"hms","app_id":"a","app_secret_file":"/nonexistent/secret"}, {"type":"hms","name":"hms-ok","app_id":"b","app_secret":"inline"} ] }`) ps := LoadProviders() if len(ps) != 1 || ps[0].Name() != "hms-ok" { t.Fatalf("坏条目应只跳自己,实际 %v", ps) } } // 没配配置文件 = 没推送,而且不是错误。 func TestNoConfigMeansNoProviders(t *testing.T) { t.Setenv("PUSH_CONFIG", filepath.Join(t.TempDir(), "does-not-exist.json")) if ps := LoadProviders(); len(ps) != 0 { t.Fatalf("没配置文件时应 0 个通道,实际 %d", len(ps)) } } // 环境变量是**可选覆盖**:配置里没有同类型条目时补一条;已有则以配置为准。 func TestEnvIsOverrideNotTheMainPath(t *testing.T) { t.Setenv("HMS_APP_ID", "env-app") t.Setenv("HMS_APP_SECRET", "env-secret") t.Setenv("HMS_TEST_MESSAGE", "false") // 1) 配置文件里没有 hms 条目 → 用环境变量补一条 writePushConfig(t, `{"providers":[]}`) ps := LoadProviders() if len(ps) != 1 || ps[0].Name() != "hms" { t.Fatalf("环境变量应能补出一条 hms 通道,实际 %v", ps) } if h := ps[0].(*HMS); h.TestMessage { t.Fatal("HMS_TEST_MESSAGE=false 没生效") } // 2) 配置文件里已有 hms 条目 → 环境变量不得再补一条(避免两个同名通道) writePushConfig(t, `{"providers":[{"type":"hms","name":"hms-from-file","app_id":"f","app_secret":"s"}]}`) ps = LoadProviders() if len(ps) != 1 || ps[0].Name() != "hms-from-file" { t.Fatalf("配置文件优先,环境变量不该再加一条,实际 %v", ps) } } // 密钥文件优先于内联密钥(用户明确要求「推送密钥(文件)应当是配置项」)。 // // 为什么这条值得单独写:两者都给是**常见**情况(配置里留着旧的内联密钥做参考, // 同时切到文件)。不明确优先关系的结果是「改了文件却没生效」这种最难查的静默失效。 // 第一版这条判据缺失,变异验证(把优先级反转)居然全绿 —— 因此补上。 func TestSecretFileBeatsInlineSecret(t *testing.T) { secret := writeSecret(t, 0o600) writePushConfig(t, `{"providers":[{"type":"hms","app_id":"a","app_secret":"inline-wrong","app_secret_file":`+jsonStr(secret)+`}]}`) ps := LoadProviders() if len(ps) != 1 { t.Fatalf("应有 1 个通道,实际 %d", len(ps)) } if h := ps[0].(*HMS); h.AppSecret != "sec-from-file" { t.Fatalf("密钥文件应优先于内联密钥,实际用了 %q", h.AppSecret) } } // 厂自定义文件表(files)也能当密钥配置项用:不是每个厂商都叫 app_secret。 func TestGenericFilesMapWorksAsSecretSource(t *testing.T) { secret := writeSecret(t, 0o600) writePushConfig(t, `{"providers":[{"type":"hms","app_id":"a","files":{"app_secret":`+jsonStr(secret)+`}}]}`) ps := LoadProviders() if len(ps) != 1 { t.Fatalf("files.app_secret 应被当作密钥文件配置项,实际 %d 个通道", len(ps)) } if h := ps[0].(*HMS); h.AppSecret != "sec-from-file" { t.Fatalf("files 里的密钥文件没被读进来: %q", h.AppSecret) } } // 缺密钥的厂商条目由**它自己的 Factory** 判失败(而不是通用配置层): // 通用层要求密钥就会把 Web Push 这类不用 app_secret 的厂商误杀(判据抓出来过)。 func TestMissingSecretIsFactoryBusiness(t *testing.T) { RegisterType("test-nosecret", func(cfg ProviderConfig) (Notifier, error) { return &fakeNotifier{name: nameOf(cfg), max: 1}, nil }) writePushConfig(t, `{"providers":[ {"type":"test-nosecret","name":"no-secret-needed"}, {"type":"hms","app_id":"a"} ]}`) ps := LoadProviders() if len(ps) != 1 || ps[0].Name() != "no-secret-needed" { t.Fatalf("不需要密钥的厂商应能建起来;需要密钥而没给的 hms 应被自己的 Factory 判失败。实际 %v", ps) } } func jsonStr(s string) string { b, _ := json.Marshal(s) return string(b) } // ─── 二、分发:按 provider 分组、按通道声明的上限分批 ──────────────────── func TestDispatchBatchesByProviderLimit(t *testing.T) { setupPushDB(t) ctx := context.Background() // 23 个 token,通道声明的上限是 10 → 必须切成 10/10/3 for i := 0; i < 23; i++ { if err := repo.UpsertPushToken(ctx, "fake", tokName(i), "alice", "", ""); err != nil { t.Fatal(err) } } f := &fakeNotifier{name: "fake", max: 10} withProviders(t, f) dispatch(ctx, NewMail{MailID: "m-1", SessionID: "s-1", From: "bob", Subject: "主题", Recipients: []string{"alice"}}) if got := f.sizes(); len(got) != 3 || got[0] != 10 || got[1] != 10 || got[2] != 3 { t.Fatalf("分批不对:期望 [10 10 3],实际 %v(超限会被华为拒:token count should within 1 and 1,000;测试消息另限 10)", got) } if f.last.MailID != "m-1" || f.last.SessionID != "s-1" || f.last.From != "bob" { t.Fatalf("推给通道的邮件内容不对: %+v", f.last) } } func TestDispatchGroupsByProvider(t *testing.T) { setupPushDB(t) ctx := context.Background() if err := repo.UpsertPushToken(ctx, "a", "t-a1", "alice", "", ""); err != nil { t.Fatal(err) } if err := repo.UpsertPushToken(ctx, "b", "t-b1", "bob", "", ""); err != nil { t.Fatal(err) } fa := &fakeNotifier{name: "a", max: 10} fb := &fakeNotifier{name: "b", max: 10} withProviders(t, fa, fb) dispatch(ctx, NewMail{MailID: "m", Recipients: []string{"alice", "bob"}}) if len(fa.calls) != 1 || len(fa.calls[0]) != 1 || fa.calls[0][0] != "t-a1" { t.Fatalf("通道 a 应只拿到自己的 token,实际 %v", fa.calls) } if len(fb.calls) != 1 || len(fb.calls[0]) != 1 || fb.calls[0][0] != "t-b1" { t.Fatalf("通道 b 应只拿到自己的 token,实际 %v", fb.calls) } } func TestDispatchSkipsRecipientsWithoutTokens(t *testing.T) { setupPushDB(t) f := &fakeNotifier{name: "fake", max: 10} withProviders(t, f) // 谁都没登记过:不该有任何发送,也不该报错 dispatch(context.Background(), NewMail{MailID: "m", Recipients: []string{"nobody"}}) if len(f.calls) != 0 { t.Fatalf("没人登记过就不该发,实际 %v", f.calls) } } func tokName(i int) string { return "tok-" + strings.Repeat("0", 2-len(itoa(i))) + itoa(i) } func itoa(i int) string { if i == 0 { return "0" } var b []byte for i > 0 { b = append([]byte{byte('0' + i%10)}, b...) i /= 10 } return string(b) } // ─── 三、华为通道的具体形状 ───────────────────────────────────────────── // hmsStub 起一个假的华为端点:/token 发令牌,其余路径收推送。 type hmsStub struct { srv *httptest.Server mu sync.Mutex tokenReq int pushReqs []map[string]any authHdrs []string code string } func newHMSStub(t *testing.T) *hmsStub { t.Helper() s := &hmsStub{code: "80000000"} s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/token" { s.mu.Lock() s.tokenReq++ s.mu.Unlock() w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"access_token":"tok-abc","expires_in":3600}`) return } body, _ := io.ReadAll(r.Body) var m map[string]any _ = json.Unmarshal(body, &m) s.mu.Lock() s.pushReqs = append(s.pushReqs, m) s.authHdrs = append(s.authHdrs, r.Header.Get("Authorization")) code := s.code s.mu.Unlock() w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"code":"`+code+`","msg":"stub"}`) })) t.Cleanup(s.srv.Close) return s } func (s *hmsStub) hms() *HMS { return &HMS{ AppID: "app-1", AppSecret: "sec-1", TestMessage: true, DailyLimit: 1000, Endpoint: s.srv.URL, TokenURL: s.srv.URL + "/token", Client: s.srv.Client(), } } func (s *hmsStub) count() int { s.mu.Lock() defer s.mu.Unlock() return len(s.pushReqs) } // 推送请求的形状:v1 端点 + message.token + notification + data(字符串) + testMessage。 // 形状不对时华为回的是参数类错误(实测 80300010),而这条判据把它钉死在本地。 func TestHMSSendShape(t *testing.T) { s := newHMSStub(t) err := s.hms().Send(context.Background(), []string{"tok-1", "tok-2"}, NewMail{ MailID: "mail-1", SessionID: "sess-1", From: "bob", Subject: "标题", }) if err != nil { t.Fatalf("成功码应被当成功: %v", err) } if s.count() != 1 { t.Fatalf("应发出 1 个请求,实际 %d", s.count()) } req := s.pushReqs[0] if req["testMessage"] != true { t.Fatalf("未上架应用必须带 testMessage=true,实际 %v", req["testMessage"]) } msg, ok := req["message"].(map[string]any) if !ok { t.Fatalf("缺 message 字段: %v", req) } tokens, _ := msg["token"].([]any) if len(tokens) != 2 || tokens[0] != "tok-1" || tokens[1] != "tok-2" { t.Fatalf("token 列表不对: %v", msg["token"]) } notif, _ := msg["notification"].(map[string]any) if notif == nil || !strings.Contains(str(notif["title"]), "标题") || notif["body"] != "bob" { t.Fatalf("通知内容不对: %v", notif) } // data 必须是**字符串**(华为这套要求序列化后的 JSON 字符串) dataStr, ok := msg["data"].(string) if !ok { t.Fatalf("data 必须是字符串,实际 %T", msg["data"]) } var data map[string]string if err := json.Unmarshal([]byte(dataStr), &data); err != nil { t.Fatalf("data 不是合法 JSON 字符串: %v", err) } if data["type"] != "new_mail" || data["mail_id"] != "mail-1" || data["session_id"] != "sess-1" || data["action"] != "open_mail" { t.Fatalf("data 的跳转契约不对(客户端按它跳转): %v", data) } if s.authHdrs[0] != "Bearer tok-abc" { t.Fatalf("Authorization 头不对: %q", s.authHdrs[0]) } } // 访问令牌要缓存:两次发送只换一次令牌(华为给 3600 秒,我们的实现提前 5 分钟刷新)。 func TestHMSCachesAccessToken(t *testing.T) { s := newHMSStub(t) h := s.hms() for i := 0; i < 2; i++ { if err := h.Send(context.Background(), []string{"tok-1"}, NewMail{MailID: "m"}); err != nil { t.Fatal(err) } } s.mu.Lock() defer s.mu.Unlock() if s.tokenReq != 1 { t.Fatalf("两次发送应只换一次令牌,实际换了 %d 次", s.tokenReq) } } func TestHMSTestMessageCanBeDisabled(t *testing.T) { s := newHMSStub(t) h := s.hms() h.TestMessage = false // 上架之后 if err := h.Send(context.Background(), []string{"tok-1"}, NewMail{MailID: "m"}); err != nil { t.Fatal(err) } if _, present := s.pushReqs[0]["testMessage"]; present { t.Fatal("HMS_TEST_MESSAGE=false 时不该带 testMessage") } } // 华为回「这些 token 全无效」时必须把它们从库里清掉: // 否则每次发信都向死 token 发(白吃项目级额度),而用户永远收不到。 func TestHMSSendPrunesAllInvalidTokens(t *testing.T) { setupPushDB(t) ctx := context.Background() if err := repo.UpsertPushToken(ctx, "hms", "dead-1", "alice", "", ""); err != nil { t.Fatal(err) } if err := repo.UpsertPushToken(ctx, "hms", "dead-2", "alice", "", ""); err != nil { t.Fatal(err) } s := newHMSStub(t) s.code = "80300007" // 实测码:All the tokens are invalid h := s.hms() err := h.Send(ctx, []string{"dead-1", "dead-2"}, NewMail{MailID: "m"}) if err == nil { t.Fatal("无效 token 必须报错(不能静默当成功)") } if !strings.Contains(err.Error(), "80300007") { t.Fatalf("错误里应带上华为的 code,便于排查: %v", err) } left, _ := repo.ListPushTokensOf(ctx, "alice") if len(left) != 0 { t.Fatalf("无效 token 没被清理: %+v", left) } } // 认证/参数类错误不该清 token(那是我们自己的问题,不是设备的问题)。 func TestHMSSendKeepsTokensOnOtherErrors(t *testing.T) { setupPushDB(t) ctx := context.Background() if err := repo.UpsertPushToken(ctx, "hms", "good-1", "alice", "", ""); err != nil { t.Fatal(err) } s := newHMSStub(t) s.code = "80200001" // Authentication Error if err := s.hms().Send(ctx, []string{"good-1"}, NewMail{MailID: "m"}); err == nil { t.Fatal("认证失败必须报错") } left, _ := repo.ListPushTokensOf(ctx, "alice") if len(left) != 1 { t.Fatal("认证类错误不该删 token(设备是好的,错在我们)") } } // 每日上限只是**兜底**(华为对未上架应用的测试消息限 1000 条/天/项目)。 // 到线就停手,不把额度打光换来一串失败响应。 func TestHMSDailyLimitStopsSending(t *testing.T) { s := newHMSStub(t) h := s.hms() h.DailyLimit = 1 if err := h.Send(context.Background(), []string{"tok-1"}, NewMail{MailID: "m1"}); err != nil { t.Fatal(err) } err := h.Send(context.Background(), []string{"tok-2"}, NewMail{MailID: "m2"}) if err == nil { t.Fatal("超过每日上限必须拒绝发送") } if !strings.Contains(err.Error(), "上限") { t.Fatalf("错误信息应说明是上限问题: %v", err) } if s.count() != 1 { t.Fatalf("到线后不该再打网络,实际打了 %d 次", s.count()) } } func str(v any) string { s, _ := v.(string) return s }