ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
222 lines
8.1 KiB
Go
222 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|