mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(client): 3 defect(s) (OC-0037, OC-0063, OC-0116) Route the tray Status submenu through saveUserStatus() (mapping the legacy "offline" to "invisible") so notifications, autoIdle, and reconnect presence restore all agree with the tray's choice; build the connected overlay from the auth_ok payload instead of a pre-dispatch authStore snapshot; keep the TOTP overlay open across a rejected verify (totpPending latch) and retain the partial token for the retry instead of clearing it in finally. Hand-applied combined cluster preserved from the previous fix run's overlap-guard block (both clusters edit main.ts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): 2 defect(s) (OC-0010, OC-0011) * fix(ws): 1 defect(s) (OC-0050) * fix(db): 1 defect(s) (OC-0052) * fix(client): 1 defect(s) (OC-0054) * fix(client): 1 defect(s) (OC-0059) * fix(auth): 1 defect(s) (OC-0061) * fix(ws): 1 defect(s) (OC-0062) * fix(client): 1 defect(s) (OC-0064) * fix(service): 1 defect(s) (OC-0070) * fix(ws): 1 defect(s) (OC-0073) * fix(service): 2 defect(s) (OC-0075, OC-0120) * fix(admin): 1 defect(s) (OC-0076) * fix(voice): 1 defect(s) (OC-0084) * fix(client): 2 defect(s) (OC-0085, OC-0094) Scope collapsed-category persistence to the connected host instead of the server display name, and stop the DM back button from jumping to the first text channel when DM mode was entered without recording channelBeforeDm. * fix(service): 1 defect(s) (OC-0087) * fix(client): 1 defect(s) (OC-0089) * fix(ws): 1 defect(s) (OC-0091) * fix(api): 1 defect(s) (OC-0093) * fix(identity): 1 defect(s) (OC-0118) * fix(dm): 1 defect(s) (OC-0119) * fix(voice): 1 defect(s) (OC-0135) * fix(api): 1 defect(s) (OC-0137) * fix(client): 1 defect(s) (OC-0142) * fix(client): 1 defect(s) (OC-0144) * fix(admin): 1 defect(s) (OC-0145) * fix(updater): 1 defect(s) (OC-0146) * fix(client): 1 defect(s) (OC-0150) * fix(mentions): 1 defect(s) (OC-0131) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
253 lines
9.7 KiB
Go
253 lines
9.7 KiB
Go
package ws
|
|
|
|
import (
|
|
"context"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/owncord/server/auth"
|
|
)
|
|
|
|
// TestStartSweep_NeverRunsConcurrentlyWithItself locks the in-flight guard
|
|
// shut: while one sweep is still running, further startSweep calls for the
|
|
// same guard must be dropped, and once it finishes the next call runs again.
|
|
func TestStartSweep_NeverRunsConcurrentlyWithItself(t *testing.T) {
|
|
h := &Hub{}
|
|
|
|
var inFlight atomic.Bool
|
|
var active, maxActive, runs atomic.Int64
|
|
release := make(chan struct{})
|
|
|
|
sweep := func() {
|
|
cur := active.Add(1)
|
|
if cur > maxActive.Load() {
|
|
maxActive.Store(cur)
|
|
}
|
|
runs.Add(1)
|
|
<-release
|
|
active.Add(-1)
|
|
}
|
|
|
|
// First call claims the guard; the sweep blocks on release.
|
|
h.startSweep(&inFlight, sweep)
|
|
// Wait until the goroutine is actually inside the sweep.
|
|
for active.Load() == 0 {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
// Ticks arriving mid-sweep must be dropped, not stacked.
|
|
for range 5 {
|
|
h.startSweep(&inFlight, sweep)
|
|
}
|
|
if got := runs.Load(); got != 1 {
|
|
t.Fatalf("runs = %d while first sweep still in flight, want 1", got)
|
|
}
|
|
|
|
close(release)
|
|
for inFlight.Load() {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
// Guard released — the next tick runs a fresh sweep.
|
|
done := make(chan struct{})
|
|
h.startSweep(&inFlight, func() { close(done) })
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("sweep did not run after the previous one finished")
|
|
}
|
|
|
|
if got := maxActive.Load(); got != 1 {
|
|
t.Fatalf("max concurrent sweeps = %d, want 1", got)
|
|
}
|
|
if got := runs.Load(); got != 1 {
|
|
t.Fatalf("blocking sweep ran %d times, want 1", got)
|
|
}
|
|
}
|
|
|
|
// Every kick path (the sweeps, the handlers.go expiry/ban kicks, DisconnectUser)
|
|
// deletes the hub entry via kickClient, so the readPump defer's unregisterNow
|
|
// finds nothing. "Absent" is a real disconnect, not a replacement: reporting it
|
|
// as replaced makes readPump skip MarkUserDisconnected, the offline presence
|
|
// broadcast, and handleVoiceLeave, so peers keep rendering the kicked user
|
|
// online.
|
|
func TestUnregisterNow_KickedClientIsNotReportedAsReplaced(t *testing.T) {
|
|
h := newEmitTestHub()
|
|
c := NewTestClient(h, 1, make(chan []byte, 4))
|
|
h.clients[1] = c
|
|
|
|
h.kickClient(c)
|
|
|
|
if replaced := h.unregisterNow(c); replaced {
|
|
t.Error("unregisterNow(kicked client) = true (replaced), want false (real disconnect)")
|
|
}
|
|
}
|
|
|
|
// The genuine replacement case must keep reporting true, so a reconnect's
|
|
// teardown does not mark the live connection's user offline.
|
|
func TestUnregisterNow_ReplacedClientIsReportedAsReplaced(t *testing.T) {
|
|
h := newEmitTestHub()
|
|
old := NewTestClient(h, 1, make(chan []byte, 4))
|
|
live := NewTestClient(h, 1, make(chan []byte, 4))
|
|
h.clients[1] = live // the reconnect already took the slot
|
|
|
|
if replaced := h.unregisterNow(old); !replaced {
|
|
t.Error("unregisterNow(old client) = false, want true (a live client holds the slot)")
|
|
}
|
|
if _, ok := h.clients[1]; !ok {
|
|
t.Error("unregisterNow(old client) evicted the live client from the hub")
|
|
}
|
|
}
|
|
|
|
// TestSweepStaleVoiceStates_TransientPermissionErrorDoesNotEvict locks the
|
|
// fail-open-on-error behavior sweepStaleVoiceStates must have: a DB read
|
|
// failure on the CONNECT_VOICE check (as opposed to a genuine revocation) must
|
|
// leave the client in voice, mirroring sweepRevokedSessions' own guard against
|
|
// treating a transient batch-lookup error as a mass disconnect. Before the
|
|
// fix, hasChannelPerm collapsed any GetChannelPermissions error to "denied",
|
|
// so a read-path fault alone evicted every in-voice participant.
|
|
func TestSweepStaleVoiceStates_TransientPermissionErrorDoesNotEvict(t *testing.T) {
|
|
ctx := context.Background()
|
|
database := newHarvestVoiceDB(t)
|
|
uid := seedHarvestVoiceUser(t, database, "sweep-transient-err")
|
|
chID := mustCreateVoiceChannel(t, database, "voice-transient-err")
|
|
if err := database.JoinVoiceChannel(ctx, uid, chID); err != nil {
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
}
|
|
|
|
h := NewHub(database, auth.NewRateLimiter(), nil)
|
|
c := NewTestClient(h, uid, make(chan []byte, 8))
|
|
c.setVoiceState(chID, "tok")
|
|
h.clients[uid] = c
|
|
|
|
// Fault-inject exactly the permission read: harvestVoiceRoleID grants
|
|
// CONNECT_VOICE directly on the role, so hasChannelPermChecked must reach
|
|
// GetChannelPermissions (channel_overrides) before it can resolve —
|
|
// nobody's permissions actually changed.
|
|
if _, err := database.ExecContext(ctx, `ALTER TABLE channel_overrides RENAME TO channel_overrides_offline`); err != nil {
|
|
t.Fatalf("rename channel_overrides: %v", err)
|
|
}
|
|
|
|
h.sweepStaleVoiceStates()
|
|
|
|
if got := c.getVoiceChID(); got != chID {
|
|
t.Fatalf("client voice channel = %d after a transient permission-read error, want it to stay at %d (not evicted)", got, chID)
|
|
}
|
|
vs, err := database.GetVoiceState(ctx, uid)
|
|
if err != nil {
|
|
t.Fatalf("GetVoiceState: %v", err)
|
|
}
|
|
if vs == nil {
|
|
t.Error("voice_states row was deleted after a transient permission-read error, want it to survive")
|
|
}
|
|
}
|
|
|
|
// TestSweepStaleVoiceStates_GhostRemovalReelectsKeyHolder locks the sweep's
|
|
// ghost-row branch into re-electing the key holder, matching every sibling
|
|
// removal path (finishVoiceLeave, the LiveKit webhook, registerNow,
|
|
// handleVoiceJoin). Before the fix, the ghost branch deleted the row and
|
|
// broadcast voice_leave without calling updateKeyHolder, so a departed user
|
|
// stripped out here while still named as key holder left the remaining
|
|
// participant self-promoting and rotating the room key locally, with its
|
|
// voice_e2ee_offers rejected as NOT_KEY_HOLDER.
|
|
func TestSweepStaleVoiceStates_GhostRemovalReelectsKeyHolder(t *testing.T) {
|
|
ctx := context.Background()
|
|
database := newHarvestVoiceDB(t)
|
|
// Ghost has the lower userID, so it would win election if it were still a
|
|
// candidate — the test only proves anything if survivor isn't already the
|
|
// answer regardless of re-election.
|
|
ghostUID := seedHarvestVoiceUser(t, database, "sweep-ghost-holder-a")
|
|
survivorUID := seedHarvestVoiceUser(t, database, "sweep-ghost-holder-b")
|
|
if ghostUID > survivorUID {
|
|
t.Fatalf("test setup assumes ghostUID (%d) < survivorUID (%d)", ghostUID, survivorUID)
|
|
}
|
|
chID := mustCreateVoiceChannel(t, database, "voice-ghost-holder")
|
|
|
|
// Ghost: a real voice_states row, but no connected client — the exact
|
|
// state sweepStaleVoiceStates' second loop treats as a ghost.
|
|
if err := database.JoinVoiceChannel(ctx, ghostUID, chID); err != nil {
|
|
t.Fatalf("JoinVoiceChannel(ghost): %v", err)
|
|
}
|
|
|
|
h := NewHub(database, auth.NewRateLimiter(), nil)
|
|
survivor := NewTestClient(h, survivorUID, make(chan []byte, 8))
|
|
survivor.setVoiceState(chID, "tok-survivor")
|
|
h.clients[survivorUID] = survivor
|
|
|
|
// Simulate the stale-holder precondition from the finding: the ghost is
|
|
// still recorded as key holder (e.g. from before it dropped out of
|
|
// h.clients), and the survivor has not yet been elected.
|
|
h.keyHolderMu.Lock()
|
|
h.voiceKeyHolders[chID] = ghostUID
|
|
h.keyHolderMu.Unlock()
|
|
|
|
h.sweepStaleVoiceStates()
|
|
|
|
if h.IsVoiceKeyHolder(chID, ghostUID) {
|
|
t.Error("ghost user is still recorded as key holder after the sweep removed its ghost voice state")
|
|
}
|
|
if !h.IsVoiceKeyHolder(chID, survivorUID) {
|
|
t.Error("surviving participant was not re-elected key holder after the sweep removed the ghost")
|
|
}
|
|
vs, err := database.GetVoiceState(ctx, ghostUID)
|
|
if err != nil {
|
|
t.Fatalf("GetVoiceState(ghost): %v", err)
|
|
}
|
|
if vs != nil {
|
|
t.Error("ghost voice_states row was not removed by the sweep")
|
|
}
|
|
}
|
|
|
|
// TestCleanupVoiceForChannel_ConcurrentJoinNotClobbered pins OC-0050:
|
|
// CleanupVoiceForChannel's client-state clear must be conditional on the
|
|
// participant still being in the channel being cleaned up at the moment it
|
|
// clears, not just at the moment it read (hub_sweep.go's own comment already
|
|
// promises this: "the client-state clear [is] conditional on the participant
|
|
// still being in THIS channel"). A voice_join to a different channel landing
|
|
// between the read and the clear must survive, exactly as
|
|
// TestSweepStaleVoiceStates_EvictionIsScopedToCheckedChannel already proves
|
|
// for the sibling sweep.
|
|
//
|
|
// The vulnerable window (read, then a separate unconditional clear) is two
|
|
// back-to-back voiceMu acquisitions with no I/O between them, too narrow to
|
|
// land reliably by staggering real goroutines. cleanupVoiceRaceClearHook
|
|
// (test-only, nil in production) fires at exactly that point so the test
|
|
// reproduces the interleaving deterministically instead of by luck.
|
|
func TestCleanupVoiceForChannel_ConcurrentJoinNotClobbered(t *testing.T) {
|
|
ctx := context.Background()
|
|
database := newHarvestVoiceDB(t)
|
|
uid := seedHarvestVoiceUser(t, database, "cleanup-race")
|
|
chA := mustCreateVoiceChannel(t, database, "voice-cleanup-a")
|
|
chB := mustCreateVoiceChannel(t, database, "voice-cleanup-b")
|
|
|
|
if err := database.JoinVoiceChannel(ctx, uid, chA); err != nil {
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
}
|
|
|
|
h := NewHub(database, auth.NewRateLimiter(), nil)
|
|
c := NewTestClient(h, uid, make(chan []byte, 8))
|
|
h.clients[uid] = c
|
|
c.setVoiceState(chA, "tok-a")
|
|
h.pubsub.Subscribe(c, VoiceTopic(chA))
|
|
|
|
// Simulate handleVoiceJoin's state-setting step (voice_join.go's
|
|
// c.setVoiceState + pubsub.Subscribe) landing exactly between
|
|
// CleanupVoiceForChannel's read of the client's current voice channel and
|
|
// its clear of that state.
|
|
cleanupVoiceRaceClearHook = func(client *Client) {
|
|
client.setVoiceState(chB, "tok-b")
|
|
h.pubsub.Subscribe(client, VoiceTopic(chB))
|
|
}
|
|
defer func() { cleanupVoiceRaceClearHook = nil }()
|
|
|
|
h.CleanupVoiceForChannel(chA)
|
|
|
|
if got := c.getVoiceChID(); got != chB {
|
|
t.Fatalf("client voiceChID = %d after a voice_join raced CleanupVoiceForChannel's read-then-clear window, want %d — the newer join must survive, not be silently wiped", got, chB)
|
|
}
|
|
if !h.SubscribedToVoiceTopicForTest(c, chB) {
|
|
t.Error("client lost its new channel's voice-topic subscription to a concurrent CleanupVoiceForChannel clear")
|
|
}
|
|
}
|