Files
job-search/internal/cli/ws/dial_test.go
T
bwilliams a279405cae Added the HTTP server and serve command, with proxy, CSRF, TLS and security header config; wsserve is now ws serve, alongside a new ws dial
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.
2026-10-15 17:47:40 +01:00

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)
}
}
}