Ctrl K
ComunidadGuías…

coderd/httpapi/websocket_internal_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package httpapi

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

	"github.com/prometheus/client_golang/prometheus"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"cdr.dev/slog/v3"
	"github.com/coder/coder/v2/testutil"
	"github.com/coder/quartz"
	"github.com/coder/websocket"
)

// websocketPair sets up an httptest server with a websocket endpoint and
// returns the server-side conn. The server handler stays alive until ctx
// is done.
func websocketPair(ctx context.Context, t *testing.T) *websocket.Conn {
	t.Helper()
	serverConnCh := make(chan *websocket.Conn, 1)

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		conn, err := websocket.Accept(w, r, nil)
		if err != nil {
			return
		}
		serverConnCh <- conn
		// Keep the handler alive so the HTTP server doesn't close
		// the connection from under us.
		<-ctx.Done()
	}))
	t.Cleanup(srv.Close)

	//nolint:bodyclose
	clientConn, _, err := websocket.Dial(ctx, srv.URL, nil)
	require.NoError(t, err)
	_ = clientConn.CloseRead(ctx) // Needed to handle pings/pongs.
	t.Cleanup(func() {
		_ = clientConn.Close(websocket.StatusNormalClosure, "test cleanup")
	})

	select {
	case sc := <-serverConnCh:
		_ = sc.CloseRead(ctx) // Needed to handle pings/pongs.
		return sc
	case <-ctx.Done():
		t.Fatal("timed out waiting for server websocket accept")
		return nil
	}
}

// probeRecorder is a simple wrapper around a channel used to record probe results.
type probeRecorder struct {
	T testing.TB
	C chan ProbeResult
}

func (r *probeRecorder) record(_ context.Context, result ProbeResult) {
	select {
	case r.C <- result:
	default:
		r.T.Errorf("probeRecorder.C is full, dropping result %s", result)
	}
}

func TestWSWatcher(t *testing.T) {
	t.Parallel()

	t.Run("ServerSideClose", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitShort)

		sink := testutil.NewFakeSink(t)
		logger := sink.Logger()
		mClock := quartz.NewMock(t)
		rec := &probeRecorder{T: t, C: make(chan ProbeResult, 1)}

		trap := mClock.Trap().NewTicker("WSWatcher")
		defer trap.Close()

		serverConn := websocketPair(ctx, t)

		w := &WSWatcher{rec: rec.record, clk: mClock, interval: time.Second}
		watchCtx := w.Watch(ctx, logger, serverConn)

		// Wait for the ticker to be created, then release.
		trap.MustWait(ctx).MustRelease(ctx)

		// Close the server-side connection before the tick fires.
		// The next ping will get a close/net.ErrClosed error.
		_ = serverConn.Close(websocket.StatusGoingAway, "simulated teardown")

		// Advance clock to trigger the tick.
		mClock.Advance(time.Second).MustWait(ctx)

		// The watch context should be canceled after probe failure.
		select {
		case <-watchCtx.Done():
		case <-ctx.Done():
			t.Fatal("timed out waiting for watch context to be canceled")
		}

		gotRes := testutil.RequireReceive(ctx, t, rec.C)
		assert.Equal(t, ProbePeerClosed, gotRes, "expected ProbePeerClosed result")

		// A closed connection is a normal shutdown condition. The
		// error should be logged at Debug, not Error.
		errorEntries := sink.Entries(func(e slog.SinkEntry) bool { return e.Level == slog.LevelError })
		assert.Empty(t, errorEntries,
			"closed connection should not produce error-level logs, got: %+v", errorEntries)
		debugEntries := sink.Entries(func(e slog.SinkEntry) bool { return e.Level == slog.LevelDebug })
		assert.NotEmpty(t, debugEntries,
			"expected a debug-level log entry for the closed connection")
	})

	t.Run("ContextCanceled", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitShort)

		sink := testutil.NewFakeSink(t)
		logger := sink.Logger()
		mClock := quartz.NewMock(t)
		rec := &probeRecorder{T: t, C: make(chan ProbeResult, 1)}

		trap := mClock.Trap().NewTicker("WSWatcher")
		defer trap.Close()

		serverCtx, serverCancel := context.WithCancel(ctx)
		serverConn := websocketPair(ctx, t)

		w := &WSWatcher{rec: rec.record, clk: mClock, interval: time.Second}
		watchCtx := w.Watch(serverCtx, logger, serverConn)

		trap.MustWait(ctx).MustRelease(ctx)

		// Cancel the parent context. The watcher should exit via
		// the <-ctx.Done() branch without closing the conn.
		serverCancel()

		select {
		case <-watchCtx.Done():
		case <-ctx.Done():
			t.Fatal("timed out waiting for watch context to be canceled")
		}

		errorEntries := sink.Entries(func(e slog.SinkEntry) bool { return e.Level == slog.LevelError })
		assert.Empty(t, errorEntries,
			"context cancellation should not produce error-level logs, got: %+v", errorEntries)
		assert.Empty(t, rec.C, "expected no probes when context is canceled before tick")
	})

	t.Run("PingSucceeds", func(t *testing.T) {
		t.Parallel()
		ctx, cancel := context.WithCancel(testutil.Context(t, testutil.WaitShort))
		defer cancel()

		sink := testutil.NewFakeSink(t)
		logger := sink.Logger()
		mClock := quartz.NewMock(t)
		rec := &probeRecorder{T: t, C: make(chan ProbeResult, 3)}

		trap := mClock.Trap().NewTicker("WSWatcher")
		defer trap.Close()

		serverConn := websocketPair(ctx, t)

		w := &WSWatcher{rec: rec.record, clk: mClock, interval: time.Second}
		watchCtx := w.Watch(ctx, logger, serverConn)
		t.Cleanup(func() {
			<-watchCtx.Done()
		})

		trap.MustWait(ctx).MustRelease(ctx)

		// Fire several ticks; pings should succeed each time.
		for i := range 3 {
			mClock.Advance(time.Second).MustWait(ctx)
			gotRes := testutil.RequireReceive(ctx, t, rec.C)
			assert.Equal(t, ProbeOK, gotRes, "expected probe result to be ProbeOK at tick %d", i+1)
		}

		// No logs should be emitted during normal operation.
		errorEntries := sink.Entries(func(e slog.SinkEntry) bool { return e.Level == slog.LevelError })
		assert.Empty(t, errorEntries,
			"successful pings should not produce error-level logs, got: %+v", errorEntries)
		debugEntries := sink.Entries(func(e slog.SinkEntry) bool { return e.Level == slog.LevelDebug })
		assert.Empty(t, debugEntries,
			"successful pings should not produce debug-level logs, got: %+v", debugEntries)
	})

	t.Run("RecordsPrometheusCounter", func(t *testing.T) {
		t.Parallel()

		// Use a real prometheus registry to verify end-to-end metric recording.
		registry := prometheus.NewRegistry()
		probes := prometheus.NewCounterVec(prometheus.CounterOpts{
			Namespace: "coderd",
			Subsystem: "api",
			Name:      "websocket_probes_total",
			Help:      "test",
		}, []string{"path", "result"})
		registry.MustRegister(probes)

		recorder := func(ctx context.Context, r ProbeResult) {
			probes.WithLabelValues("/test/path", string(r)).Inc()
		}

		sink := testutil.NewFakeSink(t)
		logger := sink.Logger()
		mClock := quartz.NewMock(t)

		trap := mClock.Trap().NewTicker("WSWatcher")
		defer trap.Close()

		ctx, cancel := context.WithCancel(testutil.Context(t, testutil.WaitShort))
		defer cancel()
		serverConn := websocketPair(ctx, t)

		w := &WSWatcher{rec: recorder, clk: mClock, interval: time.Second}
		watchCtx := w.Watch(ctx, logger, serverConn)
		t.Cleanup(func() {
			<-watchCtx.Done()
		})

		trap.MustWait(ctx).MustRelease(ctx)
		mClock.Advance(time.Second).MustWait(ctx)

		testutil.Eventually(ctx, t, func(context.Context) bool {
			select {
			case <-watchCtx.Done():
				t.Fatal("watch context should not be canceled when pings succeed")
			default:
			}
			metrics, err := registry.Gather()
			require.NoError(t, err)
			return testutil.PromCounterHasValue(t, metrics, 1,
				"coderd_api_websocket_probes_total", "/test/path", "ok")
		}, testutil.IntervalFast, "probe counter not incremented")
	})

	t.Run("ProbeTimeout", func(t *testing.T) {
		t.Parallel()
		ctx := testutil.Context(t, testutil.WaitShort)

		mClock := quartz.NewMock(t)
		trap := mClock.Trap().NewTicker("WSWatcher")
		defer trap.Close()

		sink := testutil.NewFakeSink(t)
		logger := sink.Logger()
		rec := &probeRecorder{T: t, C: make(chan ProbeResult, 1)}

		pingCh := make(chan struct{})
		closeCodeCh := make(chan websocket.StatusCode, 1)
		fConn := &fakePingCloser{
			pingFn: func(context.Context) error {
				t.Log("ping")
				close(pingCh)
				// Determinism tradeoff: by returning DeadlineExceeded directly
				// we lose coverage of the WithTimeout path in probe().
				return context.DeadlineExceeded
			},
			closeFn: func(code websocket.StatusCode, _ string) error {
				closeCodeCh <- code
				return nil
			},
		}

		w := &WSWatcher{rec: rec.record, clk: mClock, interval: time.Second}
		watchCtx := w.Watch(ctx, logger, fConn)

		trap.MustWait(ctx).MustRelease(ctx)
		mClock.Advance(time.Second).MustWait(ctx)

		_, _ = testutil.SoftTryReceive(ctx, t, pingCh)

		select {
		case <-watchCtx.Done():
		case <-ctx.Done():
			t.Fatal("timed out waiting for watch context to be canceled")
		}

		gotRes := testutil.RequireReceive(ctx, t, rec.C)
		assert.Equal(t, ProbeTimeout, gotRes, "expected ProbeTimeout result")
		gotCode := testutil.RequireReceive(ctx, t, closeCodeCh)
		assert.Equal(t, websocket.StatusGoingAway, gotCode, "expected StatusGoingAway code")

		// Timeout is an expected condition, should be Debug not Error.
		errorEntries := sink.Entries(func(e slog.SinkEntry) bool { return e.Level == slog.LevelError })
		assert.Empty(t, errorEntries,
			"probe timeout should not produce error-level logs, got: %+v", errorEntries)
	})

	t.Run("ProbeError", func(t *testing.T) {
		t.Parallel()

		sink := testutil.NewFakeSink(t)
		logger := sink.Logger()
		mClock := quartz.NewMock(t)
		trap := mClock.Trap().NewTicker("WSWatcher")
		defer trap.Close()

		rec := &probeRecorder{T: t, C: make(chan ProbeResult, 1)}
		closeCodeCh := make(chan websocket.StatusCode, 1)

		fConn := &fakePingCloser{
			pingFn: func(context.Context) error {
				return assert.AnError
			},
			closeFn: func(code websocket.StatusCode, _ string) error {
				t.Log("close error", code)
				closeCodeCh <- code
				return nil
			},
		}

		w := &WSWatcher{rec: rec.record, clk: mClock, interval: time.Second}

		ctx, cancel := context.WithCancel(testutil.Context(t, testutil.WaitShort))
		defer cancel()
		watchCtx := w.Watch(ctx, logger, fConn)
		t.Cleanup(func() {
			<-watchCtx.Done()
		})

		trap.MustWait(ctx).MustRelease(ctx)
		mClock.Advance(time.Second).MustWait(ctx)

		gotRes := testutil.RequireReceive(ctx, t, rec.C)
		assert.Equal(t, ProbeError, gotRes, "expected ProbeError result")

		gotCode := testutil.RequireReceive(ctx, t, closeCodeCh)
		assert.Equal(t, websocket.StatusGoingAway, gotCode)

		// ProbeError should log at Error level (unlike other failures).
		errorEntries := sink.Entries(func(e slog.SinkEntry) bool {
			return e.Level == slog.LevelError
		})
		assert.NotEmpty(t, errorEntries, "ProbeError should produce error-level log")
	})
}

// fakePingCloser is a test double for the pingCloser interface.
type fakePingCloser struct {
	pingFn  func(context.Context) error
	closeFn func(websocket.StatusCode, string) error
}

func (f *fakePingCloser) Ping(ctx context.Context) error {
	return f.pingFn(ctx)
}

func (f *fakePingCloser) Close(code websocket.StatusCode, reason string) error {
	return f.closeFn(code, reason)
}