feat: add NFT usernames and bot verification (#22)
Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review. The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation. Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9 Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
This commit is contained in:
parent
b0fd3976f1
commit
fff8de783a
169 changed files with 55769 additions and 282 deletions
319
internal/rpc/verification_notify_test.go
Normal file
319
internal/rpc/verification_notify_test.go
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap"
|
||||
|
||||
verificationapp "telesrv/internal/app/verification"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// The process wires the badge hook through a dynamic type assertion on
|
||||
// *rpc.Router, so a signature drift would silently fall back to the
|
||||
// invalidation-only notifier instead of breaking the build. Assert the port here
|
||||
// so it breaks the build instead.
|
||||
var _ verificationapp.PeerNotifier = (*Router)(nil)
|
||||
|
||||
// verifiedNotifyUsers is the authoritative account reader plus the moderation
|
||||
// audience port, i.e. exactly the two capabilities the badge push relies on.
|
||||
type verifiedNotifyUsers struct {
|
||||
UsersService
|
||||
user domain.User
|
||||
found bool
|
||||
adminCalls int
|
||||
audience []int64
|
||||
viewers []int64
|
||||
}
|
||||
|
||||
func (s *verifiedNotifyUsers) AdminUser(_ context.Context, userID int64) (domain.User, bool, error) {
|
||||
s.adminCalls++
|
||||
if !s.found || s.user.ID != userID {
|
||||
return domain.User{}, false, nil
|
||||
}
|
||||
return s.user, true, nil
|
||||
}
|
||||
|
||||
func (s *verifiedNotifyUsers) ByIDs(_ context.Context, viewerUserID int64, ids []int64) ([]domain.User, error) {
|
||||
s.viewers = append(s.viewers, viewerUserID)
|
||||
if !s.found || len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []domain.User{s.user}, nil
|
||||
}
|
||||
|
||||
func (s *verifiedNotifyUsers) ModerationFlagAudience(_ context.Context, _ int64, _ int) ([]int64, error) {
|
||||
return append([]int64(nil), s.audience...), nil
|
||||
}
|
||||
|
||||
// verifiedNotifyChannels exposes the viewer-independent base row the hook needs
|
||||
// plus the membership filter the channel fan-out uses.
|
||||
type verifiedNotifyChannels struct {
|
||||
ChannelsService
|
||||
channel domain.Channel
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *verifiedNotifyChannels) GetChannelByID(_ context.Context, channelID int64) (domain.Channel, error) {
|
||||
s.calls++
|
||||
if s.err != nil {
|
||||
return domain.Channel{}, s.err
|
||||
}
|
||||
if s.channel.ID != channelID {
|
||||
return domain.Channel{}, nil
|
||||
}
|
||||
return s.channel, nil
|
||||
}
|
||||
|
||||
func (s *verifiedNotifyChannels) FilterActiveMemberIDs(_ context.Context, _ int64, userIDs []int64) ([]int64, error) {
|
||||
return append([]int64(nil), userIDs...), nil
|
||||
}
|
||||
|
||||
// channelsWithoutDirectory models a channels adapter that does not expose the
|
||||
// non-personalized base-row reader.
|
||||
type channelsWithoutDirectory struct{ ChannelsService }
|
||||
|
||||
func seedUserFullProjection(t *testing.T, r *Router, viewerUserID, targetUserID int64) {
|
||||
t.Helper()
|
||||
epoch := r.userFullProjectionCache.LoadEpoch()
|
||||
r.userFullProjectionCache.StoreIfEpoch(viewerUserID, targetUserID, tg.UserFull{ID: targetUserID}, epoch)
|
||||
if _, ok := r.userFullProjectionCache.Lookup(viewerUserID, targetUserID); !ok {
|
||||
t.Fatalf("seed userFull projection for viewer %d target %d", viewerUserID, targetUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func seedChannelFullProjection(t *testing.T, r *Router, viewerUserID, channelID int64) {
|
||||
t.Helper()
|
||||
epoch := r.channelFullProjectionCache.LoadEpoch()
|
||||
r.channelFullProjectionCache.StoreIfEpoch(viewerUserID, channelID, channelFullProjection{
|
||||
accessHash: 1,
|
||||
full: tg.ChannelFull{ID: channelID},
|
||||
}, epoch)
|
||||
if _, ok := r.channelFullProjectionCache.Lookup(viewerUserID, channelID); !ok {
|
||||
t.Fatalf("seed channelFull projection for viewer %d channel %d", viewerUserID, channelID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyPeerVerifiedUserInvalidatesAndPushesUpdateUser is the bot/user half of
|
||||
// the main scenario: an approved bot must reach every online account that already
|
||||
// sees it, through the same non-PTS updateUser shape the scam/fake flags use.
|
||||
func TestNotifyPeerVerifiedUserInvalidatesAndPushesUpdateUser(t *testing.T) {
|
||||
const (
|
||||
botID = int64(1250000012)
|
||||
onlineViewerID = int64(1001)
|
||||
offlineViewerID = int64(3003)
|
||||
)
|
||||
users := &verifiedNotifyUsers{
|
||||
user: domain.User{ID: botID, FirstName: "Shop", Bot: true, Verified: true},
|
||||
found: true,
|
||||
audience: []int64{botID, onlineViewerID, offlineViewerID},
|
||||
}
|
||||
sessions := &captureSessions{onlineUserIDs: []int64{botID, onlineViewerID}}
|
||||
r := New(Config{}, Deps{Users: users, Sessions: sessions}, zap.NewNop(), clock.System)
|
||||
seedUserFullProjection(t, r, onlineViewerID, botID)
|
||||
seedUserFullProjection(t, r, botID, botID)
|
||||
|
||||
if err := r.NotifyPeerVerified(context.Background(), domain.Peer{
|
||||
Type: domain.PeerTypeUser, ID: botID,
|
||||
}); err != nil {
|
||||
t.Fatalf("notify peer verified: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := r.userFullProjectionCache.Lookup(onlineViewerID, botID); ok {
|
||||
t.Fatal("viewer userFull projection survived the badge change")
|
||||
}
|
||||
if _, ok := r.userFullProjectionCache.Lookup(botID, botID); ok {
|
||||
t.Fatal("target userFull projection survived the badge change")
|
||||
}
|
||||
if users.adminCalls != 1 {
|
||||
t.Fatalf("authoritative account reads = %d", users.adminCalls)
|
||||
}
|
||||
|
||||
// Offline audience members are skipped; they converge through the bumped
|
||||
// user_base read model on their next getDifference / getFullUser.
|
||||
pushed := sessions.pushedUserIDs()
|
||||
if len(pushed) != 2 || pushed[0] != botID || pushed[1] != onlineViewerID {
|
||||
t.Fatalf("pushed user ids = %v", pushed)
|
||||
}
|
||||
if len(users.viewers) != 2 || users.viewers[0] != botID || users.viewers[1] != onlineViewerID {
|
||||
t.Fatalf("re-projected viewers = %v", users.viewers)
|
||||
}
|
||||
|
||||
updates, ok := sessions.lastUserPush().(*tg.Updates)
|
||||
if !ok || len(updates.Updates) != 1 {
|
||||
t.Fatalf("updates = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
|
||||
}
|
||||
refresh, ok := updates.Updates[0].(*tg.UpdateUser)
|
||||
if !ok || refresh.UserID != botID {
|
||||
t.Fatalf("refresh = %T %+v", updates.Updates[0], updates.Updates[0])
|
||||
}
|
||||
if len(updates.Users) != 1 {
|
||||
t.Fatalf("users = %+v", updates.Users)
|
||||
}
|
||||
user, ok := updates.Users[0].(*tg.User)
|
||||
if !ok || user.ID != botID || !user.Verified {
|
||||
t.Fatalf("user = %T %+v", updates.Users[0], updates.Users[0])
|
||||
}
|
||||
if user.Scam || user.Fake {
|
||||
t.Fatalf("badge push leaked moderation flags: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyPeerVerifiedChannelInvalidatesAndPushesUpdateChannel is the channel
|
||||
// half: the badge rides the existing channel state-mutation fan-out.
|
||||
func TestNotifyPeerVerifiedChannelInvalidatesAndPushesUpdateChannel(t *testing.T) {
|
||||
const (
|
||||
channelID = int64(4004)
|
||||
ownerID = int64(3003)
|
||||
memberID = int64(3004)
|
||||
)
|
||||
channels := &verifiedNotifyChannels{channel: domain.Channel{
|
||||
ID: channelID, AccessHash: 44, CreatorUserID: ownerID,
|
||||
Title: "Official", Username: "official", Broadcast: true, Verified: true,
|
||||
}}
|
||||
sessions := &captureSessions{
|
||||
onlineUserIDs: []int64{ownerID, memberID},
|
||||
channelMembers: map[int64][]int64{channelID: {ownerID, memberID}},
|
||||
}
|
||||
r := New(Config{}, Deps{Channels: channels, Sessions: sessions}, zap.NewNop(), clock.System)
|
||||
seedChannelFullProjection(t, r, ownerID, channelID)
|
||||
seedChannelFullProjection(t, r, memberID, channelID)
|
||||
|
||||
if err := r.NotifyPeerVerified(context.Background(), domain.Peer{
|
||||
Type: domain.PeerTypeChannel, ID: channelID,
|
||||
}); err != nil {
|
||||
t.Fatalf("notify peer verified: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := r.channelFullProjectionCache.Lookup(ownerID, channelID); ok {
|
||||
t.Fatal("owner channelFull projection survived the badge change")
|
||||
}
|
||||
if _, ok := r.channelFullProjectionCache.Lookup(memberID, channelID); ok {
|
||||
t.Fatal("member channelFull projection survived the badge change")
|
||||
}
|
||||
if channels.calls != 1 {
|
||||
t.Fatalf("base channel row reads = %d", channels.calls)
|
||||
}
|
||||
|
||||
if pushed := sessions.pushedUserIDs(); len(pushed) != 2 {
|
||||
t.Fatalf("pushed user ids = %v", pushed)
|
||||
}
|
||||
updates, ok := sessions.lastUserPush().(*tg.Updates)
|
||||
if !ok || len(updates.Updates) != 1 {
|
||||
t.Fatalf("updates = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
|
||||
}
|
||||
refresh, ok := updates.Updates[0].(*tg.UpdateChannel)
|
||||
if !ok || refresh.ChannelID != channelID {
|
||||
t.Fatalf("refresh = %T %+v", updates.Updates[0], updates.Updates[0])
|
||||
}
|
||||
if len(updates.Chats) != 1 {
|
||||
t.Fatalf("chats = %+v", updates.Chats)
|
||||
}
|
||||
channel, ok := updates.Chats[0].(*tg.Channel)
|
||||
if !ok || channel.ID != channelID || !channel.Verified {
|
||||
t.Fatalf("channel = %T %+v", updates.Chats[0], updates.Chats[0])
|
||||
}
|
||||
if channel.Scam || channel.Fake {
|
||||
t.Fatalf("badge push leaked moderation flags: %+v", channel)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyPeerVerifiedNilRouterIsSafe pins the nil-receiver contract: the hook is
|
||||
// invoked after the decision already committed, so it may never panic.
|
||||
func TestNotifyPeerVerifiedNilRouterIsSafe(t *testing.T) {
|
||||
var r *Router
|
||||
if err := r.NotifyPeerVerified(context.Background(), domain.Peer{
|
||||
Type: domain.PeerTypeUser, ID: 1001,
|
||||
}); err != nil {
|
||||
t.Fatalf("nil router notify = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyPeerVerifiedWithoutServicesStillInvalidates covers the degraded wiring:
|
||||
// with no user/channel service the hook cannot push, but the stale projection must
|
||||
// still be dropped and no error reported.
|
||||
func TestNotifyPeerVerifiedWithoutServicesStillInvalidates(t *testing.T) {
|
||||
r := New(Config{}, Deps{}, zap.NewNop(), clock.System)
|
||||
seedUserFullProjection(t, r, 1001, 2002)
|
||||
seedChannelFullProjection(t, r, 1001, 4004)
|
||||
|
||||
if err := r.NotifyPeerVerified(context.Background(), domain.Peer{
|
||||
Type: domain.PeerTypeUser, ID: 2002,
|
||||
}); err != nil {
|
||||
t.Fatalf("notify user without users service = %v", err)
|
||||
}
|
||||
if err := r.NotifyPeerVerified(context.Background(), domain.Peer{
|
||||
Type: domain.PeerTypeChannel, ID: 4004,
|
||||
}); err != nil {
|
||||
t.Fatalf("notify channel without channels service = %v", err)
|
||||
}
|
||||
if _, ok := r.userFullProjectionCache.Lookup(1001, 2002); ok {
|
||||
t.Fatal("userFull projection survived without a users service")
|
||||
}
|
||||
if _, ok := r.channelFullProjectionCache.Lookup(1001, 4004); ok {
|
||||
t.Fatal("channelFull projection survived without a channels service")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyPeerVerifiedRejectsUnknownPeers pins the "explain, never panic"
|
||||
// contract for every peer the hook cannot act on.
|
||||
func TestNotifyPeerVerifiedRejectsUnknownPeers(t *testing.T) {
|
||||
users := &verifiedNotifyUsers{}
|
||||
channels := &verifiedNotifyChannels{}
|
||||
sessions := &captureSessions{}
|
||||
r := New(Config{}, Deps{Users: users, Channels: channels, Sessions: sessions}, zap.NewNop(), clock.System)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
peer domain.Peer
|
||||
want string
|
||||
}{
|
||||
{"zero id", domain.Peer{Type: domain.PeerTypeUser}, "invalid peer id"},
|
||||
{"negative id", domain.Peer{Type: domain.PeerTypeChannel, ID: -1}, "invalid peer id"},
|
||||
{"community peer", domain.Peer{Type: domain.PeerTypeCommunity, ID: 5005}, "unsupported peer type"},
|
||||
{"empty type", domain.Peer{ID: 5005}, "unsupported peer type"},
|
||||
{"missing user", domain.Peer{Type: domain.PeerTypeUser, ID: 2002}, "user 2002 not found"},
|
||||
{"missing channel", domain.Peer{Type: domain.PeerTypeChannel, ID: 4004}, "channel 4004 not found"},
|
||||
} {
|
||||
err := r.NotifyPeerVerified(ctx, tc.peer)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("%s: err = %v, want mention of %q", tc.name, err, tc.want)
|
||||
}
|
||||
}
|
||||
if pushed := sessions.pushedUserIDs(); len(pushed) != 0 {
|
||||
t.Fatalf("unresolved peers pushed updates to %v", pushed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyPeerVerifiedReportsLookupFailures keeps a directory error distinct from
|
||||
// "peer not found", and keeps a channels adapter without the base-row reader from
|
||||
// failing silently.
|
||||
func TestNotifyPeerVerifiedReportsLookupFailures(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
loadErr := errors.New("boom")
|
||||
|
||||
failing := New(Config{}, Deps{
|
||||
Channels: &verifiedNotifyChannels{err: loadErr},
|
||||
Sessions: &captureSessions{},
|
||||
}, zap.NewNop(), clock.System)
|
||||
err := failing.NotifyPeerVerified(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: 4004})
|
||||
if !errors.Is(err, loadErr) {
|
||||
t.Fatalf("channel load error = %v", err)
|
||||
}
|
||||
|
||||
unwired := New(Config{}, Deps{
|
||||
Channels: channelsWithoutDirectory{},
|
||||
Sessions: &captureSessions{},
|
||||
}, zap.NewNop(), clock.System)
|
||||
err = unwired.NotifyPeerVerified(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: 4004})
|
||||
if err == nil || !strings.Contains(err.Error(), "GetChannelByID") {
|
||||
t.Fatalf("missing channel directory error = %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue