Short answer: test the authorization decision and its audit record as one deterministic transaction, then use browser tests only to verify transport wiring. In an e-commerce-style shared workspace for a stock trading desk, “online” says who currently holds a permitted connection; it does not grant access to somebody else’s watchlist or notification settings. A logical event trace makes that distinction reproducible when CI timing is noisy.
Start with an evidence contract, not a socket
The useful artifact is an append-only decision record. For every presence transition or alert attempt, record the token subject, workspace, connection identifier, requested scope, symbol, preference revision, and outcome. This is the same discipline I apply to a payment ledger: a counter is a hint, while a record that can be reconciled is evidence.
The client can send a workspace name and a symbol, but those values are claims. The server derives the principal from a signed token and checks the token's workspace and scopes. Give a trader presence:read for desk-7; grant watchlist:notify separately. A second tab may be online in that workspace while still being denied an alert. That is a policy result, not a network race.
One sentence is enough.
Keep the record schema boring and stable. A revision is incremented whenever a symbol preference changes, and the revision travels with the alert event. A consumer can then reject an old event after reconnect without comparing wall-clock timestamps or guessing which callback arrived first.
What does an audit-first test prove about realtime notification preferences?
It proves four facts independently: the connection belongs to the token subject, the subject is in the requested workspace, the token has the notification scope, and the event uses the currently authorized preference revision. Presence and delivery therefore have separate assertions. This is important for a shared stock trading watchlist, where exposing membership can be acceptable while exposing a private symbol alert is not.
The pure model below is intentionally transport-neutral. WebSocket and WebRTC adapters can call it after parsing an event; the model itself has no sleeps, goroutines, or browser state. Its small surface also makes a failed property test readable.
package watchlist import "testing" type Token struct { Subject string Workspace string Scopes map[string]bool } type Preference struct { Enabled bool Revision uint64 } type Decision struct { Subject string Workspace string Symbol string Revision uint64 Allowed bool } type Desk struct { preferences map[string]Preference presence map[string]string decisions []Decision } func NewDesk() *Desk { return &Desk{ preferences: map[string]Preference{}, presence: map[string]string{}, } } func (d *Desk) SetPreference(symbol string, enabled bool) uint64 { p := d.preferences[symbol] p.Enabled = enabled p.Revision++ d.preferences[symbol] = p return p.Revision } func (d *Desk) Connect(t Token, connectionID string) bool { allowed := t.Workspace == "desk-7" && t.Scopes["presence:read"] if allowed { d.presence[t.Subject] = connectionID } d.decisions = append(d.decisions, Decision{Subject: t.Subject, Workspace: t.Workspace, Allowed: allowed}) return allowed } func (d *Desk) AuthorizeAlert(t Token, symbol string) bool { p, exists := d.preferences[symbol] allowed := t.Workspace == "desk-7" && t.Scopes["watchlist:notify"] && exists && p.Enabled d.decisions = append(d.decisions, Decision{Subject: t.Subject, Workspace: t.Workspace, Symbol: symbol, Revision: p.Revision, Allowed: allowed}) return allowed } func TestPresenceDoesNotImplyAlertAccess(t *testing.T) { d := NewDesk() d.SetPreference("ACME", true) observer := Token{Subject: "u2", Workspace: "desk-7", Scopes: map[string]bool{"presence:read": true}} if !d.Connect(observer, "conn-b") { t.Fatal("presence scope should permit connection") } if d.AuthorizeAlert(observer, "ACME") { t.Fatal("missing notification scope must deny delivery") } } The test checks a decision, not a duration. Persist the preference revision with the preference transaction, and include it in the event envelope. On a retry, use an event key for idempotent processing; use a different connection key for duplicate-open handling. Exactly-once delivery is not a promise a browser transport can make, but idempotent processing plus a durable decision record gives reconciliation something concrete to inspect.
Turn flaky timing into a replayable governance case
Represent each action as a record with an actor, an integer logical time, a connection ID, a symbol, and (when relevant) a revision. A minimal trace might open conn-a for u1 at time 10, disable ACME at 11, close the connection at 12, reopen conn-b at 13, and retry an event carrying revision 3 at 14. The expected result is one current presence owner, no delivery under the disabled revision, and a denial record naming the missing condition.
Now permute that trace. A property-based runner can shrink a failure to six records and save the prefix as a regression fixture. The fixture documents ordering; a 500 ms sleep documents one scheduler run. Timing lies.
Do not discard rejected actions from the trace. A token that can read presence but lacks watchlist:notify is an authorization boundary worth testing. So is a token whose workspace claim differs from desk-7, and so is an alert created under an older preference revision. Keeping those records lets an auditor distinguish a policy denial from a transport disconnect.
The integration layer still has a job. A WebRTC connection's state transitions and a WebSocket close frame should be translated into the same model actions, with the W3C connection semantics and RFC 6455 framing tested at that boundary. The timing of a callback is an observation; the audit decision remains the contract.
Measure enforcement before changing client behavior
Run the policy in shadow mode first. Count scope denials, stale-revision rejections, duplicate connection IDs, reconnect durations, and authorized deliveries. Reconcile those counters against the decision stream. A mismatch is a ledger defect and deserves an incident ticket even when users report no visible problem.
The choices have costs:
| Decision | Why it helps | Where it hurts |
|---|---|---|
| Separate presence and notification scopes | Prevents membership from becoming data access | Feature growth requires explicit scope changes |
| Revisioned preferences | Makes stale alerts rejectable without clock assumptions | Revisions need durable, shared storage |
| Logical-clock replay | Reproduces rare orderings quickly | Fixtures must be reviewed when policy changes |
| Browser end-to-end checks | Verifies serialization and connection wiring | Slow and unsuitable for correctness timing assertions |
The catch is fail-closed behavior: if the policy store cannot be read, an alert may be skipped. For a private watchlist that is generally safer than delivering a disabled notification, provided the skipped decision is visible and retry policy is explicit. This design is not suitable for a microsecond-sensitive execution signal; use a dedicated low-latency market feed and a separate compliance record there. Stick with a callback smoke test for a prototype that makes no user-preference promise.
I'm not sure one reconnect grace interval fits every mobile client class. Measure the distribution, document the chosen policy, and keep that interval out of authorization assertions. Background browser tabs can be suspended, so your mileage may vary, but the scope and revision invariants should remain unchanged.
Roll out the contract in reviewable steps
First emit scoped-token decisions, preference revisions, and presence transitions without blocking delivery. Next replay captured traces through both the pure model and the transport adapter, and compare their audit records byte for byte. Finally enforce stale-event rejection and fail-closed policy, with a runbook that names the evidence a support engineer should inspect.
This sequence gives a shared workspace a precise vocabulary: a member is online only for an authorized workspace, and an alert is delivered only for an authorized symbol and revision. The transport may change; the decision record should not.
References
- W3C WebRTC Recommendation: https://www.w3.org/TR/webrtc/
- RFC 6455, The WebSocket Protocol: https://www.rfc-editor.org/rfc/rfc6455
- MDN WebRTC API: https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API