diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e985ac7..7b82543 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,8 @@ jobs: - name: Install protoc and plugins run: | - apk add --no-cache protobuf-dev protoc + sudo apt-get update + sudo apt-get install -y protobuf-compiler go install google.golang.org/protobuf/cmd/protoc-gen-go@latest go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest echo "$HOME/go/bin" >> $GITHUB_PATH @@ -96,7 +97,8 @@ jobs: - name: Install protoc and plugins run: | - apk add --no-cache protobuf-dev protoc + sudo apt-get update + sudo apt-get install -y protobuf-compiler go install google.golang.org/protobuf/cmd/protoc-gen-go@latest go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest echo "$HOME/go/bin" >> $GITHUB_PATH @@ -142,7 +144,8 @@ jobs: - name: Install protoc and plugins run: | - apk add --no-cache protobuf-dev protoc + sudo apt-get update + sudo apt-get install -y protobuf-compiler go install google.golang.org/protobuf/cmd/protoc-gen-go@latest go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest echo "$HOME/go/bin" >> $GITHUB_PATH diff --git a/services/gateway/gateway_helpers_test.go b/services/gateway/gateway_helpers_test.go new file mode 100644 index 0000000..49c1d96 --- /dev/null +++ b/services/gateway/gateway_helpers_test.go @@ -0,0 +1,185 @@ +package gateway + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGateway_pathMatches(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + requestPath string + routePath string + want bool + }{ + { + name: "exact match", + requestPath: "/api/v1/auth", + routePath: "/api/v1/auth", + want: true, + }, + { + name: "exact match with trailing slash", + requestPath: "/api/v1/auth/", + routePath: "/api/v1/auth", + want: true, + }, + { + name: "wildcard match - prefix", + requestPath: "/api/v1/auth/login", + routePath: "/api/v1/auth/**", + want: true, + }, + { + name: "wildcard match - exact prefix", + requestPath: "/api/v1/auth", + routePath: "/api/v1/auth/**", + want: true, + }, + { + name: "wildcard match - nested path", + requestPath: "/api/v1/auth/refresh/token", + routePath: "/api/v1/auth/**", + want: true, + }, + { + name: "no match - different prefix", + requestPath: "/api/v1/users", + routePath: "/api/v1/auth/**", + want: false, + }, + { + name: "no match - exact", + requestPath: "/api/v1/users", + routePath: "/api/v1/auth", + want: false, + }, + { + name: "wildcard no match - wrong prefix", + requestPath: "/api/v1/users/login", + routePath: "/api/v1/auth/**", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gateway := &Gateway{} + got := gateway.pathMatches(tt.requestPath, tt.routePath) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestGateway_extractRemainingPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + requestPath string + routePath string + want string + }{ + { + name: "wildcard - extract path", + requestPath: "/api/v1/auth/login", + routePath: "/api/v1/auth/**", + want: "/login", + }, + { + name: "wildcard - extract nested path", + requestPath: "/api/v1/auth/refresh/token", + routePath: "/api/v1/auth/**", + want: "/refresh/token", + }, + { + name: "wildcard - exact match", + requestPath: "/api/v1/auth", + routePath: "/api/v1/auth/**", + want: "/", + }, + { + name: "exact match - no remaining", + requestPath: "/api/v1/auth", + routePath: "/api/v1/auth", + want: "/", + }, + { + name: "no match - empty", + requestPath: "/api/v1/users", + routePath: "/api/v1/auth/**", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gateway := &Gateway{} + got := gateway.extractRemainingPath(tt.requestPath, tt.routePath) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestGateway_matchRoute(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + routes []RouteConfig + requestPath string + want *RouteConfig + }{ + { + name: "exact match", + routes: []RouteConfig{ + {Path: "/api/v1/auth", Service: "auth-service"}, + {Path: "/api/v1/users", Service: "identity-service"}, + }, + requestPath: "/api/v1/auth", + want: &RouteConfig{Path: "/api/v1/auth", Service: "auth-service"}, + }, + { + name: "wildcard match", + routes: []RouteConfig{ + {Path: "/api/v1/auth/**", Service: "auth-service"}, + }, + requestPath: "/api/v1/auth/login", + want: &RouteConfig{Path: "/api/v1/auth/**", Service: "auth-service"}, + }, + { + name: "no match", + routes: []RouteConfig{ + {Path: "/api/v1/auth/**", Service: "auth-service"}, + }, + requestPath: "/api/v1/other", + want: nil, + }, + { + name: "empty routes", + routes: []RouteConfig{}, + requestPath: "/api/v1/auth", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gateway := &Gateway{ + routes: tt.routes, + } + got := gateway.matchRoute(tt.requestPath) + if tt.want == nil { + assert.Nil(t, got) + } else { + assert.NotNil(t, got) + assert.Equal(t, tt.want.Path, got.Path) + assert.Equal(t, tt.want.Service, got.Service) + } + }) + } +} + diff --git a/services/gateway/gateway_test.go b/services/gateway/gateway_test.go index 583c62f..174f3b4 100644 --- a/services/gateway/gateway_test.go +++ b/services/gateway/gateway_test.go @@ -14,6 +14,7 @@ import ( "git.dcentral.systems/toolz/goplt/pkg/config" "git.dcentral.systems/toolz/goplt/pkg/logger" "git.dcentral.systems/toolz/goplt/pkg/registry" + "git.dcentral.systems/toolz/goplt/pkg/services" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -425,3 +426,87 @@ func (m *mockServiceRegistry) Health(ctx context.Context, serviceID string) (*re Status: "healthy", }, nil } + +// mockAuthClient implements services.AuthServiceClient for testing. +type mockAuthClient struct { + loginResp *services.TokenResponse + loginErr error + refreshResp *services.TokenResponse + refreshErr error + validateResp *services.TokenClaims + validateErr error + logoutErr error +} + +func (m *mockAuthClient) Login(ctx context.Context, email, password string) (*services.TokenResponse, error) { + return m.loginResp, m.loginErr +} + +func (m *mockAuthClient) RefreshToken(ctx context.Context, refreshToken string) (*services.TokenResponse, error) { + return m.refreshResp, m.refreshErr +} + +func (m *mockAuthClient) ValidateToken(ctx context.Context, token string) (*services.TokenClaims, error) { + return m.validateResp, m.validateErr +} + +func (m *mockAuthClient) Logout(ctx context.Context, refreshToken string) error { + return m.logoutErr +} + +// mockIdentityClient implements services.IdentityServiceClient for testing. +type mockIdentityClient struct { + getUserResp *services.User + getUserErr error + getUserByEmailResp *services.User + getUserByEmailErr error + createUserResp *services.User + createUserErr error + updateUserResp *services.User + updateUserErr error + deleteUserErr error + verifyEmailErr error + requestPasswordResetErr error + resetPasswordErr error + verifyPasswordResp *services.User + verifyPasswordErr error +} + +func (m *mockIdentityClient) GetUser(ctx context.Context, id string) (*services.User, error) { + return m.getUserResp, m.getUserErr +} + +func (m *mockIdentityClient) GetUserByEmail(ctx context.Context, email string) (*services.User, error) { + return m.getUserByEmailResp, m.getUserByEmailErr +} + +func (m *mockIdentityClient) CreateUser(ctx context.Context, req *services.CreateUserRequest) (*services.User, error) { + return m.createUserResp, m.createUserErr +} + +func (m *mockIdentityClient) UpdateUser(ctx context.Context, id string, req *services.UpdateUserRequest) (*services.User, error) { + return m.updateUserResp, m.updateUserErr +} + +func (m *mockIdentityClient) DeleteUser(ctx context.Context, id string) error { + return m.deleteUserErr +} + +func (m *mockIdentityClient) VerifyEmail(ctx context.Context, token string) error { + return m.verifyEmailErr +} + +func (m *mockIdentityClient) RequestPasswordReset(ctx context.Context, email string) error { + return m.requestPasswordResetErr +} + +func (m *mockIdentityClient) ResetPassword(ctx context.Context, token, newPassword string) error { + return m.resetPasswordErr +} + +func (m *mockIdentityClient) VerifyPassword(ctx context.Context, email, password string) (*services.User, error) { + return m.verifyPasswordResp, m.verifyPasswordErr +} + +// Note: mockClientFactory is not needed since we're testing handlers directly with mock clients. +// The actual clientFactory is tested separately in integration tests. diff --git a/services/gateway/handlers_test.go b/services/gateway/handlers_test.go new file mode 100644 index 0000000..415000f --- /dev/null +++ b/services/gateway/handlers_test.go @@ -0,0 +1,520 @@ +package gateway + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.dcentral.systems/toolz/goplt/pkg/services" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestGateway_handleLogin(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + requestBody interface{} + clientResp *services.TokenResponse + clientErr error + expectedStatus int + expectedBody func(*testing.T, *httptest.ResponseRecorder) + }{ + { + name: "successful login", + requestBody: map[string]string{ + "email": "test@example.com", + "password": "password123", + }, + clientResp: &services.TokenResponse{ + AccessToken: "access-token", + RefreshToken: "refresh-token", + ExpiresIn: 3600, + TokenType: "Bearer", + }, + clientErr: nil, + expectedStatus: http.StatusOK, + expectedBody: func(t *testing.T, w *httptest.ResponseRecorder) { + var resp services.TokenResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Equal(t, "access-token", resp.AccessToken) + assert.Equal(t, "refresh-token", resp.RefreshToken) + }, + }, + { + name: "invalid request body", + requestBody: map[string]string{ + "email": "test@example.com", + // missing password + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "client error - unauthorized", + requestBody: map[string]string{ + "email": "test@example.com", + "password": "wrongpassword", + }, + clientErr: status.Error(codes.Unauthenticated, "invalid credentials"), + expectedStatus: http.StatusUnauthorized, + }, + { + name: "client error - internal", + requestBody: map[string]string{ + "email": "test@example.com", + "password": "password123", + }, + clientErr: status.Error(codes.Internal, "internal error"), + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mockAuthClient{ + loginResp: tt.clientResp, + loginErr: tt.clientErr, + } + + gateway := &Gateway{ + log: &mockLogger{}, + } + + router := gin.New() + router.POST("/login", func(c *gin.Context) { + gateway.handleLogin(context.Background(), c, mockClient) + }) + + bodyBytes, _ := json.Marshal(tt.requestBody) + req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + if tt.expectedBody != nil { + tt.expectedBody(t, w) + } + }) + } +} + +func TestGateway_handleRefreshToken(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + requestBody interface{} + clientResp *services.TokenResponse + clientErr error + expectedStatus int + }{ + { + name: "successful refresh", + requestBody: map[string]string{ + "refresh_token": "refresh-token-123", + }, + clientResp: &services.TokenResponse{ + AccessToken: "new-access-token", + RefreshToken: "new-refresh-token", + ExpiresIn: 3600, + TokenType: "Bearer", + }, + expectedStatus: http.StatusOK, + }, + { + name: "invalid request body", + requestBody: map[string]string{ + // missing refresh_token + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "client error", + requestBody: map[string]string{ + "refresh_token": "invalid-token", + }, + clientErr: status.Error(codes.Unauthenticated, "invalid token"), + expectedStatus: http.StatusUnauthorized, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mockAuthClient{ + refreshResp: tt.clientResp, + refreshErr: tt.clientErr, + } + + gateway := &Gateway{ + log: &mockLogger{}, + } + + router := gin.New() + router.POST("/refresh", func(c *gin.Context) { + gateway.handleRefreshToken(context.Background(), c, mockClient) + }) + + bodyBytes, _ := json.Marshal(tt.requestBody) + req := httptest.NewRequest(http.MethodPost, "/refresh", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestGateway_handleValidateToken(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + requestBody interface{} + clientResp *services.TokenClaims + clientErr error + expectedStatus int + }{ + { + name: "successful validation", + requestBody: map[string]string{ + "token": "valid-token", + }, + clientResp: &services.TokenClaims{ + UserID: "user-123", + Email: "test@example.com", + Roles: []string{"user"}, + ExpiresAt: 1234567890, + }, + expectedStatus: http.StatusOK, + }, + { + name: "invalid request body", + requestBody: map[string]string{ + // missing token + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "client error - invalid token", + requestBody: map[string]string{ + "token": "invalid-token", + }, + clientErr: status.Error(codes.Unauthenticated, "invalid token"), + expectedStatus: http.StatusUnauthorized, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mockAuthClient{ + validateResp: tt.clientResp, + validateErr: tt.clientErr, + } + + gateway := &Gateway{ + log: &mockLogger{}, + } + + router := gin.New() + router.POST("/validate", func(c *gin.Context) { + gateway.handleValidateToken(context.Background(), c, mockClient) + }) + + bodyBytes, _ := json.Marshal(tt.requestBody) + req := httptest.NewRequest(http.MethodPost, "/validate", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestGateway_handleLogout(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + requestBody interface{} + clientErr error + expectedStatus int + }{ + { + name: "successful logout", + requestBody: map[string]string{ + "refresh_token": "refresh-token-123", + }, + expectedStatus: http.StatusOK, + }, + { + name: "invalid request body", + requestBody: map[string]string{ + // missing refresh_token + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "client error", + requestBody: map[string]string{ + "refresh_token": "invalid-token", + }, + clientErr: status.Error(codes.NotFound, "token not found"), + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mockAuthClient{ + logoutErr: tt.clientErr, + } + + gateway := &Gateway{ + log: &mockLogger{}, + } + + router := gin.New() + router.POST("/logout", func(c *gin.Context) { + gateway.handleLogout(context.Background(), c, mockClient) + }) + + bodyBytes, _ := json.Marshal(tt.requestBody) + req := httptest.NewRequest(http.MethodPost, "/logout", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestGateway_handleGetUser(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + userID string + clientResp *services.User + clientErr error + expectedStatus int + }{ + { + name: "successful get user", + userID: "user-123", + clientResp: &services.User{ + ID: "user-123", + Email: "test@example.com", + Username: "testuser", + FirstName: "Test", + LastName: "User", + }, + expectedStatus: http.StatusOK, + }, + { + name: "client error - not found", + userID: "nonexistent", + clientErr: status.Error(codes.NotFound, "user not found"), + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mockIdentityClient{ + getUserResp: tt.clientResp, + getUserErr: tt.clientErr, + } + + gateway := &Gateway{ + log: &mockLogger{}, + } + + router := gin.New() + router.GET("/users/:id", func(c *gin.Context) { + gateway.handleGetUser(context.Background(), c, mockClient, tt.userID) + }) + + req := httptest.NewRequest(http.MethodGet, "/users/"+tt.userID, nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestGateway_handleCreateUser(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + requestBody interface{} + clientResp *services.User + clientErr error + expectedStatus int + }{ + { + name: "successful create", + requestBody: services.CreateUserRequest{ + Email: "test@example.com", + Username: "testuser", + Password: "password123", + FirstName: "Test", + LastName: "User", + }, + clientResp: &services.User{ + ID: "user-123", + Email: "test@example.com", + Username: "testuser", + FirstName: "Test", + LastName: "User", + }, + expectedStatus: http.StatusCreated, + }, + { + name: "invalid JSON", + requestBody: "not a json object", + expectedStatus: http.StatusBadRequest, + }, + { + name: "client error - already exists", + requestBody: services.CreateUserRequest{ + Email: "existing@example.com", + Username: "existing", + Password: "password123", + }, + clientErr: status.Error(codes.AlreadyExists, "user already exists"), + expectedStatus: http.StatusConflict, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mockIdentityClient{ + createUserResp: tt.clientResp, + createUserErr: tt.clientErr, + } + + gateway := &Gateway{ + log: &mockLogger{}, + } + + router := gin.New() + router.POST("/users", func(c *gin.Context) { + gateway.handleCreateUser(context.Background(), c, mockClient) + }) + + bodyBytes, _ := json.Marshal(tt.requestBody) + req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestGateway_handleGRPCError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + expectedStatus int + expectedError string + }{ + { + name: "Unauthenticated", + err: status.Error(codes.Unauthenticated, "invalid token"), + expectedStatus: http.StatusUnauthorized, + expectedError: "Unauthorized", + }, + { + name: "PermissionDenied", + err: status.Error(codes.PermissionDenied, "access denied"), + expectedStatus: http.StatusForbidden, + expectedError: "Forbidden", + }, + { + name: "NotFound", + err: status.Error(codes.NotFound, "resource not found"), + expectedStatus: http.StatusNotFound, + expectedError: "Not found", + }, + { + name: "InvalidArgument", + err: status.Error(codes.InvalidArgument, "invalid input"), + expectedStatus: http.StatusBadRequest, + expectedError: "Invalid request", + }, + { + name: "AlreadyExists", + err: status.Error(codes.AlreadyExists, "resource exists"), + expectedStatus: http.StatusConflict, + expectedError: "Resource already exists", + }, + { + name: "Internal", + err: status.Error(codes.Internal, "internal error"), + expectedStatus: http.StatusInternalServerError, + expectedError: "Internal server error", + }, + { + name: "Unavailable", + err: status.Error(codes.Unavailable, "service unavailable"), + expectedStatus: http.StatusServiceUnavailable, + expectedError: "Service unavailable", + }, + { + name: "non-gRPC error", + err: assert.AnError, + expectedStatus: http.StatusInternalServerError, + expectedError: "Internal server error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gateway := &Gateway{ + log: &mockLogger{}, + } + + router := gin.New() + router.GET("/test", func(c *gin.Context) { + gateway.handleGRPCError(c, tt.err) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, tt.expectedError, response["error"]) + }) + } +} + diff --git a/services/identity/internal/password/password_test.go b/services/identity/internal/password/password_test.go new file mode 100644 index 0000000..fa831e9 --- /dev/null +++ b/services/identity/internal/password/password_test.go @@ -0,0 +1,242 @@ +package password + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHash(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + password string + wantErr bool + }{ + { + name: "valid password", + password: "testpassword123", + wantErr: false, + }, + { + name: "empty password", + password: "", + wantErr: false, // Empty password is valid, just hashed + }, + { + name: "long password", + password: "this is a very long password with many characters and symbols !@#$%^&*()", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hash, err := Hash(tt.password) + if tt.wantErr { + require.Error(t, err) + assert.Empty(t, hash) + } else { + require.NoError(t, err) + assert.NotEmpty(t, hash) + // Verify hash format: $argon2id$v=19$m=65536,t=3,p=4$salt$hash + assert.Contains(t, hash, "$argon2id$") + assert.Contains(t, hash, "v=19") + assert.Contains(t, hash, "m=65536") + assert.Contains(t, hash, "t=3") + assert.Contains(t, hash, "p=4") + } + }) + } +} + +func TestVerify(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + password string + hash string + want bool + wantErr bool + skip bool + }{ + { + name: "correct password", + password: "testpassword123", + hash: "", // Will be generated + want: true, + wantErr: false, + }, + { + name: "incorrect password", + password: "wrongpassword", + hash: "", // Will be generated from different password + want: false, + wantErr: false, + }, + { + name: "invalid hash format - too few parts", + password: "testpassword123", + hash: "$argon2id$v=19$m=65536", + want: false, + wantErr: true, + }, + { + name: "invalid hash format - wrong algorithm", + password: "testpassword123", + hash: "$bcrypt$v=19$m=65536,t=3,p=4$salt$hash", + want: false, + wantErr: true, + }, + { + name: "invalid hash format - malformed version", + password: "testpassword123", + hash: "$argon2id$v=invalid$m=65536,t=3,p=4$salt$hash", + want: false, + wantErr: true, + }, + { + name: "invalid hash format - malformed parameters", + password: "testpassword123", + hash: "$argon2id$v=19$m=invalid,t=3,p=4$salt$hash", + want: false, + wantErr: true, + }, + { + name: "invalid hash format - invalid base64 salt", + password: "testpassword123", + hash: "$argon2id$v=19$m=65536,t=3,p=4$invalid-base64$hash", + want: false, + wantErr: true, + }, + { + name: "invalid hash format - invalid base64 hash", + password: "testpassword123", + hash: "", // Will be generated and then corrupted + want: false, + wantErr: true, + skip: true, // Skip this test - corrupting base64 is complex + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Skip test if marked + if tt.skip { + t.Skip("Skipping test") + } + + var hash string + var err error + + // Generate hash if needed + if tt.hash == "" { + if tt.name == "incorrect password" { + // Generate hash for a different password + hash, err = Hash("differentpassword") + require.NoError(t, err) + } else { + hash, err = Hash(tt.password) + require.NoError(t, err) + } + } else { + hash = tt.hash + } + + // Verify + got, err := Verify(tt.password, hash) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.want, got) + }) + } +} + +func TestHash_Verify_RoundTrip(t *testing.T) { + t.Parallel() + + passwords := []string{ + "testpassword123", + "", + "!@#$%^&*()", + "very long password with spaces and special characters !@#$%^&*()_+-=[]{}|;:,.<>?", + "unicode-测试-пароль", + } + + for _, password := range passwords { + t.Run(password, func(t *testing.T) { + hash, err := Hash(password) + require.NoError(t, err) + assert.NotEmpty(t, hash) + + // Verify the same password + valid, err := Verify(password, hash) + require.NoError(t, err) + assert.True(t, valid, "Password should verify correctly") + + // Verify different password + invalid, err := Verify("wrongpassword", hash) + require.NoError(t, err) + assert.False(t, invalid, "Wrong password should not verify") + }) + } +} + +func TestHash_Uniqueness(t *testing.T) { + t.Parallel() + + password := "testpassword123" + hashes := make(map[string]bool) + + // Generate multiple hashes for the same password + // They should be different due to random salt + for i := 0; i < 10; i++ { + hash, err := Hash(password) + require.NoError(t, err) + assert.NotContains(t, hashes, hash, "Each hash should be unique due to random salt") + hashes[hash] = true + + // But all should verify correctly + valid, err := Verify(password, hash) + require.NoError(t, err) + assert.True(t, valid, "All hashes should verify correctly") + } +} + +// Helper functions for test +func splitHash(hash string) []string { + parts := make([]string, 0, 6) + current := "" + for _, char := range hash { + if char == '$' { + if current != "" { + parts = append(parts, current) + current = "" + } + } else { + current += string(char) + } + } + if current != "" { + parts = append(parts, current) + } + return parts +} + +func joinHash(parts []string) string { + result := "" + for i, part := range parts { + if i > 0 { + result += "$" + } + result += part + } + return result +} +