package websocket import ( "context" "net/http" "net/http/httptest" "strings" "testing" "time" ws "github.com/coder/websocket" "github.com/coder/websocket/wsjson" "git.avsdev.uk/Go/job-search/internal/config" ) // 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, }, WithLogger(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, }, WithLogger(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, WithLogger(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{}, WithLogger(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{}, WithLogger(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) } }