owpengram-server/internal/rpc/bot_verification_rpc_test.go
Egor Egorov fff8de783a
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>
2026-07-28 01:18:00 +08:00

537 lines
21 KiB
Go

package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
botsapp "telesrv/internal/app/bots"
appchannels "telesrv/internal/app/channels"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// fakeBotVerifications is an in-memory BotVerificationService. It enforces the same
// domain rules the real service must (verifier status, the operator kill switch,
// domain.BotVerifierSettings.DescriptionFor, the per-verifier bound and
// idempotent mutations), so the RPC tests exercise the real error mapping
// instead of hand-rolled sentinels.
type fakeBotVerifications struct {
marks map[domain.Peer]domain.CustomVerification
settings map[int64]domain.BotVerifierSettings
// limit, when positive, bounds how many peers one verifier may mark.
limit int
// err, when set, fails every read. Used for the degradation tests.
err error
// Call counters let the tests assert the batch fan-out (no N+1).
peerCalls int
batchCalls int
settingsCalls int
settingsBatchCalls int
setCalls int
// notifier mirrors production wiring: the application service owns the badge
// push for all three drivers (this RPC, the bot dialog, the admin panel), so the
// fake pushes here exactly where the real service does.
notifier interface {
NotifyPeerBotVerification(ctx context.Context, peer domain.Peer) error
}
}
func newFakeBotVerifications() *fakeBotVerifications {
return &fakeBotVerifications{
marks: make(map[domain.Peer]domain.CustomVerification),
settings: make(map[int64]domain.BotVerifierSettings),
}
}
func (f *fakeBotVerifications) PeerVerification(_ context.Context, peer domain.Peer) (domain.CustomVerification, error) {
f.peerCalls++
if f.err != nil {
return domain.CustomVerification{}, f.err
}
mark, ok := f.marks[peer]
if !ok {
return domain.CustomVerification{}, domain.ErrCustomVerificationNotFound
}
return mark, nil
}
func (f *fakeBotVerifications) PeerVerificationBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer]domain.CustomVerification, error) {
f.batchCalls++
if f.err != nil {
return nil, f.err
}
out := make(map[domain.Peer]domain.CustomVerification, len(peers))
for _, peer := range peers {
if mark, ok := f.marks[peer]; ok {
out[peer] = mark
}
}
return out, nil
}
func (f *fakeBotVerifications) VerifierSettings(_ context.Context, botID int64) (domain.BotVerifierSettings, error) {
f.settingsCalls++
if f.err != nil {
return domain.BotVerifierSettings{}, f.err
}
settings, ok := f.settings[botID]
if !ok {
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
}
return settings, nil
}
func (f *fakeBotVerifications) VerifierSettingsBatch(_ context.Context, botIDs []int64) (map[int64]domain.BotVerifierSettings, error) {
f.settingsBatchCalls++
if f.err != nil {
return nil, f.err
}
out := make(map[int64]domain.BotVerifierSettings, len(botIDs))
for _, id := range botIDs {
if settings, ok := f.settings[id]; ok {
out[id] = settings
}
}
return out, nil
}
func (f *fakeBotVerifications) SetCustomVerification(_ context.Context, req domain.SetCustomVerificationRequest) (bool, error) {
f.setCalls++
if f.err != nil {
return false, f.err
}
if err := req.Validate(); err != nil {
return false, err
}
settings, ok := f.settings[req.VerifierBotID]
if !ok {
return false, domain.ErrVerifierNotFound
}
if !settings.Enabled {
return false, domain.ErrVerifierForbidden
}
existing, marked := f.marks[req.Peer]
if !req.Enabled {
// A revoke only touches this verifier's own mark, and a repeated revoke is a
// no-op rather than an error.
if !marked || existing.VerifierBotID != req.VerifierBotID {
return false, nil
}
delete(f.marks, req.Peer)
f.notify(req.Peer)
return true, nil
}
description, err := settings.DescriptionFor(req.CustomDescription)
if err != nil {
return false, err
}
if marked && existing.VerifierBotID == req.VerifierBotID &&
existing.IconDocumentID == settings.IconDocumentID && existing.Description == description {
return false, nil
}
if f.limit > 0 && !marked && f.countFor(req.VerifierBotID) >= f.limit {
return false, domain.ErrCustomVerificationLimit
}
f.marks[req.Peer] = domain.CustomVerification{
VerifierBotID: req.VerifierBotID,
Peer: req.Peer,
IconDocumentID: settings.IconDocumentID,
Description: description,
GrantedByUserID: req.CallerUserID,
}
f.notify(req.Peer)
return true, nil
}
// notify reproduces the service's post-commit push.
func (f *fakeBotVerifications) notify(peer domain.Peer) {
if f.notifier == nil {
return
}
_ = f.notifier.NotifyPeerBotVerification(context.Background(), peer)
}
func (f *fakeBotVerifications) countFor(verifierBotID int64) int {
n := 0
for _, mark := range f.marks {
if mark.VerifierBotID == verifierBotID {
n++
}
}
return n
}
var _ BotVerificationService = (*fakeBotVerifications)(nil)
// botVerificationFixture wires the real users/bots/channels services next to the
// fake verification service, so the ownership checks bots.setCustomVerification
// depends on are the production ones.
type botVerificationFixture struct {
router *Router
verify *fakeBotVerifications
bots *botsapp.Service
owner domain.User
stranger domain.User
target domain.User
bot domain.User
foreign domain.User
}
func newBotVerificationFixture(t *testing.T, verify BotVerificationService) botVerificationFixture {
t.Helper()
ctx := context.Background()
userStore := memory.NewUserStore()
botStore := memory.NewBotStore(userStore)
dialogs := memory.NewDialogStore()
messageStore := memory.NewMessageStore(dialogs)
bots := botsapp.NewService(userStore, botStore, messageStore)
channelStore := memory.NewChannelStore()
channels := appchannels.NewService(channelStore, appchannels.WithBotProfileResolver(bots))
owner, err := userStore.Create(ctx, domain.User{AccessHash: 9101, Phone: "15550009101", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
stranger, err := userStore.Create(ctx, domain.User{AccessHash: 9102, Phone: "15550009102", FirstName: "Stranger"})
if err != nil {
t.Fatalf("create stranger: %v", err)
}
target, err := userStore.Create(ctx, domain.User{AccessHash: 9103, Phone: "15550009103", FirstName: "Target", Username: "target_shop"})
if err != nil {
t.Fatalf("create target: %v", err)
}
bot, _, err := bots.CreateBot(ctx, owner.ID, "Verifier Bot", "verifier_shape_bot")
if err != nil {
t.Fatalf("create verifier bot: %v", err)
}
foreign, _, err := bots.CreateBot(ctx, stranger.ID, "Foreign Bot", "foreign_shape_bot")
if err != nil {
t.Fatalf("create foreign bot: %v", err)
}
fake, _ := verify.(*fakeBotVerifications)
deps := Deps{
Users: appusers.NewService(userStore),
Bots: bots,
Channels: channels,
}
if verify != nil {
deps.BotVerifications = verify
}
return botVerificationFixture{
router: New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, deps, zaptest.NewLogger(t), clock.System),
verify: fake,
bots: bots,
owner: owner,
stranger: stranger,
target: target,
bot: bot,
foreign: foreign,
}
}
// enableVerifier grants the fake verifier status the way the operator would.
func (f botVerificationFixture) enableVerifier(botID, icon int64, canModifyDescription bool) {
f.verify.settings[botID] = domain.BotVerifierSettings{
BotID: botID,
IconDocumentID: icon,
CompanyName: "Acme Trust",
DefaultDescription: "Verified by Acme Trust",
CanModifyCustomDescription: canModifyDescription,
Enabled: true,
}
}
func setCustomVerificationRequest(peer tg.InputPeerClass, bot tg.InputUserClass, enabled bool, description string) *tg.BotsSetCustomVerificationRequest {
req := &tg.BotsSetCustomVerificationRequest{Peer: peer}
if bot != nil {
req.SetBot(bot)
}
req.SetEnabled(enabled)
if description != "" {
req.SetCustomDescription(description)
}
return req
}
// TestBotsSetCustomVerificationOwnerGrantsAndRevokes is the main happy path: the
// owner of a verifier bot marks a peer, the repeat call remains successful, and
// the revoke removes exactly that mark.
//
// The Bool result is asserted rather than checking only err == nil: official
// clients require BoolTrue even when the requested state was already applied.
func TestBotsSetCustomVerificationOwnerGrantsAndRevokes(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550001, true)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if err != nil || !ok {
t.Fatalf("owner grant = %v,%v, want true,nil", ok, err)
}
mark, exists := f.verify.marks[peer]
if !exists {
t.Fatalf("grant stored nothing: %+v", f.verify.marks)
}
if mark.VerifierBotID != f.bot.ID || mark.IconDocumentID != 5550001 {
t.Fatalf("stored mark = %+v, want verifier %d icon 5550001", mark, f.bot.ID)
}
if mark.Description != "Verified by Acme Trust" {
t.Fatalf("stored description = %q, want the verifier default", mark.Description)
}
if mark.GrantedByUserID != f.owner.ID {
t.Fatalf("granted by = %d, want calling owner %d", mark.GrantedByUserID, f.owner.ID)
}
// The method reports successful processing, including an idempotent replay.
// Official clients treat BoolFalse as failure and do not infer "unchanged".
repeat, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if err != nil || !repeat {
t.Fatalf("repeated grant = %v,%v, want true,nil", repeat, err)
}
revoked, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), false, ""))
if err != nil || !revoked {
t.Fatalf("revoke = %v,%v, want true,nil", revoked, err)
}
if _, exists := f.verify.marks[peer]; exists {
t.Fatalf("revoke left the mark behind: %+v", f.verify.marks)
}
repeatRevoke, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), false, ""))
if err != nil || !repeatRevoke {
t.Fatalf("repeated revoke = %v,%v, want true,nil", repeatRevoke, err)
}
}
// TestBotsSetCustomVerificationBotCallerActsAsItself covers the other caller the TL
// constructor allows: a bot invoking without bot:flags.0, which must resolve to the
// calling bot's own id.
func TestBotsSetCustomVerificationBotCallerActsAsItself(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550002, true)
botCtx := WithUserID(context.Background(), f.bot.ID)
ok, err := f.router.onBotsSetCustomVerification(botCtx,
setCustomVerificationRequest(inputPeerUser(f.target), nil, true, ""))
if err != nil || !ok {
t.Fatalf("bot self grant = %v,%v, want true,nil", ok, err)
}
mark := f.verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}]
if mark.VerifierBotID != f.bot.ID {
t.Fatalf("verifier id = %d, want the calling bot %d", mark.VerifierBotID, f.bot.ID)
}
if mark.GrantedByUserID != f.bot.ID {
t.Fatalf("granted by = %d, want the calling bot %d", mark.GrantedByUserID, f.bot.ID)
}
}
// TestBotsSetCustomVerificationRejectsForeignBot pins the ownership boundary: naming
// somebody else's bot is BOT_INVALID, and nothing reaches the service.
func TestBotsSetCustomVerificationRejectsForeignBot(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.foreign.ID, 5550003, true)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.foreign), true, ""))
if ok || !tgerr.Is(err, "BOT_INVALID") {
t.Fatalf("foreign bot = %v,%v, want false,BOT_INVALID", ok, err)
}
if f.verify.setCalls != 0 {
t.Fatalf("foreign bot reached the service %d times", f.verify.setCalls)
}
if len(f.verify.marks) != 0 {
t.Fatalf("foreign bot minted a mark: %+v", f.verify.marks)
}
}
// TestBotsSetCustomVerificationRejectsNonVerifier covers a bot the operator never
// granted verifier status, and a verifier the operator switched off: both are
// BOT_VERIFIER_FORBIDDEN, because in both cases the bot may not verify anything.
func TestBotsSetCustomVerificationRejectsNonVerifier(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
ownerCtx := WithUserID(context.Background(), f.owner.ID)
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("non-verifier = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
disabled := f.verify.settings
f.enableVerifier(f.bot.ID, 5550004, true)
settings := disabled[f.bot.ID]
settings.Enabled = false
disabled[f.bot.ID] = settings
ok, err = f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("disabled verifier = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
if len(f.verify.marks) != 0 {
t.Fatalf("forbidden verifier minted a mark: %+v", f.verify.marks)
}
}
// TestBotsSetCustomVerificationRejectsInvalidPeer covers both invalid-target paths:
// an unresolvable inputPeer at the edge, and a peer the domain refuses to verify.
// Both must read PEER_ID_INVALID to a client -- the verifier is fine, the target is
// not.
func TestBotsSetCustomVerificationRejectsInvalidPeer(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550005, true)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(&tg.InputPeerEmpty{}, inputUser(f.bot), true, ""))
if ok || !tgerr.Is(err, "PEER_ID_INVALID") {
t.Fatalf("empty peer = %v,%v, want false,PEER_ID_INVALID", ok, err)
}
if f.verify.setCalls != 0 {
t.Fatalf("empty peer reached the service %d times", f.verify.setCalls)
}
// A stored-state rejection maps to the same code, so a client cannot tell the
// two apart and cannot probe which peers exist.
f.verify.err = domain.ErrCustomVerificationTargetInvalid
ok, err = f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if ok || !tgerr.Is(err, "PEER_ID_INVALID") {
t.Fatalf("domain-rejected peer = %v,%v, want false,PEER_ID_INVALID", ok, err)
}
}
// TestBotsSetCustomVerificationRejectsForbiddenDescription pins the
// can_modify_custom_description permission: a verifier that may only apply the
// operator default is refused when it supplies its own text, and the refusal is a
// documented BOT_VERIFIER_FORBIDDEN error.
func TestBotsSetCustomVerificationRejectsForbiddenDescription(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550006, false)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "Hand-written blurb"))
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("forbidden description = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
if code, _ := tgerr.AsType(err, "BOT_VERIFIER_FORBIDDEN"); code == nil || code.Code != 403 {
t.Fatalf("forbidden description error = %+v, want code 403", code)
}
if len(f.verify.marks) != 0 {
t.Fatalf("rejected description still minted a mark: %+v", f.verify.marks)
}
// Without the description the very same call goes through with the default text.
ok, err = f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if err != nil || !ok {
t.Fatalf("default description = %v,%v, want true,nil", ok, err)
}
if got := f.verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}].Description; got != "Verified by Acme Trust" {
t.Fatalf("stored description = %q, want the operator default", got)
}
// A verifier that IS allowed to override stores its own text.
f.enableVerifier(f.bot.ID, 5550006, true)
ok, err = f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "Official reseller"))
if err != nil || !ok {
t.Fatalf("custom description = %v,%v, want true,nil", ok, err)
}
if got := f.verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}].Description; got != "Official reseller" {
t.Fatalf("stored description = %q, want the verifier text", got)
}
}
// TestBotsSetCustomVerificationRejectsRevokeWithDescription pins the edge-side shape
// validation: a revoke that still carries a description is a caller bug (usually a
// forgotten enabled flag) and is reported rather than silently reinterpreted.
func TestBotsSetCustomVerificationRejectsRevokeWithDescription(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550007, true)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), false, "why"))
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("revoke with description = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
if f.verify.setCalls != 0 {
t.Fatalf("malformed request reached the service %d times", f.verify.setCalls)
}
}
// TestBotsSetCustomVerificationReportsLimit keeps the local per-verifier bound
// behind the only applicable documented method error.
func TestBotsSetCustomVerificationReportsLimit(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550008, true)
f.verify.limit = 1
ownerCtx := WithUserID(context.Background(), f.owner.ID)
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "")); err != nil || !ok {
t.Fatalf("first grant = %v,%v, want true,nil", ok, err)
}
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.stranger), inputUser(f.bot), true, ""))
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("over-limit grant = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
}
func TestBotsSetCustomVerificationRejectsNilRequest(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
ok, err := f.router.onBotsSetCustomVerification(
WithUserID(context.Background(), f.owner.ID),
nil,
)
if ok || !tgerr.Is(err, "PEER_ID_INVALID") {
t.Fatalf("nil request = %v,%v, want false,PEER_ID_INVALID", ok, err)
}
}
// TestBotsSetCustomVerificationWithoutServiceStaysForbidden is the degradation half
// of the RPC: with no verification service wired no bot can be a verifier, so the
// answer must be exactly what it was before the feature existed.
func TestBotsSetCustomVerificationWithoutServiceStaysForbidden(t *testing.T) {
f := newBotVerificationFixture(t, nil)
if f.router.deps.BotVerifications != nil {
t.Fatal("fixture wired a verification service, want nil")
}
ownerCtx := WithUserID(context.Background(), f.owner.ID)
botCtx := WithUserID(context.Background(), f.bot.ID)
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "")); ok ||
!tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("owner call without service = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
if ok, err := f.router.onBotsSetCustomVerification(botCtx,
setCustomVerificationRequest(inputPeerUser(f.target), nil, true, "")); ok ||
!tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("bot call without service = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
// The ownership and peer checks still run first, so their codes are unchanged too.
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.foreign), true, "")); ok ||
!tgerr.Is(err, "BOT_INVALID") {
t.Fatalf("foreign bot without service = %v,%v, want false,BOT_INVALID", ok, err)
}
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(&tg.InputPeerEmpty{}, inputUser(f.bot), true, "")); ok ||
!tgerr.Is(err, "PEER_ID_INVALID") {
t.Fatalf("empty peer without service = %v,%v, want false,PEER_ID_INVALID", ok, err)
}
}