mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(voice): 4 defect(s) (OC-0008, OC-0009, OC-0042, OC-0080) Guard LiveKit session state against supersession: bump the camera/screen generation in leaveVoice and teardownForReconnect so an in-flight enable discards its track, bail out of restoreLocalVoiceState when a newer room claimed _room mid-await, and recheck isStateConnected in the auto-reconnect tail. * fix(ws): 1 defect(s) (OC-0019) * fix(db): 1 defect(s) (OC-0023) * fix(ws): 1 defect(s) (OC-0029) * fix(ws): 1 defect(s) (OC-0032) * fix(voice): 1 defect(s) (OC-0034) * fix(admin): 1 defect(s) (OC-0035) * fix(service): 2 defect(s) (OC-0036, OC-0128) * fix(voice): 2 defect(s) (OC-0038, OC-0065) OC-0038: the LiveKit participant_left webhook cleared the leaver's own client voice state before broadcasting voice_leave, so the broadcast audience (READ_MESSAGES holders union still-in-the-room participants) could no longer see them. Voice membership is gated on CONNECT_VOICE alone, so a participant without READ_MESSAGES never learned the server had torn down their call. Extracted finishVoiceLeave's audience logic into broadcastVoiceEventWithLeaver and used it on the webhook path. OC-0065: handleWebhookParticipantJoined OR'd a GetVoiceState read error into the same branch as "no matching row", so a transient DB failure ejected a legitimate participant from the SFU mid-call. Now the read error is logged and the check skipped, matching sweepStaleVoiceStates. * fix(client): 1 defect(s) (OC-0041) * fix(client): 1 defect(s) (OC-0043) * fix(client): 1 defect(s) (OC-0046) * fix(client): 1 defect(s) (OC-0047) * fix(client): 1 defect(s) (OC-0049) * fix(client): 1 defect(s) (OC-0108) * fix(client): 2 defect(s) (OC-0111, OC-0143) OC-0111: retry a presence_update dropped by the 1-per-10s limiter once the window reopens, so auto-idle's return-to-online does not leave the server and every other client stuck on idle. OC-0143: pass apiConfig.host to the DM profile sidebar so per-user notes are scoped per server, matching channel mutes, the NSFW gate and volume. * test(ws): align aborted-switch test with OC-0034 no-resurrect behavior The fix agent rewrote this pre-existing test (it locked the buggy restore path) but the prove agent left it out of c67d25ed; committed state alone failed go test ./ws/ without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
405 lines
16 KiB
Go
405 lines
16 KiB
Go
package service
|
|
|
|
// Tests for the 2026-08-12/13 message_crud.go findings: the post-commit
|
|
// GetDMParticipantIDs fan-out gap on send/edit/delete (OC-0033, OC-0067,
|
|
// OC-0068), EditMessage's fail-open channel lookup (OC-0074), DeleteMessage's
|
|
// missing archived-channel gate (OC-0077), and SendMessage's redundant
|
|
// dm_channel_open re-opens (OC-0106).
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/owncord/server/auth"
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/permissions"
|
|
)
|
|
|
|
// disconnectAfterWriteStore models a client whose connection drops the instant
|
|
// its write commits — the request ctx gets canceled right after the wrapped
|
|
// write call returns, before the post-commit DM participant lookup runs.
|
|
// GetDMParticipantIDs is overridden to fail whenever it is handed an
|
|
// already-canceled context, so a test can tell whether the caller used the
|
|
// (canceled) request ctx or a detached one for that lookup.
|
|
type disconnectAfterWriteStore struct {
|
|
Store
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func (s disconnectAfterWriteStore) CreateMessageWithMentions(ctx context.Context, channelID, userID int64, content string, replyTo *int64, mentionedUserIDs []int64, mentionsEveryone bool) (*db.Message, error) {
|
|
msg, err := s.Store.CreateMessageWithMentions(ctx, channelID, userID, content, replyTo, mentionedUserIDs, mentionsEveryone)
|
|
s.cancel()
|
|
return msg, err
|
|
}
|
|
|
|
func (s disconnectAfterWriteStore) EditMessage(ctx context.Context, id, userID int64, content string) (*db.Message, error) {
|
|
msg, err := s.Store.EditMessage(ctx, id, userID, content)
|
|
s.cancel()
|
|
return msg, err
|
|
}
|
|
|
|
func (s disconnectAfterWriteStore) DeleteMessage(ctx context.Context, id, userID int64, isMod bool) error {
|
|
err := s.Store.DeleteMessage(ctx, id, userID, isMod)
|
|
s.cancel()
|
|
return err
|
|
}
|
|
|
|
func (s disconnectAfterWriteStore) GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
return s.Store.GetDMParticipantIDs(ctx, channelID)
|
|
}
|
|
|
|
// newDMFixture seeds a two-person DM channel (alice=1, bob=2) and returns the
|
|
// permission service so callers can build plain and wrapped MessageServices
|
|
// against the same underlying database.
|
|
func newDMFixture(t *testing.T) (*db.DB, *PermissionService) {
|
|
t.Helper()
|
|
database := newTestDB(t)
|
|
seedRole(t, database, &db.Role{
|
|
ID: permissions.MemberRoleID,
|
|
Name: "member",
|
|
Permissions: permissions.SendMessages | permissions.ReadMessages,
|
|
Position: 1,
|
|
})
|
|
seedUser(t, database, &db.User{ID: 1, Username: "alice"})
|
|
seedUser(t, database, &db.User{ID: 2, Username: "bob"})
|
|
seedUserRole(t, database, 1, permissions.MemberRoleID)
|
|
seedUserRole(t, database, 2, permissions.MemberRoleID)
|
|
seedChannel(t, database, &db.Channel{ID: 50, Name: "dm-1-2", Type: "dm"})
|
|
seedDMParticipant(t, database, 50, 1)
|
|
seedDMParticipant(t, database, 50, 2)
|
|
return database, NewPermissionService(database, permissions.NewChecker(database))
|
|
}
|
|
|
|
// OC-0033: a sender whose connection drops right after their DM message
|
|
// commits must not silently drop live fan-out to the other participant(s).
|
|
func TestSendMessage_DMFanoutSurvivesSenderDisconnectAfterCommit(t *testing.T) {
|
|
database, permSvc := newDMFixture(t)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
svc := NewMessageService(disconnectAfterWriteStore{Store: database, cancel: cancel}, permSvc, nil)
|
|
|
|
result, err := svc.SendMessage(ctx, SendMessageParams{
|
|
ChannelID: 50, UserID: 1, Username: "alice", Content: "hi bob",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SendMessage: %v", err)
|
|
}
|
|
if len(result.ParticipantIDs) == 0 {
|
|
t.Fatal("ParticipantIDs empty after a sender disconnect that raced the post-commit DM lookup — " +
|
|
"the recipient gets no live chat_message fan-out until their next reconnect")
|
|
}
|
|
}
|
|
|
|
// OC-0067: same gap on the edit path — a chat_edited must still reach the
|
|
// other participant even when the editor's connection drops after the DB
|
|
// write commits.
|
|
func TestEditMessage_DMFanoutSurvivesEditorDisconnectAfterCommit(t *testing.T) {
|
|
database, permSvc := newDMFixture(t)
|
|
plainSvc := NewMessageService(database, permSvc, nil)
|
|
|
|
sent, err := plainSvc.SendMessage(context.Background(), SendMessageParams{
|
|
ChannelID: 50, UserID: 1, Username: "alice", Content: "original",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
svc := NewMessageService(disconnectAfterWriteStore{Store: database, cancel: cancel}, permSvc, nil)
|
|
|
|
editResult, err := svc.EditMessage(ctx, 1, sent.MessageID, "edited content")
|
|
if err != nil {
|
|
t.Fatalf("EditMessage: %v", err)
|
|
}
|
|
if len(editResult.ParticipantIDs) == 0 {
|
|
t.Fatal("ParticipantIDs empty after an editor disconnect that raced the post-commit DM lookup — " +
|
|
"the other participant never sees the edit live")
|
|
}
|
|
}
|
|
|
|
// OC-0068: same gap on the delete path — a chat_deleted must still reach the
|
|
// other participant even when the deleter's connection drops after the
|
|
// soft-delete commits.
|
|
func TestDeleteMessage_DMFanoutSurvivesDeleterDisconnectAfterCommit(t *testing.T) {
|
|
database, permSvc := newDMFixture(t)
|
|
plainSvc := NewMessageService(database, permSvc, nil)
|
|
|
|
sent, err := plainSvc.SendMessage(context.Background(), SendMessageParams{
|
|
ChannelID: 50, UserID: 1, Username: "alice", Content: "delete me",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
svc := NewMessageService(disconnectAfterWriteStore{Store: database, cancel: cancel}, permSvc, nil)
|
|
|
|
delResult, err := svc.DeleteMessage(ctx, 1, sent.MessageID)
|
|
if err != nil {
|
|
t.Fatalf("DeleteMessage: %v", err)
|
|
}
|
|
if len(delResult.ParticipantIDs) == 0 {
|
|
t.Fatal("ParticipantIDs empty after a deleter disconnect that raced the post-commit DM lookup — " +
|
|
"the other participant never sees the delete live")
|
|
}
|
|
}
|
|
|
|
// erroringGetChannelStore fails GetChannel for exactly one channel id,
|
|
// modeling a transient DB hiccup on that lookup alone.
|
|
type erroringGetChannelStore struct {
|
|
Store
|
|
failFor int64
|
|
}
|
|
|
|
func (s erroringGetChannelStore) GetChannel(ctx context.Context, id int64) (*db.Channel, error) {
|
|
if id == s.failFor {
|
|
return nil, errors.New("simulated transient GetChannel failure")
|
|
}
|
|
return s.Store.GetChannel(ctx, id)
|
|
}
|
|
|
|
// OC-0074: a GetChannel failure inside EditMessage must not fail OPEN into
|
|
// the non-DM permission branch — that branch passes on the base role mask
|
|
// (SEND_MESSAGES|READ_MESSAGES) with no per-channel override to stop it,
|
|
// skipping both the DM-participant check and the block gate entirely.
|
|
func TestEditMessage_FailsClosedWhenChannelLookupErrors(t *testing.T) {
|
|
database, permSvc := newDMFixture(t)
|
|
plainSvc := NewMessageService(database, permSvc, nil)
|
|
|
|
sent, err := plainSvc.SendMessage(context.Background(), SendMessageParams{
|
|
ChannelID: 50, UserID: 1, Username: "alice", Content: "hi bob",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
seedBlock(t, database, 2, 1) // bob blocks alice
|
|
|
|
svc := NewMessageService(erroringGetChannelStore{Store: database, failFor: 50}, permSvc, nil)
|
|
|
|
if _, err := svc.EditMessage(context.Background(), 1, sent.MessageID, "slipped past the block"); err == nil {
|
|
t.Fatal("EditMessage succeeded despite a failed channel lookup and an active block — " +
|
|
"a GetChannel error must fail closed, not fall through to the non-DM permission path")
|
|
}
|
|
msg, err := database.GetMessage(context.Background(), sent.MessageID)
|
|
if err != nil || msg == nil {
|
|
t.Fatalf("GetMessage: %v", err)
|
|
}
|
|
if msg.Content != "hi bob" {
|
|
t.Fatalf("content must survive the refused edit, got %q", msg.Content)
|
|
}
|
|
}
|
|
|
|
// OC-0077: DeleteMessage must refuse to mutate a message in an archived
|
|
// channel, mirroring the SendMessage gate (message_crud.go:54).
|
|
func TestDeleteMessage_RefusedInArchivedChannel(t *testing.T) {
|
|
svc, database := newTestMessageService(t)
|
|
ctx := context.Background()
|
|
|
|
sent, err := svc.SendMessage(ctx, SendMessageParams{
|
|
ChannelID: 10, UserID: 1, Username: "alice", RoleName: "member", Content: "delete me",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
|
|
if _, err := database.ExecContext(ctx, `UPDATE channels SET archived = 1 WHERE id = 10`); err != nil {
|
|
t.Fatalf("archive channel: %v", err)
|
|
}
|
|
|
|
if _, err := svc.DeleteMessage(ctx, 1, sent.MessageID); !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("DeleteMessage in an archived channel: err = %v, want ErrForbidden", err)
|
|
}
|
|
|
|
msg, err := database.GetMessage(ctx, sent.MessageID)
|
|
if err != nil || msg == nil {
|
|
t.Fatalf("GetMessage: %v", err)
|
|
}
|
|
if msg.Deleted {
|
|
t.Fatal("message must survive a delete attempt against an archived channel")
|
|
}
|
|
}
|
|
|
|
// OC-0106: OpenDM is INSERT OR IGNORE and SendMessage used to append every
|
|
// recipient to OpenedDMFor unconditionally, so a DM that is already open
|
|
// still re-emits dm_channel_open (and bumps the hub's global visibility
|
|
// watermark) on every single message. Once a DM is open for a recipient, a
|
|
// second send must not report it as freshly opened.
|
|
func TestSendMessage_DoesNotReopenAlreadyOpenDM(t *testing.T) {
|
|
database, permSvc := newDMFixture(t)
|
|
svc := NewMessageService(database, permSvc, nil)
|
|
ctx := context.Background()
|
|
|
|
first, err := svc.SendMessage(ctx, SendMessageParams{
|
|
ChannelID: 50, UserID: 1, Username: "alice", Content: "first",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("first send: %v", err)
|
|
}
|
|
if len(first.OpenedDMFor) != 1 || first.OpenedDMFor[0] != 2 {
|
|
t.Fatalf("first send OpenedDMFor = %v, want [2] (bob's first open)", first.OpenedDMFor)
|
|
}
|
|
|
|
second, err := svc.SendMessage(ctx, SendMessageParams{
|
|
ChannelID: 50, UserID: 1, Username: "alice", Content: "second",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("second send: %v", err)
|
|
}
|
|
if len(second.OpenedDMFor) != 0 {
|
|
t.Fatalf("second send OpenedDMFor = %v, want [] — the DM was already open for bob, "+
|
|
"so re-reporting it forces a redundant dm_channel_open and a global visibility-watermark bump "+
|
|
"for every other connected client's next reconnect", second.OpenedDMFor)
|
|
}
|
|
}
|
|
|
|
// OC-0033 residual: the recipient's dm_open_state re-open must survive the
|
|
// sender's disconnect too — GetDMParticipantIDs was detached from ctx but the
|
|
// OpenDM loop was not, so a canceled request ctx silently skipped the re-open
|
|
// and the recipient's sidebar never learned the DM existed.
|
|
func TestSendMessage_DMReopenSurvivesSenderDisconnectAfterCommit(t *testing.T) {
|
|
database, permSvc := newDMFixture(t)
|
|
// Bob has closed the DM; this send must genuinely re-open it for him.
|
|
if err := database.CloseDM(context.Background(), 2, 50); err != nil {
|
|
t.Fatalf("CloseDM: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
svc := NewMessageService(disconnectAfterWriteStore{Store: database, cancel: cancel}, permSvc, nil)
|
|
|
|
result, err := svc.SendMessage(ctx, SendMessageParams{
|
|
ChannelID: 50, UserID: 1, Username: "alice", Content: "hi bob",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SendMessage: %v", err)
|
|
}
|
|
if len(result.OpenedDMFor) != 1 || result.OpenedDMFor[0] != 2 {
|
|
t.Fatalf("OpenedDMFor = %v, want [2] — OpenDM must run detached from the request ctx, "+
|
|
"or a sender disconnect after commit leaves the DM invisible in the recipient's sidebar",
|
|
result.OpenedDMFor)
|
|
}
|
|
}
|
|
|
|
// Same fail-closed rule the edit path got (OC-0074): DeleteMessage must not
|
|
// fall open into the non-DM permission branch — and past the new archived
|
|
// gate (OC-0077) — when the channel lookup errors.
|
|
func TestDeleteMessage_FailsClosedWhenChannelLookupErrors(t *testing.T) {
|
|
database, permSvc := newDMFixture(t)
|
|
|
|
sendSvc := NewMessageService(database, permSvc, nil)
|
|
sent, err := sendSvc.SendMessage(context.Background(), SendMessageParams{
|
|
ChannelID: 50, UserID: 1, Username: "alice", Content: "to be deleted",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SendMessage: %v", err)
|
|
}
|
|
|
|
svc := NewMessageService(erroringGetChannelStore{Store: database, failFor: 50}, permSvc, nil)
|
|
if _, err := svc.DeleteMessage(context.Background(), 1, sent.MessageID); err == nil {
|
|
t.Fatal("DeleteMessage succeeded despite a failed channel lookup — " +
|
|
"a GetChannel error must fail closed, not fall through to the non-DM permission path")
|
|
}
|
|
msg, err := database.GetMessage(context.Background(), sent.MessageID)
|
|
if err != nil || msg == nil {
|
|
t.Fatalf("message must survive the refused delete; GetMessage: msg=%v err=%v", msg, err)
|
|
}
|
|
}
|
|
|
|
// OC-0036: slow mode's cooldown token is spent by limiter.Allow, which must
|
|
// only run once the send has passed content/attachment validation. Consuming
|
|
// it earlier means a send that gets rejected for an unrelated reason (content
|
|
// too long, in this case) still locks the composer for the full slow-mode
|
|
// window even though nothing was ever posted.
|
|
func TestSendMessage_SlowModeNotConsumedByFailedContentValidation(t *testing.T) {
|
|
_, database := newTestMessageService(t)
|
|
if err := database.SetChannelSlowMode(context.Background(), 10, 3600); err != nil {
|
|
t.Fatalf("SetChannelSlowMode: %v", err)
|
|
}
|
|
checker := permissions.NewChecker(database)
|
|
permSvc := NewPermissionService(database, checker)
|
|
svc := NewMessageService(database, permSvc, auth.NewRateLimiter())
|
|
ctx := context.Background()
|
|
|
|
overLong := strings.Repeat("a", maxMessageLen+1)
|
|
if _, err := svc.SendMessage(ctx, SendMessageParams{
|
|
ChannelID: 10, UserID: 1, Username: "alice", Content: overLong,
|
|
}); !errors.Is(err, ErrBadRequest) {
|
|
t.Fatalf("over-length send: err = %v, want ErrBadRequest", err)
|
|
}
|
|
|
|
// The rejected send above must not have spent the once-per-hour slow-mode
|
|
// token: a valid, short send immediately after should still go through.
|
|
if _, err := svc.SendMessage(ctx, SendMessageParams{
|
|
ChannelID: 10, UserID: 1, Username: "alice", Content: "hi",
|
|
}); err != nil {
|
|
t.Fatalf("SendMessage right after a rejected over-length send: %v — slow mode must only be "+
|
|
"charged once a send clears content/attachment validation, not before", err)
|
|
}
|
|
}
|
|
|
|
// disconnectAfterLinkStore models a client whose connection drops the instant
|
|
// LinkAttachmentsToMessage commits — mirrors disconnectAfterWriteStore but for
|
|
// the attachment path. GetAttachmentsByMessageIDs is overridden to fail
|
|
// whenever handed an already-canceled context, so a test can tell whether the
|
|
// post-link attachment read used the (canceled) request ctx or a detached one.
|
|
type disconnectAfterLinkStore struct {
|
|
Store
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func (s disconnectAfterLinkStore) LinkAttachmentsToMessage(ctx context.Context, messageID, uploaderID int64, attachmentIDs []string) (int64, error) {
|
|
n, err := s.Store.LinkAttachmentsToMessage(ctx, messageID, uploaderID, attachmentIDs)
|
|
s.cancel()
|
|
return n, err
|
|
}
|
|
|
|
func (s disconnectAfterLinkStore) GetAttachmentsByMessageIDs(ctx context.Context, msgIDs []int64) (map[int64][]db.AttachmentInfo, error) {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
return s.Store.GetAttachmentsByMessageIDs(ctx, msgIDs)
|
|
}
|
|
|
|
// OC-0128: a sender whose connection drops the instant the attachment link
|
|
// commits must still get the linked attachment back on the broadcast result —
|
|
// not a message with no content and no attachments. The post-link read must
|
|
// run on a detached ctx, the same way the compensating deletes in SendMessage
|
|
// already do.
|
|
func TestSendMessage_AttachmentsSurviveSenderDisconnectAfterLink(t *testing.T) {
|
|
_, database := newTestMessageService(t)
|
|
// Grant ATTACH_FILES on top of the base member perms newTestMessageService seeds.
|
|
seedRole(t, database, &db.Role{
|
|
ID: permissions.MemberRoleID,
|
|
Name: "member",
|
|
Permissions: permissions.SendMessages | permissions.ReadMessages | permissions.AddReactions | permissions.AttachFiles,
|
|
Position: 1,
|
|
})
|
|
if _, err := database.ExecContext(context.Background(),
|
|
`INSERT INTO attachments (id, uploader_id, filename, stored_as, mime_type, size)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
"att-1", 1, "photo.png", "stored-photo.png", "image/png", 100,
|
|
); err != nil {
|
|
t.Fatalf("seed attachment: %v", err)
|
|
}
|
|
checker := permissions.NewChecker(database)
|
|
permSvc := NewPermissionService(database, checker)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
svc := NewMessageService(disconnectAfterLinkStore{Store: database, cancel: cancel}, permSvc, nil)
|
|
|
|
result, err := svc.SendMessage(ctx, SendMessageParams{
|
|
ChannelID: 10, UserID: 1, Username: "alice", AttachmentIDs: []string{"att-1"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SendMessage: %v", err)
|
|
}
|
|
if len(result.Attachments) != 1 {
|
|
t.Fatalf("Attachments = %v, want 1 — a disconnect right after the attachment link commits must not "+
|
|
"broadcast a blank message bubble", result.Attachments)
|
|
}
|
|
}
|