fix: sync protocol and discussion stability fixes

This commit is contained in:
A 2026-07-12 07:05:02 +08:00
parent 9f73dc20da
commit aa21bd04e1
43 changed files with 7258 additions and 503 deletions

View file

@ -0,0 +1,119 @@
package memory
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
func TestLinkedDiscussionGuestAuthorizationFailsClosedMemory(t *testing.T) {
ctx := context.Background()
channels := NewChannelStore()
const (
ownerID int64 = 9101
outsiderID int64 = 9102
)
privateGroup, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: ownerID,
Title: "unlinked private group",
Megagroup: true,
Date: 1700009100,
})
if err != nil {
t.Fatalf("create private group: %v", err)
}
if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: outsiderID,
ChannelID: privateGroup.Channel.ID,
RandomID: 91001,
Message: "must be rejected",
Date: 1700009101,
}); !errors.Is(err, domain.ErrChannelPrivate) {
t.Fatalf("unlinked outsider send err = %v, want ErrChannelPrivate", err)
}
if _, err := channels.ResolveDiscussionReadTarget(ctx, outsiderID, privateGroup.Channel.ID, privateGroup.Message.ID, privateGroup.Message.ID); !errors.Is(err, domain.ErrChannelPrivate) {
t.Fatalf("unlinked outsider discussion read err = %v, want ErrChannelPrivate", err)
}
if got := len(channels.messages[privateGroup.Channel.ID]); got != 1 {
t.Fatalf("unlinked outsider changed history length = %d, want create message only", got)
}
}
func TestLinkedDiscussionGuestRepliesKeepViewerProjectionMemory(t *testing.T) {
ctx := context.Background()
channels := NewChannelStore()
const (
ownerID int64 = 9201
subscriberID int64 = 9202
)
broadcast, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: ownerID,
Title: "linked source",
Broadcast: true,
Date: 1700009200,
})
if err != nil {
t.Fatalf("create broadcast: %v", err)
}
group, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: ownerID,
Title: "linked discussion",
Megagroup: true,
Date: 1700009201,
})
if err != nil {
t.Fatalf("create group: %v", err)
}
if _, err := channels.SetDiscussionGroup(ctx, ownerID, broadcast.Channel.ID, group.Channel.ID); err != nil {
t.Fatalf("set discussion group: %v", err)
}
if _, err := channels.InviteToChannel(ctx, broadcast.Channel.ID, ownerID, []int64{subscriberID}, 1700009202); err != nil {
t.Fatalf("invite broadcast subscriber: %v", err)
}
post, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: ownerID, ChannelID: broadcast.Channel.ID, RandomID: 92001,
Message: "post", Date: 1700009203,
})
if err != nil || post.Discussion == nil {
t.Fatalf("send linked post = %+v err %v", post, err)
}
assertGuest := func(name string, history domain.ChannelHistory, err error) {
t.Helper()
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if history.Channel.ID != group.Channel.ID || history.Self.ChannelID != group.Channel.ID || history.Self.UserID != subscriberID || !history.Self.Guest || history.Self.Status != domain.ChannelMemberLeft {
t.Fatalf("%s = channel %+v self %+v, want linked left guest", name, history.Channel, history.Self)
}
}
direct, err := channels.ListChannelReplies(ctx, subscriberID, domain.ChannelRepliesFilter{
ChannelID: group.Channel.ID, RootMessageID: post.Discussion.Message.ID, Limit: 20,
})
assertGuest("direct replies", direct, err)
viaBroadcast, err := channels.ListChannelReplies(ctx, subscriberID, domain.ChannelRepliesFilter{
ChannelID: broadcast.Channel.ID, RootMessageID: post.Message.ID, Limit: 20,
})
assertGuest("broadcast replies", viaBroadcast, err)
if _, err := channels.EditChannelBanned(ctx, domain.EditChannelBannedRequest{
UserID: ownerID, ChannelID: group.Channel.ID,
Participant: domain.Peer{Type: domain.PeerTypeUser, ID: subscriberID},
BannedRights: domain.ChannelBannedRights{ViewMessages: true, UntilDate: 2147483647},
Date: 1700009204,
}); err != nil {
t.Fatalf("ban linked subscriber from target: %v", err)
}
if _, err := channels.ListChannelReplies(ctx, subscriberID, domain.ChannelRepliesFilter{
ChannelID: group.Channel.ID, RootMessageID: post.Discussion.Message.ID, Limit: 20,
}); !errors.Is(err, domain.ErrChannelUserBanned) {
t.Fatalf("target-banned direct replies err = %v, want ErrChannelUserBanned", err)
}
if _, err := channels.ListChannelReplies(ctx, subscriberID, domain.ChannelRepliesFilter{
ChannelID: broadcast.Channel.ID, RootMessageID: post.Message.ID, Limit: 20,
}); !errors.Is(err, domain.ErrChannelUserBanned) {
t.Fatalf("target-banned broadcast replies err = %v, want ErrChannelUserBanned", err)
}
}

View file

@ -12,7 +12,7 @@ import (
func (s *ChannelStore) GetParticipants(_ context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
s.mu.RLock()
defer s.mu.RUnlock()
channel, viewer, err := s.channelAndMemberOrLinkedGuestLocked(viewerUserID, channelID)
channel, viewer, _, err := s.channelForViewerLocked(viewerUserID, channelID)
if err != nil {
return domain.ChannelParticipantList{}, err
}
@ -75,17 +75,16 @@ func (s *ChannelStore) GetParticipants(_ context.Context, viewerUserID, channelI
func (s *ChannelStore) GetParticipant(_ context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) {
s.mu.RLock()
defer s.mu.RUnlock()
channel, viewer, err := s.channelAndMemberOrLinkedGuestLocked(viewerUserID, channelID)
_, viewer, _, err := s.channelForViewerLocked(viewerUserID, channelID)
if err != nil {
return domain.ChannelMember{}, err
}
if viewerUserID == participantUserID && viewer.Guest {
return viewer, nil
return domain.ChannelMember{}, domain.ErrUserNotParticipant
}
_ = channel
member, ok := s.members[channelID][participantUserID]
if !ok {
return domain.ChannelMember{}, domain.ErrChannelPrivate
if !ok || (participantUserID == viewerUserID && member.Status == domain.ChannelMemberLeft) {
return domain.ChannelMember{}, domain.ErrUserNotParticipant
}
return member, nil
}
@ -868,7 +867,7 @@ func (s *ChannelStore) FilterActiveChannelMemberIDs(_ context.Context, channelID
func (s *ChannelStore) ListActiveChannelMembers(_ context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error) {
s.mu.RLock()
defer s.mu.RUnlock()
channel, viewer, err := s.channelAndMemberOrLinkedGuestLocked(viewerUserID, channelID)
channel, viewer, _, err := s.channelForViewerLocked(viewerUserID, channelID)
if err != nil {
return domain.Channel{}, domain.ChannelMember{}, nil, err
}

View file

@ -443,7 +443,16 @@ func (s *ChannelStore) ResolveDiscussionReadTarget(_ context.Context, viewerUser
if targetChannelID != sourceChannelID {
_, targetMember, err = s.channelAndMemberLocked(viewerUserID, targetChannelID)
if errors.Is(err, domain.ErrChannelPrivate) {
targetMember, _, err = s.linkedDiscussionGuestLocked(viewerUserID, s.channels[targetChannelID])
guestMember, allowed, guestErr := s.linkedDiscussionGuestLocked(viewerUserID, s.channels[targetChannelID])
switch {
case guestErr != nil:
err = guestErr
case allowed:
targetMember = guestMember
err = nil
default:
err = domain.ErrChannelPrivate
}
}
}
if err != nil {

View file

@ -41,10 +41,18 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID)
if errors.Is(err, domain.ErrChannelPrivate) {
if candidate, ok := s.channels[req.ChannelID]; ok && !candidate.Deleted {
var guest bool
member, guest, err = s.linkedDiscussionGuestLocked(req.UserID, candidate)
if guest {
guestMember, guest, guestErr := s.linkedDiscussionGuestLocked(req.UserID, candidate)
switch {
case guestErr != nil:
err = guestErr
case guest:
channel = candidate
member = guestMember
err = nil
default:
// A clean "not a linked guest" result is not authorization.
// Preserve the original private-member error and fail closed.
err = domain.ErrChannelPrivate
}
}
}

View file

@ -513,23 +513,19 @@ func (s *ChannelStore) ListChannelReplies(_ context.Context, viewerUserID int64,
extraChannels := []domain.Channel(nil)
if source.Broadcast {
if root.Discussion == nil || root.Discussion.ChannelID == 0 || root.Discussion.MessageID == 0 {
return domain.ChannelHistory{Channel: source, Count: 0}, nil
return domain.ChannelHistory{Channel: source, Self: member, Count: 0}, nil
}
linked, ok := s.channels[root.Discussion.ChannelID]
if !ok || linked.Deleted {
return domain.ChannelHistory{Channel: source, Count: 0}, nil
linked, linkedMember, linkedErr := s.channelAndMemberOrLinkedGuestLocked(viewerUserID, root.Discussion.ChannelID)
if linkedErr != nil {
return domain.ChannelHistory{}, linkedErr
}
targetChannel = linked
targetMember = linkedMember
rootID = root.Discussion.MessageID
if linkedMember, ok := s.members[linked.ID][viewerUserID]; ok {
targetMember = linkedMember
} else {
targetMember = domain.ChannelMember{}
}
extraChannels = append(extraChannels, source)
}
if targetRoot, ok := s.findMessageLocked(targetChannel.ID, rootID); !ok || targetRoot.Deleted {
return domain.ChannelHistory{Channel: targetChannel, Channels: extraChannels, Count: 0}, nil
return domain.ChannelHistory{Channel: targetChannel, Self: targetMember, Channels: extraChannels, Count: 0}, nil
}
limit := filter.Limit
if limit <= 0 || limit > domain.MaxChannelRepliesLimit {
@ -570,7 +566,7 @@ func (s *ChannelStore) ListChannelReplies(_ context.Context, viewerUserID int64,
topics = append(topics, cloneChannelForumTopic(topic))
}
}
return domain.ChannelHistory{Channel: targetChannel, Channels: extraChannels, Topics: topics, Messages: out, Count: len(base)}, nil
return domain.ChannelHistory{Channel: targetChannel, Self: targetMember, Channels: extraChannels, Topics: topics, Messages: out, Count: len(base)}, nil
}
func (s *ChannelStore) populateChannelMessageRepliesLocked(viewerUserID, channelID int64, messages []domain.ChannelMessage) {