Files
story/internal/onboarding/captcha_policy_test.go
T

165 lines
5.9 KiB
Go
Raw Normal View History

package onboarding
import (
"bytes"
"context"
"data-terminal-cloud/internal/identity"
"encoding/json"
"github.com/google/uuid"
"net/http/httptest"
"strings"
"testing"
"time"
)
type fakeCaptchaProvider struct {
calls int
client, ip string
err error
}
func (p *fakeCaptchaProvider) AppID(client string) string {
if client == "mini_program" {
return "456"
}
return "123"
}
func (p *fakeCaptchaProvider) Verify(_ context.Context, client, ticket, randstr, ip string) error {
p.calls++
p.client = client
p.ip = ip
return p.err
}
func setCaptchaTestPolicy(t *testing.T, f *fixture, saas, mini string) {
t.Helper()
_, err := f.s.pool.Exec(f.ctx, `UPDATE login_captcha_policy SET saas_mode=$1,mini_mode=$2,version=version+1`, saas, mini)
must(t, err)
t.Cleanup(func() {
_, e := f.s.pool.Exec(f.ctx, `UPDATE login_captcha_policy SET saas_mode='image',mini_mode='image',version=version+1`)
if e != nil {
t.Error(e)
}
})
}
func TestCaptchaPolicyTencentSendCannotBypassOrReplay(t *testing.T) {
f := newLoginFixture(t)
p := &fakeCaptchaProvider{}
f.s.options.Captcha = p
setCaptchaTestPolicy(t, f.fixture, "tencent", "image")
w, ch := f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, f.csrf, "")
if w.Code != 200 || ch["mode"] != "tencent" || ch["captcha_app_id"] != "123" || ch["image"] != nil {
t.Fatal(w.Body.String())
}
in := SMSSendInput{Phone: f.phone, Purpose: "LOGIN", CaptchaID: ch["captcha_id"].(string), CaptchaCode: "654321"}
w, _ = f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, "")
if w.Code != 401 || p.calls != 0 || f.sender.calls != 0 {
t.Fatal("numeric answer bypassed tencent")
}
_, ch = f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, f.csrf, "")
in.CaptchaID = ch["captcha_id"].(string)
in.CaptchaTicket = uuid.NewString()
in.CaptchaRandstr = "random"
in.CaptchaCode = ""
req := uuid.NewString()
w, _ = f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, req)
if w.Code != 200 || p.calls != 1 || p.client != "saas" || p.ip != strings.Split(f.ip, ":")[0] || f.sender.calls != 1 {
t.Fatal(w.Body.String())
}
w, _ = f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, req)
if w.Code != 200 || p.calls != 1 || f.sender.calls != 1 {
t.Fatal("idempotency failed")
}
_, ch = f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, f.csrf, "")
in.CaptchaID = ch["captcha_id"].(string)
w, _ = f.request("POST", "/api/v1/web/login/sms/send", in, f.cookie, f.csrf, "")
if w.Code != 401 || p.calls != 1 || f.sender.calls != 1 {
t.Fatal("ticket replay accepted")
}
}
func TestCaptchaPolicyClientSeparationExpiryAndCloudFailure(t *testing.T) {
f := newLoginFixture(t)
p := &fakeCaptchaProvider{}
f.s.options.Captcha = p
setCaptchaTestPolicy(t, f.fixture, "image", "tencent")
webctx := context.WithValue(f.ctx, captchaRequestKey{}, captchaRequestInfo{client: "saas", ip: "203.0.113.1"})
mini := context.WithValue(f.ctx, captchaRequestKey{}, captchaRequestInfo{client: "mini_program", ip: "203.0.113.2"})
hash := digest([]byte(f.cookie.Value))
ch, err := f.s.loginCaptcha(mini, hash)
must(t, err)
if ch["captcha_app_id"] != "456" {
t.Fatal("mixed app ids")
}
if f.s.verifyLoginCaptcha(webctx, hash, ch["captcha_id"].(string), "", uuid.NewString(), "") == nil {
t.Fatal("cross-client bypass")
}
ch, err = f.s.loginCaptcha(mini, hash)
must(t, err)
_, err = f.s.redis.Expire(f.ctx, f.s.captchaKey(hash), -time.Second).Result()
must(t, err)
if f.s.verifyLoginCaptcha(mini, hash, ch["captcha_id"].(string), "", uuid.NewString(), "") == nil {
t.Fatal("expired accepted")
}
ch, err = f.s.loginCaptcha(mini, hash)
must(t, err)
p.err = captchaUnavailable()
if f.s.verifyLoginCaptcha(mini, hash, ch["captcha_id"].(string), "", uuid.NewString(), "") == nil || p.client != "mini_program" {
t.Fatal("cloud failure accepted")
}
ch, err = f.s.loginCaptcha(mini, hash)
must(t, err)
p.err = nil
_, err = f.s.pool.Exec(f.ctx, `UPDATE login_captcha_policy SET version=version+1`)
must(t, err)
if f.s.verifyLoginCaptcha(mini, hash, ch["captcha_id"].(string), "", uuid.NewString(), "") == nil {
t.Fatal("old policy accepted")
}
}
type captchaPolicyToken struct{ p identity.Principal }
func (v captchaPolicyToken) Verify(context.Context, string) (identity.Principal, error) {
return v.p, nil
}
func TestCaptchaPolicyAdminOnlyReadinessVersionAndAudit(t *testing.T) {
f := newFixture(t)
setCaptchaTestPolicy(t, f, "image", "image")
f.s.options.TokenVerifier = captchaPolicyToken{f.p}
call := func(method string, body any) (any, error) {
raw, _ := json.Marshal(body)
r := httptest.NewRequest(method, "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())
}
out, err := call("GET", nil)
must(t, err)
policy := out.(map[string]any)
in := captchaPolicy{SaaS: "tencent", Mini: "image", Version: policy["version"].(int64)}
if _, err = call("POST", in); err == nil {
t.Fatal("unconfigured provider accepted")
}
f.s.options.Captcha = &fakeCaptchaProvider{}
out, err = call("POST", in)
must(t, err)
if out.(map[string]any)["saas_mode"] != "tencent" {
t.Fatal("not persisted")
}
if _, err = call("POST", in); err == nil {
t.Fatal("stale version accepted")
}
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("missing audit")
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE user_account SET platform_role='ops_operator' WHERE id=$1`, f.operatorID)
must(t, err)
if _, err = call("GET", nil); err == nil {
t.Fatal("nonadmin read")
}
if _, err = call("POST", captchaPolicy{SaaS: "image", Mini: "image", Version: in.Version + 1}); err == nil {
t.Fatal("nonadmin write")
}
}