Files
OwnCord/Server/ws/hub_broadcast_test.go
T
J3vbandClaude Fable 5 d880b64d64 test: audit 2026-08-19 — fix stale tests, close coverage gaps (#1397)
* test(server): admin/handlers/channels — test-audit 2026-08-19 fixes

* test(server): api/constants — test-audit 2026-08-19 fixes

* test(server): api/middleware — test-audit 2026-08-19 fixes

* test(server): api/waf — test-audit 2026-08-19 fixes

* test(server): auth/totp/encrypt — test-audit 2026-08-19 fixes

* test(server): db/session/expiry/test — test-audit 2026-08-19 fixes

* test(server): migrations/030/attachments/unlink/on/message/delete — test-audit 2026-08-19 fixes

* test(server): updater/download — test-audit 2026-08-19 fixes

* test(server): ws/handlers_command — test-audit 2026-08-19 fixes

* test(server): ws/hub/broadcast — test-audit 2026-08-19 fixes

* test(server): ws/hub/events — test-audit 2026-08-19 fixes

* test(server): ws/livekit/webhook — test-audit 2026-08-19 fixes

* test(server): ws/voice/controls — test-audit 2026-08-19 fixes

* test(server): ws/voice/join — test-audit 2026-08-19 fixes

* test(server): ws/voice/moderation — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/commands.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/secret_store.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/update_commands.rs — test-audit 2026-08-19 fixes

* test(client): src/components/ChannelSidebar.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/ws.ts — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/credentials.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/tofu.rs — test-audit 2026-08-19 fixes

* test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 fixes

* test(client): src/pages/connect-page/LoginForm.ts — test-audit 2026-08-19 fixes

* test(client): src/pages/main-page/SidebarArea.ts — test-audit 2026-08-19 fixes

* test(client): src/stores/voice.store.ts — test-audit 2026-08-19 fixes

* test(client): tests/browser/smoke.test.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/media.test.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/renderers.test.ts — test-audit 2026-08-19 fixes

* test(client): src/components/UserProfilePopup.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/log-persistence.test.ts — test-audit 2026-08-19 fixes

* test(client): keep tests/browser out of the jsdom suite and run it in CI

* test(server): ws/hub_broadcast_test.go — bytes.Equal payload compare (gocritic)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(client): src/lib/credentials.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/dispatcher.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/permissions.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/messages.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/ws.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/identity.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/livekitE2EE.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/auth.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/voice.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* docs: test audit 2026-08-19 — findings, fixes, measured baselines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(graph): refresh the knowledge graph after the 2026-08-19 test audit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 19:29:31 +02:00

317 lines
10 KiB
Go

package ws_test
import (
"bytes"
"context"
"encoding/json"
"testing"
"time"
"github.com/owncord/server/db"
"github.com/owncord/server/ws"
)
// BroadcastUserUpdate, BroadcastDropCount and SetEventPersister had no
// coverage. The first is what propagates a profile or identity-key change to
// every connected client — an identity key that fails to propagate silently
// breaks E2EE key agreement for everyone already online.
// awaitRawMessage reads one raw frame from ch, failing if none arrives.
func awaitRawMessage(t *testing.T, ch chan []byte) []byte {
t.Helper()
select {
case raw := <-ch:
return raw
case <-time.After(2 * time.Second):
t.Fatal("no message received")
return nil
}
}
// awaitMessage reads one message from ch, failing if none arrives.
func awaitMessage(t *testing.T, ch chan []byte) map[string]any {
t.Helper()
raw := awaitRawMessage(t, ch)
var msg map[string]any
if err := json.Unmarshal(raw, &msg); err != nil {
t.Fatalf("unmarshal %q: %v", raw, err)
}
return msg
}
func TestHub_BroadcastUserUpdate(t *testing.T) {
hub, _ := newTestHub(t)
go hub.Run()
t.Cleanup(hub.Stop)
send := make(chan []byte, 8)
client := ws.NewTestClient(hub, 1, send)
hub.RegisterNowForTest(client)
avatar := "avatar.png"
identityKey := "pubkey-abc"
hub.BroadcastUserUpdate(ws.UserUpdate{UserID: 42, Username: "renamed", Avatar: &avatar, IdentityPublicKey: &identityKey})
msg := awaitMessage(t, send)
if msg["type"] != "user_update" {
t.Fatalf("type = %v, want user_update", msg["type"])
}
payload, ok := msg["payload"].(map[string]any)
if !ok {
t.Fatalf("payload is not an object: %v", msg["payload"])
}
if payload["user_id"] != float64(42) {
t.Errorf("user_id = %v, want 42", payload["user_id"])
}
if payload["username"] != "renamed" {
t.Errorf("username = %v, want renamed", payload["username"])
}
if payload["avatar"] != "avatar.png" {
t.Errorf("avatar = %v, want avatar.png", payload["avatar"])
}
// The identity key is the E2EE handshake input; dropping it here would
// leave peers unable to derive a session with this user.
if payload["identity_public_key"] != "pubkey-abc" {
t.Errorf("identity_public_key = %v, want pubkey-abc", payload["identity_public_key"])
}
}
func TestHub_BroadcastUserUpdate_NilOptionalFields(t *testing.T) {
hub, _ := newTestHub(t)
go hub.Run()
t.Cleanup(hub.Stop)
send := make(chan []byte, 8)
hub.RegisterNowForTest(ws.NewTestClient(hub, 1, send))
hub.BroadcastUserUpdate(ws.UserUpdate{UserID: 42, Username: "noextras"})
msg := awaitMessage(t, send)
payload, ok := msg["payload"].(map[string]any)
if !ok {
t.Fatalf("payload is not an object: %v", msg["payload"])
}
if payload["username"] != "noextras" {
t.Errorf("username = %v, want noextras", payload["username"])
}
// A user with no avatar / no published key must serialize as null rather
// than an empty string, so clients can tell "unset" from "cleared".
if v, present := payload["avatar"]; present && v != nil {
t.Errorf("avatar = %v, want null", v)
}
if v, present := payload["identity_public_key"]; present && v != nil {
t.Errorf("identity_public_key = %v, want null", v)
}
}
func TestHub_BroadcastUserUpdate_ReachesEveryClient(t *testing.T) {
hub, _ := newTestHub(t)
go hub.Run()
t.Cleanup(hub.Stop)
a := make(chan []byte, 8)
b := make(chan []byte, 8)
hub.RegisterNowForTest(ws.NewTestClient(hub, 1, a))
hub.RegisterNowForTest(ws.NewTestClient(hub, 2, b))
hub.BroadcastUserUpdate(ws.UserUpdate{UserID: 7, Username: "everyone"})
for i, ch := range []chan []byte{a, b} {
msg := awaitMessage(t, ch)
if msg["type"] != "user_update" {
t.Errorf("client %d got type %v, want user_update", i, msg["type"])
}
}
}
func TestHub_BroadcastDropCount(t *testing.T) {
hub, _ := newTestHub(t)
// A hub that has broadcast nothing has dropped nothing. The admin
// diagnostics endpoint reads this counter, so a nonzero baseline would
// read as backpressure that never happened.
if got := hub.BroadcastDropCount(); got != 0 {
t.Errorf("BroadcastDropCount = %d on a fresh hub, want 0", got)
}
go hub.Run()
t.Cleanup(hub.Stop)
send := make(chan []byte, 8)
hub.RegisterNowForTest(ws.NewTestClient(hub, 1, send))
hub.BroadcastUserUpdate(ws.UserUpdate{UserID: 1, Username: "u"})
awaitMessage(t, send)
// A single delivered broadcast must not increment the drop counter.
if got := hub.BroadcastDropCount(); got != 0 {
t.Errorf("BroadcastDropCount = %d after one delivered broadcast, want 0", got)
}
}
// TestHub_ChannelReadAudience_ExcludesArchivedChannel is OC-0073:
// channelReadAudience (shared by BroadcastChannelCreate/Update and the voice
// event / CleanupVoiceForChannel fan-outs) never checked ch.Archived, unlike
// its sibling RefreshChannelVisibility which treats an archived channel as
// invisible to every role. A Member has base READ_MESSAGES with no override,
// so before archiving they are a legitimate audience member for this channel;
// archiving it must remove them from channelReadAudience even though their
// role's READ_MESSAGES grant never changed.
func TestHub_ChannelReadAudience_ExcludesArchivedChannel(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
t.Cleanup(hub.Stop)
member := seedMemberUser(t, database, "archived-audience-member")
send := make(chan []byte, 8)
hub.RegisterNowForTest(ws.NewTestClient(hub, member.ID, send))
chID := seedTestChannel(t, database, "will-be-archived")
ch, err := database.GetChannel(context.Background(), chID)
if err != nil || ch == nil {
t.Fatalf("GetChannel: %v", err)
}
if err := database.AdminUpdateChannel(context.Background(), chID, db.ChannelUpdate{
Name: ch.Name,
Topic: ch.Topic,
Category: ch.Category,
SlowMode: ch.SlowMode,
Position: ch.Position,
Archived: true,
}); err != nil {
t.Fatalf("AdminUpdateChannel: %v", err)
}
archived, err := database.GetChannel(context.Background(), chID)
if err != nil || archived == nil {
t.Fatalf("GetChannel after archive: %v", err)
}
if !archived.Archived {
t.Fatalf("channel not archived after AdminUpdateChannel")
}
hub.BroadcastChannelUpdate(archived)
assertNotReceived(t, send, "member with base READ_MESSAGES on an archived channel")
}
// stubDMEvent is a minimal ws.SequencedDMEvent so EmitEvents routes through
// sendSequencedToUsers — persistEvent's second call site, the one that stamps
// a non-zero channel_id without going through the broadcast queue.
type stubDMEvent struct {
channelID int64
participantIDs []int64
payload []byte
}
func (e stubDMEvent) EventType() string { return "chat_message" }
func (e stubDMEvent) ChannelID() int64 { return e.channelID }
func (e stubDMEvent) ParticipantIDs() []int64 { return e.participantIDs }
func (e stubDMEvent) Payload() []byte { return e.payload }
// TestHub_SetEventPersister pins the invariant persistEvent exists for: the
// row written to the EventStore carries the same seq (and type, and channel)
// as the wrapped payload the client received — from both call sites,
// deliverBroadcast and sendSequencedToUsers. Cold-tier reconnect replay
// selects rows by row-seq against the payload-seq the client acked, so a
// mismatch silently replays the wrong window.
func TestHub_SetEventPersister(t *testing.T) {
hub, database := newTestHub(t)
// The persister needs the real events table; the hub's own test schema has
// none, so the store is a separately migrated DB.
store := openEventStoreDB(t)
persister := ws.NewEventPersister(store, 64, 1, 5*time.Millisecond)
persister.Start(context.Background())
// Setting and clearing must both be safe — SetEventPersister is called at
// startup and again on shutdown/reconfiguration.
hub.SetEventPersister(persister)
hub.SetEventPersister(nil)
hub.SetEventPersister(persister)
go hub.Run()
t.Cleanup(func() {
hub.Stop()
persister.Stop(context.Background())
})
user := seedMemberUser(t, database, "persisted-event-member")
chID := seedTestChannel(t, database, "persisted-event-dm")
send := make(chan []byte, 8)
hub.RegisterNowForTest(ws.NewTestClient(hub, user.ID, send))
// Global broadcast → deliverBroadcast → persistEvent(seq, 0, wrapped).
hub.BroadcastToAll([]byte(`{"type":"user_update","payload":{"user_id":7}}`))
globalFrame := awaitRawMessage(t, send)
// Sequenced DM → sendSequencedToUsers → persistEvent(seq, chID, wrapped).
hub.EmitEvents(context.Background(), []ws.Event{stubDMEvent{
channelID: chID,
participantIDs: []int64{user.ID},
payload: []byte(`{"type":"chat_message","payload":{"id":1}}`),
}})
dmFrame := awaitRawMessage(t, send)
// Stop drains the queue and waits for the flusher to exit, so everything
// enqueued above is on disk once it returns. The cleanup's second Stop is
// a no-op.
persister.Stop(context.Background())
stored, err := store.GetEventsSince(context.Background(), 0, 100)
if err != nil {
t.Fatalf("GetEventsSince: %v", err)
}
bySeq := make(map[int64]db.PersistedEvent, len(stored))
seen := make([]int64, 0, len(stored))
for _, row := range stored {
bySeq[row.Seq] = row
seen = append(seen, row.Seq)
}
cases := []struct {
label string
frame []byte
eventType string
channelID int64
}{
{"global broadcast", globalFrame, "user_update", 0},
{"sequenced DM", dmFrame, "chat_message", chID},
}
seqs := make([]int64, 0, len(cases))
for _, tc := range cases {
var wire struct {
Seq int64 `json:"seq"`
Type string `json:"type"`
}
if err := json.Unmarshal(tc.frame, &wire); err != nil {
t.Fatalf("%s: unmarshal %q: %v", tc.label, tc.frame, err)
}
if wire.Seq == 0 {
t.Fatalf("%s: delivered frame carries no seq: %s", tc.label, tc.frame)
}
if wire.Type != tc.eventType {
t.Fatalf("%s: frame type = %q, want %q", tc.label, wire.Type, tc.eventType)
}
seqs = append(seqs, wire.Seq)
row, ok := bySeq[wire.Seq]
if !ok {
t.Fatalf("%s: no persisted row at the delivered seq %d (stored seqs %v)",
tc.label, wire.Seq, seen)
}
if row.EventType != tc.eventType {
t.Errorf("%s: row event_type = %q, want %q", tc.label, row.EventType, tc.eventType)
}
if row.ChannelID != tc.channelID {
t.Errorf("%s: row channel_id = %d, want %d", tc.label, row.ChannelID, tc.channelID)
}
if !bytes.Equal(row.Payload, tc.frame) {
t.Errorf("%s: row payload = %s, want the delivered frame %s", tc.label, row.Payload, tc.frame)
}
}
if seqs[1] <= seqs[0] {
t.Errorf("seqs not monotonic across the two persist call sites: %v", seqs)
}
}