Files
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
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.
2026-10-08 10:06:44 +08:00

198 lines
8.1 KiB
Go

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