Files

198 lines
8.1 KiB
Go
Raw Permalink Normal View History

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")
}
}