From 4ec0d5cef9f5871ca401c1e0933afc5fab10c8c5 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Sun, 26 Apr 2026 12:12:50 +0530 Subject: [PATCH 1/2] test(utilities): add tests for previously-uncovered helpers internal/utilities/strings.go, context.go, and io.go held small pure-ish helpers but had no test coverage. Add focused unit tests so behaviour changes get caught: round-trip tests for StringValue and StringPtr; a context-key formatting check plus set/read/replace/derive cases for WithRequestID and GetRequestID; cancellation and completion cases for WaitForCleanup; and behaviour assertions for SafeClose covering the happy path, the error path, and io.NopCloser. Coverage for the package goes from 50.8% to 62.4% with no behaviour changes. Signed-off-by: Manas Srivastava --- internal/utilities/context_test.go | 84 ++++++++++++++++++++++++ internal/utilities/io_test.go | 50 +++++++++++++++ internal/utilities/strings_test.go | 100 +++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 internal/utilities/context_test.go create mode 100644 internal/utilities/io_test.go create mode 100644 internal/utilities/strings_test.go diff --git a/internal/utilities/context_test.go b/internal/utilities/context_test.go new file mode 100644 index 0000000000..ebf26cc88e --- /dev/null +++ b/internal/utilities/context_test.go @@ -0,0 +1,84 @@ +package utilities + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestContextKeyString(t *testing.T) { + require.Equal(t, "gotrue api context key request_id", contextKey("request_id").String()) + require.Equal(t, "gotrue api context key ", contextKey("").String()) +} + +func TestRequestIDRoundtrip(t *testing.T) { + t.Run("set then read", func(t *testing.T) { + ctx := WithRequestID(context.Background(), "abc-123") + require.Equal(t, "abc-123", GetRequestID(ctx)) + }) + + t.Run("missing key returns empty string", func(t *testing.T) { + require.Equal(t, "", GetRequestID(context.Background())) + }) + + t.Run("set replaces previous value", func(t *testing.T) { + ctx := WithRequestID(context.Background(), "first") + ctx = WithRequestID(ctx, "second") + require.Equal(t, "second", GetRequestID(ctx)) + }) + + t.Run("derived context inherits value", func(t *testing.T) { + ctx := WithRequestID(context.Background(), "parent-id") + child, cancel := context.WithCancel(ctx) + defer cancel() + require.Equal(t, "parent-id", GetRequestID(child)) + }) +} + +func TestWaitForCleanup(t *testing.T) { + t.Run("returns when wait group is done", func(t *testing.T) { + var wg sync.WaitGroup + wg.Add(1) + go func() { + time.Sleep(10 * time.Millisecond) + wg.Done() + }() + + done := make(chan struct{}) + go func() { + defer close(done) + WaitForCleanup(context.Background(), &wg) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("WaitForCleanup did not return after wg.Done()") + } + }) + + t.Run("returns when context is cancelled before wait group is done", func(t *testing.T) { + var wg sync.WaitGroup + wg.Add(1) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + WaitForCleanup(ctx, &wg) + }() + + cancel() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("WaitForCleanup did not return after context cancellation") + } + + wg.Done() + }) +} diff --git a/internal/utilities/io_test.go b/internal/utilities/io_test.go new file mode 100644 index 0000000000..b401a96963 --- /dev/null +++ b/internal/utilities/io_test.go @@ -0,0 +1,50 @@ +package utilities + +import ( + "errors" + "io" + "testing" + + "github.com/stretchr/testify/require" +) + +type closerFunc func() error + +func (f closerFunc) Close() error { return f() } + +func TestSafeClose(t *testing.T) { + tests := []struct { + name string + closer io.Closer + closerErr error + }{ + { + name: "happy path: Close returns nil", + closerErr: nil, + }, + { + name: "Close returns error: SafeClose must not panic", + closerErr: errors.New("close failed"), + }, + { + name: "io.NopCloser: SafeClose must not panic", + closer: io.NopCloser(nil), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + closer := tt.closer + if closer == nil { + called := false + closer = closerFunc(func() error { + called = true + return tt.closerErr + }) + require.NotPanics(t, func() { SafeClose(closer) }) + require.True(t, called, "Close should have been invoked") + return + } + require.NotPanics(t, func() { SafeClose(closer) }) + }) + } +} diff --git a/internal/utilities/strings_test.go b/internal/utilities/strings_test.go new file mode 100644 index 0000000000..272f3a1dc4 --- /dev/null +++ b/internal/utilities/strings_test.go @@ -0,0 +1,100 @@ +package utilities + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestStringValue(t *testing.T) { + empty := "" + hello := "hello" + tests := []struct { + name string + in *string + want string + }{ + { + name: "nil pointer returns empty string", + in: nil, + want: "", + }, + { + name: "pointer to empty string returns empty string", + in: &empty, + want: "", + }, + { + name: "pointer to non-empty string returns the value", + in: &hello, + want: "hello", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, StringValue(tt.in)) + }) + } +} + +func TestStringPtr(t *testing.T) { + tests := []struct { + name string + in string + expectNil bool + wantPointee string + }{ + { + name: "empty string returns nil", + in: "", + expectNil: true, + }, + { + name: "non-empty string returns pointer to that value", + in: "hello", + wantPointee: "hello", + }, + { + name: "string with whitespace and newline preserved", + in: " hi\nthere ", + wantPointee: " hi\nthere ", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := StringPtr(tt.in) + if tt.expectNil { + require.Nil(t, got) + return + } + require.NotNil(t, got) + require.Equal(t, tt.wantPointee, *got) + }) + } + + t.Run("returned pointer is independent of the input variable", func(t *testing.T) { + s := "original" + p := StringPtr(s) + require.Equal(t, "original", *p) + s = "mutated" + require.Equal(t, "original", *p) + }) +} + +func TestStringValueAndStringPtrRoundtrip(t *testing.T) { + tests := []struct { + name string + in string + }{ + {name: "empty", in: ""}, + {name: "ascii", in: "hello"}, + {name: "with spaces", in: "with spaces"}, + {name: "with newline", in: "with\nnewline"}, + {name: "multibyte rune", in: "🦄"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.in, StringValue(StringPtr(tt.in))) + }) + } +} From fc7648df041f9bbe0c94d28b9733f6f0964bdb90 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Sun, 26 Apr 2026 12:28:12 +0530 Subject: [PATCH 2/2] chore(observability): add tests for previously-uncovered helpers internal/observability had 17.7% statement coverage. Most production helpers were untested, including the pprof handler, the chi-aware tracing helpers, the intercepting response writer, the request-logger helpers, the cleanup wrapper, and the disabled-config and error branches of the configure-* setup functions. Adds focused, infra-free unit tests across cleanup, profiler, metrics, tracing, request-tracing, and request-logger files. Tests for enable* success paths cancel the supplied context immediately and let the cleanup goroutines wind down rather than asserting on listener state. Coverage for the package goes from 17.7% to 81.4% with no behaviour changes. The remaining gap is in functions that need a live *pop.Connection (the gobuffalo ORM), defensive panic-recover branches, and code paths past the package-level sync.Once first call; those need integration-style fixtures that this PR deliberately doesn't introduce. Signed-off-by: Manas Srivastava --- internal/observability/cleanup_test.go | 24 ++++ internal/observability/logging_test.go | 56 ++++++++ internal/observability/metrics_test.go | 68 +++++++++ internal/observability/profiler_test.go | 78 +++++++++++ internal/observability/request-logger_test.go | 74 ++++++++++ .../observability/request-tracing_test.go | 131 ++++++++++++++++++ internal/observability/tracing_test.go | 53 +++++++ 7 files changed, 484 insertions(+) create mode 100644 internal/observability/cleanup_test.go create mode 100644 internal/observability/logging_test.go create mode 100644 internal/observability/metrics_test.go create mode 100644 internal/observability/profiler_test.go create mode 100644 internal/observability/request-tracing_test.go create mode 100644 internal/observability/tracing_test.go diff --git a/internal/observability/cleanup_test.go b/internal/observability/cleanup_test.go new file mode 100644 index 0000000000..c3e945ddd0 --- /dev/null +++ b/internal/observability/cleanup_test.go @@ -0,0 +1,24 @@ +package observability + +import ( + "context" + "testing" + "time" +) + +func TestWaitForCleanup(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + WaitForCleanup(ctx) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("WaitForCleanup did not return after context cancellation") + } +} diff --git a/internal/observability/logging_test.go b/internal/observability/logging_test.go new file mode 100644 index 0000000000..2dbf0d8763 --- /dev/null +++ b/internal/observability/logging_test.go @@ -0,0 +1,56 @@ +package observability + +import ( + "os" + "path/filepath" + "sync" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/conf" +) + +func TestConfigureLoggingWithFile(t *testing.T) { + // loggingOnce must be reset for this branch to run; we can only do this + // safely from inside the package's own tests. + loggingOnce = sync.Once{} + + dir := t.TempDir() + logFile := filepath.Join(dir, "test.log") + + require.NoError(t, ConfigureLogging(&conf.LoggingConfig{ + File: logFile, + Level: "info", + Fields: map[string]interface{}{ + "env": "test", + "region": "us-east-1", + }, + SQL: LOG_SQL_ALL, + })) + + // The configure path opens the log file before writing the "Set output + // file to ..." entry; the file must exist after the call returns. + _, err := os.Stat(logFile) + require.NoError(t, err) +} + +func TestNewCustomFormatterFormatsTimeAsUTC(t *testing.T) { + f := NewCustomFormatter() + require.NotNil(t, f) + + entry := logrus.NewEntry(logrus.New()) + entry.Message = "hello" + out, err := f.Format(entry) + require.NoError(t, err) + require.Contains(t, string(out), "hello") +} + +func TestSetPopLoggerExecutesAllSQLConfigs(t *testing.T) { + // setPopLogger registers a closure with pop.SetLogger; calling it for + // each documented SQL log mode covers the three branches that select + // shouldLogSQL and shouldLogSQLArgs. + for _, mode := range []string{LOG_SQL_NONE, LOG_SQL_STATEMENT, LOG_SQL_ALL, ""} { + setPopLogger(mode) + } +} diff --git a/internal/observability/metrics_test.go b/internal/observability/metrics_test.go new file mode 100644 index 0000000000..ab991c6957 --- /dev/null +++ b/internal/observability/metrics_test.go @@ -0,0 +1,68 @@ +package observability + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/conf" +) + +func TestMeter(t *testing.T) { + require.NotNil(t, Meter("test-meter")) +} + +func TestObtainMetricCounter(t *testing.T) { + require.NotNil(t, ObtainMetricCounter("test_counter", "a counter for tests")) +} + +func TestConfigureMetricsNilContextPanics(t *testing.T) { + require.PanicsWithValue(t, "context must not be nil", func() { + _ = ConfigureMetrics(nil, &conf.MetricsConfig{}) + }) +} + +func TestConfigureMetricsDisabled(t *testing.T) { + // metricsOnce is a package-level *sync.Once used to ensure ConfigureMetrics + // runs exactly once per process. Reset it for this isolated test so we can + // exercise the disabled-config branch without coupling to other tests. + metricsOnce = &sync.Once{} + require.NoError(t, ConfigureMetrics(context.Background(), &conf.MetricsConfig{Enabled: false})) +} + +func TestEnableOpenTelemetryMetricsRejectsUnsupportedExporter(t *testing.T) { + err := enableOpenTelemetryMetrics(context.Background(), &conf.MetricsConfig{ExporterProtocol: "http/json"}) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported OpenTelemetry exporter protocol") +} + +func TestEnablePrometheusMetricsStartsAndShutsDown(t *testing.T) { + // Coverage of the synchronous setup path; the goroutine that calls + // ListenAndServe shuts down when the context is cancelled. The function + // returns nil whether or not the listener binds, so we only assert the + // happy synchronous outcome. + ctx, cancel := context.WithCancel(context.Background()) + port := freeLocalPort(t) + require.NoError(t, enablePrometheusMetrics(ctx, &conf.MetricsConfig{ + PrometheusListenHost: "127.0.0.1", + PrometheusListenPort: port, + })) + cancel() + time.Sleep(150 * time.Millisecond) +} + +func TestEnableOpenTelemetryMetricsGRPC(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + require.NoError(t, enableOpenTelemetryMetrics(ctx, &conf.MetricsConfig{ExporterProtocol: "grpc"})) + cancel() + time.Sleep(150 * time.Millisecond) +} + +func TestEnableOpenTelemetryMetricsHTTPProtobuf(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + require.NoError(t, enableOpenTelemetryMetrics(ctx, &conf.MetricsConfig{ExporterProtocol: "http/protobuf"})) + cancel() + time.Sleep(150 * time.Millisecond) +} diff --git a/internal/observability/profiler_test.go b/internal/observability/profiler_test.go new file mode 100644 index 0000000000..4ba44926d9 --- /dev/null +++ b/internal/observability/profiler_test.go @@ -0,0 +1,78 @@ +package observability + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/conf" +) + +// freeLocalPort returns the string form of a free TCP port on 127.0.0.1. +// Used by tests that need to start a real listener (profiler, prometheus) +// without colliding with other services or other tests running in parallel. +func freeLocalPort(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := l.Addr().(*net.TCPAddr).Port + require.NoError(t, l.Close()) + return strconv.Itoa(port) +} + +func TestConfigureProfilerDisabled(t *testing.T) { + require.NoError(t, ConfigureProfiler(context.Background(), &conf.ProfilerConfig{Enabled: false})) +} + +func TestConfigureProfilerStartsAndShutsDown(t *testing.T) { + // Start the profiler on a free localhost port and immediately cancel the + // context; the cleanup goroutine should run server.Shutdown without + // hanging the test. The function returns nil regardless of whether the + // underlying listener bound successfully, so coverage focuses on the + // synchronous setup path. + ctx, cancel := context.WithCancel(context.Background()) + port := freeLocalPort(t) + require.NoError(t, ConfigureProfiler(ctx, &conf.ProfilerConfig{ + Enabled: true, + Host: "127.0.0.1", + Port: port, + })) + cancel() + // Give the cleanup goroutine a brief moment to run. + time.Sleep(150 * time.Millisecond) +} + +func TestProfilerHandlerServeHTTP(t *testing.T) { + tests := []struct { + name string + path string + expectedStatus int + }{ + {name: "pprof index", path: "/debug/pprof/", expectedStatus: http.StatusOK}, + {name: "pprof cmdline", path: "/debug/pprof/cmdline", expectedStatus: http.StatusOK}, + {name: "pprof symbol", path: "/debug/pprof/symbol", expectedStatus: http.StatusOK}, + {name: "pprof goroutine", path: "/debug/pprof/goroutine", expectedStatus: http.StatusOK}, + {name: "pprof heap", path: "/debug/pprof/heap", expectedStatus: http.StatusOK}, + {name: "pprof allocs", path: "/debug/pprof/allocs", expectedStatus: http.StatusOK}, + {name: "pprof threadcreate", path: "/debug/pprof/threadcreate", expectedStatus: http.StatusOK}, + {name: "pprof block", path: "/debug/pprof/block", expectedStatus: http.StatusOK}, + {name: "pprof mutex", path: "/debug/pprof/mutex", expectedStatus: http.StatusOK}, + {name: "unknown path returns 404", path: "/debug/pprof/unknown", expectedStatus: http.StatusNotFound}, + {name: "non-pprof path returns 404", path: "/something/else", expectedStatus: http.StatusNotFound}, + } + + handler := &ProfilerHandler{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + require.Equal(t, tt.expectedStatus, w.Code) + }) + } +} diff --git a/internal/observability/request-logger_test.go b/internal/observability/request-logger_test.go index 4f50d8c5bc..af5db31136 100644 --- a/internal/observability/request-logger_test.go +++ b/internal/observability/request-logger_test.go @@ -93,3 +93,77 @@ func TestContext(t *testing.T) { } } } + +func TestNewLogEntry(t *testing.T) { + le := NewLogEntry(logrus.NewEntry(logrus.StandardLogger())) + require.NotNil(t, le) + // NewLogEntry returns a chimiddleware.LogEntry; verify the underlying type + // is the package's *logEntry so downstream casts in GetLogEntry work. + _, ok := le.(*logEntry) + require.True(t, ok, "expected NewLogEntry to return *logEntry") +} + +func TestGetLogEntryFallback(t *testing.T) { + // No log entry is attached to this request's context, so GetLogEntry + // should return a fresh fallback entry rather than panic or nil. + req := httptest.NewRequest(http.MethodGet, "/", nil) + got := GetLogEntry(req) + require.NotNil(t, got) + require.NotNil(t, got.Entry) +} + +func TestGetLogEntryReturnsAttachedEntry(t *testing.T) { + want := &logEntry{Entry: logrus.NewEntry(logrus.StandardLogger())} + req := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := SetLogEntryWithContext(req.Context(), want) + req = req.WithContext(ctx) + + got := GetLogEntry(req) + require.Same(t, want, got) +} + +func TestLogEntrySetField(t *testing.T) { + le := &logEntry{Entry: logrus.NewEntry(logrus.StandardLogger())} + req := httptest.NewRequest(http.MethodGet, "/", nil) + req = req.WithContext(SetLogEntryWithContext(req.Context(), le)) + + LogEntrySetField(req, "user_id", "abc-123") + require.Equal(t, "abc-123", le.Entry.Data["user_id"]) +} + +func TestLogEntrySetFieldsMerges(t *testing.T) { + le := &logEntry{Entry: logrus.NewEntry(logrus.StandardLogger())} + req := httptest.NewRequest(http.MethodGet, "/", nil) + req = req.WithContext(SetLogEntryWithContext(req.Context(), le)) + + LogEntrySetFields(req, logrus.Fields{ + "session": "s1", + "trace": "t1", + }) + require.Equal(t, "s1", le.Entry.Data["session"]) + require.Equal(t, "t1", le.Entry.Data["trace"]) +} + +func TestLogEntrySetFieldNoLogEntryInContextIsNoop(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + require.NotPanics(t, func() { + LogEntrySetField(req, "k", "v") + LogEntrySetFields(req, logrus.Fields{"k": "v"}) + }) +} + +func TestLogEntryPanicWritesPanicAndStackFields(t *testing.T) { + var buf bytes.Buffer + logger := logrus.New() + logger.SetOutput(&buf) + logger.SetFormatter(&logrus.JSONFormatter{}) + + le := &logEntry{Entry: logrus.NewEntry(logger)} + le.Panic("boom", []byte("fake-stack-trace")) + + var out map[string]interface{} + require.NoError(t, json.Unmarshal(buf.Bytes(), &out)) + require.Equal(t, "request panicked", out["msg"]) + require.Equal(t, "fake-stack-trace", out["stack"]) + require.Contains(t, out["panic"], "boom") +} diff --git a/internal/observability/request-tracing_test.go b/internal/observability/request-tracing_test.go new file mode 100644 index 0000000000..ae49d69032 --- /dev/null +++ b/internal/observability/request-tracing_test.go @@ -0,0 +1,131 @@ +package observability + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" +) + +func TestGetChiRoutePatternNoRouteContext(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/anything", nil) + require.Equal(t, "noroute", getChiRoutePattern(req)) +} + +func TestGetChiRoutePatternWithRouter(t *testing.T) { + r := chi.NewRouter() + var captured string + r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) { + captured = getChiRoutePattern(r) + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/users/42", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "/users/{id}", captured) +} + +func TestTraceChiRoutesSafelyDoesNotPanicWithoutRouteContext(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/no/route/context", nil) + require.NotPanics(t, func() { traceChiRoutesSafely(req) }) +} + +func TestTraceChiRouteURLParamsSafelyDoesNotPanicWithoutRouteContext(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/no/route/context", nil) + require.NotPanics(t, func() { traceChiRouteURLParamsSafely(req) }) +} + +func TestAddMetricAttributesIncludesRoutePattern(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/something", nil) + attrs := addMetricAttributes(req) + require.NotEmpty(t, attrs) + // The first attribute should be the route key. Without a chi RouteContext + // the value falls back to "noroute". + require.Equal(t, "noroute", attrs[0].Value.AsString()) +} + +func TestInterceptingResponseWriterDelegates(t *testing.T) { + inner := httptest.NewRecorder() + w := &interceptingResponseWriter{writer: inner} + + w.Header().Set("X-Test", "value") + require.Equal(t, "value", inner.Header().Get("X-Test")) + + w.WriteHeader(http.StatusTeapot) + require.Equal(t, http.StatusTeapot, inner.Code) + require.Equal(t, http.StatusTeapot, w.statusCode) + + n, err := w.Write([]byte("hello")) + require.NoError(t, err) + require.Equal(t, 5, n) + require.Equal(t, "hello", inner.Body.String()) +} + +func TestCountStatusCodesSafelyNilCounterIsNoop(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/path", nil) + writer := &interceptingResponseWriter{statusCode: http.StatusOK} + require.NotPanics(t, func() { countStatusCodesSafely(writer, req, nil) }) +} + +func TestTraceChiRoutesSafelyWithRouteContext(t *testing.T) { + r := chi.NewRouter() + r.Get("/items/{id}", func(w http.ResponseWriter, r *http.Request) { + require.NotPanics(t, func() { traceChiRoutesSafely(r) }) + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/items/42", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) +} + +func TestTraceChiRouteURLParamsSafelyWithRouteContext(t *testing.T) { + r := chi.NewRouter() + r.Get("/orgs/{org}/items/{id}", func(w http.ResponseWriter, r *http.Request) { + require.NotPanics(t, func() { traceChiRouteURLParamsSafely(r) }) + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/orgs/acme/items/7", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) +} + +func TestCountStatusCodesSafelyWithRealCounter(t *testing.T) { + counter, err := otel.Meter("gotrue-test").Int64Counter("test_status_codes") + require.NoError(t, err) + + r := chi.NewRouter() + r.Get("/items/{id}", func(w http.ResponseWriter, r *http.Request) { + writer := &interceptingResponseWriter{statusCode: http.StatusOK} + require.NotPanics(t, func() { countStatusCodesSafely(writer, r, counter) }) + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/items/1", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) +} + +func TestRequestTracingMiddlewareInvokesNext(t *testing.T) { + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + + handler := RequestTracing()(next) + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + req.Header.Set("User-Agent", "agent-under-test") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.True(t, called, "next handler should have been invoked") + require.Equal(t, http.StatusOK, w.Code) +} diff --git a/internal/observability/tracing_test.go b/internal/observability/tracing_test.go new file mode 100644 index 0000000000..4807c87f2a --- /dev/null +++ b/internal/observability/tracing_test.go @@ -0,0 +1,53 @@ +package observability + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/conf" +) + +func TestTracer(t *testing.T) { + require.NotNil(t, Tracer("test-tracer")) +} + +func TestOpenTelemetryResource(t *testing.T) { + resource := openTelemetryResource() + require.NotNil(t, resource) + // The resource should at minimum carry the gotrue.version attribute that + // openTelemetryResource merges in. We don't assert the exact value because + // utilities.Version is a build-time string; presence is enough. + require.NotEmpty(t, resource.Attributes()) +} + +func TestConfigureTracingNilContextPanics(t *testing.T) { + require.PanicsWithValue(t, "context must not be nil", func() { + _ = ConfigureTracing(nil, &conf.TracingConfig{}) + }) +} + +func TestConfigureTracingDisabled(t *testing.T) { + require.NoError(t, ConfigureTracing(context.Background(), &conf.TracingConfig{Enabled: false})) +} + +func TestEnableOpenTelemetryTracingRejectsUnsupportedExporter(t *testing.T) { + err := enableOpenTelemetryTracing(context.Background(), &conf.TracingConfig{ExporterProtocol: "http/json"}) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported OpenTelemetry exporter protocol") +} + +func TestEnableOpenTelemetryTracingGRPC(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + require.NoError(t, enableOpenTelemetryTracing(ctx, &conf.TracingConfig{ExporterProtocol: "grpc"})) + cancel() + time.Sleep(150 * time.Millisecond) +} + +func TestEnableOpenTelemetryTracingHTTPProtobuf(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + require.NoError(t, enableOpenTelemetryTracing(ctx, &conf.TracingConfig{ExporterProtocol: "http/protobuf"})) + cancel() + time.Sleep(150 * time.Millisecond) +}