mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix: correctness fixes from the 2026-08-20 bug hunt (#1398)
* fix(identity): 2 defect(s) (OC-0192, OC-0197)
OC-0192: bound raw display_name/about/avatar bytes before the quadratic
fixpoint sanitizer runs, in both the REST handler and UserService.UpdateProfile.
OC-0197: sanitize display_name before validateDisplayName so an
HTML-entity-encoded bidi override (e.g. "‮") can no longer pass
validation as ASCII and be decoded into the real character on the way to
storage.
* fix(ws): 1 defect(s) (OC-0196)
A transient DB error during WebSocket auth (session or user lookup) was
collapsed into the terminal auth_error frame, which the client treats as
non-recoverable: it stops reconnecting and clears stored credentials. A
sub-second SQLite hiccup therefore force-logged-out every reconnecting
client with a perfectly valid session. Send a non-terminal INTERNAL error
frame instead so normal backoff/reconnect retries.
* fix(api): 1 defect(s) (OC-0198)
* fix(ws): 1 defect(s) (OC-0200)
normalizeHostForCertCompare now unwraps a bracketed IPv6 literal after the
trailing-":443" strip and before lowercasing, matching tofu::cert_store_key's
normalization order. Without the unwrap, every cert-tofu host equality guard
took the "unrelated host" branch for bracketed-IPv6 servers.
* fix(api): 1 defect(s) (OC-0202)
* fix(admin): 1 defect(s) (OC-0203)
Channel permission override handlers applied requireGrantableOverride only
to the bits being written, so an all-zero PUT or a DELETE could clear a
deny bit the actor's own role does not hold — EffectivePerms =
(rolePerm &^ deny) | allow makes removing a deny an escalation. Both the
role-layer and per-user handlers now check the guard against the bits
already on the row.
* fix(client): 1 defect(s) (OC-0205)
* fix(client): 3 defect(s) (OC-0207, OC-0227, OC-0235)
* fix(client): 1 defect(s) (OC-0208)
* fix(voice): 3 defect(s) (OC-0209, OC-0212, OC-0213)
OC-0209: reject a replayed retired-key announce before verifyPeerAnnounce
runs, so the replay cannot overwrite the peer's displayed verification
status/session fingerprint with the retired key's before being rejected.
OC-0212: buffer an announce blocked as a TOFU pin mismatch and replay it
after a successful rePinPeerIdentity, so re-pinning actually restores the
peer for the live call instead of clearing the badge and leaving them
un-keyed (a mid-call peer never re-announces on its own).
OC-0213: skip retiring a departing peer's key when the local voice roster
still lists them as present — a rejoin announce published straight into
the send queue can overtake the buffered, stale voice_leave, and retiring
a still-live key would reject every later genuine re-announce as a replay.
* fix(ws): 1 defect(s) (OC-0211)
* fix(identity): 1 defect(s) (OC-0214)
The delete-account admin guard counted remaining admins with a raw
`banned = 0` filter, so an admin whose temporary ban had already lapsed
was treated as unusable. Use the shared notBannedClause, appended outside
the Sprintf format string because its strftime verbs (%Y, %H) would
otherwise be parsed as fmt directives.
* fix(client): 1 defect(s) (OC-0215)
* fix(voice): 1 defect(s) (OC-0216)
* fix(client): 1 defect(s) (OC-0217)
* fix(voice): 1 defect(s) (OC-0219)
rollbackVoiceJoin cleared the client's in-memory voiceChID but left its
VoiceTopic subscription in place, so a socket whose join failed after
voiceJoinComplete's Subscribe kept receiving that room's E2EE relays for
the rest of the connection. Use clearVoiceAndUnsubscribe instead, matching
every other path that takes a client out of voice while its WS stays up.
* fix(client): 2 defect(s) (OC-0220, OC-0224)
dmDisplayName: a group DM whose other members have all left keeps a live
is_group row, but the server leaves `recipient` zero-valued, so the empty
username fell through as a blank label. Fall back to a non-empty placeholder.
updateDmLastMessage: a queued chat_message redelivered for an id already
reflected in the `ready` snapshot double-counted the unread badge. Only
increment when the message id advances past lastMessageId.
* fix(client): 1 defect(s) (OC-0221)
Cap queued attachments at the server's 10-attachment limit in the message
composer. Past that the server rejects the whole chat_send frame as a
generic parse error, orphaning already-uploaded attachments; refusing
before the upload starts keeps composer state and the send in sync.
* fix(ws): 1 defect(s) (OC-0222)
handleReconnect built the resume auth_ok before applyConnectStatus settled
c.user.Status, so a resumed client was told its disconnect-time status
(routinely "offline") instead of the status it was coming online as.
Move applyConnectStatus ahead of reconnectWriteReplay, matching
handleFreshConnect's ordering.
* fix(mentions): 1 defect(s) (OC-0223)
* fix(admin): 1 defect(s) (OC-0225)
* fix(client): 1 defect(s) (OC-0226)
* fix(client): 1 defect(s) (OC-0228)
* fix(client): 1 defect(s) (OC-0230)
Route the Logs tab entry counter through renderLogEntries so every render path (filter change, Clear, Refresh, live entry) keeps the count in sync with the list.
* fix(voice): 1 defect(s) (OC-0231)
* fix(client): 1 defect(s) (OC-0232)
Reduce Motion toggle wrote the reduced-motion class directly, fighting the
OS-sync media-query listener that owns it when Sync with OS is on. Route the
side effect through syncOsMotionListener so whichever source owns the class
re-derives it.
* fix(client): 1 defect(s) (OC-0233)
notifyIncomingMessage titled the desktop notification with the raw
payload username, so the popup named the sender differently from the
message row it points at. Resolve the author the same way the message
list does (resolveAuthor over the live membersStore, then
resolveDisplayName).
* fix(client): 1 defect(s) (OC-0234)
* fix(client): 1 defect(s) (OC-0236)
* fix(ws): 1 defect(s) (OC-0237)
* fix(client): 4 defect(s) (OC-0193, OC-0201, OC-0204, OC-0218)
* fix(identity): 1 defect(s) (OC-0195)
Bound free-text profile fields by raw byte length before cleanText's
quadratic sanitizeToFixpoint pass runs, generalizing OC-0192's guard into
cleanTextBounded and applying it to HandlePresenceUpdate's custom_status,
SetCustomStatus, and group DM names.
* fix(dm): 1 defect(s) (OC-0199)
handleCreateDM now broadcasts dm_channel_open to the recipient when a 1:1 DM is newly created, matching handleCreateGroupDM. GetOrCreateDMChannel pre-seeds dm_open_state for both users, so the recipient's later OpenDM reported opened=false and nothing ever told them the DM existed.
* fix(voice): 1 defect(s) (OC-0206)
vad-worklet.js gate timing constants were copied from the setTimeout
fallback's ~16ms poll cadence, but AudioWorkletProcessor.process() runs
once per 128-sample render quantum (~2.667ms at the 48kHz AudioContext).
The mic gate therefore closed ~6x faster than intended (~32ms of silence
instead of ~200ms), with the startup grace and RMS post interval off by
the same factor. Scale the frame counts to render quanta.
* fix(client): 1 defect(s) (OC-0229)
* test(client): assert the real TOFU re-pin outcome and make the pin mock faithful
The e2e journey test asserted that "Trust New Key" makes the peer's verify
badge disappear. That is the behaviour OC-0212 identifies as the defect: a
mid-call peer never re-announces, so clearing the badge left the peer
un-keyed for the rest of the call with nothing on screen. Re-pinning now
replays the announce that was blocked as a mismatch and re-verifies it
against the pin just stored, so assert the peer actually lands verified.
The mock's store_identity_pin was a no-op recorder while get_identity_pin
served a static seed map, so the replayed announce re-read the stale pin and
re-failed — a mismatch the real keyring never produces. Back the pins with a
mutable map so a write is visible to the next read. The unreadable-store
(DC-08) and reject-keeps-blocked paths are unchanged and still pass.
* fix(dm): 1 defect(s) (OC-0194)
Add regression tests pinning the raw-byte bound on group DM names, for
both CreateGroupDM and RenameGroupDM.
The Server/service/dm.go source fix for OC-0194 already landed in
bdbd5ac (fix(identity): 1 defect(s) (OC-0195)), which generalized the
guard into cleanTextBounded and applied it to the group DM name paths
alongside the profile fields. This commit therefore carries the OC-0194
tests only; dm.go is unchanged.
Revert-proof: with dm.go restored to bdbd5ac^ (cleanText before the
rune-count check) both new tests fail — CreateGroupDM returns "recipient
not found" after 222ms and RenameGroupDM accepts the name after 251ms,
against a 150ms budget. With the fix in place both pass in 0.03s.
* fix(ws): 1 defect(s) (OC-0210)
* chore(findings): record the 2026-08-20 hunt's 46 findings as fixed
Appends OC-0192..OC-0237 from the 2026-08-20 converging hunt and marks each
fixed with its commit and the test that pins it. Pre-existing records are
byte-identical; nextId moves 192 -> 238 so the next hunt cannot collide with
these ids.
Every fix was independently revert-proofed: the commit's own source diff is
reverse-applied, its test must go red, and must return green once restored.
43 of 46 carry revertProof "pass" from that mechanical run. Three could not be
checked at file level and were proved by hand at hunk level instead, recorded
as "pass (hand-proved)": OC-0200, whose ws.ts edit no longer reverse-applies
because the merge kept main's equivalent implementation; OC-0215, whose Rust
tests live in-file under #[cfg(test)]; and OC-0194, which stacks on a helper
introduced by an earlier commit. No fix was found to rest on a vacuous test.
OC-0200 additionally carries a note: main fixed that same normalizer
independently while this branch was in flight, so the branch is no longer the
only thing closing it.
* docs: record the dm_channel_open emission on 1:1 DM creation
POST /api/v1/dms now emits dm_channel_open to the recipient when it creates a
channel (it previously emitted nothing on that path), so api.md states it the
way the sibling DM endpoints already state theirs.
The channels/members/DMs UX spec claimed the server broadcast the event "to
both parties" on this flow. That was never true — nothing was broadcast before,
and now only the recipient is sent it; the creator learns the channel from the
response body. This doc lists dispatcher.ts, dm.store.ts, ChannelSidebar.ts,
service/channel.go and dm.go among its sources of truth, all touched here, so
it is corrected in the same change per its maintenance rule.
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
+12
-1
@@ -119,7 +119,18 @@ func (h *Hub) handleMessageSessionRecheck(c *Client) bool {
|
||||
|
||||
if shouldCheck && c.tokenHash != "" {
|
||||
result, dbErr := h.db.GetSessionWithBanStatus(c.ctx, c.tokenHash)
|
||||
if dbErr != nil || result == nil || auth.IsSessionExpired(result.ExpiresAt) {
|
||||
if dbErr != nil {
|
||||
// A failed read says nothing about this session's validity —
|
||||
// kicking the client on a transient DB error (SQLITE_BUSY, an
|
||||
// I/O error, a maintenance window) would be a false positive.
|
||||
// Skip this recheck; the next one retries, and
|
||||
// sweepRevokedSessions remains the time-based backstop for
|
||||
// idle connections. Matches sweepRevokedSessions's identical
|
||||
// rule for a failed batch lookup (hub_sweep.go).
|
||||
slog.Warn("ws session recheck: lookup failed, skipping", "user_id", c.userID, "err", dbErr)
|
||||
return false
|
||||
}
|
||||
if result == nil || auth.IsSessionExpired(result.ExpiresAt) {
|
||||
slog.Info("ws session expired, closing connection", "user_id", c.userID)
|
||||
h.kickClient(c)
|
||||
return true
|
||||
|
||||
@@ -3,6 +3,7 @@ package ws
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/service"
|
||||
@@ -182,6 +183,13 @@ func serviceErrorToResult(err error) Result {
|
||||
case errors.Is(err, service.ErrConflict):
|
||||
return Result{Error: ClientError{Code: ErrCodeConflict, Message: err.Error()}}
|
||||
default:
|
||||
return Result{Error: ClientError{Code: ErrCodeInternal, Message: err.Error()}}
|
||||
// Internal errors (service.ErrInternal wrappers embed the underlying
|
||||
// driver error via %v) must not reach the client verbatim, matching
|
||||
// the REST twin writeServiceError (Server/api/channel_handler.go) and
|
||||
// every other ErrCodeInternal site in this package. Log server-side
|
||||
// since this is the only ErrCodeInternal path whose caller
|
||||
// (handlers.go) skips its own logging for ClientError results.
|
||||
slog.Error("ws service internal error", "err", err)
|
||||
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "internal error"}}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,11 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) *
|
||||
callDeps.DMSvc = svc.DMs
|
||||
h.messageSvc = svc.Messages
|
||||
h.perms = svc.Permissions
|
||||
// So @here's offline narrowing can tell a disconnected idle/dnd reader
|
||||
// (users.status keeps their last *chosen* value across a disconnect)
|
||||
// from one who is actually still connected — the same live-connection
|
||||
// rule presentableMembers applies to the members array.
|
||||
svc.Messages.SetOnlineChecker(h.IsUserConnected)
|
||||
}
|
||||
|
||||
registerChatHandlers(reg, chatDeps)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package ws
|
||||
|
||||
// Internal test for OC-0211: handleMessageSessionRecheck must not treat a
|
||||
// transient DB error the same as a genuinely revoked/expired session. The
|
||||
// sibling sweep in hub_sweep.go (sweepRevokedSessions) already documents and
|
||||
// implements the correct rule for the identical failure: "a failed batch
|
||||
// lookup says nothing about any individual session — kicking everyone on a
|
||||
// transient DB error would be a mass disconnect. Skip this sweep; the next
|
||||
// tick retries." handleMessageSessionRecheck disagreed, kicking the client on
|
||||
// dbErr != nil exactly like a deleted/expired session.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
)
|
||||
|
||||
func TestHandleMessageSessionRecheck_TransientDBErrorDoesNotKick(t *testing.T) {
|
||||
database := newHarvestVoiceDB(t)
|
||||
uid := seedHarvestVoiceUser(t, database, "recheck-dberr")
|
||||
|
||||
tokenHash := "tok-recheck-dberr"
|
||||
if _, err := database.CreateSession(context.Background(), uid, tokenHash, "test-device", "127.0.0.1"); err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
c := NewTestClient(h, uid, make(chan []byte, 8))
|
||||
c.tokenHash = tokenHash
|
||||
// Put the client one message away from the periodic recheck boundary, so
|
||||
// the very next call to handleMessageSessionRecheck triggers the DB read.
|
||||
c.msgCount = SessionCheckInterval - 1
|
||||
h.clients[uid] = c
|
||||
|
||||
// Force GetSessionWithBanStatus to fail with a genuine DB error (not
|
||||
// sql.ErrNoRows, which the production code already treats as "session
|
||||
// gone" — that path is not in question here). Closing the underlying
|
||||
// connection pool reproduces the same dbErr != nil branch a transient
|
||||
// SQLITE_BUSY, I/O error, or maintenance window would.
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("database.Close: %v", err)
|
||||
}
|
||||
|
||||
closed := h.handleMessageSessionRecheck(c)
|
||||
|
||||
if closed {
|
||||
t.Fatalf("handleMessageSessionRecheck reported the connection closed on a transient DB lookup error; " +
|
||||
"a failed read is not evidence the session is invalid (compare sweepRevokedSessions, which skips on the same failure)")
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
_, stillConnected := h.clients[uid]
|
||||
h.mu.RUnlock()
|
||||
if !stillConnected {
|
||||
t.Fatalf("client was removed from h.clients on a transient DB lookup error during session recheck")
|
||||
}
|
||||
if c.isSendClosed() {
|
||||
t.Fatalf("client's send channels were closed on a transient DB lookup error during session recheck")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package ws
|
||||
|
||||
// oc_0219_voice_join_rollback_unsubscribe_test.go — regression test for
|
||||
// finding OC-0219.
|
||||
//
|
||||
// rollbackVoiceJoin clears the client's voice channel ID but never drops its
|
||||
// VoiceTopic subscription. voiceJoinComplete subscribes the joiner to
|
||||
// VoiceTopic(channelID) (voice_join.go) BEFORE it reads back the channel's
|
||||
// existing participants via GetChannelVoiceStates; when that read fails, the
|
||||
// handler calls rollbackVoiceJoin to undo the join. Every other path that
|
||||
// takes a client out of voice while its WS stays up (clearVoiceAndUnsubscribe
|
||||
// in voice_leave.go, and its callers) also drops the VoiceTopic subscription
|
||||
// — rollbackVoiceJoin is the only one that does not. A socket left subscribed
|
||||
// after a failed join keeps receiving that room's voice_e2ee_announce relays
|
||||
// (which carry no channel_id to filter on) for the rest of the connection,
|
||||
// polluting whatever voice session the client joins next.
|
||||
//
|
||||
// This reuses voiceJoinPostTokenRaceHook (test-only plumbing shared with
|
||||
// OC-0008 and OC-0172) to fault-inject a GetChannelVoiceStates failure inside
|
||||
// voiceJoinComplete, landing strictly after h.pubsub.Subscribe has already
|
||||
// run for this join.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/config"
|
||||
"github.com/owncord/server/db"
|
||||
)
|
||||
|
||||
// TestVoiceJoin_GetChannelVoiceStatesError_UnsubscribesVoiceTopic pins
|
||||
// OC-0219: rollbackVoiceJoin must drop the client's VoiceTopic subscription,
|
||||
// not just its in-memory voiceChID, so a socket that failed mid-join stops
|
||||
// receiving that room's E2EE relays.
|
||||
func TestVoiceJoin_GetChannelVoiceStatesError_UnsubscribesVoiceTopic(t *testing.T) {
|
||||
database := newHarvestVoiceDB(t)
|
||||
uid := seedHarvestVoiceUser(t, database, "join-0219-victim")
|
||||
chID := mustCreateVoiceChannel(t, database, "voice-join-0219")
|
||||
|
||||
lk, err := NewLiveKitClient(&config.VoiceConfig{
|
||||
LiveKitAPIKey: "test-api-key-0219",
|
||||
LiveKitAPISecret: "test-api-secret-0219-xyz",
|
||||
LiveKitURL: "ws://127.0.0.1:1", // never dialed: GenerateToken is local
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewLiveKitClient: %v", err)
|
||||
}
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h.SetLiveKit(lk)
|
||||
|
||||
send := make(chan []byte, 8)
|
||||
c := NewTestClient(h, uid, send)
|
||||
c.user = &db.User{ID: uid, Username: "join-0219-victim"}
|
||||
h.mu.Lock()
|
||||
h.clients[uid] = c
|
||||
h.mu.Unlock()
|
||||
|
||||
// Fault-inject the GetChannelVoiceStates call inside voiceJoinComplete —
|
||||
// same technique as the OC-0172 regression test. This hook fires after
|
||||
// GenerateToken succeeds and strictly before voiceJoinComplete's
|
||||
// h.pubsub.Subscribe call runs, so by the time GetChannelVoiceStates
|
||||
// executes the client is already subscribed to VoiceTopic(chID).
|
||||
var hookRan bool
|
||||
voiceJoinPostTokenRaceHook = func(client *Client) {
|
||||
hookRan = true
|
||||
if _, err := database.ExecContext(context.Background(), `ALTER TABLE users RENAME TO users_bak_0219`); err != nil {
|
||||
t.Fatalf("hook: rename users: %v", err)
|
||||
}
|
||||
}
|
||||
defer func() { voiceJoinPostTokenRaceHook = nil }()
|
||||
|
||||
payload, _ := json.Marshal(map[string]any{"channel_id": chID})
|
||||
h.handleVoiceJoin(context.Background(), c, json.RawMessage(payload))
|
||||
|
||||
if !hookRan {
|
||||
t.Fatal("voiceJoinPostTokenRaceHook never fired — test setup is broken, not exercising the join path")
|
||||
}
|
||||
|
||||
drainChan(send, 200*time.Millisecond)
|
||||
|
||||
// Sanity: the in-memory voiceChID was rolled back (OC-0172 already pins
|
||||
// this half of the cleanup).
|
||||
if gotCh := c.getVoiceChID(); gotCh != 0 {
|
||||
t.Fatalf("client voiceChID = %d after GetChannelVoiceStates failed mid-join, want 0 (rolled back)", gotCh)
|
||||
}
|
||||
|
||||
// The bug: rollbackVoiceJoin must also drop the VoiceTopic subscription
|
||||
// that voiceJoinComplete already established. Left in place, this socket
|
||||
// keeps receiving voice_e2ee_announce relays for chID indefinitely.
|
||||
topic := VoiceTopic(chID)
|
||||
for _, tp := range h.pubsub.TopicsForClient(uid) {
|
||||
if tp == topic {
|
||||
t.Fatalf("client is still subscribed to %q after rollbackVoiceJoin — E2EE relays for this channel will keep reaching a socket that never finished joining it", topic)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package ws
|
||||
|
||||
// oc_0222_reconnect_status_order_test.go — regression test for OC-0222.
|
||||
//
|
||||
// handleReconnect wrote the resume handshake's auth_ok (reconnectWriteReplay,
|
||||
// which reads c.user.Status) BEFORE calling applyConnectStatus, which is what
|
||||
// settles c.user.Status via db.ConnectStatus(saved) and persists it. So a
|
||||
// resumed auth_ok always carried the disconnect-time status rather than the
|
||||
// status the session is about to come online as.
|
||||
//
|
||||
// Concretely: MarkUserDisconnected rewrites a plain "online" user to
|
||||
// "offline" on socket loss. On a fast reconnect (still covered by the ring
|
||||
// buffer, so the buffer-tier replay path is taken) the resumed auth_ok's
|
||||
// payload.user.status must reflect db.ConnectStatus("offline") == "online" —
|
||||
// matching what applyConnectStatus is about to write and broadcast — not the
|
||||
// raw "offline" row value read moments earlier by refreshUserSnapshot.
|
||||
//
|
||||
// handleFreshConnect already gets this right: it calls applyConnectStatus
|
||||
// before building auth_ok (serve.go, handleFreshConnect). This test locks the
|
||||
// same ordering for the resume path.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/db"
|
||||
)
|
||||
|
||||
func TestReconnect_AuthOKReflectsSettledStatus_NotDisconnectTimeStatus(t *testing.T) {
|
||||
database := newTeardownTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID, err := database.CreateUser(ctx, "resume-status-user", "hash", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
// Establish the user as a plain "online" session, then simulate the
|
||||
// socket loss that precedes every reconnect: MarkUserDisconnected only
|
||||
// rewrites a plain "online" row to "offline" (idle/dnd/invisible survive
|
||||
// untouched), so this is the ordinary case, not a contrived one.
|
||||
if err := database.UpdateUserStatus(ctx, userID, db.StatusOnline); err != nil {
|
||||
t.Fatalf("UpdateUserStatus(online): %v", err)
|
||||
}
|
||||
if err := database.MarkUserDisconnected(ctx, userID); err != nil {
|
||||
t.Fatalf("MarkUserDisconnected: %v", err)
|
||||
}
|
||||
pre, err := database.GetUserByID(ctx, userID)
|
||||
if err != nil || pre == nil {
|
||||
t.Fatalf("GetUserByID (precondition): %v", err)
|
||||
}
|
||||
if pre.Status != db.StatusOffline {
|
||||
t.Fatalf("precondition: expected status=offline after MarkUserDisconnected, got %q", pre.Status)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
if _, err := database.CreateSession(ctx, userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
// Global (channel_id 0) frames bracketing last_seq=99, so the resume takes
|
||||
// the buffer tier rather than falling through to a full ready (which also
|
||||
// sends auth_ok, but via handleFreshConnect's already-correct ordering —
|
||||
// asserting on that path would not exercise the bug).
|
||||
rb := hub.ReplayBuffer()
|
||||
rb.Push(98, 0, []byte(`{"seq":98,"type":"presence","payload":{}}`))
|
||||
rb.Push(99, 0, []byte(`{"seq":99,"type":"presence","payload":{}}`))
|
||||
rb.Push(100, 0, []byte(`{"seq":100,"type":"presence","payload":{}}`))
|
||||
|
||||
srv := httptest.NewServer(ServeWS(hub, database, []string{"*"}, 0))
|
||||
defer srv.Close()
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
conn, dialResp, dialErr := websocket.Dial(dialCtx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil)
|
||||
if dialResp != nil && dialResp.Body != nil {
|
||||
_ = dialResp.Body.Close()
|
||||
}
|
||||
if dialErr != nil {
|
||||
t.Fatalf("websocket.Dial: %v", dialErr)
|
||||
}
|
||||
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
|
||||
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"type": "auth",
|
||||
"payload": map[string]any{
|
||||
"token": token,
|
||||
"last_seq": uint64(99),
|
||||
},
|
||||
})
|
||||
if err := conn.Write(dialCtx, websocket.MessageText, raw); err != nil {
|
||||
t.Fatalf("write auth: %v", err)
|
||||
}
|
||||
|
||||
readCtx, readCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer readCancel()
|
||||
_, msg, err := conn.Read(readCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("read handshake response: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(msg, &parsed); err != nil {
|
||||
t.Fatalf("unmarshal handshake response: %v; raw=%s", err, msg)
|
||||
}
|
||||
if parsed["type"] != MsgTypeAuthOK {
|
||||
t.Fatalf("expected auth_ok (buffer-tier resume), got %v; raw=%s", parsed["type"], msg)
|
||||
}
|
||||
payload, _ := parsed["payload"].(map[string]any)
|
||||
if payload["replay_source"] != "buffer" {
|
||||
t.Fatalf("expected replay_source=buffer (so this exercises handleReconnect, not the fresh-connect fallback), got %v", payload["replay_source"])
|
||||
}
|
||||
userField, _ := payload["user"].(map[string]any)
|
||||
gotStatus, _ := userField["status"].(string)
|
||||
if gotStatus != db.StatusOnline {
|
||||
t.Fatalf("resumed auth_ok payload.user.status = %q, want %q (db.ConnectStatus of the pre-reconnect \"offline\" row) — "+
|
||||
"the resumed auth_ok must carry the status the session is settling on, not the stale disconnect-time row value",
|
||||
gotStatus, db.StatusOnline)
|
||||
}
|
||||
|
||||
// The persisted row must agree with what auth_ok claimed — applyConnectStatus
|
||||
// must have actually run and been visible before/at the point auth_ok was
|
||||
// built, not merely be about to run after the client already parsed the
|
||||
// (wrong) value.
|
||||
post, err := database.GetUserByID(ctx, userID)
|
||||
if err != nil || post == nil {
|
||||
t.Fatalf("GetUserByID (postcondition): %v", err)
|
||||
}
|
||||
if post.Status != db.StatusOnline {
|
||||
t.Fatalf("persisted status after reconnect = %q, want %q", post.Status, db.StatusOnline)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package ws
|
||||
|
||||
// Internal test for OC-0237: serviceErrorToResult's default branch (the one
|
||||
// hit for service.ErrInternal, since ErrInternal has no dedicated case above
|
||||
// it) put err.Error() straight into the ClientError sent to the requesting
|
||||
// client, and never logged anything server-side. Service-layer ErrInternal
|
||||
// wrappers embed the underlying driver error via %v (see Server/service/dm.go),
|
||||
// so this leaked internal query names and driver state to an ordinary member,
|
||||
// while producing zero server-side log output — handlers.go only logs when
|
||||
// result.Error is NOT a ClientError. The REST twin, writeServiceError in
|
||||
// Server/api/channel_handler.go, does the opposite: it logs the error and
|
||||
// replies with the fixed string "an internal error occurred".
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/service"
|
||||
)
|
||||
|
||||
func TestServiceErrorToResult_InternalErrorDoesNotLeakAndIsLogged(t *testing.T) {
|
||||
prev := slog.Default()
|
||||
var buf bytes.Buffer
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
|
||||
// Mirrors Server/service/dm.go:149 — a real ErrInternal wrapper embedding
|
||||
// driver error text via %v, exactly what handlers_call.go's RingTargets
|
||||
// call produces when GetDMParticipantIDs fails.
|
||||
driverErr := errors.New("GetDMParticipantIDs: database is locked")
|
||||
svcErr := fmt.Errorf("%w: failed to read DM participants: %v", service.ErrInternal, driverErr)
|
||||
|
||||
result := serviceErrorToResult(svcErr)
|
||||
|
||||
ce, ok := result.Error.(ClientError)
|
||||
if !ok {
|
||||
t.Fatalf("serviceErrorToResult(ErrInternal wrapper) did not return a ClientError, got %T", result.Error)
|
||||
}
|
||||
if ce.Code != ErrCodeInternal {
|
||||
t.Fatalf("ClientError.Code = %q, want %q", ce.Code, ErrCodeInternal)
|
||||
}
|
||||
|
||||
// The client-facing message must not leak driver/query internals — it
|
||||
// must match every other ErrCodeInternal site in this package, which all
|
||||
// use a fixed string (deps.go, registry.go, voice_controls.go, serve.go).
|
||||
if strings.Contains(ce.Message, "database is locked") || strings.Contains(ce.Message, "GetDMParticipantIDs") {
|
||||
t.Fatalf("ClientError.Message leaked internal error detail to the client: %q", ce.Message)
|
||||
}
|
||||
if ce.Message == svcErr.Error() {
|
||||
t.Fatalf("ClientError.Message is the raw wrapped service error verbatim: %q", ce.Message)
|
||||
}
|
||||
|
||||
// Unlike the REST path (writeServiceError), and unlike this same handler
|
||||
// path for every other error class, nothing was ever written to the
|
||||
// server log for an internal error — the operator had no record the
|
||||
// failure happened at all.
|
||||
if !strings.Contains(buf.String(), "database is locked") {
|
||||
t.Fatalf("serviceErrorToResult did not log the internal error server-side; log output: %q", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -133,3 +133,13 @@ func (rb *EventRingBuffer) OldestSeq() uint64 {
|
||||
oldestIdx := (rb.pos - rb.count + rb.size) % rb.size
|
||||
return rb.entries[oldestIdx].seq
|
||||
}
|
||||
|
||||
// NewestSeq returns the highest sequence number in the buffer, or 0 if empty.
|
||||
func (rb *EventRingBuffer) NewestSeq() uint64 {
|
||||
rb.mu.RLock()
|
||||
defer rb.mu.RUnlock()
|
||||
if rb.count == 0 {
|
||||
return 0
|
||||
}
|
||||
return rb.newestSeqLocked()
|
||||
}
|
||||
|
||||
+9
-2
@@ -277,6 +277,15 @@ func (h *Hub) handleReconnect(
|
||||
events = append(events, h.liveVoiceEventsSince(ctx, lastSeq, liveVoiceChID)...)
|
||||
}
|
||||
|
||||
// Settle the session's status BEFORE the auth_ok write below, mirroring
|
||||
// handleFreshConnect's ordering: reconnectWriteReplay reads c.user.Status
|
||||
// to build auth_ok, so if this ran after that write the resumed client
|
||||
// would be told its disconnect-time status (routinely "offline", since
|
||||
// MarkUserDisconnected just rewrote it) instead of the status it is about
|
||||
// to come online as and broadcast (OC-0222). Skips member_join — the user
|
||||
// was already known.
|
||||
applyConnectStatus(ctx, database, c)
|
||||
|
||||
if !h.reconnectWriteReplay(ctx, conn, c, lastSeq, events, replaySource) {
|
||||
// startPumps=false: the teardown inside reconnectWriteReplay already ran
|
||||
// in full. Starting readPump on this closed conn would hit an immediate
|
||||
@@ -285,8 +294,6 @@ func (h *Hub) handleReconnect(
|
||||
return true, false
|
||||
}
|
||||
|
||||
// Update presence but skip member_join — user was already known.
|
||||
applyConnectStatus(ctx, database, c)
|
||||
h.announceConnectPresence(c)
|
||||
|
||||
return true, true
|
||||
|
||||
+16
-10
@@ -58,13 +58,17 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db
|
||||
|
||||
hash := auth.HashToken(p.Token)
|
||||
sess, err := database.GetSessionByTokenHash(ctx, hash)
|
||||
if err != nil || sess == nil {
|
||||
if err != nil {
|
||||
// DB outage, not a bad token — send a non-terminal error frame so the
|
||||
// client's normal backoff/reconnect logic retries instead of treating
|
||||
// this like a genuinely invalid session (buildAuthError is defined as
|
||||
// non-recoverable on the wire: the client stops reconnecting and
|
||||
// clears its stored credentials on that frame).
|
||||
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeInternal, "temporary failure, please retry"))
|
||||
return nil, "", resumeHint{}, fmt.Errorf("auth: session lookup failed: %w", err)
|
||||
}
|
||||
if sess == nil {
|
||||
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("invalid token"))
|
||||
if err != nil {
|
||||
// DB outage, not a bad token — carry the cause so the caller's log
|
||||
// distinguishes it from an ordinary invalid-token rejection.
|
||||
return nil, "", resumeHint{}, fmt.Errorf("auth: session lookup failed: %w", err)
|
||||
}
|
||||
return nil, "", resumeHint{}, fmt.Errorf("auth: invalid session")
|
||||
}
|
||||
|
||||
@@ -74,11 +78,13 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db
|
||||
}
|
||||
|
||||
user, err := database.GetUserByID(ctx, sess.UserID)
|
||||
if err != nil || user == nil {
|
||||
if err != nil {
|
||||
// Same DB-outage-vs-bad-credential distinction as above.
|
||||
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeInternal, "temporary failure, please retry"))
|
||||
return nil, "", resumeHint{}, fmt.Errorf("auth: user lookup failed: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("user not found"))
|
||||
if err != nil {
|
||||
return nil, "", resumeHint{}, fmt.Errorf("auth: user lookup failed: %w", err)
|
||||
}
|
||||
return nil, "", resumeHint{}, fmt.Errorf("auth: user not found")
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -640,7 +640,16 @@ func handleVoiceTokenRefreshV2(ctx context.Context, cmd Command, info ClientInfo
|
||||
// the row back far enough to learn it), the row is re-read here and the
|
||||
// delete is skipped unless it still names channelID.
|
||||
func (h *Hub) rollbackVoiceJoin(ctx context.Context, c *Client, channelID int64, joinedAt string, broadcast bool) {
|
||||
c.clearVoiceChID()
|
||||
// OC-0219: use clearVoiceAndUnsubscribe (not the bare clearVoiceChID) so a
|
||||
// join that already reached voiceJoinComplete's h.pubsub.Subscribe call
|
||||
// drops its VoiceTopic subscription along with its in-memory voiceChID —
|
||||
// exactly like every other path that takes a client out of voice while its
|
||||
// WS stays up (see clearVoiceAndUnsubscribe's doc comment in
|
||||
// voice_leave.go). Safe for the two earlier call sites too:
|
||||
// Unsubscribe is a documented no-op when the client was never subscribed
|
||||
// to that topic (pubsub.go), which is the case whenever this fires before
|
||||
// voiceJoinComplete's Subscribe has run.
|
||||
h.clearVoiceAndUnsubscribe(c)
|
||||
// The client's voice state is now set before token generation (BUG-088),
|
||||
// so a concurrent join/leave in the same channel can have elected this
|
||||
// half-joined client key holder. Re-run the election after taking it back
|
||||
|
||||
@@ -281,6 +281,89 @@ func TestAuthenticateConn_InvalidToken_ReceivesAuthError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthenticateConn_SessionLookupDBError_NotTerminal verifies OC-0196: a
|
||||
// transient DB error while looking up the session (GetSessionByTokenHash
|
||||
// returning a genuine error rather than sql.ErrNoRows) must NOT be reported
|
||||
// as the terminal auth_error frame. The client treats auth_error as
|
||||
// non-recoverable — it stops reconnecting and clears the user's stored
|
||||
// credentials (see Client/tauri-client/src/lib/ws.ts and dispatcher.ts) — so
|
||||
// collapsing "DB unreachable" into "bad token" force-logs-out every client
|
||||
// that reconnects during a sub-second SQLite hiccup even though its session
|
||||
// row is perfectly valid. A DB error must surface as a non-terminal error
|
||||
// frame instead, so the client's normal backoff/reconnect logic retries.
|
||||
func TestAuthenticateConn_SessionLookupDBError_NotTerminal(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
userID, err := database.CreateUser(context.Background(), "db-hiccup-user", "hash", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
token, err := auth.GenerateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
if _, err := database.CreateSession(context.Background(), userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
handler := ws.ServeWS(hub, database, []string{"*"}, 0)
|
||||
srv := httptest.NewServer(handler)
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, resp, err := websocket.Dial(ctx, wsURL, nil)
|
||||
if resp != nil && resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("websocket.Dial: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
|
||||
|
||||
// Simulate a transient DB outage AFTER the session/token above were
|
||||
// written successfully: close the database so the next query
|
||||
// (GetSessionByTokenHash, made when the auth frame below is processed)
|
||||
// returns a genuine driver error instead of (nil, nil). The session row
|
||||
// itself remains logically valid — this models momentary SQLite reader
|
||||
// contention (WAL checkpoint, backup, busy_timeout), not a bad token.
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("database.Close: %v", err)
|
||||
}
|
||||
|
||||
authMsg := map[string]any{
|
||||
"type": "auth",
|
||||
"payload": map[string]string{"token": token},
|
||||
}
|
||||
raw, _ := json.Marshal(authMsg)
|
||||
if err := conn.Write(ctx, websocket.MessageText, raw); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
_, respRaw, readErr := conn.Read(ctx)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read: %v", readErr)
|
||||
}
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(respRaw, &msg); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if msg["type"] == ws.MsgTypeAuthError {
|
||||
t.Errorf("got terminal %q frame for a transient DB error — the client "+
|
||||
"treats this as non-recoverable and clears stored credentials; a DB "+
|
||||
"hiccup must surface as a retryable error instead", ws.MsgTypeAuthError)
|
||||
}
|
||||
if msg["type"] != ws.MsgTypeError {
|
||||
t.Errorf("response type = %q, want %q (non-terminal error frame)", msg["type"], ws.MsgTypeError)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeWS_ValidAuth_FullHandshake verifies the complete happy path:
|
||||
// valid token → auth_ok + ready received, client counted in hub.
|
||||
func TestServeWS_ValidAuth_FullHandshake(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user