Files
job-search/internal/websocket/http_test.go
T
bwilliams c9f42b62ad Added websocket hub for handling websocket clients, with a wsserve command
which runs a plain TCP server for receiving websocket hub clients, with
server config, CSRF and rate limit middleware.

- internal/websocket: Hub pushing messages to WebSocket clients (ServeHTTP,
  example.com/ws upgrade with optional CSRF check) and plain TCP clients (ServeTCP,
  newline-delimited JSON envelopes); ping/pong keepalive, per-client message
  rate limit, slow consumer handling, graceful Shutdown, debug logging of
  messages sent and received
- internal/websocket/tcpserver: standalone TCP server for a hub, with accept
  back-off, a per-IP connection limit (server.websocket.connection_limit) and
  graceful shutdown
- internal/cli/wsserve: `wsserve` command running the TCP server, with
  --listen/--host/--port overrides
- internal/middleware: keyed token bucket rate limiter, real client IP from
  trusted proxies
- internal/httpx: RFC 9457 problem responses, request IDs in context
- internal/config: server section (host, port, shutdown timeout, rate limiter
  idle TTL, websocket); util.ByteSize settings (e.g. "32KiB"); lists of
  sections as single keys; empty lists stored as nil; isExplicitlySet for
  printLoadedConfig; JobSearchConfigSource renamed to JSCSourcesSource;
  TestSections checks every section's methods
- internal/util: ByteSize, parsed and formatted with go-humanize
2026-10-14 15:55:25 +01:00

184 lines
5.3 KiB
Go

package websocket
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.avsdev.uk/Go/job-search/internal/config"
ws "github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
// wsHub serves h's WebSocket clients on a test HTTP server and returns its
// ws:// URL.
func wsHub(t *testing.T, h *Hub) string {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
h.Shutdown(ctx)
srv.Close()
})
return "ws" + strings.TrimPrefix(srv.URL, "http")
}
// dialWS connects a WebSocket client to url and reads its welcome.
func dialWS(t *testing.T, ctx context.Context, url string) *ws.Conn {
t.Helper()
conn, _, err := ws.Dial(ctx, url, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { conn.CloseNow() })
var e Envelope
if err := wsjson.Read(ctx, conn, &e); err != nil || e.Type != TypeWelcome {
t.Fatalf("want welcome, got %+v, %v", e, err)
}
return conn
}
// A client upgraded over HTTP gets replies. A max_message_size of 0 uses the
// default limit rather than refusing every message.
func TestHTTPMessages(t *testing.T) {
h := NewHub(config.JSCServerWebSocket{WriteTimeout: time.Second}, discardLog)
url := wsHub(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
conn := dialWS(t, ctx, url)
if err := wsjson.Write(ctx, conn, Envelope{Type: TypePing, ID: "1"}); err != nil {
t.Fatal(err)
}
var e Envelope
if err := wsjson.Read(ctx, conn, &e); err != nil || e.Type != TypePong || e.ID != "1" {
t.Fatalf("want pong 1, got %+v, %v", e, err)
}
}
// One Push reaches WebSocket and plain TCP clients of the same hub.
func TestPushReachesBothTransports(t *testing.T) {
h, addr := tcpHub(t, nil)
url := wsHub(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
conn := dialWS(t, ctx, url)
c := dialTCP(t, addr)
c.read() // welcome
waitCount(t, h, 2)
h.Push("item.created", map[string]string{"id": "x"})
var e Envelope
if err := wsjson.Read(ctx, conn, &e); err != nil || e.Type != "item.created" {
t.Fatalf("websocket: want item.created, got %+v, %v", e, err)
}
if e := c.read(); e.Type != "item.created" {
t.Fatalf("tcp: want item.created, got %+v", e)
}
}
// A payload that can't be marshalled is not sent.
func TestPushBadPayload(t *testing.T) {
h, addr := tcpHub(t, nil)
c := dialTCP(t, addr)
c.read() // welcome
waitCount(t, h, 1)
h.Push("bad", func() {})
h.Push("good", nil)
if e := c.read(); e.Type != "good" || e.Payload != nil {
t.Fatalf("want good with no payload, got %+v", e)
}
}
// A binary frame closes the connection with 1003 (unsupported data).
func TestHTTPBinaryFrame(t *testing.T) {
h := NewHub(config.JSCServerWebSocket{WriteTimeout: time.Second}, discardLog)
url := wsHub(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
conn := dialWS(t, ctx, url)
if err := conn.Write(ctx, ws.MessageBinary, []byte{1, 2, 3}); err != nil {
t.Fatal(err)
}
_, _, err := conn.Read(ctx)
if ws.CloseStatus(err) != ws.StatusUnsupportedData {
t.Fatalf("read after binary frame = %v; want close 1003", err)
}
}
// Messages over the message rate limit get a "rate limited" error and are not
// handled; the connection stays open.
func TestHTTPMessageRateLimit(t *testing.T) {
conf := config.JSCServerWebSocket{
WriteTimeout: time.Second,
MessageRateLimit: config.JSCRateLimitRule{Enabled: true, PerSecond: 1, Burst: 1},
}
h := NewHub(conf, discardLog)
url := wsHub(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
conn := dialWS(t, ctx, url)
for _, id := range []string{"1", "2"} {
if err := wsjson.Write(ctx, conn, Envelope{Type: TypePing, ID: id}); err != nil {
t.Fatal(err)
}
}
var e Envelope
if err := wsjson.Read(ctx, conn, &e); err != nil || e.Type != TypePong || e.ID != "1" {
t.Fatalf("first: want pong 1, got %+v, %v", e, err)
}
err := wsjson.Read(ctx, conn, &e)
if err != nil || e.Type != TypeError || e.Error != "rate limited" {
t.Fatalf("second: want rate limited, got %+v, %v", e, err)
}
if h.Count() != 1 {
t.Errorf("Count = %d; want the client kept", h.Count())
}
}
// A request that isn't a WebSocket upgrade is refused by the upgrade and no
// client is registered.
func TestHTTPNotAnUpgrade(t *testing.T) {
h := NewHub(config.JSCServerWebSocket{}, discardLog)
srv := httptest.NewServer(h)
defer srv.Close()
res, err := http.Get(srv.URL)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode < 400 {
t.Errorf("status = %d; want an error", res.StatusCode)
}
if h.Count() != 0 {
t.Errorf("Count = %d; want 0", h.Count())
}
}
// After Shutdown, an upgrade succeeds but the client is closed at once with
// 1001 (going away).
func TestHTTPRefusedAfterShutdown(t *testing.T) {
h := NewHub(config.JSCServerWebSocket{}, discardLog)
url := wsHub(t, h)
if err := h.Shutdown(context.Background()); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
conn, _, err := ws.Dial(ctx, url, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.CloseNow()
if _, _, err := conn.Read(ctx); ws.CloseStatus(err) != ws.StatusGoingAway {
t.Fatalf("read = %v; want close 1001", err)
}
}