package onboarding import ( "context" "encoding/json" "github.com/google/uuid" "net/http" "net/http/httptest" "strings" "testing" ) type fakeWeChat struct{ openID, phone string } func (f *fakeWeChat) OpenID(context.Context, string) (string, error) { return f.openID, nil } func (f *fakeWeChat) Phone(context.Context, string) (string, error) { return f.phone, nil } func TestWeChatProviderExchangeCacheRefreshAndErrors(t *testing.T) { tokens, phones := 0, 0 mode := "ok" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/sns/jscode2session": if r.URL.Query().Get("js_code") != "login" || r.URL.Query().Get("appid") != "wx-test" { t.Error("bad login request") } if mode == "badlogin" { json.NewEncoder(w).Encode(map[string]any{"errcode": 40029, "errmsg": "secret-value"}) return } json.NewEncoder(w).Encode(map[string]string{"openid": "openid-test"}) case "/cgi-bin/stable_token": tokens++ var body map[string]any json.NewDecoder(r.Body).Decode(&body) if r.Method != "POST" || body["secret"] != "secret-value" { t.Error("bad token request") } json.NewEncoder(w).Encode(map[string]any{"access_token": "token-" + strings.Repeat("x", tokens), "expires_in": 7200}) case "/wxa/business/getuserphonenumber": phones++ var body map[string]string json.NewDecoder(r.Body).Decode(&body) if body["code"] != "phone-code" || r.URL.Query().Get("access_token") == "" { t.Error("bad phone request") } if mode == "refresh" && phones == 3 { json.NewEncoder(w).Encode(map[string]int{"errcode": 40014}) return } if mode == "denied" { json.NewEncoder(w).Encode(map[string]int{"errcode": 48001}) return } appID := "wx-test" if mode == "watermark" { appID = "other-app" } json.NewEncoder(w).Encode(map[string]any{"phone_info": map[string]any{"purePhoneNumber": "13800000000", "countryCode": "86", "watermark": map[string]string{"appid": appID}}}) default: t.Error("unexpected endpoint") w.WriteHeader(404) } })) defer server.Close() c := newWeChatClient("wx-test", "secret-value") c.base = server.URL id, err := c.OpenID(context.Background(), "login") must(t, err) if id != "openid-test" { t.Fatal(id) } for i := 0; i < 2; i++ { phone, err := c.Phone(context.Background(), "phone-code") must(t, err) if phone != "+8613800000000" { t.Fatal(phone) } } if tokens != 1 { t.Fatal("token not cached", tokens) } mode = "refresh" _, err = c.Phone(context.Background(), "phone-code") must(t, err) if tokens != 2 || phones != 4 { t.Fatal("refresh retry", tokens, phones) } for _, m := range []string{"denied", "watermark", "badlogin"} { mode = m if m == "badlogin" { _, err = c.OpenID(context.Background(), "login") } else { _, err = c.Phone(context.Background(), "phone-code") } if err == nil || strings.Contains(err.Error(), "secret-value") { t.Fatal("invalid provider response accepted or secret leaked", m) } } server.Close() _, err = c.OpenID(context.Background(), "login") if err == nil || strings.Contains(err.Error(), "secret-value") { t.Fatal("network error leaked credentials") } } func TestWeChatLoginPreservesAccountAndRejectsConflicts(t *testing.T) { f := newLoginFixture(t) account, csrf := f.smsAccount() original, err := f.s.AccountPrincipal(f.ctx, account.Value, csrf, true) must(t, err) provider := &fakeWeChat{openID: uuid.NewString(), phone: f.phone} f.s.options.WeChat = provider f.s.options.WeChatAppID = "wx4553119e05f11660" login := func(portal string) string { s, d := miniRequest(t, f.s, "POST", "/api/v1/mini/login/session", "", map[string]string{"portal": portal}) if s != 200 { t.Fatal(s) } return "MiniLogin " + d["login_token"].(string) } auth := login("customer") status, data := miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", auth, WeChatLoginInput{LoginCode: "first"}) if status != 200 || data["phone_required"] != true || data["access_token"] != nil { t.Fatal("first login bypassed phone", status) } status, data = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", auth, WeChatLoginInput{LoginCode: "second", PhoneCode: "phone"}) if status != 200 { t.Fatal(status) } check := func(data map[string]any) { token := strings.TrimPrefix(data["access_token"].(string), "customer.") p, err := f.s.AccountPrincipal(f.ctx, token, "", false) must(t, err) if p.Issuer != original.Issuer || p.Subject != original.Subject { t.Fatal("account changed") } var verified bool must(t, f.s.pool.QueryRow(f.ctx, `SELECT phone_verified_at IS NOT NULL FROM onboarding_account_session WHERE token_hash=$1`, digest([]byte(token))).Scan(&verified)) if verified { t.Fatal("login granted password reset") } } check(data) status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", auth, WeChatLoginInput{LoginCode: "replay"}) if status != 401 { t.Fatal("session replay", status) } status, data = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", login("customer"), WeChatLoginInput{LoginCode: "next"}) if status != 200 { t.Fatal(status) } check(data) status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", login("operator"), WeChatLoginInput{LoginCode: "operator"}) if status != 403 { t.Fatal("operator accepted", status) } provider.openID = uuid.NewString() status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", login("customer"), WeChatLoginInput{LoginCode: "other", PhoneCode: "phone"}) if status != 409 { t.Fatal("phone conflict accepted", status) } provider.openID = uuid.NewString() f.s.options.SelfSignupTenantIDs = nil provider.phone = "+8613900000001" status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", login("customer"), WeChatLoginInput{LoginCode: "new", PhoneCode: "phone"}) if status != 403 { t.Fatal("unconfigured signup accepted", status) } } func TestWeChatPasswordLinkAtomicAndLaterQuickLogin(t *testing.T) { f := newLoginFixture(t) account, csrf := f.smsAccount() w, _ := f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "LocalOnly123!"}, account, csrf, "") if w.Code != 200 { t.Fatal(w.Code) } provider := &fakeWeChat{openID: uuid.NewString(), phone: f.phone} f.s.options.WeChat = provider f.s.options.WeChatAppID = "wx4553119e05f11660" start := func() (string, string) { status, data := miniRequest(t, f.s, "POST", "/api/v1/mini/login/session", "", map[string]string{"portal": "customer"}) if status != 200 { t.Fatal(status) } auth := "MiniLogin " + data["login_token"].(string) _, token, _ := miniCredential(auth, "MiniLogin") return auth, digest([]byte(token)) } auth, hash := start() in := WeChatPasswordInput{PasswordLoginInput: PasswordLoginInput{Phone: f.phone, Password: "Wrong123!", CaptchaID: f.knownCaptchaFor(hash), CaptchaCode: "654321", TrustedDeviceToken: f.trusted.Value}, LoginCode: "link"} status, _ := miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat-link", auth, in) if status != 401 { t.Fatal(status) } var count int must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM customer_wechat_identity WHERE open_id=$1`, provider.openID).Scan(&count)) if count != 0 { t.Fatal("wrong password linked identity") } in.Password = "LocalOnly123!" in.CaptchaID = f.knownCaptchaFor(hash) status, data := miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat-link", auth, in) if status != 200 || data["access_token"] == nil { t.Fatal("password link failed", status) } auth, _ = start() status, data = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", auth, WeChatLoginInput{LoginCode: "later"}) if status != 200 || data["access_token"] == nil { t.Fatal("quick login failed", status) } _, err := f.s.pool.Exec(f.ctx, `UPDATE user_account SET status='disabled' WHERE oidc_issuer='urn:platform:onboarding:sms' AND oidc_subject=$1`, domainMAC(f.s.crypt.hashKey, "PHONE1\n"+f.phone)) must(t, err) auth, _ = start() status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/wechat", auth, WeChatLoginInput{LoginCode: "disabled"}) if status != 403 { t.Fatal("disabled user accepted", status) } }