package weixin_ilink // 本文件钉住扫码登录期**上游字段进日志**的边界(M50 复审的第五个出口): // // - `get_qrcode_status` 的 `status` 与 `scaned_but_redirect` 的 `redirect_host` 此前 // 原样写进 INFO —— 两个都是上游字段,此前不过 `trace.ErrorToken`、不过 // `RedactText`、也没有长度上限。现在两者都过令牌守卫后才出镜。 // - 🔴 **控制流仍用原值**:状态机读 `status.Status`、路由读 `status.RedirectHost`。 // 这条比「日志干净」更容易被修坏 —— 一个图省事的修法是把守卫后的值喂给 // `setPollBaseURL`,那会让带端口的合法重定向主机整体失效。 // // 两条用例的变异方向各一:把守卫从日志里去掉 ⇒ 哨兵出现(红);把守卫**挪进**控制流 // ⇒ 第二条用例的重定向落不到上游给的主机上(红)。 import ( "bytes" "context" "log/slog" "net/http" "strings" "sync" "testing" "time" ) // loginProse 是一段「像人话、不像协议令牌」的上游字段值:它若出现在日志里,就说明这一栏 // 又是一个不受控的正文通道。 const loginProse = "她刚才说的那句话" // syncBuffer 让后台登录 goroutine 的日志写入与测试的读取不打架(-race 下也成立)。 type syncBuffer struct { mu sync.Mutex b bytes.Buffer } func (s *syncBuffer) Write(p []byte) (int, error) { s.mu.Lock() defer s.mu.Unlock() return s.b.Write(p) } func (s *syncBuffer) String() string { s.mu.Lock() defer s.mu.Unlock() return s.b.String() } // newLoggingScriptedAdapter 与 newScriptedAdapter 相同,但把日志收进 buf 供断言。 func newLoggingScriptedAdapter(t *testing.T, cfg Config, tr http.RoundTripper, buf *syncBuffer) *Adapter { t.Helper() ad, err := New(cfg, &http.Client{Transport: tr}, slog.New(slog.NewJSONHandler(buf, nil))) if err != nil { t.Fatalf("New: %v", err) } return ad } // TestStartLogin_ProseStatusNeverReachesLog status 喂散文 ⇒ 日志里那一栏是空串, // 哨兵一个字都搜不到;而控制流照旧(散文不匹配任何 case ⇒ 继续轮询,直到窗口结束)。 func TestStartLogin_ProseStatusNeverReachesLog(t *testing.T) { t.Parallel() var buf syncBuffer tr := &scriptedTransport{ qr: []byte(scriptedQR), statusFn: func(int) []byte { return []byte(`{"status":"` + loginProse + `"}`) }, } ad := newLoggingScriptedAdapter(t, Config{ BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 300 * time.Millisecond, }, tr, &buf) if _, err := ad.StartLogin(context.Background()); err != nil { t.Fatalf("StartLogin: %v", err) } waitFor(t, func() bool { s, _ := ad.LoginStatus(context.Background()) return s == LoginStatusFailed }) line := buf.String() if line == "" { t.Fatal("扫码轮询一条日志都没有(静默即缺陷)") } if strings.Contains(line, loginProse) { t.Fatalf("上游 status 原文进了日志:%s", line) } // 守卫的实际效果:散文 ⇒ 空串(不是「截断」也不是「保留前几个字」)。 if !strings.Contains(line, `"status":""`) { t.Fatalf("散文 status 没有收敛成空串(令牌守卫没生效?):%s", line) } // 控制流不受影响:散文不匹配任何 case ⇒ 一直轮询到窗口结束(≥2 次),而不是 // 被当成某个阶段直接失败/确认。 if n := len(tr.hosts()); n < 2 { t.Fatalf("status 轮询 %d 次, want >= 2(散文 status 不该改变控制流)", n) } if ad.IsConnected() { t.Fatal("散文 status 被当成了成功(控制流被渲染口径污染)") } } // TestStartLogin_ProseRedirectHostNeverReachesLog redirect_host 喂散文 ⇒ 日志里没有它, // 而路由**照旧**切到上游给的那个主机(证明守卫只作用于渲染,没有混进控制流)。 func TestStartLogin_ProseRedirectHostNeverReachesLog(t *testing.T) { t.Parallel() var buf syncBuffer tr := &scriptedTransport{ qr: []byte(scriptedQR), statusFn: func(n int) []byte { if n == 1 { return []byte(`{"status":"scaned_but_redirect","redirect_host":"` + loginProse + `"}`) } return []byte(`{"status":"confirmed","bot_token":"tok-x","ilink_bot_id":"bot-x",` + `"ilink_user_id":"o-1","baseurl":"https://ilinkai.weixin.qq.com"}`) }, } ad := newLoggingScriptedAdapter(t, Config{ BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 5 * time.Second, }, tr, &buf) if _, err := ad.StartLogin(context.Background()); err != nil { t.Fatalf("StartLogin: %v", err) } waitFor(t, func() bool { s, _ := ad.LoginStatus(context.Background()) return s == LoginStatusConfirmed }) line := buf.String() if strings.Contains(line, loginProse) { t.Fatalf("上游 redirect_host 原文进了日志:%s", line) } if !strings.Contains(line, `"host":""`) { t.Fatalf("散文 redirect_host 没有收敛成空串(令牌守卫没生效?):%s", line) } // 🔴 控制流仍用原值:第二次轮询必须落在上游给的那个主机上。 hosts := tr.hosts() if len(hosts) < 2 { t.Fatalf("status 轮询 %d 次, want >= 2", len(hosts)) } if hosts[1] != loginProse { t.Fatalf("第二次轮询的主机 = %q, want 上游给的 redirect_host 原值(控制流被守卫污染)", hosts[1]) } } // TestStartLogin_ProsePhaseNeverReachesLog 同一个上游 status 还有**第二个日志出口**: // 复用二维码时那条 INFO 打的是 `sess.currentPhase()`(setPhase 存下的上游原值)。它现在 // 过同一道令牌守卫。变异:把守卫从这一行去掉 ⇒ 本用例红。 func TestStartLogin_ProsePhaseNeverReachesLog(t *testing.T) { t.Parallel() var buf syncBuffer tr := &scriptedTransport{ qr: []byte(scriptedQR), statusFn: func(int) []byte { return []byte(`{"status":"` + loginProse + `"}`) }, } ad := newLoggingScriptedAdapter(t, Config{ BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 300 * time.Millisecond, }, tr, &buf) if _, err := ad.StartLogin(context.Background()); err != nil { t.Fatalf("StartLogin: %v", err) } // 等后台轮询把 phase 填成上游原值,再点第二次「扫码登录」走复用分支。 waitFor(t, func() bool { return len(tr.hosts()) >= 1 }) if _, err := ad.StartLogin(context.Background()); err != nil { t.Fatalf("StartLogin (reuse): %v", err) } waitFor(t, func() bool { s, _ := ad.LoginStatus(context.Background()) return s == LoginStatusFailed }) line := buf.String() if strings.Contains(line, loginProse) { t.Fatalf("上游 status 经 currentPhase() 进了第二条日志:%s", line) } if !strings.Contains(line, `"phase":""`) { t.Fatalf("复用二维码那条 INFO 的 phase 没有过令牌守卫:%s", line) } // 控制流不受影响:散文 phase 不满足 confirmingPhase,复用走的是「码仍新鲜」那一支, // 返回的仍是同一个码。 if qr := ad.loginSession; qr != nil && qr.qrcode != "qr-r" { t.Fatalf("复用分支返回的 qrcode = %q, want qr-r", qr.qrcode) } } // TestStartLogin_QrcodeURLLogIsMasked get_bot_qrcode 的 `qrcode_img_content` 也是 // **上游给的串**(可能是带签名的图片地址,也可能整段是 data: 内联图)。它进日志前走 // trace.MaskQuery:凭据类 query 值打码、路径保留;解析不出 host 时回空串(data: URI // 就是这么被整体挡掉的)。变异:改回原串 ⇒ 本用例立刻红。 func TestStartLogin_QrcodeURLLogIsMasked(t *testing.T) { t.Parallel() const sig = "SIG-SECRET-123" var buf syncBuffer tr := &scriptedTransport{ qr: []byte(`{"qrcode":"qr-1","qrcode_img_content":"https://img.example/qr.png?sig=` + sig + `&size=200"}`), statusFn: func(int) []byte { return []byte(`{"status":"confirmed","bot_token":"tok-x","ilink_bot_id":"bot-x","ilink_user_id":"o-1"}`) }, } ad := newLoggingScriptedAdapter(t, Config{ BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 5 * time.Second, }, tr, &buf) if _, err := ad.StartLogin(context.Background()); err != nil { t.Fatalf("StartLogin: %v", err) } waitFor(t, func() bool { s, _ := ad.LoginStatus(context.Background()) return s == LoginStatusConfirmed }) line := buf.String() if strings.Contains(line, sig) { t.Fatalf("二维码图片地址的签名参数原样进了日志:%s", line) } if !strings.Contains(line, "https://img.example/qr.png") || !strings.Contains(line, "sig=***") { t.Fatalf("打码后把可读的路径也丢了(诊断面):%s", line) } }