package httpapi import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/transport/authcommon" "cmroubao/backend-api/internal/usecase" "github.com/gin-gonic/gin" ) const ( testOpaqueToken = "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE" testCSRFOpaqueToken = "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI" ) type fakeBuyerTokenService struct { command usecase.LoginBuyerDeviceCommand result usecase.AccessTokenResult err error calls int } func (service *fakeBuyerTokenService) LoginBuyerDevice( _ context.Context, command usecase.LoginBuyerDeviceCommand, ) (usecase.AccessTokenResult, error) { service.calls++ service.command = command return service.result, service.err } func TestIssueBuyerTokenReturnsOnlyPublicIdentityAndAccessToken(t *testing.T) { service := &fakeBuyerTokenService{ result: usecase.AccessTokenResult{ Token: testOpaqueToken, ExpiresAt: time.Now().Add(time.Hour), User: domain.User{ ID: "buyer-1", Username: "buyer01", Role: domain.UserRoleBuyer, }, Device: domain.Device{ ID: "device-1", IsEnabled: true, }, }, } router := newPublicAuthTestRouter(t, service) body := `{ "username":"buyer01", "password":"private-password", "device_id":"device-1", "device_token":"private-device-token", "app_version":"0.1.0", "android_version":"15" }` request := httptest.NewRequest( http.MethodPost, "/api/v1/auth/token", strings.NewReader(body), ) request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status/body = %d / %s", response.Code, response.Body) } if service.command.DeviceID != "device-1" || service.command.AppVersion != "0.1.0" || service.command.AndroidVersion != "15" || service.command.Password != "private-password" { t.Fatalf("command = %+v", service.command) } var decoded map[string]any if err := json.Unmarshal(response.Body.Bytes(), &decoded); err != nil { t.Fatalf("decode response: %v", err) } if decoded["access_token"] != testOpaqueToken || decoded["token_type"] != "Bearer" || decoded["expires_in"] != float64(3600) { t.Fatalf("response = %#v", decoded) } responseText := response.Body.String() for _, secret := range []string{ "private-password", "private-device-token", "android_version", "app_version", } { if strings.Contains(responseText, secret) { t.Fatalf("response leaked %q: %s", secret, responseText) } } if response.Header().Get("Cache-Control") != "no-store" { t.Fatalf("Cache-Control = %q", response.Header().Get("Cache-Control")) } } func TestIssueBuyerTokenUsesGenericCredentialErrors(t *testing.T) { service := &fakeBuyerTokenService{ err: &usecase.Error{ Kind: usecase.ErrorKindInvalid, Code: "AUTH_VALIDATION_FAILED", Message: "private validation detail", Fields: map[string]string{"password": "private detail"}, }, } router := newPublicAuthTestRouter(t, service) request := httptest.NewRequest( http.MethodPost, "/api/v1/auth/token", strings.NewReader(`{ "username":"buyer", "password":"secret-value", "device_id":"device", "device_token":"token", "app_version":"0.1", "android_version":"15" }`), ) request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("status/body = %d / %s", response.Code, response.Body) } assertErrorCode(t, response, "AUTH_INVALID_CREDENTIALS") for _, privateValue := range []string{ "secret-value", "private validation detail", "private detail", } { if strings.Contains(response.Body.String(), privateValue) { t.Fatalf("error leaked %q", privateValue) } } } func TestIssueBuyerTokenRateLimitSkipsAuthenticationWork(t *testing.T) { service := &fakeBuyerTokenService{ err: &usecase.Error{ Kind: usecase.ErrorKindUnauthorized, Code: "AUTH_INVALID_CREDENTIALS", }, } limiter, err := authcommon.NewAttemptLimiter(1, time.Minute, 10) if err != nil { t.Fatalf("NewAttemptLimiter() error = %v", err) } registrar, err := NewPublicAuthRegistrar(service, limiter) if err != nil { t.Fatalf("NewPublicAuthRegistrar() error = %v", err) } router := gin.New() router.Use(requestIDMiddleware()) if err := registrar(router); err != nil { t.Fatalf("register public auth: %v", err) } body := `{ "username":"buyer", "password":"secret-value", "device_id":"device", "device_token":"token", "app_version":"0.1", "android_version":"15" }` requestToken := func() *httptest.ResponseRecorder { request := httptest.NewRequest( http.MethodPost, "/api/v1/auth/token", strings.NewReader(body), ) request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() router.ServeHTTP(response, request) return response } if response := requestToken(); response.Code != http.StatusUnauthorized { t.Fatalf("first status = %d", response.Code) } response := requestToken() if response.Code != http.StatusTooManyRequests || response.Header().Get("Retry-After") == "" || !strings.Contains(response.Body.String(), `"code":"AUTH_RATE_LIMITED"`) || service.calls != 1 { t.Fatalf( "second status/retry/calls/body = %d / %q / %d / %s", response.Code, response.Header().Get("Retry-After"), service.calls, response.Body, ) } } func TestAdminSessionMiddlewareSeparatesWebAPIAndCSRF(t *testing.T) { authenticator := &stubAuthenticator{ adminPrincipal: domain.AuthPrincipal{ UserID: "admin-1", Username: "admin", Role: domain.UserRoleAdmin, SessionID: "session-1", }, } router := gin.New() router.Use(requestIDMiddleware()) protected := router.Group("") protected.Use(requireAdminSession(authenticator)) protected.GET("/tasks/item", func(ctx *gin.Context) { principal, ok := authcommon.Principal(ctx.Request.Context()) if !ok { ctx.Status(http.StatusInternalServerError) return } ctx.String(http.StatusOK, principal.UserID) }) protected.POST("/api/v1/tasks", func(ctx *gin.Context) { ctx.Status(http.StatusNoContent) }) webRequest := httptest.NewRequest( http.MethodGet, "/tasks/item?q=1", nil, ) webResponse := httptest.NewRecorder() router.ServeHTTP(webResponse, webRequest) if webResponse.Code != http.StatusSeeOther || webResponse.Header().Get("Location") != "/login?next=%2Ftasks%2Fitem%3Fq%3D1" { t.Fatalf( "web status/location = %d / %q", webResponse.Code, webResponse.Header().Get("Location"), ) } apiRequest := httptest.NewRequest( http.MethodPost, "/api/v1/tasks", nil, ) apiRequest.Header.Set("Authorization", "Bearer "+testOpaqueToken) apiResponse := httptest.NewRecorder() router.ServeHTTP(apiResponse, apiRequest) if apiResponse.Code != http.StatusUnauthorized { t.Fatalf("API status/body = %d / %s", apiResponse.Code, apiResponse.Body) } assertErrorCode(t, apiResponse, "ADMIN_SESSION_REQUIRED") badCSRF := httptest.NewRequest( http.MethodPost, "/api/v1/tasks", nil, ) badCSRF.AddCookie(&http.Cookie{ Name: authcommon.AdminSessionCookieName, Value: testOpaqueToken, }) badCSRFResponse := httptest.NewRecorder() router.ServeHTTP(badCSRFResponse, badCSRF) if badCSRFResponse.Code != http.StatusForbidden { t.Fatalf( "CSRF status/body = %d / %s", badCSRFResponse.Code, badCSRFResponse.Body, ) } assertErrorCode(t, badCSRFResponse, "CSRF_INVALID") goodRequest := httptest.NewRequest( http.MethodPost, "/api/v1/tasks", nil, ) goodRequest.AddCookie(&http.Cookie{ Name: authcommon.AdminSessionCookieName, Value: testOpaqueToken, }) goodRequest.AddCookie(&http.Cookie{ Name: authcommon.CSRFCookieName, Value: testCSRFOpaqueToken, }) goodRequest.Header.Set(authcommon.CSRFHeader, testCSRFOpaqueToken) goodResponse := httptest.NewRecorder() router.ServeHTTP(goodResponse, goodRequest) if goodResponse.Code != http.StatusNoContent { t.Fatalf("authenticated status = %d", goodResponse.Code) } } func TestDeviceMiddlewareRejectsCookieAndAcceptsBuyerBearer(t *testing.T) { authenticator := &stubAuthenticator{ devicePrincipal: domain.AuthPrincipal{ UserID: "buyer-1", Username: "buyer", Role: domain.UserRoleBuyer, DeviceID: "device-1", }, } router := gin.New() router.Use(requestIDMiddleware(), RequireDeviceAccess(authenticator)) router.GET("/api/v1/device-probe", func(ctx *gin.Context) { principal, _ := authcommon.Principal(ctx.Request.Context()) ctx.String(http.StatusOK, principal.DeviceID) }) cookieRequest := httptest.NewRequest( http.MethodGet, "/api/v1/device-probe", nil, ) cookieRequest.AddCookie(&http.Cookie{ Name: authcommon.AdminSessionCookieName, Value: testOpaqueToken, }) cookieResponse := httptest.NewRecorder() router.ServeHTTP(cookieResponse, cookieRequest) if cookieResponse.Code != http.StatusUnauthorized { t.Fatalf("cookie status = %d", cookieResponse.Code) } assertErrorCode(t, cookieResponse, "DEVICE_ACCESS_REQUIRED") bearerRequest := httptest.NewRequest( http.MethodGet, "/api/v1/device-probe", nil, ) bearerRequest.Header.Set("Authorization", "Bearer "+testOpaqueToken) bearerResponse := httptest.NewRecorder() router.ServeHTTP(bearerResponse, bearerRequest) if bearerResponse.Code != http.StatusOK || bearerResponse.Body.String() != "device-1" { t.Fatalf( "bearer status/body = %d / %q", bearerResponse.Code, bearerResponse.Body.String(), ) } } type stubAuthenticator struct { adminPrincipal domain.AuthPrincipal adminErr error devicePrincipal domain.AuthPrincipal deviceErr error } func (auth *stubAuthenticator) AuthenticateAdmin( context.Context, string, ) (domain.AuthPrincipal, error) { return auth.adminPrincipal, auth.adminErr } func (auth *stubAuthenticator) AuthenticateAccessToken( context.Context, string, ) (domain.AuthPrincipal, error) { return auth.devicePrincipal, auth.deviceErr } func newPublicAuthTestRouter( t *testing.T, service BuyerTokenService, ) http.Handler { t.Helper() limiter, err := authcommon.NewAttemptLimiter( 100, time.Minute, 100, ) if err != nil { t.Fatalf("NewAttemptLimiter() error = %v", err) } registrar, err := NewPublicAuthRegistrar(service, limiter) if err != nil { t.Fatalf("NewPublicAuthRegistrar() error = %v", err) } router := gin.New() router.Use(requestIDMiddleware()) if err := registrar(router); err != nil { t.Fatalf("register public auth: %v", err) } return router } var ( _ BuyerTokenService = (*fakeBuyerTokenService)(nil) _ AdminAuthenticator = (*stubAuthenticator)(nil) _ DeviceAuthenticator = (*stubAuthenticator)(nil) )