package onboarding import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/google/uuid" ) func miniRequest(t *testing.T, s *Service, method, path, auth string, in any) (int, map[string]any) { t.Helper() raw, _ := json.Marshal(in) r := httptest.NewRequest(method, "https://platform.example"+path, bytes.NewReader(raw)) r.RemoteAddr = "127.0.0.1:3456" r.Header.Set("Content-Type", "application/json") r.Header.Set("X-Request-ID", uuid.NewString()) r.Header.Set("Authorization", auth) w := httptest.NewRecorder() mux := http.NewServeMux() s.RegisterRoutes(mux) mux.ServeHTTP(w, r) if len(w.Result().Cookies()) != 0 { t.Fatal("native API issued ambient cookie") } var out map[string]any must(t, json.Unmarshal(w.Body.Bytes(), &out)) data, _ := out["data"].(map[string]any) return w.Code, data } func TestMiniCredentialsStrictRealm(t *testing.T) { good := "customer." + randomToken(32) if _, _, ok := miniCredential("Mini "+good, "Mini"); !ok { t.Fatal("valid token rejected") } for _, value := range []string{"Bearer " + good, "Mini admin." + randomToken(32), "Mini customer.short", "Mini " + good + "\n", "MiniLogin " + good} { if _, _, ok := miniCredential(value, "Mini"); ok { t.Fatal("malformed token accepted") } } } func TestMiniSMSLoginSharesCustomerIdentityAndNoCookies(t *testing.T) { f := newLoginFixture(t) _, err := f.s.ProvisionCustomerPhone(f.ctx, f.web.UserID, f.user.Issuer, f.user.Subject, f.phone, "CustomerOnly123!", uuid.NewString()) must(t, err) status, session := miniRequest(t, f.s, "POST", "/api/v1/mini/login/session", "", map[string]string{"portal": "customer"}) if status != 200 || session["sms_enabled"] != true { t.Fatal("mini SMS session unavailable", status) } loginToken := session["login_token"].(string) _, token, _ := miniCredential("MiniLogin "+loginToken, "MiniLogin") status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/captcha", "MiniLogin "+loginToken, struct{}{}) if status != 200 { t.Fatal("mini captcha unavailable", status) } in := SMSSendInput{Phone: f.phone, Purpose: "LOGIN", CaptchaID: f.knownCaptchaFor(digest([]byte(token))), CaptchaCode: "654321"} status, challenge := miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/send", "MiniLogin "+loginToken, in) if status != 200 || f.sender.calls != 1 || challenge["sms_challenge_id"] == nil { t.Fatal("mini SMS was not sent", status, challenge) } status, result := miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/verify", "MiniLogin "+loginToken, SMSVerifyInput{ChallengeID: challenge["sms_challenge_id"].(string), Code: f.sender.code}) if status != 200 || result["portal"] != "customer" { t.Fatal("mini SMS login failed", status, result) } auth := "Mini " + result["access_token"].(string) status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", auth, nil) if status != 200 { t.Fatal("mini account did not inherit customer identity", status) } status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/verify", "MiniLogin "+loginToken, SMSVerifyInput{ChallengeID: challenge["sms_challenge_id"].(string), Code: f.sender.code}) if status != 401 { t.Fatal("mini SMS challenge was reused", status) } } func TestMiniSMSOperatorCannotSelfRegister(t *testing.T) { f := newOperatorLoginFixture(t) status, session := miniRequest(t, f.s, "POST", "/api/v1/mini/login/session", "", map[string]string{"portal": "operator"}) if status != 200 { t.Fatal(status) } loginToken := session["login_token"].(string) _, token, _ := miniCredential("MiniLogin "+loginToken, "MiniLogin") status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/captcha", "MiniLogin "+loginToken, struct{}{}) if status != 200 { t.Fatal(status) } in := SMSSendInput{Phone: f.phone, Purpose: "LOGIN", CaptchaID: f.knownCaptchaFor(digest([]byte(token))), CaptchaCode: "654321"} status, challenge := miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/send", "MiniLogin "+loginToken, in) if status != 200 { t.Fatal(status) } verify := SMSVerifyInput{ChallengeID: challenge["sms_challenge_id"].(string), Code: f.sender.code} status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/verify", "MiniLogin "+loginToken, verify) if status != 403 { t.Fatal("unprovisioned operator registered", status) } _, err := f.s.ProvisionOperatorPhone(f.ctx, f.operatorID, f.p.Issuer, f.p.Subject, f.phone, "OperatorOnly123!", uuid.NewString()) must(t, err) status, result := miniRequest(t, f.s, "POST", "/api/v1/mini/login/sms/verify", "MiniLogin "+loginToken, verify) if status != 200 || result["portal"] != "operator" { t.Fatal("provisioned operator could not log in", status, result) } auth := "Mini " + result["access_token"].(string) status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", auth, nil) if status != 200 { t.Fatal("operator session rejected", status) } status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", strings.Replace(auth, "operator.", "customer.", 1), nil) if status != 401 { t.Fatal("operator token crossed customer realm", status) } } func TestMiniPasswordRealmRevocationAndNoCookies(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) } status, login := miniRequest(t, f.s, "POST", "/api/v1/mini/login/session", "", map[string]string{"portal": "customer"}) if status != 200 { t.Fatal(status) } loginToken := login["login_token"].(string) _, token, _ := miniCredential("MiniLogin "+loginToken, "MiniLogin") status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/captcha", "MiniLogin "+loginToken, struct{}{}) if status != 200 { t.Fatal(status) } id := f.knownCaptchaFor(digest([]byte(token))) status, result := miniRequest(t, f.s, "POST", "/api/v1/mini/login/password", "MiniLogin "+loginToken, PasswordLoginInput{Phone: f.phone, Password: "LocalOnly123!", CaptchaID: id, CaptchaCode: "654321", TrustedDeviceToken: f.trusted.Value}) if status != 200 { t.Fatal(status) } auth := "Mini " + result["access_token"].(string) status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", auth, nil) if status != 200 { t.Fatal(status) } status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", strings.Replace(auth, "customer.", "operator.", 1), nil) if status != 401 { t.Fatal("realm substitution", status) } status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/login/captcha", "MiniLogin "+loginToken, struct{}{}) if status != 401 { t.Fatal("consumed login reused", status) } status, _ = miniRequest(t, f.s, "POST", "/api/v1/mini/logout", auth, struct{}{}) if status != 200 { t.Fatal(status) } status, _ = miniRequest(t, f.s, "GET", "/api/v1/mini/account", auth, nil) if status != 401 { t.Fatal("revoked session accepted", status) } // Existing web writes still require their own Origin and CSRF. w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "ChangedOnly123!"}, account, "wrong", "") if w.Code != 403 { t.Fatal("web CSRF weakened") } } func TestBLEAutomaticSession(t *testing.T) { f := newFixture(t) in := BLESessionInput{DeviceID: f.d.FactoryID, Model: f.d.Model, KeyVersion: f.d.KeyVersion, ClientNonce: randomToken(16), ServerNonce: randomToken(16)} in.Proof = domainMAC(f.d.Key, "BLE-PROOF1\n"+bleTranscript(in)) result, err := f.s.BLESession(f.ctx, f.user, in) must(t, err) if result["session_password"] != domainMAC(f.d.Key, "BLE-PASS1\n"+bleTranscript(in)) || result["session_password"] == in.Proof { t.Fatal("wrong or non-domain-separated password") } _, err = f.s.BLESession(f.ctx, f.user, in) must(t, err) bad := in bad.ServerNonce = randomToken(16) _, err = f.s.BLESession(f.ctx, f.user, bad) if err == nil { t.Fatal("tampered transcript accepted") } _, err = f.s.BLESession(f.ctx, f.p, in) if err == nil { t.Fatal("operator obtained family pairing password") } must(t, f.s.redis.Set(f.ctx, "onboarding:ble-link:"+digest([]byte(in.Proof)), "different-account", time.Minute).Err()) _, err = f.s.BLESession(f.ctx, f.user, in) if err == nil { t.Fatal("cross-account replay accepted") } }