fix(sync): websocket events are per-user — chokepoint the fan-out
Owner-flagged after the dev-docs audit: BroadcastProgressUpdate, BroadcastAnnotationUpdate, and BroadcastConflictNotification fanned out through the untargeted Broadcast, delivering one user's book progress, conflict notices, and — worst — annotation payloads (which can carry note/highlight text) to EVERY connected client on the instance. A family-lens violation of exactly the kind the RLS move eliminated for database reads: user B's client received events for user A's books, including books in libraries hidden from B. Fix is the RLS philosophy applied to the fan-out layer: enforcement at one choke point. The untargeted Broadcast method and its channel/loop machinery are deleted — BroadcastToUser is now the only door, and the compiler forces every future emitter to name its audience instead of relying on review. The three helpers take the acting user's ID; the four sync call sites (progress.go x2, annotations.go x2) already had the acting user in scope. Scan events were already per-triggering- identity and collections already per-user — unchanged. Logging is untouched: the only log line in the diff is the deleted method's own, so docker logs remains the complete administrator audit trail. TestBroadcastIsPerUser pins the invariant (alice's two devices receive her three events; bob's client receives nothing — any message there is a cross-user leak). Both websocket docs updated in the same commit: the wire doc's fan-out section now states per-user delivery and the chokepoint, and the gated endpoint doc's intro matches. Full make test green.
This commit is contained in:
1 parent
e957d385ec
commit
a431dfa10b
6 files changed
+122
-62
No files matched your search
@@ -0,0 +1,75 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestBroadcastIsPerUser pins the 2026-11-02 chokepoint: sync events are
|
||||
// private to the acting user. The pre-fix helpers fanned out through the
|
||||
// untargeted Broadcast, delivering one user's book progress — and
|
||||
// annotation payloads, which can carry note text — to every connected
|
||||
// client on the instance. The untargeted method no longer exists (the
|
||||
// compiler enforces an audience on every emitter); this test enforces the
|
||||
// routing at runtime, in the spirit of TestRlsInvariant: no event may
|
||||
// cross a user boundary.
|
||||
func TestBroadcastIsPerUser(t *testing.T) {
|
||||
cm := NewConnectionManager()
|
||||
|
||||
mkConn := func(user string) *DeviceConnection {
|
||||
conn := &DeviceConnection{
|
||||
ID: uuid.NewString(),
|
||||
UserID: user,
|
||||
DeviceType: "test",
|
||||
DeviceName: user + "-device",
|
||||
Send: make(chan BroadcastMessage, 8),
|
||||
}
|
||||
cm.AddConnection(conn)
|
||||
return conn
|
||||
}
|
||||
|
||||
// Alice has two devices connected; Bob has one.
|
||||
a1 := mkConn("alice")
|
||||
a2 := mkConn("alice")
|
||||
b1 := mkConn("bob")
|
||||
|
||||
source := SourceDevice{ID: "dev-1", Name: "alice-kobo", Type: "kobo"}
|
||||
|
||||
cm.BroadcastProgressUpdate("alice", uuid.New(), 42.5, source)
|
||||
cm.BroadcastAnnotationUpdate("alice", uuid.New(), "highlight", map[string]any{"highlight_id": "h1"}, source)
|
||||
cm.BroadcastConflictNotification("alice", uuid.New(), "detection", "conflict-1")
|
||||
|
||||
drain := func(conn *DeviceConnection) []BroadcastMessage {
|
||||
var got []BroadcastMessage
|
||||
for {
|
||||
select {
|
||||
case msg := <-conn.Send:
|
||||
got = append(got, msg)
|
||||
default:
|
||||
return got
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alice's devices each received all three of her events...
|
||||
for _, conn := range []*DeviceConnection{a1, a2} {
|
||||
got := drain(conn)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("%s got %d messages, want 3", conn.DeviceName, len(got))
|
||||
}
|
||||
for _, msg := range got {
|
||||
// Conflict events are server-detected and carry no source;
|
||||
// progress and annotation events identify the originating
|
||||
// device so clients can ignore their own echo.
|
||||
if msg.Type != "conflict" && (msg.Source == nil || msg.Source.Name != "alice-kobo") {
|
||||
t.Errorf("%s: unexpected event source on %q: %+v", conn.DeviceName, msg.Type, msg.Source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ...and Bob received nothing. Any message here is a cross-user leak.
|
||||
if leaked := drain(b1); len(leaked) != 0 {
|
||||
t.Fatalf("bob's client received %d message(s) from alice's sync activity — cross-user leak", len(leaked))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user