Added --http-upgrade and --direct to ws-test dial, so it can connect to job-search serve or ws-test serve
One of the two is required. --http-upgrade upgrades an HTTP connection to a WebSocket on /ws, as before, for job-search serve; --url and --origin go with it. --direct connects straight to ws-test serve over plain TCP, one JSON message per line, at --addr (default from server.host and server.port). --ping, --message, --listen and stdin work the same in both modes. The README's ws-test section explains the two modes.
This commit is contained in:
1 parent
88f879d844
commit
f0c171c8a8
5 files changed
+506
-60
No files matched your search
@@ -110,13 +110,22 @@ See [internal/mock](internal/mock/README.md) for its options and behaviour.
|
|||||||
## WebSocket test tool
|
## WebSocket test tool
|
||||||
`ws-test` reads the same configuration as `job-search` for its defaults:
|
`ws-test` reads the same configuration as `job-search` for its defaults:
|
||||||
```bash
|
```bash
|
||||||
go run ./cmd/ws-test dial --ping # ping the HTTP server's /ws endpoint
|
go run ./cmd/ws-test dial --http-upgrade --ping # ping job-search serve's /ws endpoint
|
||||||
go run ./cmd/ws-test dial # send each line of stdin, print replies
|
go run ./cmd/ws-test dial --direct --ping # ping ws-test serve
|
||||||
go run ./cmd/ws-test serve # plain TCP server for websocket hub clients
|
go run ./cmd/ws-test dial --http-upgrade # send each line of stdin, print replies
|
||||||
|
go run ./cmd/ws-test serve # plain TCP server for websocket hub clients
|
||||||
```
|
```
|
||||||
|
|
||||||
`dial` also takes `--message`, `--listen`, `--origin` (to test the Origin
|
`dial` needs one of `--http-upgrade`, to upgrade an HTTP connection to a
|
||||||
check), `--url` and `--timeout`; see `ws-test dial --help`.
|
WebSocket on `/ws` as `job-search serve` expects, or `--direct`, to connect
|
||||||
|
straight to `ws-test serve` over plain TCP with one JSON message per line.
|
||||||
|
It also takes `--message`, `--listen` and `--timeout`; `--url` and `--origin`
|
||||||
|
(to test the Origin check) with `--http-upgrade`, and `--addr` with
|
||||||
|
`--direct`. See `ws-test dial --help`.
|
||||||
|
|
||||||
|
`ws-test serve` is a test server only: don't run it alongside
|
||||||
|
`job-search serve`. Both listen on `server.host` and `server.port`, so the
|
||||||
|
second one fails with "address already in use".
|
||||||
|
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|||||||
+142
-49
@@ -30,9 +30,13 @@ import (
|
|||||||
// carries back.
|
// carries back.
|
||||||
const pingID = "ping"
|
const pingID = "ping"
|
||||||
|
|
||||||
// dialSettings are the dial command's flag values.
|
// dialSettings are the dial command's flag values. direct chooses a plain
|
||||||
|
// TCP connection to addr (--direct) over an HTTP upgrade at url
|
||||||
|
// (--http-upgrade).
|
||||||
type dialSettings struct {
|
type dialSettings struct {
|
||||||
|
direct bool
|
||||||
url string
|
url string
|
||||||
|
addr string
|
||||||
origin string
|
origin string
|
||||||
messages []string
|
messages []string
|
||||||
ping bool
|
ping bool
|
||||||
@@ -40,14 +44,33 @@ type dialSettings struct {
|
|||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// commandDial returns the "dial" command, which opens a WebSocket on the
|
// connection is what dial talks to: a *websocket.Conn with --http-upgrade,
|
||||||
// HTTP server's /ws path, sends messages and prints the replies.
|
// or a *directConn with --direct.
|
||||||
|
type connection interface {
|
||||||
|
Read(ctx context.Context) (websocket.MessageType, []byte, error)
|
||||||
|
Write(
|
||||||
|
ctx context.Context,
|
||||||
|
messageType websocket.MessageType,
|
||||||
|
payload []byte,
|
||||||
|
) error
|
||||||
|
Close(code websocket.StatusCode, reason string) error
|
||||||
|
CloseNow() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// commandDial returns the "dial" command, which connects to the WebSocket
|
||||||
|
// hub of job-search serve (--http-upgrade) or ws-test serve (--direct),
|
||||||
|
// sends messages and prints the replies.
|
||||||
func commandDial(opts *cli.Options) *cobra.Command {
|
func commandDial(opts *cli.Options) *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "dial",
|
Use: "dial",
|
||||||
Short: "Open a WebSocket on /ws, send messages and print the replies",
|
Short: "Connect to a WebSocket hub, send messages and print the replies",
|
||||||
Long: `Upgrade an HTTP connection to a WebSocket on the server's /ws path and print
|
Long: `Connect to a server's WebSocket hub and print every message the server sends,
|
||||||
every message the server sends, starting with its welcome.
|
starting with its welcome. One of these is required:
|
||||||
|
|
||||||
|
--http-upgrade upgrade an HTTP connection to a WebSocket on the /ws path,
|
||||||
|
as job-search serve expects
|
||||||
|
--direct connect straight to ws-test serve over plain TCP, with one
|
||||||
|
JSON message per line in each direction
|
||||||
|
|
||||||
With --ping, it sends a ping and waits for the pong, failing if none arrives
|
With --ping, it sends a ping and waits for the pong, failing if none arrives
|
||||||
within --timeout.
|
within --timeout.
|
||||||
@@ -62,10 +85,13 @@ With neither, each line read from stdin is sent as a message (blank lines are
|
|||||||
skipped), until Ctrl+C is pressed or the server closes the connection. The end
|
skipped), until Ctrl+C is pressed or the server closes the connection. The end
|
||||||
of stdin stops the sending, but the connection stays open.
|
of stdin stops the sending, but the connection stays open.
|
||||||
|
|
||||||
The URL defaults to the configured server.host and server.port (wss:// if
|
With --http-upgrade, the URL defaults to the configured server.host and
|
||||||
server.tls.enabled is set). Set --origin to test the Origin check: an upgrade
|
server.port (wss:// if server.tls.enabled is set). Set --origin to test the
|
||||||
from an origin that isn't the server's own or one of
|
Origin check: an upgrade from an origin that isn't the server's own or one of
|
||||||
server.csrf.cross_origin_protection.trusted_origins is refused with 403.`,
|
server.csrf.cross_origin_protection.trusted_origins is refused with 403.
|
||||||
|
|
||||||
|
With --direct, --addr defaults to the configured server.host and server.port.
|
||||||
|
There is no HTTP request, so --url and --origin don't apply.`,
|
||||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||||
slog.Debug("command persistent pre-run", "command", "dial")
|
slog.Debug("command persistent pre-run", "command", "dial")
|
||||||
},
|
},
|
||||||
@@ -81,12 +107,17 @@ server.csrf.cross_origin_protection.trusted_origins is refused with 403.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if settings.url == "" {
|
// Only one of url and addr can be set, for the chosen mode.
|
||||||
|
if settings.url == "" && settings.addr == "" {
|
||||||
conf, err := opts.RunSetup(cmd, true, true)
|
conf, err := opts.RunSetup(cmd, true, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load configuration: %w", err)
|
return fmt.Errorf("load configuration: %w", err)
|
||||||
}
|
}
|
||||||
settings.url = defaultURL(conf.Server)
|
if settings.direct {
|
||||||
|
settings.addr = defaultAddr(conf.Server)
|
||||||
|
} else {
|
||||||
|
settings.url = defaultURL(conf.Server)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ctx is cancelled on the first SIGINT (Ctrl+C) or SIGTERM.
|
// ctx is cancelled on the first SIGINT (Ctrl+C) or SIGTERM.
|
||||||
@@ -100,15 +131,34 @@ server.csrf.cross_origin_protection.trusted_origins is refused with 403.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
flags := cmd.Flags()
|
flags := cmd.Flags()
|
||||||
|
flags.Bool(
|
||||||
|
"http-upgrade",
|
||||||
|
false,
|
||||||
|
"upgrade an HTTP connection to a WebSocket on /ws (job-search serve)",
|
||||||
|
)
|
||||||
|
flags.Bool(
|
||||||
|
"direct",
|
||||||
|
false,
|
||||||
|
"connect straight over plain TCP, one JSON message per line "+
|
||||||
|
"(ws-test serve)",
|
||||||
|
)
|
||||||
flags.String(
|
flags.String(
|
||||||
"url",
|
"url",
|
||||||
"",
|
"",
|
||||||
"WebSocket URL (default from config, ws://127.0.0.1:9393/ws)",
|
"with --http-upgrade, the WebSocket URL "+
|
||||||
|
"(default from config, ws://127.0.0.1:9393/ws)",
|
||||||
|
)
|
||||||
|
flags.String(
|
||||||
|
"addr",
|
||||||
|
"",
|
||||||
|
"with --direct, the address to connect to "+
|
||||||
|
"(default from config, 127.0.0.1:9393)",
|
||||||
)
|
)
|
||||||
flags.String(
|
flags.String(
|
||||||
"origin",
|
"origin",
|
||||||
"",
|
"",
|
||||||
"Origin header to send, as a browser would (default none)",
|
"with --http-upgrade, the Origin header to send, as a browser would "+
|
||||||
|
"(default none)",
|
||||||
)
|
)
|
||||||
flags.StringArray(
|
flags.StringArray(
|
||||||
"message",
|
"message",
|
||||||
@@ -129,8 +179,13 @@ server.csrf.cross_origin_protection.trusted_origins is refused with 403.`,
|
|||||||
flags.Duration(
|
flags.Duration(
|
||||||
"timeout",
|
"timeout",
|
||||||
10*time.Second,
|
10*time.Second,
|
||||||
"how long to wait for the upgrade and for each reply; 0s for no limit",
|
"how long to wait to connect and for each reply; 0s for no limit",
|
||||||
)
|
)
|
||||||
|
cmd.MarkFlagsOneRequired("http-upgrade", "direct")
|
||||||
|
cmd.MarkFlagsMutuallyExclusive("http-upgrade", "direct")
|
||||||
|
cmd.MarkFlagsMutuallyExclusive("direct", "url")
|
||||||
|
cmd.MarkFlagsMutuallyExclusive("direct", "origin")
|
||||||
|
cmd.MarkFlagsMutuallyExclusive("http-upgrade", "addr")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -139,7 +194,9 @@ server.csrf.cross_origin_protection.trusted_origins is refused with 403.`,
|
|||||||
// commandDial, so getting them can't fail.
|
// commandDial, so getting them can't fail.
|
||||||
func dialFlags(cmd *cobra.Command) (dialSettings, error) {
|
func dialFlags(cmd *cobra.Command) (dialSettings, error) {
|
||||||
flags := cmd.Flags()
|
flags := cmd.Flags()
|
||||||
|
direct, directErr := flags.GetBool("direct")
|
||||||
url, urlErr := flags.GetString("url")
|
url, urlErr := flags.GetString("url")
|
||||||
|
addr, addrErr := flags.GetString("addr")
|
||||||
origin, originErr := flags.GetString("origin")
|
origin, originErr := flags.GetString("origin")
|
||||||
messages, messagesErr := flags.GetStringArray("message")
|
messages, messagesErr := flags.GetStringArray("message")
|
||||||
ping, pingErr := flags.GetBool("ping")
|
ping, pingErr := flags.GetBool("ping")
|
||||||
@@ -147,7 +204,9 @@ func dialFlags(cmd *cobra.Command) (dialSettings, error) {
|
|||||||
timeout, timeoutErr := flags.GetDuration("timeout")
|
timeout, timeoutErr := flags.GetDuration("timeout")
|
||||||
|
|
||||||
err := errors.Join(
|
err := errors.Join(
|
||||||
|
directErr,
|
||||||
urlErr,
|
urlErr,
|
||||||
|
addrErr,
|
||||||
originErr,
|
originErr,
|
||||||
messagesErr,
|
messagesErr,
|
||||||
pingErr,
|
pingErr,
|
||||||
@@ -159,7 +218,9 @@ func dialFlags(cmd *cobra.Command) (dialSettings, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return dialSettings{
|
return dialSettings{
|
||||||
|
direct: direct,
|
||||||
url: url,
|
url: url,
|
||||||
|
addr: addr,
|
||||||
origin: origin,
|
origin: origin,
|
||||||
messages: messages,
|
messages: messages,
|
||||||
ping: ping,
|
ping: ping,
|
||||||
@@ -169,59 +230,46 @@ func dialFlags(cmd *cobra.Command) (dialSettings, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// defaultURL is the /ws URL of the server conf configures: ws://, or wss://
|
// defaultURL is the /ws URL of the server conf configures: ws://, or wss://
|
||||||
// with TLS, on its host and port. An empty host (every interface) is dialled
|
// with TLS, at defaultAddr.
|
||||||
// as 127.0.0.1.
|
|
||||||
func defaultURL(conf config.JSCServer) string {
|
func defaultURL(conf config.JSCServer) string {
|
||||||
scheme := "ws"
|
scheme := "ws"
|
||||||
if conf.TLS.Enabled {
|
if conf.TLS.Enabled {
|
||||||
scheme = "wss"
|
scheme = "wss"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return scheme + "://" + defaultAddr(conf) + "/ws"
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultAddr is the host:port of the server conf configures. An empty host
|
||||||
|
// (every interface) is dialled as 127.0.0.1.
|
||||||
|
func defaultAddr(conf config.JSCServer) string {
|
||||||
host := conf.Host
|
host := conf.Host
|
||||||
if host == "" {
|
if host == "" {
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
address := net.JoinHostPort(host, strconv.Itoa(conf.Port))
|
return net.JoinHostPort(host, strconv.Itoa(conf.Port))
|
||||||
|
|
||||||
return scheme + "://" + address + "/ws"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// dial opens a WebSocket at settings.url and prints the messages received
|
// dial connects as settings choose (see connect) and prints the messages
|
||||||
// to out, each after "<". It sends each of settings.messages, printed after
|
// received to out, each after "<". It sends each of settings.messages,
|
||||||
// ">", or without them each line of input (see interact), which isn't
|
// printed after ">", or without them each line of input (see interact),
|
||||||
// printed again.
|
// which isn't printed again.
|
||||||
func dial(
|
func dial(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
input io.Reader,
|
input io.Reader,
|
||||||
out io.Writer,
|
out io.Writer,
|
||||||
settings dialSettings,
|
settings dialSettings,
|
||||||
) error {
|
) error {
|
||||||
header := http.Header{}
|
connectCtx, cancel := util.WithTimeout(ctx, settings.timeout)
|
||||||
if settings.origin != "" {
|
|
||||||
header.Set("Origin", settings.origin)
|
|
||||||
}
|
|
||||||
|
|
||||||
dialCtx, cancel := util.WithTimeout(ctx, settings.timeout)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
conn, res, err := websocket.Dial(
|
conn, err := connect(connectCtx, out, settings)
|
||||||
dialCtx,
|
|
||||||
settings.url,
|
|
||||||
&websocket.DialOptions{
|
|
||||||
HTTPHeader: header,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if res != nil {
|
return err
|
||||||
return fmt.Errorf("upgrade refused: %s", res.Status)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("dialling %s: %w", settings.url, err)
|
|
||||||
}
|
}
|
||||||
defer conn.CloseNow() //nolint:errcheck // finished with; it may already be closed
|
defer conn.CloseNow() //nolint:errcheck // finished with; it may already be closed
|
||||||
|
|
||||||
fmt.Fprintf(out, "connected (%s)\n", res.Status) //nolint:errcheck // printing only
|
|
||||||
|
|
||||||
if !settings.ping && len(settings.messages) == 0 {
|
if !settings.ping && len(settings.messages) == 0 {
|
||||||
return interact(ctx, input, out, conn, settings.timeout)
|
return interact(ctx, input, out, conn, settings.timeout)
|
||||||
}
|
}
|
||||||
@@ -260,12 +308,53 @@ func dial(
|
|||||||
return conn.Close(websocket.StatusNormalClosure, "")
|
return conn.Close(websocket.StatusNormalClosure, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// connect opens a WebSocket at settings.url, or with settings.direct a
|
||||||
|
// plain TCP connection to settings.addr, giving up when ctx is done. It
|
||||||
|
// prints how it connected to out.
|
||||||
|
func connect(
|
||||||
|
ctx context.Context,
|
||||||
|
out io.Writer,
|
||||||
|
settings dialSettings,
|
||||||
|
) (connection, error) {
|
||||||
|
if settings.direct {
|
||||||
|
conn, err := dialDirect(ctx, settings.addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connecting to %s: %w", settings.addr, err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "connected directly to %s\n", settings.addr) //nolint:errcheck // printing only
|
||||||
|
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
header := http.Header{}
|
||||||
|
if settings.origin != "" {
|
||||||
|
header.Set("Origin", settings.origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, res, err := websocket.Dial(
|
||||||
|
ctx,
|
||||||
|
settings.url,
|
||||||
|
&websocket.DialOptions{
|
||||||
|
HTTPHeader: header,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if res != nil {
|
||||||
|
return nil, fmt.Errorf("upgrade refused: %s", res.Status)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("dialling %s: %w", settings.url, err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "connected (%s)\n", res.Status) //nolint:errcheck // printing only
|
||||||
|
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
// pingPong sends a ping on conn and waits at most timeout for its pong,
|
// pingPong sends a ping on conn and waits at most timeout for its pong,
|
||||||
// printing each message received meanwhile to out.
|
// printing each message received meanwhile to out.
|
||||||
func pingPong(
|
func pingPong(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
out io.Writer,
|
out io.Writer,
|
||||||
conn *websocket.Conn,
|
conn connection,
|
||||||
timeout time.Duration,
|
timeout time.Duration,
|
||||||
) error {
|
) error {
|
||||||
ping, err := json.Marshal(hub.Envelope{
|
ping, err := json.Marshal(hub.Envelope{
|
||||||
@@ -312,7 +401,7 @@ func interact(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
input io.Reader,
|
input io.Reader,
|
||||||
out io.Writer,
|
out io.Writer,
|
||||||
conn *websocket.Conn,
|
conn connection,
|
||||||
timeout time.Duration,
|
timeout time.Duration,
|
||||||
) error {
|
) error {
|
||||||
listening := make(chan error, 1)
|
listening := make(chan error, 1)
|
||||||
@@ -366,7 +455,7 @@ func interact(
|
|||||||
// send writes message to conn as a text frame, waiting at most timeout.
|
// send writes message to conn as a text frame, waiting at most timeout.
|
||||||
func send(
|
func send(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn *websocket.Conn,
|
conn connection,
|
||||||
message string,
|
message string,
|
||||||
timeout time.Duration,
|
timeout time.Duration,
|
||||||
) error {
|
) error {
|
||||||
@@ -386,7 +475,7 @@ func send(
|
|||||||
func receive(
|
func receive(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
out io.Writer,
|
out io.Writer,
|
||||||
conn *websocket.Conn,
|
conn connection,
|
||||||
timeout time.Duration,
|
timeout time.Duration,
|
||||||
) error {
|
) error {
|
||||||
readCtx, cancel := util.WithTimeout(ctx, timeout)
|
readCtx, cancel := util.WithTimeout(ctx, timeout)
|
||||||
@@ -404,7 +493,7 @@ func receive(
|
|||||||
// listen prints each message received on conn to out until ctx is cancelled
|
// listen prints each message received on conn to out until ctx is cancelled
|
||||||
// (Ctrl+C) or the server closes the connection, either of which is a normal
|
// (Ctrl+C) or the server closes the connection, either of which is a normal
|
||||||
// end.
|
// end.
|
||||||
func listen(ctx context.Context, out io.Writer, conn *websocket.Conn) error {
|
func listen(ctx context.Context, out io.Writer, conn connection) error {
|
||||||
for {
|
for {
|
||||||
_, data, err := conn.Read(ctx)
|
_, data, err := conn.Read(ctx)
|
||||||
status := websocket.CloseStatus(err)
|
status := websocket.CloseStatus(err)
|
||||||
@@ -412,8 +501,12 @@ func listen(ctx context.Context, out io.Writer, conn *websocket.Conn) error {
|
|||||||
case err == nil:
|
case err == nil:
|
||||||
fmt.Fprintf(out, "< %s\n", data) //nolint:errcheck // printing only
|
fmt.Fprintf(out, "< %s\n", data) //nolint:errcheck // printing only
|
||||||
case ctx.Err() != nil:
|
case ctx.Err() != nil:
|
||||||
// Ctrl+C: Read has closed the connection already.
|
// Ctrl+C: dial closes the connection.
|
||||||
return nil //nolint:nilerr // a normal end
|
return nil //nolint:nilerr // a normal end
|
||||||
|
case errors.Is(err, io.EOF):
|
||||||
|
// A direct connection has no close status.
|
||||||
|
fmt.Fprintln(out, "closed by the server") //nolint:errcheck // printing only
|
||||||
|
return nil
|
||||||
case status != -1:
|
case status != -1:
|
||||||
fmt.Fprintf(out, "closed by the server (%d)\n", status) //nolint:errcheck // printing only
|
fmt.Fprintf(out, "closed by the server (%d)\n", status) //nolint:errcheck // printing only
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestDialPing(t *testing.T) {
|
|||||||
_, url := wsServer(t)
|
_, url := wsServer(t)
|
||||||
|
|
||||||
out := cmdtest.MustRun(
|
out := cmdtest.MustRun(
|
||||||
t, newRoot, "dial", "--url", url, "--message", ping,
|
t, newRoot, "dial", "--http-upgrade", "--url", url, "--message", ping,
|
||||||
)
|
)
|
||||||
cmdtest.Contains(
|
cmdtest.Contains(
|
||||||
t,
|
t,
|
||||||
@@ -76,7 +76,7 @@ func TestDialPingFlag(t *testing.T) {
|
|||||||
cmdtest.Setup(t, "")
|
cmdtest.Setup(t, "")
|
||||||
_, url := wsServer(t)
|
_, url := wsServer(t)
|
||||||
|
|
||||||
out := cmdtest.MustRun(t, newRoot, "dial", "--url", url, "--ping")
|
out := cmdtest.MustRun(t, newRoot, "dial", "--http-upgrade", "--url", url, "--ping")
|
||||||
cmdtest.Contains(
|
cmdtest.Contains(
|
||||||
t,
|
t,
|
||||||
out,
|
out,
|
||||||
@@ -111,7 +111,7 @@ func TestDialPingTimeout(t *testing.T) {
|
|||||||
t,
|
t,
|
||||||
newRoot,
|
newRoot,
|
||||||
"",
|
"",
|
||||||
"dial", "--url", url, "--ping", "--timeout", "100ms",
|
"dial", "--http-upgrade", "--url", url, "--ping", "--timeout", "100ms",
|
||||||
)
|
)
|
||||||
cmdtest.ErrContains(t, err, "no pong within 100ms")
|
cmdtest.ErrContains(t, err, "no pong within 100ms")
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ func TestDialMessages(t *testing.T) {
|
|||||||
out := cmdtest.MustRun(
|
out := cmdtest.MustRun(
|
||||||
t,
|
t,
|
||||||
newRoot,
|
newRoot,
|
||||||
"dial", "--url", url,
|
"dial", "--http-upgrade", "--url", url,
|
||||||
"--message", `{"type":"echo","id":"7","payload":{"a":1}}`,
|
"--message", `{"type":"echo","id":"7","payload":{"a":1}}`,
|
||||||
"--message", `{"type":"nonsense","id":"8"}`,
|
"--message", `{"type":"nonsense","id":"8"}`,
|
||||||
)
|
)
|
||||||
@@ -145,7 +145,7 @@ func TestDialOrigin(t *testing.T) {
|
|||||||
t,
|
t,
|
||||||
newRoot,
|
newRoot,
|
||||||
"",
|
"",
|
||||||
"dial", "--url", url, "--message", ping,
|
"dial", "--http-upgrade", "--url", url, "--message", ping,
|
||||||
"--origin", "https://evil.example",
|
"--origin", "https://evil.example",
|
||||||
)
|
)
|
||||||
cmdtest.ErrContains(t, err, "upgrade refused: 403 Forbidden")
|
cmdtest.ErrContains(t, err, "upgrade refused: 403 Forbidden")
|
||||||
@@ -153,7 +153,7 @@ func TestDialOrigin(t *testing.T) {
|
|||||||
out := cmdtest.MustRun(
|
out := cmdtest.MustRun(
|
||||||
t,
|
t,
|
||||||
newRoot,
|
newRoot,
|
||||||
"dial", "--url", url, "--message", ping,
|
"dial", "--http-upgrade", "--url", url, "--message", ping,
|
||||||
"--origin", "https://admin.example.com",
|
"--origin", "https://admin.example.com",
|
||||||
)
|
)
|
||||||
cmdtest.Contains(t, out, "connected (101 Switching Protocols)")
|
cmdtest.Contains(t, out, "connected (101 Switching Protocols)")
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxDirectLineBytes is the longest line a directConn reads.
|
||||||
|
const maxDirectLineBytes = 1 << 20
|
||||||
|
|
||||||
|
// errBinaryMessage is returned for a binary message, which a directConn
|
||||||
|
// can't send.
|
||||||
|
var errBinaryMessage = errors.New("only text messages can be sent directly")
|
||||||
|
|
||||||
|
// directConn is a plain TCP connection to ws-test serve, without an HTTP
|
||||||
|
// upgrade: each message is one line of JSON in both directions (see
|
||||||
|
// websocket.Hub.ServeTCP). It has the methods dial uses of a
|
||||||
|
// *websocket.Conn.
|
||||||
|
type directConn struct {
|
||||||
|
conn net.Conn
|
||||||
|
scanner *bufio.Scanner
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialDirect connects to addr, giving up when ctx is done.
|
||||||
|
func dialDirect(ctx context.Context, addr string) (*directConn, error) {
|
||||||
|
var dialer net.Dialer
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(conn)
|
||||||
|
scanner.Buffer(nil, maxDirectLineBytes)
|
||||||
|
|
||||||
|
return &directConn{
|
||||||
|
conn: conn,
|
||||||
|
scanner: scanner,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read returns the next line as a text message, skipping the blank lines the
|
||||||
|
// server sends to check the connection. Cancelling ctx interrupts it. The
|
||||||
|
// server closing the connection is reported as io.EOF.
|
||||||
|
func (d *directConn) Read(ctx context.Context) (websocket.MessageType, []byte, error) {
|
||||||
|
stop := context.AfterFunc(ctx, func() {
|
||||||
|
d.conn.SetReadDeadline(time.Now()) //nolint:errcheck,gosec // only interrupts the read
|
||||||
|
})
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
for d.scanner.Scan() {
|
||||||
|
line := strings.TrimSuffix(d.scanner.Text(), "\r")
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return websocket.MessageText, []byte(line), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := d.scanner.Err()
|
||||||
|
switch {
|
||||||
|
case ctx.Err() != nil:
|
||||||
|
return 0, nil, ctx.Err()
|
||||||
|
case err == nil:
|
||||||
|
return 0, nil, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write sends payload as one line. Cancelling ctx interrupts it.
|
||||||
|
func (d *directConn) Write(
|
||||||
|
ctx context.Context,
|
||||||
|
messageType websocket.MessageType,
|
||||||
|
payload []byte,
|
||||||
|
) error {
|
||||||
|
if messageType != websocket.MessageText {
|
||||||
|
return errBinaryMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
stop := context.AfterFunc(ctx, func() {
|
||||||
|
d.conn.SetWriteDeadline(time.Now()) //nolint:errcheck,gosec // only interrupts the write
|
||||||
|
})
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
line := make([]byte, 0, len(payload)+1)
|
||||||
|
line = append(append(line, payload...), '\n')
|
||||||
|
_, err := d.conn.Write(line)
|
||||||
|
if err != nil && ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the connection. Plain TCP has no close handshake, so code and
|
||||||
|
// reason aren't sent.
|
||||||
|
func (d *directConn) Close(code websocket.StatusCode, reason string) error {
|
||||||
|
return d.CloseNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseNow closes the connection. Closing it again isn't an error.
|
||||||
|
func (d *directConn) CloseNow() error {
|
||||||
|
err := d.conn.Close()
|
||||||
|
if errors.Is(err, net.ErrClosed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
coder "github.com/coder/websocket"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// directServer serves a hub's plain TCP clients on a local port, as ws-test
|
||||||
|
// serve does, and returns the hub and its address.
|
||||||
|
func directServer(t *testing.T) (*websocket.Hub, string) {
|
||||||
|
t.Helper()
|
||||||
|
hub := websocket.NewHub(
|
||||||
|
config.JSCServerWebSocket{
|
||||||
|
WriteTimeout: time.Second,
|
||||||
|
},
|
||||||
|
websocket.WithLogger(slog.New(slog.DiscardHandler)),
|
||||||
|
)
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go hub.ServeTCP(ctx, conn)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
listener.Close()
|
||||||
|
shutdownCtx, cancelShutdown := context.WithTimeout(
|
||||||
|
context.Background(), time.Second,
|
||||||
|
)
|
||||||
|
defer cancelShutdown()
|
||||||
|
hub.Shutdown(shutdownCtx)
|
||||||
|
cancel()
|
||||||
|
})
|
||||||
|
|
||||||
|
return hub, listener.Addr().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// With --direct, --ping and --message work over plain TCP as they do over an
|
||||||
|
// HTTP upgrade.
|
||||||
|
func TestDialDirect(t *testing.T) {
|
||||||
|
cmdtest.Setup(t, "")
|
||||||
|
_, addr := directServer(t)
|
||||||
|
|
||||||
|
out := cmdtest.MustRun(
|
||||||
|
t,
|
||||||
|
newRoot,
|
||||||
|
"dial", "--direct", "--addr", addr, "--ping",
|
||||||
|
"--message", `{"type":"echo","id":"7","payload":{"a":1}}`,
|
||||||
|
)
|
||||||
|
cmdtest.Contains(
|
||||||
|
t,
|
||||||
|
out,
|
||||||
|
"connected directly to "+addr,
|
||||||
|
`< {"type":"welcome"`,
|
||||||
|
`> {"type":"ping","id":"ping"}`,
|
||||||
|
`< {"type":"pong","id":"ping"`,
|
||||||
|
`< {"type":"echo","id":"7","payload":{"a":1}}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With --direct and --listen, pushed messages are printed until the server
|
||||||
|
// closes the connection, which has no close status.
|
||||||
|
func TestDialDirectListen(t *testing.T) {
|
||||||
|
hub, addr := directServer(t)
|
||||||
|
out := &syncBuffer{}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- dial(context.Background(), nil, out, dialSettings{
|
||||||
|
direct: true,
|
||||||
|
addr: addr,
|
||||||
|
messages: []string{ping},
|
||||||
|
listen: true,
|
||||||
|
timeout: time.Second,
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
waitFor(t, out, `< {"type":"pong","id":"1"`)
|
||||||
|
hub.Push("event", map[string]any{"n": 1})
|
||||||
|
waitFor(t, out, `< {"type":"event","payload":{"n":1}}`)
|
||||||
|
|
||||||
|
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, `"error":"server shutting down"`, "closed by the server\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// With --direct and no messages, each line of stdin is sent.
|
||||||
|
func TestDialDirectStdin(t *testing.T) {
|
||||||
|
_, addr := directServer(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{
|
||||||
|
direct: true,
|
||||||
|
addr: addr,
|
||||||
|
timeout: time.Second,
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
io.WriteString(input, ping+"\n")
|
||||||
|
waitFor(t, out, `< {"type":"welcome"`, `< {"type":"pong","id":"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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --direct fails clearly when nothing is listening.
|
||||||
|
func TestDialDirectRefused(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := listener.Addr().String()
|
||||||
|
listener.Close()
|
||||||
|
|
||||||
|
err = dial(context.Background(), nil, io.Discard, dialSettings{
|
||||||
|
direct: true,
|
||||||
|
addr: addr,
|
||||||
|
timeout: time.Second,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("dial succeeded with nothing listening")
|
||||||
|
}
|
||||||
|
cmdtest.ErrContains(t, err, "connecting to "+addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One of --http-upgrade and --direct is required, and each refuses the other's
|
||||||
|
// flags.
|
||||||
|
func TestDialModeFlags(t *testing.T) {
|
||||||
|
cmdtest.Setup(t, "")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"neither", []string{"--ping"}, "at least one of the flags"},
|
||||||
|
{"both", []string{"--http-upgrade", "--direct"}, "none of the others"},
|
||||||
|
{"direct with url", []string{"--direct", "--url", "ws://h/ws"}, "none of the others"},
|
||||||
|
{"direct with origin", []string{"--direct", "--origin", "https://o"}, "none of the others"},
|
||||||
|
{"upgrade with addr", []string{"--http-upgrade", "--addr", "h:1"}, "none of the others"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
_, err := cmdtest.Run(t, newRoot, "", append([]string{"dial"}, tt.args...)...)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("%s: dial was accepted", tt.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cmdtest.ErrContains(t, err, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A directConn sends only text messages, and a closed one closes again
|
||||||
|
// without an error.
|
||||||
|
func TestDirectConn(t *testing.T) {
|
||||||
|
_, addr := directServer(t)
|
||||||
|
conn, err := dialDirect(context.Background(), addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = conn.Write(context.Background(), coder.MessageBinary, []byte("x"))
|
||||||
|
if !errors.Is(err, errBinaryMessage) {
|
||||||
|
t.Errorf("binary Write = %v; want errBinaryMessage", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.CloseNow(); err != nil {
|
||||||
|
t.Errorf("CloseNow = %v", err)
|
||||||
|
}
|
||||||
|
if err := conn.CloseNow(); err != nil {
|
||||||
|
t.Errorf("second CloseNow = %v; want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without --addr, --direct uses the configured host and port.
|
||||||
|
func TestDefaultAddr(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
conf config.JSCServer
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{config.JSCServer{Host: "10.0.0.1", Port: 8080}, "10.0.0.1:8080"},
|
||||||
|
{config.JSCServer{Port: 9393}, "127.0.0.1:9393"},
|
||||||
|
{config.JSCServer{Host: "::1", Port: 9393}, "[::1]:9393"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := defaultAddr(tt.conf); got != tt.want {
|
||||||
|
t.Errorf("defaultAddr(%+v) = %q; want %q", tt.conf, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user