feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
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.
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user