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,425 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func TestLocalSlideRenderAndBounds(t *testing.T) {
|
||||
for i := 0; i < 8; i++ {
|
||||
out, target, err := renderSlideCaptcha()
|
||||
must(t, err)
|
||||
if out["mode"] != "slide" || out["thumb_x"] != 5 || out["thumb_y"] != target.Y || target.X < 80 || target.X > 258 {
|
||||
t.Fatal("invalid slide geometry")
|
||||
}
|
||||
for _, key := range []string{"x", "y", "target_x", "target_y", "solution", "answer_hash"} {
|
||||
if _, exists := out[key]; exists {
|
||||
t.Fatal("solution exposed")
|
||||
}
|
||||
}
|
||||
for _, part := range []struct {
|
||||
key, prefix string
|
||||
w, h int
|
||||
}{
|
||||
{"image", "data:image/jpeg;base64,", slideWidth, slideHeight},
|
||||
{"thumb_image", "data:image/png;base64,", slideTileSize, slideTileSize},
|
||||
} {
|
||||
encoded := out[part.key].(string)
|
||||
if !strings.HasPrefix(encoded, part.prefix) {
|
||||
t.Fatal("unexpected image format")
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(encoded, part.prefix))
|
||||
must(t, err)
|
||||
img, _, err := image.Decode(bytes.NewReader(raw))
|
||||
must(t, err)
|
||||
if img.Bounds().Dx() != part.w || img.Bounds().Dy() != part.h {
|
||||
t.Fatal("incorrect image bounds")
|
||||
}
|
||||
if len(raw) > 200000 {
|
||||
t.Fatal("unbounded captcha payload")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSlideParallelRenderer(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
var rendered atomic.Int32
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
out, _, err := renderSlideCaptcha()
|
||||
if err == nil {
|
||||
if out["mode"] != "slide" {
|
||||
t.Error("mode")
|
||||
}
|
||||
rendered.Add(1)
|
||||
return
|
||||
}
|
||||
if e, ok := err.(*Error); !ok || e.Code != "CAPTCHA_BUSY" {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if rendered.Load() < 1 {
|
||||
t.Fatal("no render completed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSlideDecoyGeometry(t *testing.T) {
|
||||
for x := 80; x <= 258; x++ {
|
||||
positions, err := slideGapPositions(slideSolution{X: x, Y: 20})
|
||||
must(t, err)
|
||||
if len(positions) != 3 || positions[0] != x {
|
||||
t.Fatal("target lost")
|
||||
}
|
||||
for i, gap := range positions {
|
||||
if gap < 10 || gap > 258 {
|
||||
t.Fatal("gap outside image")
|
||||
}
|
||||
for _, other := range positions[:i] {
|
||||
if gap-other < slideTileSize+10 && other-gap < slideTileSize+10 {
|
||||
t.Fatal("overlapping gaps")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newSlideLoginFixture(t *testing.T) *loginFixture {
|
||||
f := newLoginFixture(t)
|
||||
setCaptchaTestPolicy(t, f.fixture, "slide", "image")
|
||||
return f
|
||||
}
|
||||
func (f *loginFixture) slideChallenge() (map[string]any, slideSolution) {
|
||||
f.t.Helper()
|
||||
w, ch := f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, f.csrf, "")
|
||||
if w.Code != 200 || ch["mode"] != "slide" {
|
||||
f.t.Fatal("slide generation failed", w.Body.String())
|
||||
}
|
||||
value, err := f.s.redis.Get(f.ctx, f.s.captchaKey(digest([]byte(f.cookie.Value)))).Result()
|
||||
must(f.t, err)
|
||||
var stored storedCaptcha
|
||||
must(f.t, json.Unmarshal([]byte(value), &stored))
|
||||
plain, err := f.s.crypt.open(stored.Solution, slideAAD(digest([]byte(f.cookie.Value)), stored))
|
||||
must(f.t, err)
|
||||
var target slideSolution
|
||||
must(f.t, json.Unmarshal(plain, &target))
|
||||
return ch, target
|
||||
}
|
||||
func slideInput(ch map[string]any, target slideSolution) map[string]any {
|
||||
return map[string]any{"captcha_id": ch["captcha_id"], "x": target.X, "y": target.Y}
|
||||
}
|
||||
|
||||
func TestLocalSlideSuccessTicketSMSAndReplay(t *testing.T) {
|
||||
f := newSlideLoginFixture(t)
|
||||
provider := &fakeCaptchaProvider{}
|
||||
f.s.options.Captcha = provider
|
||||
ch, target := f.slideChallenge()
|
||||
w, proof := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
|
||||
if w.Code != 200 || proof["captcha_id"] != ch["captcha_id"] || proof["expires_at_ms"] != ch["expires_at_ms"] {
|
||||
t.Fatal("valid position rejected or lifetime extended", w.Body.String())
|
||||
}
|
||||
ticket := proof["captcha_ticket"].(string)
|
||||
if !validB64(ticket, 32) {
|
||||
t.Fatal("insecure proof")
|
||||
}
|
||||
value, err := f.s.redis.Get(f.ctx, f.s.captchaKey(digest([]byte(f.cookie.Value)))).Result()
|
||||
must(t, err)
|
||||
var stored storedCaptcha
|
||||
must(t, json.Unmarshal([]byte(value), &stored))
|
||||
if len(stored.Solution) != 0 || stored.ProofHash == "" || strings.Contains(value, ticket) {
|
||||
t.Fatal("proof must be hashed and solution removed")
|
||||
}
|
||||
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
|
||||
if w.Code != 401 {
|
||||
t.Fatal("position replay accepted")
|
||||
}
|
||||
input := SMSSendInput{Phone: f.phone, Purpose: "LOGIN", CaptchaID: ch["captcha_id"].(string), CaptchaTicket: ticket}
|
||||
w, _ = f.request("POST", "/api/v1/web/login/sms/send", input, f.cookie, f.csrf, "")
|
||||
if w.Code != 200 || f.sender.calls != 1 || provider.calls != 0 {
|
||||
t.Fatal("local proof did not authorize SMS independently", w.Body.String())
|
||||
}
|
||||
w, _ = f.request("POST", "/api/v1/web/login/sms/send", input, f.cookie, f.csrf, "")
|
||||
if w.Code != 401 || f.sender.calls != 1 {
|
||||
t.Fatal("proof reused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSlidePasswordUsesLocalProof(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.Body.String())
|
||||
}
|
||||
setCaptchaTestPolicy(t, f.fixture, "slide", "image")
|
||||
provider := &fakeCaptchaProvider{}
|
||||
f.s.options.Captcha = provider
|
||||
f.newLoginSession()
|
||||
ch, target := f.slideChallenge()
|
||||
w, proof := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
w, _ = f.request("POST", "/api/v1/web/login/password", PasswordLoginInput{Phone: f.phone, Password: "LocalOnly123!", CaptchaID: ch["captcha_id"].(string), CaptchaTicket: proof["captcha_ticket"].(string)}, f.cookie, f.csrf, "")
|
||||
if w.Code != 200 || provider.calls != 0 {
|
||||
t.Fatal("password did not use local proof", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSlidePolicyAuditAndMiniRestriction(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
setCaptchaTestPolicy(t, f, "image", "image")
|
||||
f.s.options.TokenVerifier = captchaPolicyToken{f.p}
|
||||
policy, err := f.s.readCaptchaPolicy(f.ctx, f.s.pool)
|
||||
must(t, err)
|
||||
call := func(in captchaPolicy) (any, error) {
|
||||
raw, _ := json.Marshal(in)
|
||||
r := httptest.NewRequest("POST", "https://platform.example/api/v1/ops/login-captcha-policy", bytes.NewReader(raw))
|
||||
r.Header.Set("Authorization", "Bearer test")
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("X-Tenant-ID", f.tenantPublic)
|
||||
return f.s.captchaPolicyHTTP(r, uuid.NewString())
|
||||
}
|
||||
if _, err = call(captchaPolicy{SaaS: "slide", Mini: "slide", Version: policy.Version}); err == nil {
|
||||
t.Fatal("mini slide enabled before release")
|
||||
}
|
||||
out, err := call(captchaPolicy{SaaS: "slide", Mini: "image", Version: policy.Version})
|
||||
must(t, err)
|
||||
if out.(map[string]any)["saas_mode"] != "slide" {
|
||||
t.Fatal("slide policy not persisted")
|
||||
}
|
||||
var count int
|
||||
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM audit_event WHERE tenant_id=$1 AND action='login.captcha.policy.update'`, f.tenant).Scan(&count))
|
||||
if count != 1 {
|
||||
t.Fatal("slide switch missing audit")
|
||||
}
|
||||
if _, err = call(captchaPolicy{SaaS: "image", Mini: "image", Version: policy.Version}); err == nil {
|
||||
t.Fatal("stale policy accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSlideWrongPositionConsumedAndRefreshProtected(t *testing.T) {
|
||||
f := newSlideLoginFixture(t)
|
||||
ch, target := f.slideChallenge()
|
||||
wrong := target
|
||||
wrong.X += 20
|
||||
w, _ := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, wrong), f.cookie, f.csrf, "")
|
||||
if w.Code != 401 {
|
||||
t.Fatal("wrong position accepted")
|
||||
}
|
||||
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
|
||||
if w.Code != 401 {
|
||||
t.Fatal("failed challenge reused")
|
||||
}
|
||||
old, oldTarget := f.slideChallenge()
|
||||
current, currentTarget := f.slideChallenge()
|
||||
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(old, oldTarget), f.cookie, f.csrf, "")
|
||||
if w.Code != 401 {
|
||||
t.Fatal("refreshed challenge accepted")
|
||||
}
|
||||
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(current, currentTarget), f.cookie, f.csrf, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatal("old request destroyed fresh challenge")
|
||||
}
|
||||
key := f.s.captchaKey(digest([]byte(f.cookie.Value)))
|
||||
oldValue, err := f.s.redis.Get(f.ctx, key).Result()
|
||||
must(t, err)
|
||||
f.slideChallenge()
|
||||
updated, err := f.s.redis.Eval(f.ctx, replaceSlideRecord, []string{key}, oldValue, "invalid overwrite").Int64()
|
||||
must(t, err)
|
||||
if updated != 0 {
|
||||
t.Fatal("delayed proof overwrote a refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSlideExpiryPolicyClientAndForgery(t *testing.T) {
|
||||
f := newSlideLoginFixture(t)
|
||||
hash := digest([]byte(f.cookie.Value))
|
||||
ch, target := f.slideChallenge()
|
||||
must(t, f.s.redis.PExpire(f.ctx, f.s.captchaKey(hash), -time.Second).Err())
|
||||
w, _ := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
|
||||
if w.Code != 401 {
|
||||
t.Fatal("expired challenge accepted")
|
||||
}
|
||||
ch, target = f.slideChallenge()
|
||||
w, proof := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
mini := context.WithValue(f.ctx, captchaRequestKey{}, captchaRequestInfo{client: "mini_program"})
|
||||
if f.s.verifyLoginCaptcha(mini, hash, ch["captcha_id"].(string), "", proof["captcha_ticket"].(string), "") == nil {
|
||||
t.Fatal("cross-client proof accepted")
|
||||
}
|
||||
ch, target = f.slideChallenge()
|
||||
w, proof = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
_, err := f.s.pool.Exec(f.ctx, `UPDATE login_captcha_policy SET version=version+1`)
|
||||
must(t, err)
|
||||
if f.s.verifyLoginCaptcha(f.ctx, hash, ch["captcha_id"].(string), "", proof["captcha_ticket"].(string), "") == nil {
|
||||
t.Fatal("old-policy proof accepted")
|
||||
}
|
||||
ch, _ = f.slideChallenge()
|
||||
if f.s.verifyLoginCaptcha(f.ctx, hash, ch["captcha_id"].(string), "", randomToken(32), "") == nil {
|
||||
t.Fatal("unverified position authorized proof")
|
||||
}
|
||||
ch, target = f.slideChallenge()
|
||||
w, proof = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
must(t, f.s.redis.PExpire(f.ctx, f.s.captchaKey(hash), -time.Second).Err())
|
||||
if f.s.verifyLoginCaptcha(f.ctx, hash, ch["captcha_id"].(string), "", proof["captcha_ticket"].(string), "") == nil {
|
||||
t.Fatal("expired proof accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSlideHTTPOriginCSRFSessionAndStrictCoordinates(t *testing.T) {
|
||||
f := newSlideLoginFixture(t)
|
||||
ch, target := f.slideChallenge()
|
||||
w, _ := f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, "wrong", "")
|
||||
if w.Code != 403 {
|
||||
t.Fatal("invalid CSRF accepted")
|
||||
}
|
||||
w, session := f.request("GET", "/api/v1/web/login/session", nil, nil, "", "")
|
||||
other := w.Result().Cookies()[0]
|
||||
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), other, session["csrf_token"].(string), "")
|
||||
if w.Code != 401 {
|
||||
t.Fatal("cross-session position accepted")
|
||||
}
|
||||
call := func(raw, origin string) int {
|
||||
r := httptest.NewRequest("POST", "https://platform.example/api/v1/web/login/captcha/verify", strings.NewReader(raw))
|
||||
r.RemoteAddr = f.ip
|
||||
r.AddCookie(f.cookie)
|
||||
r.Header.Set("Origin", origin)
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("X-CSRF-Token", f.csrf)
|
||||
r.Header.Set("X-Request-ID", uuid.NewString())
|
||||
w := httptest.NewRecorder()
|
||||
f.mux.ServeHTTP(w, r)
|
||||
return w.Code
|
||||
}
|
||||
raw, _ := json.Marshal(slideInput(ch, target))
|
||||
if call(string(raw), "https://other.example") != 403 {
|
||||
t.Fatal("cross-origin accepted")
|
||||
}
|
||||
for _, bad := range []string{
|
||||
`{"captcha_id":"` + ch["captcha_id"].(string) + `","x":1.5,"y":10}`,
|
||||
`{"captcha_id":"` + ch["captcha_id"].(string) + `","x":10}`,
|
||||
`{"captcha_id":"` + ch["captcha_id"].(string) + `","x":null,"y":10}`,
|
||||
`{"captcha_id":"` + ch["captcha_id"].(string) + `","x":10,"y":10,"mode":"image"}`,
|
||||
} {
|
||||
if call(bad, "https://platform.example") != 400 {
|
||||
t.Fatal("malformed coordinates accepted")
|
||||
}
|
||||
}
|
||||
w, _ = f.request("POST", "/api/v1/web/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatal("unauthorized request consumed challenge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSlideConcurrentProofAndConsumeOnce(t *testing.T) {
|
||||
f := newSlideLoginFixture(t)
|
||||
ch, target := f.slideChallenge()
|
||||
x, y := target.X, target.Y
|
||||
hash := digest([]byte(f.cookie.Value))
|
||||
var wg sync.WaitGroup
|
||||
var winners atomic.Int32
|
||||
var ticket string
|
||||
var ticketMu sync.Mutex
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
out, err := f.s.verifySlideCaptcha(f.ctx, hash, SlideCaptchaVerifyInput{ID: ch["captcha_id"].(string), X: &x, Y: &y}, func(tx pgx.Tx) error { return f.s.checkLoginSession(f.ctx, tx, hash) })
|
||||
if err == nil {
|
||||
winners.Add(1)
|
||||
ticketMu.Lock()
|
||||
ticket = out["captcha_ticket"].(string)
|
||||
ticketMu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if winners.Load() != 1 {
|
||||
t.Fatal("concurrent position generated multiple proofs")
|
||||
}
|
||||
winners.Store(0)
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if f.s.verifyLoginCaptcha(f.ctx, hash, ch["captcha_id"].(string), "", ticket, "") == nil {
|
||||
winners.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if winners.Load() != 1 {
|
||||
t.Fatal("concurrent proof consumed more than once")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSlideOperatorAndSetupRoutes(t *testing.T) {
|
||||
f := newOperatorLoginFixture(t)
|
||||
setCaptchaTestPolicy(t, f.fixture, "slide", "image")
|
||||
w, ch := f.request("POST", "/api/v1/web/operator/login/captcha", struct{}{}, f.cookie, f.csrf, "")
|
||||
if w.Code != 200 || ch["mode"] != "slide" {
|
||||
t.Fatal("operator slide unavailable")
|
||||
}
|
||||
hash := digest([]byte(f.cookie.Value))
|
||||
value, err := f.s.redis.Get(f.ctx, f.s.captchaKey(hash)).Result()
|
||||
must(t, err)
|
||||
var stored storedCaptcha
|
||||
must(t, json.Unmarshal([]byte(value), &stored))
|
||||
plain, err := f.s.crypt.open(stored.Solution, slideAAD(hash, stored))
|
||||
must(t, err)
|
||||
var target slideSolution
|
||||
must(t, json.Unmarshal(plain, &target))
|
||||
w, _ = f.request("POST", "/api/v1/web/operator/login/captcha/verify", slideInput(ch, target), f.cookie, f.csrf, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatal("operator position rejected", w.Body.String())
|
||||
}
|
||||
setup := newSlideLoginFixture(t)
|
||||
setup.s.options.Enabled = true
|
||||
token, csrf := randomToken(32), randomToken(32)
|
||||
_, err = setup.s.pool.Exec(setup.ctx, `UPDATE onboarding_web_session SET token_hash=$1,csrf_hash=$2 WHERE token_hash=$3`, digest([]byte(token)), digest([]byte(csrf)), setup.web.Hash)
|
||||
must(t, err)
|
||||
cookie := &http.Cookie{Name: setup.s.cookieName(false), Value: token}
|
||||
w, ch = setup.request("POST", "/api/v1/web/auth/captcha", struct{}{}, cookie, csrf, "")
|
||||
if w.Code != 200 || ch["mode"] != "slide" {
|
||||
t.Fatal("setup slide unavailable", w.Body.String())
|
||||
}
|
||||
hash = digest([]byte(token))
|
||||
value, err = setup.s.redis.Get(setup.ctx, setup.s.captchaKey(hash)).Result()
|
||||
must(t, err)
|
||||
must(t, json.Unmarshal([]byte(value), &stored))
|
||||
plain, err = setup.s.crypt.open(stored.Solution, slideAAD(hash, stored))
|
||||
must(t, err)
|
||||
must(t, json.Unmarshal(plain, &target))
|
||||
w, _ = setup.request("POST", "/api/v1/web/auth/captcha/verify", slideInput(ch, target), cookie, csrf, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatal("setup position rejected", w.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user