返回 DeepSeek-Reasonix
bot_connection_app_test.go
根目录 / desktop / bot_connection_app_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "net/http"
6 "net/http/httptest"
7 "net/url"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 "reasonix/internal/config"
14 )
15
16 func TestNormalizeBotInstallTarget(t *testing.T) {
17 cases := []struct {
18 provider string
19 domain string
20 wantProvider string
21 wantDomain string
22 }{
23 {provider: "lark", wantProvider: "feishu", wantDomain: "lark"},
24 {provider: "feishu", domain: "lark", wantProvider: "feishu", wantDomain: "lark"},
25 {provider: "wechat", wantProvider: "weixin", wantDomain: "weixin"},
26 {provider: "weixin", domain: "anything", wantProvider: "weixin", wantDomain: "weixin"},
27 {provider: "unknown", domain: "unknown", wantProvider: "feishu", wantDomain: "feishu"},
28 }
29 for _, tc := range cases {
30 gotProvider, gotDomain := normalizeBotInstallTarget(tc.provider, tc.domain)
31 if gotProvider != tc.wantProvider || gotDomain != tc.wantDomain {
32 t.Fatalf("normalizeBotInstallTarget(%q,%q) = %q,%q; want %q,%q", tc.provider, tc.domain, gotProvider, gotDomain, tc.wantProvider, tc.wantDomain)
33 }
34 }
35 }
36
37 func TestLarkInstallFollowsSDKDomainSwitchAndStoresSecret(t *testing.T) {
38 isolateDesktopUserDirs(t)
39 t.Cleanup(func() { _ = os.Unsetenv("LARK_BOT_APP_SECRET") })
40 pollCount := 0
41 var beginHost string
42 var pollHosts []string
43 var actions []string
44 withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45 if r.URL.Path != "/oauth/v1/app/registration" {
46 http.NotFound(w, r)
47 return
48 }
49 if err := r.ParseForm(); err != nil {
50 http.Error(w, err.Error(), http.StatusBadRequest)
51 return
52 }
53 switch r.Form.Get("action") {
54 case "begin":
55 beginHost = r.Header.Get("X-Test-Original-Host")
56 actions = append(actions, "begin")
57 if r.Form.Get("archetype") != "PersonalAgent" || r.Form.Get("auth_method") != "client_secret" {
58 http.Error(w, "wrong begin form", http.StatusBadRequest)
59 return
60 }
61 writeJSON(t, w, map[string]any{
62 "device_code": "dev-lark",
63 "verification_uri_complete": "https://open.feishu.cn/page/launcher?user_code=CODE",
64 "user_code": "CODE",
65 "interval": 3,
66 "expire_in": 300,
67 })
68 case "poll":
69 pollHosts = append(pollHosts, r.Header.Get("X-Test-Original-Host"))
70 actions = append(actions, "poll")
71 if r.Form.Get("device_code") != "dev-lark" {
72 http.Error(w, "wrong device code", http.StatusBadRequest)
73 return
74 }
75 pollCount++
76 if pollCount == 1 {
77 writeJSON(t, w, map[string]any{"user_info": map[string]any{"tenant_brand": "lark"}})
78 return
79 }
80 writeJSON(t, w, map[string]any{
81 "client_id": "cli-1",
82 "client_secret": "secret-1",
83 "user_info": map[string]any{"tenant_brand": "lark", "open_id": "ou-installer"},
84 })
85 default:
86 http.Error(w, "unknown action", http.StatusBadRequest)
87 }
88 }))
89
90 app := NewApp()
91 start, err := app.StartBotConnectionInstall("lark", "")
92 if err != nil {
93 t.Fatalf("StartBotConnectionInstall: %v", err)
94 }
95 if !start.OK || start.Domain != "lark" || start.InstallID == "" || start.URL == "" || start.DeviceCode != "dev-lark" {
96 t.Fatalf("start result = %+v, want ok lark-capable QR result", start)
97 }
98 qrURL, err := url.Parse(start.URL)
99 if err != nil {
100 t.Fatalf("start URL = %q, want valid QR URL: %v", start.URL, err)
101 }
102 query := qrURL.Query()
103 if query.Get("user_code") != "CODE" || query.Get("from") != "sdk" || query.Get("tp") != "sdk" || query.Get("source") != "go-sdk" {
104 t.Fatalf("start URL query = %v, want SDK registration QR metadata with user_code", query)
105 }
106 if qrURL.Host != "open.feishu.cn" {
107 t.Fatalf("start URL host = %q, want SDK Feishu launcher host", qrURL.Host)
108 }
109
110 pending, err := app.PollBotConnectionInstall(start.InstallID)
111 if err != nil {
112 t.Fatalf("PollBotConnectionInstall pending: %v", err)
113 }
114 if pending.Done || pending.Status != "pending" {
115 t.Fatalf("pending poll result = %+v, want pending domain switch", pending)
116 }
117 poll, err := app.PollBotConnectionInstall(start.InstallID)
118 if err != nil {
119 t.Fatalf("PollBotConnectionInstall: %v", err)
120 }
121 if !poll.Done {
122 t.Fatalf("poll result = %+v, want done", poll)
123 }
124 if poll.Connection.Provider != "feishu" || poll.Connection.Domain != "lark" || poll.Connection.ID != "feishu-lark" {
125 t.Fatalf("connection = %+v, want feishu-lark from tenant_brand", poll.Connection)
126 }
127 if beginHost != "accounts.feishu.cn" {
128 t.Fatalf("begin host = %q, want SDK Feishu accounts host", beginHost)
129 }
130 if got := strings.Join(pollHosts, ","); got != "accounts.feishu.cn,accounts.larksuite.com" {
131 t.Fatalf("poll hosts = %q, want Feishu poll then Lark poll", got)
132 }
133 if got := strings.Join(actions, ","); got != "begin,poll,poll" {
134 t.Fatalf("registration actions = %q, want SDK begin, domain switch, final poll", got)
135 }
136 if poll.Connection.WorkspaceRoot != "" {
137 t.Fatalf("connection workspaceRoot = %q, want empty global default", poll.Connection.WorkspaceRoot)
138 }
139 if poll.Connection.Credential.AppID != "cli-1" || poll.Connection.Credential.AppSecretEnv != "LARK_BOT_APP_SECRET" || !poll.Connection.Credential.SecretSet {
140 t.Fatalf("credential = %+v, want stored Lark secret", poll.Connection.Credential)
141 }
142 cfg := config.LoadForEdit(config.UserConfigPath())
143 if !cfg.Bot.Enabled || !cfg.Bot.Feishu.Enabled || cfg.Bot.Feishu.Domain != "lark" || cfg.Bot.Feishu.Mode != "websocket" || !cfg.Bot.Feishu.RequireMention {
144 t.Fatalf("saved feishu config = %+v, want enabled websocket lark with mention gating", cfg.Bot.Feishu)
145 }
146 if len(cfg.Bot.Allowlist.FeishuUsers) != 1 || cfg.Bot.Allowlist.FeishuUsers[0] != "ou-installer" {
147 t.Fatalf("feishu allowlist = %+v, want installer open_id", cfg.Bot.Allowlist.FeishuUsers)
148 }
149 if err := os.Unsetenv("LARK_BOT_APP_SECRET"); err != nil {
150 t.Fatalf("unset lark secret env: %v", err)
151 }
152 reloaded, err := config.Load()
153 if err != nil {
154 t.Fatalf("reload config: %v", err)
155 }
156 if got := os.Getenv("LARK_BOT_APP_SECRET"); got != "secret-1" {
157 t.Fatalf("reloaded LARK_BOT_APP_SECRET = %q, want persisted secret", got)
158 }
159 if len(reloaded.Bot.Connections) != 1 || !botConnectionView(reloaded.Bot.Connections[0]).Credential.SecretSet {
160 t.Fatalf("reloaded connections = %+v, want secret to survive restart", reloaded.Bot.Connections)
161 }
162 }
163
164 func TestFeishuInstallSwitchesToLarkDomainWhenTenantBrandIsLark(t *testing.T) {
165 isolateDesktopUserDirs(t)
166 t.Cleanup(func() { _ = os.Unsetenv("LARK_BOT_APP_SECRET") })
167 pollCount := 0
168 var beginHost string
169 var pollHosts []string
170 withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
171 if r.URL.Path != "/oauth/v1/app/registration" {
172 http.NotFound(w, r)
173 return
174 }
175 if err := r.ParseForm(); err != nil {
176 http.Error(w, err.Error(), http.StatusBadRequest)
177 return
178 }
179 switch r.Form.Get("action") {
180 case "begin":
181 beginHost = r.Header.Get("X-Test-Original-Host")
182 writeJSON(t, w, map[string]any{
183 "device_code": "dev-feishu",
184 "verification_uri_complete": "https://accounts.example/verify?user_code=CODE",
185 "user_code": "CODE",
186 "interval": 3,
187 "expire_in": 300,
188 })
189 case "poll":
190 pollHosts = append(pollHosts, r.Header.Get("X-Test-Original-Host"))
191 if r.Form.Get("device_code") != "dev-feishu" {
192 http.Error(w, "wrong device code", http.StatusBadRequest)
193 return
194 }
195 pollCount++
196 if pollCount == 1 {
197 writeJSON(t, w, map[string]any{"user_info": map[string]any{"tenant_brand": "lark"}})
198 return
199 }
200 writeJSON(t, w, map[string]any{
201 "client_id": "cli-lark",
202 "client_secret": "secret-lark",
203 "user_info": map[string]any{"tenant_brand": "lark", "open_id": "ou-lark-installer"},
204 })
205 default:
206 http.Error(w, "unknown action", http.StatusBadRequest)
207 }
208 }))
209
210 app := NewApp()
211 start, err := app.StartBotConnectionInstall("feishu", "")
212 if err != nil {
213 t.Fatalf("StartBotConnectionInstall: %v", err)
214 }
215 if !start.OK || start.Domain != "feishu" || start.DeviceCode != "dev-feishu" {
216 t.Fatalf("start result = %+v, want Feishu QR result", start)
217 }
218 pending, err := app.PollBotConnectionInstall(start.InstallID)
219 if err != nil {
220 t.Fatalf("PollBotConnectionInstall pending: %v", err)
221 }
222 if pending.Done || pending.Status != "pending" {
223 t.Fatalf("pending poll result = %+v, want pending domain switch", pending)
224 }
225 poll, err := app.PollBotConnectionInstall(start.InstallID)
226 if err != nil {
227 t.Fatalf("PollBotConnectionInstall: %v", err)
228 }
229 if !poll.Done || poll.Connection.Domain != "lark" || poll.Connection.Credential.AppSecretEnv != "LARK_BOT_APP_SECRET" {
230 t.Fatalf("poll result = %+v, want stored Lark connection after domain switch", poll)
231 }
232 if beginHost != "accounts.feishu.cn" {
233 t.Fatalf("begin host = %q, want Feishu accounts host", beginHost)
234 }
235 if got := strings.Join(pollHosts, ","); got != "accounts.feishu.cn,accounts.larksuite.com" {
236 t.Fatalf("poll hosts = %q, want Feishu poll then Lark poll", got)
237 }
238 }
239
240 func TestFeishuInstallStoresFeishuSecretAndSurvivesReload(t *testing.T) {
241 isolateDesktopUserDirs(t)
242 t.Cleanup(func() { _ = os.Unsetenv("FEISHU_BOT_APP_SECRET") })
243 var hosts []string
244 withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
245 if r.URL.Path != "/oauth/v1/app/registration" {
246 http.NotFound(w, r)
247 return
248 }
249 if err := r.ParseForm(); err != nil {
250 http.Error(w, err.Error(), http.StatusBadRequest)
251 return
252 }
253 hosts = append(hosts, r.Form.Get("action")+":"+r.Header.Get("X-Test-Original-Host"))
254 switch r.Form.Get("action") {
255 case "begin":
256 writeJSON(t, w, map[string]any{
257 "device_code": "dev-feishu",
258 "verification_uri_complete": "https://accounts.example/verify?user_code=CODE",
259 "user_code": "CODE",
260 "interval": 3,
261 "expire_in": 300,
262 })
263 case "poll":
264 writeJSON(t, w, map[string]any{
265 "client_id": "cli-feishu",
266 "client_secret": "secret-feishu",
267 "user_info": map[string]any{"tenant_brand": "feishu", "open_id": "ou-feishu-installer"},
268 })
269 default:
270 http.Error(w, "unknown action", http.StatusBadRequest)
271 }
272 }))
273
274 app := NewApp()
275 start, err := app.StartBotConnectionInstall("feishu", "")
276 if err != nil {
277 t.Fatalf("StartBotConnectionInstall: %v", err)
278 }
279 if !start.OK || start.Domain != "feishu" || start.InstallID == "" {
280 t.Fatalf("start result = %+v, want ok Feishu QR result", start)
281 }
282 poll, err := app.PollBotConnectionInstall(start.InstallID)
283 if err != nil {
284 t.Fatalf("PollBotConnectionInstall: %v", err)
285 }
286 if !poll.Done {
287 t.Fatalf("poll result = %+v, want done", poll)
288 }
289 if poll.Connection.Provider != "feishu" || poll.Connection.Domain != "feishu" || poll.Connection.ID != "feishu-feishu" {
290 t.Fatalf("connection = %+v, want feishu-feishu", poll.Connection)
291 }
292 if poll.Connection.Credential.AppID != "cli-feishu" || poll.Connection.Credential.AppSecretEnv != "FEISHU_BOT_APP_SECRET" || !poll.Connection.Credential.SecretSet {
293 t.Fatalf("credential = %+v, want stored Feishu secret", poll.Connection.Credential)
294 }
295 if got := strings.Join(hosts, ","); got != "begin:accounts.feishu.cn,poll:accounts.feishu.cn" {
296 t.Fatalf("registration hosts = %q, want Feishu begin and poll", got)
297 }
298 cfg := config.LoadForEdit(config.UserConfigPath())
299 if !cfg.Bot.Enabled || !cfg.Bot.Feishu.Enabled || cfg.Bot.Feishu.Domain != "feishu" || cfg.Bot.Feishu.AppID != "cli-feishu" {
300 t.Fatalf("saved feishu config = %+v, want enabled Feishu websocket config", cfg.Bot.Feishu)
301 }
302 if len(cfg.Bot.Allowlist.FeishuUsers) != 1 || cfg.Bot.Allowlist.FeishuUsers[0] != "ou-feishu-installer" {
303 t.Fatalf("feishu allowlist = %+v, want installer open_id", cfg.Bot.Allowlist.FeishuUsers)
304 }
305 if err := os.Unsetenv("FEISHU_BOT_APP_SECRET"); err != nil {
306 t.Fatalf("unset feishu secret env: %v", err)
307 }
308 reloaded, err := config.Load()
309 if err != nil {
310 t.Fatalf("reload config: %v", err)
311 }
312 if got := os.Getenv("FEISHU_BOT_APP_SECRET"); got != "secret-feishu" {
313 t.Fatalf("reloaded FEISHU_BOT_APP_SECRET = %q, want persisted secret", got)
314 }
315 if len(reloaded.Bot.Connections) != 1 || !botConnectionView(reloaded.Bot.Connections[0]).Credential.SecretSet {
316 t.Fatalf("reloaded connections = %+v, want secret to survive restart", reloaded.Bot.Connections)
317 }
318 }
319
320 func TestWeixinInstallStoresSavedAccountAndConnection(t *testing.T) {
321 isolateDesktopUserDirs(t)
322 serveWeixinInstall(t)
323
324 app := NewApp()
325 start, err := app.StartBotConnectionInstall("weixin", "")
326 if err != nil {
327 t.Fatalf("StartBotConnectionInstall: %v", err)
328 }
329 if !start.OK || start.Provider != "weixin" || start.Domain != "weixin" || start.URL != "data:image/png;base64,abc" || start.DeviceCode != "qr-weixin" {
330 t.Fatalf("start result = %+v, want weixin QR result", start)
331 }
332
333 poll, err := app.PollBotConnectionInstall(start.InstallID)
334 if err != nil {
335 t.Fatalf("PollBotConnectionInstall: %v", err)
336 }
337 if !poll.Done {
338 t.Fatalf("poll result = %+v, want done", poll)
339 }
340 if poll.Connection.Provider != "weixin" || poll.Connection.Domain != "weixin" || poll.Connection.Credential.AccountID != "weixin-account" {
341 t.Fatalf("connection = %+v, want weixin account connection", poll.Connection)
342 }
343 if poll.Connection.WorkspaceRoot != "" {
344 t.Fatalf("connection workspaceRoot = %q, want empty global default", poll.Connection.WorkspaceRoot)
345 }
346 if poll.Connection.Credential.TokenEnv != "WEIXIN_BOT_TOKEN" || !poll.Connection.Credential.SecretSet {
347 t.Fatalf("credential = %+v, want saved account to count as configured token", poll.Connection.Credential)
348 }
349 cfg := config.LoadForEdit(config.UserConfigPath())
350 if !cfg.Bot.Enabled || !cfg.Bot.Weixin.Enabled || cfg.Bot.Weixin.AccountID != "weixin-account" || cfg.Bot.Weixin.TokenEnv != "WEIXIN_BOT_TOKEN" {
351 t.Fatalf("saved weixin config = %+v, want enabled saved account", cfg.Bot.Weixin)
352 }
353 if len(cfg.Bot.Allowlist.WeixinUsers) != 1 || cfg.Bot.Allowlist.WeixinUsers[0] != "user-1" {
354 t.Fatalf("weixin allowlist = %+v, want installer user id", cfg.Bot.Allowlist.WeixinUsers)
355 }
356 reloaded, err := config.Load()
357 if err != nil {
358 t.Fatalf("reload config: %v", err)
359 }
360 if len(reloaded.Bot.Connections) != 1 {
361 t.Fatalf("reloaded connections = %+v, want saved weixin connection", reloaded.Bot.Connections)
362 }
363 reloadedConnection := botConnectionView(reloaded.Bot.Connections[0])
364 if reloadedConnection.Credential.AccountID != "weixin-account" || !reloadedConnection.Credential.SecretSet {
365 t.Fatalf("reloaded credential = %+v, want saved weixin account to survive restart", reloadedConnection.Credential)
366 }
367 }
368
369 func serveWeixinInstall(t *testing.T) {
370 t.Helper()
371 withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
372 switch r.URL.Path {
373 case "/ilink/bot/get_bot_qrcode":
374 if r.URL.Query().Get("bot_type") != "3" {
375 http.Error(w, "missing bot type", http.StatusBadRequest)
376 return
377 }
378 writeJSON(t, w, map[string]any{
379 "qrcode": "qr-weixin",
380 "qrcode_img_content": "data:image/png;base64,abc",
381 })
382 case "/ilink/bot/get_qrcode_status":
383 if r.URL.Query().Get("qrcode") != "qr-weixin" {
384 http.Error(w, "wrong qr", http.StatusBadRequest)
385 return
386 }
387 writeJSON(t, w, map[string]any{
388 "status": "confirmed",
389 "ilink_bot_id": "weixin-account",
390 "bot_token": "token-1",
391 "ilink_user_id": "user-1",
392 "baseurl": "https://ilinkai.weixin.qq.com",
393 })
394 default:
395 http.NotFound(w, r)
396 }
397 }))
398 }
399
400 func writeUserConfig(t *testing.T, body string) {
401 t.Helper()
402 path := config.UserConfigPath()
403 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
404 t.Fatal(err)
405 }
406 if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
407 t.Fatal(err)
408 }
409 }
410
411 func completeWeixinInstall(t *testing.T, app *App) {
412 t.Helper()
413 start, err := app.StartBotConnectionInstall("weixin", "")
414 if err != nil {
415 t.Fatalf("StartBotConnectionInstall: %v", err)
416 }
417 poll, err := app.PollBotConnectionInstall(start.InstallID)
418 if err != nil {
419 t.Fatalf("PollBotConnectionInstall: %v", err)
420 }
421 if !poll.Done {
422 t.Fatalf("poll result = %+v, want done", poll)
423 }
424 }
425
426 func TestWeixinInstallKeepsExplicitBotDisabled(t *testing.T) {
427 isolateDesktopUserDirs(t)
428 serveWeixinInstall(t)
429 writeUserConfig(t, `[bot]
430 enabled = false
431
432 [[bot.connections]]
433 id = "weixin-weixin"
434 provider = "weixin"
435 domain = "weixin"
436 label = "微信"
437 enabled = true
438 status = "connected"
439 `)
440
441 completeWeixinInstall(t, NewApp())
442
443 cfg := config.LoadForEdit(config.UserConfigPath())
444 if cfg.Bot.Enabled {
445 t.Fatal("re-scanning an existing connection re-enabled the embedded bot over an explicit enabled = false")
446 }
447 if !cfg.Bot.Weixin.Enabled || cfg.Bot.Weixin.AccountID != "weixin-account" {
448 t.Fatalf("saved weixin config = %+v, want refreshed credentials", cfg.Bot.Weixin)
449 }
450 if len(cfg.Bot.Allowlist.WeixinUsers) != 1 || cfg.Bot.Allowlist.WeixinUsers[0] != "user-1" {
451 t.Fatalf("weixin allowlist = %+v, want installer user id", cfg.Bot.Allowlist.WeixinUsers)
452 }
453 }
454
455 func TestWeixinFirstInstallEnablesBotDespiteExplicitFalse(t *testing.T) {
456 isolateDesktopUserDirs(t)
457 serveWeixinInstall(t)
458 writeUserConfig(t, "[bot]\nenabled = false\n")
459
460 completeWeixinInstall(t, NewApp())
461
462 cfg := config.LoadForEdit(config.UserConfigPath())
463 if !cfg.Bot.Enabled {
464 t.Fatal("first install left the embedded bot off, so connecting from the desktop does nothing")
465 }
466 }
467
468 func TestFeishuRegistrationQRCodeURLAddsSDKMetadata(t *testing.T) {
469 qrURL, err := feishuRegistrationQRCodeURL("https://open.larksuite.com/page/launcher?user_code=ABCD-1234&source=old")
470 if err != nil {
471 t.Fatalf("feishuRegistrationQRCodeURL: %v", err)
472 }
473 parsed, err := url.Parse(qrURL)
474 if err != nil {
475 t.Fatalf("parse QR URL: %v", err)
476 }
477 query := parsed.Query()
478 if query.Get("user_code") != "ABCD-1234" {
479 t.Fatalf("user_code = %q, want preserved code", query.Get("user_code"))
480 }
481 if query.Get("from") != "sdk" || query.Get("tp") != "sdk" || query.Get("source") != "go-sdk" {
482 t.Fatalf("query = %v, want SDK registration metadata", query)
483 }
484 }
485
486 func TestDiagnoseBotConnectionBuildsReportDetailForMissingSecret(t *testing.T) {
487 isolateDesktopUserDirs(t)
488 t.Setenv("FEISHU_BOT_APP_SECRET_PRIVATE", "")
489 app := NewApp()
490 if _, err := app.upsertBotConnection(config.BotConnectionConfig{
491 ID: "feishu-lark",
492 Provider: "feishu",
493 Domain: "lark",
494 Label: "Lark",
495 Enabled: true,
496 Status: "connected",
497 WorkspaceRoot: "/Users/alice/work/reasonix",
498 Credential: config.BotConnectionCredential{
499 AppID: "cli-private",
500 AppSecretEnv: "FEISHU_BOT_APP_SECRET_PRIVATE",
501 },
502 SessionMappings: []config.BotConnectionSessionMapping{{
503 RemoteID: "ou-private",
504 SessionID: "session-private",
505 Scope: "project",
506 WorkspaceRoot: "/Users/alice/work/reasonix",
507 }},
508 }, nil); err != nil {
509 t.Fatalf("upsert connection: %v", err)
510 }
511
512 diag, err := app.DiagnoseBotConnection("feishu-lark")
513 if err != nil {
514 t.Fatalf("DiagnoseBotConnection: %v", err)
515 }
516 if diag.Status != "warning" || diag.Phase != "credential" || diag.Code != "secret_missing" || diag.ReportKind != "bot" || diag.ReportDetail == "" {
517 t.Fatalf("diagnostic = %+v, want warning credential report", diag)
518 }
519 for _, leaked := range []string{"FEISHU_BOT_APP_SECRET_PRIVATE", "/Users/alice", "ou-private", "session-private"} {
520 if strings.Contains(diag.ReportDetail, leaked) {
521 t.Fatalf("diagnostic report leaked %q in %s", leaked, diag.ReportDetail)
522 }
523 }
524 var payload frontendCrashPayload
525 if err := json.Unmarshal([]byte(diag.ReportDetail), &payload); err != nil {
526 t.Fatalf("report detail is not structured JSON: %v", err)
527 }
528 if payload.Kind != "bot" || payload.Source != "bot.runtime" || payload.Label != "bot.feishu.lark.credential" {
529 t.Fatalf("payload = %+v, want bot runtime credential label", payload)
530 }
531 for _, want := range []string{
532 "app_secret_env_configured: true",
533 "secret_available: false",
534 "workspace_scope: project",
535 "session_mappings: 1",
536 "summary: required bot credential is not available",
537 } {
538 if !strings.Contains(payload.Message, want) {
539 t.Fatalf("payload message = %q, want it to contain %q", payload.Message, want)
540 }
541 }
542 report, err := crashReportFromDetail(diag.ReportKind, diag.ReportDetail)
543 if err != nil {
544 t.Fatalf("crashReportFromDetail: %v", err)
545 }
546 if report.Kind != "bot" || report.Source != "bot.runtime" || report.ErrorType != "BotConnectionDiagnostic" {
547 t.Fatalf("report = %+v, want accepted bot report", report)
548 }
549 }
550
551 func TestBotConnectionSendFailureReportRedactsEnvNames(t *testing.T) {
552 conn := config.BotConnectionConfig{
553 ID: "feishu-lark",
554 Provider: "feishu",
555 Domain: "lark",
556 Label: "Lark",
557 Enabled: true,
558 Status: "connected",
559 Credential: config.BotConnectionCredential{
560 AppSecretEnv: "FEISHU_BOT_APP_SECRET_PRIVATE",
561 },
562 }
563 diag := botConnectionDiagnostic(&conn, conn.ID, "error", "send", "test_send_failed", "feishu app_id or FEISHU_BOT_APP_SECRET_PRIVATE is not configured", true)
564 if diag.ReportKind != "bot" || diag.ReportDetail == "" {
565 t.Fatalf("diagnostic = %+v, want reportable bot diagnostic", diag)
566 }
567 if strings.Contains(diag.ReportDetail, "FEISHU_BOT_APP_SECRET_PRIVATE") {
568 t.Fatalf("diagnostic report leaked env name in %s", diag.ReportDetail)
569 }
570 var payload frontendCrashPayload
571 if err := json.Unmarshal([]byte(diag.ReportDetail), &payload); err != nil {
572 t.Fatalf("report detail is not structured JSON: %v", err)
573 }
574 if !strings.Contains(payload.ErrorMessage, "[redacted-env]") {
575 t.Fatalf("payload errorMessage = %q, want redacted env marker", payload.ErrorMessage)
576 }
577 }
578
579 func TestDiagnoseWeixinConnectionDetectsMissingSavedAccountWithoutTokenEnv(t *testing.T) {
580 isolateDesktopUserDirs(t)
581 app := NewApp()
582 if _, err := app.upsertBotConnection(config.BotConnectionConfig{
583 ID: "weixin-weixin",
584 Provider: "weixin",
585 Domain: "weixin",
586 Label: "微信",
587 Enabled: true,
588 Status: "connected",
589 Credential: config.BotConnectionCredential{
590 AccountID: "missing-account",
591 },
592 }, nil); err != nil {
593 t.Fatalf("upsert connection: %v", err)
594 }
595
596 diag, err := app.DiagnoseBotConnection("weixin-weixin")
597 if err != nil {
598 t.Fatalf("DiagnoseBotConnection: %v", err)
599 }
600 if diag.Status != "warning" || diag.Phase != "credential" || diag.Code != "secret_missing" || diag.ReportKind != "bot" || diag.ReportDetail == "" {
601 t.Fatalf("diagnostic = %+v, want missing local credential warning", diag)
602 }
603 if strings.Contains(diag.ReportDetail, "missing-account") {
604 t.Fatalf("diagnostic report leaked account id in %s", diag.ReportDetail)
605 }
606 }
607
608 func TestRememberBotConnectionRemoteStoresStableScope(t *testing.T) {
609 isolateDesktopUserDirs(t)
610 app := NewApp()
611 if _, err := app.upsertBotConnection(config.BotConnectionConfig{
612 ID: "feishu-lark",
613 Provider: "feishu",
614 Domain: "lark",
615 Label: "kun",
616 Enabled: true,
617 Status: "connected",
618 }, nil); err != nil {
619 t.Fatalf("upsert global connection: %v", err)
620 }
621 if err := app.rememberBotConnectionRemote("feishu-lark", "ou_global"); err != nil {
622 t.Fatalf("remember global remote: %v", err)
623 }
624 cfg := config.LoadForEdit(config.UserConfigPath())
625 if got := cfg.Bot.Connections[0].SessionMappings[0]; got.Scope != "global" || got.WorkspaceRoot != "" || got.RemoteID != "ou_global" {
626 t.Fatalf("global mapping = %+v, want scope=global without workspace", got)
627 }
628
629 if _, err := app.upsertBotConnection(config.BotConnectionConfig{
630 ID: "weixin-project",
631 Provider: "weixin",
632 Domain: "weixin",
633 Label: "project",
634 Enabled: true,
635 Status: "connected",
636 WorkspaceRoot: "/tmp/reasonix-project",
637 }, nil); err != nil {
638 t.Fatalf("upsert project connection: %v", err)
639 }
640 if err := app.rememberBotConnectionRemote("weixin-project", "wxid_project"); err != nil {
641 t.Fatalf("remember project remote: %v", err)
642 }
643 cfg = config.LoadForEdit(config.UserConfigPath())
644 var projectMapping config.BotConnectionSessionMapping
645 for _, conn := range cfg.Bot.Connections {
646 if conn.ID == "weixin-project" && len(conn.SessionMappings) == 1 {
647 projectMapping = conn.SessionMappings[0]
648 }
649 }
650 if projectMapping.Scope != "project" || projectMapping.WorkspaceRoot != "/tmp/reasonix-project" || projectMapping.RemoteID != "wxid_project" {
651 t.Fatalf("project mapping = %+v, want project scope and workspace", projectMapping)
652 }
653 }
654
655 func writeJSON(t *testing.T, w http.ResponseWriter, value any) {
656 t.Helper()
657 w.Header().Set("Content-Type", "application/json")
658 if err := json.NewEncoder(w).Encode(value); err != nil {
659 t.Fatalf("write json: %v", err)
660 }
661 }
662
663 func withRewrittenHTTP(t *testing.T, handler http.Handler) {
664 t.Helper()
665 server := httptest.NewServer(handler)
666 target, err := url.Parse(server.URL)
667 if err != nil {
668 t.Fatal(err)
669 }
670 previous := http.DefaultTransport
671 http.DefaultTransport = rewriteHTTPTransport{target: target, next: previous}
672 t.Cleanup(func() {
673 http.DefaultTransport = previous
674 server.Close()
675 })
676 }
677
678 type rewriteHTTPTransport struct {
679 target *url.URL
680 next http.RoundTripper
681 }
682
683 func (r rewriteHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
684 clone := req.Clone(req.Context())
685 clone.Header.Set("X-Test-Original-Host", req.URL.Host)
686 clone.URL.Scheme = r.target.Scheme
687 clone.URL.Host = r.target.Host
688 clone.Host = r.target.Host
689 if r.next == nil {
690 r.next = http.DefaultTransport
691 }
692 clone.URL.Path = "/" + strings.TrimLeft(clone.URL.Path, "/")
693 return r.next.RoundTrip(clone)
694 }
695
695 lines GO