Verifying the per-user chokepoint end to end surfaced a UI regression it would have caused: web/src/header.ts runs the sitewide scan indicator on scan_progress/scan_complete events in every browser, and routing those to the triggering identity alone froze the indicator for everyone else at its page-load state (active-summary stays the load-time fallback, but live movement died). The source's own comment declares the indicator sitewide by design, and scan payloads carry no user data — job ids, file counts, progress percentages only. Resolution honors both truths: BroadcastToAll is re-added as the ONE sanctioned instance-wide fan-out, its doc comment making the decision explicit (no user-derived data; anything carrying books, notes, or annotations belongs in BroadcastToUser), and the two scan emitters use it. TestBroadcastToAllReachesEveryone pins the exception alongside TestBroadcastIsPerUser — audiences are now named decisions the compiler and the tests both enforce.
112 lines
3.6 KiB
Go
112 lines
3.6 KiB
Go
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 user-derived from Alice's sync
|
|
// activity. Scan events are the one sanctioned instance-wide fan-out
|
|
// (BroadcastToAll — job ids and counts, no user data); everything
|
|
// carrying a user's books or annotations stops at that user's edge.
|
|
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))
|
|
}
|
|
}
|
|
|
|
// TestBroadcastToAllReachesEveryone pins the sanctioned exception: scan
|
|
// activity is instance-wide (the sitewide header indicator runs on it),
|
|
// while everything user-derived stays per-user (TestBroadcastIsPerUser).
|
|
func TestBroadcastToAllReachesEveryone(t *testing.T) {
|
|
cm := NewConnectionManager()
|
|
|
|
var conns []*DeviceConnection
|
|
for _, user := range []string{"alice", "bob", "carol"} {
|
|
conn := &DeviceConnection{
|
|
ID: uuid.NewString(),
|
|
UserID: user,
|
|
DeviceType: "test",
|
|
DeviceName: user + "-device",
|
|
Send: make(chan BroadcastMessage, 4),
|
|
}
|
|
cm.AddConnection(conn)
|
|
conns = append(conns, conn)
|
|
}
|
|
|
|
cm.BroadcastToAll(BroadcastMessage{Type: MessageTypeScanProgress})
|
|
|
|
for _, conn := range conns {
|
|
select {
|
|
case msg := <-conn.Send:
|
|
if msg.Type != MessageTypeScanProgress {
|
|
t.Errorf("%s: got %q, want scan_progress", conn.DeviceName, msg.Type)
|
|
}
|
|
default:
|
|
t.Errorf("%s: received nothing from BroadcastToAll", conn.DeviceName)
|
|
}
|
|
}
|
|
}
|