package observability import ( "context" "io" "log/slog" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/getsentry/sentry-go" "git.nakama.town/fmartingr/butterrobot/internal/config" ) // testToken mimics the shape of a Telegram bot token, which the webhook URL // embeds in its path. const testToken = "123456:AAFakeTelegramBotTokenValue" // captureTransport records events locally instead of shipping them to Sentry. type captureTransport struct { mu sync.Mutex events []*sentry.Event } func (t *captureTransport) Configure(sentry.ClientOptions) {} func (t *captureTransport) SendEvent(event *sentry.Event) { t.mu.Lock() defer t.mu.Unlock() t.events = append(t.events, event) } func (t *captureTransport) Flush(time.Duration) bool { return true } func (t *captureTransport) FlushWithContext(context.Context) bool { return true } func (t *captureTransport) Close() {} func (t *captureTransport) captured() []*sentry.Event { t.mu.Lock() defer t.mu.Unlock() return append([]*sentry.Event(nil), t.events...) } // enableSentry binds a client that captures events in memory to the current // hub, and unbinds it when the test ends. func enableSentry(t *testing.T, secrets []string) *captureTransport { t.Helper() transport := &captureTransport{} options := clientOptions(config.SentryConfig{ DSN: "https://key@example.invalid/1", Environment: "test", TracesSampleRate: 1.0, }, false, secrets) options.Transport = transport client, err := sentry.NewClient(options) if err != nil { t.Fatalf("failed to create client: %v", err) } hub := sentry.CurrentHub() hub.BindClient(client) t.Cleanup(func() { hub.BindClient(nil) }) return transport } func TestWebhookTokenIsScrubbedFromRequestContext(t *testing.T) { transport := enableSentry(t, []string{testToken}) handler := HTTPMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { sentry.GetHubFromContext(r.Context()).CaptureMessage("handler failure") })) handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/telegram/incoming/"+testToken, nil)) events := transport.captured() if len(events) < 2 { t.Fatalf("expected an error event and a transaction, got %d", len(events)) } for _, event := range events { if event.Request != nil && strings.Contains(event.Request.URL, testToken) { t.Errorf("request URL leaks the bot token: %s", event.Request.URL) } if strings.Contains(event.Transaction, testToken) { t.Errorf("transaction name leaks the bot token: %s", event.Transaction) } } } func TestErrorLogIsForwardedTaggedAndScrubbed(t *testing.T) { transport := enableSentry(t, []string{testToken}) logger := WrapLogger(slog.New(slog.NewTextHandler(io.Discard, nil)), config.SentryConfig{}) ctx := WithTags(context.Background(), map[string]string{"platform": "telegram"}) logger.ErrorContext(ctx, "Error sending message", "error", `Post "https://api.telegram.org/bot`+testToken+`/sendMessage": timeout`) events := transport.captured() if len(events) != 1 { t.Fatalf("expected exactly one event, got %d", len(events)) } event := events[0] if event.Tags["platform"] != "telegram" { t.Errorf("expected the platform tag to reach Sentry, got %v", event.Tags) } for _, exception := range event.Exception { if strings.Contains(exception.Value, testToken) { t.Errorf("exception value leaks the bot token: %s", exception.Value) } } for name, context := range event.Contexts { for key, value := range context { if text, ok := value.(string); ok && strings.Contains(text, testToken) { t.Errorf("context %s.%s leaks the bot token: %s", name, key, text) } } } } func TestNewScrubberIgnoresShortSecrets(t *testing.T) { if newScrubber([]string{"", "1234"}) != nil { t.Fatal("expected short secrets to be ignored") } if newScrubber([]string{testToken}) == nil { t.Fatal("expected a scrubber for a usable secret") } } func TestScrubberRedactsNestedValues(t *testing.T) { s := newScrubber([]string{testToken}) event := sentry.NewEvent() event.Message = "failed calling " + testToken event.Contexts[logContextKey] = sentry.Context{ "nested": map[string]any{"url": "https://api.telegram.org/bot" + testToken}, "count": 3, } scrubbed := s.event(event) if strings.Contains(scrubbed.Message, testToken) { t.Errorf("message leaks the token: %s", scrubbed.Message) } data := scrubbed.Contexts[logContextKey] nested := data["nested"].(map[string]any) if strings.Contains(nested["url"].(string), testToken) { t.Errorf("nested value leaks the token: %v", nested["url"]) } if data["count"] != 3 { t.Errorf("expected non-string values to be untouched, got %v", data["count"]) } }