diff --git a/.gitignore b/.gitignore index daaa36c..087f175 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ # Go build artifacts /bin/ /out/ +/dist/ # Dependency directories (vendor/) vendor/ @@ -17,7 +18,17 @@ vendor/ # Logs and temporary files *.log *.tmp -*.out + +# Test coverage files +coverage.out +coverage.html +*.coverprofile + +# Test reports directory +/reports/ + +# Security scan reports +gosec-report.sarif # OS-specific files .DS_Store diff --git a/.ignorecoverunit b/.ignorecoverunit new file mode 100644 index 0000000..36f7409 --- /dev/null +++ b/.ignorecoverunit @@ -0,0 +1,6 @@ +# Coverage exclusion patterns for unit tests +# Lines starting with # are comments +# Use * as wildcard for path matching + +# Auto-generated mocks +*_mock.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6f018..fb3124f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,60 @@ +## [2.5.0-beta.10](https://github.com/LerianStudio/lib-auth/compare/v2.5.0-beta.9...v2.5.0-beta.10) (2026-03-21) + +## [2.5.0-beta.9](https://github.com/LerianStudio/lib-auth/compare/v2.5.0-beta.8...v2.5.0-beta.9) (2026-03-20) + + +### Bug Fixes + +* **auth/middleware:** handle numeric code field in auth error response ([165db5f](https://github.com/LerianStudio/lib-auth/commit/165db5fdd324bb56304edd96b7e4d4579e351ade)) + +## [2.5.0-beta.8](https://github.com/LerianStudio/lib-auth/compare/v2.5.0-beta.7...v2.5.0-beta.8) (2026-03-17) + + +### Bug Fixes + +* **auth/middleware:** return 401 instead of 500 for malformed tokens ([4f51877](https://github.com/LerianStudio/lib-auth/commit/4f51877ef95b4db01304cb9df5ba3a38919e55ff)) + +## [2.5.0-beta.7](https://github.com/LerianStudio/lib-auth/compare/v2.5.0-beta.6...v2.5.0-beta.7) (2026-03-10) + + +### Features + +* **middleware:** migrate to lib-commons v4 API ([2e12d9b](https://github.com/LerianStudio/lib-auth/commit/2e12d9b1598eaafb27b1b427d8006a24eeea18ca)) + + +### Bug Fixes + +* **auth/middleware:** prevent panic on logger initialization failure ([6ba51e5](https://github.com/LerianStudio/lib-auth/commit/6ba51e5df9cbbdf62fc906c1837d6de41641946a)) + +## [2.5.0-beta.6](https://github.com/LerianStudio/lib-auth/compare/v2.5.0-beta.5...v2.5.0-beta.6) (2026-02-27) + + +### Features + +* make gRPC interceptor tenant-aware with streaming support ([f4dae96](https://github.com/LerianStudio/lib-auth/commit/f4dae96c438554ca65c8fff5336c45410cb9d67d)) + + +### Bug Fixes + +* replace deprecated commons API calls ([805aadb](https://github.com/LerianStudio/lib-auth/commit/805aadb8ff2f1a6a8547733eeaf2618220511ee7)) + +## [2.5.0-beta.5](https://github.com/LerianStudio/lib-auth/compare/v2.5.0-beta.4...v2.5.0-beta.5) (2026-02-20) + +## [2.5.0-beta.4](https://github.com/LerianStudio/lib-auth/compare/v2.5.0-beta.3...v2.5.0-beta.4) (2026-02-20) + +## [2.5.0-beta.3](https://github.com/LerianStudio/lib-auth/compare/v2.5.0-beta.2...v2.5.0-beta.3) (2026-02-20) + +## [2.5.0-beta.2](https://github.com/LerianStudio/lib-auth/compare/v2.5.0-beta.1...v2.5.0-beta.2) (2026-02-20) + +## [2.5.0-beta.1](https://github.com/LerianStudio/lib-auth/compare/v2.4.1-beta.1...v2.5.0-beta.1) (2026-02-09) + + +### Bug Fixes + +* **tests:** use safe find -exec instead of xargs pipeline :bug: ([da45b1a](https://github.com/LerianStudio/lib-auth/commit/da45b1ad9464aaf7925d932cd5af6b50a58562ea)) + +## [2.4.1-beta.1](https://github.com/LerianStudio/lib-auth/compare/v2.4.0...v2.4.1-beta.1) (2026-02-03) + ## [2.4.0](https://github.com/LerianStudio/lib-auth/compare/v2.3.0...v2.4.0) (2026-01-27) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d317b57 --- /dev/null +++ b/LICENSE @@ -0,0 +1,93 @@ +Elastic License 2.0 + +URL: https://www.elastic.co/licensing/elastic-license + +## Acceptance + +By using the software, you agree to all of the terms and conditions below. + +## Copyright License + +The licensor grants you a non-exclusive, royalty-free, worldwide, +non-sublicensable, non-transferable license to use, copy, distribute, make +available, and prepare derivative works of the software, in each case subject to +the limitations and conditions below. + +## Limitations + +You may not provide the software to third parties as a hosted or managed +service, where the service provides users with access to any substantial set of +the features or functionality of the software. + +You may not move, change, disable, or circumvent the license key functionality +in the software, and you may not remove or obscure any functionality in the +software that is protected by the license key. + +You may not alter, remove, or obscure any licensing, copyright, or other notices +of the licensor in the software. Any use of the licensor's trademarks is subject +to applicable law. + +## Patents + +The licensor grants you a license, under any patent claims the licensor can +license, or becomes able to license, to make, have made, use, sell, offer for +sale, import and have imported the software, in each case subject to the +limitations and conditions in this license. This license does not cover any +patent claims that you cause to be infringed by modifications or additions to +the software. If you or your company make any written claim that the software +infringes or contributes to infringement of any patent, your patent license for +the software granted under these terms ends immediately. If your company makes +such a claim, your patent license ends immediately for work on behalf of your +company. + +## Notices + +You must ensure that anyone who gets a copy of any part of the software from you +also gets a copy of these terms. + +If you modify the software, you must include in any modified copies of the +software prominent notices stating that you have modified the software. + +## No Other Rights + +These terms do not imply any licenses other than those expressly granted in +these terms. + +## Termination + +If you use the software in violation of these terms, such use is not licensed, +and your licenses will automatically terminate. If the licensor provides you +with a notice of your violation, and you cease all violation of this license no +later than 30 days after you receive that notice, your licenses will be +reinstated retroactively. However, if you violate these terms after such +reinstatement, any additional violation of these terms will cause your licenses +to terminate automatically and permanently. + +## No Liability + +*As far as the law allows, the software comes as is, without any warranty or +condition, and the licensor will not be liable to you for any damages arising +out of these terms or the use or nature of the software, under any kind of +legal claim.* + +## Definitions + +The **licensor** is the entity offering these terms, and the **software** is the +software the licensor makes available under these terms, including any portion +of it. + +**you** refers to the individual or entity agreeing to these terms. + +**your company** is any legal entity, sole proprietorship, or other kind of +organization that you work for, plus all organizations that have control over, +are under the control of, or are under common control with that +organization. **control** means ownership of substantially all the assets of an +entity, or the power to direct its management and policies by vote, contract, or +otherwise. Control can be direct or indirect. + +**your licenses** are all the licenses granted to you for the software under +these terms. + +**use** means anything you do with the software requiring one of your licenses. + +**trademark** means trademarks, service marks, and similar rights. diff --git a/Makefile b/Makefile index c57bb77..9db4a0b 100644 --- a/Makefile +++ b/Makefile @@ -1,24 +1,274 @@ -.PHONY: help lint sec +# Define the root directory of the project +LIB_AUTH := $(shell pwd) +#------------------------------------------------------- +# Color definitions (ANSI codes) +#------------------------------------------------------- +GREEN := \033[32m +RED := \033[31m +BOLD := \033[1m +NC := \033[0m + +#------------------------------------------------------- +# Utility functions +#------------------------------------------------------- + +# Check if a command exists +define check_command + @if ! command -v $(1) >/dev/null 2>&1; then \ + echo "$(RED)$(BOLD)[error]$(NC) $(1) is not installed."; \ + echo "Install it with: $(2)"; \ + exit 1; \ + fi +endef + +# Print section title +define print_title + @echo "" + @echo "------------------------------------------" + @echo " 📝 $(1) " + @echo "------------------------------------------" +endef + +# Include test targets +MK_DIR := $(abspath mk) +include $(MK_DIR)/tests.mk + +#------------------------------------------------------- +# Help Command +#------------------------------------------------------- + +.PHONY: help help: @echo "" - @echo "lib-auth Commands" - @echo " make help - Display this help message" - @echo " make lint - Run golangci-lint over ./... (auto-installs if missing)" - @echo " make sec - Run gosec over ./... (auto-installs if missing)" + @echo "" + @echo "Lib-Auth Project Management Commands" + @echo "" + @echo "" + @echo "Core Commands:" + @echo " make help - Display this help message" + @echo " make test - Run all tests" + @echo " make build - Build all packages" + @echo " make clean - Clean all build artifacts" + @echo "" + @echo "" + @echo "Test Suite Commands:" + @echo " make test-unit - Run unit tests" + @echo " make test-integration - Run integration tests with testcontainers (RUN=, LOW_RESOURCE=1)" + @echo " make test-all - Run all tests (unit + integration)" + @echo "" + @echo "" + @echo "Coverage Commands:" + @echo " make coverage-unit - Run unit tests with coverage report (PKG=./path, uses .ignorecoverunit)" + @echo " make coverage-integration - Run integration tests with coverage report (PKG=./path)" + @echo " make coverage - Run all coverage targets (unit + integration)" + @echo "" + @echo "" + @echo "Test Tooling:" + @echo " make tools - Install test tools (gotestsum)" + @echo "" + @echo "" + @echo "Code Quality Commands:" + @echo " make lint - Run linting on all packages" + @echo " make format - Format code in all packages" + @echo " make tidy - Clean dependencies" + @echo " make check-tests - Verify test coverage for packages" + @echo " make sec - Run security checks using gosec" + @echo " make sec SARIF=1 - Run security checks with SARIF output" + @echo "" + @echo "" + @echo "Git Hook Commands:" + @echo " make setup-git-hooks - Install and configure git hooks" + @echo " make check-hooks - Verify git hooks installation status" + @echo " make check-envs - Check if github hooks are installed and secret env files are not exposed" + @echo "" + @echo "" + @echo "Release Commands:" + @echo " make goreleaser - Create release snapshot with goreleaser" + @echo "" + @echo "" + +#------------------------------------------------------- +# Core Commands +#------------------------------------------------------- + + +.PHONY: build +build: + $(call print_title,Building all packages) + $(call check_command,go,"Install Go from https://golang.org/doc/install") + go build ./... + @echo "$(GREEN)$(BOLD)[ok]$(NC) All packages built successfully$(GREEN) ✔️$(NC)" + +.PHONY: clean +clean: + $(call print_title,Cleaning build artifacts) + @rm -rf ./bin ./dist ./reports coverage.out coverage.html + @go clean -cache -testcache + @echo "$(GREEN)$(BOLD)[ok]$(NC) All build artifacts cleaned$(GREEN) ✔️$(NC)" +#------------------------------------------------------- +# Code Quality Commands +#------------------------------------------------------- + +.PHONY: lint lint: - @if ! command -v golangci-lint >/dev/null 2>&1; then \ - echo "Installing golangci-lint..."; \ - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; \ + $(call print_title,Running linters on all packages) + $(call check_command,golangci-lint,"go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest") + @out=$$(golangci-lint run --fix ./... 2>&1); \ + out_err=$$?; \ + echo "$$out"; \ + if [ $$out_err -ne 0 ]; then \ + echo -e "\n$(BOLD)$(RED)An error has occurred during the lint process: \n $$out\n"; \ + exit 1; \ + fi + @echo "$(GREEN)$(BOLD)[ok]$(NC) Lint checks passed successfully$(GREEN) ✔️$(NC)" + +.PHONY: format +format: + $(call print_title,Formatting code in all packages) + $(call check_command,gofmt,"Install Go from https://golang.org/doc/install") + @gofmt -w ./ + @if command -v goimports >/dev/null 2>&1; then \ + goimports -w .; \ + else \ + echo "goimports not found, skipping import organization"; \ + echo "Install with: go install golang.org/x/tools/cmd/goimports@latest"; \ + fi + @echo "$(GREEN)$(BOLD)[ok]$(NC) All go files formatted$(GREEN) ✔️$(NC)" + +.PHONY: check-tests +check-tests: + $(call print_title,Verifying test coverage for packages) + @if [ -f "./scripts/check-tests.sh" ]; then \ + sh ./scripts/check-tests.sh; \ + else \ + echo "Running basic test coverage check..."; \ + go test -cover ./...; \ fi - golangci-lint run ./... + @echo "$(GREEN)$(BOLD)[ok]$(NC) Test coverage verification completed$(GREEN) ✔️$(NC)" +#------------------------------------------------------- +# Git Hook Commands +#------------------------------------------------------- + +.PHONY: setup-git-hooks +setup-git-hooks: + $(call print_title,Installing and configuring git hooks) + @if [ -d ".githooks" ]; then \ + for hook in .githooks/*; do \ + if [ -f "$$hook" ]; then \ + hook_name=$$(basename $$hook); \ + cp "$$hook" ".git/hooks/$$hook_name"; \ + chmod +x ".git/hooks/$$hook_name"; \ + echo "Installed $$hook_name"; \ + fi; \ + done; \ + else \ + echo "No .githooks directory found"; \ + fi + @echo "$(GREEN)$(BOLD)[ok]$(NC) All hooks installed and updated$(GREEN) ✔️$(NC)" + +.PHONY: check-hooks +check-hooks: + $(call print_title,Verifying git hooks installation status) + @err=0; \ + if [ -d ".githooks" ]; then \ + for hook in .githooks/*; do \ + if [ -f "$$hook" ]; then \ + hook_name=$$(basename $$hook); \ + f=".githooks/$$hook_name"; \ + FILE2=.git/hooks/$$hook_name; \ + if [ -f "$$FILE2" ]; then \ + if cmp -s "$$hook" "$$FILE2"; then \ + echo "$(GREEN)$(BOLD)[ok]$(NC) Hook file $$f installed and updated$(GREEN) ✔️$(NC)"; \ + else \ + echo "$(RED)Hook file $$f installed but out-of-date [OUT-OF-DATE] ✗$(NC)"; \ + err=1; \ + fi; \ + else \ + echo "$(RED)Hook file $$f not installed [NOT INSTALLED] ✗$(NC)"; \ + err=1; \ + fi; \ + fi; \ + done; \ + else \ + echo "No .githooks directory found"; \ + fi; \ + if [ $$err -ne 0 ]; then \ + echo ""; \ + echo "Run $(BOLD)make setup-git-hooks$(NC) to setup your development environment, then try again."; \ + echo ""; \ + exit 1; \ + else \ + echo "$(GREEN)$(BOLD)[ok]$(NC) All hooks are properly installed$(GREEN) ✔️$(NC)"; \ + fi + +.PHONY: check-envs +check-envs: + $(call print_title,Checking git hooks and environment files for security issues) + $(MAKE) check-hooks + @echo "Checking for exposed secrets in environment files..." + @if grep -rq "SECRET.*=" --include=".env" .; then \ + echo "$(RED)Warning: Secrets found in environment files. Make sure these are not committed to the repository.$(NC)"; \ + exit 1; \ + else \ + echo "$(GREEN)No exposed secrets found in environment files$(GREEN) ✔️$(NC)"; \ + fi + @echo "$(GREEN)$(BOLD)[ok]$(NC) Environment check completed$(GREEN) ✔️$(NC)" + +#------------------------------------------------------- +# Development Commands +#------------------------------------------------------- + +.PHONY: tidy +tidy: + $(call print_title,Cleaning dependencies) + $(call check_command,go,"Install Go from https://golang.org/doc/install") + go mod tidy + @echo "$(GREEN)$(BOLD)[ok]$(NC) Dependencies cleaned successfully$(GREEN) ✔️$(NC)" + +# SARIF output for GitHub Security tab integration (optional) +# Usage: make sec SARIF=1 +SARIF ?= 0 + +.PHONY: sec sec: + $(call print_title,Running security checks using gosec) @if ! command -v gosec >/dev/null 2>&1; then \ echo "Installing gosec..."; \ go install github.com/securego/gosec/v2/cmd/gosec@latest; \ fi - gosec ./... + @if find . -name "*.go" -type f -not -path './vendor/*' | grep -q .; then \ + echo "Running security checks on all packages..."; \ + if [ "$(SARIF)" = "1" ]; then \ + echo "Generating SARIF output: gosec-report.sarif"; \ + if gosec -fmt sarif -out gosec-report.sarif ./...; then \ + echo "$(GREEN)$(BOLD)[ok]$(NC) SARIF report generated: gosec-report.sarif$(GREEN) ✔️$(NC)"; \ + else \ + echo -e "\n$(BOLD)$(RED)Security issues found by gosec. Please address them before proceeding.$(NC)\n"; \ + echo "SARIF report with details: gosec-report.sarif"; \ + exit 1; \ + fi; \ + else \ + if gosec ./...; then \ + echo "$(GREEN)$(BOLD)[ok]$(NC) Security checks completed$(GREEN) ✔️$(NC)"; \ + else \ + echo -e "\n$(BOLD)$(RED)Security issues found by gosec. Please address them before proceeding.$(NC)\n"; \ + exit 1; \ + fi; \ + fi; \ + else \ + echo "No Go files found, skipping security checks"; \ + fi +#------------------------------------------------------- +# Release Commands +#------------------------------------------------------- +.PHONY: goreleaser +goreleaser: + $(call print_title,Creating release snapshot with goreleaser) + $(call check_command,goreleaser,"go install github.com/goreleaser/goreleaser@latest") + goreleaser release --snapshot --skip-publish --clean + @echo "$(GREEN)$(BOLD)[ok]$(NC) Release snapshot created successfully$(GREEN) ✔️$(NC)" diff --git a/auth/middleware/middleware.go b/auth/middleware/middleware.go index ba3524e..758f06c 100644 --- a/auth/middleware/middleware.go +++ b/auth/middleware/middleware.go @@ -7,16 +7,19 @@ import ( "errors" "fmt" "io" + stdlog "log" "net/http" + "os" + "strings" "time" - "github.com/LerianStudio/lib-commons/v2/commons/log" - "github.com/LerianStudio/lib-commons/v2/commons/opentelemetry" - "github.com/LerianStudio/lib-commons/v2/commons/zap" + "github.com/LerianStudio/lib-commons/v4/commons/log" + "github.com/LerianStudio/lib-commons/v4/commons/opentelemetry" + "github.com/LerianStudio/lib-commons/v4/commons/zap" "go.opentelemetry.io/otel/attribute" - "github.com/LerianStudio/lib-commons/v2/commons" - libHTTP "github.com/LerianStudio/lib-commons/v2/commons/net/http" + "github.com/LerianStudio/lib-commons/v4/commons" + libHTTP "github.com/LerianStudio/lib-commons/v4/commons/net/http" "github.com/gofiber/fiber/v2" jwt "github.com/golang-jwt/jwt/v5" ) @@ -46,16 +49,104 @@ const ( pluginName string = "plugin-auth" ) +// unmarshalErrorResponse unmarshals a JSON response body into commons.Response, +// tolerating a numeric "code" field (the auth service may return code as a number). +func unmarshalErrorResponse(body []byte) (commons.Response, error) { + var raw struct { + EntityType string `json:"entityType,omitempty"` + Title string `json:"title,omitempty"` + Message string `json:"message,omitempty"` + Code json.RawMessage `json:"code,omitempty"` + } + + if err := json.Unmarshal(body, &raw); err != nil { + return commons.Response{}, err + } + + resp := commons.Response{ + EntityType: raw.EntityType, + Title: raw.Title, + Message: raw.Message, + } + + if len(raw.Code) > 0 { + var code string + if err := json.Unmarshal(raw.Code, &code); err == nil { + resp.Code = code + } else { + // Numeric code — use raw representation (e.g. "401") + resp.Code = string(raw.Code) + } + } + + return resp, nil +} + +func logErrorf(ctx context.Context, logger log.Logger, format string, args ...any) { + if logger == nil { + return + } + + logger.Log(ctx, log.LevelError, fmt.Sprintf(format, args...)) +} + +func logInfof(ctx context.Context, logger log.Logger, format string, args ...any) { + if logger == nil { + return + } + + logger.Log(ctx, log.LevelInfo, fmt.Sprintf(format, args...)) +} + +func initializeDefaultLogger() (log.Logger, error) { + envName := strings.ToLower(strings.TrimSpace(os.Getenv("ENV_NAME"))) + + environment := zap.EnvironmentLocal + + switch envName { + case string(zap.EnvironmentProduction): + environment = zap.EnvironmentProduction + case string(zap.EnvironmentStaging): + environment = zap.EnvironmentStaging + case string(zap.EnvironmentUAT): + environment = zap.EnvironmentUAT + case string(zap.EnvironmentDevelopment), "dev": + environment = zap.EnvironmentDevelopment + } + + otelLibraryName := strings.TrimSpace(os.Getenv("OTEL_LIBRARY_NAME")) + if otelLibraryName == "" { + otelLibraryName = "lib-auth" + } + + logger, err := zap.New(zap.Config{ + Environment: environment, + OTelLibraryName: otelLibraryName, + }) + if err != nil { + return nil, err + } + + return logger, nil +} + // NewAuthClient creates a new instance of AuthClient. // It checks the health of the authorization service if the client is enabled and the address is provided. // If the service is healthy, it logs a successful connection message; otherwise, it logs the failure reason. func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient { var l log.Logger + var err error + if logger != nil { l = *logger } else { - l = zap.InitializeLogger() + l, err = initializeDefaultLogger() + if err != nil { + stdlog.Printf("failed to initialize logger, using NopLogger: %v", err) + + l = log.NewNop() + } } if !enabled || address == "" { @@ -73,29 +164,29 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient resp, err := client.Get(healthURL) if err != nil { - l.Errorf(failedToConnectMsg, err) + logErrorf(context.Background(), l, failedToConnectMsg, err) return &AuthClient{Address: address, Enabled: enabled, Logger: l} } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - l.Errorf(failedToConnectMsg, resp.Status) + logErrorf(context.Background(), l, failedToConnectMsg, resp.Status) return &AuthClient{Address: address, Enabled: enabled, Logger: l} } body, err := io.ReadAll(resp.Body) if err != nil { - l.Errorf("Failed to read response body: %v\n", err) + logErrorf(context.Background(), l, "Failed to read response body: %v", err) return &AuthClient{Address: address, Enabled: enabled, Logger: l} } if string(body) == "healthy" { - l.Infof("Connected to %s ✅ \n", pluginName) + logInfof(context.Background(), l, "Connected to %s", pluginName) } else { - l.Errorf(failedToConnectMsg, string(body)) + logErrorf(context.Background(), l, failedToConnectMsg, string(body)) } return &AuthClient{ @@ -110,10 +201,9 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient // If the user is authorized, the request is passed to the next handler; otherwise, a 403 Forbidden status is returned. func (auth *AuthClient) Authorize(sub, resource, action string) fiber.Handler { return func(c *fiber.Ctx) error { - ctx := opentelemetry.ExtractHTTPContext(c) + ctx := opentelemetry.ExtractHTTPContext(c.UserContext(), c) - tracer := commons.NewTracerFromContext(ctx) - reqID := commons.NewHeaderIDFromContext(ctx) + _, tracer, reqID, _ := commons.NewTrackingFromContext(ctx) if !auth.Enabled || auth.Address == "" { return c.Next() @@ -143,7 +233,7 @@ func (auth *AuthClient) Authorize(sub, resource, action string) fiber.Handler { span.End() - return c.Status(http.StatusInternalServerError).SendString("Internal Server Error") + return c.Status(statusCode).SendString(http.StatusText(statusCode)) } else if authorized { span.End() @@ -158,8 +248,7 @@ func (auth *AuthClient) Authorize(sub, resource, action string) fiber.Handler { // checkAuthorization sends an authorization request to the external service and returns whether the action is authorized. func (auth *AuthClient) checkAuthorization(ctx context.Context, sub, resource, action, accessToken string) (bool, int, error) { - tracer := commons.NewTracerFromContext(ctx) - reqID := commons.NewHeaderIDFromContext(ctx) + _, tracer, reqID, _ := commons.NewTrackingFromContext(ctx) ctx, span := tracer.Start(ctx, "lib_auth.check_authorization") defer span.End() @@ -172,22 +261,22 @@ func (auth *AuthClient) checkAuthorization(ctx context.Context, sub, resource, a token, _, err := new(jwt.Parser).ParseUnverified(accessToken, jwt.MapClaims{}) if err != nil { - auth.Logger.Errorf("Failed to parse token: %v", err) + logErrorf(ctx, auth.Logger, "Failed to parse token: %v", err) - opentelemetry.HandleSpanError(&span, "Failed to parse token", err) + opentelemetry.HandleSpanError(span, "Failed to parse token", err) - return false, http.StatusInternalServerError, err + return false, http.StatusUnauthorized, err } claims, ok := token.Claims.(jwt.MapClaims) if !ok { - auth.Logger.Errorf("Failed to parse claims: token.Claims is not of type jwt.MapClaims") + logErrorf(ctx, auth.Logger, "Failed to parse claims: token.Claims is not of type jwt.MapClaims") err := errors.New("token claims are not in the expected format") - opentelemetry.HandleSpanError(&span, "Failed to parse claims", err) + opentelemetry.HandleSpanError(span, "Failed to parse claims", err) - return false, http.StatusInternalServerError, err + return false, http.StatusUnauthorized, err } userType, _ := claims["type"].(string) @@ -197,11 +286,11 @@ func (auth *AuthClient) checkAuthorization(ctx context.Context, sub, resource, a } else { owner, _ := claims["owner"].(string) if owner == "" { - auth.Logger.Errorf("Missing owner claim in token") + logErrorf(ctx, auth.Logger, "Missing owner claim in token") err := errors.New("missing owner claim in token") - opentelemetry.HandleSpanError(&span, "Missing owner claim in token", err) + opentelemetry.HandleSpanError(span, "Missing owner claim in token", err) return false, http.StatusUnauthorized, err } @@ -216,41 +305,41 @@ func (auth *AuthClient) checkAuthorization(ctx context.Context, sub, resource, a "action": action, } - err = opentelemetry.SetSpanAttributesFromStruct(&span, "app.request.payload", requestBody) + err = opentelemetry.SetSpanAttributesFromValue(span, "app.request.payload", requestBody, nil) if err != nil { - opentelemetry.HandleSpanError(&span, "Failed to convert request body to JSON string", err) + opentelemetry.HandleSpanError(span, "Failed to convert request body to JSON string", err) return false, http.StatusInternalServerError, err } requestBodyJSON, err := json.Marshal(requestBody) if err != nil { - auth.Logger.Errorf("Failed to marshal request body: %v", err) + logErrorf(ctx, auth.Logger, "Failed to marshal request body: %v", err) - opentelemetry.HandleSpanError(&span, "Failed to marshal request body", err) + opentelemetry.HandleSpanError(span, "Failed to marshal request body", err) return false, http.StatusInternalServerError, err } req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/v1/authorize", auth.Address), bytes.NewBuffer(requestBodyJSON)) if err != nil { - auth.Logger.Errorf("Failed to create request: %v", err) + logErrorf(ctx, auth.Logger, "Failed to create request: %v", err) - opentelemetry.HandleSpanError(&span, "Failed to create request", err) + opentelemetry.HandleSpanError(span, "Failed to create request", err) return false, http.StatusInternalServerError, fmt.Errorf("failed to create request: %w", err) } - opentelemetry.InjectHTTPContext(&req.Header, ctx) + opentelemetry.InjectHTTPContext(ctx, req.Header) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", accessToken) resp, err := client.Do(req) if err != nil { - auth.Logger.Errorf("Failed to make request: %v", err) + logErrorf(ctx, auth.Logger, "Failed to make request: %v", err) - opentelemetry.HandleSpanError(&span, "Failed to make request", err) + opentelemetry.HandleSpanError(span, "Failed to make request", err) return false, http.StatusInternalServerError, fmt.Errorf("failed to make request: %w", err) } @@ -258,35 +347,35 @@ func (auth *AuthClient) checkAuthorization(ctx context.Context, sub, resource, a body, err := io.ReadAll(resp.Body) if err != nil { - auth.Logger.Errorf("Failed to read response body: %v", err) + logErrorf(ctx, auth.Logger, "Failed to read response body: %v", err) - opentelemetry.HandleSpanError(&span, "Failed to read response body", err) + opentelemetry.HandleSpanError(span, "Failed to read response body", err) return false, http.StatusInternalServerError, fmt.Errorf("failed to read response body: %w", err) } - var respError commons.Response - if err := json.Unmarshal(body, &respError); err != nil { - auth.Logger.Errorf("Failed to unmarshal auth error response: %v", err) + respError, err := unmarshalErrorResponse(body) + if err != nil { + logErrorf(ctx, auth.Logger, "Failed to unmarshal auth error response: %v", err) - opentelemetry.HandleSpanError(&span, "Failed to unmarshal auth error response", err) + opentelemetry.HandleSpanError(span, "Failed to unmarshal auth error response", err) return false, http.StatusInternalServerError, fmt.Errorf("failed to unmarshal auth error response: %w", err) } if respError.Code != "" && resp.StatusCode != http.StatusInternalServerError { - auth.Logger.Errorf("Authorization request failed: %s", respError.Message) + logErrorf(ctx, auth.Logger, "Authorization request failed: %s", respError.Message) - opentelemetry.HandleSpanError(&span, "Authorization request failed", respError) + opentelemetry.HandleSpanError(span, "Authorization request failed", respError) return false, resp.StatusCode, respError } var response AuthResponse if err := json.Unmarshal(body, &response); err != nil { - auth.Logger.Errorf("Failed to unmarshal response: %v", err) + logErrorf(ctx, auth.Logger, "Failed to unmarshal response: %v", err) - opentelemetry.HandleSpanError(&span, "Failed to unmarshal response", err) + opentelemetry.HandleSpanError(span, "Failed to unmarshal response", err) return false, http.StatusInternalServerError, fmt.Errorf("failed to unmarshal response: %w", err) } @@ -298,8 +387,7 @@ func (auth *AuthClient) checkAuthorization(ctx context.Context, sub, resource, a // It takes the client ID and client secret as parameters and returns the access token if the request is successful. // If the request fails at any step, an error is returned with a descriptive message. func (auth *AuthClient) GetApplicationToken(ctx context.Context, clientID, clientSecret string) (string, error) { - tracer := commons.NewTracerFromContext(ctx) - reqID := commons.NewHeaderIDFromContext(ctx) + _, tracer, reqID, _ := commons.NewTrackingFromContext(ctx) ctx, span := tracer.Start(ctx, "lib_auth.get_application_token") defer span.End() @@ -320,40 +408,40 @@ func (auth *AuthClient) GetApplicationToken(ctx context.Context, clientID, clien "clientSecret": clientSecret, } - err := opentelemetry.SetSpanAttributesFromStruct(&span, "app.request.payload", requestBody) + err := opentelemetry.SetSpanAttributesFromValue(span, "app.request.payload", requestBody, nil) if err != nil { - opentelemetry.HandleSpanError(&span, "Failed to convert request body to JSON string", err) + opentelemetry.HandleSpanError(span, "Failed to convert request body to JSON string", err) return "", fmt.Errorf("failed to convert request body to JSON string: %w", err) } requestBodyJSON, err := json.Marshal(requestBody) if err != nil { - auth.Logger.Errorf("Failed to marshal request body: %v", err) + logErrorf(ctx, auth.Logger, "Failed to marshal request body: %v", err) - opentelemetry.HandleSpanError(&span, "Failed to marshal request body", err) + opentelemetry.HandleSpanError(span, "Failed to marshal request body", err) return "", fmt.Errorf("failed to marshal request body: %w", err) } req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/v1/login/oauth/access_token", auth.Address), bytes.NewBuffer(requestBodyJSON)) if err != nil { - auth.Logger.Errorf("Failed to create request: %v", err) + logErrorf(ctx, auth.Logger, "Failed to create request: %v", err) - opentelemetry.HandleSpanError(&span, "Failed to create request", err) + opentelemetry.HandleSpanError(span, "Failed to create request", err) return "", fmt.Errorf("failed to create request: %w", err) } - opentelemetry.InjectHTTPContext(&req.Header, ctx) + opentelemetry.InjectHTTPContext(ctx, req.Header) req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { - auth.Logger.Errorf("Failed to make request: %v", err) + logErrorf(ctx, auth.Logger, "Failed to make request: %v", err) - opentelemetry.HandleSpanError(&span, "Failed to make request", err) + opentelemetry.HandleSpanError(span, "Failed to make request", err) return "", fmt.Errorf("failed to make request: %w", err) } @@ -361,35 +449,35 @@ func (auth *AuthClient) GetApplicationToken(ctx context.Context, clientID, clien body, err := io.ReadAll(resp.Body) if err != nil { - auth.Logger.Errorf("Failed to read response body: %v", err) + logErrorf(ctx, auth.Logger, "Failed to read response body: %v", err) - opentelemetry.HandleSpanError(&span, "Failed to read response body", err) + opentelemetry.HandleSpanError(span, "Failed to read response body", err) return "", fmt.Errorf("failed to read response body: %w", err) } - var respError commons.Response - if err := json.Unmarshal(body, &respError); err != nil { - auth.Logger.Errorf("Failed to unmarshal auth error response: %v", err) + respError, err := unmarshalErrorResponse(body) + if err != nil { + logErrorf(ctx, auth.Logger, "Failed to unmarshal auth error response: %v", err) - opentelemetry.HandleSpanError(&span, "Failed to unmarshal auth error response", err) + opentelemetry.HandleSpanError(span, "Failed to unmarshal auth error response", err) return "", fmt.Errorf("failed to unmarshal auth error response: %w", err) } if respError.Code != "" && resp.StatusCode != http.StatusInternalServerError { - auth.Logger.Errorf("Failed to get application token: %s", respError.Message) + logErrorf(ctx, auth.Logger, "Failed to get application token: %s", respError.Message) - opentelemetry.HandleSpanError(&span, "Failed to get application token", respError) + opentelemetry.HandleSpanError(span, "Failed to get application token", respError) return "", respError } var response oauth2Token if err := json.Unmarshal(body, &response); err != nil { - auth.Logger.Errorf("Failed to unmarshal response: %v", err) + logErrorf(ctx, auth.Logger, "Failed to unmarshal response: %v", err) - opentelemetry.HandleSpanError(&span, "Failed to unmarshal response", err) + opentelemetry.HandleSpanError(span, "Failed to unmarshal response", err) return "", fmt.Errorf("failed to unmarshal response: %w", err) } diff --git a/auth/middleware/middlewareGRPC.go b/auth/middleware/middlewareGRPC.go index bd0b91d..87e3482 100644 --- a/auth/middleware/middlewareGRPC.go +++ b/auth/middleware/middlewareGRPC.go @@ -2,12 +2,15 @@ package middleware import ( "context" + "errors" "fmt" "net/http" + "os" "strings" - "github.com/LerianStudio/lib-commons/v2/commons" - "github.com/LerianStudio/lib-commons/v2/commons/opentelemetry" + "github.com/LerianStudio/lib-commons/v4/commons" + "github.com/LerianStudio/lib-commons/v4/commons/opentelemetry" + jwt "github.com/golang-jwt/jwt/v5" "go.opentelemetry.io/otel/attribute" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -47,8 +50,7 @@ func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServer } token, ok := extractTokenFromMD(ctx) - tracer := commons.NewTracerFromContext(ctx) - reqID := commons.NewHeaderIDFromContext(ctx) + _, tracer, reqID, _ := commons.NewTrackingFromContext(ctx) ctx, span := tracer.Start(ctx, "lib_auth.authorize_grpc_unary_policy") defer span.End() @@ -61,7 +63,7 @@ func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServer pol, found := policyForMethod(cfg, info.FullMethod) if !found { - opentelemetry.HandleSpanError(&span, "no policy configured for method", fmt.Errorf("%s", info.FullMethod)) + opentelemetry.HandleSpanError(span, "no policy configured for method", fmt.Errorf("%s", info.FullMethod)) return nil, status.Error(codes.Internal, "internal configuration error") } @@ -73,7 +75,7 @@ func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServer sub, err = cfg.SubResolver(ctx, info.FullMethod, req) if err != nil { - opentelemetry.HandleSpanError(&span, "failed to resolve subject", err) + opentelemetry.HandleSpanError(span, "failed to resolve subject", err) return nil, status.Error(codes.Internal, "internal configuration error") } @@ -84,8 +86,8 @@ func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServer "resource": pol.Resource, "action": pol.Action, } - if err := opentelemetry.SetSpanAttributesFromStruct(&span, "app.request.payload", payload); err != nil { - opentelemetry.HandleSpanError(&span, "failed to set span payload", err) + if err := opentelemetry.SetSpanAttributesFromValue(span, "app.request.payload", payload, nil); err != nil { + opentelemetry.HandleSpanError(span, "failed to set span payload", err) } authorized, httpStatus, err := auth.checkAuthorization(ctx, sub, pol.Resource, pol.Action, token) @@ -97,6 +99,27 @@ func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServer return nil, status.Error(codes.PermissionDenied, "forbidden") } + // Propagate tenant claims if multi-tenant mode is enabled + if os.Getenv("MULTI_TENANT_ENABLED") == "true" { + tenantID, tenantSlug, tOwner, _ := extractTenantClaims(token) + md, _ := metadata.FromIncomingContext(ctx) + md = md.Copy() + + if tenantID != "" { + md.Set("md-tenant-id", tenantID) + } + + if tenantSlug != "" { + md.Set("md-tenant-slug", tenantSlug) + } + + if tOwner != "" { + md.Set("md-tenant-owner", tOwner) + } + + ctx = metadata.NewIncomingContext(ctx, md) + } + return handler(ctx, req) } } @@ -179,3 +202,104 @@ func SubFromMetadata(key string) func(ctx context.Context, fullMethod string, re return vals[0], nil } } + +// extractTenantClaims extracts tenant-related claims from a JWT without signature verification. +// Returns tenantID, tenantSlug, and owner from the token's custom claims. +// Used by gRPC interceptors to propagate tenant context to downstream services. +func extractTenantClaims(tokenString string) (tenantID, tenantSlug, owner string, err error) { + token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{}) + if err != nil { + return "", "", "", err + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return "", "", "", errors.New("invalid token claims") + } + + tenantID, _ = claims["tenantId"].(string) + tenantSlug, _ = claims["tenantSlug"].(string) + owner, _ = claims["owner"].(string) + + return tenantID, tenantSlug, owner, nil +} + +// NewGRPCAuthStreamPolicy authorizes streaming RPCs via per-method Policy. +// Mirrors NewGRPCAuthUnaryPolicy behavior for streaming calls: +// - Resolves Policy by info.FullMethod; falls back to DefaultPolicy. +// - Rejects missing tokens with codes.Unauthenticated. +// - Propagates tenant claims when MULTI_TENANT_ENABLED=true. +func NewGRPCAuthStreamPolicy(auth *AuthClient, cfg PolicyConfig) grpc.StreamServerInterceptor { + return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + if auth == nil || !auth.Enabled || auth.Address == "" { + return handler(srv, ss) + } + + ctx := ss.Context() + token, ok := extractTokenFromMD(ctx) + + if !ok || commons.IsNilOrEmpty(&token) { + return status.Error(codes.Unauthenticated, "missing token") + } + + pol, found := policyForMethod(cfg, info.FullMethod) + if !found { + return status.Error(codes.Internal, "internal configuration error") + } + + var sub string + + if cfg.SubResolver != nil { + var err error + + sub, err = cfg.SubResolver(ctx, info.FullMethod, nil) + if err != nil { + return status.Error(codes.Internal, "internal configuration error") + } + } + + authorized, httpStatus, err := auth.checkAuthorization(ctx, sub, pol.Resource, pol.Action, token) + if err != nil { + return grpcErrorFromHTTP(httpStatus) + } + + if !authorized { + return status.Error(codes.PermissionDenied, "forbidden") + } + + // Propagate tenant claims if multi-tenant mode is enabled + if os.Getenv("MULTI_TENANT_ENABLED") == "true" { + tenantID, tenantSlug, tOwner, _ := extractTenantClaims(token) + md, _ := metadata.FromIncomingContext(ctx) + md = md.Copy() + + if tenantID != "" { + md.Set("md-tenant-id", tenantID) + } + + if tenantSlug != "" { + md.Set("md-tenant-slug", tenantSlug) + } + + if tOwner != "" { + md.Set("md-tenant-owner", tOwner) + } + + ctx = metadata.NewIncomingContext(ctx, md) + ss = &wrappedServerStream{ServerStream: ss, ctx: ctx} + } + + return handler(srv, ss) + } +} + +// wrappedServerStream wraps grpc.ServerStream to override Context(). +type wrappedServerStream struct { + grpc.ServerStream + ctx context.Context +} + +// Context returns the wrapped context. +func (w *wrappedServerStream) Context() context.Context { + return w.ctx +} diff --git a/auth/middleware/middlewareGRPC_test.go b/auth/middleware/middlewareGRPC_test.go new file mode 100644 index 0000000..e092375 --- /dev/null +++ b/auth/middleware/middlewareGRPC_test.go @@ -0,0 +1,927 @@ +package middleware + +import ( + "context" + "net/http" + "testing" + + jwt "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +// --------------------------------------------------------------------------- +// stripBearer +// --------------------------------------------------------------------------- + +func Test_stripBearer(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + { + name: "standard_bearer_prefix", + input: "Bearer token123", + want: "token123", + }, + { + name: "lowercase_bearer_prefix", + input: "bearer token123", + want: "token123", + }, + { + name: "uppercase_bearer_prefix", + input: "BEARER token123", + want: "token123", + }, + { + name: "no_prefix_returns_token_as_is", + input: "token123", + want: "token123", + }, + { + name: "whitespace_around_bearer_and_token", + input: " Bearer token123 ", + want: "token123", + }, + { + name: "empty_string", + input: "", + want: "", + }, + { + // NOTE: "Bearer " is trimmed to "Bearer" (6 chars), which is shorter + // than the 7-char "bearer " prefix check, so stripBearer returns + // the trimmed value as-is. This documents actual behavior. + name: "bearer_prefix_with_no_token_returns_bearer_literal", + input: "Bearer ", + want: "Bearer", + }, + { + // Same trimming behavior as above. + name: "bearer_prefix_only_trailing_spaces_returns_bearer_literal", + input: "Bearer ", + want: "Bearer", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := stripBearer(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +// --------------------------------------------------------------------------- +// policyForMethod +// --------------------------------------------------------------------------- + +func Test_policyForMethod(t *testing.T) { + t.Parallel() + + defaultPol := Policy{Resource: "default-res", Action: "default-act"} + specificPol := Policy{Resource: "users", Action: "read"} + + tests := []struct { + name string + cfg PolicyConfig + fullMethod string + wantPolicy Policy + wantFound bool + }{ + { + name: "method_found_in_method_policies", + cfg: PolicyConfig{ + MethodPolicies: map[string]Policy{ + "/pkg.Service/GetUser": specificPol, + }, + }, + fullMethod: "/pkg.Service/GetUser", + wantPolicy: specificPol, + wantFound: true, + }, + { + name: "method_not_found_falls_back_to_default_policy", + cfg: PolicyConfig{ + MethodPolicies: map[string]Policy{ + "/pkg.Service/GetUser": specificPol, + }, + DefaultPolicy: &defaultPol, + }, + fullMethod: "/pkg.Service/DeleteUser", + wantPolicy: defaultPol, + wantFound: true, + }, + { + name: "method_not_found_no_default_returns_false", + cfg: PolicyConfig{ + MethodPolicies: map[string]Policy{ + "/pkg.Service/GetUser": specificPol, + }, + }, + fullMethod: "/pkg.Service/DeleteUser", + wantPolicy: Policy{}, + wantFound: false, + }, + { + name: "nil_method_policies_with_default_returns_default", + cfg: PolicyConfig{ + MethodPolicies: nil, + DefaultPolicy: &defaultPol, + }, + fullMethod: "/pkg.Service/AnyMethod", + wantPolicy: defaultPol, + wantFound: true, + }, + { + name: "nil_method_policies_no_default_returns_false", + cfg: PolicyConfig{ + MethodPolicies: nil, + DefaultPolicy: nil, + }, + fullMethod: "/pkg.Service/AnyMethod", + wantPolicy: Policy{}, + wantFound: false, + }, + { + name: "empty_method_policies_with_default", + cfg: PolicyConfig{ + MethodPolicies: map[string]Policy{}, + DefaultPolicy: &defaultPol, + }, + fullMethod: "/pkg.Service/AnyMethod", + wantPolicy: defaultPol, + wantFound: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + gotPolicy, gotFound := policyForMethod(tt.cfg, tt.fullMethod) + assert.Equal(t, tt.wantFound, gotFound) + assert.Equal(t, tt.wantPolicy, gotPolicy) + }) + } +} + +// --------------------------------------------------------------------------- +// grpcErrorFromHTTP +// --------------------------------------------------------------------------- + +func Test_grpcErrorFromHTTP(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + httpStatus int + wantCode codes.Code + wantMsg string + }{ + { + name: "401_maps_to_unauthenticated", + httpStatus: http.StatusUnauthorized, + wantCode: codes.Unauthenticated, + wantMsg: "unauthenticated", + }, + { + name: "403_maps_to_permission_denied", + httpStatus: http.StatusForbidden, + wantCode: codes.PermissionDenied, + wantMsg: "forbidden", + }, + { + name: "500_maps_to_internal", + httpStatus: http.StatusInternalServerError, + wantCode: codes.Internal, + wantMsg: "internal error", + }, + { + name: "0_default_maps_to_internal", + httpStatus: 0, + wantCode: codes.Internal, + wantMsg: "internal error", + }, + { + name: "404_unmapped_maps_to_internal", + httpStatus: http.StatusNotFound, + wantCode: codes.Internal, + wantMsg: "internal error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := grpcErrorFromHTTP(tt.httpStatus) + require.Error(t, err) + + st, ok := status.FromError(err) + require.True(t, ok, "expected a gRPC status error") + assert.Equal(t, tt.wantCode, st.Code()) + assert.Equal(t, tt.wantMsg, st.Message()) + }) + } +} + +// --------------------------------------------------------------------------- +// extractTokenFromMD +// --------------------------------------------------------------------------- + +func Test_extractTokenFromMD(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ctx context.Context + wantToken string + wantOK bool + }{ + { + name: "valid_bearer_token_in_metadata", + ctx: metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer token123"), + ), + wantToken: "token123", + wantOK: true, + }, + { + name: "no_metadata_in_context", + ctx: context.Background(), + wantToken: "", + wantOK: false, + }, + { + name: "empty_authorization_value", + ctx: metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("authorization", ""), + ), + wantToken: "", + wantOK: false, + }, + { + // NOTE: "Bearer " trimmed to "Bearer" (6 chars) which is below the + // 7-char prefix check threshold. stripBearer returns "Bearer" as a + // literal token and extractTokenFromMD treats it as non-empty. + name: "authorization_with_bearer_prefix_only_returns_bearer_literal", + ctx: metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "), + ), + wantToken: "Bearer", + wantOK: true, + }, + { + name: "multiple_authorization_values_takes_first", + ctx: metadata.NewIncomingContext( + context.Background(), + metadata.Pairs( + "authorization", "Bearer first-token", + "authorization", "Bearer second-token", + ), + ), + wantToken: "first-token", + wantOK: true, + }, + { + name: "token_without_bearer_prefix", + ctx: metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("authorization", "raw-token-value"), + ), + wantToken: "raw-token-value", + wantOK: true, + }, + { + name: "metadata_present_but_no_authorization_key", + ctx: metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("content-type", "application/json"), + ), + wantToken: "", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + gotToken, gotOK := extractTokenFromMD(tt.ctx) + assert.Equal(t, tt.wantOK, gotOK) + assert.Equal(t, tt.wantToken, gotToken) + }) + } +} + +// --------------------------------------------------------------------------- +// SubFromMetadata +// --------------------------------------------------------------------------- + +func TestSubFromMetadata(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + ctx context.Context + wantSub string + wantErr bool + }{ + { + name: "key_present_in_metadata", + key: "x-tenant-id", + ctx: metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("x-tenant-id", "tenant-abc"), + ), + wantSub: "tenant-abc", + wantErr: false, + }, + { + name: "key_absent_in_metadata", + key: "x-tenant-id", + ctx: metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("other-key", "value"), + ), + wantSub: "", + wantErr: false, + }, + { + name: "no_metadata_in_context", + key: "x-tenant-id", + ctx: context.Background(), + wantSub: "", + wantErr: false, + }, + { + name: "case_insensitive_key_lookup", + key: "X-Tenant-ID", + ctx: metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("x-tenant-id", "tenant-xyz"), + ), + wantSub: "tenant-xyz", + wantErr: false, + }, + { + name: "key_with_leading_trailing_whitespace", + key: " x-tenant-id ", + ctx: metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("x-tenant-id", "tenant-trimmed"), + ), + wantSub: "tenant-trimmed", + wantErr: false, + }, + { + name: "multiple_values_returns_first", + key: "x-scope", + ctx: metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("x-scope", "first", "x-scope", "second"), + ), + wantSub: "first", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + resolver := SubFromMetadata(tt.key) + gotSub, err := resolver(tt.ctx, "/unused.Method", nil) + + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.wantSub, gotSub) + }) + } +} + +// --------------------------------------------------------------------------- +// NewGRPCAuthUnaryPolicy (integration-level) +// --------------------------------------------------------------------------- + +func TestNewGRPCAuthUnaryPolicy(t *testing.T) { + t.Parallel() + + handlerCalled := false + + noopHandler := func(_ context.Context, _ any) (any, error) { + handlerCalled = true + return "ok", nil + } + + dummyInfo := &grpc.UnaryServerInfo{ + FullMethod: "/pkg.Service/DoThing", + } + + t.Run("auth_disabled_passes_through", func(t *testing.T) { + t.Parallel() + + called := false + handler := func(_ context.Context, _ any) (any, error) { + called = true + return "ok", nil + } + + auth := &AuthClient{Address: "http://localhost:9999", Enabled: false} + interceptor := NewGRPCAuthUnaryPolicy(auth, PolicyConfig{}) + + resp, err := interceptor(context.Background(), "req", dummyInfo, handler) + require.NoError(t, err) + assert.Equal(t, "ok", resp) + assert.True(t, called) + }) + + t.Run("auth_nil_passes_through", func(t *testing.T) { + t.Parallel() + + called := false + handler := func(_ context.Context, _ any) (any, error) { + called = true + return "ok", nil + } + + interceptor := NewGRPCAuthUnaryPolicy(nil, PolicyConfig{}) + + resp, err := interceptor(context.Background(), "req", dummyInfo, handler) + require.NoError(t, err) + assert.Equal(t, "ok", resp) + assert.True(t, called) + }) + + t.Run("auth_enabled_but_empty_address_passes_through", func(t *testing.T) { + t.Parallel() + + called := false + handler := func(_ context.Context, _ any) (any, error) { + called = true + return "ok", nil + } + + auth := &AuthClient{Address: "", Enabled: true} + interceptor := NewGRPCAuthUnaryPolicy(auth, PolicyConfig{}) + + resp, err := interceptor(context.Background(), "req", dummyInfo, handler) + require.NoError(t, err) + assert.Equal(t, "ok", resp) + assert.True(t, called) + }) + + t.Run("missing_token_returns_unauthenticated", func(t *testing.T) { + t.Parallel() + + auth := &AuthClient{Address: "http://localhost:9999", Enabled: true} + interceptor := NewGRPCAuthUnaryPolicy(auth, PolicyConfig{}) + + // Context without any metadata -> no token + resp, err := interceptor(context.Background(), "req", dummyInfo, noopHandler) + require.Error(t, err) + assert.Nil(t, resp) + + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unauthenticated, st.Code()) + assert.Contains(t, st.Message(), "missing token") + }) + + t.Run("bearer_prefix_only_passes_token_check_but_fails_policy_lookup", func(t *testing.T) { + t.Parallel() + + // NOTE: "Bearer " is trimmed to "Bearer" (6 chars) by stripBearer, + // which is treated as a non-empty token. The interceptor then proceeds + // to the policy lookup phase, which fails because no policy is + // configured for the method. + auth := &AuthClient{Address: "http://localhost:9999", Enabled: true} + interceptor := NewGRPCAuthUnaryPolicy(auth, PolicyConfig{}) + + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "), + ) + + resp, err := interceptor(ctx, "req", dummyInfo, noopHandler) + require.Error(t, err) + assert.Nil(t, resp) + + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Internal, st.Code()) + assert.Contains(t, st.Message(), "internal configuration error") + }) + + t.Run("no_policy_for_method_and_no_default_returns_internal", func(t *testing.T) { + t.Parallel() + + auth := &AuthClient{Address: "http://localhost:9999", Enabled: true} + cfg := PolicyConfig{ + MethodPolicies: map[string]Policy{ + "/pkg.Service/OtherMethod": {Resource: "other", Action: "read"}, + }, + // No DefaultPolicy + } + interceptor := NewGRPCAuthUnaryPolicy(auth, cfg) + + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer valid-token"), + ) + + resp, err := interceptor(ctx, "req", dummyInfo, noopHandler) + require.Error(t, err) + assert.Nil(t, resp) + + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Internal, st.Code()) + assert.Contains(t, st.Message(), "internal configuration error") + }) + + // Prevent compiler from optimizing away the handlerCalled variable + _ = handlerCalled +} + +// --------------------------------------------------------------------------- +// extractTenantClaims +// --------------------------------------------------------------------------- + +func Test_extractTenantClaims(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tokenString string + wantTenantID string + wantTenantSlug string + wantOwner string + wantErr bool + }{ + { + name: "valid_jwt_with_all_tenant_claims", + tokenString: createTestJWT(jwt.MapClaims{ + "tenantId": "tid-123", + "tenantSlug": "acme-corp", + "owner": "owner-456", + }), + wantTenantID: "tid-123", + wantTenantSlug: "acme-corp", + wantOwner: "owner-456", + wantErr: false, + }, + { + name: "jwt_with_only_owner", + tokenString: createTestJWT(jwt.MapClaims{ + "owner": "owner-only", + }), + wantTenantID: "", + wantTenantSlug: "", + wantOwner: "owner-only", + wantErr: false, + }, + { + name: "jwt_with_only_tenantId", + tokenString: createTestJWT(jwt.MapClaims{ + "tenantId": "tid-only", + }), + wantTenantID: "tid-only", + wantTenantSlug: "", + wantOwner: "", + wantErr: false, + }, + { + name: "invalid_token", + tokenString: "not.a.valid.jwt", + wantTenantID: "", + wantTenantSlug: "", + wantOwner: "", + wantErr: true, + }, + { + name: "empty_token", + tokenString: "", + wantTenantID: "", + wantTenantSlug: "", + wantOwner: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tenantID, tenantSlug, owner, err := extractTenantClaims(tt.tokenString) + + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.wantTenantID, tenantID) + assert.Equal(t, tt.wantTenantSlug, tenantSlug) + assert.Equal(t, tt.wantOwner, owner) + }) + } +} + +// --------------------------------------------------------------------------- +// NewGRPCAuthUnaryPolicy - tenant propagation +// --------------------------------------------------------------------------- + +func TestNewGRPCAuthUnaryPolicy_TenantPropagation(t *testing.T) { + // Cannot use t.Parallel() because subtests use t.Setenv which modifies process env. + + t.Run("multi_tenant_enabled_propagates_tenant_metadata", func(t *testing.T) { + t.Setenv("MULTI_TENANT_ENABLED", "true") + + server := mockAuthServer(t, true, http.StatusOK) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "normal-user", + "owner": "org-owner", + "sub": "user1", + "tenantId": "tid-100", + "tenantSlug": "acme", + }) + + defaultPol := Policy{Resource: "res", Action: "read"} + cfg := PolicyConfig{DefaultPolicy: &defaultPol} + interceptor := NewGRPCAuthUnaryPolicy(auth, cfg) + + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + var capturedCtx context.Context + + handler := func(ctx context.Context, _ any) (any, error) { + capturedCtx = ctx + return "ok", nil + } + + info := &grpc.UnaryServerInfo{FullMethod: "/pkg.Service/DoThing"} + + resp, err := interceptor(ctx, "req", info, handler) + require.NoError(t, err) + assert.Equal(t, "ok", resp) + + // Verify tenant metadata was propagated + md, ok := metadata.FromIncomingContext(capturedCtx) + require.True(t, ok) + assert.Equal(t, []string{"tid-100"}, md.Get("md-tenant-id")) + assert.Equal(t, []string{"acme"}, md.Get("md-tenant-slug")) + assert.Equal(t, []string{"org-owner"}, md.Get("md-tenant-owner")) + }) + + t.Run("multi_tenant_disabled_no_tenant_metadata", func(t *testing.T) { + t.Setenv("MULTI_TENANT_ENABLED", "false") + + server := mockAuthServer(t, true, http.StatusOK) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "normal-user", + "owner": "org-owner", + "sub": "user1", + "tenantId": "tid-100", + "tenantSlug": "acme", + }) + + defaultPol := Policy{Resource: "res", Action: "read"} + cfg := PolicyConfig{DefaultPolicy: &defaultPol} + interceptor := NewGRPCAuthUnaryPolicy(auth, cfg) + + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + var capturedCtx context.Context + + handler := func(ctx context.Context, _ any) (any, error) { + capturedCtx = ctx + return "ok", nil + } + + info := &grpc.UnaryServerInfo{FullMethod: "/pkg.Service/DoThing"} + + resp, err := interceptor(ctx, "req", info, handler) + require.NoError(t, err) + assert.Equal(t, "ok", resp) + + // Verify no tenant metadata was added + md, ok := metadata.FromIncomingContext(capturedCtx) + require.True(t, ok) + assert.Empty(t, md.Get("md-tenant-id")) + assert.Empty(t, md.Get("md-tenant-slug")) + assert.Empty(t, md.Get("md-tenant-owner")) + }) +} + +// --------------------------------------------------------------------------- +// NewGRPCAuthStreamPolicy +// --------------------------------------------------------------------------- + +// fakeServerStream is a minimal grpc.ServerStream for testing. +type fakeServerStream struct { + grpc.ServerStream + ctx context.Context +} + +func (f *fakeServerStream) Context() context.Context { + return f.ctx +} + +func TestNewGRPCAuthStreamPolicy(t *testing.T) { + // Cannot use t.Parallel() because a subtest uses t.Setenv which modifies process env. + + dummyInfo := &grpc.StreamServerInfo{ + FullMethod: "/pkg.Service/StreamThing", + } + + t.Run("auth_disabled_passes_through", func(t *testing.T) { + t.Parallel() + + called := false + + handler := func(_ any, _ grpc.ServerStream) error { + called = true + return nil + } + + auth := &AuthClient{Address: "http://localhost:9999", Enabled: false} + interceptor := NewGRPCAuthStreamPolicy(auth, PolicyConfig{}) + + ss := &fakeServerStream{ctx: context.Background()} + + err := interceptor(nil, ss, dummyInfo, handler) + require.NoError(t, err) + assert.True(t, called) + }) + + t.Run("auth_nil_passes_through", func(t *testing.T) { + t.Parallel() + + called := false + + handler := func(_ any, _ grpc.ServerStream) error { + called = true + return nil + } + + interceptor := NewGRPCAuthStreamPolicy(nil, PolicyConfig{}) + + ss := &fakeServerStream{ctx: context.Background()} + + err := interceptor(nil, ss, dummyInfo, handler) + require.NoError(t, err) + assert.True(t, called) + }) + + t.Run("missing_token_returns_unauthenticated", func(t *testing.T) { + t.Parallel() + + handler := func(_ any, _ grpc.ServerStream) error { + return nil + } + + auth := &AuthClient{Address: "http://localhost:9999", Enabled: true} + interceptor := NewGRPCAuthStreamPolicy(auth, PolicyConfig{}) + + // Context without any metadata -> no token + ss := &fakeServerStream{ctx: context.Background()} + + err := interceptor(nil, ss, dummyInfo, handler) + require.Error(t, err) + + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unauthenticated, st.Code()) + assert.Contains(t, st.Message(), "missing token") + }) + + t.Run("no_policy_for_method_returns_internal", func(t *testing.T) { + t.Parallel() + + handler := func(_ any, _ grpc.ServerStream) error { + return nil + } + + auth := &AuthClient{Address: "http://localhost:9999", Enabled: true} + cfg := PolicyConfig{ + MethodPolicies: map[string]Policy{ + "/pkg.Service/OtherMethod": {Resource: "other", Action: "read"}, + }, + // No DefaultPolicy + } + interceptor := NewGRPCAuthStreamPolicy(auth, cfg) + + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer valid-token"), + ) + ss := &fakeServerStream{ctx: ctx} + + err := interceptor(nil, ss, dummyInfo, handler) + require.Error(t, err) + + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Internal, st.Code()) + assert.Contains(t, st.Message(), "internal configuration error") + }) + + t.Run("multi_tenant_enabled_propagates_tenant_metadata_in_stream", func(t *testing.T) { + t.Setenv("MULTI_TENANT_ENABLED", "true") + + server := mockAuthServer(t, true, http.StatusOK) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "normal-user", + "owner": "stream-owner", + "sub": "user1", + "tenantId": "tid-stream", + "tenantSlug": "stream-org", + }) + + defaultPol := Policy{Resource: "res", Action: "read"} + cfg := PolicyConfig{DefaultPolicy: &defaultPol} + interceptor := NewGRPCAuthStreamPolicy(auth, cfg) + + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + ss := &fakeServerStream{ctx: ctx} + + var capturedStream grpc.ServerStream + + handler := func(_ any, ss grpc.ServerStream) error { + capturedStream = ss + return nil + } + + err := interceptor(nil, ss, dummyInfo, handler) + require.NoError(t, err) + + // Verify tenant metadata was propagated via the wrapped stream context + md, ok := metadata.FromIncomingContext(capturedStream.Context()) + require.True(t, ok) + assert.Equal(t, []string{"tid-stream"}, md.Get("md-tenant-id")) + assert.Equal(t, []string{"stream-org"}, md.Get("md-tenant-slug")) + assert.Equal(t, []string{"stream-owner"}, md.Get("md-tenant-owner")) + }) +} + +// --------------------------------------------------------------------------- +// wrappedServerStream +// --------------------------------------------------------------------------- + +func TestWrappedServerStream_Context(t *testing.T) { + t.Parallel() + + ctx := context.WithValue(context.Background(), struct{}{}, "test-value") //nolint:staticcheck // test-only context key + inner := &fakeServerStream{ctx: context.Background()} + wrapped := &wrappedServerStream{ServerStream: inner, ctx: ctx} + + assert.Equal(t, ctx, wrapped.Context()) + assert.NotEqual(t, inner.Context(), wrapped.Context()) +} diff --git a/auth/middleware/middleware_test.go b/auth/middleware/middleware_test.go new file mode 100644 index 0000000..3600cc7 --- /dev/null +++ b/auth/middleware/middleware_test.go @@ -0,0 +1,436 @@ +package middleware + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/LerianStudio/lib-commons/v4/commons/log" + jwt "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +// createTestJWT builds a signed JWT string for testing. +// checkAuthorization uses ParseUnverified so the signing key does not matter. +func createTestJWT(claims jwt.MapClaims) string { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + signed, err := token.SignedString([]byte("test-secret")) + if err != nil { + // This should never happen in tests with a valid key. + panic("failed to sign test JWT: " + err.Error()) + } + + return signed +} + +// mockAuthServer returns an httptest.Server that responds to POST /v1/authorize. +func mockAuthServer(t *testing.T, authorized bool, statusCode int) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + resp := AuthResponse{Authorized: authorized} + + err := json.NewEncoder(w).Encode(resp) + if err != nil { + t.Errorf("mock server: failed to encode response: %v", err) + } + })) +} + +// testLogger is a minimal log.Logger implementation for tests that discards all output. +type testLogger struct{} + +func (l *testLogger) Log(_ context.Context, _ log.Level, _ string, _ ...log.Field) {} +func (l *testLogger) With(_ ...log.Field) log.Logger { return l } +func (l *testLogger) WithGroup(_ string) log.Logger { return l } +func (l *testLogger) Enabled(_ log.Level) bool { return false } +func (l *testLogger) Sync(_ context.Context) error { return nil } + +// --------------------------------------------------------------------------- +// checkAuthorization - subject construction +// --------------------------------------------------------------------------- + +func TestCheckAuthorization_NormalUser_SubjectConstruction(t *testing.T) { + t.Parallel() + + // Mock server captures the request body to verify the constructed subject. + var capturedBody map[string]string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := json.NewDecoder(r.Body).Decode(&capturedBody) + if err != nil { + t.Errorf("mock server: failed to decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + resp := AuthResponse{Authorized: true} + + encErr := json.NewEncoder(w).Encode(resp) + if encErr != nil { + t.Errorf("mock server: failed to encode response: %v", encErr) + } + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "normal-user", + "owner": "acme-org", + "sub": "user123", + }) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "initial-sub", "resource", "action", token, + ) + + require.NoError(t, err) + assert.True(t, authorized) + assert.Equal(t, http.StatusOK, statusCode) + + // For normal-user, sub should be "owner/sub-from-jwt" (overrides the initial sub parameter). + assert.Equal(t, "acme-org/user123", capturedBody["sub"]) +} + +func TestCheckAuthorization_ApplicationUser_SubjectConstruction(t *testing.T) { + t.Parallel() + + // Documents the current behavior: non-normal-user types get "admin/-editor-role". + var capturedBody map[string]string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := json.NewDecoder(r.Body).Decode(&capturedBody) + if err != nil { + t.Errorf("mock server: failed to decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + resp := AuthResponse{Authorized: true} + + encErr := json.NewEncoder(w).Encode(resp) + if encErr != nil { + t.Errorf("mock server: failed to encode response: %v", encErr) + } + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "application", + "name": "my-app", + "sub": "app-sub", + }) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "my-app", "resource", "action", token, + ) + + require.NoError(t, err) + assert.True(t, authorized) + assert.Equal(t, http.StatusOK, statusCode) + + // BUG: hardcodes "admin/" prefix. The sub parameter is used as-is with the + // "admin/-editor-role" pattern, regardless of the actual user type. + assert.Equal(t, "admin/my-app-editor-role", capturedBody["sub"]) +} + +func TestCheckAuthorization_MissingOwnerClaim(t *testing.T) { + t.Parallel() + + server := mockAuthServer(t, true, http.StatusOK) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } + + // normal-user without "owner" claim should cause an error. + token := createTestJWT(jwt.MapClaims{ + "type": "normal-user", + "sub": "user123", + // "owner" is intentionally missing + }) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "sub", "resource", "action", token, + ) + + require.Error(t, err) + assert.False(t, authorized) + assert.Equal(t, http.StatusUnauthorized, statusCode) + assert.Contains(t, err.Error(), "missing owner claim") +} + +func TestCheckAuthorization_MockServerReturnsAuthorizedTrue(t *testing.T) { + t.Parallel() + + server := mockAuthServer(t, true, http.StatusOK) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "normal-user", + "owner": "org1", + "sub": "user1", + }) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "sub", "resource", "read", token, + ) + + require.NoError(t, err) + assert.True(t, authorized) + assert.Equal(t, http.StatusOK, statusCode) +} + +func TestCheckAuthorization_MockServerReturnsAuthorizedFalse(t *testing.T) { + t.Parallel() + + server := mockAuthServer(t, false, http.StatusOK) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "normal-user", + "owner": "org1", + "sub": "user1", + }) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "sub", "resource", "read", token, + ) + + require.NoError(t, err) + assert.False(t, authorized) + assert.Equal(t, http.StatusOK, statusCode) +} + +func TestCheckAuthorization_MockServerReturnsForbiddenWithErrorBody(t *testing.T) { + t.Parallel() + + // When the auth server returns a non-200 response with a Response body that + // has a non-empty Code field, checkAuthorization returns an error. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + + resp := map[string]string{ + "code": "FORBIDDEN", + "title": "Forbidden", + "message": "You do not have permission", + } + + err := json.NewEncoder(w).Encode(resp) + if err != nil { + t.Errorf("mock server: failed to encode response: %v", err) + } + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "normal-user", + "owner": "org1", + "sub": "user1", + }) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "sub", "resource", "write", token, + ) + + require.Error(t, err) + assert.False(t, authorized) + assert.Equal(t, http.StatusForbidden, statusCode) +} + +func TestCheckAuthorization_InvalidToken(t *testing.T) { + t.Parallel() + + server := mockAuthServer(t, true, http.StatusOK) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } + + // Completely invalid JWT string that cannot be parsed. + invalidToken := "not-a-valid-jwt" + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "sub", "resource", "action", invalidToken, + ) + + require.Error(t, err) + assert.False(t, authorized) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestCheckAuthorization_EmptyTypeClaim_TreatedAsNonNormalUser(t *testing.T) { + t.Parallel() + + // When the "type" claim is empty or absent, userType != normalUser, + // so the code takes the admin/ branch. + var capturedBody map[string]string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := json.NewDecoder(r.Body).Decode(&capturedBody) + if err != nil { + t.Errorf("mock server: failed to decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + resp := AuthResponse{Authorized: true} + + encErr := json.NewEncoder(w).Encode(resp) + if encErr != nil { + t.Errorf("mock server: failed to encode response: %v", encErr) + } + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } + + // No "type" claim at all -> defaults to empty string -> non-normal-user path + token := createTestJWT(jwt.MapClaims{ + "sub": "some-app", + }) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "some-app", "resource", "action", token, + ) + + require.NoError(t, err) + assert.True(t, authorized) + assert.Equal(t, http.StatusOK, statusCode) + assert.Equal(t, "admin/some-app-editor-role", capturedBody["sub"]) +} + +func TestCheckAuthorization_MockServerDown(t *testing.T) { + t.Parallel() + + // Use a server and immediately close it to simulate a connection failure. + server := mockAuthServer(t, true, http.StatusOK) + server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "normal-user", + "owner": "org1", + "sub": "user1", + }) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "sub", "resource", "read", token, + ) + + require.Error(t, err) + assert.False(t, authorized) + assert.Equal(t, http.StatusInternalServerError, statusCode) + assert.Contains(t, err.Error(), "failed to make request") +} + +func TestCheckAuthorization_ServerReturnsInvalidJSON(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + // Write invalid JSON + _, _ = w.Write([]byte("not-json")) + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "normal-user", + "owner": "org1", + "sub": "user1", + }) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "sub", "resource", "read", token, + ) + + require.Error(t, err) + assert.False(t, authorized) + assert.Equal(t, http.StatusInternalServerError, statusCode) + assert.Contains(t, err.Error(), "failed to unmarshal") +} + +// --------------------------------------------------------------------------- +// AuthResponse JSON serialization +// --------------------------------------------------------------------------- + +func TestAuthResponse_JSONRoundTrip(t *testing.T) { + t.Parallel() + + original := AuthResponse{Authorized: true} + + data, err := json.Marshal(original) + require.NoError(t, err) + + var decoded AuthResponse + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, original.Authorized, decoded.Authorized) +} diff --git a/go.mod b/go.mod index 971e8c6..e7ecbc2 100644 --- a/go.mod +++ b/go.mod @@ -1,62 +1,69 @@ module github.com/LerianStudio/lib-auth/v2 -go 1.23.2 - -toolchain go1.23.3 +go 1.25.7 require ( - github.com/LerianStudio/lib-commons/v2 v2.2.0 - github.com/gofiber/fiber/v2 v2.52.9 - github.com/golang-jwt/jwt/v5 v5.3.0 - go.opentelemetry.io/otel v1.37.0 + github.com/LerianStudio/lib-commons/v4 v4.2.0 + github.com/gofiber/fiber/v2 v2.52.12 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/otel v1.42.0 + google.golang.org/grpc v1.79.2 ) require github.com/google/uuid v1.6.0 // indirect require ( - github.com/Masterminds/squirrel v1.5.4 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/joho/godotenv v1.5.1 // indirect - github.com/klauspost/compress v1.18.0 // indirect - github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect - github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/rivo/uniseg v0.4.7 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect - github.com/tklauser/go-sysconf v0.3.15 // indirect - github.com/tklauser/numcpus v0.10.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sony/gobreaker v1.0.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.64.0 // indirect + github.com/valyala/fasthttp v1.69.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/bridges/otelzap v0.12.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.13.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect - go.opentelemetry.io/otel/log v0.13.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/sdk v1.37.0 // indirect - go.opentelemetry.io/otel/sdk/log v0.13.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect - go.opentelemetry.io/proto/otlp v1.7.1 // indirect - go.uber.org/mock v0.5.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/bridges/otelzap v0.17.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect + go.opentelemetry.io/otel/log v0.18.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/sdk v1.42.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.18.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.uber.org/mock v0.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.27.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect - google.golang.org/grpc v1.74.2 // indirect - google.golang.org/protobuf v1.36.6 // indirect + go.uber.org/zap v1.27.1 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d71cef3..24ad951 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,18 @@ -github.com/LerianStudio/lib-commons/v2 v2.2.0 h1:pAyerAucWl42gvVUdGpBdUDRBUvNAk7OhtGZxyJ25aE= -github.com/LerianStudio/lib-commons/v2 v2.2.0/go.mod h1:6esN/Ao/Xkp/QwvYt8wXQNMgrhb3exOstKUIPuSUBDM= -github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= -github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/LerianStudio/lib-commons/v4 v4.2.0 h1:4gbQRcTiToo2JVsFymbMPE6eDui5wiVZInkR98+KoXs= +github.com/LerianStudio/lib-commons/v4 v4.2.0/go.mod h1:89mzZKE3Hf0aDdKo3qfjfPL38ZsQRciqRbEeR718O+g= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -17,113 +21,132 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= -github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= +github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= -github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= -github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= -github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= -github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= -github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= -github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= +github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.64.0 h1:QBygLLQmiAyiXuRhthf0tuRkqAFcrC42dckN2S+N3og= -github.com/valyala/fasthttp v1.64.0/go.mod h1:dGmFxwkWXSK0NbOSJuF7AMVzU+lkHz0wQVvVITv2UQA= +github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= +github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/bridges/otelzap v0.12.0 h1:FGre0nZh5BSw7G73VpT3xs38HchsfPsa2aZtMp0NPOs= -go.opentelemetry.io/contrib/bridges/otelzap v0.12.0/go.mod h1:X2PYPViI2wTPIMIOBjG17KNybTzsrATnvPJ02kkz7LM= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.13.0 h1:z6lNIajgEBVtQZHjfw2hAccPEBDs+nx58VemmXWa2ec= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.13.0/go.mod h1:+kyc3bRx/Qkq05P6OCu3mTEIOxYRYzoIg+JsUp5X+PM= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 h1:zG8GlgXCJQd5BU98C0hZnBbElszTmUgCNCfYneaDL0A= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0/go.mod h1:hOfBCz8kv/wuq73Mx2H2QnWokh/kHZxkh6SNF2bdKtw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= -go.opentelemetry.io/otel/log v0.13.0 h1:yoxRoIZcohB6Xf0lNv9QIyCzQvrtGZklVbdCoyb7dls= -go.opentelemetry.io/otel/log v0.13.0/go.mod h1:INKfG4k1O9CL25BaM1qLe0zIedOpvlS5Z7XgSbmN83E= -go.opentelemetry.io/otel/log/logtest v0.13.0 h1:xxaIcgoEEtnwdgj6D6Uo9K/Dynz9jqIxSDu2YObJ69Q= -go.opentelemetry.io/otel/log/logtest v0.13.0/go.mod h1:+OrkmsAH38b+ygyag1tLjSFMYiES5UHggzrtY1IIEA8= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/log v0.13.0 h1:I3CGUszjM926OphK8ZdzF+kLqFvfRY/IIoFq/TjwfaQ= -go.opentelemetry.io/otel/sdk/log v0.13.0/go.mod h1:lOrQyCCXmpZdN7NchXb6DOZZa1N5G1R2tm5GMMTpDBw= -go.opentelemetry.io/otel/sdk/log/logtest v0.13.0 h1:9yio6AFZ3QD9j9oqshV1Ibm9gPLlHNxurno5BreMtIA= -go.opentelemetry.io/otel/sdk/log/logtest v0.13.0/go.mod h1:QOGiAJHl+fob8Nu85ifXfuQYmJTFAvcrxL6w5/tu168= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= -go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/otelzap v0.17.0 h1:oCltVHJcblcth2z9B9dRTeZIZTe2Sf9Ad9h8bcc+s8M= +go.opentelemetry.io/contrib/bridges/otelzap v0.17.0/go.mod h1:G/VE1A/hRn6mEWdfC8rMvSdQVGM64KUPi4XilLkwcQw= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= +go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg= +go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI= +go.opentelemetry.io/otel/log/logtest v0.18.0 h1:2QeyoKJdIgK2LJhG1yn78o/zmpXx1EditeyRDREqVS8= +go.opentelemetry.io/otel/log/logtest v0.18.0/go.mod h1:v1vh3PYR9zIa5MK6HwkH2lMrLBg/Y9Of6Qc+krlesX0= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw= +go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk= +go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA= +go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= -go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc= -google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= -google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mk/tests.mk b/mk/tests.mk new file mode 100644 index 0000000..8b9cec0 --- /dev/null +++ b/mk/tests.mk @@ -0,0 +1,297 @@ +# ------------------------------------------------------ +# Test configuration for lib-auth +# ------------------------------------------------------ + +# Native fuzz test controls +# FUZZ: specific fuzz target name (e.g., FuzzValidateEmail) +# FUZZTIME: duration per fuzz target (default: 10s) +FUZZ ?= +FUZZTIME ?= 10s + +# Integration test filter +# RUN: specific test name pattern (e.g., TestIntegration_FeatureName) +# PKG: specific package to test (e.g., ./auth/...) +# Usage: make test-integration RUN=TestIntegration_FeatureName +# make test-integration PKG=./auth/... +RUN ?= +PKG ?= + +# Computed run pattern: uses RUN if set, otherwise defaults to '^TestIntegration' +ifeq ($(RUN),) + RUN_PATTERN := ^TestIntegration +else + RUN_PATTERN := $(RUN) +endif + +# Low-resource mode for limited machines (sets -p=1 -parallel=1, disables -race) +# Usage: make test-integration LOW_RESOURCE=1 +# make coverage-integration LOW_RESOURCE=1 +LOW_RESOURCE ?= 0 + +# Computed flags for low-resource mode +ifeq ($(LOW_RESOURCE),1) + LOW_RES_P_FLAG := -p 1 + LOW_RES_PARALLEL_FLAG := -parallel 1 + LOW_RES_RACE_FLAG := +else + LOW_RES_P_FLAG := + LOW_RES_PARALLEL_FLAG := + LOW_RES_RACE_FLAG := -race +endif + +# macOS ld64 workaround: newer ld emits noisy LC_DYSYMTAB warnings when linking test binaries with -race. +# If available, prefer Apple's classic linker to silence them. +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) + # Prefer classic mode to suppress LC_DYSYMTAB warnings on macOS. + # Set DISABLE_OSX_LINKER_WORKAROUND=1 to disable this behavior. + ifneq ($(DISABLE_OSX_LINKER_WORKAROUND),1) + GO_TEST_LDFLAGS := -ldflags="-linkmode=external -extldflags=-ld_classic" + else + GO_TEST_LDFLAGS := + endif +else + GO_TEST_LDFLAGS := +endif + +# ------------------------------------------------------ +# Test tooling configuration +# ------------------------------------------------------ + +TEST_REPORTS_DIR ?= ./reports +GOTESTSUM := $(shell command -v gotestsum 2>/dev/null) +RETRY_ON_FAIL ?= 0 + +.PHONY: tools tools-gotestsum +tools: tools-gotestsum ## Install helpful dev/test tools + +tools-gotestsum: + @if [ -z "$(GOTESTSUM)" ]; then \ + echo "Installing gotestsum..."; \ + GO111MODULE=on go install gotest.tools/gotestsum@latest; \ + else \ + echo "gotestsum already installed: $(GOTESTSUM)"; \ + fi + +#------------------------------------------------------- +# Core Test Commands +#------------------------------------------------------- + +.PHONY: test +test: + $(call print_title,Running all tests) + $(call check_command,go,"Install Go from https://golang.org/doc/install") + @set -e; mkdir -p $(TEST_REPORTS_DIR); \ + if [ -n "$(GOTESTSUM)" ]; then \ + echo "Running tests with gotestsum"; \ + gotestsum --format testname -- -v -race -count=1 $(GO_TEST_LDFLAGS) ./...; \ + else \ + go test -v -race -count=1 $(GO_TEST_LDFLAGS) ./...; \ + fi + @echo "$(GREEN)$(BOLD)[ok]$(NC) All tests passed$(GREEN) ✔️$(NC)" + +#------------------------------------------------------- +# Test Suite Aliases +#------------------------------------------------------- + +# Unit tests (excluding integration tests) +.PHONY: test-unit +test-unit: + $(call print_title,Running Go unit tests) + $(call check_command,go,"Install Go from https://golang.org/doc/install") + @set -e; mkdir -p $(TEST_REPORTS_DIR); \ + pkgs=$$(go list ./... | grep -v '/tests'); \ + if [ -z "$$pkgs" ]; then \ + echo "No unit test packages found"; \ + else \ + if [ -n "$(GOTESTSUM)" ]; then \ + echo "Running unit tests with gotestsum"; \ + gotestsum --format testname -- -v -race -count=1 $(GO_TEST_LDFLAGS) $$pkgs || { \ + if [ "$(RETRY_ON_FAIL)" = "1" ]; then \ + echo "Retrying unit tests once..."; \ + gotestsum --format testname -- -v -race -count=1 $(GO_TEST_LDFLAGS) $$pkgs; \ + else \ + exit 1; \ + fi; \ + }; \ + else \ + go test -v -race -count=1 $(GO_TEST_LDFLAGS) $$pkgs; \ + fi; \ + fi + @echo "$(GREEN)$(BOLD)[ok]$(NC) Unit tests passed$(GREEN) ✔️$(NC)" + +# Integration tests with testcontainers (no coverage) +# These tests use the `integration` build tag and testcontainers-go to spin up +# ephemeral containers. No external Docker stack is required. +# +# Requirements: +# - Test files must follow the naming convention: *_integration_test.go +# - Test functions must start with TestIntegration_ (e.g., TestIntegration_MyFeature_Works) +.PHONY: test-integration +test-integration: + $(call print_title,Running integration tests with testcontainers) + $(call check_command,go,"Install Go from https://golang.org/doc/install") + $(call check_command,docker,"Install Docker from https://docs.docker.com/get-docker/") + @set -e; mkdir -p $(TEST_REPORTS_DIR); \ + if [ -n "$(PKG)" ]; then \ + echo "Using specified package: $(PKG)"; \ + pkgs=$$(go list $(PKG) 2>/dev/null | tr '\n' ' '); \ + else \ + echo "Finding packages with *_integration_test.go files..."; \ + dirs=$$(find . -name '*_integration_test.go' -not -path './vendor/*' -exec dirname {} \; 2>/dev/null | sort -u | tr '\n' ' '); \ + pkgs=$$(if [ -n "$$dirs" ]; then go list $$dirs 2>/dev/null | tr '\n' ' '; fi); \ + fi; \ + if [ -z "$$pkgs" ]; then \ + echo "No integration test packages found"; \ + else \ + echo "Packages: $$pkgs"; \ + echo "Running packages sequentially (-p=1) to avoid Docker container conflicts"; \ + if [ "$(LOW_RESOURCE)" = "1" ]; then \ + echo "LOW_RESOURCE mode: -parallel=1, race detector disabled"; \ + fi; \ + if [ -n "$(GOTESTSUM)" ]; then \ + echo "Running testcontainers integration tests with gotestsum"; \ + gotestsum --format testname -- \ + -tags=integration -v $(LOW_RES_RACE_FLAG) -count=1 -timeout 600s $(GO_TEST_LDFLAGS) \ + -p 1 $(LOW_RES_PARALLEL_FLAG) \ + -run '$(RUN_PATTERN)' $$pkgs || { \ + if [ "$(RETRY_ON_FAIL)" = "1" ]; then \ + echo "Retrying integration tests once..."; \ + gotestsum --format testname -- \ + -tags=integration -v $(LOW_RES_RACE_FLAG) -count=1 -timeout 600s $(GO_TEST_LDFLAGS) \ + -p 1 $(LOW_RES_PARALLEL_FLAG) \ + -run '$(RUN_PATTERN)' $$pkgs; \ + else \ + exit 1; \ + fi; \ + }; \ + else \ + go test -tags=integration -v $(LOW_RES_RACE_FLAG) -count=1 -timeout 600s $(GO_TEST_LDFLAGS) \ + -p 1 $(LOW_RES_PARALLEL_FLAG) \ + -run '$(RUN_PATTERN)' $$pkgs; \ + fi; \ + fi + @echo "$(GREEN)$(BOLD)[ok]$(NC) Integration tests passed$(GREEN) ✔️$(NC)" + +# Run all tests (unit + integration) +.PHONY: test-all +test-all: + $(call print_title,Running all tests (unit + integration)) + $(call print_title,Running unit tests) + $(MAKE) test-unit + $(call print_title,Running integration tests) + $(MAKE) test-integration + @echo "$(GREEN)$(BOLD)[ok]$(NC) All tests passed$(GREEN) ✔️$(NC)" + +#------------------------------------------------------- +# Coverage Commands +#------------------------------------------------------- + +# Unit tests with coverage (uses covermode=atomic) +# Supports PKG parameter to filter packages (e.g., PKG=./auth/...) +# Supports .ignorecoverunit file to exclude patterns from coverage stats +.PHONY: coverage-unit +coverage-unit: + $(call print_title,Running Go unit tests with coverage) + $(call check_command,go,"Install Go from https://golang.org/doc/install") + @set -e; mkdir -p $(TEST_REPORTS_DIR); \ + if [ -n "$(PKG)" ]; then \ + echo "Using specified package: $(PKG)"; \ + pkgs=$$(go list $(PKG) 2>/dev/null | grep -v '/tests' | tr '\n' ' '); \ + else \ + pkgs=$$(go list ./... | grep -v '/tests'); \ + fi; \ + if [ -z "$$pkgs" ]; then \ + echo "No unit test packages found"; \ + else \ + echo "Packages: $$pkgs"; \ + if [ -n "$(GOTESTSUM)" ]; then \ + echo "Running unit tests with gotestsum (coverage enabled)"; \ + gotestsum --format testname -- -v -race -count=1 $(GO_TEST_LDFLAGS) -covermode=atomic -coverprofile=$(TEST_REPORTS_DIR)/unit_coverage.out $$pkgs || { \ + if [ "$(RETRY_ON_FAIL)" = "1" ]; then \ + echo "Retrying unit tests once..."; \ + gotestsum --format testname -- -v -race -count=1 $(GO_TEST_LDFLAGS) -covermode=atomic -coverprofile=$(TEST_REPORTS_DIR)/unit_coverage.out $$pkgs; \ + else \ + exit 1; \ + fi; \ + }; \ + else \ + go test -v -race -count=1 $(GO_TEST_LDFLAGS) -covermode=atomic -coverprofile=$(TEST_REPORTS_DIR)/unit_coverage.out $$pkgs; \ + fi; \ + if [ -f .ignorecoverunit ]; then \ + echo "Filtering coverage with .ignorecoverunit patterns..."; \ + patterns=$$(grep -v '^#' .ignorecoverunit | grep -v '^$$' | tr '\n' '|' | sed 's/|$$//'); \ + if [ -n "$$patterns" ]; then \ + regex_patterns=$$(echo "$$patterns" | sed 's/\./\\./g' | sed 's/\*/.*/g'); \ + head -1 $(TEST_REPORTS_DIR)/unit_coverage.out > $(TEST_REPORTS_DIR)/unit_coverage_filtered.out; \ + tail -n +2 $(TEST_REPORTS_DIR)/unit_coverage.out | grep -vE "$$regex_patterns" >> $(TEST_REPORTS_DIR)/unit_coverage_filtered.out || true; \ + mv $(TEST_REPORTS_DIR)/unit_coverage_filtered.out $(TEST_REPORTS_DIR)/unit_coverage.out; \ + echo "Excluded patterns: $$patterns"; \ + fi; \ + fi; \ + echo "----------------------------------------"; \ + go tool cover -func=$(TEST_REPORTS_DIR)/unit_coverage.out | grep total | awk '{print "Total coverage: " $$3}'; \ + echo "----------------------------------------"; \ + fi + @echo "$(GREEN)$(BOLD)[ok]$(NC) Unit coverage report generated$(GREEN) ✔️$(NC)" + +# Integration tests with testcontainers (with coverage, uses covermode=atomic) +.PHONY: coverage-integration +coverage-integration: + $(call print_title,Running integration tests with testcontainers (coverage enabled)) + $(call check_command,go,"Install Go from https://golang.org/doc/install") + $(call check_command,docker,"Install Docker from https://docs.docker.com/get-docker/") + @set -e; mkdir -p $(TEST_REPORTS_DIR); \ + if [ -n "$(PKG)" ]; then \ + echo "Using specified package: $(PKG)"; \ + pkgs=$$(go list $(PKG) 2>/dev/null | tr '\n' ' '); \ + else \ + echo "Finding packages with *_integration_test.go files..."; \ + dirs=$$(find . -name '*_integration_test.go' -not -path './vendor/*' -exec dirname {} \; 2>/dev/null | sort -u | tr '\n' ' '); \ + pkgs=$$(if [ -n "$$dirs" ]; then go list $$dirs 2>/dev/null | tr '\n' ' '; fi); \ + fi; \ + if [ -z "$$pkgs" ]; then \ + echo "No integration test packages found"; \ + else \ + echo "Packages: $$pkgs"; \ + echo "Running packages sequentially (-p=1) to avoid Docker container conflicts"; \ + if [ "$(LOW_RESOURCE)" = "1" ]; then \ + echo "LOW_RESOURCE mode: -parallel=1, race detector disabled"; \ + fi; \ + if [ -n "$(GOTESTSUM)" ]; then \ + echo "Running testcontainers integration tests with gotestsum (coverage enabled)"; \ + gotestsum --format testname -- \ + -tags=integration -v $(LOW_RES_RACE_FLAG) -count=1 -timeout 600s $(GO_TEST_LDFLAGS) \ + -p 1 $(LOW_RES_PARALLEL_FLAG) \ + -run '$(RUN_PATTERN)' -covermode=atomic -coverprofile=$(TEST_REPORTS_DIR)/integration_coverage.out \ + $$pkgs || { \ + if [ "$(RETRY_ON_FAIL)" = "1" ]; then \ + echo "Retrying integration tests once..."; \ + gotestsum --format testname -- \ + -tags=integration -v $(LOW_RES_RACE_FLAG) -count=1 -timeout 600s $(GO_TEST_LDFLAGS) \ + -p 1 $(LOW_RES_PARALLEL_FLAG) \ + -run '$(RUN_PATTERN)' -covermode=atomic -coverprofile=$(TEST_REPORTS_DIR)/integration_coverage.out \ + $$pkgs; \ + else \ + exit 1; \ + fi; \ + }; \ + else \ + go test -tags=integration -v $(LOW_RES_RACE_FLAG) -count=1 -timeout 600s $(GO_TEST_LDFLAGS) \ + -p 1 $(LOW_RES_PARALLEL_FLAG) \ + -run '$(RUN_PATTERN)' -covermode=atomic -coverprofile=$(TEST_REPORTS_DIR)/integration_coverage.out \ + $$pkgs; \ + fi; \ + echo "----------------------------------------"; \ + go tool cover -func=$(TEST_REPORTS_DIR)/integration_coverage.out | grep total | awk '{print "Total coverage: " $$3}'; \ + echo "----------------------------------------"; \ + fi + @echo "$(GREEN)$(BOLD)[ok]$(NC) Integration coverage report generated$(GREEN) ✔️$(NC)" + +# Run all coverage targets +.PHONY: coverage +coverage: + $(call print_title,Running all coverage targets) + $(MAKE) coverage-unit + $(MAKE) coverage-integration + @echo "$(GREEN)$(BOLD)[ok]$(NC) All coverage reports generated$(GREEN) ✔️$(NC)"