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