Files
cmroubao/backend-api/internal/transport/httpapi/auth_handlers_test.go
T

406 lines
11 KiB
Go

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