Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ vendor/

# Logs
*.log

# Local benchmark captures
before.txt
after.txt
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
.PHONY: all build run test lint fmt clean migrate generate help
.PHONY: all build run test lint fmt clean migrate generate help bench-perf

BINARY=uniauth
CMD=./cmd/server
BENCH_PERF_PATTERN='^(BenchmarkRouter_(Health|ListUsers_(Permitted|Superuser))|BenchmarkJWTAuth_BlacklistLookup|BenchmarkRateLimit_APIRequest)$$'
BENCHCOUNT?=1

# ─── Build ───────────────────────────────────────────────────────────────────

Expand All @@ -25,6 +27,9 @@ test:
test-short:
go test ./... -short -timeout 30s

bench-perf:
go test ./internal/api ./internal/api/middleware -run '^$$' -bench $(BENCH_PERF_PATTERN) -benchmem -count $(BENCHCOUNT)

# ─── Code Quality ────────────────────────────────────────────────────────────

lint:
Expand Down
64 changes: 64 additions & 0 deletions internal/api/middleware/benchmark_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package middleware

import (
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/google/uuid"

"github.com/osama1998h/uniauth/internal/testutil"
"github.com/osama1998h/uniauth/pkg/token"
)

func BenchmarkJWTAuth_BlacklistLookup(b *testing.B) {
testutil.RequireEnv(b, "REDIS_URL")
maker := token.NewMaker(testJWTSecret, 15*time.Minute, 7*24*time.Hour)
redisCache := testutil.RequireTestCache(b)
userID := uuid.New()
orgID := uuid.New()

tokenStr, _, err := maker.CreateAccessToken(userID, orgID)
if err != nil {
b.Fatalf("create access token: %v", err)
}

handler := JWTAuth(maker, redisCache, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))

b.ReportAllocs()
b.ResetTimer()

for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
req.Header.Set("Authorization", "Bearer "+tokenStr)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("JWTAuth status = %d, want %d", rec.Code, http.StatusOK)
}
}
}

func BenchmarkRateLimit_APIRequest(b *testing.B) {
testutil.RequireEnv(b, "REDIS_URL")
redisCache := testutil.RequireTestCache(b)
handler := RateLimit(redisCache, 1_000_000_000, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))

b.ReportAllocs()
b.ResetTimer()

for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
req.RemoteAddr = "203.0.113.13:4000"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("RateLimit status = %d, want %d", rec.Code, http.StatusOK)
}
}
}
5 changes: 3 additions & 2 deletions internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func NewRouter(
userSvc := service.NewUserService(store, auditSvc, webhookSvc)
orgSvc := service.NewOrgService(store)
rbacSvc := service.NewRBACService(store, auditSvc)
apiKeySvc := service.NewAPIKeyService(store, auditSvc)
apiKeySvc := service.NewAPIKeyService(store, auditSvc, logger)

// --- Handlers ---
healthH := handlers.NewHealthHandler(store, redisCache)
Expand Down Expand Up @@ -64,7 +64,6 @@ func NewRouter(
AllowCredentials: true,
MaxAge: 300,
}))
r.Use(middleware.RateLimit(redisCache, cfg.Auth.RateLimitPerMinute, logger))

// Health
r.Get("/health", healthH.Live)
Expand All @@ -77,6 +76,8 @@ func NewRouter(

// API v1
r.Route("/api/v1", func(r chi.Router) {
r.Use(middleware.RateLimit(redisCache, cfg.Auth.RateLimitPerMinute, logger))

// Auth — public
r.Route("/auth", func(r chi.Router) {
r.Post("/register", authH.Register)
Expand Down
10 changes: 5 additions & 5 deletions internal/api/router_authorization_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,10 +287,10 @@ func TestRouterAllowsSuperuserBypass(t *testing.T) {
wantStatus: http.StatusOK,
},
{
name: "create user",
method: http.MethodPost,
path: func(_ *testing.T, _ context.Context, _ *db.Store, _ uuid.UUID) string { return "/api/v1/users" },
body: `{"email":"superuser-create-` + uuid.NewString() + `@example.com","password":"S3cur3P@ss!"}`,
name: "create user",
method: http.MethodPost,
path: func(_ *testing.T, _ context.Context, _ *db.Store, _ uuid.UUID) string { return "/api/v1/users" },
body: `{"email":"superuser-create-` + uuid.NewString() + `@example.com","password":"S3cur3P@ss!"}`,
wantStatus: http.StatusCreated,
},
{
Expand Down Expand Up @@ -398,7 +398,7 @@ func createSuperuser(t *testing.T, ctx context.Context, store *db.Store, orgID u
return user
}

func grantPermissionToUser(t *testing.T, ctx context.Context, store *db.Store, orgID, userID uuid.UUID, permission string) {
func grantPermissionToUser(t testing.TB, ctx context.Context, store *db.Store, orgID, userID uuid.UUID, permission string) {
t.Helper()

role := testutil.CreateRole(t, store, orgID, "router-permission-role")
Expand Down
139 changes: 139 additions & 0 deletions internal/api/router_benchmark_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package api_test

import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/google/uuid"

"github.com/osama1998h/uniauth/internal/api"
"github.com/osama1998h/uniauth/internal/config"
"github.com/osama1998h/uniauth/internal/domain"
"github.com/osama1998h/uniauth/internal/testutil"
"github.com/osama1998h/uniauth/pkg/token"
)

const benchmarkJWTSecret = "benchmark-secret-at-least-32-chars!"

func BenchmarkRouter_Health(b *testing.B) {
handler := newBenchmarkRouter(b)
b.ReportAllocs()
b.ResetTimer()

for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
req.RemoteAddr = "203.0.113.10:4000"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("GET /health status = %d, want %d", rec.Code, http.StatusOK)
}
}
}

func BenchmarkRouter_ListUsers_Permitted(b *testing.B) {
handler, accessToken := newBenchmarkListUsersRouter(b, false)
b.ReportAllocs()
b.ResetTimer()

for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
req.RemoteAddr = "203.0.113.11:4000"
req.Header.Set("Authorization", "Bearer "+accessToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("GET /api/v1/users status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
}
}

func BenchmarkRouter_ListUsers_Superuser(b *testing.B) {
handler, accessToken := newBenchmarkListUsersRouter(b, true)
b.ReportAllocs()
b.ResetTimer()

for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
req.RemoteAddr = "203.0.113.12:4000"
req.Header.Set("Authorization", "Bearer "+accessToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("GET /api/v1/users status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
}
}

func newBenchmarkRouter(b *testing.B) http.Handler {
b.Helper()

testutil.RequireEnv(b, "DATABASE_URL", "REDIS_URL", "JWT_SECRET")
store := testutil.RequireTestStore(b)
redisCache := testutil.RequireTestCache(b)

return api.NewRouter(newBenchmarkConfig(), store, redisCache, testutil.DiscardLogger())
}

func newBenchmarkListUsersRouter(b *testing.B, superuser bool) (http.Handler, string) {
b.Helper()

testutil.RequireEnv(b, "DATABASE_URL", "REDIS_URL", "JWT_SECRET")
store := testutil.RequireTestStore(b)
redisCache := testutil.RequireTestCache(b)
handler := api.NewRouter(newBenchmarkConfig(), store, redisCache, testutil.DiscardLogger())

ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()

org := testutil.CreateOrganization(b, store, "bench-users-org")
for i := 0; i < 20; i++ {
testutil.CreateUser(b, store, org.ID, "bench-users-seed")
}

var userID uuid.UUID
if superuser {
admin, err := store.CreateUser(ctx, org.ID, "bench-superuser@example.com", "hashed-password", nil, true)
if err != nil {
b.Fatalf("CreateUser(superuser) error = %v", err)
}
userID = admin.ID
} else {
user := testutil.CreateUser(b, store, org.ID, "bench-users-reader")
grantPermissionToUser(b, ctx, store, org.ID, user.ID, domain.PermissionUsersRead)
userID = user.ID
}

return handler, issueBenchmarkAccessToken(b, userID, org.ID)
}

func newBenchmarkConfig() *config.Config {
return &config.Config{
Server: config.ServerConfig{
Environment: "development",
},
Auth: config.AuthConfig{
JWTSecret: benchmarkJWTSecret,
AccessTokenDuration: 15 * time.Minute,
RefreshTokenDuration: 7 * 24 * time.Hour,
RateLimitPerMinute: 1_000_000_000,
},
Email: config.EmailConfig{
BaseURL: "http://localhost:8080",
},
}
}

func issueBenchmarkAccessToken(b *testing.B, userID, orgID uuid.UUID) string {
b.Helper()

maker := token.NewMaker(benchmarkJWTSecret, 15*time.Minute, 7*24*time.Hour)
tokenStr, _, err := maker.CreateAccessToken(userID, orgID)
if err != nil {
b.Fatalf("CreateAccessToken() error = %v", err)
}
return tokenStr
}
84 changes: 84 additions & 0 deletions internal/api/router_rate_limit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package api_test

import (
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/osama1998h/uniauth/internal/api"
"github.com/osama1998h/uniauth/internal/config"
"github.com/osama1998h/uniauth/internal/testutil"
)

func TestRouterDoesNotRateLimitHealthOrReady(t *testing.T) {
testutil.RequireEnv(t, "DATABASE_URL", "REDIS_URL")

store := testutil.RequireTestStore(t)
redisCache := testutil.RequireTestCache(t)
handler := api.NewRouter(newLowRateLimitConfig(), store, redisCache, testutil.DiscardLogger())

tests := []struct {
name string
path string
ip string
}{
{name: "health", path: "/health", ip: "203.0.113.31:4000"},
{name: "ready", path: "/ready", ip: "203.0.113.32:4000"},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
for i := 0; i < 2; i++ {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
req.RemoteAddr = tc.ip
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s request %d status = %d, want %d, body=%s", tc.path, i+1, rec.Code, http.StatusOK, rec.Body.String())
}
}
})
}
}

func TestRouterStillRateLimitsAPIRoutes(t *testing.T) {
testutil.RequireEnv(t, "DATABASE_URL", "REDIS_URL")

store := testutil.RequireTestStore(t)
redisCache := testutil.RequireTestCache(t)
handler := api.NewRouter(newLowRateLimitConfig(), store, redisCache, testutil.DiscardLogger())

req1 := httptest.NewRequest(http.MethodGet, "/api/v1/users/me", nil)
req1.RemoteAddr = "203.0.113.33:4000"
rec1 := httptest.NewRecorder()
handler.ServeHTTP(rec1, req1)
if rec1.Code != http.StatusUnauthorized {
t.Fatalf("first API request status = %d, want %d", rec1.Code, http.StatusUnauthorized)
}

req2 := httptest.NewRequest(http.MethodGet, "/api/v1/users/me", nil)
req2.RemoteAddr = "203.0.113.33:4000"
rec2 := httptest.NewRecorder()
handler.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusTooManyRequests {
t.Fatalf("second API request status = %d, want %d, body=%s", rec2.Code, http.StatusTooManyRequests, rec2.Body.String())
}
}

func newLowRateLimitConfig() *config.Config {
return &config.Config{
Server: config.ServerConfig{
Environment: "development",
},
Auth: config.AuthConfig{
JWTSecret: routerTestJWTSecret,
AccessTokenDuration: 15 * time.Minute,
RefreshTokenDuration: 7 * 24 * time.Hour,
RateLimitPerMinute: 1,
},
Email: config.EmailConfig{
BaseURL: "http://localhost:8080",
},
}
}
Loading
Loading