package rpc import ( "context" "errors" "fmt" "testing" "time" "github.com/iamxvbaba/td/bin" "github.com/iamxvbaba/td/clock" "github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tgerr" "go.uber.org/zap/zaptest" "github.com/iamxvbaba/td/tlprofile" "telesrv/internal/domain" ) type frozenGateFreezeProvider struct { freeze domain.AccountFreeze found bool err error calls int items map[int64]domain.AccountFreeze } func (p *frozenGateFreezeProvider) AccountFreeze(_ context.Context, userID int64) (domain.AccountFreeze, bool, error) { p.calls++ if p.items != nil { freeze, found := p.items[userID] return freeze, found, p.err } return p.freeze, p.found, p.err } type frozenGateChannels struct { ChannelsService views map[int64]domain.ChannelView err error } func (s frozenGateChannels) ResolveChannel(_ context.Context, _ int64, channelID int64) (domain.ChannelView, error) { if s.err != nil { return domain.ChannelView{}, s.err } view, ok := s.views[channelID] if !ok { return domain.ChannelView{}, domain.ErrChannelInvalid } return view, nil } func frozenGateActiveState(userID int64) domain.AccountFreeze { since := time.Unix(1_700_000_000, 0).UTC() return domain.AccountFreeze{ UserID: userID, Frozen: true, Since: since, Until: since.Add(7 * 24 * time.Hour), AppealURL: "https://example.test/appeal", } } func TestFrozenMethodGateIsReadOnlyAndFailsClosed(t *testing.T) { tests := map[string]bool{ "help.getAppConfig": false, "messages.getHistory": false, "messages.searchGlobal": false, "contacts.resolveUsername": false, "payments.checkCanSendGift": false, "messages.readHistory": false, "messages.readDiscussion": false, "stats.loadAsyncGraph": false, "stories.incrementStoryViews": false, "account.updateDeviceLocked": false, "account.updateStatus": false, "account.deleteAccount": false, "auth.logOut": false, "phone.acceptCall": false, "phone.confirmCall": false, "phone.discardCall": false, "phone.receivedCall": false, "phone.saveCallDebug": false, "phone.sendSignalingData": false, "phone.setCallRating": false, "messages.sendMessage": true, "messages.editMessage": true, "messages.deleteHistory": true, "messages.forwardMessages": true, "messages.sendReaction": true, "channels.joinChannel": true, "channels.searchPosts": true, "contacts.importContacts": true, "account.saveAutoDownloadSettings": true, "phone.requestCall": true, "phone.joinGroupCall": true, "future.performNewMutation": true, } for method, want := range tests { t.Run(method, func(t *testing.T) { if got := frozenMethodRequiresWriteGate(method); got != want { t.Fatalf("frozenMethodRequiresWriteGate(%q) = %v, want %v", method, got, want) } }) } } func TestFrozenPrivateCallLifecyclePassesExactLayerGate(t *testing.T) { const frozenUserID = int64(1001) for _, profile := range []tlprofile.Profile{ tlprofile.Profile225, tlprofile.Profile226, tlprofile.Profile227, tlprofile.Profile228, } { t.Run(fmt.Sprintf("layer_%d", profile), func(t *testing.T) { provider := &frozenGateFreezeProvider{freeze: frozenGateActiveState(frozenUserID), found: true} router := New( Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{AccountFreeze: provider}, zaptest.NewLogger(t), clock.System, ) body := encodeExactLayerRPC(t, profile, &tg.PhoneAcceptCallRequest{ Peer: tg.InputPhoneCall{ID: 1, AccessHash: 2}, GB: make([]byte, 256), Protocol: phoneTestProtocol(), }) admitted, err := router.AdmitLayer(profile, &body, tlprofile.Limits{}) if err != nil { t.Fatal(err) } _, method, err := router.DispatchAdmitted(WithUserID(context.Background(), frozenUserID), [8]byte{1}, 10, 0, 1, admitted) if method != "phone.acceptCall" || !tgerr.Is(err, "NOT_IMPLEMENTED") { t.Fatalf("DispatchAdmitted = method:%q err:%v, want handler NOT_IMPLEMENTED", method, err) } if provider.calls != 0 { t.Fatalf("freeze provider calls = %d, lifecycle method should bypass write gate", provider.calls) } }) } } func TestFrozenPrivateCallLifecyclePassesLegacyGate(t *testing.T) { const frozenUserID = int64(1001) provider := &frozenGateFreezeProvider{freeze: frozenGateActiveState(frozenUserID), found: true} router := New( Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{AccountFreeze: provider}, zaptest.NewLogger(t), clock.System, ) request := &tg.PhoneAcceptCallRequest{ Peer: tg.InputPhoneCall{ID: 1, AccessHash: 2}, GB: make([]byte, 256), Protocol: phoneTestProtocol(), } var body bin.Buffer if err := request.Encode(&body); err != nil { t.Fatal(err) } if _, err := router.Dispatch(WithUserID(context.Background(), frozenUserID), [8]byte{1}, 10, &body); !tgerr.Is(err, "NOT_IMPLEMENTED") { t.Fatalf("legacy Dispatch err = %v, want handler NOT_IMPLEMENTED", err) } if provider.calls != 0 { t.Fatalf("freeze provider calls = %d, lifecycle method should bypass write gate", provider.calls) } } func TestFrozenCalleeCompletesPrivateCallLifecycle(t *testing.T) { f := newPhoneFixture(t, stubPrivacy{}) provider := &frozenGateFreezeProvider{items: map[int64]domain.AccountFreeze{ f.callee.ID: frozenGateActiveState(f.callee.ID), }} f.router.deps.AccountFreeze = provider ga, gaHash, gb := phoneTestKeys() requested, err := f.router.onPhoneRequestCall(f.callerCtx(), &tg.PhoneRequestCallRequest{ UserID: inputUser(f.callee), RandomID: 7001, GAHash: gaHash, Protocol: phoneTestProtocol(), }) if err != nil { t.Fatalf("request call to frozen callee: %v", err) } waiting, ok := requested.PhoneCall.(*tg.PhoneCallWaiting) if !ok { t.Fatalf("request result = %T, want PhoneCallWaiting", requested.PhoneCall) } peer := tg.InputPhoneCall{ID: waiting.ID, AccessHash: waiting.AccessHash} f.sessions.reset() dispatch := func(ctx context.Context, request bin.Object) tlprofile.Result { t.Helper() body := encodeExactLayerRPC(t, tlprofile.Profile228, request) admitted, err := f.router.AdmitLayer(tlprofile.Profile228, &body, tlprofile.Limits{}) if err != nil { t.Fatalf("admit %T: %v", request, err) } sessionID, _ := SessionIDFrom(ctx) result, method, err := f.router.DispatchAdmitted(ctx, [8]byte{1}, sessionID, 0, 1, admitted) if err != nil { t.Fatalf("dispatch %s: %v", method, err) } return result } received := dispatch(f.calleeCtx(), &tg.PhoneReceivedCallRequest{Peer: peer}) if value, ok := dispatchCanonicalValue(received).(bool); !ok || !value { t.Fatalf("receivedCall = %#v, want true", dispatchCanonicalValue(received)) } f.sessions.reset() accepted := dispatch(f.calleeCtx(), &tg.PhoneAcceptCallRequest{ Peer: peer, GB: gb, Protocol: phoneTestProtocol(), }) acceptedCall, ok := dispatchCanonicalValue(accepted).(*tg.PhonePhoneCall) if !ok { t.Fatalf("acceptCall = %T, want *tg.PhonePhoneCall", dispatchCanonicalValue(accepted)) } if _, ok := acceptedCall.PhoneCall.(*tg.PhoneCallWaiting); !ok { t.Fatalf("acceptCall phone_call = %T, want PhoneCallWaiting", acceptedCall.PhoneCall) } dispatch(f.callerCtx(), &tg.PhoneConfirmCallRequest{ Peer: peer, GA: ga, KeyFingerprint: 99, Protocol: phoneTestProtocol(), }) f.sessions.reset() signaled := dispatch(f.calleeCtx(), &tg.PhoneSendSignalingDataRequest{Peer: peer, Data: []byte("offer")}) if value, ok := dispatchCanonicalValue(signaled).(bool); !ok || !value { t.Fatalf("sendSignalingData = %#v, want true", dispatchCanonicalValue(signaled)) } if pushes := f.sessions.records(); len(pushes) != 1 || pushes[0].targetSession != phoneCallerSession { t.Fatalf("signaling pushes = %+v, want caller device", pushes) } f.sessions.reset() dispatch(f.calleeCtx(), &tg.PhoneDiscardCallRequest{ Peer: peer, Duration: 3, Reason: &tg.PhoneCallDiscardReasonHangup{}, }) if call, found := f.router.deps.Phone.Lookup(f.ctx, peer.ID, peer.AccessHash); !found || !call.Terminal() { t.Fatalf("discarded call = %+v found=%v, want terminal tombstone", call, found) } newCall := &tg.PhoneRequestCallRequest{ UserID: inputUser(f.caller), RandomID: 7002, GAHash: gaHash, Protocol: phoneTestProtocol(), } body := encodeExactLayerRPC(t, tlprofile.Profile228, newCall) admitted, err := f.router.AdmitLayer(tlprofile.Profile228, &body, tlprofile.Limits{}) if err != nil { t.Fatal(err) } if _, method, err := f.router.DispatchAdmitted(f.calleeCtx(), [8]byte{1}, phoneCalleeSession, 0, 1, admitted); method != "phone.requestCall" || !tgerr.Is(err, "FROZEN_METHOD_INVALID") { t.Fatalf("frozen outbound call = method:%q err:%v, want FROZEN_METHOD_INVALID", method, err) } delete(provider.items, f.callee.ID) body = encodeExactLayerRPC(t, tlprofile.Profile228, newCall) admitted, err = f.router.AdmitLayer(tlprofile.Profile228, &body, tlprofile.Limits{}) if err != nil { t.Fatal(err) } if _, method, err := f.router.DispatchAdmitted(f.calleeCtx(), [8]byte{1}, phoneCalleeSession, 0, 1, admitted); err != nil || method != "phone.requestCall" { t.Fatalf("unfrozen outbound call = method:%q err:%v", method, err) } } func TestFrozenMethodGateIsUserScopedAcrossSessionsAndUnfreezesImmediately(t *testing.T) { const ( frozenUser = int64(1001) otherUser = int64(1002) ) provider := &frozenGateFreezeProvider{items: map[int64]domain.AccountFreeze{ frozenUser: frozenGateActiveState(frozenUser), }} router := &Router{deps: Deps{AccountFreeze: provider}} // Separate MTProto sessions for one user share the same durable account fact. for _, sessionID := range []int64{11, 22} { ctx := WithSessionID(WithUserID(context.Background(), frozenUser), sessionID) if err := router.checkFrozenRPC(ctx, "messages.sendMessage"); !tgerr.Is(err, "FROZEN_METHOD_INVALID") { t.Fatalf("session %d err = %v, want FROZEN_METHOD_INVALID", sessionID, err) } } if err := router.checkFrozenRPC(WithUserID(context.Background(), otherUser), "messages.sendMessage"); err != nil { t.Fatalf("other user was gated: %v", err) } // Unfreeze is a durable state transition; existing sessions are admitted on // their very next RPC without reconnecting or retaining a per-session flag. delete(provider.items, frozenUser) for _, sessionID := range []int64{11, 22} { ctx := WithSessionID(WithUserID(context.Background(), frozenUser), sessionID) if err := router.checkFrozenRPC(ctx, "messages.sendMessage"); err != nil { t.Fatalf("session %d remained gated after unfreeze: %v", sessionID, err) } } } func TestFrozenMethodGateReturns420BeforeLayerHandler(t *testing.T) { const userID = int64(1001) for _, profile := range []tlprofile.Profile{ tlprofile.Profile225, tlprofile.Profile226, tlprofile.Profile227, tlprofile.Profile228, } { t.Run(fmt.Sprintf("layer_%d", profile), func(t *testing.T) { provider := &frozenGateFreezeProvider{freeze: frozenGateActiveState(userID), found: true} router := New( Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{AccountFreeze: provider}, zaptest.NewLogger(t), clock.System, ) body := encodeExactLayerRPC(t, profile, &tg.MessagesSendMessageRequest{ Peer: &tg.InputPeerSelf{}, Message: "must not reach handler", RandomID: 1, }) admitted, err := router.AdmitLayer(profile, &body, tlprofile.Limits{}) if err != nil { t.Fatal(err) } _, method, err := router.DispatchAdmitted(WithUserID(context.Background(), userID), [8]byte{1}, 10, 0, 1, admitted) if method != "messages.sendMessage" || !tgerr.Is(err, "FROZEN_METHOD_INVALID") { t.Fatalf("DispatchAdmitted = method:%q err:%v", method, err) } rpcErr, ok := tgerr.As(err) if !ok || rpcErr.Code != 420 { t.Fatalf("RPC error = %#v, want code 420", rpcErr) } if provider.calls != 1 { t.Fatalf("freeze provider calls = %d, want 1", provider.calls) } }) } } func TestFrozenMethodGateReturns420BeforeLegacyHandler(t *testing.T) { const userID = int64(1001) provider := &frozenGateFreezeProvider{freeze: frozenGateActiveState(userID), found: true} router := New( Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{AccountFreeze: provider}, zaptest.NewLogger(t), clock.System, ) request := &tg.MessagesSendMessageRequest{ Peer: &tg.InputPeerSelf{}, Message: "must not reach legacy handler", RandomID: 2, } var body bin.Buffer if err := request.Encode(&body); err != nil { t.Fatal(err) } if _, err := router.Dispatch(WithUserID(context.Background(), userID), [8]byte{1}, 10, &body); !tgerr.Is(err, "FROZEN_METHOD_INVALID") { t.Fatalf("legacy Dispatch err = %v, want FROZEN_METHOD_INVALID", err) } else if rpcErr, ok := tgerr.As(err); !ok || rpcErr.Code != 420 { t.Fatalf("legacy RPC error = %#v, want code 420", rpcErr) } if provider.calls != 1 { t.Fatalf("freeze provider calls = %d, want 1", provider.calls) } } func TestFrozenParticipantGateAllowsOnlyJoinedChannels(t *testing.T) { const userID = int64(1001) provider := &frozenGateFreezeProvider{freeze: frozenGateActiveState(userID), found: true} router := &Router{deps: Deps{ AccountFreeze: provider, Channels: frozenGateChannels{views: map[int64]domain.ChannelView{ 10: {Self: domain.ChannelMember{UserID: userID, Status: domain.ChannelMemberActive}}, 20: {Self: domain.ChannelMember{UserID: userID, Status: domain.ChannelMemberLeft}}, 30: {Self: domain.ChannelMember{UserID: userID, Status: domain.ChannelMemberActive, Guest: true}}, }}, }} ctx := WithUserID(context.Background(), userID) if err := router.checkFrozenChannelParticipants(ctx, userID, 10, 10); err != nil { t.Fatalf("joined channel: %v", err) } for _, channelID := range []int64{20, 30} { if err := router.checkFrozenChannelParticipants(ctx, userID, channelID); !tgerr.Is(err, "FROZEN_PARTICIPANT_MISSING") { t.Fatalf("channel %d err = %v, want FROZEN_PARTICIPANT_MISSING", channelID, err) } else if rpcErr, ok := tgerr.As(err); !ok || rpcErr.Code != 400 { t.Fatalf("channel %d RPC error = %#v, want code 400", channelID, rpcErr) } } } func TestFrozenParticipantGateRejectsBeforeCatchupRateLimitWrite(t *testing.T) { const ( userID = int64(1001) channelID = int64(20) accessHash = int64(20020) ) provider := &frozenGateFreezeProvider{freeze: frozenGateActiveState(userID), found: true} limiter := &captureRateLimiter{} router := &Router{ cfg: Config{CatchupRateLimit: 1, CatchupRateWindow: time.Minute}, deps: Deps{ AccountFreeze: provider, Limiter: limiter, Channels: frozenGateChannels{views: map[int64]domain.ChannelView{ channelID: { Channel: domain.Channel{ID: channelID, AccessHash: accessHash}, Self: domain.ChannelMember{UserID: userID, Status: domain.ChannelMemberLeft}, }, }}, }, } _, err := router.onUpdatesGetChannelDifference( WithUserID(context.Background(), userID), &tg.UpdatesGetChannelDifferenceRequest{ Channel: &tg.InputChannel{ChannelID: channelID, AccessHash: accessHash}, Limit: 100, }, ) if !tgerr.Is(err, "FROZEN_PARTICIPANT_MISSING") { t.Fatalf("getChannelDifference err = %v, want FROZEN_PARTICIPANT_MISSING", err) } if len(limiter.calls) != 0 { t.Fatalf("rejected frozen participant consumed rate-limit state: %+v", limiter.calls) } } func TestFrozenGatesFailClosedOnFreezeLookupError(t *testing.T) { provider := &frozenGateFreezeProvider{err: errors.New("database unavailable")} router := &Router{deps: Deps{AccountFreeze: provider}} if err := router.checkFrozenRPC(WithUserID(context.Background(), 1001), "messages.sendMessage"); !tgerr.Is(err, "INTERNAL_SERVER_ERROR") { t.Fatalf("checkFrozenRPC error = %v, want INTERNAL_SERVER_ERROR", err) } }