1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2026-10-29 05:32:31 +00:00

test(rest): add black-box WebSocket monitor tests (#621)

Cover the REST WebSocket monitor endpoints (v1 + v2), previously at 0%
for all 18 functions in socketv1_controller.go and socketv2_controller.go;
pkg/rest coverage goes from 20.9% to 73.2%.

The harness dials a real gorilla/websocket client into an httptest server
wired to a running msghub.Hub, and relies on the hub's history replay for
deterministic ordering: dispatching before dialing seeds the replay, and
events are relayed in dispatch order, so receiving event N next proves
filtered events were skipped rather than delayed.

Covered for both API versions:

- monitor-all and per-mailbox endpoints, including history replay and live
  delivery, and mailbox filtering of both message-stored and (v2)
  message-deleted events
- address-form mailbox names via Manager.MailboxForAddress (user%40example.com)
- invalid mailbox names rejected with 500 before the upgrade, and plain GET
  requests failing the handshake with 400
- client data messages discarded by the read pump
- clean client shutdown: listener deregistration, empty close frame from
  the writer racing the read pump's close-code echo, and the
  WebSocketConnectsCurrent expvar gauge increment/decrement

Tests only; no production changes.  Notes for follow-up commits:

- v1 monitors ignore deletions while v2 reports them (pinned as current
  behavior in TestMonitorMailboxMessagesV1)
- httptest.Server.Close does not wait for hijacked connections, so
  dialMonitor's cleanup awaits the handler goroutine via the connections
  gauge before the hub is stopped; the read/write pumps calling
  msgListener.Close concurrently can still double-close its channel
  (check-then-act race in Close's select) and deserves a fix
- deadline-set error paths, the 54s ping/pong cycle, and write-failure
  paths remain uncovered, consistent with the POP3 send-error paths
This commit is contained in:
James Hillyerd
2026-09-17 18:23:26 -07:00
committed by GitHub
parent a85a4ab996
commit 9aaad6b950
3 changed files with 345 additions and 5 deletions
+4 -3
View File
@@ -11,6 +11,7 @@ import (
"github.com/inbucket/inbucket/v3/pkg/extension/event" "github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/message" "github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/test" "github.com/inbucket/inbucket/v3/pkg/test"
"github.com/jhillyerd/enmime/v2" "github.com/jhillyerd/enmime/v2"
) )
@@ -18,7 +19,7 @@ import (
func TestRestMailboxList(t *testing.T) { func TestRestMailboxList(t *testing.T) {
// Setup // Setup
mm := test.NewManager() mm := test.NewManager()
logbuf := setupWebServer(mm) logbuf := setupWebServer(mm, &msghub.Hub{})
// Test invalid mailbox name // Test invalid mailbox name
w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar") w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar")
@@ -122,7 +123,7 @@ func TestRestMailboxList(t *testing.T) {
func TestRestMessage(t *testing.T) { func TestRestMessage(t *testing.T) {
// Setup // Setup
mm := test.NewManager() mm := test.NewManager()
logbuf := setupWebServer(mm) logbuf := setupWebServer(mm, &msghub.Hub{})
// Test invalid mailbox name // Test invalid mailbox name
w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar/0001") w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar/0001")
@@ -244,7 +245,7 @@ func TestRestMessage(t *testing.T) {
func TestRestMarkSeen(t *testing.T) { func TestRestMarkSeen(t *testing.T) {
mm := test.NewManager() mm := test.NewManager()
logbuf := setupWebServer(mm) logbuf := setupWebServer(mm, &msghub.Hub{})
// Create some messages. // Create some messages.
tzPDT := time.FixedZone("PDT", -7*3600) tzPDT := time.FixedZone("PDT", -7*3600)
tzPST := time.FixedZone("PST", -8*3600) tzPST := time.FixedZone("PST", -8*3600)
+339
View File
@@ -0,0 +1,339 @@
package rest
import (
"context"
"encoding/json"
"errors"
"net/http/httptest"
"net/mail"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/inbucket/inbucket/v3/pkg/extension"
"github.com/inbucket/inbucket/v3/pkg/extension/event"
"github.com/inbucket/inbucket/v3/pkg/msghub"
"github.com/inbucket/inbucket/v3/pkg/server/web"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// monitorTestTimeout bounds WebSocket reads so a broken server fails the test instead of hanging.
const monitorTestTimeout = 5 * time.Second
// monitorDialer dials test servers directly; the default dialer would honor proxy env vars.
var monitorDialer = &websocket.Dialer{}
// startMsgHub returns a running *msghub.Hub with room for every message a test dispatches
// (replayed to listeners that connect later). The actor goroutine is stopped via t.Cleanup.
func startMsgHub(t *testing.T) *msghub.Hub {
t.Helper()
hub := msghub.New(10, extension.NewHost())
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go hub.Start(ctx)
return hub
}
// setupMonitorServer wires a real (running) msg hub into the web server globals and serves the
// global router over HTTP; upgrading WebSocket endpoints needs a real connection to hijack.
// The returned gauge baseline is the value of web.ExpWebSocketConnectsCurrent before any
// connection is made; pass it to dialMonitor so sessions can be awaited on teardown.
func setupMonitorServer(t *testing.T, hub *msghub.Hub) (*httptest.Server, int64) {
t.Helper()
mm := test.NewManager()
setupWebServer(mm, hub)
server := httptest.NewServer(web.Router)
// Registered before any connection cleanup so the listener socket is closed after the
// sessions below have been awaited.
t.Cleanup(server.Close)
return server, web.ExpWebSocketConnectsCurrent.Value()
}
// dialMonitor connects a WebSocket client to path on server. The cleanup closes the connection
// and waits for the server-side handler goroutine to exit, which is required before the msg hub
// may be stopped: httptest.Server.Close does not wait for hijacked (WebSocket) connections, and
// a handler deregistering its hub listener after the hub was stopped races with it.
func dialMonitor(t *testing.T, server *httptest.Server, path string, gaugeBase int64) *websocket.Conn {
t.Helper()
url := "ws" + strings.TrimPrefix(server.URL, "http") + path
conn, resp, err := monitorDialer.Dial(url, nil)
require.NoError(t, err)
// resp.Body is an empty no-op closer once the connection is upgraded.
_ = resp.Body.Close()
t.Cleanup(func() {
_ = conn.Close()
require.Eventually(t, func() bool {
return web.ExpWebSocketConnectsCurrent.Value() == gaugeBase
}, monitorTestTimeout, time.Millisecond, "server handler did not shut down")
})
return conn
}
// readMonitorJSON reads the next data message from conn and decodes it into a generic map.
func readMonitorJSON(t *testing.T, conn *websocket.Conn) map[string]interface{} {
t.Helper()
require.NoError(t, conn.SetReadDeadline(time.Now().Add(monitorTestTimeout)))
_, data, err := conn.ReadMessage()
require.NoError(t, err)
var decoded interface{}
require.NoError(t, json.Unmarshal(data, &decoded), "payload: %s", data)
obj, ok := decoded.(map[string]interface{})
require.True(t, ok, "expected JSON object, got: %s", data)
return obj
}
// testMetadata builds a message for dispatch into the hub.
func testMetadata(mailbox, id string) event.MessageMetadata {
return event.MessageMetadata{
Mailbox: mailbox,
ID: id,
From: &mail.Address{Address: "from-" + id + "@host"},
To: []*mail.Address{{Address: "to-" + id + "@host"}},
Subject: "subject " + id,
Date: time.Date(2012, 2, 1, 10, 11, 12, 0, time.UTC),
Size: 1234,
}
}
// assertMonitorHeader checks the JSONMessageHeaderV1 fields of a decoded monitor payload.
func assertMonitorHeader(t *testing.T, decoded interface{}, want event.MessageMetadata) {
t.Helper()
decodedStringEquals(t, decoded, "mailbox", want.Mailbox)
decodedStringEquals(t, decoded, "id", want.ID)
decodedStringEquals(t, decoded, "from", "<"+want.From.Address+">")
decodedStringEquals(t, decoded, "to/[0]", "<"+want.To[0].Address+">")
decodedStringEquals(t, decoded, "subject", want.Subject)
decodedStringEquals(t, decoded, "date", "2012-02-01T10:11:12Z")
decodedNumberEquals(t, decoded, "posix-millis", float64(want.Date.UnixNano()/1000000))
decodedNumberEquals(t, decoded, "size", float64(want.Size))
}
// monitorEventHeader returns the nested header object of a decoded v2 monitor event.
func monitorEventHeader(t *testing.T, ev map[string]interface{}) map[string]interface{} {
t.Helper()
header, ok := ev["header"].(map[string]interface{})
require.True(t, ok, "event has no header object: %v", ev)
return header
}
func TestMonitorAllMessagesV1(t *testing.T) {
hub := startMsgHub(t)
server, gaugeBase := setupMonitorServer(t, hub)
// Messages dispatched before connecting are replayed from hub history to new listeners.
replayed := testMetadata("replay", "0001")
hub.Dispatch(replayed)
conn := dialMonitor(t, server, "/api/v1/monitor/messages", gaugeBase)
assertMonitorHeader(t, readMonitorJSON(t, conn), replayed)
assert.Equal(t, gaugeBase+1, web.ExpWebSocketConnectsCurrent.Value(),
"open WebSocket connections gauge")
// Messages dispatched after registration are delivered live. Receiving the replayed
// message above proves the listener was registered before this dispatch.
live := testMetadata("live", "0002")
hub.Dispatch(live)
assertMonitorHeader(t, readMonitorJSON(t, conn), live)
// A clean client close tears the session down server-side: the read pump sees the close
// frame, the listener deregisters from the hub, and the connections gauge drops back.
require.NoError(t, conn.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
time.Now().Add(monitorTestTimeout)))
require.NoError(t, conn.Close())
require.Eventually(t, func() bool {
return web.ExpWebSocketConnectsCurrent.Value() == gaugeBase
}, monitorTestTimeout, time.Millisecond, "open WebSocket connections gauge")
}
func TestMonitorMailboxMessagesV1(t *testing.T) {
hub := startMsgHub(t)
server, gaugeBase := setupMonitorServer(t, hub)
hub.Dispatch(testMetadata("other", "0001"))
watched := testMetadata("watched", "0002")
hub.Dispatch(watched)
hub.Dispatch(testMetadata("other", "0003"))
conn := dialMonitor(t, server, "/api/v1/monitor/messages/watched", gaugeBase)
assertMonitorHeader(t, readMonitorJSON(t, conn), watched)
// Events are relayed in dispatch order, so receiving 0005 next proves both the replayed
// and live deliveries for other mailboxes were filtered, not merely delayed.
hub.Dispatch(testMetadata("other", "0004"))
live := testMetadata("watched", "0005")
hub.Dispatch(live)
assertMonitorHeader(t, readMonitorJSON(t, conn), live)
// Documents current behavior: v1 monitors ignore message deletions and emit no event;
// v2 emits message-deleted. The subsequent dispatch arriving next proves the delete
// produced nothing. Flagged for a follow-up fix.
hub.Delete("watched", "0005")
after := testMetadata("watched", "0006")
hub.Dispatch(after)
assertMonitorHeader(t, readMonitorJSON(t, conn), after)
}
func TestMonitorMailboxAddressV1(t *testing.T) {
hub := startMsgHub(t)
server, gaugeBase := setupMonitorServer(t, hub)
// The {name} path segment goes through Manager.MailboxForAddress; with the test stub's
// full-address naming policy, user@example.com maps to a mailbox of the same name.
stored := testMetadata("user@example.com", "0001")
hub.Dispatch(stored)
conn := dialMonitor(t, server, "/api/v1/monitor/messages/user%40example.com", gaugeBase)
assertMonitorHeader(t, readMonitorJSON(t, conn), stored)
}
func TestMonitorMailboxInvalidNameV1(t *testing.T) {
hub := startMsgHub(t)
setupWebServer(test.NewManager(), hub)
// Invalid mailbox names are rejected before the connection is upgraded.
w, err := testRestGet("http://localhost/api/v1/monitor/messages/foo%20bar")
require.NoError(t, err)
assert.Equal(t, 500, w.Code)
}
func TestMonitorUpgradeFailedV1(t *testing.T) {
hub := startMsgHub(t)
setupWebServer(test.NewManager(), hub)
// A plain GET without WebSocket upgrade headers fails the handshake; the upgrader writes
// the 400 itself before the handler error is reported.
w, err := testRestGet("http://localhost/api/v1/monitor/messages")
require.NoError(t, err)
assert.Equal(t, 400, w.Code)
}
func TestMonitorClientCloseV1(t *testing.T) {
hub := startMsgHub(t)
server, gaugeBase := setupMonitorServer(t, hub)
stored := testMetadata("close", "0001")
hub.Dispatch(stored)
conn := dialMonitor(t, server, "/api/v1/monitor/messages", gaugeBase)
// Data messages sent by the client are discarded; the monitor keeps relaying events.
require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"junk":true}`)))
assertMonitorHeader(t, readMonitorJSON(t, conn), stored)
// A clean client close ends the session. The peer may observe any of: the close-code
// echo sent by the server read pump's default close handler, the writer's empty close
// frame, or a dropped TCP connection from the handler teardown; all indicate shutdown.
require.NoError(t, conn.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
time.Now().Add(monitorTestTimeout)))
require.NoError(t, conn.SetReadDeadline(time.Now().Add(monitorTestTimeout)))
_, _, err := conn.ReadMessage()
require.Error(t, err)
var closeErr *websocket.CloseError
if errors.As(err, &closeErr) {
assert.Contains(t, []int{
websocket.CloseNormalClosure, // echoed by the server read pump
websocket.CloseNoStatusReceived, // writer's empty close frame
}, closeErr.Code)
}
}
func TestMonitorAllMessagesV2(t *testing.T) {
hub := startMsgHub(t)
server, gaugeBase := setupMonitorServer(t, hub)
// Stored messages dispatched before connecting are replayed from hub history.
stored := testMetadata("replay", "0001")
hub.Dispatch(stored)
conn := dialMonitor(t, server, "/api/v2/monitor/messages", gaugeBase)
ev := readMonitorJSON(t, conn)
decodedStringEquals(t, ev, "variant", "message-stored")
assertMonitorHeader(t, monitorEventHeader(t, ev), stored)
// Data messages sent by the client are discarded; the monitor keeps relaying events.
require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"junk":true}`)))
live := testMetadata("live", "0002")
hub.Dispatch(live)
ev = readMonitorJSON(t, conn)
decodedStringEquals(t, ev, "variant", "message-stored")
assertMonitorHeader(t, monitorEventHeader(t, ev), live)
// Deletions are reported as message-deleted events with the message identifier.
hub.Delete("live", "0002")
ev = readMonitorJSON(t, conn)
decodedStringEquals(t, ev, "variant", "message-deleted")
decodedStringEquals(t, ev, "identifier/mailbox", "live")
decodedStringEquals(t, ev, "identifier/id", "0002")
}
func TestMonitorMailboxMessagesV2(t *testing.T) {
hub := startMsgHub(t)
server, gaugeBase := setupMonitorServer(t, hub)
hub.Dispatch(testMetadata("other", "0001"))
watched := testMetadata("watched", "0002")
hub.Dispatch(watched)
conn := dialMonitor(t, server, "/api/v2/monitor/messages/watched", gaugeBase)
ev := readMonitorJSON(t, conn)
decodedStringEquals(t, ev, "variant", "message-stored")
assertMonitorHeader(t, monitorEventHeader(t, ev), watched)
// Events are relayed in dispatch order, so receiving message-stored for 0003 next proves
// the other-mailbox delete was filtered, not merely delayed.
hub.Delete("other", "0001")
live := testMetadata("watched", "0003")
hub.Dispatch(live)
ev = readMonitorJSON(t, conn)
decodedStringEquals(t, ev, "variant", "message-stored")
assertMonitorHeader(t, monitorEventHeader(t, ev), live)
hub.Delete("other", "9999")
hub.Delete("watched", "0003")
ev = readMonitorJSON(t, conn)
decodedStringEquals(t, ev, "variant", "message-deleted")
decodedStringEquals(t, ev, "identifier/mailbox", "watched")
decodedStringEquals(t, ev, "identifier/id", "0003")
}
func TestMonitorMailboxAddressV2(t *testing.T) {
hub := startMsgHub(t)
server, gaugeBase := setupMonitorServer(t, hub)
// The {name} path segment goes through Manager.MailboxForAddress; with the test stub's
// full-address naming policy, user@example.com maps to a mailbox of the same name.
stored := testMetadata("user@example.com", "0001")
hub.Dispatch(stored)
conn := dialMonitor(t, server, "/api/v2/monitor/messages/user%40example.com", gaugeBase)
ev := readMonitorJSON(t, conn)
decodedStringEquals(t, ev, "variant", "message-stored")
assertMonitorHeader(t, monitorEventHeader(t, ev), stored)
}
func TestMonitorMailboxInvalidNameV2(t *testing.T) {
hub := startMsgHub(t)
setupWebServer(test.NewManager(), hub)
// Invalid mailbox names are rejected before the connection is upgraded.
w, err := testRestGet("http://localhost/api/v2/monitor/messages/foo%20bar")
require.NoError(t, err)
assert.Equal(t, 500, w.Code)
}
func TestMonitorUpgradeFailedV2(t *testing.T) {
hub := startMsgHub(t)
setupWebServer(test.NewManager(), hub)
// A plain GET without WebSocket upgrade headers fails the handshake; the upgrader writes
// the 400 itself before the handler error is reported.
w, err := testRestGet("http://localhost/api/v2/monitor/messages")
require.NoError(t, err)
assert.Equal(t, 400, w.Code)
}
+2 -2
View File
@@ -51,7 +51,7 @@ func testRestPatch(url string, body string) (*httptest.ResponseRecorder, error)
return w, nil return w, nil
} }
func setupWebServer(mm message.Manager) *bytes.Buffer { func setupWebServer(mm message.Manager, hub *msghub.Hub) *bytes.Buffer {
// Capture log output // Capture log output
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
log.SetOutput(buf) log.SetOutput(buf)
@@ -63,7 +63,7 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
}, },
} }
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter()) SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
web.NewServer(cfg, mm, &msghub.Hub{}) web.NewServer(cfg, mm, hub)
return buf return buf
} }