From 37baef565ee0fc388d02c0a13d991ea093f1daad Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Sun, 5 Jul 2026 08:42:42 +0330 Subject: [PATCH] feat: add telescope --- cmd/server/main.go | 25 +++- deploy.sh | 28 ++++ internal/admin/templates/layout.html | 1 + internal/auth/kavenegar.go | 48 ++++++- internal/config/config.go | 16 ++- internal/economy/verify.go | 34 ++++- internal/telescope/page.go | 122 +++++++++++++++++ internal/telescope/telescope.go | 188 +++++++++++++++++++++++++++ internal/telescope/telescope_test.go | 76 +++++++++++ 9 files changed, 530 insertions(+), 8 deletions(-) create mode 100755 deploy.sh create mode 100644 internal/telescope/page.go create mode 100644 internal/telescope/telescope.go create mode 100644 internal/telescope/telescope_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 4c72bde..f2d112a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -18,6 +18,7 @@ import ( "hakemsho/internal/httpx" "hakemsho/internal/static" "hakemsho/internal/store" + "hakemsho/internal/telescope" "hakemsho/internal/user" "hakemsho/internal/ws" ) @@ -26,6 +27,9 @@ func main() { slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil))) cfg := config.Load() + // تلسکوپ: بازرسِ درون‌برنامه‌ایِ درخواست‌ها (رینگ‌بافرِ حافظه، بدونِ وابستگی). + scope := telescope.New(cfg.TelescopeSize) + st, err := store.Open(cfg.DBPath) if err != nil { slog.Error("open db", "err", err) @@ -43,9 +47,15 @@ func main() { // اقتصاد و فروشگاه. تأییدِ خرید: مایکت (X-Access-Token) و کافه‌بازار (امضای RSA). // اگر کلیدها تنظیم نشده باشند، به تأییدکننده‌ی توسعه برمی‌گردیم (dev fallback). iap := economy.StoreRouter{DevFallback: true} - if cfg.MyketAccessToken != "" && cfg.IAPPackage != "" { - iap.Myket = economy.NewMyketVerifier(cfg.IAPPackage, cfg.MyketAccessToken) - slog.Info("IAP myket configured", "package", cfg.IAPPackage) + // مایکت: تأییدِ آفلاینِ RSA (اگر کلید باشد) و/یا سمت‌سرور با access token. + if cfg.MyketRSAKey != "" || (cfg.MyketAccessToken != "" && cfg.IAPPackage != "") { + mv, err := economy.NewMyketVerifier(cfg.IAPPackage, cfg.MyketAccessToken).WithRSAKey(cfg.MyketRSAKey) + if err != nil { + slog.Error("myket rsa key", "err", err) + os.Exit(1) + } + iap.Myket = mv + slog.Info("IAP myket configured", "package", cfg.IAPPackage, "rsa", cfg.MyketRSAKey != "", "server", cfg.MyketAccessToken != "") } if cfg.BazaarRSAKey != "" { bv, err := economy.NewBazaarRSAVerifier(cfg.BazaarRSAKey) @@ -87,6 +97,8 @@ func main() { r.Use(middleware.RealIP) r.Use(middleware.Recoverer) r.Use(middleware.Timeout(15 * time.Second)) + // بازرسِ درخواست‌ها: هر درخواستِ /api و /admin را در رینگ‌بافر ثبت می‌کند. + r.Use(scope.Middleware) r.Get("/health", func(w http.ResponseWriter, _ *http.Request) { httpx.JSON(w, http.StatusOK, map[string]string{"status": "ok"}) @@ -103,6 +115,13 @@ func main() { // پنل ادمین (HTML سرور‌ساید، پشت Basic Auth) r.Mount("/admin", admin.New(st.DB, eco, cfg.CardsDir, cfg.CarpetsDir).Routes(cfg.AdminUser, cfg.AdminPass)) + // بازرسِ درخواست‌ها (Telescope-lite) — پشتِ همان Basic Auth ادمین. + r.Group(func(r chi.Router) { + r.Use(middleware.BasicAuth("hakemsho-admin", map[string]string{cfg.AdminUser: cfg.AdminPass})) + r.Get("/admin/telescope", scope.Page) + r.Get("/admin/telescope/data", scope.Data) + }) + r.Route("/api", func(r chi.Router) { r.Route("/auth", func(r chi.Router) { r.Post("/login-otp", authH.LoginOTP) diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..ee86ef9 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# استقرارِ بکندِ حکم‌شو روی سرور: کراس‌کامپایل لینوکس + آپلود + ری‌استارتِ systemd. +# بدونِ رمز (کلیدِ SSH). اجرا: ./deploy.sh +set -euo pipefail + +HOST="${HAKEM_HOST:-ubuntu@95.38.179.159}" +KEY="${HAKEM_KEY:-$HOME/.ssh/hakem_deploy}" +REMOTE_DIR="/home/ubuntu/hakemsho" + +cd "$(dirname "$0")" + +echo "==> build linux/amd64 (CGO off, pure-Go sqlite)" +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /tmp/hakemsho-linux ./cmd/server + +echo "==> upload to $HOST" +scp -i "$KEY" -o StrictHostKeyChecking=no /tmp/hakemsho-linux "$HOST:$REMOTE_DIR/hakemsho.new" + +echo "==> swap in + restart (zero-config; data & env untouched)" +ssh -i "$KEY" -o StrictHostKeyChecking=no "$HOST" ' + set -e + mv ~/hakemsho/hakemsho.new ~/hakemsho/hakemsho + chmod +x ~/hakemsho/hakemsho + sudo systemctl restart hakemsho + sleep 2 + systemctl is-active hakemsho + curl -s -m5 http://127.0.0.1:8080/health && echo +' +echo "==> done: https://hakem.approagency.ir/health" diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html index 9d4e698..6583c0c 100644 --- a/internal/admin/templates/layout.html +++ b/internal/admin/templates/layout.html @@ -36,5 +36,6 @@ فرش‌ها چت کاربران + تلسکوپ {{end}} diff --git a/internal/auth/kavenegar.go b/internal/auth/kavenegar.go index 8764ce1..9fea869 100644 --- a/internal/auth/kavenegar.go +++ b/internal/auth/kavenegar.go @@ -9,10 +9,13 @@ import ( "time" ) -// Kavenegar کلاینت ارسال OTP از طریق verify/lookup (معادل KavenegarService.php). +// Kavenegar کلاینت ارسال OTP. اگر Template تنظیم شده باشد از verify/lookup +// (تمپلیتِ تأییدشده) استفاده می‌کند؛ در غیر این صورت پیامکِ متنیِ ساده با +// sms/send می‌فرستد (مثل approagency — بدون نیاز به تأییدِ تمپلیت). type Kavenegar struct { APIKey string - Template string // مثل "loginotp" + Template string // خالی ⇒ ارسالِ متنیِ ساده + Sender string // خطِ فرستنده (خالی ⇒ خطِ پیش‌فرضِ حساب) client *http.Client } @@ -24,11 +27,14 @@ func NewKavenegar(apiKey, template string) *Kavenegar { } } -// SendVerify کد را با تمپلیت verify ارسال می‌کند. +// SendVerify کد ورود را ارسال می‌کند (verify/lookup اگر تمپلیت باشد، وگرنه متنِ ساده). func (k *Kavenegar) SendVerify(ctx context.Context, mobile, token string) error { if k.APIKey == "" { return fmt.Errorf("KAVE_API_KEY is null") } + if k.Template == "" { + return k.sendPlain(ctx, mobile, token) + } endpoint := fmt.Sprintf("https://api.kavenegar.com/v1/%s/verify/lookup.json", k.APIKey) q := url.Values{} q.Set("receptor", mobile) @@ -59,3 +65,39 @@ func (k *Kavenegar) SendVerify(ctx context.Context, mobile, token string) error } return nil } + +// sendPlain پیامکِ متنیِ سادهٔ کد ورود را با sms/send می‌فرستد (بدونِ تمپلیت). +// قالبِ پیام مانند approagency است؛ خطِ پایانی «#کد» به تشخیصِ خودکارِ کد کمک می‌کند. +func (k *Kavenegar) sendPlain(ctx context.Context, mobile, token string) error { + endpoint := fmt.Sprintf("https://api.kavenegar.com/v1/%s/sms/send.json", k.APIKey) + message := fmt.Sprintf("کد ورود به حکم‌شو: %s\n#%s", token, token) + q := url.Values{} + q.Set("receptor", mobile) + q.Set("message", message) + if k.Sender != "" { + q.Set("sender", k.Sender) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+q.Encode(), nil) + if err != nil { + return err + } + resp, err := k.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + var out struct { + Return struct { + Status int `json:"status"` + Message string `json:"message"` + } `json:"return"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return fmt.Errorf("kavenegar decode: %w", err) + } + if out.Return.Status != 200 { + return fmt.Errorf("kavenegar sms failed: status=%d msg=%s", out.Return.Status, out.Return.Message) + } + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 5786681..63d2b13 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "bufio" "os" + "strconv" "strings" "time" ) @@ -22,9 +23,11 @@ type Config struct { AdminPass string // پسوردِ پنل ادمین (Basic Auth) CardsDir string // پوشه‌ی دیسکیِ اسکین‌های آپلودی (روی volume ماندگار) CarpetsDir string // پوشه‌ی دیسکیِ تصاویرِ فرش (روی volume ماندگار) + TelescopeSize int // تعدادِ درخواستی که بازرسِ حافظه نگه می‌دارد // خرید درون‌برنامه‌ای (IAP) IAPPackage string // نامِ بسته‌ی اپ (برای مایکت/کافه) MyketAccessToken string // access token مایکت (تأیید سمت‌سرور) + MyketRSAKey string // کلیدِ عمومیِ RSA مایکت (تأیید امضای آفلاین) BazaarRSAKey string // کلیدِ عمومیِ RSA کافه‌بازار (تأیید امضای آفلاین) } @@ -37,7 +40,7 @@ func Load() Config { JWTSecret: env("JWT_SECRET", "change-me-in-production"), JWTTTL: 30 * 24 * time.Hour, KaveAPIKey: env("KAVE_API_KEY", ""), - KaveTemple: env("KAVE_TEMPLATE", "loginotp"), + KaveTemple: env("KAVE_TEMPLATE", ""), // خالی ⇒ پیامکِ متنیِ ساده (بدونِ تمپلیت) OTPTTL: 15 * time.Minute, AdminMobile: env("ADMIN_MOBILE", ""), AdminOTP: env("ADMIN_OTP", ""), @@ -45,8 +48,10 @@ func Load() Config { AdminPass: env("ADMIN_PANEL_PASS", "admin"), CardsDir: env("CARDS_DIR", "carddata"), CarpetsDir: env("CARPETS_DIR", "carpetdata"), + TelescopeSize: envInt("TELESCOPE_SIZE", 200), IAPPackage: env("IAP_PACKAGE", ""), MyketAccessToken: env("MYKET_ACCESS_TOKEN", ""), + MyketRSAKey: env("MYKET_RSA_KEY", ""), BazaarRSAKey: env("BAZAAR_RSA_KEY", ""), } } @@ -83,3 +88,12 @@ func env(key, def string) string { } return def } + +func envInt(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} diff --git a/internal/economy/verify.go b/internal/economy/verify.go index 9c7cd36..d087e99 100644 --- a/internal/economy/verify.go +++ b/internal/economy/verify.go @@ -95,16 +95,22 @@ func (t *TapsellAdVerifier) Verify(ctx context.Context, userID int64, token stri return out.Valid && out.Completed, nil } -// --- مایکت: تأیید سمت‌سرور با X-Access-Token --- +// --- مایکت: تأیید امضای RSA آفلاین (اولویت) یا سمت‌سرور با X-Access-Token --- // +// افزونه‌ی myket_iap مثلِ کافه‌بازار، purchaseData (JSONِ خام) و signature (base64) +// برمی‌گرداند که با کلیدِ عمومیِ RSA و SHA1withRSA به‌صورتِ آفلاین تأیید می‌شوند — +// بدونِ رفت‌وبرگشتِ شبکه به‌ازای هر خرید. اگر کلیدِ عمومی تنظیم نشده باشد یا امضا +// ارسال نشده باشد، به تأییدِ سمت‌سرور برمی‌گردیم: // GET https://developer.myket.ir/api/applications/{pkg}/purchases/products/{sku}/tokens/{token} // هدر: X-Access-Token: . پاسخ purchaseState=0 یعنی معتبر (غیرِ صفر=مسترد). type MyketVerifier struct { PackageName string AccessToken string + pub *rsa.PublicKey // کلیدِ عمومیِ RSA برای تأییدِ آفلاین (اختیاری) client *http.Client } +// NewMyketVerifier یک تأییدکننده با access token (سمت‌سرور) می‌سازد. func NewMyketVerifier(pkg, accessToken string) *MyketVerifier { return &MyketVerifier{ PackageName: pkg, @@ -113,7 +119,33 @@ func NewMyketVerifier(pkg, accessToken string) *MyketVerifier { } } +// WithRSAKey کلیدِ عمومیِ RSA را برای تأییدِ آفلاین ضمیمه می‌کند (اگر خالی نباشد). +func (v *MyketVerifier) WithRSAKey(publicKey string) (*MyketVerifier, error) { + if strings.TrimSpace(publicKey) == "" { + return v, nil + } + pub, err := parseRSAPublicKey(publicKey) + if err != nil { + return nil, fmt.Errorf("myket rsa: %w", err) + } + v.pub = pub + return v, nil +} + func (v *MyketVerifier) Verify(ctx context.Context, p IAPProof) (bool, error) { + // مسیرِ ترجیحی: تأییدِ امضای آفلاین (اگر کلید و امضا موجود باشند). + if v.pub != nil && p.PurchaseData != "" && p.Signature != "" { + sig, err := base64.StdEncoding.DecodeString(p.Signature) + if err != nil { + return false, fmt.Errorf("myket: bad signature encoding: %w", err) + } + h := sha1.Sum([]byte(p.PurchaseData)) + if err := rsa.VerifyPKCS1v15(v.pub, crypto.SHA1, h[:], sig); err != nil { + return false, nil // امضای نامعتبر ⇒ خریدِ نامعتبر + } + return true, nil + } + // مسیرِ جایگزین: تأییدِ سمت‌سرور با access token. if v.AccessToken == "" || p.Token == "" { return false, fmt.Errorf("myket: missing access token or purchase token") } diff --git a/internal/telescope/page.go b/internal/telescope/page.go new file mode 100644 index 0000000..ec3763b --- /dev/null +++ b/internal/telescope/page.go @@ -0,0 +1,122 @@ +package telescope + +// pageHTML یک صفحه‌ی مستقل (بدونِ وابستگیِ خارجی) که هر ۲ ثانیه داده را می‌گیرد. +const pageHTML = ` + + + + +تلسکوپ — بازرسِ درخواست‌ها + + + +
+ +

تلسکوپ — بازرسِ درخواست‌ها

+ + +
+
+ + + +
+ + + + + +
زمانمتدمسیروضعیتمدتحجمIP
+ + + +` diff --git a/internal/telescope/telescope.go b/internal/telescope/telescope.go new file mode 100644 index 0000000..bb92b28 --- /dev/null +++ b/internal/telescope/telescope.go @@ -0,0 +1,188 @@ +// Package telescope یک بازرسِ سبکِ درخواست‌ها است (شبیهِ Laravel Telescope). +// آخرین N درخواست را در یک رینگ‌بافرِ حافظه نگه می‌دارد: متد، مسیر، وضعیت، +// مدت‌زمان، IP و در صورتِ خطا (>=۴۰۰) بدنه‌ی درخواست. بدونِ هیچ وابستگیِ خارجی +// و با سرباری ناچیز؛ برای پایشِ سریع روی سرورِ کم‌منابع مناسب است. +package telescope + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "net" + "net/http" + "strings" + "sync" + "time" +) + +// Entry یک درخواستِ ثبت‌شده را نگه می‌دارد. +type Entry struct { + ID int64 `json:"id"` + Time time.Time `json:"time"` + Method string `json:"method"` + Path string `json:"path"` + Status int `json:"status"` + Duration int64 `json:"duration_ms"` + IP string `json:"ip"` + Bytes int `json:"bytes"` + Body string `json:"body,omitempty"` // فقط برای خطاها (>=۴۰۰) و POST/PUT +} + +// Telescope رینگ‌بافرِ هم‌زمان‌امنِ درخواست‌ها. +type Telescope struct { + mu sync.Mutex + buf []Entry + size int + next int64 // شناسه‌ی افزایشی + full bool + head int // اندیسِ نوشتنِ بعدی +} + +// New یک بازرس با ظرفیتِ size می‌سازد (حداقل ۱). +func New(size int) *Telescope { + if size < 1 { + size = 200 + } + return &Telescope{buf: make([]Entry, size), size: size} +} + +func (t *Telescope) add(e Entry) { + t.mu.Lock() + t.next++ + e.ID = t.next + t.buf[t.head] = e + t.head = (t.head + 1) % t.size + if t.head == 0 { + t.full = true + } + t.mu.Unlock() +} + +// Entries آخرین درخواست‌ها را به‌ترتیبِ جدید-به-قدیم برمی‌گرداند. +func (t *Telescope) Entries() []Entry { + t.mu.Lock() + defer t.mu.Unlock() + n := t.head + if t.full { + n = t.size + } + out := make([]Entry, 0, n) + // از جدیدترین (درست قبلِ head) عقب می‌رویم. + for i := 0; i < n; i++ { + idx := (t.head - 1 - i + t.size*2) % t.size + out = append(out, t.buf[idx]) + } + return out +} + +// statusRecorder کدِ وضعیت و حجمِ پاسخ را می‌گیرد. +type statusRecorder struct { + http.ResponseWriter + status int + bytes int +} + +func (r *statusRecorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} + +func (r *statusRecorder) Write(b []byte) (int, error) { + if r.status == 0 { + r.status = http.StatusOK + } + n, err := r.ResponseWriter.Write(b) + r.bytes += n + return n, err +} + +// Hijack برای WebSocket لازم است تا میدل‌ور مانعِ ارتقاء نشود. +func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if h, ok := r.ResponseWriter.(http.Hijacker); ok { + return h.Hijack() + } + return nil, nil, http.ErrNotSupported +} + +// Middleware هر درخواستِ /api و /admin را ثبت می‌کند (به‌جز خودِ صفحه‌ی تلسکوپ). +func (t *Telescope) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + p := req.URL.Path + if p == "/ws" || strings.HasPrefix(p, "/admin/telescope") || + strings.HasPrefix(p, "/cards/") || strings.HasPrefix(p, "/carpets/") { + next.ServeHTTP(w, req) + return + } + + var body string + if (req.Method == http.MethodPost || req.Method == http.MethodPut) && req.Body != nil { + b, _ := io.ReadAll(io.LimitReader(req.Body, 4096)) + req.Body = io.NopCloser(bytes.NewReader(b)) + body = maskSecrets(string(b)) + } + + rec := &statusRecorder{ResponseWriter: w} + start := time.Now() + next.ServeHTTP(rec, req) + dur := time.Since(start) + + e := Entry{ + Time: start, + Method: req.Method, + Path: p, + Status: rec.status, + Duration: dur.Milliseconds(), + IP: req.RemoteAddr, + Bytes: rec.bytes, + } + // بدنه را فقط برای خطاها نگه می‌داریم تا حافظه/حریمِ خصوصی حفظ شود. + if rec.status >= 400 { + e.Body = body + } + t.add(e) + }) +} + +// maskSecrets مقادیرِ حساس (توکن، کد، پسورد) را در بدنه‌ی JSON پنهان می‌کند. +func maskSecrets(s string) string { + for _, k := range []string{"password", "token", "code", "otp", "signature"} { + s = maskField(s, k) + } + return s +} + +func maskField(s, key string) string { + needle := `"` + key + `"` + i := strings.Index(s, needle) + if i < 0 { + return s + } + // دنبالِ ": " و سپس مقدار می‌گردیم. + j := strings.Index(s[i:], ":") + if j < 0 { + return s + } + rest := s[i+j+1:] + rest = strings.TrimLeft(rest, " ") + if strings.HasPrefix(rest, `"`) { + end := strings.Index(rest[1:], `"`) + if end >= 0 { + masked := s[:i+j+1] + `"***"` + rest[end+2:] + return masked + } + } + return s +} + +// Data آخرین درخواست‌ها را به‌صورتِ JSON برمی‌گرداند. +func (t *Telescope) Data(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(t.Entries()) +} + +// Page یک صفحه‌ی HTML سبک برای مرورِ درخواست‌ها می‌دهد. +func (t *Telescope) Page(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, pageHTML) +} diff --git a/internal/telescope/telescope_test.go b/internal/telescope/telescope_test.go new file mode 100644 index 0000000..52bee6e --- /dev/null +++ b/internal/telescope/telescope_test.go @@ -0,0 +1,76 @@ +package telescope + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestRingOrderAndOverflow(t *testing.T) { + tel := New(3) + for i := 0; i < 5; i++ { + tel.add(Entry{Path: string(rune('a' + i))}) + } + got := tel.Entries() + if len(got) != 3 { + t.Fatalf("want 3 entries, got %d", len(got)) + } + // جدید-به-قدیم: e, d, c (a و b بازنویسی شده‌اند). + want := []string{"e", "d", "c"} + for i, w := range want { + if got[i].Path != w { + t.Errorf("entry %d: want %q got %q", i, w, got[i].Path) + } + } + // شناسه‌ها باید افزایشی و یکتا باشند. + if got[0].ID != 5 || got[2].ID != 3 { + t.Errorf("ids wrong: %d..%d", got[2].ID, got[0].ID) + } +} + +func TestMiddlewareCapturesErrorBodyMasked(t *testing.T) { + tel := New(10) + h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + req := httptest.NewRequest(http.MethodPost, "/api/x", + strings.NewReader(`{"password":"secret","kind":"coin"}`)) + h.ServeHTTP(httptest.NewRecorder(), req) + + e := tel.Entries() + if len(e) != 1 || e[0].Status != 400 { + t.Fatalf("expected one 400 entry, got %+v", e) + } + if !strings.Contains(e[0].Body, `"password":"***"`) { + t.Errorf("password not masked: %s", e[0].Body) + } + if !strings.Contains(e[0].Body, `"kind":"coin"`) { + t.Errorf("non-secret field lost: %s", e[0].Body) + } +} + +func TestMiddlewareOKHasNoBody(t *testing.T) { + tel := New(10) + h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodPost, "/api/y", + strings.NewReader(`{"token":"abc"}`)) + h.ServeHTTP(httptest.NewRecorder(), req) + e := tel.Entries() + if len(e) != 1 || e[0].Body != "" { + t.Errorf("2xx should not store body, got %+v", e) + } +} + +func TestMiddlewareSkipsWS(t *testing.T) { + tel := New(10) + h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/ws", nil)) + if len(tel.Entries()) != 0 { + t.Error("ws request should be skipped") + } +}