owpengram-server/internal/app/bots/verifybot_test.go
2026-09-01 12:06:31 +03:00

984 lines
39 KiB
Go

package bots
import (
"context"
"errors"
"sort"
"strconv"
"strings"
"testing"
"time"
verificationapp "telesrv/internal/app/verification"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// ---------------------------------------------------------------------------
// Fake verification service
// ---------------------------------------------------------------------------
// fakeVerification is an in-memory stand-in for app/verification.Service. It
// keeps the properties the bot dialog actually leans on: one draft per applicant,
// StartDraft resuming instead of duplicating, optimistic-locking versions, and
// the domain validation of the payload.
type fakeVerification struct {
targets []domain.VerificationTarget
apps map[int64]domain.VerificationApplication
nextID int64
starts int
submits int
targetsErr error
startErr error
}
func newFakeVerification(targets ...domain.VerificationTarget) *fakeVerification {
return &fakeVerification{
targets: targets,
apps: make(map[int64]domain.VerificationApplication),
nextID: 100,
}
}
func (f *fakeVerification) EligibleTargets(_ context.Context, applicantUserID int64) ([]domain.VerificationTarget, error) {
if f.targetsErr != nil {
return nil, f.targetsErr
}
if applicantUserID <= 0 {
return nil, domain.ErrVerificationApplicationInvalid
}
return append([]domain.VerificationTarget(nil), f.targets...), nil
}
func (f *fakeVerification) draftFor(applicantUserID int64) (domain.VerificationApplication, bool) {
for _, app := range f.apps {
if app.ApplicantUserID == applicantUserID && app.Status == domain.VerificationStatusDraft {
return app, true
}
}
return domain.VerificationApplication{}, false
}
func (f *fakeVerification) StartDraft(_ context.Context, req domain.SubmitVerificationApplicationRequest) (domain.VerificationApplication, bool, error) {
f.starts++
if app, found := f.draftFor(req.ApplicantUserID); found {
return app, false, nil
}
if f.startErr != nil {
return domain.VerificationApplication{}, false, f.startErr
}
var target domain.VerificationTarget
for _, candidate := range f.targets {
if candidate.Type == req.TargetType && candidate.ID == req.TargetID {
target = candidate
}
}
if target.ID == 0 {
return domain.VerificationApplication{}, false, domain.ErrVerificationTargetInvalid
}
if !target.Eligible {
return domain.VerificationApplication{}, false, domain.ErrVerificationTargetAlreadyVerified
}
f.nextID++
app := domain.VerificationApplication{
ID: f.nextID,
ApplicantUserID: req.ApplicantUserID,
TargetType: target.Type,
TargetID: target.ID,
TargetTitle: target.Title,
TargetUsername: target.Username,
Status: domain.VerificationStatusDraft,
CreatedAt: time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC),
Version: 1,
}
f.apps[app.ID] = app
return app, true, nil
}
func (f *fakeVerification) SaveDraft(_ context.Context, applicantUserID, applicationID, version int64, draft domain.VerificationDraftInput) (domain.VerificationApplication, error) {
app, found := f.apps[applicationID]
if !found || app.ApplicantUserID != applicantUserID {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if app.Version != version {
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
}
if app.Status != domain.VerificationStatusDraft {
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
}
if err := draft.ValidateDraft(); err != nil {
return domain.VerificationApplication{}, err
}
draft = draft.Normalize()
app.Category = draft.Category
app.Description = draft.Description
app.OfficialWebsite = draft.OfficialWebsite
app.SocialLinks = draft.SocialLinks
app.PressLinks = draft.PressLinks
app.AdditionalNote = draft.AdditionalNote
app.Version++
f.apps[applicationID] = app
return app, nil
}
func (f *fakeVerification) Submit(_ context.Context, applicantUserID, applicationID, version int64) (domain.VerificationApplication, error) {
app, found := f.apps[applicationID]
if !found || app.ApplicantUserID != applicantUserID {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if app.Version != version {
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
}
if !domain.CanTransitionVerificationStatus(app.Status, domain.VerificationStatusSubmitted) {
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
}
if err := (domain.VerificationDraftInput{
Category: app.Category,
Description: app.Description,
OfficialWebsite: app.OfficialWebsite,
SocialLinks: app.SocialLinks,
PressLinks: app.PressLinks,
AdditionalNote: app.AdditionalNote,
}).ValidateForSubmission(); err != nil {
return domain.VerificationApplication{}, err
}
f.submits++
app.Status = domain.VerificationStatusSubmitted
app.SubmittedAt = time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
app.Version++
f.apps[applicationID] = app
return app, nil
}
func (f *fakeVerification) Cancel(_ context.Context, applicantUserID, applicationID, version int64, reason string) (domain.VerificationApplication, error) {
app, found := f.apps[applicationID]
if !found || app.ApplicantUserID != applicantUserID {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if app.Version != version {
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
}
if !domain.CanTransitionVerificationStatus(app.Status, domain.VerificationStatusCancelled) {
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
}
app.Status = domain.VerificationStatusCancelled
app.DecisionReason = reason
app.Version++
f.apps[applicationID] = app
return app, nil
}
func (f *fakeVerification) Draft(_ context.Context, applicantUserID int64) (domain.VerificationApplication, error) {
if app, found := f.draftFor(applicantUserID); found {
return app, nil
}
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
func (f *fakeVerification) ApplicantApplications(_ context.Context, applicantUserID int64, limit int) ([]domain.VerificationApplication, error) {
out := make([]domain.VerificationApplication, 0, len(f.apps))
for _, app := range f.apps {
if app.ApplicantUserID == applicantUserID {
out = append(out, app)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (f *fakeVerification) Application(_ context.Context, applicationID int64) (domain.VerificationApplication, error) {
if app, found := f.apps[applicationID]; found {
return app, nil
}
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
var _ verificationApplications = (*fakeVerification)(nil)
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
func verifyChannelTarget() domain.VerificationTarget {
return domain.VerificationTarget{
Type: domain.VerificationTargetChannel, ID: 7001,
Title: "Example News", Username: "examplenews", AccessHash: 42, Eligible: true,
}
}
func verifyBotTarget() domain.VerificationTarget {
return domain.VerificationTarget{
Type: domain.VerificationTargetBot, ID: 8002,
Title: "Example Bot", Username: "examplebot", Eligible: true,
}
}
func newVerifyBotTestService(t *testing.T, verification verificationApplications, opts ...Option) (*Service, *memory.UserStore, *memory.MessageStore) {
t.Helper()
users := memory.NewUserStore()
bots := memory.NewBotStore(users)
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
all := append([]Option{WithVerification(verification)}, opts...)
return NewService(users, bots, messages, all...), users, messages
}
// verifyBotReplies returns every @verifybot message in the user's box, oldest
// first.
func verifyBotReplies(t *testing.T, messages *memory.MessageStore, userID int64) []domain.Message {
t.Helper()
list, err := messages.ListByUser(context.Background(), userID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.VerifyBotUserID},
Limit: 200,
})
if err != nil {
t.Fatalf("list @verifybot history: %v", err)
}
out := make([]domain.Message, 0, len(list.Messages))
for _, msg := range list.Messages {
if msg.From.ID == domain.VerifyBotUserID {
out = append(out, msg)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func latestVerifyReply(t *testing.T, messages *memory.MessageStore, userID int64) domain.Message {
t.Helper()
replies := verifyBotReplies(t, messages, userID)
if len(replies) == 0 {
t.Fatal("no @verifybot reply")
}
latest := replies[len(replies)-1]
// Every keyboard the bot renders must be a valid, persistable markup: the send
// path validates before storing, so an invalid one would silently vanish.
if err := domain.ValidateReplyMarkup(latest.ReplyMarkup); err != nil {
t.Fatalf("reply markup invalid: %v (%+v)", err, latest.ReplyMarkup)
}
for _, row := range verifyInlineRows(latest) {
for _, button := range row {
if len(button.Data) > domain.MaxCallbackDataLen {
t.Fatalf("callback data %q is %d bytes, limit is %d", button.Data, len(button.Data), domain.MaxCallbackDataLen)
}
}
}
return latest
}
func verifyInlineRows(msg domain.Message) [][]domain.MarkupButton {
if msg.ReplyMarkup == nil {
return nil
}
return msg.ReplyMarkup.Inline
}
// sendToVerifyBot drives the responder synchronously, bypassing the
// OnPrivateMessage goroutine dispatch for determinism (the same shortcut the
// BotFather and @Stickers tests take).
func sendToVerifyBot(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64, text string) domain.Message {
t.Helper()
svc.respondAsVerify(userID, domain.Message{
From: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.VerifyBotUserID},
Body: text,
})
return latestVerifyReply(t, messages, userID)
}
func verifyButtonData(msg domain.Message, label string) ([]byte, bool) {
for _, row := range verifyInlineRows(msg) {
for _, button := range row {
if button.Type == domain.MarkupButtonCallback && strings.Contains(button.Text, label) {
return append([]byte(nil), button.Data...), true
}
}
}
return nil, false
}
// pressVerifyCallbackData drives the internal callback path with raw data, the
// way rpc.Router does once it has validated the click.
func pressVerifyCallbackData(t *testing.T, svc *Service, userID int64, msg domain.Message, data []byte) domain.BotCallbackAnswer {
t.Helper()
if len(data) > domain.MaxCallbackDataLen {
t.Fatalf("callback data too long: %d bytes", len(data))
}
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
ID: 1,
BotUserID: domain.VerifyBotUserID,
UserID: userID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
MessageID: msg.ID,
Data: data,
})
if err != nil {
t.Fatalf("callback query: %v", err)
}
if !handled {
t.Fatal("callback query reported unhandled for @verifybot")
}
return answer
}
func pressVerifyButton(t *testing.T, svc *Service, userID int64, msg domain.Message, label string) domain.BotCallbackAnswer {
t.Helper()
data, found := verifyButtonData(msg, label)
if !found {
t.Fatalf("button %q is not in the keyboard of message %d: %+v", label, msg.ID, msg.ReplyMarkup)
}
return pressVerifyCallbackData(t, svc, userID, msg, data)
}
const (
verifyTestDescription = "Example News is the daily newsroom of the Example Foundation, publishing since 2015."
verifyTestWebsite = "https://news.example.com"
verifyTestPressLinks = "https://press.example.org/story-one\nhttps://media.example.net/story-two"
)
// runVerifyApplication walks the whole dialog up to (but not including) Submit and
// returns the summary message.
func runVerifyApplication(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64) domain.Message {
t.Helper()
intro := sendToVerifyBot(t, svc, messages, userID, "/start")
pressVerifyButton(t, svc, userID, intro, verifyApplyButtonText)
picker := latestVerifyReply(t, messages, userID)
pressVerifyButton(t, svc, userID, picker, "@examplenews")
categories := latestVerifyReply(t, messages, userID)
pressVerifyButton(t, svc, userID, categories, "Media outlet")
if got := latestVerifyReply(t, messages, userID); !strings.Contains(got.Body, "describe the subject") {
t.Fatalf("after category, reply = %q", got.Body)
}
sendToVerifyBot(t, svc, messages, userID, verifyTestDescription)
social := sendToVerifyBot(t, svc, messages, userID, verifyTestWebsite)
if !strings.Contains(social.Body, "social media") {
t.Fatalf("after website, reply = %q", social.Body)
}
pressVerifyButton(t, svc, userID, social, verifySkipButtonText)
if got := latestVerifyReply(t, messages, userID); !strings.Contains(got.Body, "press coverage") {
t.Fatalf("after skipping social links, reply = %q", got.Body)
}
note := sendToVerifyBot(t, svc, messages, userID, verifyTestPressLinks)
pressVerifyButton(t, svc, userID, note, verifySkipButtonText)
return latestVerifyReply(t, messages, userID)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func TestVerifyBotStartExplainsAndOffersApplyButton(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7100")
if !svc.HandlesBot(domain.VerifyBotUserID) {
t.Fatal("service should handle @verifybot")
}
reply := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
for _, want := range []string{"official", "public @username", "/new", "/help"} {
if !strings.Contains(reply.Body, want) {
t.Fatalf("/start reply missing %q: %q", want, reply.Body)
}
}
data, found := verifyButtonData(reply, verifyApplyButtonText)
if !found {
t.Fatalf("/start reply has no apply button: %+v", reply.ReplyMarkup)
}
if !strings.HasPrefix(string(data), verifyCallbackDataPrefix) {
t.Fatalf("callback data %q is not a @verifybot token", data)
}
if fake.starts != 0 {
t.Fatalf("StartDraft called %d times on /start, want 0", fake.starts)
}
}
func TestVerifyBotFullApplicationFlowFilesExactlyOneApplication(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget(), verifyBotTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7101")
summary := runVerifyApplication(t, svc, messages, owner.ID)
for _, want := range []string{"Example News", "Media outlet", verifyTestWebsite, "press.example.org/story-one", verifySubmitButtonText} {
if !strings.Contains(summary.Body, want) {
t.Fatalf("summary missing %q: %q", want, summary.Body)
}
}
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
filed := latestVerifyReply(t, messages, owner.ID)
if !strings.Contains(filed.Body, "#101") || !strings.Contains(filed.Body, "/status") {
t.Fatalf("submitted reply = %q", filed.Body)
}
if fake.submits != 1 || len(fake.apps) != 1 {
t.Fatalf("submits=%d applications=%d, want exactly one of each", fake.submits, len(fake.apps))
}
app := fake.apps[101]
if app.Status != domain.VerificationStatusSubmitted {
t.Fatalf("application status = %q", app.Status)
}
if app.Category != "media" || app.OfficialWebsite != verifyTestWebsite || len(app.PressLinks) != 2 {
t.Fatalf("stored application = %+v", app)
}
if app.TargetID != 7001 || app.TargetType != domain.VerificationTargetChannel {
t.Fatalf("stored target = %s/%d", app.TargetType, app.TargetID)
}
}
// The target buttons must not leak the peer they stand for: the whole point of the
// token table is that a click cannot name a peer at all.
func TestVerifyBotCallbackDataCarriesNoTargetIdentity(t *testing.T) {
target := verifyChannelTarget()
fake := newFakeVerification(target)
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7102")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
picker := latestVerifyReply(t, messages, owner.ID)
buttons := 0
for _, row := range verifyInlineRows(picker) {
for _, button := range row {
buttons++
data := string(button.Data)
// Structural assertion rather than a substring hunt: the data is the
// prefix plus an opaque hex token and nothing else, so it is incapable of
// encoding a peer id, an access hash, a username or a peer type.
token, ok := strings.CutPrefix(data, verifyCallbackDataPrefix)
if !ok || len(token) != 2*verifyOptionTokenBytes {
t.Fatalf("callback data %q is not <prefix><token>", data)
}
for _, c := range token {
if !strings.ContainsRune("0123456789abcdef", c) {
t.Fatalf("callback data %q carries non-token bytes", data)
}
}
for _, forbidden := range []string{target.Username, string(target.Type)} {
if strings.Contains(data, forbidden) {
t.Fatalf("callback data %q leaks %q", data, forbidden)
}
}
}
}
if buttons == 0 {
t.Fatal("target picker has no buttons")
}
// The token is minted per render, so the same target never has a stable,
// guessable identifier on the wire.
firstData, _ := verifyButtonData(picker, "@"+target.Username)
sendToVerifyBot(t, svc, messages, owner.ID, "/new")
secondData, found := verifyButtonData(latestVerifyReply(t, messages, owner.ID), "@"+target.Username)
if !found {
t.Fatal("re-rendered picker has no target button")
}
if string(firstData) == string(secondData) {
t.Fatalf("token %q is stable across renders", firstData)
}
}
func TestVerifyBotRepeatedButtonPressIsIdempotent(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7103")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
picker := latestVerifyReply(t, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
first := latestVerifyReply(t, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
second := latestVerifyReply(t, messages, owner.ID)
if first.Body != second.Body {
t.Fatalf("repeat target press changed the answer:\nfirst = %q\nsecond = %q", first.Body, second.Body)
}
if len(fake.apps) != 1 {
t.Fatalf("applications = %d after pressing the same target twice, want 1", len(fake.apps))
}
}
// The same must hold for the terminal action: a double-tapped Submit files one
// application and repeats the same confirmation.
func TestVerifyBotRepeatedSubmitFilesOneApplication(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7121")
summary := runVerifyApplication(t, svc, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
firstFiled := latestVerifyReply(t, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
secondFiled := latestVerifyReply(t, messages, owner.ID)
if firstFiled.Body != secondFiled.Body {
t.Fatalf("repeat submit changed the answer:\nfirst = %q\nsecond = %q", firstFiled.Body, secondFiled.Body)
}
if fake.submits != 1 || len(fake.apps) != 1 {
t.Fatalf("submits=%d applications=%d after double submit, want 1/1", fake.submits, len(fake.apps))
}
}
func TestVerifyBotForgedCallbackTokenIsRefused(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7104")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
before := len(verifyBotReplies(t, messages, owner.ID))
// A token that was never minted for this user, and a plausible-looking
// hand-written one: both resolve only through the user's own state, so both are
// refused without any side effect.
for _, data := range [][]byte{
[]byte(verifyCallbackDataPrefix + "deadbeefcafe"),
[]byte("tgt:channel:7001"),
[]byte(verifyCallbackDataPrefix),
} {
answer := pressVerifyCallbackData(t, svc, owner.ID, intro, data)
if !answer.Alert || !strings.Contains(answer.Message, "no longer active") {
t.Fatalf("forged data %q answered %+v, want an explaining alert", data, answer)
}
}
if got := len(verifyBotReplies(t, messages, owner.ID)); got != before {
t.Fatalf("forged callbacks produced %d new messages", got-before)
}
if fake.starts != 0 || len(fake.apps) != 0 {
t.Fatalf("forged callbacks touched the service: starts=%d apps=%d", fake.starts, len(fake.apps))
}
}
// A token minted for one applicant must be meaningless for another: resolution
// goes through the clicking user's own chat state only.
func TestVerifyBotTokenFromAnotherUserIsRefused(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
victim := newOwner(t, users, "+7105")
attacker := newOwner(t, users, "+7106")
intro := sendToVerifyBot(t, svc, messages, victim.ID, "/start")
pressVerifyButton(t, svc, victim.ID, intro, verifyApplyButtonText)
picker := latestVerifyReply(t, messages, victim.ID)
stolen, found := verifyButtonData(picker, "@examplenews")
if !found {
t.Fatal("victim picker has no target button")
}
sendToVerifyBot(t, svc, messages, attacker.ID, "/start")
attackerIntro := latestVerifyReply(t, messages, attacker.ID)
answer := pressVerifyCallbackData(t, svc, attacker.ID, attackerIntro, stolen)
if !answer.Alert {
t.Fatalf("stolen token answered %+v, want an alert", answer)
}
for _, app := range fake.apps {
if app.ApplicantUserID == attacker.ID {
t.Fatalf("stolen token created an application for the attacker: %+v", app)
}
}
}
func TestVerifyBotPressLinkMinimumIsEnforced(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7107")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
social := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestWebsite)
pressVerifyButton(t, svc, owner.ID, social, verifySkipButtonText)
tooFew := sendToVerifyBot(t, svc, messages, owner.ID, "https://press.example.org/story-one")
if !strings.Contains(tooFew.Body, strconv.Itoa(domain.MinVerificationPressLinks)) {
t.Fatalf("single press link accepted or unexplained: %q", tooFew.Body)
}
if len(fake.apps[101].PressLinks) != 0 {
t.Fatalf("press links stored despite refusal: %+v", fake.apps[101].PressLinks)
}
accepted := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestPressLinks)
if !strings.Contains(accepted.Body, "reviewers should know") {
t.Fatalf("two press links did not advance the dialog: %q", accepted.Body)
}
if len(fake.apps[101].PressLinks) != 2 {
t.Fatalf("press links = %+v, want two stored", fake.apps[101].PressLinks)
}
}
func TestVerifyBotRejectsInvalidLinksWithAReason(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7108")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
// Not a URL, a non-web scheme, and an address the domain refuses as
// non-public (which is also what keeps a submitted link from becoming an SSRF
// probe).
for _, bad := range []string{"my site", "ftp://example.com", "http://127.0.0.1/admin", "https://localhost/x"} {
reply := sendToVerifyBot(t, svc, messages, owner.ID, bad)
if !strings.Contains(reply.Body, "http:// or https://") {
t.Fatalf("website %q answered %q, want the link rules", bad, reply.Body)
}
if fake.apps[101].OfficialWebsite != "" {
t.Fatalf("website %q was stored", bad)
}
}
// A short description is refused with the actual bar, not a generic error.
shortDesc := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestWebsite)
if !strings.Contains(shortDesc.Body, "social media") {
t.Fatalf("valid website not accepted: %q", shortDesc.Body)
}
}
func TestVerifyBotDescriptionMinimumIsExplained(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7109")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
reply := sendToVerifyBot(t, svc, messages, owner.ID, "a newsroom")
if !strings.Contains(reply.Body, strconv.Itoa(domain.MinVerificationDescriptionLength)) {
t.Fatalf("short description answered %q, want the minimum length", reply.Body)
}
if fake.apps[101].Description != "" {
t.Fatalf("short description was stored: %q", fake.apps[101].Description)
}
}
func TestVerifyBotGlobalCommandsWorkMidStep(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7110")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
// /help in the middle of the description step answers help and keeps the step.
help := sendToVerifyBot(t, svc, messages, owner.ID, "/help")
if help.Body != verifyBotHelpText() {
t.Fatalf("/help mid-step = %q", help.Body)
}
status := sendToVerifyBot(t, svc, messages, owner.ID, "/status")
if !strings.Contains(status.Body, "#101") {
t.Fatalf("/status mid-step = %q", status.Body)
}
resumed := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
if !strings.Contains(resumed.Body, "official website") {
t.Fatalf("description not accepted after global commands: %q", resumed.Body)
}
if fake.apps[101].Description != verifyTestDescription {
t.Fatalf("description = %q, want the step to have survived", fake.apps[101].Description)
}
// An unknown command is never swallowed as a field value.
unknown := sendToVerifyBot(t, svc, messages, owner.ID, "/nope")
if !strings.Contains(unknown.Body, "do not know that command") {
t.Fatalf("unknown command = %q", unknown.Body)
}
if fake.apps[101].OfficialWebsite != "" {
t.Fatalf("unknown command stored as a website: %q", fake.apps[101].OfficialWebsite)
}
}
func TestVerifyBotStatusListsApplicationsWithoutInternalNotes(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7111")
if empty := sendToVerifyBot(t, svc, messages, owner.ID, "/status"); empty.Body != verifyNoApplicationsText {
t.Fatalf("/status without applications = %q", empty.Body)
}
fake.apps[500] = domain.VerificationApplication{
ID: 500, ApplicantUserID: owner.ID,
TargetType: domain.VerificationTargetChannel, TargetID: 7001,
TargetTitle: "Example News", TargetUsername: "examplenews",
Status: domain.VerificationStatusRejected,
DecisionReason: "the linked coverage does not mention the channel",
InternalNote: "applicant argued with the reviewer",
ReviewedAt: time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC),
Version: 4,
}
fake.apps[501] = domain.VerificationApplication{
ID: 501, ApplicantUserID: owner.ID,
TargetType: domain.VerificationTargetBot, TargetID: 8002, TargetUsername: "examplebot",
Status: domain.VerificationStatusSubmitted,
SubmittedAt: time.Date(2026, 7, 25, 9, 0, 0, 0, time.UTC),
Version: 2,
}
reply := sendToVerifyBot(t, svc, messages, owner.ID, "/status")
for _, want := range []string{"#500", "#501", "@examplebot", "does not mention the channel", "2026-07-20"} {
if !strings.Contains(reply.Body, want) {
t.Fatalf("/status missing %q: %q", want, reply.Body)
}
}
if strings.Contains(reply.Body, "argued with the reviewer") {
t.Fatalf("/status leaked the internal note: %q", reply.Body)
}
}
func TestVerifyBotCancelWithdrawsTheOpenApplication(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7112")
if nothing := sendToVerifyBot(t, svc, messages, owner.ID, "/cancel"); nothing.Body != verifyNothingToCancelText {
t.Fatalf("/cancel with nothing open = %q", nothing.Body)
}
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
cancelled := sendToVerifyBot(t, svc, messages, owner.ID, "/cancel")
if !strings.Contains(cancelled.Body, "#101") || !strings.Contains(cancelled.Body, "withdrawn") {
t.Fatalf("/cancel = %q", cancelled.Body)
}
if fake.apps[101].Status != domain.VerificationStatusCancelled {
t.Fatalf("application status = %q after /cancel", fake.apps[101].Status)
}
// The dialog is gone with it, so a stale button cannot revive it.
idle := sendToVerifyBot(t, svc, messages, owner.ID, "still here?")
if idle.Body != verifyBotIdleText {
t.Fatalf("after /cancel, plain text = %q", idle.Body)
}
}
func TestVerifyBotCancelButtonWithdrawsFromInsideTheForm(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7113")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
categories := latestVerifyReply(t, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, categories, verifyCancelButtonText)
if reply := latestVerifyReply(t, messages, owner.ID); !strings.Contains(reply.Body, "withdrawn") {
t.Fatalf("cancel button = %q", reply.Body)
}
if fake.apps[101].Status != domain.VerificationStatusCancelled {
t.Fatalf("application status = %q after the cancel button", fake.apps[101].Status)
}
}
func TestVerifyBotHelpAndIdleText(t *testing.T) {
fake := newFakeVerification()
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7114")
help := sendToVerifyBot(t, svc, messages, owner.ID, "/help")
for _, want := range []string{"/new", "/status", "/cancel", "/help"} {
if !strings.Contains(help.Body, want) {
t.Fatalf("/help missing %q: %q", want, help.Body)
}
}
assertReplyEntityText(t, help, domain.MessageEntityBotCommand, "/new")
// Nothing to verify: the requirement is stated instead of an empty picker.
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyNoTargetsText {
t.Fatalf("/new with no candidates = %q", reply.Body)
}
}
func TestVerifyBotShowsIneligibleTargetsWithTheirReason(t *testing.T) {
verified := verifyChannelTarget()
verified.Eligible = false
verified.Verified = true
verified.Reason = domain.ErrVerificationTargetAlreadyVerified.Error()
fake := newFakeVerification(verified)
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7115")
picker := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
if !strings.Contains(picker.Body, "cannot be filed") && !strings.Contains(picker.Body, verifyNoEligibleText) {
t.Fatalf("picker with only ineligible candidates = %q", picker.Body)
}
answer := pressVerifyButton(t, svc, owner.ID, picker, "unavailable")
if !answer.Alert || !strings.Contains(answer.Message, "already verified") {
t.Fatalf("ineligible button answered %+v, want the reason", answer)
}
if fake.starts != 0 || len(fake.apps) != 0 {
t.Fatalf("ineligible button reached the service: starts=%d apps=%d", fake.starts, len(fake.apps))
}
}
func TestVerifyBotNewResumesTheOpenDraft(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7116")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
resumed := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
if !strings.Contains(resumed.Body, "#101") || !strings.Contains(resumed.Body, "official website") {
t.Fatalf("/new mid-draft = %q, want a resume at the website step", resumed.Body)
}
if len(fake.apps) != 1 {
t.Fatalf("applications = %d after /new mid-draft, want 1", len(fake.apps))
}
}
func TestVerifyBotWithoutServiceReportsUnavailable(t *testing.T) {
svc, users, messages := newVerifyBotTestService(t, nil)
owner := newOwner(t, users, "+7117")
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyUnavailableText {
t.Fatalf("/new without a verification service = %q", reply.Body)
}
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/help"); reply.Body != verifyBotHelpText() {
t.Fatalf("/help without a verification service = %q", reply.Body)
}
}
func TestVerifyBotCallbackForForeignBotIsNotClaimed(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, _, _ := newVerifyBotTestService(t, fake)
if _, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
BotUserID: 555111, UserID: 900, Data: []byte("vb:whatever"),
}); handled || err != nil {
t.Fatalf("foreign bot callback handled=%v err=%v, want (false, nil)", handled, err)
}
// A built-in bot with no keyboards is claimed but answered empty, so the click
// cannot hang for the whole callback timeout.
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
BotUserID: domain.BotFatherUserID, UserID: 900, Data: []byte("x"),
})
if !handled || err != nil || answer.Message != "" {
t.Fatalf("BotFather callback = (%+v, %v, %v)", answer, handled, err)
}
}
func TestVerifyBotSendVerificationNoticeNeverLeaksInternalNote(t *testing.T) {
fake := newFakeVerification()
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7118")
ctx := context.Background()
app := domain.VerificationApplication{
ID: 4242, ApplicantUserID: owner.ID,
TargetType: domain.VerificationTargetChannel, TargetID: 7001,
TargetTitle: "Example News", TargetUsername: "examplenews",
DecisionReason: "the coverage you linked does not mention the channel",
InternalNote: "reviewer note: applicant is a repeat filer, escalate next time",
}
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindApproved); err != nil {
t.Fatalf("approved notice: %v", err)
}
approved := latestVerifyReply(t, messages, owner.ID)
for _, want := range []string{"#4242", "Example News", "@examplenews", "approved"} {
if !strings.Contains(approved.Body, want) {
t.Fatalf("approved notice missing %q: %q", want, approved.Body)
}
}
if strings.Contains(approved.Body, "repeat filer") {
t.Fatalf("approved notice leaked the internal note: %q", approved.Body)
}
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindRejected); err != nil {
t.Fatalf("rejected notice: %v", err)
}
rejected := latestVerifyReply(t, messages, owner.ID)
if !strings.Contains(rejected.Body, "#4242") || !strings.Contains(rejected.Body, "does not mention the channel") {
t.Fatalf("rejected notice = %q", rejected.Body)
}
if strings.Contains(rejected.Body, "repeat filer") || strings.Contains(rejected.Body, "escalate") {
t.Fatalf("rejected notice leaked the internal note: %q", rejected.Body)
}
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindRevoked); err != nil {
t.Fatalf("revoked notice: %v", err)
}
revoked := latestVerifyReply(t, messages, owner.ID)
if !strings.Contains(revoked.Body, "revoked") || strings.Contains(revoked.Body, "repeat filer") {
t.Fatalf("revoked notice = %q", revoked.Body)
}
// An unknown kind is reported rather than delivered as an empty message: the
// outbox row must stay pending instead of being marked delivered.
before := len(verifyBotReplies(t, messages, owner.ID))
if err := svc.SendVerificationNotice(ctx, owner.ID, app, "teleported"); err == nil {
t.Fatal("unknown notice kind reported success")
}
if got := len(verifyBotReplies(t, messages, owner.ID)); got != before {
t.Fatalf("unknown notice kind sent %d messages", got-before)
}
if err := svc.SendVerificationNotice(ctx, 0, app, verificationapp.NoticeKindApproved); err == nil {
t.Fatal("empty recipient reported success")
}
}
func TestVerifyBotSubmitBouncesAnIncompleteApplication(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7119")
summary := runVerifyApplication(t, svc, messages, owner.ID)
// Simulate a payload that lost a required field between rendering the summary
// and the press: Submit must send the applicant back, not file a broken record.
app := fake.apps[101]
app.PressLinks = nil
app.Version++
fake.apps[101] = app
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
bounced := latestVerifyReply(t, messages, owner.ID)
if !strings.Contains(bounced.Body, "press coverage") {
t.Fatalf("incomplete submit = %q, want the press step", bounced.Body)
}
if fake.submits != 0 {
t.Fatalf("submits = %d for an incomplete application", fake.submits)
}
}
func TestVerifyBotPolicyRefusalsAreExplained(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
fake.startErr = domain.ErrVerificationRateLimited
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7120")
picker := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
if reply := latestVerifyReply(t, messages, owner.ID); !strings.Contains(reply.Body, "limit on open applications") {
t.Fatalf("rate-limited StartDraft = %q", reply.Body)
}
fake.targetsErr = verificationapp.ErrDisabled
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyUnavailableText {
t.Fatalf("disabled verification = %q", reply.Body)
}
if !errors.Is(fake.targetsErr, verificationapp.ErrDisabled) {
t.Fatal("test setup lost the sentinel")
}
}