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>
204 lines
7.5 KiB
Go
204 lines
7.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/permissions"
|
|
)
|
|
|
|
// seedReactedMessage posts a message in channel 10 and has each of userIDs
|
|
// react to it with emoji, returning the message id.
|
|
func seedReactedMessage(t *testing.T, svc *MessageService, database *db.DB, emoji string, userIDs ...int64) int64 {
|
|
t.Helper()
|
|
msgID, err := database.CreateMessage(context.Background(), 10, 1, "react to me", nil)
|
|
if err != nil {
|
|
t.Fatalf("CreateMessage: %v", err)
|
|
}
|
|
for _, uid := range userIDs {
|
|
if _, err := svc.AddReaction(context.Background(), uid, msgID, emoji); err != nil {
|
|
t.Fatalf("AddReaction(%d): %v", uid, err)
|
|
}
|
|
}
|
|
return msgID
|
|
}
|
|
|
|
func TestGetReactionUsers_ReturnsReactors(t *testing.T) {
|
|
svc, database := newTestMessageService(t)
|
|
seedUser(t, database, &db.User{ID: 2, Username: "bob"})
|
|
seedUserRole(t, database, 2, permissions.MemberRoleID)
|
|
msgID := seedReactedMessage(t, svc, database, "👍", 1, 2)
|
|
|
|
users, err := svc.GetReactionUsers(context.Background(), 1, 10, msgID, "👍")
|
|
if err != nil {
|
|
t.Fatalf("GetReactionUsers: %v", err)
|
|
}
|
|
if len(users) != 2 {
|
|
t.Fatalf("len(users) = %d, want 2 (%+v)", len(users), users)
|
|
}
|
|
if users[0].Username != "alice" || users[1].Username != "bob" {
|
|
t.Errorf("usernames = [%s %s], want [alice bob]", users[0].Username, users[1].Username)
|
|
}
|
|
}
|
|
|
|
// An emoji nobody used is an empty list, never nil — the handler serialises it
|
|
// straight to JSON and `null` is not a list the client can iterate.
|
|
func TestGetReactionUsers_EmptyIsNonNil(t *testing.T) {
|
|
svc, database := newTestMessageService(t)
|
|
msgID := seedReactedMessage(t, svc, database, "👍", 1)
|
|
|
|
users, err := svc.GetReactionUsers(context.Background(), 1, 10, msgID, "🎉")
|
|
if err != nil {
|
|
t.Fatalf("GetReactionUsers: %v", err)
|
|
}
|
|
if users == nil {
|
|
t.Fatal("users = nil, want an empty slice")
|
|
}
|
|
if len(users) != 0 {
|
|
t.Errorf("len(users) = %d, want 0", len(users))
|
|
}
|
|
}
|
|
|
|
// Reading the reactor list is gated by the same READ_MESSAGES check as reading
|
|
// the channel's history: a reaction pill must not leak who is in a channel the
|
|
// caller cannot see.
|
|
func TestGetReactionUsers_ForbiddenWithoutReadPermission(t *testing.T) {
|
|
svc, database := newTestMessageService(t)
|
|
msgID := seedReactedMessage(t, svc, database, "👍", 1)
|
|
|
|
seedRole(t, database, &db.Role{ID: 90, Name: "outsider", Permissions: 0, Position: 1})
|
|
seedUser(t, database, &db.User{ID: 3, Username: "outsider"})
|
|
seedUserRole(t, database, 3, 90)
|
|
|
|
_, err := svc.GetReactionUsers(context.Background(), 3, 10, msgID, "👍")
|
|
if !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("err = %v, want ErrForbidden", err)
|
|
}
|
|
}
|
|
|
|
// The channel in the URL is what the permission check ran against, so a message
|
|
// that lives elsewhere must not be answered from that check.
|
|
func TestGetReactionUsers_MessageInAnotherChannelIsNotFound(t *testing.T) {
|
|
svc, database := newTestMessageService(t)
|
|
seedChannel(t, database, &db.Channel{ID: 11, Name: "other", Type: "text"})
|
|
msgID := seedReactedMessage(t, svc, database, "👍", 1)
|
|
|
|
_, err := svc.GetReactionUsers(context.Background(), 1, 11, msgID, "👍")
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("err = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestGetReactionUsers_RejectsBadInput(t *testing.T) {
|
|
svc, database := newTestMessageService(t)
|
|
msgID := seedReactedMessage(t, svc, database, "👍", 1)
|
|
|
|
tests := []struct {
|
|
name string
|
|
msgID int64
|
|
emoji string
|
|
wantErr error
|
|
}{
|
|
{"zero message id", 0, "👍", ErrBadRequest},
|
|
{"negative message id", -5, "👍", ErrBadRequest},
|
|
{"empty emoji", msgID, "", ErrBadRequest},
|
|
// The cap is derived from MaxShortcodeLen + 2, so a bare ":wave:"-shaped
|
|
// string at that exact length must pass and one rune more must not.
|
|
{"overlong emoji", msgID, strings.Repeat("a", MaxShortcodeLen+3), ErrBadRequest},
|
|
{"control character", msgID, "a\x01b", ErrBadRequest},
|
|
{"unknown message", 999999, "👍", ErrNotFound},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := svc.GetReactionUsers(context.Background(), 1, 10, tt.msgID, tt.emoji)
|
|
if !errors.Is(err, tt.wantErr) {
|
|
t.Fatalf("err = %v, want %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A DM the caller is not a participant of is reported as not-found (matching
|
|
// requireChannelRead), so its existence stays hidden.
|
|
func TestGetReactionUsers_ForeignDMIsNotFound(t *testing.T) {
|
|
svc, database := newTestMessageService(t)
|
|
seedUser(t, database, &db.User{ID: 2, Username: "bob"})
|
|
seedUser(t, database, &db.User{ID: 3, Username: "carol"})
|
|
seedUserRole(t, database, 2, permissions.MemberRoleID)
|
|
seedUserRole(t, database, 3, permissions.MemberRoleID)
|
|
|
|
ch, _, err := database.GetOrCreateDMChannel(context.Background(), 2, 3)
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreateDMChannel: %v", err)
|
|
}
|
|
msgID, err := database.CreateMessage(context.Background(), ch.ID, 2, "psst", nil)
|
|
if err != nil {
|
|
t.Fatalf("CreateMessage: %v", err)
|
|
}
|
|
if _, err := svc.AddReaction(context.Background(), 3, msgID, "👍"); err != nil {
|
|
t.Fatalf("AddReaction: %v", err)
|
|
}
|
|
|
|
if _, err := svc.GetReactionUsers(context.Background(), 1, ch.ID, msgID, "👍"); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("err = %v, want ErrNotFound", err)
|
|
}
|
|
// A participant does get the list.
|
|
users, err := svc.GetReactionUsers(context.Background(), 2, ch.ID, msgID, "👍")
|
|
if err != nil {
|
|
t.Fatalf("GetReactionUsers(participant): %v", err)
|
|
}
|
|
if len(users) != 1 || users[0].Username != "carol" {
|
|
t.Errorf("users = %+v, want [carol]", users)
|
|
}
|
|
}
|
|
|
|
// A soft-deleted message must not leak its reactor list. Its siblings in the
|
|
// same file/package already refuse a deleted message: handleReaction (this
|
|
// file) and GetMessagesAround (message_query.go) both check msg.Deleted, but
|
|
// GetReactionUsers had no such guard, so a tombstoned message's reactions
|
|
// stayed forever fetchable by direct URL even though the client no longer
|
|
// renders the message at all.
|
|
func TestGetReactionUsers_DeletedMessageIsNotFound(t *testing.T) {
|
|
svc, database := newTestMessageService(t)
|
|
msgID := seedReactedMessage(t, svc, database, "👍", 1)
|
|
|
|
if err := database.DeleteMessage(context.Background(), msgID, 1, false); err != nil {
|
|
t.Fatalf("DeleteMessage: %v", err)
|
|
}
|
|
|
|
_, err := svc.GetReactionUsers(context.Background(), 1, 10, msgID, "👍")
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("err = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
// A custom emoji is reacted with as its ":shortcode:" literal, so the longest
|
|
// shortcode the emoji service will accept has to fit inside the reaction length
|
|
// cap. Before the cap was derived from MaxShortcodeLen, a 31- or 32-character
|
|
// shortcode produced an emoji that rendered in messages but was silently
|
|
// refused as a reaction.
|
|
func TestReaction_AcceptsLongestCustomShortcode(t *testing.T) {
|
|
svc, database := newTestMessageService(t)
|
|
msgID, err := database.CreateMessage(context.Background(), 10, 1, "react to me", nil)
|
|
if err != nil {
|
|
t.Fatalf("CreateMessage: %v", err)
|
|
}
|
|
|
|
longest := ":" + strings.Repeat("a", MaxShortcodeLen) + ":"
|
|
if _, vErr := ValidateShortcode(longest); vErr != nil {
|
|
t.Fatalf("the emoji service would refuse %q: %v", longest, vErr)
|
|
}
|
|
if _, rErr := svc.AddReaction(context.Background(), 1, msgID, longest); rErr != nil {
|
|
t.Fatalf("AddReaction(longest shortcode): %v", rErr)
|
|
}
|
|
|
|
// One rune past it is still refused.
|
|
tooLong := ":" + strings.Repeat("a", MaxShortcodeLen+1) + ":"
|
|
if _, rErr := svc.AddReaction(context.Background(), 1, msgID, tooLong); !errors.Is(rErr, ErrBadRequest) {
|
|
t.Fatalf("AddReaction(one past the cap) = %v, want ErrBadRequest", rErr)
|
|
}
|
|
}
|