From c3297c87ace83ce402cd8c8eb861d35176aa62c6 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 06:27:29 +0200 Subject: [PATCH 01/20] docs: add auth example design --- docs/plans/2026-02-05-auth-example-design.md | 56 ++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/plans/2026-02-05-auth-example-design.md diff --git a/docs/plans/2026-02-05-auth-example-design.md b/docs/plans/2026-02-05-auth-example-design.md new file mode 100644 index 0000000..890fb80 --- /dev/null +++ b/docs/plans/2026-02-05-auth-example-design.md @@ -0,0 +1,56 @@ +# Auth Example Design + +**Goal:** Add a runnable, example-focused JWT authentication module to hello-mysql with a login endpoint, middleware validation, and typed context helpers. + +**Architecture:** A dedicated `auth` module provides a login handler and a JWT middleware provider. Configuration is explicit via example config/env. The middleware validates tokens and stores user info in a typed context helper, which handlers can read. The users module stays unchanged until route protection is applied in later tasks. + +**Tech Stack:** Go, chi router via modkit http adapter, standard library + minimal JWT dependency. + +--- + +## Section 1 — Architecture Summary + +We add `examples/hello-mysql/internal/modules/auth` with a deterministic module definition and provider scaffolding. The module exports two primary providers: a JWT validation middleware and a login handler/controller. Configuration is explicit and local to the example (`JWT_SECRET`, `JWT_ISSUER`, `JWT_TTL`, `AUTH_USERNAME`, `AUTH_PASSWORD`). The login endpoint verifies demo credentials (no DB, no hashing) and returns a signed HS256 JWT with a minimal subject/email claim. The middleware validates the `Authorization: Bearer ` header, verifies signature + expiry, and stores authenticated user info in the request context via typed helpers. Downstream handlers access user info using those helpers only; no global state. + +## Section 2 — Components and Data Flow + +**Config:** Extend `examples/hello-mysql/internal/platform/config` with JWT + demo auth fields. `Load()` pulls from env with defaults (e.g., username `demo`, password `demo`, issuer `hello-mysql`, TTL `1h`). + +**Auth Module:** +- `module.go`: registers module name, exports provider tokens. +- `providers.go`: builds middleware and login handler using config. +- `config.go`: holds auth config struct sourced from platform config. + +**JWT Middleware:** +- Extracts bearer token, returns 401 on missing/invalid tokens. +- Verifies signature and expiry using HS256. +- On success, stores `AuthUser{ID, Email}` in context. + +**Login Handler:** +- `POST /auth/login` expects JSON with username/password. +- Validates against demo config values. +- Returns `{ "token": "" }` on success. + +**Typed Context Helpers:** +- `WithUser(ctx, user)` and `UserFromContext(ctx)` in `context.go`. +- Used by handlers and tests to show how to access authenticated user. + +## Section 3 — Error Handling, Tests, and Docs + +**Errors:** Use existing `httpapi.WriteProblem` for auth errors with status `401`. Validation errors for login payload are `400`. Internal issues return `500` with explicit context wrapping. + +**Tests:** +- Unit tests for middleware and context helpers (valid/invalid token, missing header). +- Integration tests for `/auth/login` and protected routes (valid/invalid creds). +- Use table-driven tests for token validation cases. + +**Documentation:** Update `examples/hello-mysql/README.md` with login example, token usage, and which `/users` routes require auth. Keep examples aligned with code paths. + +--- + +**Defaults (chosen):** +- `AUTH_USERNAME=demo` +- `AUTH_PASSWORD=demo` +- `JWT_ISSUER=hello-mysql` +- `JWT_TTL=1h` +- `JWT_SECRET=dev-secret-change-me` From 16c717cd2cbdac9e7525f56d939670a0b07d0bfa Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 06:43:20 +0200 Subject: [PATCH 02/20] 88: create auth module structure --- .../internal/modules/auth/module.go | 40 +++++++++++++++++++ .../internal/modules/auth/module_test.go | 15 +++++++ .../internal/modules/auth/providers.go | 3 ++ 3 files changed, 58 insertions(+) create mode 100644 examples/hello-mysql/internal/modules/auth/module.go create mode 100644 examples/hello-mysql/internal/modules/auth/module_test.go create mode 100644 examples/hello-mysql/internal/modules/auth/providers.go diff --git a/examples/hello-mysql/internal/modules/auth/module.go b/examples/hello-mysql/internal/modules/auth/module.go new file mode 100644 index 0000000..faa2596 --- /dev/null +++ b/examples/hello-mysql/internal/modules/auth/module.go @@ -0,0 +1,40 @@ +package auth + +import "github.com/go-modkit/modkit/modkit/module" + +const ( + TokenMiddleware module.Token = "auth.middleware" + TokenHandler module.Token = "auth.handler" +) + +type Options struct{} + +type Module struct { + opts Options +} + +type AuthModule = Module + +func NewModule(opts Options) module.Module { + return &Module{opts: opts} +} + +func (m Module) Definition() module.ModuleDef { + return module.ModuleDef{ + Name: "auth", + Providers: []module.ProviderDef{ + { + Token: TokenMiddleware, + Build: func(r module.Resolver) (any, error) { + return nil, nil + }, + }, + { + Token: TokenHandler, + Build: func(r module.Resolver) (any, error) { + return nil, nil + }, + }, + }, + } +} diff --git a/examples/hello-mysql/internal/modules/auth/module_test.go b/examples/hello-mysql/internal/modules/auth/module_test.go new file mode 100644 index 0000000..a919108 --- /dev/null +++ b/examples/hello-mysql/internal/modules/auth/module_test.go @@ -0,0 +1,15 @@ +package auth + +import ( + "testing" + + "github.com/go-modkit/modkit/modkit/kernel" +) + +func TestModule_Bootstrap(t *testing.T) { + mod := NewModule(Options{}) + _, err := kernel.Bootstrap(mod) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } +} diff --git a/examples/hello-mysql/internal/modules/auth/providers.go b/examples/hello-mysql/internal/modules/auth/providers.go new file mode 100644 index 0000000..9f7f3a0 --- /dev/null +++ b/examples/hello-mysql/internal/modules/auth/providers.go @@ -0,0 +1,3 @@ +package auth + +// placeholder file for future provider implementations From f31b643609c9c8deb475b71e22d1df2d8ba23b3e Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 06:51:03 +0200 Subject: [PATCH 03/20] 89: implement jwt middleware --- examples/hello-mysql/go.mod | 3 +- examples/hello-mysql/go.sum | 2 + .../internal/modules/auth/config.go | 11 +++ .../internal/modules/auth/middleware.go | 58 +++++++++++++++ .../internal/modules/auth/middleware_test.go | 73 +++++++++++++++++++ .../internal/platform/config/config.go | 25 +++++-- go.work.sum | 3 +- 7 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 examples/hello-mysql/internal/modules/auth/config.go create mode 100644 examples/hello-mysql/internal/modules/auth/middleware.go create mode 100644 examples/hello-mysql/internal/modules/auth/middleware_test.go diff --git a/examples/hello-mysql/go.mod b/examples/hello-mysql/go.mod index 5bf0b4b..86fe2ba 100644 --- a/examples/hello-mysql/go.mod +++ b/examples/hello-mysql/go.mod @@ -3,8 +3,8 @@ module github.com/go-modkit/modkit/examples/hello-mysql go 1.24.0 require ( - github.com/go-modkit/modkit v0.0.0 github.com/go-chi/chi/v5 v5.2.4 + github.com/go-modkit/modkit v0.0.0 github.com/go-sql-driver/mysql v1.9.3 github.com/swaggo/http-swagger/v2 v2.0.2 github.com/swaggo/swag v1.16.6 @@ -38,6 +38,7 @@ require ( github.com/go-openapi/jsonreference v0.20.0 // indirect github.com/go-openapi/spec v0.20.6 // indirect github.com/go-openapi/swag v0.19.15 // indirect + github.com/golang-jwt/jwt/v5 v5.2.0 github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.0 // indirect diff --git a/examples/hello-mysql/go.sum b/examples/hello-mysql/go.sum index 02c5e63..c349a6d 100644 --- a/examples/hello-mysql/go.sum +++ b/examples/hello-mysql/go.sum @@ -61,6 +61,8 @@ github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyr github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/examples/hello-mysql/internal/modules/auth/config.go b/examples/hello-mysql/internal/modules/auth/config.go new file mode 100644 index 0000000..70ba038 --- /dev/null +++ b/examples/hello-mysql/internal/modules/auth/config.go @@ -0,0 +1,11 @@ +package auth + +import "time" + +type Config struct { + Secret string + Issuer string + TTL time.Duration + Username string + Password string +} diff --git a/examples/hello-mysql/internal/modules/auth/middleware.go b/examples/hello-mysql/internal/modules/auth/middleware.go new file mode 100644 index 0000000..25294f4 --- /dev/null +++ b/examples/hello-mysql/internal/modules/auth/middleware.go @@ -0,0 +1,58 @@ +package auth + +import ( + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func NewJWTMiddleware(cfg Config) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenStr := bearerToken(r.Header.Get("Authorization")) + if tokenStr == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + if _, err := parseToken(tokenStr, cfg, time.Now()); err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func bearerToken(header string) string { + const prefix = "Bearer " + if !strings.HasPrefix(header, prefix) { + return "" + } + + token := strings.TrimSpace(strings.TrimPrefix(header, prefix)) + if token == "" { + return "" + } + + return token +} + +func parseToken(tokenStr string, cfg Config, now time.Time) (*jwt.Token, error) { + parser := jwt.NewParser( + jwt.WithIssuer(cfg.Issuer), + jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), + jwt.WithExpirationRequired(), + jwt.WithTimeFunc(func() time.Time { return now }), + ) + + return parser.Parse(tokenStr, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrTokenSignatureInvalid + } + return []byte(cfg.Secret), nil + }) +} diff --git a/examples/hello-mysql/internal/modules/auth/middleware_test.go b/examples/hello-mysql/internal/modules/auth/middleware_test.go new file mode 100644 index 0000000..da99e57 --- /dev/null +++ b/examples/hello-mysql/internal/modules/auth/middleware_test.go @@ -0,0 +1,73 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func TestJWTMiddleware(t *testing.T) { + secret := []byte("test-secret") + issuer := "test-issuer" + + makeToken := func(exp time.Time) string { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iss": issuer, + "exp": exp.Unix(), + }) + signed, err := token.SignedString(secret) + if err != nil { + t.Fatalf("sign token: %v", err) + } + return signed + } + + tests := []struct { + name string + authHeader string + wantStatus int + }{ + { + name: "missing token", + authHeader: "", + wantStatus: http.StatusUnauthorized, + }, + { + name: "invalid token", + authHeader: "Bearer not-a-token", + wantStatus: http.StatusUnauthorized, + }, + { + name: "expired token", + authHeader: "Bearer " + makeToken(time.Now().Add(-time.Minute)), + wantStatus: http.StatusUnauthorized, + }, + { + name: "valid token", + authHeader: "Bearer " + makeToken(time.Now().Add(time.Minute)), + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + if tt.authHeader != "" { + req.Header.Set("Authorization", tt.authHeader) + } + + rr := httptest.NewRecorder() + handler := NewJWTMiddleware(Config{Secret: string(secret), Issuer: issuer})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + handler.ServeHTTP(rr, req) + + if rr.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d", rr.Code, tt.wantStatus) + } + }) + } +} diff --git a/examples/hello-mysql/internal/platform/config/config.go b/examples/hello-mysql/internal/platform/config/config.go index 7b9ae44..9d4854d 100644 --- a/examples/hello-mysql/internal/platform/config/config.go +++ b/examples/hello-mysql/internal/platform/config/config.go @@ -1,21 +1,34 @@ package config -import "os" +import ( + "os" + "strings" +) type Config struct { - HTTPAddr string - MySQLDSN string + HTTPAddr string + MySQLDSN string + JWTSecret string + JWTIssuer string + JWTTTL string + AuthUsername string + AuthPassword string } func Load() Config { return Config{ - HTTPAddr: envOrDefault("HTTP_ADDR", ":8080"), - MySQLDSN: envOrDefault("MYSQL_DSN", "root:password@tcp(localhost:3306)/app?parseTime=true&multiStatements=true"), + HTTPAddr: envOrDefault("HTTP_ADDR", ":8080"), + MySQLDSN: envOrDefault("MYSQL_DSN", "root:password@tcp(localhost:3306)/app?parseTime=true&multiStatements=true"), + JWTSecret: envOrDefault("JWT_SECRET", "dev-secret-change-me"), + JWTIssuer: envOrDefault("JWT_ISSUER", "hello-mysql"), + JWTTTL: envOrDefault("JWT_TTL", "1h"), + AuthUsername: envOrDefault("AUTH_USERNAME", "demo"), + AuthPassword: envOrDefault("AUTH_PASSWORD", "demo"), } } func envOrDefault(key, def string) string { - val := os.Getenv(key) + val := strings.TrimSpace(os.Getenv(key)) if val == "" { return def } diff --git a/go.work.sum b/go.work.sum index 6f6ef07..cb92622 100644 --- a/go.work.sum +++ b/go.work.sum @@ -132,6 +132,8 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -349,7 +351,6 @@ golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= From 42c1af120a86028a7d094826fb32b7737e47a4b9 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 07:01:20 +0200 Subject: [PATCH 04/20] 90: add typed user context helpers --- .../internal/modules/auth/context.go | 20 +++++++++++++++++++ .../internal/modules/auth/context_test.go | 20 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 examples/hello-mysql/internal/modules/auth/context.go create mode 100644 examples/hello-mysql/internal/modules/auth/context_test.go diff --git a/examples/hello-mysql/internal/modules/auth/context.go b/examples/hello-mysql/internal/modules/auth/context.go new file mode 100644 index 0000000..006f2e5 --- /dev/null +++ b/examples/hello-mysql/internal/modules/auth/context.go @@ -0,0 +1,20 @@ +package auth + +import "context" + +type User struct { + ID string + Email string +} + +type userKey struct{} + +func WithUser(ctx context.Context, user User) context.Context { + return context.WithValue(ctx, userKey{}, user) +} + +func UserFromContext(ctx context.Context) (User, bool) { + val := ctx.Value(userKey{}) + user, ok := val.(User) + return user, ok +} diff --git a/examples/hello-mysql/internal/modules/auth/context_test.go b/examples/hello-mysql/internal/modules/auth/context_test.go new file mode 100644 index 0000000..6da3119 --- /dev/null +++ b/examples/hello-mysql/internal/modules/auth/context_test.go @@ -0,0 +1,20 @@ +package auth + +import ( + "context" + "testing" +) + +func TestUserContextHelpers(t *testing.T) { + ctx := context.Background() + user := User{ID: "demo", Email: "demo@example.com"} + + ctx = WithUser(ctx, user) + got, ok := UserFromContext(ctx) + if !ok { + t.Fatal("expected user in context") + } + if got.Email != user.Email { + t.Fatalf("expected %s, got %s", user.Email, got.Email) + } +} From 21be70af1613fb153b860d61c2ee95d352094bb2 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 07:16:24 +0200 Subject: [PATCH 05/20] 91: add auth login endpoint --- .../internal/modules/auth/handler.go | 62 +++++++++++++++ .../internal/modules/auth/handler_test.go | 79 +++++++++++++++++++ .../internal/modules/auth/module.go | 23 +++--- .../internal/modules/auth/providers.go | 19 ++++- .../internal/modules/auth/routes.go | 5 ++ .../internal/modules/auth/token.go | 28 +++++++ 6 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 examples/hello-mysql/internal/modules/auth/handler.go create mode 100644 examples/hello-mysql/internal/modules/auth/handler_test.go create mode 100644 examples/hello-mysql/internal/modules/auth/routes.go create mode 100644 examples/hello-mysql/internal/modules/auth/token.go diff --git a/examples/hello-mysql/internal/modules/auth/handler.go b/examples/hello-mysql/internal/modules/auth/handler.go new file mode 100644 index 0000000..b0c6453 --- /dev/null +++ b/examples/hello-mysql/internal/modules/auth/handler.go @@ -0,0 +1,62 @@ +package auth + +import ( + "encoding/json" + "net/http" + + "github.com/go-modkit/modkit/examples/hello-mysql/internal/httpapi" +) + +type Handler struct { + cfg Config +} + +func NewHandler(cfg Config) *Handler { + return &Handler{cfg: cfg} +} + +func (h *Handler) RegisterRoutes(router Router) { + router.Handle(http.MethodPost, "/auth/login", http.HandlerFunc(h.handleLogin)) +} + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type tokenResponse struct { + Token string `json:"token"` +} + +func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { + var input loginRequest + if err := decodeJSON(r, &input); err != nil { + httpapi.WriteProblem(w, r, http.StatusBadRequest, "invalid body") + return + } + + if input.Username != h.cfg.Username || input.Password != h.cfg.Password { + httpapi.WriteProblem(w, r, http.StatusUnauthorized, "invalid credentials") + return + } + + token, err := IssueToken(h.cfg, User{ID: input.Username, Email: input.Username}) + if err != nil { + httpapi.WriteProblem(w, r, http.StatusInternalServerError, "internal error") + return + } + + writeJSON(w, http.StatusOK, tokenResponse{Token: token}) +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func decodeJSON(r *http.Request, target any) error { + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + return decoder.Decode(target) +} diff --git a/examples/hello-mysql/internal/modules/auth/handler_test.go b/examples/hello-mysql/internal/modules/auth/handler_test.go new file mode 100644 index 0000000..ceaa371 --- /dev/null +++ b/examples/hello-mysql/internal/modules/auth/handler_test.go @@ -0,0 +1,79 @@ +package auth + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + modkithttp "github.com/go-modkit/modkit/modkit/http" +) + +func setupAuthHandler() (Config, *Handler, http.Handler) { + cfg := Config{ + Secret: "test-secret", + Issuer: "test-issuer", + TTL: time.Minute, + Username: "demo", + Password: "s3cret", + } + + handler := NewHandler(cfg) + router := modkithttp.NewRouter() + handler.RegisterRoutes(modkithttp.AsRouter(router)) + + return cfg, handler, router +} + +func TestHandler_Login_BadJSON(t *testing.T) { + _, _, router := setupAuthHandler() + + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader([]byte(`{"username":`))) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestHandler_Login_WrongCreds(t *testing.T) { + _, _, router := setupAuthHandler() + + body := []byte(`{"username":"demo","password":"nope"}`) + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body)) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } +} + +func TestHandler_Login_Success(t *testing.T) { + cfg, _, router := setupAuthHandler() + + body := []byte(`{"username":"demo","password":"s3cret"}`) + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body)) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var payload struct { + Token string `json:"token"` + } + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.Token == "" { + t.Fatalf("expected token in response") + } + if _, err := parseToken(payload.Token, cfg, time.Now()); err != nil { + t.Fatalf("parse token: %v", err) + } +} diff --git a/examples/hello-mysql/internal/modules/auth/module.go b/examples/hello-mysql/internal/modules/auth/module.go index faa2596..953efe4 100644 --- a/examples/hello-mysql/internal/modules/auth/module.go +++ b/examples/hello-mysql/internal/modules/auth/module.go @@ -7,7 +7,9 @@ const ( TokenHandler module.Token = "auth.handler" ) -type Options struct{} +type Options struct { + Config Config +} type Module struct { opts Options @@ -21,18 +23,17 @@ func NewModule(opts Options) module.Module { func (m Module) Definition() module.ModuleDef { return module.ModuleDef{ - Name: "auth", - Providers: []module.ProviderDef{ - { - Token: TokenMiddleware, - Build: func(r module.Resolver) (any, error) { - return nil, nil - }, - }, + Name: "auth", + Providers: Providers(m.opts.Config), + Controllers: []module.ControllerDef{ { - Token: TokenHandler, + Name: "AuthController", Build: func(r module.Resolver) (any, error) { - return nil, nil + handlerAny, err := r.Get(TokenHandler) + if err != nil { + return nil, err + } + return handlerAny, nil }, }, }, diff --git a/examples/hello-mysql/internal/modules/auth/providers.go b/examples/hello-mysql/internal/modules/auth/providers.go index 9f7f3a0..6e90e4d 100644 --- a/examples/hello-mysql/internal/modules/auth/providers.go +++ b/examples/hello-mysql/internal/modules/auth/providers.go @@ -1,3 +1,20 @@ package auth -// placeholder file for future provider implementations +import "github.com/go-modkit/modkit/modkit/module" + +func Providers(cfg Config) []module.ProviderDef { + return []module.ProviderDef{ + { + Token: TokenMiddleware, + Build: func(r module.Resolver) (any, error) { + return NewJWTMiddleware(cfg), nil + }, + }, + { + Token: TokenHandler, + Build: func(r module.Resolver) (any, error) { + return NewHandler(cfg), nil + }, + }, + } +} diff --git a/examples/hello-mysql/internal/modules/auth/routes.go b/examples/hello-mysql/internal/modules/auth/routes.go new file mode 100644 index 0000000..ec679ea --- /dev/null +++ b/examples/hello-mysql/internal/modules/auth/routes.go @@ -0,0 +1,5 @@ +package auth + +import modkithttp "github.com/go-modkit/modkit/modkit/http" + +type Router = modkithttp.Router diff --git a/examples/hello-mysql/internal/modules/auth/token.go b/examples/hello-mysql/internal/modules/auth/token.go new file mode 100644 index 0000000..24d9428 --- /dev/null +++ b/examples/hello-mysql/internal/modules/auth/token.go @@ -0,0 +1,28 @@ +package auth + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func IssueToken(cfg Config, user User) (string, error) { + subject := user.ID + if subject == "" { + subject = user.Email + } + + claims := jwt.MapClaims{ + "iss": cfg.Issuer, + "exp": time.Now().Add(cfg.TTL).Unix(), + } + if subject != "" { + claims["sub"] = subject + } + if user.Email != "" { + claims["email"] = user.Email + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(cfg.Secret)) +} From 9bb4b7692761b280ae85b42677acffb5fcc741f8 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 07:27:24 +0200 Subject: [PATCH 06/20] 92: protect users routes with auth --- examples/hello-mysql/cmd/api/main.go | 22 ++++++++- .../internal/httpserver/docs_test.go | 14 +++++- .../internal/httpserver/server_test.go | 14 +++++- .../internal/modules/app/module.go | 7 ++- .../internal/modules/auth/module.go | 1 + .../internal/modules/users/controller.go | 19 +++++--- .../internal/modules/users/controller_test.go | 45 ++++++++++++++++--- .../internal/modules/users/module.go | 11 ++++- .../hello-mysql/internal/smoke/smoke_test.go | 44 +++++++++++++++++- 9 files changed, 155 insertions(+), 22 deletions(-) diff --git a/examples/hello-mysql/cmd/api/main.go b/examples/hello-mysql/cmd/api/main.go index 60e3145..39e395d 100644 --- a/examples/hello-mysql/cmd/api/main.go +++ b/examples/hello-mysql/cmd/api/main.go @@ -2,10 +2,12 @@ package main import ( "log" + "time" _ "github.com/go-modkit/modkit/examples/hello-mysql/docs" "github.com/go-modkit/modkit/examples/hello-mysql/internal/httpserver" "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/app" + "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/auth" "github.com/go-modkit/modkit/examples/hello-mysql/internal/platform/config" "github.com/go-modkit/modkit/examples/hello-mysql/internal/platform/logging" modkithttp "github.com/go-modkit/modkit/modkit/http" @@ -17,7 +19,25 @@ import ( // @BasePath / func main() { cfg := config.Load() - handler, err := httpserver.BuildHandler(app.Options{HTTPAddr: cfg.HTTPAddr, MySQLDSN: cfg.MySQLDSN}) + jwtTTL, err := time.ParseDuration(cfg.JWTTTL) + if err != nil { + log.Printf("invalid JWT_TTL %q, using 1h: %v", cfg.JWTTTL, err) + jwtTTL = time.Hour + } + + authCfg := auth.Config{ + Secret: cfg.JWTSecret, + Issuer: cfg.JWTIssuer, + TTL: jwtTTL, + Username: cfg.AuthUsername, + Password: cfg.AuthPassword, + } + + handler, err := httpserver.BuildHandler(app.Options{ + HTTPAddr: cfg.HTTPAddr, + MySQLDSN: cfg.MySQLDSN, + Auth: authCfg, + }) if err != nil { log.Fatalf("bootstrap failed: %v", err) } diff --git a/examples/hello-mysql/internal/httpserver/docs_test.go b/examples/hello-mysql/internal/httpserver/docs_test.go index 851ebf9..3aedb92 100644 --- a/examples/hello-mysql/internal/httpserver/docs_test.go +++ b/examples/hello-mysql/internal/httpserver/docs_test.go @@ -4,12 +4,24 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/app" + "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/auth" ) func TestBuildHandler_DocsRoute(t *testing.T) { - h, err := BuildHandler(app.Options{HTTPAddr: ":8080", MySQLDSN: "root:password@tcp(localhost:3306)/app?parseTime=true&multiStatements=true"}) + h, err := BuildHandler(app.Options{ + HTTPAddr: ":8080", + MySQLDSN: "root:password@tcp(localhost:3306)/app?parseTime=true&multiStatements=true", + Auth: auth.Config{ + Secret: "dev-secret-change-me", + Issuer: "hello-mysql", + TTL: time.Hour, + Username: "demo", + Password: "demo", + }, + }) if err != nil { t.Fatalf("build handler: %v", err) } diff --git a/examples/hello-mysql/internal/httpserver/server_test.go b/examples/hello-mysql/internal/httpserver/server_test.go index 3a42b07..870c609 100644 --- a/examples/hello-mysql/internal/httpserver/server_test.go +++ b/examples/hello-mysql/internal/httpserver/server_test.go @@ -7,8 +7,10 @@ import ( "net/http/httptest" "os" "testing" + "time" "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/app" + "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/auth" ) func TestBuildHandler_LogsRequest(t *testing.T) { @@ -23,7 +25,17 @@ func TestBuildHandler_LogsRequest(t *testing.T) { _ = r.Close() }() - h, err := BuildHandler(app.Options{HTTPAddr: ":8080", MySQLDSN: "root:password@tcp(localhost:3306)/app?parseTime=true&multiStatements=true"}) + h, err := BuildHandler(app.Options{ + HTTPAddr: ":8080", + MySQLDSN: "root:password@tcp(localhost:3306)/app?parseTime=true&multiStatements=true", + Auth: auth.Config{ + Secret: "dev-secret-change-me", + Issuer: "hello-mysql", + TTL: time.Hour, + Username: "demo", + Password: "demo", + }, + }) if err != nil { _ = w.Close() t.Fatalf("build handler: %v", err) diff --git a/examples/hello-mysql/internal/modules/app/module.go b/examples/hello-mysql/internal/modules/app/module.go index e367834..bd215a0 100644 --- a/examples/hello-mysql/internal/modules/app/module.go +++ b/examples/hello-mysql/internal/modules/app/module.go @@ -1,6 +1,7 @@ package app import ( + "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/auth" "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/audit" "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/database" "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/users" @@ -12,6 +13,7 @@ const HealthControllerID = "HealthController" type Options struct { HTTPAddr string MySQLDSN string + Auth auth.Config } type Module struct { @@ -26,12 +28,13 @@ func NewModule(opts Options) module.Module { func (m *Module) Definition() module.ModuleDef { dbModule := database.NewModule(database.Options{DSN: m.opts.MySQLDSN}) - usersModule := users.NewModule(users.Options{Database: dbModule}) + authModule := auth.NewModule(auth.Options{Config: m.opts.Auth}) + usersModule := users.NewModule(users.Options{Database: dbModule, Auth: authModule}) auditModule := audit.NewModule(audit.Options{Users: usersModule}) return module.ModuleDef{ Name: "app", - Imports: []module.Module{dbModule, usersModule, auditModule}, + Imports: []module.Module{dbModule, authModule, usersModule, auditModule}, Controllers: []module.ControllerDef{ { Name: HealthControllerID, diff --git a/examples/hello-mysql/internal/modules/auth/module.go b/examples/hello-mysql/internal/modules/auth/module.go index 953efe4..3f7ffcf 100644 --- a/examples/hello-mysql/internal/modules/auth/module.go +++ b/examples/hello-mysql/internal/modules/auth/module.go @@ -37,5 +37,6 @@ func (m Module) Definition() module.ModuleDef { }, }, }, + Exports: []module.Token{TokenMiddleware}, } } diff --git a/examples/hello-mysql/internal/modules/users/controller.go b/examples/hello-mysql/internal/modules/users/controller.go index 1a9ffb0..7dfbce6 100644 --- a/examples/hello-mysql/internal/modules/users/controller.go +++ b/examples/hello-mysql/internal/modules/users/controller.go @@ -11,19 +11,24 @@ import ( ) type Controller struct { - service Service + service Service + authMiddleware func(http.Handler) http.Handler } -func NewController(service Service) *Controller { - return &Controller{service: service} +func NewController(service Service, authMiddleware func(http.Handler) http.Handler) *Controller { + return &Controller{service: service, authMiddleware: authMiddleware} } func (c *Controller) RegisterRoutes(router Router) { - router.Handle(http.MethodGet, "/users/{id}", http.HandlerFunc(c.handleGetUser)) - router.Handle(http.MethodPost, "/users", http.HandlerFunc(c.handleCreateUser)) router.Handle(http.MethodGet, "/users", http.HandlerFunc(c.handleListUsers)) - router.Handle(http.MethodPut, "/users/{id}", http.HandlerFunc(c.handleUpdateUser)) - router.Handle(http.MethodDelete, "/users/{id}", http.HandlerFunc(c.handleDeleteUser)) + + router.Group("/", func(r Router) { + r.Use(c.authMiddleware) + r.Handle(http.MethodGet, "/users/{id}", http.HandlerFunc(c.handleGetUser)) + r.Handle(http.MethodPost, "/users", http.HandlerFunc(c.handleCreateUser)) + r.Handle(http.MethodPut, "/users/{id}", http.HandlerFunc(c.handleUpdateUser)) + r.Handle(http.MethodDelete, "/users/{id}", http.HandlerFunc(c.handleDeleteUser)) + }) } // @Summary Get user diff --git a/examples/hello-mysql/internal/modules/users/controller_test.go b/examples/hello-mysql/internal/modules/users/controller_test.go index da8972e..63d25d9 100644 --- a/examples/hello-mysql/internal/modules/users/controller_test.go +++ b/examples/hello-mysql/internal/modules/users/controller_test.go @@ -7,7 +7,9 @@ import ( "net/http" "net/http/httptest" "testing" + "time" + "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/auth" "github.com/go-modkit/modkit/examples/hello-mysql/internal/httpapi" modkithttp "github.com/go-modkit/modkit/modkit/http" ) @@ -20,6 +22,9 @@ type stubService struct { getFn func(ctx context.Context, id int64) (User, error) } +func allowAll(next http.Handler) http.Handler { + return next +} func (s stubService) GetUser(ctx context.Context, id int64) (User, error) { if s.getFn == nil { return User{}, nil @@ -56,7 +61,7 @@ func TestController_CreateUser(t *testing.T) { deleteFn: func(ctx context.Context, id int64) error { return nil }, } - controller := NewController(svc) + controller := NewController(svc, allowAll) router := modkithttp.NewRouter() controller.RegisterRoutes(modkithttp.AsRouter(router)) @@ -87,7 +92,7 @@ func TestController_CreateUser_Conflict(t *testing.T) { deleteFn: func(ctx context.Context, id int64) error { return nil }, } - controller := NewController(svc) + controller := NewController(svc, allowAll) router := modkithttp.NewRouter() controller.RegisterRoutes(modkithttp.AsRouter(router)) @@ -119,7 +124,7 @@ func TestController_GetUser_NotFound(t *testing.T) { deleteFn: func(ctx context.Context, id int64) error { return nil }, } - controller := NewController(svc) + controller := NewController(svc, allowAll) router := modkithttp.NewRouter() controller.RegisterRoutes(modkithttp.AsRouter(router)) @@ -149,7 +154,7 @@ func TestController_ListUsers(t *testing.T) { deleteFn: func(ctx context.Context, id int64) error { return nil }, } - controller := NewController(svc) + controller := NewController(svc, allowAll) router := modkithttp.NewRouter() controller.RegisterRoutes(modkithttp.AsRouter(router)) @@ -182,7 +187,7 @@ func TestController_UpdateUser(t *testing.T) { deleteFn: func(ctx context.Context, id int64) error { return nil }, } - controller := NewController(svc) + controller := NewController(svc, allowAll) router := modkithttp.NewRouter() controller.RegisterRoutes(modkithttp.AsRouter(router)) @@ -216,7 +221,7 @@ func TestController_DeleteUser(t *testing.T) { }, } - controller := NewController(svc) + controller := NewController(svc, allowAll) router := modkithttp.NewRouter() controller.RegisterRoutes(modkithttp.AsRouter(router)) @@ -228,3 +233,31 @@ func TestController_DeleteUser(t *testing.T) { t.Fatalf("expected status 204, got %d", rec.Code) } } + +func TestController_CreateUser_RequiresAuth(t *testing.T) { + svc := stubService{ + createFn: func(ctx context.Context, input CreateUserInput) (User, error) { return User{}, nil }, + listFn: func(ctx context.Context) ([]User, error) { return nil, nil }, + updateFn: func(ctx context.Context, id int64, input UpdateUserInput) (User, error) { return User{}, nil }, + deleteFn: func(ctx context.Context, id int64) error { return nil }, + } + + mw := auth.NewJWTMiddleware(auth.Config{ + Secret: "test-secret", + Issuer: "test-issuer", + TTL: time.Minute, + }) + + controller := NewController(svc, mw) + router := modkithttp.NewRouter() + controller.RegisterRoutes(modkithttp.AsRouter(router)) + + body := []byte(`{"name":"Ada","email":"ada@example.com"}`) + req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body)) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected status 401, got %d", rec.Code) + } +} diff --git a/examples/hello-mysql/internal/modules/users/module.go b/examples/hello-mysql/internal/modules/users/module.go index fbd621b..1b19556 100644 --- a/examples/hello-mysql/internal/modules/users/module.go +++ b/examples/hello-mysql/internal/modules/users/module.go @@ -2,8 +2,10 @@ package users import ( "database/sql" + "net/http" "time" + "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/auth" "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/database" "github.com/go-modkit/modkit/examples/hello-mysql/internal/platform/logging" "github.com/go-modkit/modkit/examples/hello-mysql/internal/sqlc" @@ -19,6 +21,7 @@ const ( type Options struct { Database module.Module + Auth module.Module } type Module struct { @@ -34,7 +37,7 @@ func NewModule(opts Options) module.Module { func (m Module) Definition() module.ModuleDef { return module.ModuleDef{ Name: "users", - Imports: []module.Module{m.opts.Database}, + Imports: []module.Module{m.opts.Database, m.opts.Auth}, Providers: []module.ProviderDef{ { Token: TokenRepository, @@ -66,7 +69,11 @@ func (m Module) Definition() module.ModuleDef { if err != nil { return nil, err } - return NewController(svcAny.(Service)), nil + authAny, err := r.Get(auth.TokenMiddleware) + if err != nil { + return nil, err + } + return NewController(svcAny.(Service), authAny.(func(http.Handler) http.Handler)), nil }, }, }, diff --git a/examples/hello-mysql/internal/smoke/smoke_test.go b/examples/hello-mysql/internal/smoke/smoke_test.go index 6cf89d1..53ef0dd 100644 --- a/examples/hello-mysql/internal/smoke/smoke_test.go +++ b/examples/hello-mysql/internal/smoke/smoke_test.go @@ -1,6 +1,7 @@ package smoke import ( + "bytes" "context" "database/sql" "encoding/json" @@ -12,6 +13,7 @@ import ( "github.com/go-modkit/modkit/examples/hello-mysql/internal/httpserver" "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/app" + "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/auth" "github.com/go-modkit/modkit/examples/hello-mysql/internal/platform/mysql" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" @@ -32,7 +34,17 @@ func TestSmoke_HealthAndUsers(t *testing.T) { t.Fatalf("seed failed: %v", err) } - handler, err := httpserver.BuildHandler(app.Options{HTTPAddr: ":8080", MySQLDSN: dsn}) + handler, err := httpserver.BuildHandler(app.Options{ + HTTPAddr: ":8080", + MySQLDSN: dsn, + Auth: auth.Config{ + Secret: "dev-secret-change-me", + Issuer: "hello-mysql", + TTL: time.Hour, + Username: "demo", + Password: "demo", + }, + }) if err != nil { t.Fatalf("build handler failed: %v", err) } @@ -48,7 +60,35 @@ func TestSmoke_HealthAndUsers(t *testing.T) { t.Fatalf("expected 200, got %d", resp.StatusCode) } - resp, err = http.Get(srv.URL + "/users/1") + loginBody := []byte(`{"username":"demo","password":"demo"}`) + loginReq, err := http.NewRequest(http.MethodPost, srv.URL+"/auth/login", bytes.NewReader(loginBody)) + if err != nil { + t.Fatalf("login request failed: %v", err) + } + loginReq.Header.Set("Content-Type", "application/json") + loginResp, err := http.DefaultClient.Do(loginReq) + if err != nil { + t.Fatalf("login request failed: %v", err) + } + if loginResp.StatusCode != http.StatusOK { + t.Fatalf("expected login 200, got %d", loginResp.StatusCode) + } + var loginPayload struct { + Token string `json:"token"` + } + if err := json.NewDecoder(loginResp.Body).Decode(&loginPayload); err != nil { + t.Fatalf("decode login response: %v", err) + } + if loginPayload.Token == "" { + t.Fatalf("expected login token") + } + + userReq, err := http.NewRequest(http.MethodGet, srv.URL+"/users/1", nil) + if err != nil { + t.Fatalf("users request failed: %v", err) + } + userReq.Header.Set("Authorization", "Bearer "+loginPayload.Token) + resp, err = http.DefaultClient.Do(userReq) if err != nil { t.Fatalf("users request failed: %v", err) } From 291f7e4d38c4f45fca3f16988cab363e971ad3f9 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 07:33:40 +0200 Subject: [PATCH 07/20] 93: add auth module tests --- .../internal/modules/auth/auth_test.go | 40 ++++++++ .../internal/modules/auth/integration_test.go | 91 +++++++++++++++++++ .../internal/modules/auth/middleware.go | 30 +++++- .../internal/modules/auth/middleware_test.go | 2 + 4 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 examples/hello-mysql/internal/modules/auth/auth_test.go create mode 100644 examples/hello-mysql/internal/modules/auth/integration_test.go diff --git a/examples/hello-mysql/internal/modules/auth/auth_test.go b/examples/hello-mysql/internal/modules/auth/auth_test.go new file mode 100644 index 0000000..2f5aa31 --- /dev/null +++ b/examples/hello-mysql/internal/modules/auth/auth_test.go @@ -0,0 +1,40 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestJWTMiddleware_SetsUserContext(t *testing.T) { + cfg := Config{ + Secret: "test-secret", + Issuer: "test-issuer", + TTL: time.Minute, + } + token, err := IssueToken(cfg, User{ID: "demo", Email: "demo@example.com"}) + if err != nil { + t.Fatalf("issue token: %v", err) + } + + mw := NewJWTMiddleware(cfg) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, ok := UserFromContext(r.Context()) + if !ok { + t.Fatal("expected user in context") + } + if user.Email != "demo@example.com" { + t.Fatalf("unexpected user: %+v", user) + } + w.WriteHeader(http.StatusOK) + })).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +} diff --git a/examples/hello-mysql/internal/modules/auth/integration_test.go b/examples/hello-mysql/internal/modules/auth/integration_test.go new file mode 100644 index 0000000..0e0e921 --- /dev/null +++ b/examples/hello-mysql/internal/modules/auth/integration_test.go @@ -0,0 +1,91 @@ +package auth + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + modkithttp "github.com/go-modkit/modkit/modkit/http" +) + +func TestAuthIntegration_LoginAndProtect(t *testing.T) { + cfg := Config{ + Secret: "test-secret", + Issuer: "test-issuer", + TTL: time.Minute, + Username: "demo", + Password: "s3cret", + } + + router := modkithttp.NewRouter() + root := modkithttp.AsRouter(router) + handler := NewHandler(cfg) + handler.RegisterRoutes(root) + + root.Group("/", func(r Router) { + r.Use(NewJWTMiddleware(cfg)) + r.Handle(http.MethodGet, "/protected", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, ok := UserFromContext(r.Context()) + if !ok { + t.Fatal("expected user in context") + } + if user.Email == "" { + t.Fatal("expected email in context") + } + w.WriteHeader(http.StatusOK) + })) + }) + + loginBody := []byte(`{"username":"demo","password":"s3cret"}`) + loginReq := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(loginBody)) + loginRec := httptest.NewRecorder() + router.ServeHTTP(loginRec, loginReq) + if loginRec.Code != http.StatusOK { + t.Fatalf("expected login 200, got %d", loginRec.Code) + } + + var loginResp struct { + Token string `json:"token"` + } + if err := json.NewDecoder(loginRec.Body).Decode(&loginResp); err != nil { + t.Fatalf("decode login response: %v", err) + } + if loginResp.Token == "" { + t.Fatal("expected login token") + } + + protectedReq := httptest.NewRequest(http.MethodGet, "/protected", nil) + protectedReq.Header.Set("Authorization", "Bearer "+loginResp.Token) + protectedRec := httptest.NewRecorder() + router.ServeHTTP(protectedRec, protectedReq) + if protectedRec.Code != http.StatusOK { + t.Fatalf("expected protected 200, got %d", protectedRec.Code) + } +} + +func TestAuthIntegration_Login_InvalidCredentials(t *testing.T) { + cfg := Config{ + Secret: "test-secret", + Issuer: "test-issuer", + TTL: time.Minute, + Username: "demo", + Password: "s3cret", + } + + router := modkithttp.NewRouter() + root := modkithttp.AsRouter(router) + handler := NewHandler(cfg) + handler.RegisterRoutes(root) + + loginBody := []byte(`{"username":"demo","password":"wrong"}`) + loginReq := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(loginBody)) + loginRec := httptest.NewRecorder() + router.ServeHTTP(loginRec, loginReq) + + if loginRec.Code != http.StatusUnauthorized { + t.Fatalf("expected login 401, got %d", loginRec.Code) + } +} diff --git a/examples/hello-mysql/internal/modules/auth/middleware.go b/examples/hello-mysql/internal/modules/auth/middleware.go index 25294f4..fc3d91e 100644 --- a/examples/hello-mysql/internal/modules/auth/middleware.go +++ b/examples/hello-mysql/internal/modules/auth/middleware.go @@ -17,12 +17,24 @@ func NewJWTMiddleware(cfg Config) func(http.Handler) http.Handler { return } - if _, err := parseToken(tokenStr, cfg, time.Now()); err != nil { + token, err := parseToken(tokenStr, cfg, time.Now()) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + w.WriteHeader(http.StatusUnauthorized) + return + } + user, ok := userFromClaims(claims) + if !ok { w.WriteHeader(http.StatusUnauthorized) return } - next.ServeHTTP(w, r) + ctx := WithUser(r.Context(), user) + next.ServeHTTP(w, r.WithContext(ctx)) }) } } @@ -56,3 +68,17 @@ func parseToken(tokenStr string, cfg Config, now time.Time) (*jwt.Token, error) return []byte(cfg.Secret), nil }) } + +func userFromClaims(claims jwt.MapClaims) (User, bool) { + user := User{} + if subject, ok := claims["sub"].(string); ok { + user.ID = subject + } + if email, ok := claims["email"].(string); ok { + user.Email = email + } + if user.ID == "" && user.Email == "" { + return User{}, false + } + return user, true +} diff --git a/examples/hello-mysql/internal/modules/auth/middleware_test.go b/examples/hello-mysql/internal/modules/auth/middleware_test.go index da99e57..7ce1f6b 100644 --- a/examples/hello-mysql/internal/modules/auth/middleware_test.go +++ b/examples/hello-mysql/internal/modules/auth/middleware_test.go @@ -15,6 +15,8 @@ func TestJWTMiddleware(t *testing.T) { makeToken := func(exp time.Time) string { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "demo", + "email": "demo@example.com", "iss": issuer, "exp": exp.Unix(), }) From 9a0ff0ebd3a68bb400f6d4d3c8eed2b47c71d110 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 07:41:01 +0200 Subject: [PATCH 08/20] 94: update README with auth examples --- examples/hello-mysql/README.md | 41 +++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/examples/hello-mysql/README.md b/examples/hello-mysql/README.md index c57e89b..b0fe48b 100644 --- a/examples/hello-mysql/README.md +++ b/examples/hello-mysql/README.md @@ -6,6 +6,7 @@ Example consuming app for modkit using MySQL, sqlc, and migrations. - Modules: `AppModule`, `DatabaseModule`, `UsersModule`, `AuditModule` (consumes `UsersService` export). - Endpoints: - `GET /health` → `{ "status": "ok" }` + - `POST /auth/login` → demo JWT token - `POST /users` → create user - `GET /users` → list users - `GET /users/{id}` → user payload @@ -18,6 +19,16 @@ Example consuming app for modkit using MySQL, sqlc, and migrations. - JSON request logging via `log/slog`. - Errors use RFC 7807 Problem Details (`application/problem+json`). +## Auth +- Demo login endpoint: `POST /auth/login` returns a JWT. +- Protected routes (require `Authorization: Bearer `): + - `POST /users` + - `GET /users/{id}` + - `PUT /users/{id}` + - `DELETE /users/{id}` +- Public route: + - `GET /users` + ## Run (Docker Compose + Local Migrate) ```bash @@ -30,12 +41,27 @@ Then hit: ```bash curl http://localhost:8080/health -curl -X POST http://localhost:8080/users -H 'Content-Type: application/json' -d '{"name":"Ada","email":"ada@example.com"}' + +# Login to get a token (demo credentials). Requires `jq` for parsing. +TOKEN=$(curl -s -X POST http://localhost:8080/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"demo","password":"demo"}' | jq -r '.token') + +# Public route curl http://localhost:8080/users -curl http://localhost:8080/users/1 -curl -X PUT http://localhost:8080/users/1 -H 'Content-Type: application/json' -d '{"name":"Ada Lovelace","email":"ada@example.com"}' -curl -X DELETE http://localhost:8080/users/1 -curl -X POST http://localhost:8080/users -H 'Content-Type: application/json' -d '{"name":"Ada","email":"ada@example.com"}' + +# Protected routes (require Authorization header) +curl -X POST http://localhost:8080/users \ + -H 'Authorization: Bearer '"$TOKEN"'' \ + -H 'Content-Type: application/json' \ + -d '{"name":"Ada","email":"ada@example.com"}' +curl -H 'Authorization: Bearer '"$TOKEN"'' http://localhost:8080/users/1 +curl -X PUT http://localhost:8080/users/1 \ + -H 'Authorization: Bearer '"$TOKEN"'' \ + -H 'Content-Type: application/json' \ + -d '{"name":"Ada Lovelace","email":"ada@example.com"}' +curl -X DELETE http://localhost:8080/users/1 -H 'Authorization: Bearer '"$TOKEN"'' + open http://localhost:8080/docs/index.html ``` @@ -70,6 +96,11 @@ The compose services build from `examples/hello-mysql/Dockerfile`. Environment variables: - `HTTP_ADDR` (default `:8080`) - `MYSQL_DSN` (default `root:password@tcp(localhost:3306)/app?parseTime=true&multiStatements=true`) +- `JWT_SECRET` (default `dev-secret-change-me`) +- `JWT_ISSUER` (default `hello-mysql`) +- `JWT_TTL` (default `1h`) +- `AUTH_USERNAME` (default `demo`) +- `AUTH_PASSWORD` (default `demo`) - `LOG_FORMAT` (`text` or `json`, default `text`) - `LOG_LEVEL` (`debug`, `info`, `warn`, `error`, default `info`) - `LOG_COLOR` (`auto`, `on`, `off`, default `auto`) From aade084d79c6e15a723a7de5c7e3b442af796648 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 07:46:12 +0200 Subject: [PATCH 09/20] chore: format go files --- examples/hello-mysql/internal/modules/app/module.go | 2 +- .../hello-mysql/internal/modules/auth/middleware_test.go | 6 +++--- .../hello-mysql/internal/modules/users/controller_test.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/hello-mysql/internal/modules/app/module.go b/examples/hello-mysql/internal/modules/app/module.go index bd215a0..fa1b521 100644 --- a/examples/hello-mysql/internal/modules/app/module.go +++ b/examples/hello-mysql/internal/modules/app/module.go @@ -1,8 +1,8 @@ package app import ( - "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/auth" "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/audit" + "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/auth" "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/database" "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/users" "github.com/go-modkit/modkit/modkit/module" diff --git a/examples/hello-mysql/internal/modules/auth/middleware_test.go b/examples/hello-mysql/internal/modules/auth/middleware_test.go index 7ce1f6b..0de8b02 100644 --- a/examples/hello-mysql/internal/modules/auth/middleware_test.go +++ b/examples/hello-mysql/internal/modules/auth/middleware_test.go @@ -15,10 +15,10 @@ func TestJWTMiddleware(t *testing.T) { makeToken := func(exp time.Time) string { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - "sub": "demo", + "sub": "demo", "email": "demo@example.com", - "iss": issuer, - "exp": exp.Unix(), + "iss": issuer, + "exp": exp.Unix(), }) signed, err := token.SignedString(secret) if err != nil { diff --git a/examples/hello-mysql/internal/modules/users/controller_test.go b/examples/hello-mysql/internal/modules/users/controller_test.go index 63d25d9..1f15364 100644 --- a/examples/hello-mysql/internal/modules/users/controller_test.go +++ b/examples/hello-mysql/internal/modules/users/controller_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/auth" "github.com/go-modkit/modkit/examples/hello-mysql/internal/httpapi" + "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/auth" modkithttp "github.com/go-modkit/modkit/modkit/http" ) From b7f1a01d58badaba488009b5e35cd0e433f9132f Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 07:53:47 +0200 Subject: [PATCH 10/20] test: update app module import expectations --- .../internal/modules/app/app_test.go | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/examples/hello-mysql/internal/modules/app/app_test.go b/examples/hello-mysql/internal/modules/app/app_test.go index 0f85b8a..30cb010 100644 --- a/examples/hello-mysql/internal/modules/app/app_test.go +++ b/examples/hello-mysql/internal/modules/app/app_test.go @@ -1,17 +1,32 @@ package app -import "testing" +import ( + "testing" + "time" + + "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/auth" +) func TestModule_DefinitionIncludesImports(t *testing.T) { - mod := NewModule(Options{HTTPAddr: ":8080", MySQLDSN: "user:pass@tcp(localhost:3306)/app"}) + mod := NewModule(Options{ + HTTPAddr: ":8080", + MySQLDSN: "user:pass@tcp(localhost:3306)/app", + Auth: auth.Config{ + Secret: "test-secret", + Issuer: "test-issuer", + TTL: time.Minute, + Username: "demo", + Password: "demo", + }, + }) def := mod.Definition() if def.Name == "" { t.Fatalf("expected module name") } - if len(def.Imports) != 3 { - t.Fatalf("expected 3 imports, got %d", len(def.Imports)) + if len(def.Imports) != 4 { + t.Fatalf("expected 4 imports, got %d", len(def.Imports)) } seen := map[string]bool{} @@ -19,7 +34,7 @@ func TestModule_DefinitionIncludesImports(t *testing.T) { seen[imp.Definition().Name] = true } - for _, name := range []string{"database", "users", "audit"} { + for _, name := range []string{"database", "auth", "users", "audit"} { if !seen[name] { t.Fatalf("expected import %s", name) } From 2a55a98731a6af31139000ba5973241edc5aaaf8 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 07:58:35 +0200 Subject: [PATCH 11/20] fix: address PR review feedback --- docs/plans/2026-02-05-auth-example-design.md | 2 +- examples/hello-mysql/go.mod | 2 +- examples/hello-mysql/go.sum | 2 ++ .../internal/modules/auth/handler.go | 1 + .../internal/modules/auth/middleware.go | 8 +++-- .../internal/modules/auth/token.go | 8 +++++ .../internal/modules/users/controller_test.go | 36 +++++++++++++++++++ .../hello-mysql/internal/smoke/smoke_test.go | 2 ++ 8 files changed, 57 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-02-05-auth-example-design.md b/docs/plans/2026-02-05-auth-example-design.md index 890fb80..b8471ac 100644 --- a/docs/plans/2026-02-05-auth-example-design.md +++ b/docs/plans/2026-02-05-auth-example-design.md @@ -2,7 +2,7 @@ **Goal:** Add a runnable, example-focused JWT authentication module to hello-mysql with a login endpoint, middleware validation, and typed context helpers. -**Architecture:** A dedicated `auth` module provides a login handler and a JWT middleware provider. Configuration is explicit via example config/env. The middleware validates tokens and stores user info in a typed context helper, which handlers can read. The users module stays unchanged until route protection is applied in later tasks. +**Architecture:** A dedicated `auth` module provides a login handler and a JWT middleware provider. Configuration is explicit via example config/env. The middleware validates tokens and stores user info in a typed context helper, which handlers can read. User write routes (`POST /users`, `PUT /users/{id}`, `DELETE /users/{id}`) are protected by the auth middleware, while the list route (`GET /users`) remains public. **Tech Stack:** Go, chi router via modkit http adapter, standard library + minimal JWT dependency. diff --git a/examples/hello-mysql/go.mod b/examples/hello-mysql/go.mod index 86fe2ba..63fb584 100644 --- a/examples/hello-mysql/go.mod +++ b/examples/hello-mysql/go.mod @@ -38,7 +38,7 @@ require ( github.com/go-openapi/jsonreference v0.20.0 // indirect github.com/go-openapi/spec v0.20.6 // indirect github.com/go-openapi/swag v0.19.15 // indirect - github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.0 // indirect diff --git a/examples/hello-mysql/go.sum b/examples/hello-mysql/go.sum index c349a6d..16d172c 100644 --- a/examples/hello-mysql/go.sum +++ b/examples/hello-mysql/go.sum @@ -63,6 +63,8 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1 github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +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/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/examples/hello-mysql/internal/modules/auth/handler.go b/examples/hello-mysql/internal/modules/auth/handler.go index b0c6453..e63b3e4 100644 --- a/examples/hello-mysql/internal/modules/auth/handler.go +++ b/examples/hello-mysql/internal/modules/auth/handler.go @@ -29,6 +29,7 @@ type tokenResponse struct { } func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) var input loginRequest if err := decodeJSON(r, &input); err != nil { httpapi.WriteProblem(w, r, http.StatusBadRequest, "invalid body") diff --git a/examples/hello-mysql/internal/modules/auth/middleware.go b/examples/hello-mysql/internal/modules/auth/middleware.go index fc3d91e..5a9188d 100644 --- a/examples/hello-mysql/internal/modules/auth/middleware.go +++ b/examples/hello-mysql/internal/modules/auth/middleware.go @@ -13,22 +13,26 @@ func NewJWTMiddleware(cfg Config) func(http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenStr := bearerToken(r.Header.Get("Authorization")) if tokenStr == "" { + w.Header().Set("WWW-Authenticate", "Bearer") w.WriteHeader(http.StatusUnauthorized) return } token, err := parseToken(tokenStr, cfg, time.Now()) if err != nil { + w.Header().Set("WWW-Authenticate", "Bearer") w.WriteHeader(http.StatusUnauthorized) return } claims, ok := token.Claims.(jwt.MapClaims) if !ok { + w.Header().Set("WWW-Authenticate", "Bearer") w.WriteHeader(http.StatusUnauthorized) return } user, ok := userFromClaims(claims) if !ok { + w.Header().Set("WWW-Authenticate", "Bearer") w.WriteHeader(http.StatusUnauthorized) return } @@ -41,11 +45,11 @@ func NewJWTMiddleware(cfg Config) func(http.Handler) http.Handler { func bearerToken(header string) string { const prefix = "Bearer " - if !strings.HasPrefix(header, prefix) { + if len(header) < len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) { return "" } - token := strings.TrimSpace(strings.TrimPrefix(header, prefix)) + token := strings.TrimSpace(header[len(prefix):]) if token == "" { return "" } diff --git a/examples/hello-mysql/internal/modules/auth/token.go b/examples/hello-mysql/internal/modules/auth/token.go index 24d9428..2d9b4f6 100644 --- a/examples/hello-mysql/internal/modules/auth/token.go +++ b/examples/hello-mysql/internal/modules/auth/token.go @@ -1,12 +1,20 @@ package auth import ( + "fmt" "time" "github.com/golang-jwt/jwt/v5" ) func IssueToken(cfg Config, user User) (string, error) { + if cfg.Secret == "" { + return "", fmt.Errorf("auth: missing jwt secret") + } + if cfg.TTL <= 0 { + return "", fmt.Errorf("auth: invalid jwt ttl") + } + subject := user.ID if subject == "" { subject = user.Email diff --git a/examples/hello-mysql/internal/modules/users/controller_test.go b/examples/hello-mysql/internal/modules/users/controller_test.go index 1f15364..d4deadd 100644 --- a/examples/hello-mysql/internal/modules/users/controller_test.go +++ b/examples/hello-mysql/internal/modules/users/controller_test.go @@ -261,3 +261,39 @@ func TestController_CreateUser_RequiresAuth(t *testing.T) { t.Fatalf("expected status 401, got %d", rec.Code) } } + +func TestController_CreateUser_WithAuth(t *testing.T) { + svc := stubService{ + createFn: func(ctx context.Context, input CreateUserInput) (User, error) { + return User{ID: 10, Name: input.Name, Email: input.Email}, nil + }, + listFn: func(ctx context.Context) ([]User, error) { return nil, nil }, + updateFn: func(ctx context.Context, id int64, input UpdateUserInput) (User, error) { return User{}, nil }, + deleteFn: func(ctx context.Context, id int64) error { return nil }, + } + + cfg := auth.Config{ + Secret: "test-secret", + Issuer: "test-issuer", + TTL: time.Minute, + } + token, err := auth.IssueToken(cfg, auth.User{ID: "demo", Email: "demo@example.com"}) + if err != nil { + t.Fatalf("issue token: %v", err) + } + + mw := auth.NewJWTMiddleware(cfg) + controller := NewController(svc, mw) + router := modkithttp.NewRouter() + controller.RegisterRoutes(modkithttp.AsRouter(router)) + + body := []byte(`{"name":"Ada","email":"ada@example.com"}`) + req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d", rec.Code) + } +} diff --git a/examples/hello-mysql/internal/smoke/smoke_test.go b/examples/hello-mysql/internal/smoke/smoke_test.go index 53ef0dd..6e7cf20 100644 --- a/examples/hello-mysql/internal/smoke/smoke_test.go +++ b/examples/hello-mysql/internal/smoke/smoke_test.go @@ -70,6 +70,7 @@ func TestSmoke_HealthAndUsers(t *testing.T) { if err != nil { t.Fatalf("login request failed: %v", err) } + defer loginResp.Body.Close() if loginResp.StatusCode != http.StatusOK { t.Fatalf("expected login 200, got %d", loginResp.StatusCode) } @@ -92,6 +93,7 @@ func TestSmoke_HealthAndUsers(t *testing.T) { if err != nil { t.Fatalf("users request failed: %v", err) } + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } From 972ec9bb875192f153e8197918a6b1c659c104e1 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 08:12:10 +0200 Subject: [PATCH 12/20] test: cover config defaults --- .../internal/platform/config/config_test.go | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 examples/hello-mysql/internal/platform/config/config_test.go diff --git a/examples/hello-mysql/internal/platform/config/config_test.go b/examples/hello-mysql/internal/platform/config/config_test.go new file mode 100644 index 0000000..2ad649e --- /dev/null +++ b/examples/hello-mysql/internal/platform/config/config_test.go @@ -0,0 +1,29 @@ +package config + +import "testing" + +func TestLoad_Defaults(t *testing.T) { + t.Setenv("HTTP_ADDR", "") + t.Setenv("MYSQL_DSN", "") + t.Setenv("JWT_SECRET", "") + t.Setenv("JWT_ISSUER", "") + t.Setenv("JWT_TTL", "") + t.Setenv("AUTH_USERNAME", "") + t.Setenv("AUTH_PASSWORD", "") + + cfg := Load() + + if cfg.HTTPAddr != ":8080" { + t.Fatalf("HTTPAddr = %q", cfg.HTTPAddr) + } + if cfg.JWTSecret != "dev-secret-change-me" { + t.Fatalf("JWTSecret = %q", cfg.JWTSecret) + } +} + +func TestEnvOrDefault_TrimsSpace(t *testing.T) { + t.Setenv("JWT_ISSUER", " ") + if got := envOrDefault("JWT_ISSUER", "hello-mysql"); got != "hello-mysql" { + t.Fatalf("envOrDefault = %q", got) + } +} From 5c95cd3df9cad3c474aa3d03d4a74a9a6030d903 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 08:16:44 +0200 Subject: [PATCH 13/20] test: cover IssueToken guardrails --- .../internal/modules/auth/token_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 examples/hello-mysql/internal/modules/auth/token_test.go diff --git a/examples/hello-mysql/internal/modules/auth/token_test.go b/examples/hello-mysql/internal/modules/auth/token_test.go new file mode 100644 index 0000000..020fc5a --- /dev/null +++ b/examples/hello-mysql/internal/modules/auth/token_test.go @@ -0,0 +1,18 @@ +package auth + +import ( + "testing" + "time" +) + +func TestIssueToken_InvalidConfig(t *testing.T) { + _, err := IssueToken(Config{Secret: "", TTL: time.Minute}, User{ID: "demo"}) + if err == nil { + t.Fatal("expected error") + } + + _, err = IssueToken(Config{Secret: "secret", TTL: 0}, User{ID: "demo"}) + if err == nil { + t.Fatal("expected error") + } +} From 532d910e368d7feed956c953f2a0165097c75cba Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 08:21:46 +0200 Subject: [PATCH 14/20] test: cover auth middleware edge cases --- .../internal/modules/auth/claims_test.go | 13 +++++++++++ .../internal/modules/auth/middleware_test.go | 23 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 examples/hello-mysql/internal/modules/auth/claims_test.go diff --git a/examples/hello-mysql/internal/modules/auth/claims_test.go b/examples/hello-mysql/internal/modules/auth/claims_test.go new file mode 100644 index 0000000..a04e669 --- /dev/null +++ b/examples/hello-mysql/internal/modules/auth/claims_test.go @@ -0,0 +1,13 @@ +package auth + +import ( + "testing" + + "github.com/golang-jwt/jwt/v5" +) + +func TestUserFromClaims_RejectsEmpty(t *testing.T) { + if _, ok := userFromClaims(jwt.MapClaims{}); ok { + t.Fatal("expected false") + } +} diff --git a/examples/hello-mysql/internal/modules/auth/middleware_test.go b/examples/hello-mysql/internal/modules/auth/middleware_test.go index 0de8b02..ca0d998 100644 --- a/examples/hello-mysql/internal/modules/auth/middleware_test.go +++ b/examples/hello-mysql/internal/modules/auth/middleware_test.go @@ -73,3 +73,26 @@ func TestJWTMiddleware(t *testing.T) { }) } } + +func TestBearerToken_CaseInsensitive(t *testing.T) { + got := bearerToken("bearer abc") + if got != "abc" { + t.Fatalf("token = %q", got) + } +} + +func TestJWTMiddleware_WWWAuthenticateOnMissingToken(t *testing.T) { + mw := NewJWTMiddleware(Config{Secret: "secret", Issuer: "issuer"}) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })).ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d", rec.Code) + } + if got := rec.Header().Get("WWW-Authenticate"); got != "Bearer" { + t.Fatalf("WWW-Authenticate = %q", got) + } +} From bd700d67006cfa4d3682dbe2156ff6d6ae69f6da Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 08:28:43 +0200 Subject: [PATCH 15/20] test: cover module wiring --- .../internal/modules/auth/module.go | 2 +- .../internal/modules/auth/module_test.go | 13 ++++++++ .../internal/modules/auth/providers_test.go | 31 +++++++++++++++++++ .../internal/modules/users/module.go | 2 +- .../internal/modules/users/module_test.go | 20 ++++++++++++ modkit/module/token.go | 8 +++++ 6 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 examples/hello-mysql/internal/modules/auth/providers_test.go create mode 100644 examples/hello-mysql/internal/modules/users/module_test.go diff --git a/examples/hello-mysql/internal/modules/auth/module.go b/examples/hello-mysql/internal/modules/auth/module.go index 3f7ffcf..f9e0d8e 100644 --- a/examples/hello-mysql/internal/modules/auth/module.go +++ b/examples/hello-mysql/internal/modules/auth/module.go @@ -18,7 +18,7 @@ type Module struct { type AuthModule = Module func NewModule(opts Options) module.Module { - return &Module{opts: opts} + return Module{opts: opts} } func (m Module) Definition() module.ModuleDef { diff --git a/examples/hello-mysql/internal/modules/auth/module_test.go b/examples/hello-mysql/internal/modules/auth/module_test.go index a919108..29b5afb 100644 --- a/examples/hello-mysql/internal/modules/auth/module_test.go +++ b/examples/hello-mysql/internal/modules/auth/module_test.go @@ -2,6 +2,7 @@ package auth import ( "testing" + "time" "github.com/go-modkit/modkit/modkit/kernel" ) @@ -13,3 +14,15 @@ func TestModule_Bootstrap(t *testing.T) { t.Fatalf("bootstrap: %v", err) } } + +func TestAuthModule_Definition(t *testing.T) { + cfg := Config{Secret: "secret", Issuer: "issuer", TTL: time.Minute} + def := NewModule(Options{Config: cfg}).(Module).Definition() + + if def.Name != "auth" { + t.Fatalf("name = %q", def.Name) + } + if len(def.Controllers) != 1 { + t.Fatalf("controllers = %d", len(def.Controllers)) + } +} diff --git a/examples/hello-mysql/internal/modules/auth/providers_test.go b/examples/hello-mysql/internal/modules/auth/providers_test.go new file mode 100644 index 0000000..2bc9bf9 --- /dev/null +++ b/examples/hello-mysql/internal/modules/auth/providers_test.go @@ -0,0 +1,31 @@ +package auth + +import ( + "net/http" + "testing" + "time" + + "github.com/go-modkit/modkit/modkit/module" +) + +func TestAuthProviders_BuildsHandlerAndMiddleware(t *testing.T) { + cfg := Config{Secret: "secret", Issuer: "issuer", TTL: time.Minute} + defs := Providers(cfg) + + var handlerBuilt, mwBuilt bool + for _, def := range defs { + value, err := def.Build(module.ResolverFunc(func(module.Token) (any, error) { return nil, nil })) + if err != nil { + t.Fatalf("build: %v", err) + } + switch def.Token { + case TokenHandler: + _, handlerBuilt = value.(*Handler) + case TokenMiddleware: + _, mwBuilt = value.(func(http.Handler) http.Handler) + } + } + if !handlerBuilt || !mwBuilt { + t.Fatalf("handler=%v middleware=%v", handlerBuilt, mwBuilt) + } +} diff --git a/examples/hello-mysql/internal/modules/users/module.go b/examples/hello-mysql/internal/modules/users/module.go index 1b19556..70bb8ba 100644 --- a/examples/hello-mysql/internal/modules/users/module.go +++ b/examples/hello-mysql/internal/modules/users/module.go @@ -31,7 +31,7 @@ type Module struct { type UsersModule = Module func NewModule(opts Options) module.Module { - return &Module{opts: opts} + return Module{opts: opts} } func (m Module) Definition() module.ModuleDef { diff --git a/examples/hello-mysql/internal/modules/users/module_test.go b/examples/hello-mysql/internal/modules/users/module_test.go new file mode 100644 index 0000000..7c0bf61 --- /dev/null +++ b/examples/hello-mysql/internal/modules/users/module_test.go @@ -0,0 +1,20 @@ +package users + +import ( + "testing" + + "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/auth" + "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/database" +) + +func TestUsersModule_Definition_WiresAuth(t *testing.T) { + mod := NewModule(Options{Database: &database.Module{}, Auth: auth.NewModule(auth.Options{})}) + def := mod.(Module).Definition() + + if def.Name != "users" { + t.Fatalf("name = %q", def.Name) + } + if len(def.Imports) != 2 { + t.Fatalf("imports = %d", len(def.Imports)) + } +} diff --git a/modkit/module/token.go b/modkit/module/token.go index 2889105..1b2b90c 100644 --- a/modkit/module/token.go +++ b/modkit/module/token.go @@ -7,3 +7,11 @@ type Token string type Resolver interface { Get(Token) (any, error) } + +// ResolverFunc adapts a function to a Resolver. +type ResolverFunc func(Token) (any, error) + +// Get implements Resolver. +func (f ResolverFunc) Get(token Token) (any, error) { + return f(token) +} From ad9604ff48c9d23bf436f607f1eb5f211492b149 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 08:30:47 +0200 Subject: [PATCH 16/20] fix: restore pointer modules in tests --- examples/hello-mysql/internal/modules/auth/module.go | 2 +- examples/hello-mysql/internal/modules/auth/module_test.go | 2 +- .../hello-mysql/internal/modules/auth/providers_test.go | 8 +++++++- examples/hello-mysql/internal/modules/users/module.go | 2 +- .../hello-mysql/internal/modules/users/module_test.go | 2 +- modkit/module/token.go | 8 -------- 6 files changed, 11 insertions(+), 13 deletions(-) diff --git a/examples/hello-mysql/internal/modules/auth/module.go b/examples/hello-mysql/internal/modules/auth/module.go index f9e0d8e..3f7ffcf 100644 --- a/examples/hello-mysql/internal/modules/auth/module.go +++ b/examples/hello-mysql/internal/modules/auth/module.go @@ -18,7 +18,7 @@ type Module struct { type AuthModule = Module func NewModule(opts Options) module.Module { - return Module{opts: opts} + return &Module{opts: opts} } func (m Module) Definition() module.ModuleDef { diff --git a/examples/hello-mysql/internal/modules/auth/module_test.go b/examples/hello-mysql/internal/modules/auth/module_test.go index 29b5afb..88cc677 100644 --- a/examples/hello-mysql/internal/modules/auth/module_test.go +++ b/examples/hello-mysql/internal/modules/auth/module_test.go @@ -17,7 +17,7 @@ func TestModule_Bootstrap(t *testing.T) { func TestAuthModule_Definition(t *testing.T) { cfg := Config{Secret: "secret", Issuer: "issuer", TTL: time.Minute} - def := NewModule(Options{Config: cfg}).(Module).Definition() + def := NewModule(Options{Config: cfg}).(*Module).Definition() if def.Name != "auth" { t.Fatalf("name = %q", def.Name) diff --git a/examples/hello-mysql/internal/modules/auth/providers_test.go b/examples/hello-mysql/internal/modules/auth/providers_test.go index 2bc9bf9..c2794fa 100644 --- a/examples/hello-mysql/internal/modules/auth/providers_test.go +++ b/examples/hello-mysql/internal/modules/auth/providers_test.go @@ -8,13 +8,19 @@ import ( "github.com/go-modkit/modkit/modkit/module" ) +type noopResolver struct{} + +func (noopResolver) Get(module.Token) (any, error) { + return nil, nil +} + func TestAuthProviders_BuildsHandlerAndMiddleware(t *testing.T) { cfg := Config{Secret: "secret", Issuer: "issuer", TTL: time.Minute} defs := Providers(cfg) var handlerBuilt, mwBuilt bool for _, def := range defs { - value, err := def.Build(module.ResolverFunc(func(module.Token) (any, error) { return nil, nil })) + value, err := def.Build(noopResolver{}) if err != nil { t.Fatalf("build: %v", err) } diff --git a/examples/hello-mysql/internal/modules/users/module.go b/examples/hello-mysql/internal/modules/users/module.go index 70bb8ba..1b19556 100644 --- a/examples/hello-mysql/internal/modules/users/module.go +++ b/examples/hello-mysql/internal/modules/users/module.go @@ -31,7 +31,7 @@ type Module struct { type UsersModule = Module func NewModule(opts Options) module.Module { - return Module{opts: opts} + return &Module{opts: opts} } func (m Module) Definition() module.ModuleDef { diff --git a/examples/hello-mysql/internal/modules/users/module_test.go b/examples/hello-mysql/internal/modules/users/module_test.go index 7c0bf61..56b72a8 100644 --- a/examples/hello-mysql/internal/modules/users/module_test.go +++ b/examples/hello-mysql/internal/modules/users/module_test.go @@ -9,7 +9,7 @@ import ( func TestUsersModule_Definition_WiresAuth(t *testing.T) { mod := NewModule(Options{Database: &database.Module{}, Auth: auth.NewModule(auth.Options{})}) - def := mod.(Module).Definition() + def := mod.(*Module).Definition() if def.Name != "users" { t.Fatalf("name = %q", def.Name) diff --git a/modkit/module/token.go b/modkit/module/token.go index 1b2b90c..2889105 100644 --- a/modkit/module/token.go +++ b/modkit/module/token.go @@ -7,11 +7,3 @@ type Token string type Resolver interface { Get(Token) (any, error) } - -// ResolverFunc adapts a function to a Resolver. -type ResolverFunc func(Token) (any, error) - -// Get implements Resolver. -func (f ResolverFunc) Get(token Token) (any, error) { - return f(token) -} From 35f34d4816c324f2bf888b6fbee5c3787ca6aaca Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 08:32:07 +0200 Subject: [PATCH 17/20] test: cover jwt ttl parsing --- examples/hello-mysql/cmd/api/main.go | 15 ++++++++++----- examples/hello-mysql/cmd/api/main_test.go | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 examples/hello-mysql/cmd/api/main_test.go diff --git a/examples/hello-mysql/cmd/api/main.go b/examples/hello-mysql/cmd/api/main.go index 39e395d..3a14b76 100644 --- a/examples/hello-mysql/cmd/api/main.go +++ b/examples/hello-mysql/cmd/api/main.go @@ -19,11 +19,7 @@ import ( // @BasePath / func main() { cfg := config.Load() - jwtTTL, err := time.ParseDuration(cfg.JWTTTL) - if err != nil { - log.Printf("invalid JWT_TTL %q, using 1h: %v", cfg.JWTTTL, err) - jwtTTL = time.Hour - } + jwtTTL := parseJWTTTL(cfg.JWTTTL) authCfg := auth.Config{ Secret: cfg.JWTSecret, @@ -49,3 +45,12 @@ func main() { log.Fatalf("server failed: %v", err) } } + +func parseJWTTTL(raw string) time.Duration { + ttl, err := time.ParseDuration(raw) + if err != nil { + log.Printf("invalid JWT_TTL %q, using 1h: %v", raw, err) + return time.Hour + } + return ttl +} diff --git a/examples/hello-mysql/cmd/api/main_test.go b/examples/hello-mysql/cmd/api/main_test.go new file mode 100644 index 0000000..83e22bb --- /dev/null +++ b/examples/hello-mysql/cmd/api/main_test.go @@ -0,0 +1,20 @@ +package main + +import ( + "testing" + "time" +) + +func TestParseJWTTTL_DefaultOnInvalid(t *testing.T) { + got := parseJWTTTL("bad-value") + if got != time.Hour { + t.Fatalf("ttl = %v", got) + } +} + +func TestParseJWTTTL_Valid(t *testing.T) { + got := parseJWTTTL("30m") + if got != 30*time.Minute { + t.Fatalf("ttl = %v", got) + } +} From ee3086c243b55693caea43fe471b77f61eab82fe Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 08:42:21 +0200 Subject: [PATCH 18/20] fix: address remaining review comments --- docs/plans/2026-02-05-auth-example-design.md | 4 +- examples/hello-mysql/cmd/api/main.go | 4 ++ examples/hello-mysql/cmd/api/main_test.go | 9 ++++ examples/hello-mysql/go.mod | 15 ++++--- examples/hello-mysql/go.sum | 44 +++++++++---------- .../internal/modules/users/controller.go | 2 +- 6 files changed, 46 insertions(+), 32 deletions(-) diff --git a/docs/plans/2026-02-05-auth-example-design.md b/docs/plans/2026-02-05-auth-example-design.md index b8471ac..7a26fc4 100644 --- a/docs/plans/2026-02-05-auth-example-design.md +++ b/docs/plans/2026-02-05-auth-example-design.md @@ -2,7 +2,7 @@ **Goal:** Add a runnable, example-focused JWT authentication module to hello-mysql with a login endpoint, middleware validation, and typed context helpers. -**Architecture:** A dedicated `auth` module provides a login handler and a JWT middleware provider. Configuration is explicit via example config/env. The middleware validates tokens and stores user info in a typed context helper, which handlers can read. User write routes (`POST /users`, `PUT /users/{id}`, `DELETE /users/{id}`) are protected by the auth middleware, while the list route (`GET /users`) remains public. +**Architecture:** A dedicated `auth` module provides a login handler and a JWT middleware provider. Configuration is explicit via example config/env. The middleware validates tokens and stores user info in a typed context helper, which handlers can read. User write routes (`POST /users`, `PUT /users/{id}`, `DELETE /users/{id}`) are protected by the auth middleware, while read routes (`GET /users`, `GET /users/{id}`) remain public. **Tech Stack:** Go, chi router via modkit http adapter, standard library + minimal JWT dependency. @@ -24,7 +24,7 @@ We add `examples/hello-mysql/internal/modules/auth` with a deterministic module **JWT Middleware:** - Extracts bearer token, returns 401 on missing/invalid tokens. - Verifies signature and expiry using HS256. -- On success, stores `AuthUser{ID, Email}` in context. +- On success, stores `User{ID, Email}` in context. **Login Handler:** - `POST /auth/login` expects JSON with username/password. diff --git a/examples/hello-mysql/cmd/api/main.go b/examples/hello-mysql/cmd/api/main.go index 3a14b76..9c06a42 100644 --- a/examples/hello-mysql/cmd/api/main.go +++ b/examples/hello-mysql/cmd/api/main.go @@ -52,5 +52,9 @@ func parseJWTTTL(raw string) time.Duration { log.Printf("invalid JWT_TTL %q, using 1h: %v", raw, err) return time.Hour } + if ttl <= 0 { + log.Printf("invalid JWT_TTL %q, using 1h: non-positive duration", raw) + return time.Hour + } return ttl } diff --git a/examples/hello-mysql/cmd/api/main_test.go b/examples/hello-mysql/cmd/api/main_test.go index 83e22bb..1fa9bf0 100644 --- a/examples/hello-mysql/cmd/api/main_test.go +++ b/examples/hello-mysql/cmd/api/main_test.go @@ -18,3 +18,12 @@ func TestParseJWTTTL_Valid(t *testing.T) { t.Fatalf("ttl = %v", got) } } + +func TestParseJWTTTL_RejectsNonPositive(t *testing.T) { + for _, value := range []string{"0s", "-1s"} { + got := parseJWTTTL(value) + if got != time.Hour { + t.Fatalf("ttl for %q = %v", value, got) + } + } +} diff --git a/examples/hello-mysql/go.mod b/examples/hello-mysql/go.mod index 63fb584..a9012c7 100644 --- a/examples/hello-mysql/go.mod +++ b/examples/hello-mysql/go.mod @@ -1,11 +1,12 @@ module github.com/go-modkit/modkit/examples/hello-mysql -go 1.24.0 +go 1.25 require ( github.com/go-chi/chi/v5 v5.2.4 github.com/go-modkit/modkit v0.0.0 github.com/go-sql-driver/mysql v1.9.3 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/swaggo/http-swagger/v2 v2.0.2 github.com/swaggo/swag v1.16.6 github.com/testcontainers/testcontainers-go v0.40.0 @@ -24,7 +25,7 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v28.5.1+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect @@ -38,7 +39,6 @@ require ( github.com/go-openapi/jsonreference v0.20.0 // indirect github.com/go-openapi/spec v0.20.6 // indirect github.com/go-openapi/swag v0.19.15 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.0 // indirect @@ -56,11 +56,11 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/stretchr/testify v1.11.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/swaggo/files/v2 v2.0.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect @@ -73,9 +73,10 @@ require ( go.opentelemetry.io/otel/trace v1.35.0 // indirect go.opentelemetry.io/proto/otlp v1.0.0 // indirect golang.org/x/crypto v0.43.0 // indirect - golang.org/x/mod v0.17.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + golang.org/x/tools v0.41.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/examples/hello-mysql/go.sum b/examples/hello-mysql/go.sum index 16d172c..a8ccf2b 100644 --- a/examples/hello-mysql/go.sum +++ b/examples/hello-mysql/go.sum @@ -23,11 +23,12 @@ github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7np github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= -github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= @@ -61,8 +62,6 @@ github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyr github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= -github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= -github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 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/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -116,12 +115,13 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +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/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -132,8 +132,8 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= -github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg= @@ -168,12 +168,12 @@ go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lI go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= -golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -184,12 +184,12 @@ golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8= google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo= @@ -197,8 +197,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw= google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/examples/hello-mysql/internal/modules/users/controller.go b/examples/hello-mysql/internal/modules/users/controller.go index 7dfbce6..7d466b3 100644 --- a/examples/hello-mysql/internal/modules/users/controller.go +++ b/examples/hello-mysql/internal/modules/users/controller.go @@ -21,10 +21,10 @@ func NewController(service Service, authMiddleware func(http.Handler) http.Handl func (c *Controller) RegisterRoutes(router Router) { router.Handle(http.MethodGet, "/users", http.HandlerFunc(c.handleListUsers)) + router.Handle(http.MethodGet, "/users/{id}", http.HandlerFunc(c.handleGetUser)) router.Group("/", func(r Router) { r.Use(c.authMiddleware) - r.Handle(http.MethodGet, "/users/{id}", http.HandlerFunc(c.handleGetUser)) r.Handle(http.MethodPost, "/users", http.HandlerFunc(c.handleCreateUser)) r.Handle(http.MethodPut, "/users/{id}", http.HandlerFunc(c.handleUpdateUser)) r.Handle(http.MethodDelete, "/users/{id}", http.HandlerFunc(c.handleDeleteUser)) From c2986ff644b7873335506d63ad4c58b3a9f244b1 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 08:52:12 +0200 Subject: [PATCH 19/20] test: raise example coverage --- examples/hello-mysql/cmd/api/main.go | 32 ++++---- examples/hello-mysql/cmd/api/main_test.go | 57 ++++++++++++++ .../internal/modules/auth/claims_test.go | 18 +++++ .../internal/modules/auth/handler_test.go | 23 ++++++ .../internal/modules/auth/middleware_test.go | 59 ++++++++++++++ .../internal/modules/auth/module_test.go | 27 +++++++ .../internal/modules/auth/token_test.go | 29 +++++++ .../internal/modules/users/module_test.go | 78 +++++++++++++++++++ 8 files changed, 310 insertions(+), 13 deletions(-) diff --git a/examples/hello-mysql/cmd/api/main.go b/examples/hello-mysql/cmd/api/main.go index 9c06a42..4d2c35f 100644 --- a/examples/hello-mysql/cmd/api/main.go +++ b/examples/hello-mysql/cmd/api/main.go @@ -21,19 +21,7 @@ func main() { cfg := config.Load() jwtTTL := parseJWTTTL(cfg.JWTTTL) - authCfg := auth.Config{ - Secret: cfg.JWTSecret, - Issuer: cfg.JWTIssuer, - TTL: jwtTTL, - Username: cfg.AuthUsername, - Password: cfg.AuthPassword, - } - - handler, err := httpserver.BuildHandler(app.Options{ - HTTPAddr: cfg.HTTPAddr, - MySQLDSN: cfg.MySQLDSN, - Auth: authCfg, - }) + handler, err := httpserver.BuildHandler(buildAppOptions(cfg, jwtTTL)) if err != nil { log.Fatalf("bootstrap failed: %v", err) } @@ -46,6 +34,24 @@ func main() { } } +func buildAppOptions(cfg config.Config, jwtTTL time.Duration) app.Options { + return app.Options{ + HTTPAddr: cfg.HTTPAddr, + MySQLDSN: cfg.MySQLDSN, + Auth: buildAuthConfig(cfg, jwtTTL), + } +} + +func buildAuthConfig(cfg config.Config, jwtTTL time.Duration) auth.Config { + return auth.Config{ + Secret: cfg.JWTSecret, + Issuer: cfg.JWTIssuer, + TTL: jwtTTL, + Username: cfg.AuthUsername, + Password: cfg.AuthPassword, + } +} + func parseJWTTTL(raw string) time.Duration { ttl, err := time.ParseDuration(raw) if err != nil { diff --git a/examples/hello-mysql/cmd/api/main_test.go b/examples/hello-mysql/cmd/api/main_test.go index 1fa9bf0..525e328 100644 --- a/examples/hello-mysql/cmd/api/main_test.go +++ b/examples/hello-mysql/cmd/api/main_test.go @@ -3,6 +3,9 @@ package main import ( "testing" "time" + + "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/auth" + "github.com/go-modkit/modkit/examples/hello-mysql/internal/platform/config" ) func TestParseJWTTTL_DefaultOnInvalid(t *testing.T) { @@ -27,3 +30,57 @@ func TestParseJWTTTL_RejectsNonPositive(t *testing.T) { } } } + +func TestBuildAuthConfig(t *testing.T) { + cfg := config.Config{ + JWTSecret: "secret", + JWTIssuer: "issuer", + AuthUsername: "demo", + AuthPassword: "s3cret", + } + ttl := 2 * time.Minute + + got := buildAuthConfig(cfg, ttl) + + if got.Secret != cfg.JWTSecret { + t.Fatalf("secret = %q", got.Secret) + } + if got.Issuer != cfg.JWTIssuer { + t.Fatalf("issuer = %q", got.Issuer) + } + if got.TTL != ttl { + t.Fatalf("ttl = %v", got.TTL) + } + if got.Username != cfg.AuthUsername { + t.Fatalf("username = %q", got.Username) + } + if got.Password != cfg.AuthPassword { + t.Fatalf("password = %q", got.Password) + } +} + +func TestBuildAppOptions(t *testing.T) { + cfg := config.Config{ + HTTPAddr: ":9999", + MySQLDSN: "dsn", + JWTSecret: "secret", + JWTIssuer: "issuer", + AuthUsername: "demo", + AuthPassword: "s3cret", + } + ttl := 3 * time.Minute + + opts := buildAppOptions(cfg, ttl) + + if opts.HTTPAddr != cfg.HTTPAddr { + t.Fatalf("http addr = %q", opts.HTTPAddr) + } + if opts.MySQLDSN != cfg.MySQLDSN { + t.Fatalf("mysql dsn = %q", opts.MySQLDSN) + } + if opts.Auth != (auth.Config{}) { + if opts.Auth.Secret != cfg.JWTSecret || opts.Auth.Issuer != cfg.JWTIssuer || opts.Auth.TTL != ttl { + t.Fatalf("auth config mismatch: %+v", opts.Auth) + } + } +} diff --git a/examples/hello-mysql/internal/modules/auth/claims_test.go b/examples/hello-mysql/internal/modules/auth/claims_test.go index a04e669..c3c7131 100644 --- a/examples/hello-mysql/internal/modules/auth/claims_test.go +++ b/examples/hello-mysql/internal/modules/auth/claims_test.go @@ -11,3 +11,21 @@ func TestUserFromClaims_RejectsEmpty(t *testing.T) { t.Fatal("expected false") } } + +func TestUserFromClaims_SubOrEmail(t *testing.T) { + user, ok := userFromClaims(jwt.MapClaims{"sub": "demo"}) + if !ok { + t.Fatal("expected true for sub claim") + } + if user.ID != "demo" || user.Email != "" { + t.Fatalf("unexpected user: %+v", user) + } + + user, ok = userFromClaims(jwt.MapClaims{"email": "demo@example.com"}) + if !ok { + t.Fatal("expected true for email claim") + } + if user.Email != "demo@example.com" || user.ID != "" { + t.Fatalf("unexpected user: %+v", user) + } +} diff --git a/examples/hello-mysql/internal/modules/auth/handler_test.go b/examples/hello-mysql/internal/modules/auth/handler_test.go index ceaa371..c587d5f 100644 --- a/examples/hello-mysql/internal/modules/auth/handler_test.go +++ b/examples/hello-mysql/internal/modules/auth/handler_test.go @@ -77,3 +77,26 @@ func TestHandler_Login_Success(t *testing.T) { t.Fatalf("parse token: %v", err) } } + +func TestHandler_Login_IssueTokenFailure(t *testing.T) { + cfg := Config{ + Secret: "", + Issuer: "test-issuer", + TTL: time.Minute, + Username: "demo", + Password: "s3cret", + } + + handler := NewHandler(cfg) + router := modkithttp.NewRouter() + handler.RegisterRoutes(modkithttp.AsRouter(router)) + + body := []byte(`{"username":"demo","password":"s3cret"}`) + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body)) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError) + } +} diff --git a/examples/hello-mysql/internal/modules/auth/middleware_test.go b/examples/hello-mysql/internal/modules/auth/middleware_test.go index ca0d998..98063b8 100644 --- a/examples/hello-mysql/internal/modules/auth/middleware_test.go +++ b/examples/hello-mysql/internal/modules/auth/middleware_test.go @@ -1,6 +1,9 @@ package auth import ( + "crypto/rand" + "crypto/rsa" + "errors" "net/http" "net/http/httptest" "testing" @@ -74,6 +77,62 @@ func TestJWTMiddleware(t *testing.T) { } } +func TestParseToken_RejectsInvalidSigningMethod(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + claims := jwt.MapClaims{ + "iss": "issuer", + "exp": time.Now().Add(time.Minute).Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + signed, err := token.SignedString(key) + if err != nil { + t.Fatalf("sign token: %v", err) + } + + _, err = parseToken(signed, Config{Secret: "secret", Issuer: "issuer"}, time.Now()) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, jwt.ErrTokenSignatureInvalid) { + t.Fatalf("expected signature error, got %v", err) + } +} + +func TestJWTMiddleware_RejectsNoUserClaims(t *testing.T) { + cfg := Config{ + Secret: "secret", + Issuer: "issuer", + TTL: time.Minute, + } + claims := jwt.MapClaims{ + "iss": cfg.Issuer, + "exp": time.Now().Add(time.Minute).Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(cfg.Secret)) + if err != nil { + t.Fatalf("sign token: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer "+signed) + rec := httptest.NewRecorder() + NewJWTMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("expected middleware to reject missing user claims") + })).ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } + if got := rec.Header().Get("WWW-Authenticate"); got != "Bearer" { + t.Fatalf("WWW-Authenticate = %q", got) + } +} + func TestBearerToken_CaseInsensitive(t *testing.T) { got := bearerToken("bearer abc") if got != "abc" { diff --git a/examples/hello-mysql/internal/modules/auth/module_test.go b/examples/hello-mysql/internal/modules/auth/module_test.go index 88cc677..21bc666 100644 --- a/examples/hello-mysql/internal/modules/auth/module_test.go +++ b/examples/hello-mysql/internal/modules/auth/module_test.go @@ -1,10 +1,12 @@ package auth import ( + "errors" "testing" "time" "github.com/go-modkit/modkit/modkit/kernel" + "github.com/go-modkit/modkit/modkit/module" ) func TestModule_Bootstrap(t *testing.T) { @@ -26,3 +28,28 @@ func TestAuthModule_Definition(t *testing.T) { t.Fatalf("controllers = %d", len(def.Controllers)) } } + +type errorResolver struct { + token module.Token + err error +} + +func (r errorResolver) Get(token module.Token) (any, error) { + if token == r.token { + return nil, r.err + } + return nil, nil +} + +func TestAuthModule_ControllerBuildError(t *testing.T) { + cfg := Config{Secret: "secret", Issuer: "issuer", TTL: time.Minute} + def := NewModule(Options{Config: cfg}).(*Module).Definition() + + _, err := def.Controllers[0].Build(errorResolver{ + token: TokenHandler, + err: errors.New("boom"), + }) + if err == nil { + t.Fatal("expected error") + } +} diff --git a/examples/hello-mysql/internal/modules/auth/token_test.go b/examples/hello-mysql/internal/modules/auth/token_test.go index 020fc5a..7f8d008 100644 --- a/examples/hello-mysql/internal/modules/auth/token_test.go +++ b/examples/hello-mysql/internal/modules/auth/token_test.go @@ -3,6 +3,8 @@ package auth import ( "testing" "time" + + "github.com/golang-jwt/jwt/v5" ) func TestIssueToken_InvalidConfig(t *testing.T) { @@ -16,3 +18,30 @@ func TestIssueToken_InvalidConfig(t *testing.T) { t.Fatal("expected error") } } + +func TestIssueToken_EmptyUserClaims(t *testing.T) { + cfg := Config{ + Secret: "secret", + Issuer: "issuer", + TTL: time.Minute, + } + token, err := IssueToken(cfg, User{}) + if err != nil { + t.Fatalf("issue token: %v", err) + } + + parsed, err := parseToken(token, cfg, time.Now()) + if err != nil { + t.Fatalf("parse token: %v", err) + } + claims, ok := parsed.Claims.(jwt.MapClaims) + if !ok { + t.Fatal("expected map claims") + } + if _, ok := claims["sub"]; ok { + t.Fatalf("unexpected sub claim: %v", claims["sub"]) + } + if _, ok := claims["email"]; ok { + t.Fatalf("unexpected email claim: %v", claims["email"]) + } +} diff --git a/examples/hello-mysql/internal/modules/users/module_test.go b/examples/hello-mysql/internal/modules/users/module_test.go index 56b72a8..dfa1056 100644 --- a/examples/hello-mysql/internal/modules/users/module_test.go +++ b/examples/hello-mysql/internal/modules/users/module_test.go @@ -1,10 +1,14 @@ package users import ( + "context" + "errors" + "net/http" "testing" "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/auth" "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/database" + "github.com/go-modkit/modkit/modkit/module" ) func TestUsersModule_Definition_WiresAuth(t *testing.T) { @@ -18,3 +22,77 @@ func TestUsersModule_Definition_WiresAuth(t *testing.T) { t.Fatalf("imports = %d", len(def.Imports)) } } + +type stubResolver struct { + values map[module.Token]any + errors map[module.Token]error +} + +func (r stubResolver) Get(token module.Token) (any, error) { + if err := r.errors[token]; err != nil { + return nil, err + } + if value, ok := r.values[token]; ok { + return value, nil + } + return nil, nil +} + +type serviceStub struct{} + +func (serviceStub) GetUser(ctx context.Context, id int64) (User, error) { + return User{}, nil +} + +func (serviceStub) CreateUser(ctx context.Context, input CreateUserInput) (User, error) { + return User{}, nil +} + +func (serviceStub) ListUsers(ctx context.Context) ([]User, error) { + return nil, nil +} + +func (serviceStub) UpdateUser(ctx context.Context, id int64, input UpdateUserInput) (User, error) { + return User{}, nil +} + +func (serviceStub) DeleteUser(ctx context.Context, id int64) error { + return nil +} + +func TestUsersModule_ControllerBuildErrors(t *testing.T) { + mod := NewModule(Options{Database: &database.Module{}, Auth: auth.NewModule(auth.Options{})}) + def := mod.(*Module).Definition() + controller := def.Controllers[0] + + _, err := controller.Build(stubResolver{ + errors: map[module.Token]error{ + TokenService: errors.New("missing service"), + }, + }) + if err == nil { + t.Fatal("expected error for missing service") + } + + _, err = controller.Build(stubResolver{ + values: map[module.Token]any{ + TokenService: serviceStub{}, + }, + errors: map[module.Token]error{ + auth.TokenMiddleware: errors.New("missing middleware"), + }, + }) + if err == nil { + t.Fatal("expected error for missing middleware") + } + + _, err = controller.Build(stubResolver{ + values: map[module.Token]any{ + TokenService: serviceStub{}, + auth.TokenMiddleware: func(next http.Handler) http.Handler { return next }, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} From 71d9eee1306905357b7b91efb51fb444e541a00c Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Thu, 5 Feb 2026 08:58:21 +0200 Subject: [PATCH 20/20] test: tighten app options assertions --- examples/hello-mysql/cmd/api/main_test.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/examples/hello-mysql/cmd/api/main_test.go b/examples/hello-mysql/cmd/api/main_test.go index 525e328..46efc30 100644 --- a/examples/hello-mysql/cmd/api/main_test.go +++ b/examples/hello-mysql/cmd/api/main_test.go @@ -4,7 +4,6 @@ import ( "testing" "time" - "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/auth" "github.com/go-modkit/modkit/examples/hello-mysql/internal/platform/config" ) @@ -78,9 +77,19 @@ func TestBuildAppOptions(t *testing.T) { if opts.MySQLDSN != cfg.MySQLDSN { t.Fatalf("mysql dsn = %q", opts.MySQLDSN) } - if opts.Auth != (auth.Config{}) { - if opts.Auth.Secret != cfg.JWTSecret || opts.Auth.Issuer != cfg.JWTIssuer || opts.Auth.TTL != ttl { - t.Fatalf("auth config mismatch: %+v", opts.Auth) - } + if opts.Auth.Secret != cfg.JWTSecret { + t.Fatalf("auth secret = %q", opts.Auth.Secret) + } + if opts.Auth.Issuer != cfg.JWTIssuer { + t.Fatalf("auth issuer = %q", opts.Auth.Issuer) + } + if opts.Auth.TTL != ttl { + t.Fatalf("auth ttl = %v", opts.Auth.TTL) + } + if opts.Auth.Username != cfg.AuthUsername { + t.Fatalf("auth username = %q", opts.Auth.Username) + } + if opts.Auth.Password != cfg.AuthPassword { + t.Fatalf("auth password = %q", opts.Auth.Password) } }