DEV Community

FerdinandBlake3517
FerdinandBlake3517

Posted on

Stock Trading Watchlist Access Control: Deterministic Realtime Moderator Tests

A live stock watchlist should treat moderation as an authorization transition, not as a race between a test runner and a websocket. The practical choice is a server-authoritative state machine, short-lived participant credentials, and a virtual clock in tests. That combination lets a test prove who may publish or observe a symbol after removal without sleeping for an arbitrary number of milliseconds.

Short answer: test the permission boundary with explicit acknowledgments and observable state, while treating every client event as an untrusted claim.

This framing matters in a marketplace session where participants vote on which ticker an analyst should discuss next. A moderator may mute a participant, remove one from the room, or restore access. None of those actions is complete merely because a button changed color. Completion means the authority has committed a new membership version, affected credentials can no longer authorize protected actions, and connected clients have converged on a view that does not leak restricted watchlist updates.

How should realtime participant moderation tests protect a stock trading watchlist?

Start by separating three things that user interfaces tend to collapse: identity, membership, and connection. Identity answers who the principal is. Membership answers what that principal may do in this particular poll. A connection is only a transport session; it is not proof that its holder still has membership.

That last distinction carries most of the security weight. A removed participant can still have an open data channel, a queued message, or JavaScript state that says can_vote = True. The server must reject the action based on authoritative membership and token scope. Client state is useful for responsiveness, but it can't grant permission.

Connection is not authority.

Use a monotonically increasing membership version for each room. A moderation command commits version 18, for example, and the acknowledgment returns 18. Subsequent publish attempts carry a credential bound to the room, participant, allowed action, and a version no newer than the authority issued. The test waits for the acknowledgment and queries an observable projection until it reaches version 18; it never assumes that 200 ms is enough. The number 18 is example data, not a timing promise.

This is also where scope beats role names. moderator and participant are convenient labels, but the enforceable claims should be narrower: publish a poll choice, receive aggregate results, or administer membership for one room. A token valid for session market-open-42 should not work in earnings-call-43, and a receive-only viewer should not gain publish access because the same browser once held a broader credential.

Be strict here.

WebRTC does not define the application's signaling protocol, so moderation commands and authorization policy remain application responsibilities. A peer connection or data channel can carry bytes; it does not decide that a participant is entitled to see a watchlist event. This boundary is easy to miss when a happy-path test uses one trusted test client for every role.

Model the boundary before testing the transport

Write the smallest state model that can express the promises users care about. For this poll, a participant is active, muted, or removed; a removed participant may later be admitted with a newly issued credential. Keep transport conditions separate: connected, reconnecting, and closed. The cross-product exposes edge cases that a single status flag hides. A participant can be connected and removed. That is a valid intermediate condition, and authorization still has to be correct.

The following Python model is deliberately boring. It gives the test a deterministic oracle without pretending to implement a network stack.

from dataclasses import dataclass
from enum import Enum


class Membership(Enum):
    ACTIVE = "active"
    MUTED = "muted"
    REMOVED = "removed"


@dataclass(frozen=True)
class Grant:
    room_id: str
    participant_id: str
    can_publish: bool
    membership_version: int


@dataclass
class RoomAuthority:
    room_id: str
    version: int = 0
    states: dict[str, Membership] = None

    def __post_init__(self):
        if self.states is None:
            self.states = {}

    def moderate(self, participant_id: str, state: Membership) -> int:
        self.version += 1
        self.states[participant_id] = state
        return self.version

    def may_publish(self, grant: Grant) -> bool:
        return (
            grant.room_id == self.room_id
            and grant.membership_version == self.version
            and grant.can_publish
            and self.states.get(grant.participant_id) == Membership.ACTIVE
        )
Enter fullscreen mode Exit fullscreen mode

The equality check on membership_version is one possible policy, not a universal recipe. It intentionally invalidates all older grants whenever room membership changes. That is simple and conservative, but it can cause needless credential refreshes in a large room. A production design can instead keep a per-participant grant version or a revocation epoch. The invariant stays the same: a credential issued before the relevant removal must not authorize a later publish.

The catch is operational. Short-lived, narrowly scoped credentials reduce the useful lifetime of a copied token, yet aggressive expiry increases refresh traffic and creates more boundary cases around reconnects. I'm not sure there is one correct lifetime across a quiet research session and a market-open event. Measure reconnect duration and credential refresh latency in the actual deployment, then choose a lifetime with headroom; don't turn a guessed duration into a security guarantee.

Replace sleeps with evidence

A flaky test usually observes elapsed time instead of causality. sleep(0.5) says nothing about whether the moderation command was committed, whether an old event was already queued, or whether the observer processed the new membership version. On a quiet laptop it may pass. Under contention it becomes a coin toss with a longer fuse.

Use a controllable clock for token expiry, retry backoff, and moderation deadlines. Use barriers or acknowledgments for state transitions. Use an event log with stable sequence numbers for delivery assertions. These are different tools because time, authority, and ordering are different dimensions.

from dataclasses import dataclass


@dataclass
class ManualClock:
    now_ms: int = 0

    def advance(self, milliseconds: int) -> None:
        if milliseconds < 0:
            raise ValueError("time cannot move backward")
        self.now_ms += milliseconds


def test_removed_participant_cannot_publish():
    authority = RoomAuthority("market-open-42")
    authority.states["participant-7"] = Membership.ACTIVE
    old_grant = Grant(
        room_id="market-open-42",
        participant_id="participant-7",
        can_publish=True,
        membership_version=0,
    )

    committed_version = authority.moderate(
        "participant-7", Membership.REMOVED
    )

    assert committed_version == 1
    assert authority.may_publish(old_grant) is False
Enter fullscreen mode Exit fullscreen mode

This unit test is necessary but insufficient. The integration suite should also prove that the command acknowledgment exposes the committed version, an observer eventually reports that same version, and a publish carrying the old grant is denied. Drive eventual observation with a bounded condition loop tied to diagnostic output, not a blind pause. If the bound expires, report the latest observed version, connection state, participant ID, and event sequence. A bare timed out message wastes the most useful evidence.

There is one subtle race worth making explicit. Suppose participant 7 publishes choice NVDA at sequence 103 while a moderator removes that participant at membership version 18. The system needs a documented linearization rule. If the publish committed before the removal, observers may legitimately receive it; if authorization was evaluated after the removal committed, it must be denied. A test that merely asserts NVDA never appears can reject correct behavior because it ignores ordering. Record both commit positions, then assert the rule.

Ordering decides.

No stopwatch needed.

Cover the failures that client mocks conceal

A browser mock often makes moderation look atomic: it receives removed, updates a store, and disables the vote button. Real clients reconnect, duplicate messages, retain stale tabs, and process messages at different speeds. The test matrix should follow trust boundaries rather than UI components.

Case Controlled disruption Required evidence
Stale publisher Reuse the pre-removal credential Publish is denied by the authority
Cross-room replay Send a grant for another poll Room scope does not match
Duplicate command Deliver the same moderation command twice State and audit effect remain idempotent
Reordered observation Show a later event before an earlier one Client uses version or sequence rules
Rejoin Admit the removed identity again Only a newly issued grant can publish
Slow observer Delay result consumption Authorization remains correct despite lag

Include positive checks too. Overzealous revocation can be just as damaging as weak revocation: muting participant 7 might block publishing while still allowing aggregate poll results, depending on the product rule. A removed participant should not receive protected room updates, but a public market price may come from a separate entitlement domain. Mixing those streams under one is_connected flag creates both leaks and false denials.

For end-to-end coverage, keep a small number of tests on the actual transport. WebRTC peers can negotiate data channels through application-defined signaling, and tests should exercise that negotiation where browser behavior matters. Most state combinations belong below that layer, where a fake transport can reorder, duplicate, delay, and drop messages under deterministic control. This split keeps the suite fast while retaining evidence that the browser-to-authority path carries scope and version correctly.

Observability is part of the contract. Log an opaque token identifier rather than the token itself, plus room ID, participant ID, requested action, membership version used for the decision, decision outcome, and event sequence. Treat watchlist membership and moderation records as sensitive operational data: restrict access, define retention, and keep raw credentials out of traces. Deliverability work teaches the same lesson in another domain — metadata that helps diagnose a delayed message can become a privacy liability if retained without purpose.

Choose a design by its trust boundary

The design decision is not websocket versus WebRTC. It is where authorization is evaluated and how a test observes that decision. A managed realtime service, a self-hosted relay, or a direct peer topology can all transport the session, but their fit changes with the required trust boundary.

Server-authoritative moderation is the default when watchlist events are private, auditability matters, or untrusted participants can modify clients. Direct peer enforcement is not suitable in that case because a removed peer cannot be trusted to erase data or honor a local flag. Stick with peer-side controls only when the content is already public and moderation is a presentation convenience rather than an access-control boundary.

A global room epoch is attractive for small sessions because its correctness argument is short. It is a poor fit when one membership change would force thousands of unrelated participants to refresh credentials. Per-participant versions reduce that churn, at the cost of more authority state and a more involved recovery path. Neither choice removes the need for deterministic tests; it changes which state the test must control and inspect.

Cost should be evaluated as load shape, not as a headline unit price: count authorization checks, fan-out deliveries, reconnect refreshes, retained audit events, and test-environment concurrency. Team fit matters as well. If engineers can't retrieve the decision version and sequence from diagnostics, failures will be hard to distinguish from transport lag, and the test suite will drift back toward longer sleeps.

Roll out the invariant in small steps

First, add decision versions and structured denial reasons without changing client behavior. Next, shadow-evaluate old and new authorization paths and compare decisions using sanitized identifiers. Then move one poll cohort to server-authoritative enforcement, while retaining an immediate rollback path at the policy boundary. Finally, remove sleep-based assertions only after each test can wait on a committed version or drive a manual clock.

Keep the release gate compact: a stale grant cannot publish, a cross-room grant cannot publish, a valid current grant can publish, and observers can correlate the result with the authority's committed version. Those four checks capture the core trust promise. Transport chaos and browser coverage expand confidence around it, but they should not obscure it.

A deterministic moderation suite is ultimately a security specification written as executable state transitions. Once authority, scope, ordering, and time are separate controls, flaky timing stops deciding whether the stock trading watchlist appears correct.

References

Top comments (0)