owpengram-server/internal/rpc/receives_updates_mark_test.go
Astra 206bde18e0 channels: give kicked/banned/promoted/transferred users a real qts so their client applies it
updateChannelParticipant carries the account's qts per the MTProto spec, but
the server always sent Qts: 0, so real clients silently discarded it as a
stale duplicate -- the banned/kicked user's channel never vanished locally
and no correct "removed by admin" message showed, even though the update was
delivered successfully at the transport layer.

Add a durable per-device qts queue (channel_participant_event_queue) sharing
its qts number space with the existing secret-chat queue (one qts sequence
per device, per spec), and use it to stamp a correct, monotonically
increasing qts on the update for every device of the affected user -- for
channel bans/kicks, admin promotion/demotion, and ownership transfer. A
device offline when it happened can now recover the event via
updates.getDifference instead of missing it permanently.
2026-09-15 15:43:52 +01:00

456 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package rpc
import (
"context"
"errors"
"reflect"
"sync"
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appsecret "telesrv/internal/app/secretchat"
"telesrv/internal/domain"
"telesrv/internal/postresponse"
"telesrv/internal/store/memory"
)
func dispatchForReceivesUpdates(t *testing.T, sessions SessionBinder, wrapWithoutUpdates, loggedIn bool) context.Context {
t.Helper()
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System)
var inner bin.Buffer
if err := (&tg.HelpGetConfigRequest{}).Encode(&inner); err != nil {
t.Fatalf("encode help.getConfig: %v", err)
}
var in bin.Buffer
if wrapWithoutUpdates {
in.PutID(tg.InvokeWithoutUpdatesRequestTypeID)
}
in.Put(inner.Buf)
ctx := postresponse.WithCallbacks(context.Background())
if loggedIn {
ctx = WithUserID(ctx, 1000000001)
}
if _, err := r.Dispatch(ctx, [8]byte{1}, 42, &in); err != nil {
t.Fatalf("dispatch: %v", err)
}
return ctx
}
// TestDispatchMarksSessionReceivesUpdates 验证已登录连接发出的裸 RPC未包
// invokeWithoutUpdates即视为 updates 接收声明。仅靠 updates.getState/getDifference
// 置位会漏掉热恢复重连的客户端:它不重建同步基线,置位永不发生时主动推送一直
// 暂存直至超时丢弃,表现为另一端消息不再实时同步。
func TestDispatchMarksSessionReceivesUpdates(t *testing.T) {
sessions := &captureSessions{}
ctx := dispatchForReceivesUpdates(t, sessions, false, true)
if sessions.snapshot().receives {
t.Fatal("plain RPC marked receivesUpdates before rpc_result delivery")
}
postresponse.Run(ctx)
sessions.mu.Lock()
defer sessions.mu.Unlock()
if !sessions.receives {
t.Fatal("plain RPC from logged-in session must mark receivesUpdates")
}
if sessions.sessionID != 42 {
t.Fatalf("marked session_id = %d, want 42", sessions.sessionID)
}
if sessions.message != nil {
t.Fatalf("session readiness emitted unsolicited update %T; readiness must only open delivery", sessions.message)
}
}
type activationCaptureSessions struct {
*captureSessions
activationMu sync.Mutex
nextToken uint64
activeToken uint64
beginCalls int
grants int
endCalls int
bootstrapMu sync.Mutex
bootstrapNext uint64
bootstrapActive uint64
bootstrapBegin int
bootstrapEnd int
bootstrapSuccess int
bootstrapProbed bool
}
func (s *activationCaptureSessions) ReceivesUpdatesForAuthKey([8]byte, int64) bool {
return s.captureSessions.snapshot().receives
}
func (s *activationCaptureSessions) BeginSessionUpdatesActivation([8]byte, int64) (uint64, bool) {
s.activationMu.Lock()
defer s.activationMu.Unlock()
s.beginCalls++
if s.activeToken != 0 || s.captureSessions.snapshot().receives {
return 0, false
}
s.nextToken++
s.activeToken = s.nextToken
s.grants++
return s.activeToken, true
}
func (s *activationCaptureSessions) EndSessionUpdatesActivation(_ [8]byte, _ int64, token uint64) {
s.activationMu.Lock()
defer s.activationMu.Unlock()
s.endCalls++
if s.activeToken == token {
s.activeToken = 0
}
}
func (s *activationCaptureSessions) activationSnapshot() (begin, grants, end int, active uint64) {
s.activationMu.Lock()
defer s.activationMu.Unlock()
return s.beginCalls, s.grants, s.endCalls, s.activeToken
}
func (s *activationCaptureSessions) BeginSessionBootstrapProbe([8]byte, int64) (uint64, bool) {
s.bootstrapMu.Lock()
defer s.bootstrapMu.Unlock()
s.bootstrapBegin++
if s.bootstrapActive != 0 || s.bootstrapProbed {
return 0, false
}
s.bootstrapNext++
s.bootstrapActive = s.bootstrapNext
return s.bootstrapActive, true
}
func (s *activationCaptureSessions) EndSessionBootstrapProbe(_ [8]byte, _ int64, token uint64, success bool) {
s.bootstrapMu.Lock()
defer s.bootstrapMu.Unlock()
s.bootstrapEnd++
if s.bootstrapActive != token {
return
}
s.bootstrapActive = 0
if success {
s.bootstrapSuccess++
s.bootstrapProbed = true
}
}
func (s *activationCaptureSessions) bootstrapSnapshot() (begin, end, success int, active uint64, probed bool) {
s.bootstrapMu.Lock()
defer s.bootstrapMu.Unlock()
return s.bootstrapBegin, s.bootstrapEnd, s.bootstrapSuccess, s.bootstrapActive, s.bootstrapProbed
}
func TestDispatchCoalescesConcurrentSessionReadinessActivation(t *testing.T) {
sessions := &activationCaptureSessions{captureSessions: &captureSessions{}}
first := dispatchForReceivesUpdates(t, sessions, false, true)
second := dispatchForReceivesUpdates(t, sessions, false, true)
if begin, grants, end, active := sessions.activationSnapshot(); begin != 2 || grants != 1 || end != 0 || active == 0 {
t.Fatalf("activation before delivery = begin:%d grants:%d end:%d active:%d", begin, grants, end, active)
}
postresponse.Run(second)
if got := sessions.snapshot().receivesCalls; got != 0 {
t.Fatalf("non-owner delivery marked readiness %d times", got)
}
postresponse.Run(first)
if got := sessions.snapshot(); !got.receives || got.receivesCalls != 1 {
t.Fatalf("owner delivery readiness = receives:%v calls:%d", got.receives, got.receivesCalls)
}
if begin, grants, end, active := sessions.activationSnapshot(); begin != 2 || grants != 1 || end != 1 || active != 0 {
t.Fatalf("activation after delivery = begin:%d grants:%d end:%d active:%d", begin, grants, end, active)
}
}
func TestSessionReadinessActivationReleasedWhenCallbackRegistrationFails(t *testing.T) {
sessions := &activationCaptureSessions{captureSessions: &captureSessions{}}
r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System)
ctx := WithSessionID(WithRawAuthKeyID(context.Background(), [8]byte{9}), 99)
r.stageSessionUpdatesReadyAfterDelivery(ctx, 1000000009)
if begin, grants, end, active := sessions.activationSnapshot(); begin != 1 || grants != 1 || end != 1 || active != 0 {
t.Fatalf("activation after missing callback registry = begin:%d grants:%d end:%d active:%d", begin, grants, end, active)
}
}
func TestSuppressSessionActivationReleasesClaimButKeepsCursorWork(t *testing.T) {
sessions := &activationCaptureSessions{captureSessions: &captureSessions{}}
bootstrap := &captureBootstrapReadyStore{BootstrapUpdateJobStore: memory.NewBootstrapUpdateJobStore()}
r := New(Config{}, Deps{Sessions: sessions, BootstrapUpdates: bootstrap}, zaptest.NewLogger(t), clock.System)
ctx := WithSessionID(WithRawAuthKeyID(context.Background(), [8]byte{8}), 88)
ctx, plan := withUpdatesDeliveryPlan(ctx)
r.tryStageSessionUpdatesReady(ctx, plan, 1000000008)
r.tryStageBootstrapProbe(ctx, plan, 1000000008)
plan.stageCursor([8]byte{7}, 1000000008, domain.UpdateState{Pts: 7}, domain.UpdateStateCommitDeliveredOnly)
plan.suppressSessionActivation()
if plan.markSessionReady || plan.publishBootstrap || !plan.commitCursor {
t.Fatalf("suppressed plan = ready:%v bootstrap:%v cursor:%v", plan.markSessionReady, plan.publishBootstrap, plan.commitCursor)
}
if begin, grants, end, active := sessions.activationSnapshot(); begin != 1 || grants != 1 || end != 1 || active != 0 {
t.Fatalf("activation after suppression = begin:%d grants:%d end:%d active:%d", begin, grants, end, active)
}
if begin, end, success, active, probed := sessions.bootstrapSnapshot(); begin != 1 || end != 1 || success != 0 || active != 0 || probed {
t.Fatalf("bootstrap after suppression = begin:%d end:%d success:%d active:%d probed:%v", begin, end, success, active, probed)
}
}
func TestBootstrapProbeReleasedWhenCallbackRegistrationFails(t *testing.T) {
sessions := &activationCaptureSessions{captureSessions: &captureSessions{}}
bootstrap := &captureBootstrapReadyStore{BootstrapUpdateJobStore: memory.NewBootstrapUpdateJobStore()}
r := New(Config{}, Deps{Sessions: sessions, BootstrapUpdates: bootstrap}, zaptest.NewLogger(t), clock.System)
ctx := WithAuthKeyID(context.Background(), [8]byte{7})
ctx = WithRawAuthKeyID(ctx, [8]byte{8})
ctx = WithSessionID(ctx, 88)
r.stageUpdatesBaselineAfterDelivery(ctx, 1000000008, nil, 0, nil, true)
if bootstrap.readyCalls != 0 {
t.Fatalf("bootstrap store called before an attached delivery callback: %d", bootstrap.readyCalls)
}
if begin, end, success, active, probed := sessions.bootstrapSnapshot(); begin != 1 || end != 1 || success != 0 || active != 0 || probed {
t.Fatalf("bootstrap after missing registry = begin:%d end:%d success:%d active:%d probed:%v", begin, end, success, active, probed)
}
}
// TestDispatchSkipsReceivesUpdatesForInvokeWithoutUpdates 验证 invokeWithoutUpdates
// 包装的请求media/temp 连接)不会把该 session 标记为 updates 接收者。
func TestDispatchSkipsReceivesUpdatesForInvokeWithoutUpdates(t *testing.T) {
sessions := &captureSessions{}
ctx := dispatchForReceivesUpdates(t, sessions, true, true)
postresponse.Run(ctx)
sessions.mu.Lock()
defer sessions.mu.Unlock()
if sessions.receives {
t.Fatal("invokeWithoutUpdates-wrapped RPC must not mark receivesUpdates")
}
}
type captureBootstrapReadyStore struct {
*memory.BootstrapUpdateJobStore
readyCalls int
readyErr error
}
func (s *captureBootstrapReadyStore) MarkReadyForSession(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64) (int, error) {
s.readyCalls++
if s.readyErr != nil {
err := s.readyErr
s.readyErr = nil
return 0, err
}
return s.BootstrapUpdateJobStore.MarkReadyForSession(ctx, userID, authKeyID, sessionID)
}
func TestBootstrapReadinessProbeIsOneShotAndRetriesFailure(t *testing.T) {
const userID int64 = 1000000199
rawAuthKeyID := [8]byte{19}
businessAuthKeyID := [8]byte{29}
const sessionID int64 = 199
sessions := &activationCaptureSessions{captureSessions: &captureSessions{}}
bootstrap := &captureBootstrapReadyStore{
BootstrapUpdateJobStore: memory.NewBootstrapUpdateJobStore(),
readyErr: errors.New("temporary bootstrap lookup failure"),
}
r := New(Config{}, Deps{Sessions: sessions, BootstrapUpdates: bootstrap}, zaptest.NewLogger(t), clock.System)
baseline := func() {
ctx := postresponse.WithCallbacks(context.Background())
ctx = WithAuthKeyID(ctx, businessAuthKeyID)
ctx = WithRawAuthKeyID(ctx, rawAuthKeyID)
ctx = WithSessionID(ctx, sessionID)
ctx = WithUserID(ctx, userID)
r.stageUpdatesBaselineAfterDelivery(ctx, userID, nil, 0, nil, true)
postresponse.Run(ctx)
}
baseline() // authoritative query fails: release the physical-generation claim
baseline() // successful zero-row result: complete the one-shot
baseline() // no third store call on the same physical generation
if bootstrap.readyCalls != 2 {
t.Fatalf("bootstrap readiness calls = %d, want failure + one successful retry", bootstrap.readyCalls)
}
begin, end, success, active, probed := sessions.bootstrapSnapshot()
if begin != 3 || end != 2 || success != 1 || active != 0 || !probed {
t.Fatalf("bootstrap probe = begin:%d end:%d success:%d active:%d probed:%v", begin, end, success, active, probed)
}
}
func TestInvokeWithoutUpdatesBaselineCommitsResultAndSecretEventsWithoutSubscribing(t *testing.T) {
const userID int64 = 1000000201
authKeyID := [8]byte{21}
deviceKey := businessAuthKeyInt64(authKeyID)
queue := memory.NewEncryptedQueueStore()
secret := appsecret.NewService(memory.NewSecretChatStore(), queue, queue)
eventID, err := queue.AppendStateEvent(context.Background(), domain.EncryptedStateEvent{
TargetUserID: userID,
ChatID: 77,
Type: domain.EncryptedStateEventRead,
MaxDate: 1700000200,
Date: 1700000201,
})
if err != nil {
t.Fatalf("append state event: %v", err)
}
sessions := &captureSessions{}
updates := &captureUpdates{state: domain.UpdateState{Pts: 4, Date: 1700000201}}
bootstrap := &captureBootstrapReadyStore{BootstrapUpdateJobStore: memory.NewBootstrapUpdateJobStore()}
r := New(Config{}, Deps{
Sessions: sessions, Updates: updates, SecretChats: secret, BootstrapUpdates: bootstrap,
}, zaptest.NewLogger(t), clock.System)
var inner bin.Buffer
if err := (&tg.UpdatesGetDifferenceRequest{Pts: 4, Date: 1700000201}).Encode(&inner); err != nil {
t.Fatalf("encode getDifference: %v", err)
}
var wrapped bin.Buffer
wrapped.PutID(tg.InvokeWithoutUpdatesRequestTypeID)
wrapped.Put(inner.Raw())
ctx := postresponse.WithCallbacks(WithAuthKeyID(WithSessionID(WithUserID(context.Background(), userID), 202), authKeyID))
if _, err := r.Dispatch(ctx, authKeyID, 202, &wrapped); err != nil {
t.Fatalf("dispatch wrapped baseline: %v", err)
}
if updates.commitCalls != 0 || sessions.snapshot().receivesCalls != 0 {
t.Fatalf("pre-delivery effects = commits:%d ready_calls:%d", updates.commitCalls, sessions.snapshot().receivesCalls)
}
pending, err := queue.ListUndeliveredStateEvents(context.Background(), userID, deviceKey, 10)
if err != nil || len(pending) != 1 || pending[0].ID != eventID {
t.Fatalf("pending before delivery = %+v err=%v", pending, err)
}
postresponse.Run(ctx)
if updates.commitCalls != 1 || updates.committedState.Pts != 4 {
t.Fatalf("delivered cursor commit = calls:%d state:%+v", updates.commitCalls, updates.committedState)
}
if got := sessions.snapshot(); got.receives || got.receivesCalls != 0 {
t.Fatalf("invokeWithoutUpdates subscribed session: receives=%v calls=%d", got.receives, got.receivesCalls)
}
if bootstrap.readyCalls != 0 {
t.Fatalf("invokeWithoutUpdates released bootstrap %d times", bootstrap.readyCalls)
}
pending, err = queue.ListUndeliveredStateEvents(context.Background(), userID, deviceKey, 10)
if err != nil || len(pending) != 0 {
t.Fatalf("secret events after delivered wrapped baseline = %+v err=%v", pending, err)
}
}
// TestDispatchSkipsReceivesUpdatesWhenLoggedOut 验证未登录连接的 RPC 不置位。
func TestDispatchSkipsReceivesUpdatesWhenLoggedOut(t *testing.T) {
sessions := &captureSessions{}
ctx := dispatchForReceivesUpdates(t, sessions, false, false)
postresponse.Run(ctx)
sessions.mu.Lock()
defer sessions.mu.Unlock()
if sessions.receives {
t.Fatal("RPC without bound user must not mark receivesUpdates")
}
}
type fifoFlushCaptureSessions struct {
*captureSessions
flushMu sync.Mutex
pending []int
flushed []int
}
func (s *fifoFlushCaptureSessions) SetReceivesUpdatesForAuthKey(rawAuthKeyID [8]byte, sessionID int64, receives bool) {
if receives {
s.flushMu.Lock()
s.flushed = append(s.flushed, s.pending...)
s.pending = nil
s.flushMu.Unlock()
}
s.captureSessions.SetReceivesUpdatesForAuthKey(rawAuthKeyID, sessionID, receives)
}
func (s *fifoFlushCaptureSessions) flushedSnapshot() []int {
s.flushMu.Lock()
defer s.flushMu.Unlock()
return append([]int(nil), s.flushed...)
}
// TestDispatchDefersMembershipAndFIFOFlushUntilPostResponse pins the complete
// readiness barrier: channel membership and pending updates remain untouched
// while the rpc_result is only prepared, then the delivery hook rebuilds
// membership before SetReceivesUpdates drains the original FIFO order.
func TestDispatchDefersMembershipAndFIFOFlushUntilPostResponse(t *testing.T) {
const (
userID = int64(1000000111)
sessionID = int64(87)
)
channelSvc := appchannels.NewService(memory.NewChannelStore())
created, err := channelSvc.CreateMegagroupFromCreateChat(context.Background(), userID, domain.CreateChannelRequest{
Title: "delivery barrier",
Date: 1700000000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
sessions := &fifoFlushCaptureSessions{
captureSessions: &captureSessions{},
pending: []int{11, 22, 33},
}
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Sessions: sessions,
Channels: channelSvc,
}, zaptest.NewLogger(t), clock.System)
var in bin.Buffer
if err := (&tg.HelpGetConfigRequest{}).Encode(&in); err != nil {
t.Fatalf("encode help.getConfig: %v", err)
}
ctx := postresponse.WithCallbacks(WithUserID(context.Background(), userID))
if _, err := r.Dispatch(ctx, [8]byte{7}, sessionID, &in); err != nil {
t.Fatalf("dispatch: %v", err)
}
if got := sessions.flushedSnapshot(); len(got) != 0 {
t.Fatalf("pending flushed before result delivery: %v", got)
}
if got := sessions.onlineChannelMemberIDs(created.Channel.ID); len(got) != 0 {
t.Fatalf("membership synced before result delivery: %v", got)
}
if sessions.snapshot().receives {
t.Fatal("session ready before result delivery")
}
postresponse.Run(ctx)
if got, want := sessions.flushedSnapshot(), []int{11, 22, 33}; !reflect.DeepEqual(got, want) {
t.Fatalf("FIFO flush after result delivery = %v, want %v", got, want)
}
if got := sessions.onlineChannelMemberIDs(created.Channel.ID); !reflect.DeepEqual(got, []int64{userID}) {
t.Fatalf("membership after result delivery = %v, want [%d]", got, userID)
}
if !sessions.snapshot().receives {
t.Fatal("session not ready after result delivery")
}
}
type failingCurrentStateUpdates struct{ *captureUpdates }
func (s *failingCurrentStateUpdates) CurrentState(context.Context, int64) (domain.UpdateState, error) {
return domain.UpdateState{}, errors.New("current state failed")
}
func TestFailedRPCDoesNotRegisterSessionReadyPostResponse(t *testing.T) {
sessions := &captureSessions{}
r := New(Config{}, Deps{
Sessions: sessions,
Updates: &failingCurrentStateUpdates{captureUpdates: &captureUpdates{}},
}, zaptest.NewLogger(t), clock.System)
var in bin.Buffer
if err := (&tg.UpdatesGetStateRequest{}).Encode(&in); err != nil {
t.Fatalf("encode updates.getState: %v", err)
}
ctx := postresponse.WithCallbacks(WithUserID(context.Background(), 1000000123))
if _, err := r.Dispatch(ctx, [8]byte{8}, 91, &in); err == nil {
t.Fatal("updates.getState unexpectedly succeeded")
}
postresponse.Run(ctx)
if sessions.snapshot().receives {
t.Fatal("failed RPC marked session ready")
}
}