package api import ( "context" "net/http" "net/http/httptest" "strings" "testing" "time" "tera-clone/services/tera-api/internal/adapters/mock" "tera-clone/services/tera-api/internal/authcaptcha" "tera-clone/services/tera-api/internal/domain" ) type captchaServiceStub struct { createdFor string verifiedFor string verifiedID string verifiedAns string } func (s *captchaServiceStub) Create(subject string) (authcaptcha.Challenge, error) { s.createdFor = subject return authcaptcha.Challenge{ ChallengeID: "challenge-1", ImageDataURL: "data:image/png;base64,iVBORw0KGgo=", ExpiresAt: fixedClock().Add(2 * time.Minute), }, nil } func (s *captchaServiceStub) Verify(subject, challengeID, answer string) bool { s.verifiedFor, s.verifiedID, s.verifiedAns = subject, challengeID, answer return subject == "192.0.2.44" && challengeID == "challenge-1" && answer == "23456789" } func TestLoginPolicyDefaultsAndDisabledCaptchaEndpoint(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) policy := getJSON(t, handler, http.MethodGet, "/api/v1/auth/login-policy", "") if policy["captcha_enabled"] != false || policy["login_detection_minutes"] != float64(0) || policy["login_security_hit_count"] != float64(0) { t.Fatalf("default login policy = %#v", policy) } if policy["fixed_safety_threshold"] != float64(5) || policy["fixed_safety_minutes"] != float64(15) { t.Fatalf("fixed login safety policy = %#v", policy) } policyResponse := httptest.NewRecorder() handler.ServeHTTP(policyResponse, httptest.NewRequest(http.MethodGet, "/api/v1/auth/login-policy", nil)) if policyResponse.Header().Get("Cache-Control") != "no-store" { t.Fatalf("login policy cache control=%q", policyResponse.Header().Get("Cache-Control")) } response := httptest.NewRecorder() handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/api/v1/auth/captcha", nil)) if response.Code != http.StatusConflict { t.Fatalf("disabled captcha endpoint status=%d body=%s", response.Code, response.Body.String()) } assertErrorCode(t, response, "captcha_disabled") } func TestEnabledCaptchaIsPeerBoundAndRequiredBeforeCredentialVerification(t *testing.T) { store := mock.NewStore(fixedClock) enabled, detection, hits := true, 9, 4 if _, err := store.UpdateManagementAccess(context.Background(), domain.ManagementAccessUpdate{ CaptchaEnabled: &enabled, LoginDetectionMinutes: &detection, LoginSecurityHitCount: &hits, }); err != nil { t.Fatal(err) } if _, err := store.ApplyConfig(context.Background()); err != nil { t.Fatal(err) } captcha := &captchaServiceStub{} handler := NewRouterWithOptions(store, RouterOptions{CaptchaService: captcha}) policy := getJSON(t, handler, http.MethodGet, "/api/v1/auth/login-policy", "") if policy["captcha_enabled"] != true || policy["login_detection_minutes"] != float64(9) || policy["login_security_hit_count"] != float64(4) { t.Fatalf("applied login policy = %#v", policy) } challengeResponse := httptest.NewRecorder() challengeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/auth/captcha", nil) challengeRequest.RemoteAddr = "127.0.0.1:41000" challengeRequest.Header.Set("X-Forwarded-For", "192.0.2.44") handler.ServeHTTP(challengeResponse, challengeRequest) if challengeResponse.Code != http.StatusOK || captcha.createdFor != "192.0.2.44" || !strings.Contains(challengeResponse.Body.String(), "challenge-1") { t.Fatalf("challenge status=%d subject=%q body=%s", challengeResponse.Code, captcha.createdFor, challengeResponse.Body.String()) } missingResponse := httptest.NewRecorder() missingRequest := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"username":"admin","password":"mock-admin-secret"}`)) missingRequest.RemoteAddr = "127.0.0.1:41001" missingRequest.Header.Set("X-Forwarded-For", "192.0.2.44") handler.ServeHTTP(missingResponse, missingRequest) if missingResponse.Code != http.StatusUnauthorized { t.Fatalf("login without captcha status=%d body=%s", missingResponse.Code, missingResponse.Body.String()) } assertErrorCode(t, missingResponse, "captcha_failed") loginResponse := httptest.NewRecorder() loginRequest := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"username":"admin","password":"mock-admin-secret","captcha_id":"challenge-1","captcha_answer":"23456789"}`)) loginRequest.RemoteAddr = "127.0.0.1:41002" loginRequest.Header.Set("X-Forwarded-For", "192.0.2.44") handler.ServeHTTP(loginResponse, loginRequest) if loginResponse.Code != http.StatusOK { t.Fatalf("login with captcha status=%d body=%s", loginResponse.Code, loginResponse.Body.String()) } if captcha.verifiedFor != "192.0.2.44" || captcha.verifiedID != "challenge-1" || captcha.verifiedAns != "23456789" { t.Fatalf("captcha verification = subject=%q id=%q answer=%q", captcha.verifiedFor, captcha.verifiedID, captcha.verifiedAns) } } func TestCaptchaPeerSubjectTrustsOnlySingleAddressFromLoopbackGateway(t *testing.T) { request := httptest.NewRequest(http.MethodGet, "/", nil) request.RemoteAddr = "127.0.0.1:1234" request.Header.Set("X-Forwarded-For", "198.51.100.20") if got := requestPeerSubject(request); got != "198.51.100.20" { t.Fatalf("loopback gateway subject=%q", got) } request.Header.Set("X-Forwarded-For", "203.0.113.9, 198.51.100.20") if got := requestPeerSubject(request); got != "127.0.0.1" { t.Fatalf("forwarded chain subject=%q, want loopback peer", got) } request.RemoteAddr = "192.0.2.77:4321" request.Header.Set("X-Forwarded-For", "198.51.100.20") if got := requestPeerSubject(request); got != "192.0.2.77" { t.Fatalf("direct peer subject=%q, want direct peer", got) } }