Files
silo-server/internal/api/handlers/events_ws_declared_channels_test.go
90f5d117c8 feat(events): let websocket clients declare channels on connect (#525)
* feat(events): let websocket clients declare channels on connect

Observing the events hub over `/api/v1/events/ws` costs more than it
should. A read-only consumer has to send a `subscribe` frame within five
seconds or be closed with a policy violation, which means implementing
the handshake and holding the write half of the socket open purely to
satisfy it. That cost is contract, not transport: `subscribe` is the only
inbound message this endpoint accepts.

Accept the selection on the URL instead. `?channels=catalog,user_state`
subscribes on connect, answers with the same `subscribed` frame and
per-channel snapshots the handshake produces, and is never put on the
grace-period clock. A connection that declares nothing is unchanged —
it still owes a subscribe frame within five seconds.

Two supporting changes:

- Channel selection now resolves through one shared function used by both
  paths, so the URL and handshake cannot drift on who may subscribe to
  what. Role, profile-binding, and validity checks are unchanged.
- An unrecognized channel name is reported in the existing `rejected`
  array as `unknown_channel` rather than closing the connection. Closing
  took down every other channel the client held over one bad name, and a
  client cannot always know which channels its role allows before asking.
  Forbidden and profile-scoped channels were already handled this way.

`required_action` in the hello frame is `"none"` for a declared
connection and `"subscribe"` otherwise; the web type is widened to match.
No wire field changes type or disappears, so this stays additive under
the v1 rules.

Part of #523

AI disclosure: tool Claude Code, model claude-opus-5, fully AI-generated,
reviewed and verified by the author before submission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(events): address review findings on declared-channel subscriptions

Three findings from automated review, all verified against the code
before acting on them.

**Start the reader before declared-channel snapshots (regression).**
configureWebSocket installs an absolute read deadline that only pongs
extend, and gorilla processes pongs solely inside ReadMessage
(conn.go:950, reached only via advanceFrame). The declared path built its
snapshots before starting the reader goroutine, so a snapshot slower than
the deadline — a loaded jobs/sessions/scans/history query — would kill an
otherwise healthy connection the instant reading began. The handshake
path never had this problem because its snapshots run downstream of an
active reader. Regression test stalls a tasks snapshot past the deadline;
it fails with the previous ordering.

**Advertise the feature through a capability endpoint.** Adding a
client-visible subscription mode without one leaves a read-only client
unable to tell, before connecting, whether ?channels= will be honored: an
older server ignores it and closes the connection after the grace period,
so the client must retain the very handshake this removes. GET
/api/v1/events/capability reports both modes, the grace period the
handler actually enforces, and the known channels, following the existing
per-subsystem convention.

**Deduplicate rejections, not just acceptances.** Asking twice for one
forbidden channel produced two identical `rejected` entries. Pre-existing
— the dedup check sat after the rejection branches — but cheap to correct
in the function this PR extracted.

Part of #523

AI disclosure: tool Claude Code, model claude-opus-5, fully AI-generated,
reviewed and verified by the author before submission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(events): frame the capability endpoint as a staleness probe

Clients are expected to run a current build rather than negotiate down to
an old server, so the endpoint is not a branch-on-capability contract.
Its value is letting a client distinguish "this server does not do that"
from "the connection failed" — the two are indistinguishable from the
socket alone, since an older server ignores ?channels= and then closes on
the grace period — so it can tell the user the deployment is out of date
instead of failing opaquely.

Comment-only; no behavior or wire change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(events): bound the subscribed answer and reap unsubscribed connections

Second review pass on the declared-channel path. Four issues, all at the
edges of the new URL surface rather than in the design itself.

Rejections amplified the request. Making an unknown channel non-fatal
removed the brake that used to close the connection on the first bad
name, and every refusal quotes the name it refuses — so a large
?channels= of distinct garbage produced a far larger `subscribed` frame,
buffered server-side. Cap a selection at 32 distinct channels, report the
overrun once instead of per name, truncate an echoed name at 64 bytes,
and set a 64 KiB read limit on the socket so an oversize frame cannot be
buffered whole before it is rejected.

The grace period was disarmed by declaring, not by subscribing. Both
`?channels=` with no names and a non-admin naming only an admin channel
came up subscribed to nothing and were never reaped, each holding a hub
subscriber, two goroutines, and an envelope channel that every published
event fans into. Disarm on holding a subscription instead. Selection now
resolves before the hello frame — it is pure, so nothing moves ahead of
the reader — which lets required_action say "subscribe" when the
connection really does still owe one.

Repeating the parameter dropped channels silently.
`?channels=a&channels=b` honored only the first and reported nothing
rejected. Read every occurrence.

The capability endpoint advertised `plugins`, which is host-to-plugin
runtime dispatch and is granted to no role. An admin following the
endpoint's stated purpose got `forbidden` while already being admin, and
the hardcoded "Admin access required" made it a dead end rather than a
soft failure. Split evt.ClientChannels out of AllChannels, advertise
that, and word the refusal so it does not promise a remedy that does not
exist. A test pins that an admin can subscribe to everything the
endpoint names.

Each guard was verified to bite by reverting it and watching the test
fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 10:59:50 -04:00

639 lines
23 KiB
Go

package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/cache"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/taskmanager"
)
// eventsWSTestConn dials the events websocket against a handler authenticated
// as the given claims, and returns a frame reader. These tests go through the
// real socket rather than calling the handler directly because the behavior
// under test — what a connection is subscribed to before it has said anything,
// and whether it survives the grace period — only exists in the connection
// loop.
func eventsWSTestConn(t *testing.T, hub *evt.Hub, claims *auth.Claims, query string) (
*websocket.Conn,
func(wantType string) map[string]json.RawMessage,
) {
t.Helper()
return eventsWSTestConnWithHandler(t, &EventsHandler{hub: hub}, claims, query)
}
func eventsWSTestConnWithHandler(
t *testing.T,
handler *EventsHandler,
claims *auth.Claims,
query string,
) (*websocket.Conn, func(wantType string) map[string]json.RawMessage) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := apimw.SetClaims(r.Context(), claims)
handler.HandleWebSocket(w, r.WithContext(ctx))
}))
t.Cleanup(server.Close)
conn, resp, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http")+query, nil)
if err != nil {
t.Fatalf("dialing events websocket: %v", err)
}
if resp != nil && resp.Body != nil {
t.Cleanup(func() { _ = resp.Body.Close() })
}
t.Cleanup(func() { _ = conn.Close() })
readFrame := func(wantType string) map[string]json.RawMessage {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
_, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("reading %s frame: %v", wantType, err)
}
var frame map[string]json.RawMessage
if err := json.Unmarshal(data, &frame); err != nil {
t.Fatalf("frame is not JSON: %v (%s)", err, data)
}
if string(frame["type"]) != `"`+wantType+`"` {
t.Fatalf("frame type = %s, want %q (frame: %s)", frame["type"], wantType, data)
}
return frame
}
return conn, readFrame
}
// TestEventsWebSocketDeclaredChannelsSkipHandshake is the point of the feature:
// a connection that named its channels on the URL receives events without ever
// writing to the socket.
func TestEventsWebSocketDeclaredChannelsSkipHandshake(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
_, readFrame := eventsWSTestConn(t, hub,
&auth.Claims{UserID: 1, Role: "user"}, "?channels=user_settings")
hello := readFrame("hello")
if string(hello["required_action"]) != `"none"` {
t.Errorf("required_action = %s, want \"none\"", hello["required_action"])
}
subscribed := readFrame("subscribed")
if !strings.Contains(string(subscribed["channels"]), `"user_settings"`) {
t.Fatalf("declared channel was not accepted: %s", subscribed["channels"])
}
// Accepted channels hydrate with a snapshot, exactly as the handshake does.
if snapshot := readFrame("snapshot"); string(snapshot["channel"]) != `"user_settings"` {
t.Fatalf("snapshot channel = %s, want user_settings", snapshot["channel"])
}
publishUserSettingsEvent(context.Background(), hub, 1, "profile-1",
"playback.subtitle_language", "profile")
if event := readFrame("event"); string(event["channel"]) != `"user_settings"` {
t.Errorf("event channel = %s, want user_settings", event["channel"])
}
}
// TestEventsWebSocketDeclaredChannelsSurviveGracePeriod guards the core promise
// of the change: an observer that connects and never speaks stays connected.
// Before this, it was closed with a policy violation after five seconds.
func TestEventsWebSocketDeclaredChannelsSurviveGracePeriod(t *testing.T) {
if testing.Short() {
t.Skip("waits out the subscribe grace period in real time")
}
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub,
&auth.Claims{UserID: 1, Role: "user"}, "?channels=user_settings")
readFrame("hello")
readFrame("subscribed")
readFrame("snapshot")
// Stay silent well past the deadline that would have closed a
// handshake-style connection, then confirm the socket still delivers.
time.Sleep(subscribeGracePeriod + time.Second)
publishUserSettingsEvent(context.Background(), hub, 1, "profile-1",
"playback.subtitle_language", "profile")
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
_, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("connection did not survive the grace period: %v", err)
}
var frame map[string]json.RawMessage
if err := json.Unmarshal(data, &frame); err != nil {
t.Fatalf("frame is not JSON: %v (%s)", err, data)
}
if string(frame["type"]) != `"event"` {
t.Fatalf("frame type = %s, want \"event\" (frame: %s)", frame["type"], data)
}
}
// TestEventsWebSocketEmptyDeclarationStillClosed pins what disarms the grace
// period: holding a subscription, not having spelled ?channels=. A declaration
// that resolved to nothing leaves the connection in the exact state the clock
// exists to reap — no subscriptions, no reason to expect a frame, but still a
// hub subscriber, two goroutines, and an envelope channel every published
// event fans into.
func TestEventsWebSocketEmptyDeclarationStillClosed(t *testing.T) {
if testing.Short() {
t.Skip("waits out the subscribe grace period in real time")
}
tests := []struct {
name string
query string
}{
{name: "declares no channels", query: "?channels="},
// Every name refused: a non-admin naming only an admin channel is
// subscribed to nothing, exactly as if it had named nothing.
{name: "every declared channel refused", query: "?channels=sessions"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub,
&auth.Claims{UserID: 1, Role: "user"}, tt.query)
hello := readFrame("hello")
// The obligation is real, so the hello frame has to say so rather
// than sending the client off to wait silently on a doomed socket.
if string(hello["required_action"]) != `"subscribe"` {
t.Errorf("required_action = %s, want \"subscribe\"", hello["required_action"])
}
readFrame("subscribed")
if err := conn.SetReadDeadline(time.Now().Add(subscribeGracePeriod + 5*time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
// The error frame, then the close.
if _, _, err := conn.ReadMessage(); err != nil {
t.Fatalf("reading error frame: %v", err)
}
if _, _, err := conn.ReadMessage(); !websocket.IsCloseError(err, websocket.ClosePolicyViolation) {
t.Fatalf("close error = %v, want policy violation", err)
}
})
}
}
// TestEventsWebSocketPartialDeclarationSurvives is the boundary case on the
// other side: one accepted channel among refusals is a live subscription, so
// the connection is not on the clock.
func TestEventsWebSocketPartialDeclarationSurvives(t *testing.T) {
if testing.Short() {
t.Skip("waits out the subscribe grace period in real time")
}
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub,
&auth.Claims{UserID: 1, Role: "user"}, "?channels=sessions,user_settings")
hello := readFrame("hello")
if string(hello["required_action"]) != `"none"` {
t.Errorf("required_action = %s, want \"none\"", hello["required_action"])
}
readFrame("subscribed")
readFrame("snapshot")
time.Sleep(subscribeGracePeriod + time.Second)
publishUserSettingsEvent(context.Background(), hub, 1, "profile-1",
"playback.subtitle_language", "profile")
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
if _, _, err := conn.ReadMessage(); err != nil {
t.Fatalf("a partially accepted declaration did not survive the grace period: %v", err)
}
}
// TestEventsWebSocketRepeatedChannelsParameter covers the other natural
// spelling of a selection. Honoring only the first occurrence dropped the rest
// with an empty rejected array — the connection came up subscribed to less than
// it asked for and reported nothing wrong.
func TestEventsWebSocketRepeatedChannelsParameter(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
_, readFrame := eventsWSTestConn(t, hub, &auth.Claims{UserID: 1, Role: "user"},
"?channels=catalog&channels=user_settings")
readFrame("hello")
subscribed := readFrame("subscribed")
for _, want := range []string{`"catalog"`, `"user_settings"`} {
if !strings.Contains(string(subscribed["channels"]), want) {
t.Errorf("channel %s was dropped: %s", want, subscribed["channels"])
}
}
}
// TestEventsWebSocketSilentConnectionStillClosed pins the other half: the grace
// period still applies to a connection that declared nothing, so the URL path
// relaxes the rule rather than removing it.
func TestEventsWebSocketSilentConnectionStillClosed(t *testing.T) {
if testing.Short() {
t.Skip("waits out the subscribe grace period in real time")
}
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub, &auth.Claims{UserID: 1, Role: "user"}, "")
hello := readFrame("hello")
if string(hello["required_action"]) != `"subscribe"` {
t.Errorf("required_action = %s, want \"subscribe\"", hello["required_action"])
}
if err := conn.SetReadDeadline(time.Now().Add(subscribeGracePeriod + 5*time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
// The error frame, then the close.
if _, _, err := conn.ReadMessage(); err != nil {
t.Fatalf("reading error frame: %v", err)
}
_, _, err := conn.ReadMessage()
if err == nil {
t.Fatal("silent connection was not closed after the grace period")
}
if !websocket.IsCloseError(err, websocket.ClosePolicyViolation) {
t.Fatalf("close error = %v, want policy violation", err)
}
}
// TestEventsWebSocketDeclaredChannelsCannotEscalate is the authorization
// guarantee: naming an admin-only channel on the URL must not grant it, and
// must not grant the events published to it either.
func TestEventsWebSocketDeclaredChannelsCannotEscalate(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
_, readFrame := eventsWSTestConn(t, hub,
&auth.Claims{UserID: 1, Role: "user"}, "?channels=sessions,user_settings")
readFrame("hello")
subscribed := readFrame("subscribed")
if strings.Contains(string(subscribed["channels"]), `"sessions"`) {
t.Fatalf("non-admin was granted the sessions channel: %s", subscribed["channels"])
}
if !strings.Contains(string(subscribed["rejected"]), `"forbidden"`) {
t.Errorf("sessions was not reported as forbidden: %s", subscribed["rejected"])
}
// The permitted channel in the same request still landed.
if !strings.Contains(string(subscribed["channels"]), `"user_settings"`) {
t.Errorf("a forbidden channel denied the rest of the request: %s", subscribed["channels"])
}
readFrame("snapshot") // user_settings
// An admin-only event on the refused channel must not be delivered. Publish
// it first, then a permitted event; receiving the second without the first
// proves the first was filtered rather than merely slow.
if err := hub.PublishJSON(context.Background(), evt.ChannelSessions, "sessions.replaced", nil,
evt.PublishOptions{AdminOnly: true}); err != nil {
t.Fatalf("publishing admin-only event: %v", err)
}
publishUserSettingsEvent(context.Background(), hub, 1, "profile-1",
"playback.subtitle_language", "profile")
event := readFrame("event")
if string(event["channel"]) != `"user_settings"` {
t.Fatalf("received an event on a channel this connection was refused: %s", event["channel"])
}
}
// TestEventsWebSocketUnknownChannelDoesNotCloseConnection covers the third
// change: an unrecognized channel name used to close the socket outright,
// taking down every other channel the client held over one bad name.
func TestEventsWebSocketUnknownChannelDoesNotCloseConnection(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub, &auth.Claims{UserID: 1, Role: "user"}, "")
readFrame("hello")
if err := conn.WriteJSON(evt.EventsSubscribeMessage{
Type: "subscribe",
RequestID: "r1",
Channels: []evt.EventChannel{"not_a_channel", evt.ChannelUserSettings},
}); err != nil {
t.Fatalf("sending subscribe: %v", err)
}
subscribed := readFrame("subscribed")
if !strings.Contains(string(subscribed["rejected"]), `"unknown_channel"`) {
t.Errorf("unknown channel was not reported as such: %s", subscribed["rejected"])
}
if !strings.Contains(string(subscribed["channels"]), `"user_settings"`) {
t.Fatalf("an unknown channel denied the valid one alongside it: %s", subscribed["channels"])
}
// The connection is still usable.
readFrame("snapshot")
publishUserSettingsEvent(context.Background(), hub, 1, "profile-1",
"playback.subtitle_language", "profile")
if event := readFrame("event"); string(event["channel"]) != `"user_settings"` {
t.Errorf("event channel = %s, want user_settings", event["channel"])
}
}
// TestEventsWebSocketRejectsOversizeFrame covers the one case that is still
// fatal, and has to be: a frame is buffered whole before its type can be read,
// so an oversize frame cannot be answered with a rejection the way a bad
// channel name can — refusing it politely would mean first doing the thing the
// limit exists to prevent.
func TestEventsWebSocketRejectsOversizeFrame(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub, &auth.Claims{UserID: 1, Role: "user"}, "")
readFrame("hello")
oversize := `{"type":"subscribe","channels":["` +
strings.Repeat("x", maxEventsFrameBytes*2) + `"]}`
// The write itself may fail once the server has already torn the connection
// down, which is the same outcome; only accepting the frame is a failure.
if err := conn.WriteMessage(websocket.TextMessage, []byte(oversize)); err != nil {
return
}
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
if _, _, err := conn.ReadMessage(); err == nil {
t.Fatal("a frame past the read limit was accepted")
}
}
// slowTaskLister stalls the tasks snapshot long enough to outlast the read
// deadline configureWebSocket installs at connect.
type slowTaskLister struct{ delay time.Duration }
func (s slowTaskLister) ListTasks(bool) []taskmanager.TaskInfo {
time.Sleep(s.delay)
return []taskmanager.TaskInfo{}
}
// TestEventsWebSocketDeclaredChannelsSurviveSlowSnapshot pins the ordering the
// declared path depends on. configureWebSocket sets an absolute read deadline
// that only pongs extend, and gorilla processes pongs solely inside
// ReadMessage — so if snapshot queries ran before the reader goroutine started,
// a snapshot slower than the deadline would kill a healthy connection the
// instant reading began. The handshake path gets this for free by building
// snapshots downstream of an active reader; the declared path arranges it
// deliberately.
func TestEventsWebSocketDeclaredChannelsSurviveSlowSnapshot(t *testing.T) {
if testing.Short() {
t.Skip("stalls a snapshot past the websocket read deadline in real time")
}
hub := evt.NewHub("test", &cache.NoopEventBus{})
handler := &EventsHandler{
hub: hub,
tasks: slowTaskLister{delay: wsPingInterval + wsPongTimeout + 2*time.Second},
}
conn, readFrame := eventsWSTestConnWithHandler(t, handler,
&auth.Claims{UserID: 1, Role: "admin"}, "?channels=tasks")
readFrame("hello")
readFrame("subscribed")
// The snapshot arrives late by design; allow for the stall plus slack.
if err := conn.SetReadDeadline(time.Now().Add(wsPingInterval + wsPongTimeout + 15*time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
_, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("connection died during a slow snapshot: %v", err)
}
var frame map[string]json.RawMessage
if err := json.Unmarshal(data, &frame); err != nil {
t.Fatalf("frame is not JSON: %v (%s)", err, data)
}
if string(frame["type"]) != `"snapshot"` {
t.Fatalf("frame type = %s, want \"snapshot\" (frame: %s)", frame["type"], data)
}
// And the connection is still live afterwards.
if err := hub.PublishJSON(context.Background(), evt.ChannelTasks, "tasks.changed",
map[string]string{"id": "t1"}, evt.PublishOptions{}); err != nil {
t.Fatalf("publishing: %v", err)
}
if event := readFrame("event"); string(event["channel"]) != `"tasks"` {
t.Errorf("event channel = %s, want tasks", event["channel"])
}
}
func TestParseDeclaredChannels(t *testing.T) {
tests := []struct {
name string
query string
want []evt.EventChannel
declared bool
}{
{
name: "absent parameter keeps the handshake",
query: "",
want: nil,
declared: false,
},
{
name: "empty value declares nothing, but still declares",
query: "channels=",
want: []evt.EventChannel{},
declared: true,
},
{
name: "whitespace and empty entries are dropped",
query: "channels=catalog,%20,,user_state%20",
want: []evt.EventChannel{evt.ChannelCatalog, evt.ChannelUserState},
declared: true,
},
{
// Repeating the parameter is as natural a spelling as one comma
// list; reading only the first occurrence lost the rest silently.
name: "every occurrence of the parameter is read",
query: "channels=catalog&channels=user_state,user_settings",
want: []evt.EventChannel{evt.ChannelCatalog, evt.ChannelUserState, evt.ChannelUserSettings},
declared: true,
},
{
name: "a repeated parameter with only blank values still declares",
query: "channels=&channels=%20",
want: []evt.EventChannel{},
declared: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
query, err := url.ParseQuery(tt.query)
if err != nil {
t.Fatalf("parsing query: %v", err)
}
got, declared := parseDeclaredChannels(query)
if declared != tt.declared {
t.Fatalf("declared = %v, want %v", declared, tt.declared)
}
if len(got) != len(tt.want) {
t.Fatalf("channels = %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("channels = %v, want %v", got, tt.want)
}
}
})
}
}
func TestResolveChannelSelectionDeniesByDefault(t *testing.T) {
allowed := allowedChannelsForRole("user")
subs, accepted, rejected := resolveChannelSelection(
[]evt.EventChannel{evt.ChannelSessions, "bogus", evt.ChannelNotifications},
allowed,
"", // unbound: notifications requires a profile-bound ticket
)
if len(subs) != 0 || len(accepted) != 0 {
t.Fatalf("nothing should have been accepted: subs=%v accepted=%v", subs, accepted)
}
codes := make(map[string]evt.EventChannel, len(rejected))
for _, r := range rejected {
codes[r.Code] = r.Channel
}
if codes["forbidden"] != evt.ChannelSessions {
t.Errorf("sessions not rejected as forbidden: %v", rejected)
}
if codes["unknown_channel"] != "bogus" {
t.Errorf("bogus not rejected as unknown: %v", rejected)
}
if codes["profile_required"] != evt.ChannelNotifications {
t.Errorf("notifications not rejected as profile_required: %v", rejected)
}
}
func TestResolveChannelSelectionDeduplicates(t *testing.T) {
subs, accepted, rejected := resolveChannelSelection(
[]evt.EventChannel{evt.ChannelJobs, evt.ChannelJobs},
allowedChannelsForRole("admin"),
"",
)
if len(subs) != 1 || len(accepted) != 1 {
t.Fatalf("duplicate channel was not collapsed: subs=%v accepted=%v", subs, accepted)
}
if len(rejected) != 0 {
t.Fatalf("unexpected rejections: %v", rejected)
}
}
// TestResolveChannelSelectionDeduplicatesRejections covers the other half of
// dedup: a channel asked for twice is answered once whether it was accepted or
// refused. Only the accepted side deduplicated before.
func TestResolveChannelSelectionDeduplicatesRejections(t *testing.T) {
_, _, rejected := resolveChannelSelection(
[]evt.EventChannel{
evt.ChannelSessions, evt.ChannelSessions, // forbidden for a user
"bogus", "bogus", // unknown
},
allowedChannelsForRole("user"),
"",
)
if len(rejected) != 2 {
t.Fatalf("rejected = %v, want one entry per distinct channel", rejected)
}
}
// TestResolveChannelSelectionBoundsTheAnswer is the amplification guard.
// Refusals quote the name they refuse, so once an unknown channel stopped
// closing the connection, the response grew with the request: a large selection
// of distinct garbage names produced a far larger subscribed frame, buffered
// server-side. The answer has to be bounded independently of the request.
func TestResolveChannelSelectionBoundsTheAnswer(t *testing.T) {
requested := make([]evt.EventChannel, 0, 5000)
for i := range 5000 {
requested = append(requested, evt.EventChannel("bogus-"+strconv.Itoa(i)))
}
subs, accepted, rejected := resolveChannelSelection(requested, allowedChannelsForRole("user"), "")
if len(subs) != 0 || len(accepted) != 0 {
t.Fatalf("garbage names were accepted: subs=%v accepted=%v", subs, accepted)
}
// Every considered name is refused, plus exactly one entry for the overrun.
if len(rejected) != maxRequestedChannels+1 {
t.Fatalf("rejected %d entries, want %d", len(rejected), maxRequestedChannels+1)
}
overrun := rejected[len(rejected)-1]
if overrun.Code != "too_many_channels" {
t.Errorf("last rejection code = %q, want too_many_channels", overrun.Code)
}
// The response must not scale with the request, whatever the constants are.
encoded, err := json.Marshal(evt.EventsSubscribedMessage{
Type: "subscribed",
Channels: accepted,
Rejected: rejected,
})
if err != nil {
t.Fatalf("encoding subscribed frame: %v", err)
}
if len(encoded) > 8*1024 {
t.Errorf("subscribed frame is %d bytes for a garbage selection", len(encoded))
}
}
// TestResolveChannelSelectionTruncatesLongNames covers the per-name half of the
// same concern: one enormous name is as good an amplifier as many small ones.
func TestResolveChannelSelectionTruncatesLongNames(t *testing.T) {
long := evt.EventChannel(strings.Repeat("x", 4096))
_, _, rejected := resolveChannelSelection(
[]evt.EventChannel{long}, allowedChannelsForRole("user"), "")
if len(rejected) != 1 {
t.Fatalf("rejected = %v, want one entry", rejected)
}
if len(rejected[0].Channel) != maxChannelNameLength {
t.Errorf("echoed name is %d bytes, want it truncated to %d",
len(rejected[0].Channel), maxChannelNameLength)
}
}
// TestResolveChannelSelectionRefusesPluginsChannel pins that the host-to-plugin
// dispatch channel is not reachable from a client connection, for any role.
func TestResolveChannelSelectionRefusesPluginsChannel(t *testing.T) {
for _, role := range []string{"user", "admin"} {
t.Run(role, func(t *testing.T) {
subs, accepted, rejected := resolveChannelSelection(
[]evt.EventChannel{evt.ChannelPlugins}, allowedChannelsForRole(role), "profile-1")
if len(subs) != 0 || len(accepted) != 0 {
t.Fatalf("%s was granted the plugins channel: %v", role, accepted)
}
if len(rejected) != 1 || rejected[0].Code != "unknown_channel" {
t.Errorf("rejected = %v, want a single unknown_channel entry", rejected)
}
})
}
}