The serve command runs internal/server: the API under /api (health, and the CSRF token endpoint) and the WebSocket hub under /ws, over HTTP or TLS, with an optional HTTP to HTTPS redirect, the PROXY protocol from trusted sources and a graceful shutdown that closes WebSockets first. Its middleware covers the client IP behind trusted proxies, request IDs, request logging, panic recovery, global and API rate limits, Host and body size checks, security response headers, compression, CSRF protection (cross-origin checks and signed double-submit tokens) and JSON-only API bodies. Middleware that logs takes a WithLogger option. New server config sections: proxy, csrf, tls, headers and frontend (not used yet), plus max_header_bytes, max_body_bytes, global and API rate limits and HTTP read, write and idle timeouts. Every setting documents what 0 or empty means, and internal/config/README.md is reorganised by section. server.timeouts.read_header must be greater than 0, and a header or idle timeout of 0 means no limit rather than the read timeout. WebSocket upgrades are protected by the Origin check: the server's own origin, or one of csrf.cross_origin_protection.trusted_origins, which the hub now takes with WithTrustedOrigins. wsserve becomes "ws serve". "ws dial" opens a WebSocket on /ws to test it: --ping sends a ping and waits for the pong, --message sends messages and prints the replies, --origin tests the Origin check, and with neither --ping nor --message each line of stdin is sent until Ctrl+C.
340 lines
7.9 KiB
Go
340 lines
7.9 KiB
Go
package ws
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
coder "github.com/coder/websocket"
|
|
"github.com/spf13/cobra"
|
|
|
|
"git.avsdev.uk/Go/job-search/internal/cli"
|
|
"git.avsdev.uk/Go/job-search/internal/cli/cmdtest"
|
|
"git.avsdev.uk/Go/job-search/internal/config"
|
|
"git.avsdev.uk/Go/job-search/internal/websocket"
|
|
)
|
|
|
|
func newRoot() *cobra.Command {
|
|
opts := &cli.Options{}
|
|
return cmdtest.NewRoot(opts, NewCommand(opts))
|
|
}
|
|
|
|
// wsServer serves a hub that trusts https://admin.example.com on a test HTTP
|
|
// server, and returns the hub and its ws:// URL.
|
|
func wsServer(t *testing.T) (*websocket.Hub, string) {
|
|
t.Helper()
|
|
hub := websocket.NewHub(
|
|
config.JSCServerWebSocket{
|
|
WriteTimeout: time.Second,
|
|
},
|
|
websocket.WithLogger(slog.New(slog.DiscardHandler)),
|
|
websocket.WithTrustedOrigins([]string{"https://admin.example.com"}),
|
|
)
|
|
server := httptest.NewServer(hub)
|
|
t.Cleanup(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
hub.Shutdown(ctx)
|
|
server.Close()
|
|
})
|
|
|
|
return hub, "ws" + strings.TrimPrefix(server.URL, "http")
|
|
}
|
|
|
|
// ping is a message the server answers with a pong.
|
|
const ping = `{"type":"ping","id":"1"}`
|
|
|
|
// With --message, dial prints the welcome, sends the message and prints the
|
|
// reply.
|
|
func TestDialPing(t *testing.T) {
|
|
cmdtest.Setup(t, "")
|
|
_, url := wsServer(t)
|
|
|
|
out := cmdtest.MustRun(
|
|
t, newRoot, "ws", "dial", "--url", url, "--message", ping,
|
|
)
|
|
cmdtest.Contains(
|
|
t,
|
|
out,
|
|
"connected (101 Switching Protocols)",
|
|
`< {"type":"welcome"`,
|
|
`> {"type":"ping","id":"1"}`,
|
|
`< {"type":"pong","id":"1"`,
|
|
)
|
|
}
|
|
|
|
// --ping sends a ping and waits for its pong, then closes the connection.
|
|
func TestDialPingFlag(t *testing.T) {
|
|
cmdtest.Setup(t, "")
|
|
_, url := wsServer(t)
|
|
|
|
out := cmdtest.MustRun(t, newRoot, "ws", "dial", "--url", url, "--ping")
|
|
cmdtest.Contains(
|
|
t,
|
|
out,
|
|
`> {"type":"ping","id":"ping"}`,
|
|
`< {"type":"pong","id":"ping"`,
|
|
)
|
|
}
|
|
|
|
// --ping fails if no pong arrives within --timeout.
|
|
func TestDialPingTimeout(t *testing.T) {
|
|
cmdtest.Setup(t, "")
|
|
// A server that sends a welcome, then never replies.
|
|
server := httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := coder.Accept(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.CloseNow()
|
|
conn.Write(r.Context(), coder.MessageText, []byte(`{"type":"welcome"}`))
|
|
for {
|
|
if _, _, err := conn.Read(r.Context()); err != nil {
|
|
return
|
|
}
|
|
}
|
|
},
|
|
))
|
|
t.Cleanup(server.Close)
|
|
url := "ws" + strings.TrimPrefix(server.URL, "http")
|
|
|
|
_, err := cmdtest.Run(
|
|
t,
|
|
newRoot,
|
|
"",
|
|
"ws", "dial", "--url", url, "--ping", "--timeout", "100ms",
|
|
)
|
|
cmdtest.ErrContains(t, err, "no pong within 100ms")
|
|
}
|
|
|
|
// Each --message is sent in turn, and the reply to each printed.
|
|
func TestDialMessages(t *testing.T) {
|
|
cmdtest.Setup(t, "")
|
|
_, url := wsServer(t)
|
|
|
|
out := cmdtest.MustRun(
|
|
t,
|
|
newRoot,
|
|
"ws", "dial", "--url", url,
|
|
"--message", `{"type":"echo","id":"7","payload":{"a":1}}`,
|
|
"--message", `{"type":"nonsense","id":"8"}`,
|
|
)
|
|
cmdtest.Contains(
|
|
t,
|
|
out,
|
|
`< {"type":"echo","id":"7","payload":{"a":1}}`,
|
|
`< {"type":"error","id":"8"`,
|
|
)
|
|
}
|
|
|
|
// An upgrade from a foreign origin is refused; a trusted one is accepted.
|
|
func TestDialOrigin(t *testing.T) {
|
|
cmdtest.Setup(t, "")
|
|
_, url := wsServer(t)
|
|
|
|
_, err := cmdtest.Run(
|
|
t,
|
|
newRoot,
|
|
"",
|
|
"ws", "dial", "--url", url, "--message", ping,
|
|
"--origin", "https://evil.example",
|
|
)
|
|
cmdtest.ErrContains(t, err, "upgrade refused: 403 Forbidden")
|
|
|
|
out := cmdtest.MustRun(
|
|
t,
|
|
newRoot,
|
|
"ws", "dial", "--url", url, "--message", ping,
|
|
"--origin", "https://admin.example.com",
|
|
)
|
|
cmdtest.Contains(t, out, "connected (101 Switching Protocols)")
|
|
}
|
|
|
|
// With --listen, pushed messages are printed until the server closes the
|
|
// connection.
|
|
func TestDialListen(t *testing.T) {
|
|
cmdtest.Setup(t, "")
|
|
hub, url := wsServer(t)
|
|
|
|
go func() {
|
|
// Wait for the client to connect and get its pong.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for hub.Count() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
hub.Push("event", map[string]any{"n": 1})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
hub.Shutdown(ctx)
|
|
}()
|
|
|
|
out := cmdtest.MustRun(
|
|
t, newRoot, "ws", "dial", "--url", url, "--message", ping, "--listen",
|
|
)
|
|
cmdtest.Contains(
|
|
t,
|
|
out,
|
|
`< {"type":"event","payload":{"n":1}}`,
|
|
"closed by the server (1001)",
|
|
)
|
|
}
|
|
|
|
// syncBuffer is a bytes.Buffer safe to write from one goroutine while
|
|
// another reads it.
|
|
type syncBuffer struct {
|
|
mu sync.Mutex
|
|
buffer bytes.Buffer
|
|
}
|
|
|
|
func (b *syncBuffer) Write(data []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buffer.Write(data)
|
|
}
|
|
|
|
func (b *syncBuffer) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buffer.String()
|
|
}
|
|
|
|
// waitFor fails the test unless out comes to contain every string in want
|
|
// within a second.
|
|
func waitFor(t *testing.T, out *syncBuffer, want ...string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(time.Second)
|
|
for {
|
|
missing := ""
|
|
for _, wanted := range want {
|
|
if !strings.Contains(out.String(), wanted) {
|
|
missing = wanted
|
|
break
|
|
}
|
|
}
|
|
if missing == "" {
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("output does not contain %q:\n%s", missing, out)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// Without --message, each line of stdin is sent, skipping blank ones. The end
|
|
// of stdin doesn't end dial: pushes are still printed until Ctrl+C.
|
|
func TestDialStdin(t *testing.T) {
|
|
hub, url := wsServer(t)
|
|
stdin, input := io.Pipe()
|
|
out := &syncBuffer{}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- dial(ctx, stdin, out, dialSettings{
|
|
url: url,
|
|
timeout: time.Second,
|
|
})
|
|
}()
|
|
|
|
io.WriteString(input, ping+"\n\n"+`{"type":"echo","id":"2"}`+"\n")
|
|
input.Close()
|
|
waitFor(
|
|
t,
|
|
out,
|
|
`< {"type":"welcome"`,
|
|
`< {"type":"pong","id":"1"`,
|
|
`< {"type":"echo","id":"2"}`,
|
|
)
|
|
if strings.Contains(out.String(), `"type":"error"`) {
|
|
t.Errorf("a blank line was sent:\n%s", out)
|
|
}
|
|
|
|
hub.Push("event", map[string]any{"n": 1})
|
|
waitFor(t, out, `< {"type":"event","payload":{"n":1}}`)
|
|
|
|
cancel() // Ctrl+C
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("dial = %v; want nil after Ctrl+C", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("dial did not return after Ctrl+C")
|
|
}
|
|
}
|
|
|
|
// Without --message, dial ends when the server closes the connection.
|
|
func TestDialStdinServerClose(t *testing.T) {
|
|
hub, url := wsServer(t)
|
|
stdin, _ := io.Pipe() // never written: stdin stays open
|
|
out := &syncBuffer{}
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- dial(context.Background(), stdin, out, dialSettings{
|
|
url: url,
|
|
timeout: time.Second,
|
|
})
|
|
}()
|
|
|
|
waitFor(t, out, `< {"type":"welcome"`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
hub.Shutdown(ctx)
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("dial = %v; want nil when the server closes", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("dial did not return when the server closed")
|
|
}
|
|
waitFor(t, out, "closed by the server (1001)")
|
|
}
|
|
|
|
// Without --url, dial uses the configured host and port.
|
|
func TestDefaultURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
conf config.JSCServer
|
|
want string
|
|
}{
|
|
{"configured", config.JSCServer{
|
|
Host: "10.0.0.1",
|
|
Port: 8080,
|
|
}, "ws://10.0.0.1:8080/ws"},
|
|
{"every interface", config.JSCServer{
|
|
Port: 9393,
|
|
}, "ws://127.0.0.1:9393/ws"},
|
|
{"IPv6", config.JSCServer{
|
|
Host: "::1",
|
|
Port: 9393,
|
|
}, "ws://[::1]:9393/ws"},
|
|
{"TLS", config.JSCServer{
|
|
Host: "h",
|
|
Port: 443,
|
|
TLS: config.JSCServerTLS{
|
|
Enabled: true,
|
|
},
|
|
}, "wss://h:443/ws"},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := defaultURL(tt.conf); got != tt.want {
|
|
t.Errorf("%s: defaultURL = %q; want %q", tt.name, got, tt.want)
|
|
}
|
|
}
|
|
}
|