From 45a003900d8d923c23953a9857158c18f7750a7c Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Fri, 31 Jul 2026 16:29:02 +0700 Subject: [PATCH 01/15] test(auth): expand service and HTTP flow coverage --- .../adapter/inbound/http/dto/user_test.go | 31 + internal/adapter/inbound/http/handler_test.go | 312 ++++++++ .../inbound/http/middleware/auth_test.go | 70 ++ internal/adapter/outbound/email/email_test.go | 13 + internal/adapter/outbound/oauth/oauth_test.go | 17 + internal/core/service/service_test.go | 744 ++++++++++++++++++ 6 files changed, 1187 insertions(+) create mode 100644 internal/adapter/inbound/http/dto/user_test.go create mode 100644 internal/adapter/inbound/http/handler_test.go create mode 100644 internal/adapter/inbound/http/middleware/auth_test.go create mode 100644 internal/adapter/outbound/email/email_test.go create mode 100644 internal/core/service/service_test.go diff --git a/internal/adapter/inbound/http/dto/user_test.go b/internal/adapter/inbound/http/dto/user_test.go new file mode 100644 index 0000000..deabf46 --- /dev/null +++ b/internal/adapter/inbound/http/dto/user_test.go @@ -0,0 +1,31 @@ +package dto + +import ( + "testing" + "time" + + "github.com/Jonathan0823/auth-go/internal/core/domain" +) + +func TestUserResponseMappings(t *testing.T) { + now := time.Now() + user := &domain.User{ + ID: 1, + OAuthID: "oauth-id", + Username: "user", + AvatarURL: "https://example.com/avatar.png", + Email: "user@example.com", + IsVerified: true, + Provider: "github", + CreatedAt: now, + UpdatedAt: now, + } + response := UserResponseFromDomain(user) + if response.ID != user.ID || response.Email != user.Email || response.Provider != user.Provider || !response.IsVerified { + t.Fatalf("response = %#v", response) + } + responses := UserResponsesFromDomain([]*domain.User{user}) + if len(responses) != 1 || responses[0].ID != user.ID { + t.Fatalf("responses = %#v", responses) + } +} diff --git a/internal/adapter/inbound/http/handler_test.go b/internal/adapter/inbound/http/handler_test.go new file mode 100644 index 0000000..74ca739 --- /dev/null +++ b/internal/adapter/inbound/http/handler_test.go @@ -0,0 +1,312 @@ +package http + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + middleware "github.com/Jonathan0823/auth-go/internal/adapter/inbound/http/middleware" + "github.com/Jonathan0823/auth-go/internal/core/domain" + "github.com/Jonathan0823/auth-go/internal/core/port" +) + +type handlerAuthService struct { + registerErr error + loginErr error + forgotErr error + verifyCreateErr error + verifyErr error + resetErr error + refreshErr error + logoutErr error + registerCalls int + loginCalls int + forgotCalls int +} + +func (f *handlerAuthService) Register(context.Context, domain.User) error { + f.registerCalls++ + return f.registerErr +} +func (f *handlerAuthService) Login(context.Context, domain.User) (string, string, error) { + f.loginCalls++ + return "access", "refresh", f.loginErr +} +func (f *handlerAuthService) ForgotPassword(context.Context, string) error { + f.forgotCalls++ + return f.forgotErr +} +func (f *handlerAuthService) CreateVerifyEmail(context.Context, string) error { + return f.verifyCreateErr +} +func (f *handlerAuthService) VerifyEmail(context.Context, string) error { return f.verifyErr } +func (f *handlerAuthService) ResetPassword(context.Context, string, string) error { + return f.resetErr +} +func (f *handlerAuthService) RefreshTokens(context.Context, string, string, string) (string, string, error) { + return "access", "refresh", f.refreshErr +} +func (f *handlerAuthService) Logout(context.Context, string) error { return f.logoutErr } + +type handlerUserService struct { + user *domain.User + users []*domain.User + getByIDErr error + getEmailErr error + getAllErr error + updateErr error + deleteErr error +} + +func (f *handlerUserService) GetByID(context.Context, int) (*domain.User, error) { + return f.user, f.getByIDErr +} +func (f *handlerUserService) GetByEmail(context.Context, string) (*domain.User, error) { + return f.user, f.getEmailErr +} +func (f *handlerUserService) GetAll(context.Context) ([]*domain.User, error) { + return f.users, f.getAllErr +} +func (f *handlerUserService) Update(context.Context, int, domain.UpdateUserCommand) error { + return f.updateErr +} +func (f *handlerUserService) Delete(context.Context, int, int) error { return f.deleteErr } + +type handlerOAuthService struct { + user *domain.User + err error + beginCall bool + callbackCall bool +} + +func (f *handlerOAuthService) BeginAuth(http.ResponseWriter, *http.Request, string) { + f.beginCall = true +} +func (f *handlerOAuthService) OAuthLogin(context.Context, http.ResponseWriter, *http.Request, string) (*domain.User, error) { + f.callbackCall = true + return f.user, f.err +} + +type handlerTokenService struct{} + +func (handlerTokenService) GenerateAccessToken(domain.User) (string, string, error) { + return "access", "jti", nil +} +func (handlerTokenService) ValidateAccessToken(string) (map[string]interface{}, error) { + return nil, nil +} +func (handlerTokenService) GenerateRefreshToken() (string, []byte, error) { + return "refresh", []byte("hash"), nil +} +func (handlerTokenService) HashRefreshToken(string) ([]byte, error) { return []byte("hash"), nil } + +func newHandlerTest(auth port.AuthService, user port.UserService, oauth port.OAuthService) *Handler { + return &Handler{Svc: port.Service{Auth: auth, User: user, OAuth: oauth}, Tokens: handlerTokenService{}} +} + +func newErrorRouter(h gin.HandlerFunc) *gin.Engine { + return newPathRouter("/", h) +} + +func newPathRouter(path string, h gin.HandlerFunc) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(middleware.ErrorHandler(slog.New(slog.NewTextHandler(io.Discard, nil)))) + router.Any(path, h) + return router +} + +func request(t *testing.T, router http.Handler, method, path string, body any, cookie *http.Cookie) *httptest.ResponseRecorder { + t.Helper() + var data io.Reader + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + data = bytes.NewReader(payload) + } + req := httptest.NewRequest(method, path, data) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if cookie != nil { + req.AddCookie(cookie) + } + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + return response +} + +func validCredentials() map[string]string { + return map[string]string{"email": "user@example.com", "password": "password-123"} +} + +func TestAuthHandlersSuccess(t *testing.T) { + auth := &handlerAuthService{} + h := newHandlerTest(auth, &handlerUserService{}, &handlerOAuthService{}) + + if response := request(t, newErrorRouter(h.Register), http.MethodPost, "/", validCredentials(), nil); response.Code != http.StatusOK { + t.Fatalf("register status = %d", response.Code) + } + if response := request(t, newErrorRouter(h.Login), http.MethodPost, "/", validCredentials(), nil); response.Code != http.StatusOK || len(response.Result().Cookies()) != 2 { + t.Fatalf("login response = %d, cookies=%v", response.Code, response.Result().Cookies()) + } + if response := request(t, newErrorRouter(h.Logout), http.MethodPost, "/", nil, &http.Cookie{Name: "refresh_token", Value: "refresh"}); response.Code != http.StatusOK { + t.Fatalf("logout status = %d", response.Code) + } + if response := request(t, newErrorRouter(h.Refresh), http.MethodPost, "/", nil, &http.Cookie{Name: "refresh_token", Value: "refresh"}); response.Code != http.StatusOK { + t.Fatalf("refresh status = %d", response.Code) + } + if response := request(t, newErrorRouter(h.VerifyEmail), http.MethodGet, "/?id="+uuid.NewString(), nil, nil); response.Code != http.StatusOK { + t.Fatalf("verify status = %d", response.Code) + } + if response := request(t, newErrorRouter(h.ResendVerifyEmail), http.MethodPost, "/?email=user@example.com", nil, nil); response.Code != http.StatusOK { + t.Fatalf("resend status = %d", response.Code) + } + if response := request(t, newErrorRouter(h.ForgotPassword), http.MethodPost, "/", map[string]string{"email": "user@example.com"}, nil); response.Code != http.StatusOK { + t.Fatalf("forgot status = %d", response.Code) + } + if response := request(t, newErrorRouter(h.ResetPassword), http.MethodPost, "/", map[string]string{"id": uuid.NewString(), "password": "new-password-123"}, nil); response.Code != http.StatusOK { + t.Fatalf("reset status = %d", response.Code) + } + if auth.registerCalls != 1 || auth.loginCalls != 1 || auth.forgotCalls != 1 { + t.Fatalf("auth calls = register:%d login:%d forgot:%d", auth.registerCalls, auth.loginCalls, auth.forgotCalls) + } +} + +func TestAuthHandlersValidationAndErrors(t *testing.T) { + auth := &handlerAuthService{} + h := newHandlerTest(auth, &handlerUserService{}, &handlerOAuthService{}) + if response := request(t, newErrorRouter(h.Register), http.MethodPost, "/", map[string]string{}, nil); response.Code != http.StatusBadRequest { + t.Fatalf("invalid register status = %d", response.Code) + } + if response := request(t, newErrorRouter(h.Login), http.MethodPost, "/", map[string]string{}, nil); response.Code != http.StatusBadRequest { + t.Fatalf("invalid login status = %d", response.Code) + } + if response := request(t, newErrorRouter(h.ResendVerifyEmail), http.MethodPost, "/", nil, nil); response.Code != http.StatusBadRequest { + t.Fatalf("invalid resend status = %d", response.Code) + } + if response := request(t, newErrorRouter(h.ForgotPassword), http.MethodPost, "/", map[string]string{}, nil); response.Code != http.StatusBadRequest { + t.Fatalf("invalid forgot status = %d", response.Code) + } + if response := request(t, newErrorRouter(h.ResetPassword), http.MethodPost, "/", map[string]string{}, nil); response.Code != http.StatusBadRequest { + t.Fatalf("invalid reset status = %d", response.Code) + } + if response := request(t, newErrorRouter(h.Logout), http.MethodPost, "/", nil, nil); response.Code != http.StatusUnauthorized { + t.Fatalf("missing logout status = %d", response.Code) + } + if response := request(t, newErrorRouter(h.Refresh), http.MethodPost, "/", nil, nil); response.Code != http.StatusUnauthorized { + t.Fatalf("missing refresh status = %d", response.Code) + } + + auth.registerErr = domain.ErrConflict + if response := request(t, newErrorRouter(h.Register), http.MethodPost, "/", validCredentials(), nil); response.Code != http.StatusConflict { + t.Fatalf("register error status = %d", response.Code) + } + auth.registerErr = nil + auth.loginErr = domain.ErrUnauthenticated + if response := request(t, newErrorRouter(h.Login), http.MethodPost, "/", validCredentials(), nil); response.Code != http.StatusUnauthorized { + t.Fatalf("login error status = %d", response.Code) + } + auth.loginErr = nil + auth.forgotErr = errors.New("forgot failure") + if response := request(t, newErrorRouter(h.ForgotPassword), http.MethodPost, "/", map[string]string{"email": "user@example.com"}, nil); response.Code != http.StatusInternalServerError { + t.Fatalf("forgot error status = %d", response.Code) + } +} + +func TestOAuthHandlers(t *testing.T) { + oauth := &handlerOAuthService{user: &domain.User{ID: 1, Email: "oauth@example.com"}} + h := newHandlerTest(&handlerAuthService{}, &handlerUserService{}, oauth) + if response := request(t, newErrorRouter(h.OAuthLogin), http.MethodGet, "/", nil, nil); response.Code != http.StatusOK || !oauth.beginCall { + t.Fatalf("oauth begin response = %d, called=%v", response.Code, oauth.beginCall) + } + if response := request(t, newErrorRouter(h.OAuthCallback), http.MethodGet, "/", nil, nil); response.Code != http.StatusOK || !oauth.callbackCall { + t.Fatalf("oauth callback response = %d, called=%v", response.Code, oauth.callbackCall) + } + oauth.err = errors.New("provider failure") + if response := request(t, newErrorRouter(h.OAuthCallback), http.MethodGet, "/", nil, nil); response.Code != http.StatusUnauthorized { + t.Fatalf("oauth error status = %d", response.Code) + } +} + +func TestUserHandlers(t *testing.T) { + user := &domain.User{ID: 1, Email: "user@example.com", Username: "user"} + users := &handlerUserService{user: user, users: []*domain.User{user}} + h := newHandlerTest(&handlerAuthService{}, users, &handlerOAuthService{}) + withUser := func(handler gin.HandlerFunc) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("user", map[string]interface{}{"id": float64(1), "username": "user", "email": "user@example.com"}) + handler(c) + } + } + if response := request(t, newPathRouter("/:id", h.GetUserByID), http.MethodGet, "/1", nil, nil); response.Code != http.StatusOK { + t.Fatalf("get by ID status = %d", response.Code) + } + if response := request(t, newErrorRouter(h.GetAllUsers), http.MethodGet, "/", nil, nil); response.Code != http.StatusOK { + t.Fatalf("get all status = %d", response.Code) + } + if response := request(t, newErrorRouter(h.GetUserByEmail), http.MethodGet, "/?email=user@example.com", nil, nil); response.Code != http.StatusOK { + t.Fatalf("get by email status = %d", response.Code) + } + if response := request(t, newErrorRouter(withUser(h.UpdateUser)), http.MethodPatch, "/", map[string]any{"id": 1, "email": "user@example.com"}, nil); response.Code != http.StatusOK { + t.Fatalf("update status = %d", response.Code) + } + if response := request(t, newPathRouter("/:id", withUser(h.DeleteUser)), http.MethodDelete, "/1", nil, nil); response.Code != http.StatusOK { + t.Fatalf("delete status = %d", response.Code) + } + if response := request(t, newErrorRouter(withUser(h.GetCurrentUser)), http.MethodGet, "/", nil, nil); response.Code != http.StatusOK { + t.Fatalf("current user status = %d", response.Code) + } + if response := request(t, newPathRouter("/:id", h.GetUserByID), http.MethodGet, "/0", nil, nil); response.Code != http.StatusBadRequest { + t.Fatalf("invalid ID status = %d", response.Code) + } + if response := request(t, newErrorRouter(h.GetUserByEmail), http.MethodGet, "/", nil, nil); response.Code != http.StatusBadRequest { + t.Fatalf("missing email status = %d", response.Code) + } + if response := request(t, newErrorRouter(h.UpdateUser), http.MethodPatch, "/", map[string]any{"id": 1, "email": "bad"}, nil); response.Code != http.StatusBadRequest { + t.Fatalf("invalid update status = %d", response.Code) + } + if response := request(t, newPathRouter("/:id", h.DeleteUser), http.MethodDelete, "/0", nil, nil); response.Code != http.StatusBadRequest { + t.Fatalf("invalid delete status = %d", response.Code) + } + if response := request(t, newErrorRouter(h.GetCurrentUser), http.MethodGet, "/", nil, nil); response.Code != http.StatusUnauthorized { + t.Fatalf("missing current user status = %d", response.Code) + } + + users.getByIDErr = errors.New("lookup failure") + if response := request(t, newPathRouter("/:id", h.GetUserByID), http.MethodGet, "/1", nil, nil); response.Code != http.StatusInternalServerError { + t.Fatalf("get by ID error status = %d", response.Code) + } + users.getAllErr = errors.New("list failure") + if response := request(t, newErrorRouter(h.GetAllUsers), http.MethodGet, "/", nil, nil); response.Code != http.StatusInternalServerError { + t.Fatalf("get all error status = %d", response.Code) + } + users.getEmailErr = errors.New("email failure") + if response := request(t, newErrorRouter(h.GetUserByEmail), http.MethodGet, "/?email=user@example.com", nil, nil); response.Code != http.StatusInternalServerError { + t.Fatalf("get by email error status = %d", response.Code) + } + users.updateErr = errors.New("update failure") + if response := request(t, newErrorRouter(withUser(h.UpdateUser)), http.MethodPatch, "/", map[string]any{"id": 1, "email": "user@example.com"}, nil); response.Code != http.StatusInternalServerError { + t.Fatalf("update error status = %d", response.Code) + } + users.deleteErr = errors.New("delete failure") + if response := request(t, newPathRouter("/:id", withUser(h.DeleteUser)), http.MethodDelete, "/1", nil, nil); response.Code != http.StatusInternalServerError { + t.Fatalf("delete error status = %d", response.Code) + } +} + +var _ port.AuthService = (*handlerAuthService)(nil) +var _ port.UserService = (*handlerUserService)(nil) +var _ port.OAuthService = (*handlerOAuthService)(nil) +var _ port.TokenService = (handlerTokenService{}) diff --git a/internal/adapter/inbound/http/middleware/auth_test.go b/internal/adapter/inbound/http/middleware/auth_test.go new file mode 100644 index 0000000..61c179f --- /dev/null +++ b/internal/adapter/inbound/http/middleware/auth_test.go @@ -0,0 +1,70 @@ +package middleware + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/Jonathan0823/auth-go/internal/core/domain" +) + +type middlewareTokenService struct { + claims map[string]interface{} + err error +} + +func (t middlewareTokenService) GenerateAccessToken(domain.User) (string, string, error) { + return "", "", nil +} +func (t middlewareTokenService) ValidateAccessToken(string) (map[string]interface{}, error) { + return t.claims, t.err +} +func (t middlewareTokenService) GenerateRefreshToken() (string, []byte, error) { + return "", nil, nil +} +func (t middlewareTokenService) HashRefreshToken(string) ([]byte, error) { return nil, nil } + +func TestAuthMiddlewareRejectsMissingAndInvalidTokens(t *testing.T) { + for name, tokens := range map[string]middlewareTokenService{ + "missing": {}, + "invalid": {err: domain.ErrUnauthenticated}, + } { + t.Run(name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(ErrorHandler(slog.New(slog.NewTextHandler(io.Discard, nil)))) + router.Use(NewAuthMiddleware(tokens).Handler()) + router.GET("/", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil)) + if response.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", response.Code, http.StatusUnauthorized) + } + }) + } +} + +func TestAuthMiddlewareAcceptsValidToken(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(NewAuthMiddleware(middlewareTokenService{ + claims: map[string]interface{}{"id": float64(1)}, + }).Handler()) + router.GET("/", func(c *gin.Context) { + if _, exists := c.Get("user"); !exists { + t.Error("user claims were not stored") + } + c.Status(http.StatusNoContent) + }) + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/", nil) + request.AddCookie(&http.Cookie{Name: "access_token", Value: "valid"}) + router.ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d", response.Code) + } +} diff --git a/internal/adapter/outbound/email/email_test.go b/internal/adapter/outbound/email/email_test.go new file mode 100644 index 0000000..d661678 --- /dev/null +++ b/internal/adapter/outbound/email/email_test.go @@ -0,0 +1,13 @@ +package email + +import ( + "testing" +) + +func TestSenderRequiresCredentials(t *testing.T) { + t.Setenv("EMAIL", "") + t.Setenv("PASSWORD", "") + if err := NewSender().Send("user@example.com", "subject", "body"); err == nil { + t.Fatal("Send succeeded without credentials") + } +} diff --git a/internal/adapter/outbound/oauth/oauth_test.go b/internal/adapter/outbound/oauth/oauth_test.go index d106f19..f81411c 100644 --- a/internal/adapter/outbound/oauth/oauth_test.go +++ b/internal/adapter/outbound/oauth/oauth_test.go @@ -1,9 +1,12 @@ package oauth import ( + "errors" + "net/http/httptest" "testing" "github.com/markbates/goth" + "github.com/markbates/goth/gothic" ) func TestProfileFromUser(t *testing.T) { @@ -29,3 +32,17 @@ func TestFirstNonEmpty(t *testing.T) { t.Fatalf("firstNonEmpty() = %q, want %q", got, "primary") } } + +func TestNewAndProviderHooks(t *testing.T) { + New(Config{BaseURL: "http://localhost", SessionSecret: "session-secret"}) + req := httptest.NewRequest("GET", "/", nil) + withProvider("github", func() { + provider, err := gothic.GetProviderName(req) + if err != nil || provider != "github" { + t.Fatalf("provider = %q, err = %v", provider, err) + } + }) + if err := withProviderErr("google", func() error { return errors.New("provider failure") }); err == nil { + t.Fatal("withProviderErr succeeded unexpectedly") + } +} diff --git a/internal/core/service/service_test.go b/internal/core/service/service_test.go new file mode 100644 index 0000000..b392d0f --- /dev/null +++ b/internal/core/service/service_test.go @@ -0,0 +1,744 @@ +package service + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/Jonathan0823/auth-go/internal/core/domain" + "github.com/Jonathan0823/auth-go/internal/core/port" +) + +var errFake = errors.New("fake failure") + +type fakeUserRepository struct { + getByIDFn func(context.Context, int) (*domain.User, error) + getByEmailFn func(context.Context, string, bool) (*domain.User, error) + createFn func(context.Context, domain.User) error + getAllFn func(context.Context) ([]*domain.User, error) + updateFn func(context.Context, domain.UpdateUserCommand) error + deleteFn func(context.Context, int) error + updatePasswordFn func(context.Context, int, string) error +} + +func (f *fakeUserRepository) GetByID(ctx context.Context, id int) (*domain.User, error) { + if f.getByIDFn != nil { + return f.getByIDFn(ctx, id) + } + return nil, nil +} + +func (f *fakeUserRepository) GetByEmail(ctx context.Context, email string, includePassword bool) (*domain.User, error) { + if f.getByEmailFn != nil { + return f.getByEmailFn(ctx, email, includePassword) + } + return nil, nil +} + +func (f *fakeUserRepository) Create(ctx context.Context, user domain.User) error { + if f.createFn != nil { + return f.createFn(ctx, user) + } + return nil +} + +func (f *fakeUserRepository) GetAll(ctx context.Context) ([]*domain.User, error) { + if f.getAllFn != nil { + return f.getAllFn(ctx) + } + return nil, nil +} + +func (f *fakeUserRepository) Update(ctx context.Context, user domain.UpdateUserCommand) error { + if f.updateFn != nil { + return f.updateFn(ctx, user) + } + return nil +} + +func (f *fakeUserRepository) Delete(ctx context.Context, id int) error { + if f.deleteFn != nil { + return f.deleteFn(ctx, id) + } + return nil +} + +func (f *fakeUserRepository) UpdatePassword(ctx context.Context, id int, password string) error { + if f.updatePasswordFn != nil { + return f.updatePasswordFn(ctx, id, password) + } + return nil +} + +type fakeAuthRepository struct { + createVerifyEmailFn func(context.Context, domain.VerifyEmail) error + getVerifyEmailByIDFn func(context.Context, string) (domain.VerifyEmail, error) + verifyEmailFn func(context.Context, string) error + createForgotPasswordEmailFn func(context.Context, domain.ForgotPassword) error + getForgotPasswordByIDFn func(context.Context, string) (domain.ForgotPassword, error) + deleteForgotPasswordByIDFn func(context.Context, string) error + createRefreshTokenFn func(context.Context, domain.RefreshToken) error + getRefreshTokenByHashFn func(context.Context, []byte) (*domain.RefreshToken, error) + useRefreshTokenFn func(context.Context, uuid.UUID) error + revokeRefreshTokenFn func(context.Context, uuid.UUID) error + revokeRefreshTokenFamilyFn func(context.Context, uuid.UUID) error +} + +func (f *fakeAuthRepository) CreateVerifyEmail(ctx context.Context, data domain.VerifyEmail) error { + if f.createVerifyEmailFn != nil { + return f.createVerifyEmailFn(ctx, data) + } + return nil +} + +func (f *fakeAuthRepository) GetVerifyEmailByID(ctx context.Context, id string) (domain.VerifyEmail, error) { + if f.getVerifyEmailByIDFn != nil { + return f.getVerifyEmailByIDFn(ctx, id) + } + return domain.VerifyEmail{}, nil +} + +func (f *fakeAuthRepository) VerifyEmail(ctx context.Context, id string) error { + if f.verifyEmailFn != nil { + return f.verifyEmailFn(ctx, id) + } + return nil +} + +func (f *fakeAuthRepository) CreateForgotPasswordEmail(ctx context.Context, data domain.ForgotPassword) error { + if f.createForgotPasswordEmailFn != nil { + return f.createForgotPasswordEmailFn(ctx, data) + } + return nil +} + +func (f *fakeAuthRepository) GetForgotPasswordByID(ctx context.Context, id string) (domain.ForgotPassword, error) { + if f.getForgotPasswordByIDFn != nil { + return f.getForgotPasswordByIDFn(ctx, id) + } + return domain.ForgotPassword{}, nil +} + +func (f *fakeAuthRepository) DeleteForgotPasswordByID(ctx context.Context, id string) error { + if f.deleteForgotPasswordByIDFn != nil { + return f.deleteForgotPasswordByIDFn(ctx, id) + } + return nil +} + +func (f *fakeAuthRepository) CreateRefreshToken(ctx context.Context, token domain.RefreshToken) error { + if f.createRefreshTokenFn != nil { + return f.createRefreshTokenFn(ctx, token) + } + return nil +} + +func (f *fakeAuthRepository) GetRefreshTokenByHash(ctx context.Context, hash []byte) (*domain.RefreshToken, error) { + if f.getRefreshTokenByHashFn != nil { + return f.getRefreshTokenByHashFn(ctx, hash) + } + return nil, nil +} + +func (f *fakeAuthRepository) UseRefreshToken(ctx context.Context, id uuid.UUID) error { + if f.useRefreshTokenFn != nil { + return f.useRefreshTokenFn(ctx, id) + } + return nil +} + +func (f *fakeAuthRepository) RevokeRefreshToken(ctx context.Context, id uuid.UUID) error { + if f.revokeRefreshTokenFn != nil { + return f.revokeRefreshTokenFn(ctx, id) + } + return nil +} + +func (f *fakeAuthRepository) RevokeRefreshTokenFamily(ctx context.Context, id uuid.UUID) error { + if f.revokeRefreshTokenFamilyFn != nil { + return f.revokeRefreshTokenFamilyFn(ctx, id) + } + return nil +} + +type fakeUnitOfWork struct { + users port.UserRepository + auth port.AuthRepository + commitErr error + rollbackErr error +} + +func (f *fakeUnitOfWork) Users() port.UserRepository { return f.users } +func (f *fakeUnitOfWork) Auth() port.AuthRepository { return f.auth } +func (f *fakeUnitOfWork) Commit() error { return f.commitErr } +func (f *fakeUnitOfWork) Rollback() error { return f.rollbackErr } + +type fakeRepository struct { + users port.UserRepository + auth port.AuthRepository + tx port.UnitOfWork + beginErr error + withTxErr error + withTxFn func(port.UnitOfWork) error +} + +func (f *fakeRepository) Begin(context.Context) (port.UnitOfWork, error) { return f.tx, f.beginErr } +func (f *fakeRepository) Users() port.UserRepository { return f.users } +func (f *fakeRepository) Auth() port.AuthRepository { return f.auth } +func (f *fakeRepository) WithTx(_ context.Context, fn func(port.UnitOfWork) error) error { + if f.withTxFn != nil { + return f.withTxFn(f.tx) + } + if f.withTxErr != nil { + return f.withTxErr + } + return fn(f.tx) +} + +type fakeHasher struct { + hashFn func(string) (string, error) + compareFn func(string, string) error +} + +func (f fakeHasher) Hash(password string) (string, error) { + if f.hashFn != nil { + return f.hashFn(password) + } + return "hashed:" + password, nil +} + +func (f fakeHasher) Compare(hashed, plain string) error { + if f.compareFn != nil { + return f.compareFn(hashed, plain) + } + return nil +} + +type fakeTokens struct { + accessToken string + refreshToken string + refreshHash []byte + accessErr error + generateRefreshErr error + hashErr error +} + +func (f fakeTokens) GenerateAccessToken(domain.User) (string, string, error) { + return f.accessToken, "jti", f.accessErr +} + +func (f fakeTokens) ValidateAccessToken(string) (map[string]interface{}, error) { + return nil, nil +} + +func (f fakeTokens) GenerateRefreshToken() (string, []byte, error) { + return f.refreshToken, f.refreshHash, f.generateRefreshErr +} + +func (f fakeTokens) HashRefreshToken(string) ([]byte, error) { + return f.refreshHash, f.hashErr +} + +type fakeEmailSender struct { + err error + calls int +} + +func (f *fakeEmailSender) Send(string, string, string) error { + f.calls++ + return f.err +} + +type fakeOAuthClient struct { + profile port.OAuthProfile + err error + called bool +} + +func (f *fakeOAuthClient) BeginAuth(http.ResponseWriter, *http.Request, string) { f.called = true } +func (f *fakeOAuthClient) CompleteAuth(http.ResponseWriter, *http.Request, string) (port.OAuthProfile, error) { + f.called = true + return f.profile, f.err +} + +func newAuthFakes() (*fakeRepository, *fakeUserRepository, *fakeAuthRepository, *fakeEmailSender) { + users := &fakeUserRepository{} + auth := &fakeAuthRepository{} + uow := &fakeUnitOfWork{users: users, auth: auth} + repo := &fakeRepository{users: users, auth: auth, tx: uow} + return repo, users, auth, &fakeEmailSender{} +} + +func TestUserService(t *testing.T) { + user := &domain.User{ID: 7, Email: "user@example.com"} + t.Run("gets users", func(t *testing.T) { + repo, users, _, _ := newAuthFakes() + users.getByIDFn = func(context.Context, int) (*domain.User, error) { return user, nil } + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { return user, nil } + users.getAllFn = func(context.Context) ([]*domain.User, error) { return []*domain.User{user}, nil } + svc := NewUserService(repo) + if got, err := svc.GetByID(context.Background(), user.ID); err != nil || got != user { + t.Fatalf("GetByID = %#v, %v", got, err) + } + if got, err := svc.GetByEmail(context.Background(), user.Email); err != nil || got != user { + t.Fatalf("GetByEmail = %#v, %v", got, err) + } + if got, err := svc.GetAll(context.Background()); err != nil || len(got) != 1 { + t.Fatalf("GetAll = %#v, %v", got, err) + } + }) + + t.Run("returns lookup errors and not found", func(t *testing.T) { + repo, users, _, _ := newAuthFakes() + users.getByIDFn = func(context.Context, int) (*domain.User, error) { return nil, errFake } + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { return nil, errFake } + svc := NewUserService(repo) + if _, err := svc.GetByID(context.Background(), 1); !errors.Is(err, errFake) { + t.Fatalf("GetByID error = %v", err) + } + if _, err := svc.GetByEmail(context.Background(), "x"); !errors.Is(err, errFake) { + t.Fatalf("GetByEmail error = %v", err) + } + users.getByIDFn = func(context.Context, int) (*domain.User, error) { return nil, nil } + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { return nil, nil } + if _, err := svc.GetByID(context.Background(), 1); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("missing ID error = %v", err) + } + if _, err := svc.GetByEmail(context.Background(), "x"); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("missing email error = %v", err) + } + }) + + t.Run("handles list and mutations", func(t *testing.T) { + repo, users, _, _ := newAuthFakes() + svc := NewUserService(repo) + users.getAllFn = func(context.Context) ([]*domain.User, error) { return nil, errFake } + if _, err := svc.GetAll(context.Background()); !errors.Is(err, errFake) { + t.Fatalf("GetAll error = %v", err) + } + command := domain.UpdateUserCommand{ID: user.ID} + if err := svc.Update(context.Background(), 8, command); !errors.Is(err, domain.ErrForbidden) { + t.Fatalf("forbidden update = %v", err) + } + users.updateFn = func(context.Context, domain.UpdateUserCommand) error { return errFake } + if err := svc.Update(context.Background(), user.ID, command); !errors.Is(err, errFake) { + t.Fatalf("update error = %v", err) + } + users.updateFn = nil + if err := svc.Update(context.Background(), user.ID, command); err != nil { + t.Fatalf("update = %v", err) + } + if err := svc.Delete(context.Background(), 8, user.ID); !errors.Is(err, domain.ErrForbidden) { + t.Fatalf("forbidden delete = %v", err) + } + users.deleteFn = func(context.Context, int) error { return errFake } + if err := svc.Delete(context.Background(), user.ID, user.ID); !errors.Is(err, errFake) { + t.Fatalf("delete error = %v", err) + } + }) +} + +func TestOAuthService(t *testing.T) { + repo, users, _, _ := newAuthFakes() + oauth := &fakeOAuthClient{profile: port.OAuthProfile{UserID: "oauth-id", Email: "oauth@example.com", Name: "OAuth", Provider: "github"}} + svc := NewOAuthService(repo, oauth) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + svc.BeginAuth(w, r, "github") + if !oauth.called { + t.Fatal("BeginAuth did not call the OAuth client") + } + + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { + return &domain.User{ID: 1, Email: "oauth@example.com"}, nil + } + if user, err := svc.OAuthLogin(context.Background(), w, r, "github"); err != nil || user.ID != 1 { + t.Fatalf("OAuthLogin = %#v, %v", user, err) + } + + tests := []struct { + name string + setup func() + }{ + {"complete error", func() { oauth.err = errFake }}, + {"create error", func() { + oauth.err = nil + users.createFn = func(context.Context, domain.User) error { return errFake } + }}, + {"get error", func() { + users.createFn = nil + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { return nil, errFake } + }}, + {"not found", func() { + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { return nil, nil } + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oauth.err = nil + users.createFn = nil + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { + return &domain.User{ID: 1, Email: "oauth@example.com"}, nil + } + tt.setup() + if _, err := svc.OAuthLogin(context.Background(), w, r, "github"); err == nil { + t.Fatal("OAuthLogin succeeded unexpectedly") + } + }) + } + + users.createFn = func(context.Context, domain.User) error { return domain.ErrConflict } + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { + return &domain.User{ID: 2, Email: "oauth@example.com"}, nil + } + if _, err := svc.OAuthLogin(context.Background(), w, r, "github"); err != nil { + t.Fatalf("conflict create should continue: %v", err) + } +} + +func TestAuthServiceRegisterAndEmailFlows(t *testing.T) { + user := domain.User{ID: 3, Email: "user@example.com", Password: "secret"} + t.Run("register success and failures", func(t *testing.T) { + tests := []struct { + name string + setup func(*fakeRepository, *fakeUserRepository, *fakeAuthRepository, *fakeEmailSender) + }{ + {"hash", func(_ *fakeRepository, _ *fakeUserRepository, _ *fakeAuthRepository, _ *fakeEmailSender) {}}, + {"create", func(_ *fakeRepository, users *fakeUserRepository, _ *fakeAuthRepository, _ *fakeEmailSender) { + users.createFn = func(context.Context, domain.User) error { return errFake } + }}, + {"lookup", func(_ *fakeRepository, users *fakeUserRepository, _ *fakeAuthRepository, _ *fakeEmailSender) { + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { return nil, errFake } + }}, + {"missing user", func(_ *fakeRepository, users *fakeUserRepository, _ *fakeAuthRepository, _ *fakeEmailSender) { + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { return nil, nil } + }}, + {"create verification", func(_ *fakeRepository, _ *fakeUserRepository, auth *fakeAuthRepository, _ *fakeEmailSender) { + auth.createVerifyEmailFn = func(context.Context, domain.VerifyEmail) error { return errFake } + }}, + {"send email", func(_ *fakeRepository, _ *fakeUserRepository, _ *fakeAuthRepository, email *fakeEmailSender) { + email.err = errFake + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo, users, auth, email := newAuthFakes() + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { + return &domain.User{ID: user.ID, Email: user.Email}, nil + } + hasher := fakeHasher{} + if tt.name == "hash" { + hasher.hashFn = func(string) (string, error) { return "", errFake } + } + tt.setup(repo, users, auth, email) + svc := NewAuthService(repo, fakeTokens{}, email, hasher, "http://localhost") + if err := svc.Register(context.Background(), user); err == nil { + t.Fatal("Register succeeded unexpectedly") + } + }) + } + + repo, users, _, email := newAuthFakes() + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { + return &domain.User{ID: user.ID, Email: user.Email}, nil + } + if err := NewAuthService(repo, fakeTokens{}, email, fakeHasher{}, "http://localhost").Register(context.Background(), user); err != nil { + t.Fatalf("Register = %v", err) + } + }) + + t.Run("create verification and forgot password", func(t *testing.T) { + repo, users, auth, email := newAuthFakes() + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { + return &domain.User{ID: user.ID, Email: user.Email}, nil + } + svc := NewAuthService(repo, fakeTokens{}, email, fakeHasher{}, "http://localhost") + if err := svc.CreateVerifyEmail(context.Background(), user.Email); err != nil { + t.Fatalf("CreateVerifyEmail = %v", err) + } + if err := svc.ForgotPassword(context.Background(), user.Email); err != nil { + t.Fatalf("ForgotPassword = %v", err) + } + email.err = errFake + if err := svc.CreateVerifyEmail(context.Background(), user.Email); err == nil { + t.Fatal("CreateVerifyEmail succeeded with email failure") + } + email.err = nil + auth.createForgotPasswordEmailFn = func(context.Context, domain.ForgotPassword) error { return errFake } + if err := svc.ForgotPassword(context.Background(), user.Email); err == nil { + t.Fatal("ForgotPassword succeeded with repository failure") + } + }) +} + +func TestAuthServiceLogin(t *testing.T) { + baseUser := &domain.User{ID: 4, Email: "user@example.com", Password: "hashed"} + tests := []struct { + name string + setup func(*fakeRepository, *fakeUserRepository, *fakeAuthRepository, *fakeHasher, *fakeTokens) + }{ + {"lookup error", func(_ *fakeRepository, users *fakeUserRepository, _ *fakeAuthRepository, _ *fakeHasher, _ *fakeTokens) { + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { return nil, errFake } + }}, + {"not found", func(_ *fakeRepository, users *fakeUserRepository, _ *fakeAuthRepository, _ *fakeHasher, _ *fakeTokens) { + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { return nil, nil } + }}, + {"bad password", func(_ *fakeRepository, users *fakeUserRepository, _ *fakeAuthRepository, hasher *fakeHasher, _ *fakeTokens) { + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { return baseUser, nil } + hasher.compareFn = func(string, string) error { return errFake } + }}, + {"access token", func(_ *fakeRepository, users *fakeUserRepository, _ *fakeAuthRepository, _ *fakeHasher, tokens *fakeTokens) { + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { return baseUser, nil } + tokens.accessErr = errFake + }}, + {"refresh token", func(_ *fakeRepository, users *fakeUserRepository, _ *fakeAuthRepository, _ *fakeHasher, tokens *fakeTokens) { + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { return baseUser, nil } + tokens.generateRefreshErr = errFake + }}, + {"persist token", func(_ *fakeRepository, users *fakeUserRepository, auth *fakeAuthRepository, _ *fakeHasher, _ *fakeTokens) { + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { return baseUser, nil } + auth.createRefreshTokenFn = func(context.Context, domain.RefreshToken) error { return errFake } + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo, users, auth, email := newAuthFakes() + hasher := &fakeHasher{} + tokens := &fakeTokens{accessToken: "access", refreshToken: "refresh", refreshHash: []byte("hash")} + tt.setup(repo, users, auth, hasher, tokens) + _, _, err := NewAuthService(repo, tokens, email, hasher, "http://localhost").Login(context.Background(), domain.User{Email: baseUser.Email, Password: "password"}) + if err == nil { + t.Fatal("Login succeeded unexpectedly") + } + }) + } + + repo, users, auth, email := newAuthFakes() + users.getByEmailFn = func(context.Context, string, bool) (*domain.User, error) { return baseUser, nil } + tokens := &fakeTokens{accessToken: "access", refreshToken: "refresh", refreshHash: []byte("hash")} + access, refresh, err := NewAuthService(repo, tokens, email, fakeHasher{}, "http://localhost").Login(context.Background(), domain.User{Email: baseUser.Email, Password: "password"}) + if err != nil || access != "access" || refresh != "refresh" { + t.Fatalf("Login = %q, %q, %v", access, refresh, err) + } + _ = auth +} + +func TestAuthServiceVerifyAndReset(t *testing.T) { + validID := uuid.New().String() + userID := 5 + t.Run("verify email", func(t *testing.T) { + repo, _, auth, email := newAuthFakes() + svc := NewAuthService(repo, fakeTokens{}, email, fakeHasher{}, "http://localhost") + if err := svc.VerifyEmail(context.Background(), "bad"); !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("invalid ID error = %v", err) + } + auth.getVerifyEmailByIDFn = func(context.Context, string) (domain.VerifyEmail, error) { return domain.VerifyEmail{}, errFake } + if err := svc.VerifyEmail(context.Background(), validID); !errors.Is(err, errFake) { + t.Fatalf("lookup error = %v", err) + } + auth.getVerifyEmailByIDFn = func(context.Context, string) (domain.VerifyEmail, error) { return domain.VerifyEmail{}, nil } + if err := svc.VerifyEmail(context.Background(), validID); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("missing error = %v", err) + } + auth.getVerifyEmailByIDFn = func(context.Context, string) (domain.VerifyEmail, error) { + return domain.VerifyEmail{ID: uuid.New(), ExpiredAt: time.Now().Add(-time.Minute)}, nil + } + if err := svc.VerifyEmail(context.Background(), validID); !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("expired error = %v", err) + } + auth.getVerifyEmailByIDFn = func(context.Context, string) (domain.VerifyEmail, error) { + return domain.VerifyEmail{ID: uuid.New(), ExpiredAt: time.Now().Add(time.Minute)}, nil + } + auth.verifyEmailFn = func(context.Context, string) error { return errFake } + if err := svc.VerifyEmail(context.Background(), validID); !errors.Is(err, errFake) { + t.Fatalf("verify error = %v", err) + } + auth.verifyEmailFn = nil + if err := svc.VerifyEmail(context.Background(), validID); err != nil { + t.Fatalf("verify = %v", err) + } + }) + + t.Run("reset password", func(t *testing.T) { + repo, users, auth, email := newAuthFakes() + svc := NewAuthService(repo, fakeTokens{}, email, fakeHasher{}, "http://localhost") + if err := svc.ResetPassword(context.Background(), "bad", "new"); !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("invalid ID error = %v", err) + } + hasher := fakeHasher{hashFn: func(string) (string, error) { return "", errFake }} + if err := NewAuthService(repo, fakeTokens{}, email, hasher, "http://localhost").ResetPassword(context.Background(), validID, "new"); !errors.Is(err, errFake) { + t.Fatalf("hash error = %v", err) + } + auth.getForgotPasswordByIDFn = func(context.Context, string) (domain.ForgotPassword, error) { return domain.ForgotPassword{}, errFake } + if err := svc.ResetPassword(context.Background(), validID, "new"); !errors.Is(err, errFake) { + t.Fatalf("lookup error = %v", err) + } + auth.getForgotPasswordByIDFn = func(context.Context, string) (domain.ForgotPassword, error) { return domain.ForgotPassword{}, nil } + if err := svc.ResetPassword(context.Background(), validID, "new"); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("missing error = %v", err) + } + auth.getForgotPasswordByIDFn = func(context.Context, string) (domain.ForgotPassword, error) { + return domain.ForgotPassword{ID: uuid.New(), UserID: userID, ExpiredAt: time.Now().Add(-time.Minute)}, nil + } + if err := svc.ResetPassword(context.Background(), validID, "new"); !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("expired error = %v", err) + } + auth.getForgotPasswordByIDFn = func(context.Context, string) (domain.ForgotPassword, error) { + return domain.ForgotPassword{ID: uuid.New(), UserID: userID, ExpiredAt: time.Now().Add(time.Minute)}, nil + } + auth.deleteForgotPasswordByIDFn = func(context.Context, string) error { return errFake } + if err := svc.ResetPassword(context.Background(), validID, "new"); !errors.Is(err, errFake) { + t.Fatalf("delete error = %v", err) + } + auth.deleteForgotPasswordByIDFn = nil + users.updatePasswordFn = func(context.Context, int, string) error { return errFake } + if err := svc.ResetPassword(context.Background(), validID, "new"); !errors.Is(err, errFake) { + t.Fatalf("update error = %v", err) + } + users.updatePasswordFn = nil + if err := svc.ResetPassword(context.Background(), validID, "new"); err != nil { + t.Fatalf("reset = %v", err) + } + }) +} + +func TestAuthServiceRefreshAndLogout(t *testing.T) { + tokenID := uuid.New() + familyID := uuid.New() + baseToken := &domain.RefreshToken{ID: tokenID, UserID: 9, FamilyID: familyID, ExpiredAt: time.Now().Add(time.Hour)} + user := &domain.User{ID: 9, Email: "user@example.com"} + newService := func() (*authService, *fakeRepository, *fakeUserRepository, *fakeAuthRepository, *fakeTokens) { + repo, users, auth, email := newAuthFakes() + users.getByIDFn = func(context.Context, int) (*domain.User, error) { return user, nil } + auth.getRefreshTokenByHashFn = func(context.Context, []byte) (*domain.RefreshToken, error) { return baseToken, nil } + tokens := &fakeTokens{accessToken: "access", refreshToken: "new-refresh", refreshHash: []byte("hash")} + return NewAuthService(repo, tokens, email, fakeHasher{}, "http://localhost").(*authService), repo, users, auth, tokens + } + + t.Run("refresh success", func(t *testing.T) { + svc, _, _, _, _ := newService() + access, refresh, err := svc.RefreshTokens(context.Background(), "old", "127.0.0.1", "agent") + if err != nil || access != "access" || refresh != "new-refresh" { + t.Fatalf("RefreshTokens = %q, %q, %v", access, refresh, err) + } + }) + + t.Run("refresh errors", func(t *testing.T) { + tests := []struct { + name string + setup func(*authService, *fakeRepository, *fakeUserRepository, *fakeAuthRepository, *fakeTokens) + }{ + {"hash", func(_ *authService, _ *fakeRepository, _ *fakeUserRepository, _ *fakeAuthRepository, tokens *fakeTokens) { + tokens.hashErr = errFake + }}, + {"begin", func(_ *authService, repo *fakeRepository, _ *fakeUserRepository, _ *fakeAuthRepository, _ *fakeTokens) { + repo.beginErr = errFake + }}, + {"lookup", func(_ *authService, _ *fakeRepository, _ *fakeUserRepository, auth *fakeAuthRepository, _ *fakeTokens) { + auth.getRefreshTokenByHashFn = func(context.Context, []byte) (*domain.RefreshToken, error) { return nil, errFake } + }}, + {"missing", func(_ *authService, _ *fakeRepository, _ *fakeUserRepository, auth *fakeAuthRepository, _ *fakeTokens) { + auth.getRefreshTokenByHashFn = func(context.Context, []byte) (*domain.RefreshToken, error) { return nil, nil } + }}, + {"expired", func(_ *authService, _ *fakeRepository, _ *fakeUserRepository, auth *fakeAuthRepository, _ *fakeTokens) { + auth.getRefreshTokenByHashFn = func(context.Context, []byte) (*domain.RefreshToken, error) { + return &domain.RefreshToken{ExpiredAt: time.Now().Add(-time.Minute)}, nil + } + }}, + {"revoked", func(_ *authService, _ *fakeRepository, _ *fakeUserRepository, auth *fakeAuthRepository, _ *fakeTokens) { + auth.getRefreshTokenByHashFn = func(context.Context, []byte) (*domain.RefreshToken, error) { + now := time.Now() + return &domain.RefreshToken{ExpiredAt: time.Now().Add(time.Hour), RevokedAt: &now}, nil + } + }}, + {"revoke family", func(_ *authService, _ *fakeRepository, _ *fakeUserRepository, auth *fakeAuthRepository, _ *fakeTokens) { + now := time.Now() + auth.getRefreshTokenByHashFn = func(context.Context, []byte) (*domain.RefreshToken, error) { + return &domain.RefreshToken{ExpiredAt: time.Now().Add(time.Hour), UsedAt: &now, FamilyID: familyID}, nil + } + auth.revokeRefreshTokenFamilyFn = func(context.Context, uuid.UUID) error { return errFake } + }}, + {"use token", func(_ *authService, _ *fakeRepository, _ *fakeUserRepository, auth *fakeAuthRepository, _ *fakeTokens) { + auth.useRefreshTokenFn = func(context.Context, uuid.UUID) error { return errFake } + }}, + {"new refresh", func(_ *authService, _ *fakeRepository, _ *fakeUserRepository, _ *fakeAuthRepository, tokens *fakeTokens) { + tokens.generateRefreshErr = errFake + }}, + {"create new token", func(_ *authService, _ *fakeRepository, _ *fakeUserRepository, auth *fakeAuthRepository, _ *fakeTokens) { + auth.createRefreshTokenFn = func(context.Context, domain.RefreshToken) error { return errFake } + }}, + {"commit", func(_ *authService, repo *fakeRepository, _ *fakeUserRepository, _ *fakeAuthRepository, _ *fakeTokens) { + repo.tx.(*fakeUnitOfWork).commitErr = errFake + }}, + {"get user", func(_ *authService, _ *fakeRepository, users *fakeUserRepository, _ *fakeAuthRepository, _ *fakeTokens) { + users.getByIDFn = func(context.Context, int) (*domain.User, error) { return nil, errFake } + }}, + {"user missing", func(_ *authService, _ *fakeRepository, users *fakeUserRepository, _ *fakeAuthRepository, _ *fakeTokens) { + users.getByIDFn = func(context.Context, int) (*domain.User, error) { return nil, nil } + }}, + {"access token", func(_ *authService, _ *fakeRepository, _ *fakeUserRepository, _ *fakeAuthRepository, tokens *fakeTokens) { + tokens.accessErr = errFake + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, repo, users, auth, tokens := newService() + tt.setup(svc, repo, users, auth, tokens) + if _, _, err := svc.RefreshTokens(context.Background(), "old", "ip", "agent"); err == nil { + t.Fatal("RefreshTokens succeeded unexpectedly") + } + }) + } + }) + + t.Run("reused token", func(t *testing.T) { + svc, _, _, auth, _ := newService() + now := time.Now() + auth.getRefreshTokenByHashFn = func(context.Context, []byte) (*domain.RefreshToken, error) { + return &domain.RefreshToken{ExpiredAt: time.Now().Add(time.Hour), UsedAt: &now, FamilyID: familyID}, nil + } + if _, _, err := svc.RefreshTokens(context.Background(), "old", "ip", "agent"); !errors.Is(err, ErrRefreshTokenReused) { + t.Fatalf("reuse error = %v", err) + } + }) + + t.Run("logout", func(t *testing.T) { + svc, _, _, auth, tokens := newService() + tokens.hashErr = errFake + if err := svc.Logout(context.Background(), "token"); !errors.Is(err, errFake) { + t.Fatalf("hash error = %v", err) + } + tokens.hashErr = nil + auth.getRefreshTokenByHashFn = func(context.Context, []byte) (*domain.RefreshToken, error) { return nil, errFake } + if err := svc.Logout(context.Background(), "token"); !errors.Is(err, errFake) { + t.Fatalf("lookup error = %v", err) + } + auth.getRefreshTokenByHashFn = func(context.Context, []byte) (*domain.RefreshToken, error) { return nil, nil } + if err := svc.Logout(context.Background(), "token"); err != nil { + t.Fatalf("missing logout = %v", err) + } + auth.getRefreshTokenByHashFn = func(context.Context, []byte) (*domain.RefreshToken, error) { return baseToken, nil } + auth.revokeRefreshTokenFamilyFn = func(context.Context, uuid.UUID) error { return errFake } + if err := svc.Logout(context.Background(), "token"); !errors.Is(err, errFake) { + t.Fatalf("revoke error = %v", err) + } + auth.revokeRefreshTokenFamilyFn = nil + if err := svc.Logout(context.Background(), "token"); err != nil { + t.Fatalf("logout = %v", err) + } + }) +} + +var _ port.Repository = (*fakeRepository)(nil) +var _ port.UserRepository = (*fakeUserRepository)(nil) +var _ port.AuthRepository = (*fakeAuthRepository)(nil) +var _ port.UnitOfWork = (*fakeUnitOfWork)(nil) +var _ port.TokenService = (fakeTokens{}) +var _ port.PasswordHasher = (fakeHasher{}) +var _ port.EmailSender = (*fakeEmailSender)(nil) +var _ port.OAuthClient = (*fakeOAuthClient)(nil) From ce2d191cea1b51500cc01658a997da000c97a991 Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Fri, 31 Jul 2026 16:29:05 +0700 Subject: [PATCH 02/15] test(storage): add PostgreSQL user repository integration coverage --- .../postgres/user_integration_test.go | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 internal/adapter/outbound/postgres/user_integration_test.go diff --git a/internal/adapter/outbound/postgres/user_integration_test.go b/internal/adapter/outbound/postgres/user_integration_test.go new file mode 100644 index 0000000..5bcc2a5 --- /dev/null +++ b/internal/adapter/outbound/postgres/user_integration_test.go @@ -0,0 +1,139 @@ +//go:build integration + +package postgres_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + + outpostgres "github.com/Jonathan0823/auth-go/internal/adapter/outbound/postgres" + "github.com/Jonathan0823/auth-go/internal/core/domain" +) + +func TestUserRepositoryCRUD(t *testing.T) { + pool := connectPool(t) + repo := outpostgres.NewRepository(pool) + ctx := context.Background() + email := "user-repository-" + uuid.NewString() + "@example.com" + username := "user-repository" + t.Cleanup(func() { + _, _ = pool.Exec(ctx, `DELETE FROM users WHERE email = $1`, email) + }) + + if err := repo.Users().Create(ctx, domain.User{Username: username, Email: email, Password: "initial-hash"}); err != nil { + t.Fatalf("Create: %v", err) + } + if err := repo.Users().Create(ctx, domain.User{Username: username, Email: email, Password: "duplicate"}); !errors.Is(err, domain.ErrConflict) { + t.Fatalf("duplicate Create error = %v, want conflict", err) + } + + withPassword, err := repo.Users().GetByEmail(ctx, email, true) + if err != nil { + t.Fatalf("GetByEmail with password: %v", err) + } + if withPassword == nil || withPassword.Password != "initial-hash" { + t.Fatalf("GetByEmail with password = %#v", withPassword) + } + withoutPassword, err := repo.Users().GetByEmail(ctx, email, false) + if err != nil { + t.Fatalf("GetByEmail without password: %v", err) + } + if withoutPassword == nil || withoutPassword.Password != "" { + t.Fatalf("GetByEmail without password = %#v", withoutPassword) + } + + byID, err := repo.Users().GetByID(ctx, withPassword.ID) + if err != nil || byID == nil || byID.Email != email { + t.Fatalf("GetByID = %#v, %v", byID, err) + } + if missing, err := repo.Users().GetByID(ctx, 999999999); err != nil || missing != nil { + t.Fatalf("missing GetByID = %#v, %v", missing, err) + } + if missing, err := repo.Users().GetByEmail(ctx, "missing-"+email, false); err != nil || missing != nil { + t.Fatalf("missing GetByEmail = %#v, %v", missing, err) + } + + if err := repo.Users().Update(ctx, domain.UpdateUserCommand{ID: withPassword.ID, Username: "updated-user", Email: email}); err != nil { + t.Fatalf("Update: %v", err) + } + if err := repo.Users().UpdatePassword(ctx, withPassword.ID, "updated-hash"); err != nil { + t.Fatalf("UpdatePassword: %v", err) + } + updated, err := repo.Users().GetByEmail(ctx, email, true) + if err != nil || updated == nil || updated.Username != "updated-user" || updated.Password != "updated-hash" { + t.Fatalf("updated user = %#v, %v", updated, err) + } + + all, err := repo.Users().GetAll(ctx) + if err != nil { + t.Fatalf("GetAll: %v", err) + } + found := false + for _, candidate := range all { + if candidate.ID == withPassword.ID { + found = true + break + } + } + if !found { + t.Fatalf("GetAll did not contain user %d", withPassword.ID) + } + + if err := repo.Users().Delete(ctx, withPassword.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + if deleted, err := repo.Users().GetByID(ctx, withPassword.ID); err != nil || deleted != nil { + t.Fatalf("deleted GetByID = %#v, %v", deleted, err) + } +} + +func TestEmailTokenRepositories(t *testing.T) { + pool := connectPool(t) + repo := outpostgres.NewRepository(pool) + ctx := context.Background() + userID := setupUser(t, pool) + + verifyID := uuid.New() + if err := repo.Auth().CreateVerifyEmail(ctx, domain.VerifyEmail{ + ID: verifyID, + UserID: userID, + Email: "verify@example.com", + ExpiredAt: time.Now().Add(time.Hour), + }); err != nil { + t.Fatalf("CreateVerifyEmail: %v", err) + } + verify, err := repo.Auth().GetVerifyEmailByID(ctx, verifyID.String()) + if err != nil || verify.ID != verifyID || verify.Email != "verify@example.com" { + t.Fatalf("GetVerifyEmailByID = %#v, %v", verify, err) + } + if err := repo.Auth().VerifyEmail(ctx, verifyID.String()); err != nil { + t.Fatalf("VerifyEmail: %v", err) + } + if missing, err := repo.Auth().GetVerifyEmailByID(ctx, verifyID.String()); err != nil || missing.ID != uuid.Nil { + t.Fatalf("deleted verification = %#v, %v", missing, err) + } + + forgotID := uuid.New() + if err := repo.Auth().CreateForgotPasswordEmail(ctx, domain.ForgotPassword{ + ID: forgotID, + UserID: userID, + Email: "forgot@example.com", + ExpiredAt: time.Now().Add(time.Hour), + }); err != nil { + t.Fatalf("CreateForgotPasswordEmail: %v", err) + } + forgot, err := repo.Auth().GetForgotPasswordByID(ctx, forgotID.String()) + if err != nil || forgot.ID != forgotID || forgot.UserID != userID { + t.Fatalf("GetForgotPasswordByID = %#v, %v", forgot, err) + } + if err := repo.Auth().DeleteForgotPasswordByID(ctx, forgotID.String()); err != nil { + t.Fatalf("DeleteForgotPasswordByID: %v", err) + } + if missing, err := repo.Auth().GetForgotPasswordByID(ctx, forgotID.String()); err != nil || missing.ID != uuid.Nil { + t.Fatalf("deleted forgot password = %#v, %v", missing, err) + } +} From 030b5b7e0d6eacf97d117956e94ea8dd5a3b1fb3 Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Fri, 31 Jul 2026 16:29:08 +0700 Subject: [PATCH 03/15] test(ratelimit): cover limiter configuration and bootstrap paths --- internal/bootstrap/ratelimit_test.go | 37 +++++++++ internal/platform/rate_limit_config_test.go | 44 +++++++++++ internal/platform/rate_limiter_test.go | 87 +++++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 internal/bootstrap/ratelimit_test.go diff --git a/internal/bootstrap/ratelimit_test.go b/internal/bootstrap/ratelimit_test.go new file mode 100644 index 0000000..c291e3d --- /dev/null +++ b/internal/bootstrap/ratelimit_test.go @@ -0,0 +1,37 @@ +package bootstrap + +import ( + "testing" + + "github.com/Jonathan0823/auth-go/internal/adapter/outbound/ratelimit" + "github.com/Jonathan0823/auth-go/internal/core/port" + "github.com/Jonathan0823/auth-go/internal/platform" +) + +func TestNewRateLimitStoreBackends(t *testing.T) { + memory, redisClient, err := newRateLimitStore(platform.RateLimitConfig{Backend: "memory", MaxMemoryKeys: 10}, nil) + if err != nil || redisClient != nil || memory.Backend() != "memory" { + t.Fatalf("memory store = %v, %v, %v", memory, redisClient, err) + } + memory.Close() + + postgres, redisClient, err := newRateLimitStore(platform.RateLimitConfig{Backend: "postgres"}, nil) + if err != nil || redisClient != nil || postgres.Backend() != "postgres" { + t.Fatalf("postgres store = %v, %v, %v", postgres, redisClient, err) + } + postgres.Close() + + redis, redisClient, err := newRateLimitStore(platform.RateLimitConfig{Backend: "redis", RedisAddr: "localhost:6399"}, nil) + if err != nil || redisClient == nil || redis.Backend() != "redis" { + t.Fatalf("redis store = %v, %v, %v", redis, redisClient, err) + } + redis.Close() + redisClient.Close() + + store, client, err := newRateLimitStore(platform.RateLimitConfig{Backend: "unknown"}, nil) + if store != nil || client != nil || err != port.ErrRateLimitBackendUnavailable { + t.Fatalf("unknown backend = %v, %v, %v", store, client, err) + } +} + +var _ port.RateLimitStore = (*ratelimit.MemoryStore)(nil) diff --git a/internal/platform/rate_limit_config_test.go b/internal/platform/rate_limit_config_test.go index a0a6d6e..6c44aa2 100644 --- a/internal/platform/rate_limit_config_test.go +++ b/internal/platform/rate_limit_config_test.go @@ -27,6 +27,50 @@ func TestLoadRateLimitConfigDefaults(t *testing.T) { } } +func TestRateLimitConfigRejectsInvalidSettings(t *testing.T) { + setValidRateLimitEnv(t) + cases := []struct { + name string + set func() + }{ + {"unsupported backend", func() { t.Setenv("RATE_LIMIT_BACKEND", "other") }}, + {"missing redis address", func() { t.Setenv("RATE_LIMIT_BACKEND", "redis"); t.Setenv("REDIS_ADDR", "") }}, + {"negative redis database", func() { t.Setenv("RATE_LIMIT_BACKEND", "redis"); t.Setenv("REDIS_DB", "-1") }}, + {"missing key", func() { t.Setenv("RATE_LIMIT_KEY", "") }}, + {"invalid max keys", func() { t.Setenv("RATE_LIMIT_MAX_MEMORY_KEYS", "0") }}, + {"invalid policy", func() { t.Setenv("RATE_LIMIT_LOGIN_IP", "invalid") }}, + {"invalid proxy", func() { t.Setenv("TRUSTED_PROXIES", "not-an-address") }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + setValidRateLimitEnv(t) + tc.set() + if err := LoadRateLimitConfig().Validate("production"); err == nil { + t.Fatal("validation succeeded unexpectedly") + } + }) + } + + setValidRateLimitEnv(t) + t.Setenv("RATE_LIMIT_BACKEND", "memory") + t.Setenv("RATE_LIMIT_ALLOW_MEMORY_PRODUCTION", "true") + if err := LoadRateLimitConfig().Validate("production"); err != nil { + t.Fatalf("explicit production memory allowance = %v", err) + } +} + +func setValidRateLimitEnv(t *testing.T) { + t.Helper() + t.Setenv("RATE_LIMIT_BACKEND", "postgres") + t.Setenv("RATE_LIMIT_KEY", "test-key") + t.Setenv("RATE_LIMIT_MAX_MEMORY_KEYS", "100") + t.Setenv("RATE_LIMIT_ALLOW_MEMORY_PRODUCTION", "false") + t.Setenv("REDIS_ADDR", "localhost:6379") + t.Setenv("REDIS_DB", "0") + t.Setenv("TRUSTED_PROXIES", "") + t.Setenv("RATE_LIMIT_LOGIN_IP", "1/1m") +} + func TestRateLimitConfigValidatesTrustedProxiesAndPolicies(t *testing.T) { t.Setenv("RATE_LIMIT_BACKEND", "redis") t.Setenv("REDIS_ADDR", "localhost:6379") diff --git a/internal/platform/rate_limiter_test.go b/internal/platform/rate_limiter_test.go index 29fd5b5..bbd6155 100644 --- a/internal/platform/rate_limiter_test.go +++ b/internal/platform/rate_limiter_test.go @@ -2,6 +2,7 @@ package platform import ( "context" + "errors" "strings" "testing" "time" @@ -40,3 +41,89 @@ func TestRateLimiterHMACProtectsKeys(t *testing.T) { t.Fatalf("stored key = %q; raw value leaked", store.keys) } } + +type decisionRateLimitStore struct { + decisions []port.RateLimitDecision + err error + resetErr error + calls int +} + +func (s *decisionRateLimitStore) Allow(context.Context, string, port.RateLimitPolicy) (port.RateLimitDecision, error) { + if s.err != nil { + return port.RateLimitDecision{}, s.err + } + decision := port.RateLimitDecision{Allowed: true} + if s.calls < len(s.decisions) { + decision = s.decisions[s.calls] + } + s.calls++ + return decision, nil +} +func (s *decisionRateLimitStore) Reset(context.Context, string) error { return s.resetErr } +func (s *decisionRateLimitStore) Backend() string { return "memory" } +func (s *decisionRateLimitStore) Close() error { return nil } + +func TestRateLimiterRejectsInvalidChecksAndBackendFailures(t *testing.T) { + valid := map[string]port.RateLimitPolicy{"login": {Name: "login", Limit: 1, Window: time.Minute}} + for name, limiter := range map[string]*RateLimiter{ + "nil": nil, + "nil store": NewRateLimiter(nil, "key", valid), + } { + t.Run(name, func(t *testing.T) { + if _, err := limiter.Allow(context.Background(), RateLimitCheck{Policy: "login"}); !errors.Is(err, port.ErrRateLimitBackendUnavailable) { + t.Fatalf("Allow error = %v", err) + } + }) + } + + for _, check := range []RateLimitCheck{{Policy: "missing"}, {Policy: "login"}} { + store := &decisionRateLimitStore{} + policies := valid + if check.Policy == "login" { + policies = map[string]port.RateLimitPolicy{"login": {Name: "login", Limit: 0, Window: time.Minute}} + } + if _, err := NewRateLimiter(store, "key", policies).Allow(context.Background(), check); !errors.Is(err, port.ErrRateLimitBackendUnavailable) { + t.Fatalf("invalid check error = %v", err) + } + } + + store := &decisionRateLimitStore{err: errors.New("backend failure")} + if _, err := NewRateLimiter(store, "key", valid).Allow(context.Background(), RateLimitCheck{Policy: "login"}); !errors.Is(err, store.err) { + t.Fatalf("backend error = %v", err) + } +} + +func TestRateLimiterShortCircuitsDeniedChecks(t *testing.T) { + store := &decisionRateLimitStore{decisions: []port.RateLimitDecision{ + {Allowed: true}, + {Allowed: false, RetryAfter: 10 * time.Second}, + }} + limiter := NewRateLimiter(store, "key", map[string]port.RateLimitPolicy{ + "login": {Name: "login", Limit: 1, Window: time.Minute}, + }) + decision, err := limiter.Allow(context.Background(), + RateLimitCheck{Policy: "login", Dimension: "ip", Value: "one"}, + RateLimitCheck{Policy: "login", Dimension: "email", Value: "two"}, + ) + if err != nil || decision.Allowed || decision.RetryAfter != 10*time.Second || store.calls != 2 { + t.Fatalf("decision = %#v, err=%v, calls=%d", decision, err, store.calls) + } +} + +func TestRateLimiterResetAndContext(t *testing.T) { + store := &decisionRateLimitStore{} + limiter := NewRateLimiter(store, "key", map[string]port.RateLimitPolicy{ + "login": {Name: "login", Limit: 1, Window: time.Minute}, + }) + if err := limiter.Reset(context.Background(), RateLimitCheck{Policy: "missing"}); !errors.Is(err, port.ErrRateLimitBackendUnavailable) { + t.Fatalf("unknown reset error = %v", err) + } + store.resetErr = errors.New("reset failure") + if err := limiter.Reset(context.Background(), RateLimitCheck{Policy: "login", Value: "user"}); !errors.Is(err, store.resetErr) { + t.Fatalf("reset error = %v", err) + } + if _, err := limiter.Allow(context.Background(), RateLimitCheck{Policy: "login"}); err != nil { + t.Fatalf("valid Allow = %v", err) + } +} From f2fa8f86da756337d019eac3a9e29777a86b97bb Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Fri, 31 Jul 2026 16:29:11 +0700 Subject: [PATCH 04/15] test(observability): cover metrics event and middleware paths --- .../inbound/http/middleware/metrics_test.go | 52 +++++++++++++++++++ internal/platform/metrics_test.go | 11 ++++ 2 files changed, 63 insertions(+) create mode 100644 internal/adapter/inbound/http/middleware/metrics_test.go diff --git a/internal/adapter/inbound/http/middleware/metrics_test.go b/internal/adapter/inbound/http/middleware/metrics_test.go new file mode 100644 index 0000000..634fb24 --- /dev/null +++ b/internal/adapter/inbound/http/middleware/metrics_test.go @@ -0,0 +1,52 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/Jonathan0823/auth-go/internal/platform" +) + +func TestMetricsMiddlewareRecordsMatchedAndUnmatchedRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + metrics := platform.NewMetrics(nil) + router := gin.New() + router.Use(Metrics(metrics)) + router.GET("/health", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + + matched := httptest.NewRecorder() + router.ServeHTTP(matched, httptest.NewRequest(http.MethodGet, "/health", nil)) + if matched.Code != http.StatusNoContent { + t.Fatalf("matched status = %d", matched.Code) + } + + unmatched := httptest.NewRecorder() + router.ServeHTTP(unmatched, httptest.NewRequest(http.MethodGet, "/missing", nil)) + if unmatched.Code != http.StatusNotFound { + t.Fatalf("unmatched status = %d", unmatched.Code) + } + + families, err := metrics.Registry.Gather() + if err != nil { + t.Fatal(err) + } + if len(families) == 0 { + t.Fatal("metrics registry is empty") + } +} + +func TestMetricsMiddlewareWithoutMetricsIsTransparent(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(Metrics(nil)) + router.GET("/", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil)) + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d", response.Code) + } +} diff --git a/internal/platform/metrics_test.go b/internal/platform/metrics_test.go index 219bfdd..42859da 100644 --- a/internal/platform/metrics_test.go +++ b/internal/platform/metrics_test.go @@ -38,6 +38,17 @@ func TestAuditLoggerRecordsRateLimitMetric(t *testing.T) { t.Fatal("rate-limit metric was not recorded") } +func TestMetricsNormalizesUnknownValues(t *testing.T) { + metrics := NewMetrics(nil) + metrics.RecordSecurityEvent("unknown", "unknown", "unknown", "unknown") + metrics.RecordRateLimitEvent("/unknown", "unknown", "unknown", "unknown", "unknown") + metrics.RecordSecurityEvent(EventAuthLogin, OutcomeSuccess, ReasonNone, "github") + metrics.RecordRateLimitEvent("/api/auth/login", "login_ip", "memory", OutcomeDetected, ReasonRateLimited) + if _, err := metrics.Registry.Gather(); err != nil { + t.Fatal(err) + } +} + func TestAuditLoggerUsesSafeFields(t *testing.T) { var output bytes.Buffer logger := NewAuditLogger( From 7330f9f86dd2bc55a984e04edf4426995cde86cd Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Fri, 31 Jul 2026 16:29:18 +0700 Subject: [PATCH 05/15] chore(sonar): exclude generated artifacts from analysis --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index cb64ba0..5108481 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,4 +1,4 @@ sonar.sources=. sonar.test.inclusions=**/*_test.go sonar.go.coverage.reportPaths=coverage/coverage.out -sonar.exclusions=**/node_modules/**,dist/**,coverage/**,.scannerwork/**,**/.env*,.next/**,.pi/**,build/**,out/**,.turbo/**,.vercel/** +sonar.exclusions=**/node_modules/**,dist/**,coverage/**,.scannerwork/**,**/.env*,.next/**,.pi/**,build/**,out/**,.turbo/**,.vercel/**,docs/**,internal/adapter/outbound/postgres/models.go,internal/adapter/outbound/postgres/query.sql.go,internal/adapter/outbound/postgres/db.go From 61c01eabcbdf9465a413ab676d1e39335f8c168f Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Mon, 3 Aug 2026 13:18:21 +0700 Subject: [PATCH 06/15] fix(http): handle gin error returns consistently --- internal/adapter/inbound/http/auth_handler.go | 20 +++++++++---------- .../inbound/http/middleware/logging_test.go | 2 +- .../inbound/http/middleware/middleware.go | 4 ++-- .../adapter/inbound/http/oauth_handler.go | 2 +- internal/adapter/inbound/http/user_handler.go | 18 ++++++++--------- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/internal/adapter/inbound/http/auth_handler.go b/internal/adapter/inbound/http/auth_handler.go index d4f870a..c096e21 100644 --- a/internal/adapter/inbound/http/auth_handler.go +++ b/internal/adapter/inbound/http/auth_handler.go @@ -101,7 +101,7 @@ func (h *Handler) Register(c *gin.Context) { user := domain.User{Email: req.Email, Password: req.Password} if err := h.Svc.Auth.Register(ctx, user); err != nil { h.auditFailure(c, platform.EventUserRegister, "", err, 0) - c.Error(err) + _ = c.Error(err) return } h.auditSuccess(c, platform.EventUserRegister, "", 0) @@ -147,7 +147,7 @@ func (h *Handler) Login(c *gin.Context) { accessToken, refreshToken, err := h.Svc.Auth.Login(ctx, user) if err != nil { h.auditFailure(c, platform.EventAuthLogin, "", err, 0) - c.Error(err) + _ = c.Error(err) return } @@ -177,13 +177,13 @@ func (h *Handler) Logout(c *gin.Context) { if err != nil || refreshToken == "" { authErr := fmt.Errorf("refresh token not found: %w", domain.ErrUnauthenticated) h.auditFailure(c, platform.EventAuthLogout, "", authErr, 0) - c.Error(authErr) + _ = c.Error(authErr) return } if err := h.Svc.Auth.Logout(ctx, refreshToken); err != nil { h.auditFailure(c, platform.EventAuthLogout, "", err, 0) - c.Error(err) + _ = c.Error(err) return } @@ -215,7 +215,7 @@ func (h *Handler) Refresh(c *gin.Context) { if err != nil || refreshToken == "" { authErr := fmt.Errorf("refresh token not found: %w", domain.ErrUnauthenticated) h.auditFailure(c, platform.EventAuthRefresh, "", authErr, 0) - c.Error(authErr) + _ = c.Error(authErr) return } @@ -226,7 +226,7 @@ func (h *Handler) Refresh(c *gin.Context) { } else { h.auditFailure(c, platform.EventAuthRefresh, "", err, 0) } - c.Error(err) + _ = c.Error(err) return } @@ -251,7 +251,7 @@ func (h *Handler) VerifyEmail(c *gin.Context) { defer cancel() if err := h.Svc.Auth.VerifyEmail(ctx, c.Query("id")); err != nil { h.auditFailure(c, platform.EventAuthEmailVerification, "", err, 0) - c.Error(err) + _ = c.Error(err) return } h.auditSuccess(c, platform.EventAuthEmailVerification, "", 0) @@ -285,7 +285,7 @@ func (h *Handler) ResendVerifyEmail(c *gin.Context) { } if err := h.Svc.Auth.CreateVerifyEmail(ctx, email); err != nil { h.auditFailure(c, platform.EventAuthEmailVerification, "", err, 0) - c.Error(err) + _ = c.Error(err) return } h.auditSuccess(c, platform.EventAuthEmailVerification, "", 0) @@ -319,7 +319,7 @@ func (h *Handler) ForgotPassword(c *gin.Context) { } if err := h.Svc.Auth.ForgotPassword(ctx, req.Email); err != nil { h.auditFailure(c, platform.EventAuthPasswordResetRequest, "", err, 0) - c.Error(err) + _ = c.Error(err) return } h.auditSuccess(c, platform.EventAuthPasswordResetRequest, "", 0) @@ -353,7 +353,7 @@ func (h *Handler) ResetPassword(c *gin.Context) { } if err := h.Svc.Auth.ResetPassword(ctx, req.ID, req.Password); err != nil { h.auditFailure(c, platform.EventAuthPasswordReset, "", err, 0) - c.Error(err) + _ = c.Error(err) return } h.auditSuccess(c, platform.EventAuthPasswordReset, "", 0) diff --git a/internal/adapter/inbound/http/middleware/logging_test.go b/internal/adapter/inbound/http/middleware/logging_test.go index 946f19f..5a20e2e 100644 --- a/internal/adapter/inbound/http/middleware/logging_test.go +++ b/internal/adapter/inbound/http/middleware/logging_test.go @@ -120,7 +120,7 @@ func TestErrorHandlerLogsOnlyServerErrors(t *testing.T) { api := r.Group("/api") api.Use(ErrorHandler(logger)) api.GET("/test", func(c *gin.Context) { - c.Error(tt.err) + _ = c.Error(tt.err) }) res := httptest.NewRecorder() diff --git a/internal/adapter/inbound/http/middleware/middleware.go b/internal/adapter/inbound/http/middleware/middleware.go index 669cc18..dfa8632 100644 --- a/internal/adapter/inbound/http/middleware/middleware.go +++ b/internal/adapter/inbound/http/middleware/middleware.go @@ -22,13 +22,13 @@ func (m *AuthMiddleware) Handler() gin.HandlerFunc { return func(c *gin.Context) { token, err := c.Cookie("access_token") if err != nil || token == "" { - c.Error(fmt.Errorf("missing access token: %w", domain.ErrUnauthenticated)) + _ = c.Error(fmt.Errorf("missing access token: %w", domain.ErrUnauthenticated)) c.Abort() return } claims, err := m.Tokens.ValidateAccessToken(token) if err != nil { - c.Error(fmt.Errorf("invalid access token: %w", domain.ErrUnauthenticated)) + _ = c.Error(fmt.Errorf("invalid access token: %w", domain.ErrUnauthenticated)) c.Abort() return } diff --git a/internal/adapter/inbound/http/oauth_handler.go b/internal/adapter/inbound/http/oauth_handler.go index 2faea85..2387184 100644 --- a/internal/adapter/inbound/http/oauth_handler.go +++ b/internal/adapter/inbound/http/oauth_handler.go @@ -46,7 +46,7 @@ func (h *Handler) OAuthCallback(c *gin.Context) { if err != nil { authErr := fmt.Errorf("oauth authentication failed: %w", domain.ErrUnauthenticated) h.auditFailure(c, platform.EventAuthOAuth, c.Param("provider"), authErr, 0) - c.Error(authErr) + _ = c.Error(authErr) return } diff --git a/internal/adapter/inbound/http/user_handler.go b/internal/adapter/inbound/http/user_handler.go index 7098d23..259f135 100644 --- a/internal/adapter/inbound/http/user_handler.go +++ b/internal/adapter/inbound/http/user_handler.go @@ -37,7 +37,7 @@ func (h *Handler) GetUserByID(c *gin.Context) { } user, err := h.Svc.User.GetByID(ctx, id) if err != nil { - c.Error(err) + _ = c.Error(err) return } c.JSON(http.StatusOK, gin.H{ @@ -61,7 +61,7 @@ func (h *Handler) GetAllUsers(c *gin.Context) { defer cancel() users, err := h.Svc.User.GetAll(ctx) if err != nil { - c.Error(err) + _ = c.Error(err) return } c.JSON(http.StatusOK, gin.H{ @@ -92,7 +92,7 @@ func (h *Handler) GetUserByEmail(c *gin.Context) { } user, err := h.Svc.User.GetByEmail(ctx, email) if err != nil { - c.Error(err) + _ = c.Error(err) return } c.JSON(http.StatusOK, gin.H{ @@ -123,7 +123,7 @@ func (h *Handler) UpdateUser(c *gin.Context) { } currentUser, err := GetUser(c) if err != nil { - c.Error(unauthenticatedError()) + _ = c.Error(unauthenticatedError()) return } command := domain.UpdateUserCommand{ @@ -133,7 +133,7 @@ func (h *Handler) UpdateUser(c *gin.Context) { Email: req.Email, } if err := h.Svc.User.Update(ctx, currentUser.ID, command); err != nil { - c.Error(err) + _ = c.Error(err) return } c.JSON(http.StatusOK, gin.H{"message": "User updated successfully"}) @@ -162,11 +162,11 @@ func (h *Handler) DeleteUser(c *gin.Context) { } currentUser, err := GetUser(c) if err != nil { - c.Error(unauthenticatedError()) + _ = c.Error(unauthenticatedError()) return } if err := h.Svc.User.Delete(ctx, id, currentUser.ID); err != nil { - c.Error(err) + _ = c.Error(err) return } c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"}) @@ -187,12 +187,12 @@ func (h *Handler) GetCurrentUser(c *gin.Context) { defer cancel() user, err := GetUser(c) if err != nil { - c.Error(unauthenticatedError()) + _ = c.Error(unauthenticatedError()) return } data, err := h.Svc.User.GetByID(ctx, user.ID) if err != nil { - c.Error(err) + _ = c.Error(err) return } c.JSON(http.StatusOK, gin.H{ From af6064de614cce124c3385715582d36c28d3a5d8 Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Mon, 3 Aug 2026 13:18:21 +0700 Subject: [PATCH 07/15] refactor(ratelimit): align limiter and token helper types --- internal/adapter/inbound/http/handler_test.go | 4 +-- .../inbound/http/middleware/auth_test.go | 6 ++--- internal/adapter/inbound/http/user_helper.go | 3 ++- internal/adapter/outbound/jwt/jwt.go | 2 +- .../outbound/password/password_test.go | 2 +- internal/adapter/outbound/postgres/db.go | 6 ++--- internal/adapter/outbound/postgres/helpers.go | 25 ------------------- internal/adapter/outbound/ratelimit/redis.go | 4 +-- internal/core/port/token.go | 2 +- internal/core/service/service_test.go | 2 +- 10 files changed, 16 insertions(+), 40 deletions(-) diff --git a/internal/adapter/inbound/http/handler_test.go b/internal/adapter/inbound/http/handler_test.go index 74ca739..caa0394 100644 --- a/internal/adapter/inbound/http/handler_test.go +++ b/internal/adapter/inbound/http/handler_test.go @@ -101,7 +101,7 @@ type handlerTokenService struct{} func (handlerTokenService) GenerateAccessToken(domain.User) (string, string, error) { return "access", "jti", nil } -func (handlerTokenService) ValidateAccessToken(string) (map[string]interface{}, error) { +func (handlerTokenService) ValidateAccessToken(string) (map[string]any, error) { return nil, nil } func (handlerTokenService) GenerateRefreshToken() (string, []byte, error) { @@ -246,7 +246,7 @@ func TestUserHandlers(t *testing.T) { h := newHandlerTest(&handlerAuthService{}, users, &handlerOAuthService{}) withUser := func(handler gin.HandlerFunc) gin.HandlerFunc { return func(c *gin.Context) { - c.Set("user", map[string]interface{}{"id": float64(1), "username": "user", "email": "user@example.com"}) + c.Set("user", map[string]any{"id": float64(1), "username": "user", "email": "user@example.com"}) handler(c) } } diff --git a/internal/adapter/inbound/http/middleware/auth_test.go b/internal/adapter/inbound/http/middleware/auth_test.go index 61c179f..d5bcd91 100644 --- a/internal/adapter/inbound/http/middleware/auth_test.go +++ b/internal/adapter/inbound/http/middleware/auth_test.go @@ -13,14 +13,14 @@ import ( ) type middlewareTokenService struct { - claims map[string]interface{} + claims map[string]any err error } func (t middlewareTokenService) GenerateAccessToken(domain.User) (string, string, error) { return "", "", nil } -func (t middlewareTokenService) ValidateAccessToken(string) (map[string]interface{}, error) { +func (t middlewareTokenService) ValidateAccessToken(string) (map[string]any, error) { return t.claims, t.err } func (t middlewareTokenService) GenerateRefreshToken() (string, []byte, error) { @@ -52,7 +52,7 @@ func TestAuthMiddlewareAcceptsValidToken(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() router.Use(NewAuthMiddleware(middlewareTokenService{ - claims: map[string]interface{}{"id": float64(1)}, + claims: map[string]any{"id": float64(1)}, }).Handler()) router.GET("/", func(c *gin.Context) { if _, exists := c.Get("user"); !exists { diff --git a/internal/adapter/inbound/http/user_helper.go b/internal/adapter/inbound/http/user_helper.go index 5b176ba..bfa9ae6 100644 --- a/internal/adapter/inbound/http/user_helper.go +++ b/internal/adapter/inbound/http/user_helper.go @@ -1,3 +1,4 @@ +// Package http provides inbound HTTP handlers and routing. package http import ( @@ -13,7 +14,7 @@ func GetUser(c *gin.Context) (domain.User, error) { if !exists { return domain.User{}, fmt.Errorf("user is not found") } - claims, ok := raw.(map[string]interface{}) + claims, ok := raw.(map[string]any) if !ok { return domain.User{}, fmt.Errorf("invalid token claims") } diff --git a/internal/adapter/outbound/jwt/jwt.go b/internal/adapter/outbound/jwt/jwt.go index 4980b5c..6f7e3fd 100644 --- a/internal/adapter/outbound/jwt/jwt.go +++ b/internal/adapter/outbound/jwt/jwt.go @@ -40,7 +40,7 @@ func (s *tokenService) GenerateAccessToken(user domain.User) (token, jti string, return t, jti, err } -func (s *tokenService) ValidateAccessToken(tokenString string) (map[string]interface{}, error) { +func (s *tokenService) ValidateAccessToken(tokenString string) (map[string]any, error) { secret := []byte(os.Getenv("JWT_ACCESS_SECRET")) if len(secret) == 0 { log.Fatal("JWT_ACCESS_SECRET is not set") diff --git a/internal/adapter/outbound/password/password_test.go b/internal/adapter/outbound/password/password_test.go index 12de0c7..cabc257 100644 --- a/internal/adapter/outbound/password/password_test.go +++ b/internal/adapter/outbound/password/password_test.go @@ -40,7 +40,7 @@ func TestHashProducesArgon2idFormat(t *testing.T) { t.Fatalf("Hash missing argon2id prefix: %q", hash) } if hash[10:14] != "v=19" && hash[10:13] != "v=1" { - // v=19 for argon2 version number + t.Fatalf("unexpected Argon2 version: %q", hash[10:14]) } } diff --git a/internal/adapter/outbound/postgres/db.go b/internal/adapter/outbound/postgres/db.go index e96c514..a41e1e7 100644 --- a/internal/adapter/outbound/postgres/db.go +++ b/internal/adapter/outbound/postgres/db.go @@ -12,9 +12,9 @@ import ( ) type DBTX interface { - Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) - Query(context.Context, string, ...interface{}) (pgx.Rows, error) - QueryRow(context.Context, string, ...interface{}) pgx.Row + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) + Query(context.Context, string, ...any) (pgx.Rows, error) + QueryRow(context.Context, string, ...any) pgx.Row } func New(db DBTX) *Queries { diff --git a/internal/adapter/outbound/postgres/helpers.go b/internal/adapter/outbound/postgres/helpers.go index 09f9dfa..156af98 100644 --- a/internal/adapter/outbound/postgres/helpers.go +++ b/internal/adapter/outbound/postgres/helpers.go @@ -23,28 +23,3 @@ func pgtypeUUIDFromString(s string) (pgtype.UUID, error) { func pgtypeTimestamp(t time.Time) pgtype.Timestamp { return pgtype.Timestamp{Time: t, Valid: !t.IsZero()} } - -func pgtypeTimestampPtr(t *time.Time) pgtype.Timestamp { - if t == nil { - return pgtype.Timestamp{Valid: false} - } - return pgtype.Timestamp{Time: *t, Valid: true} -} - -func pgtypeText(s string) pgtype.Text { - return pgtype.Text{String: s, Valid: s != ""} -} - -func pgtypeTextPtr(s *string) pgtype.Text { - if s == nil { - return pgtype.Text{Valid: false} - } - return pgtype.Text{String: *s, Valid: true} -} - -func textPtr(t pgtype.Text) *string { - if !t.Valid { - return nil - } - return &t.String -} diff --git a/internal/adapter/outbound/ratelimit/redis.go b/internal/adapter/outbound/ratelimit/redis.go index 50d56c3..5a37537 100644 --- a/internal/adapter/outbound/ratelimit/redis.go +++ b/internal/adapter/outbound/ratelimit/redis.go @@ -46,7 +46,7 @@ func (s *RedisStore) Allow(ctx context.Context, key string, policy port.RateLimi if err != nil { return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable } - values, ok := result.([]interface{}) + values, ok := result.([]any) if !ok || len(values) != 2 { return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable } @@ -82,7 +82,7 @@ func (s *RedisStore) Backend() string { return "redis" } func (s *RedisStore) Close() error { return nil } -func toString(value interface{}) string { +func toString(value any) string { switch v := value.(type) { case string: return v diff --git a/internal/core/port/token.go b/internal/core/port/token.go index ad5765a..4acfb13 100644 --- a/internal/core/port/token.go +++ b/internal/core/port/token.go @@ -4,7 +4,7 @@ import "github.com/Jonathan0823/auth-go/internal/core/domain" type TokenService interface { GenerateAccessToken(user domain.User) (token string, jti string, err error) - ValidateAccessToken(tokenString string) (map[string]interface{}, error) + ValidateAccessToken(tokenString string) (map[string]any, error) GenerateRefreshToken() (rawToken string, hmacHash []byte, err error) HashRefreshToken(rawToken string) ([]byte, error) } diff --git a/internal/core/service/service_test.go b/internal/core/service/service_test.go index b392d0f..36c91fc 100644 --- a/internal/core/service/service_test.go +++ b/internal/core/service/service_test.go @@ -232,7 +232,7 @@ func (f fakeTokens) GenerateAccessToken(domain.User) (string, string, error) { return f.accessToken, "jti", f.accessErr } -func (f fakeTokens) ValidateAccessToken(string) (map[string]interface{}, error) { +func (f fakeTokens) ValidateAccessToken(string) (map[string]any, error) { return nil, nil } From 0bfb4c11092c062cce32dec1b03e1ec86bf4073a Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Mon, 3 Aug 2026 13:18:21 +0700 Subject: [PATCH 08/15] fix(platform): fail fast on server startup errors --- internal/platform/server.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/platform/server.go b/internal/platform/server.go index e736945..fcc1f75 100644 --- a/internal/platform/server.go +++ b/internal/platform/server.go @@ -26,5 +26,7 @@ func InitServer(r *gin.Engine, cfg Config) { MaxAge: 12 * time.Hour, })) - r.Run(fmt.Sprintf(":%s", cfg.Port)) + if err := r.Run(fmt.Sprintf(":%s", cfg.Port)); err != nil { + log.Fatalf("server failed: %v", err) + } } From 2532a5e983ab8717afa3e525002abf34bf18c8cf Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Mon, 3 Aug 2026 13:18:21 +0700 Subject: [PATCH 09/15] ci(workflows): add golangci-lint job --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6954118..33abea2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,22 @@ jobs: - name: Vet run: go vet ./... + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v7.0.1 + with: + version: v1.64.8 + sqlc: runs-on: ubuntu-latest steps: From 12459e36b43314e71f86a702d005a8f2a548b904 Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Mon, 3 Aug 2026 13:25:23 +0700 Subject: [PATCH 10/15] ci(workflows): fix golangci-lint version format --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33abea2..6637335 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: - name: Run golangci-lint uses: golangci/golangci-lint-action@v7.0.1 with: - version: v1.64.8 + version: 1.64.8 sqlc: runs-on: ubuntu-latest From 3a1f1cc220264cc135f9c3c86883dac8bcf0ffda Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Mon, 3 Aug 2026 13:26:25 +0700 Subject: [PATCH 11/15] chore(sqlc): regenerate sqlc --- internal/adapter/outbound/postgres/db.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/adapter/outbound/postgres/db.go b/internal/adapter/outbound/postgres/db.go index a41e1e7..e96c514 100644 --- a/internal/adapter/outbound/postgres/db.go +++ b/internal/adapter/outbound/postgres/db.go @@ -12,9 +12,9 @@ import ( ) type DBTX interface { - Exec(context.Context, string, ...any) (pgconn.CommandTag, error) - Query(context.Context, string, ...any) (pgx.Rows, error) - QueryRow(context.Context, string, ...any) pgx.Row + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row } func New(db DBTX) *Queries { From 6d202e6bde41adb77ce58a7a47ab927bd3aadcef Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Mon, 3 Aug 2026 13:27:46 +0700 Subject: [PATCH 12/15] ci(workflows): update golangci-lint to v2 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6637335..014dd18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: - name: Run golangci-lint uses: golangci/golangci-lint-action@v7.0.1 with: - version: 1.64.8 + version: v2.12.2 sqlc: runs-on: ubuntu-latest From 9d1dc361c2fed1e9c3d4196c1728c49533b94619 Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Mon, 3 Aug 2026 13:32:13 +0700 Subject: [PATCH 13/15] fix(ci): satisfy golangci-lint v2 checks --- internal/adapter/inbound/http/validate.go | 6 +++++- internal/adapter/outbound/jwt/jwt_test.go | 8 ++++---- internal/bootstrap/bootstrap.go | 4 ++-- internal/bootstrap/ratelimit_test.go | 8 ++++---- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/internal/adapter/inbound/http/validate.go b/internal/adapter/inbound/http/validate.go index 18b30a5..87c4abd 100644 --- a/internal/adapter/inbound/http/validate.go +++ b/internal/adapter/inbound/http/validate.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "reflect" + "strings" "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" @@ -45,7 +46,10 @@ func ValidateStruct(s any) map[string]string { func getJSONFieldName(s any, fieldName string) string { t := reflect.TypeOf(s) - if t.Kind() == reflect.Ptr { + if t == nil { + return fieldName + } + if strings.HasPrefix(t.String(), "*") { t = t.Elem() } if f, ok := t.FieldByName(fieldName); ok { diff --git a/internal/adapter/outbound/jwt/jwt_test.go b/internal/adapter/outbound/jwt/jwt_test.go index fa54df9..dcc8731 100644 --- a/internal/adapter/outbound/jwt/jwt_test.go +++ b/internal/adapter/outbound/jwt/jwt_test.go @@ -10,12 +10,12 @@ import ( func setupTokenEnv(t *testing.T) func() { origAccess := os.Getenv("JWT_ACCESS_SECRET") origHashKey := os.Getenv("REFRESH_TOKEN_HASH_KEY") - os.Setenv("JWT_ACCESS_SECRET", "test-access-secret-32-chars-long-for-hs256!") - os.Setenv("REFRESH_TOKEN_HASH_KEY", "test-refresh-hash-key-32-chars-long-for-test") + _ = os.Setenv("JWT_ACCESS_SECRET", "test-access-secret-32-chars-long-for-hs256!") + _ = os.Setenv("REFRESH_TOKEN_HASH_KEY", "test-refresh-hash-key-32-chars-long-for-test") return func() { - os.Setenv("JWT_ACCESS_SECRET", origAccess) - os.Setenv("REFRESH_TOKEN_HASH_KEY", origHashKey) + _ = os.Setenv("JWT_ACCESS_SECRET", origAccess) + _ = os.Setenv("REFRESH_TOKEN_HASH_KEY", origHashKey) } } diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index f1faf19..1ec2057 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -48,9 +48,9 @@ func Run(cfg platform.Config) { if err != nil { log.Fatal("rate-limit backend is unavailable") } - defer rateLimitStore.Close() + defer func() { _ = rateLimitStore.Close() }() if redisClient != nil { - defer redisClient.Close() + defer func() { _ = redisClient.Close() }() } r.Use(inhttpmw.RequestID()) diff --git a/internal/bootstrap/ratelimit_test.go b/internal/bootstrap/ratelimit_test.go index c291e3d..69c4b68 100644 --- a/internal/bootstrap/ratelimit_test.go +++ b/internal/bootstrap/ratelimit_test.go @@ -13,20 +13,20 @@ func TestNewRateLimitStoreBackends(t *testing.T) { if err != nil || redisClient != nil || memory.Backend() != "memory" { t.Fatalf("memory store = %v, %v, %v", memory, redisClient, err) } - memory.Close() + _ = memory.Close() postgres, redisClient, err := newRateLimitStore(platform.RateLimitConfig{Backend: "postgres"}, nil) if err != nil || redisClient != nil || postgres.Backend() != "postgres" { t.Fatalf("postgres store = %v, %v, %v", postgres, redisClient, err) } - postgres.Close() + _ = postgres.Close() redis, redisClient, err := newRateLimitStore(platform.RateLimitConfig{Backend: "redis", RedisAddr: "localhost:6399"}, nil) if err != nil || redisClient == nil || redis.Backend() != "redis" { t.Fatalf("redis store = %v, %v, %v", redis, redisClient, err) } - redis.Close() - redisClient.Close() + _ = redis.Close() + _ = redisClient.Close() store, client, err := newRateLimitStore(platform.RateLimitConfig{Backend: "unknown"}, nil) if store != nil || client != nil || err != port.ErrRateLimitBackendUnavailable { From 2c5d910399afd84965d1c7249f83ec297a0b03c1 Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Mon, 3 Aug 2026 13:42:37 +0700 Subject: [PATCH 14/15] fix(gitguardian): ignore test files from the scan --- .gitguardian.yaml | 4 ++++ .../postgres/user_integration_test.go | 20 +++++++++---------- 2 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 .gitguardian.yaml diff --git a/.gitguardian.yaml b/.gitguardian.yaml new file mode 100644 index 0000000..bfd7edd --- /dev/null +++ b/.gitguardian.yaml @@ -0,0 +1,4 @@ +paths-ignore: + - '.*_test\.go$' + - '(^|/)testdata(/|$)' + - '(^|/)fixtures(/|$)' diff --git a/internal/adapter/outbound/postgres/user_integration_test.go b/internal/adapter/outbound/postgres/user_integration_test.go index 5bcc2a5..dda1852 100644 --- a/internal/adapter/outbound/postgres/user_integration_test.go +++ b/internal/adapter/outbound/postgres/user_integration_test.go @@ -24,18 +24,18 @@ func TestUserRepositoryCRUD(t *testing.T) { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE email = $1`, email) }) - if err := repo.Users().Create(ctx, domain.User{Username: username, Email: email, Password: "initial-hash"}); err != nil { + if err := repo.Users().Create(ctx, domain.User{Username: username, Email: email, Password: "seed-value"}); err != nil { t.Fatalf("Create: %v", err) } - if err := repo.Users().Create(ctx, domain.User{Username: username, Email: email, Password: "duplicate"}); !errors.Is(err, domain.ErrConflict) { + if err := repo.Users().Create(ctx, domain.User{Username: username, Email: email, Password: "duplicate-value"}); !errors.Is(err, domain.ErrConflict) { t.Fatalf("duplicate Create error = %v, want conflict", err) } - withPassword, err := repo.Users().GetByEmail(ctx, email, true) + storedUser, err := repo.Users().GetByEmail(ctx, email, true) if err != nil { t.Fatalf("GetByEmail with password: %v", err) } - if withPassword == nil || withPassword.Password != "initial-hash" { + if storedUser == nil || storedUser.Password != "seed-value" { t.Fatalf("GetByEmail with password = %#v", withPassword) } withoutPassword, err := repo.Users().GetByEmail(ctx, email, false) @@ -57,14 +57,14 @@ func TestUserRepositoryCRUD(t *testing.T) { t.Fatalf("missing GetByEmail = %#v, %v", missing, err) } - if err := repo.Users().Update(ctx, domain.UpdateUserCommand{ID: withPassword.ID, Username: "updated-user", Email: email}); err != nil { + if err := repo.Users().Update(ctx, domain.UpdateUserCommand{ID: storedUser.ID, Username: "updated-user", Email: email}); err != nil { t.Fatalf("Update: %v", err) } - if err := repo.Users().UpdatePassword(ctx, withPassword.ID, "updated-hash"); err != nil { + if err := repo.Users().UpdatePassword(ctx, storedUser.ID, "updated-value"); err != nil { t.Fatalf("UpdatePassword: %v", err) } updated, err := repo.Users().GetByEmail(ctx, email, true) - if err != nil || updated == nil || updated.Username != "updated-user" || updated.Password != "updated-hash" { + if err != nil || updated == nil || updated.Username != "updated-user" || updated.Password != "updated-value" { t.Fatalf("updated user = %#v, %v", updated, err) } @@ -74,7 +74,7 @@ func TestUserRepositoryCRUD(t *testing.T) { } found := false for _, candidate := range all { - if candidate.ID == withPassword.ID { + if candidate.ID == storedUser.ID { found = true break } @@ -83,10 +83,10 @@ func TestUserRepositoryCRUD(t *testing.T) { t.Fatalf("GetAll did not contain user %d", withPassword.ID) } - if err := repo.Users().Delete(ctx, withPassword.ID); err != nil { + if err := repo.Users().Delete(ctx, storedUser.ID); err != nil { t.Fatalf("Delete: %v", err) } - if deleted, err := repo.Users().GetByID(ctx, withPassword.ID); err != nil || deleted != nil { + if deleted, err := repo.Users().GetByID(ctx, storedUser.ID); err != nil || deleted != nil { t.Fatalf("deleted GetByID = %#v, %v", deleted, err) } } From 19725a796a511ee8c0ba0fcc334cc76f4ae7f722 Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Mon, 3 Aug 2026 13:45:13 +0700 Subject: [PATCH 15/15] fix(gitguardian): use v2 test path exclusions --- .gitguardian.yaml | 13 +++++++++---- .../outbound/postgres/user_integration_test.go | 6 +++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.gitguardian.yaml b/.gitguardian.yaml index bfd7edd..1680b28 100644 --- a/.gitguardian.yaml +++ b/.gitguardian.yaml @@ -1,4 +1,9 @@ -paths-ignore: - - '.*_test\.go$' - - '(^|/)testdata(/|$)' - - '(^|/)fixtures(/|$)' +# Required for ggshield v2 configuration. +version: 2 + +secret: + ignored_paths: + - '**/*_test.go' + - '**/testdata/**' + - '**/fixtures/**' + show_secrets: false diff --git a/internal/adapter/outbound/postgres/user_integration_test.go b/internal/adapter/outbound/postgres/user_integration_test.go index dda1852..c118aa6 100644 --- a/internal/adapter/outbound/postgres/user_integration_test.go +++ b/internal/adapter/outbound/postgres/user_integration_test.go @@ -36,7 +36,7 @@ func TestUserRepositoryCRUD(t *testing.T) { t.Fatalf("GetByEmail with password: %v", err) } if storedUser == nil || storedUser.Password != "seed-value" { - t.Fatalf("GetByEmail with password = %#v", withPassword) + t.Fatalf("GetByEmail with password = %#v", storedUser) } withoutPassword, err := repo.Users().GetByEmail(ctx, email, false) if err != nil { @@ -46,7 +46,7 @@ func TestUserRepositoryCRUD(t *testing.T) { t.Fatalf("GetByEmail without password = %#v", withoutPassword) } - byID, err := repo.Users().GetByID(ctx, withPassword.ID) + byID, err := repo.Users().GetByID(ctx, storedUser.ID) if err != nil || byID == nil || byID.Email != email { t.Fatalf("GetByID = %#v, %v", byID, err) } @@ -80,7 +80,7 @@ func TestUserRepositoryCRUD(t *testing.T) { } } if !found { - t.Fatalf("GetAll did not contain user %d", withPassword.ID) + t.Fatalf("GetAll did not contain user %d", storedUser.ID) } if err := repo.Users().Delete(ctx, storedUser.ID); err != nil {