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

1034 lines
42 KiB
Go

package bots
import (
"context"
"sort"
"strings"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// ---------------------------------------------------------------------------
// Fake third-party verification service
// ---------------------------------------------------------------------------
// fakeCustomVerification is an in-memory stand-in for app/botverification.Service.
// It keeps the properties the dialog leans on: verifier status that an operator may
// or may not have granted, one pending application per (verifier, peer), marks that
// exist independently of the applications, and a revoke that reports whether it
// actually removed anything.
type fakeCustomVerification struct {
settings domain.BotVerifierSettings
settingsFound bool
settingsErr error
requests map[int64]domain.CustomVerificationRequest
marks map[domain.Peer]domain.CustomVerification
nextID int64
creates int
revokes int
revokedPeers []domain.Peer
createErr error
}
func newFakeCustomVerification() *fakeCustomVerification {
return &fakeCustomVerification{
requests: make(map[int64]domain.CustomVerificationRequest),
marks: make(map[domain.Peer]domain.CustomVerification),
nextID: 700,
}
}
// activated grants the bot the verifier status an operator would grant in the admin
// panel.
func (f *fakeCustomVerification) activated() *fakeCustomVerification {
f.settings = verifierTestSettings()
f.settingsFound = true
return f
}
func verifierTestSettings() domain.BotVerifierSettings {
return domain.BotVerifierSettings{
BotID: domain.VerifierBotUserID,
IconDocumentID: 990001,
CompanyName: "Example Trust",
DefaultDescription: "Identity checked by Example Trust",
Enabled: true,
GrantedBy: "operator",
GrantReason: "reference verifier of this deployment",
Version: 1,
}
}
func (f *fakeCustomVerification) VerifierSettings(_ context.Context, botID int64) (domain.BotVerifierSettings, error) {
if f.settingsErr != nil {
return domain.BotVerifierSettings{}, f.settingsErr
}
if botID != domain.VerifierBotUserID || !f.settingsFound {
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
}
return f.settings, nil
}
func (f *fakeCustomVerification) CreateRequest(_ context.Context, req domain.CustomVerificationRequest) (domain.CustomVerificationRequest, error) {
f.creates++
if f.createErr != nil {
return domain.CustomVerificationRequest{}, f.createErr
}
// The record the bot hands over must be a valid one: this is what keeps the dialog
// from filing paperwork the service would have to reject.
if err := req.Validate(); err != nil {
return domain.CustomVerificationRequest{}, err
}
for _, existing := range f.requests {
if existing.VerifierBotID == req.VerifierBotID && existing.Peer == req.Peer &&
existing.Status == domain.CustomVerificationPending {
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestExists
}
}
f.nextID++
req.ID = f.nextID
req.CreatedAt = time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC)
req.Version = 1
f.requests[req.ID] = req
return req, nil
}
func (f *fakeCustomVerification) ApplicantRequests(_ context.Context, applicantUserID int64, limit int) ([]domain.CustomVerificationRequest, error) {
out := make([]domain.CustomVerificationRequest, 0, len(f.requests))
for _, req := range f.requests {
if req.ApplicantUserID == applicantUserID {
out = append(out, req)
}
}
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 *fakeCustomVerification) PendingRequest(_ context.Context, verifierBotID int64, peer domain.Peer) (domain.CustomVerificationRequest, error) {
for _, req := range f.requests {
if req.VerifierBotID == verifierBotID && req.Peer == peer && req.Status == domain.CustomVerificationPending {
return req, nil
}
}
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
}
func (f *fakeCustomVerification) RevokeMark(_ context.Context, verifierBotID int64, peer domain.Peer) (bool, error) {
f.revokes++
f.revokedPeers = append(f.revokedPeers, peer)
mark, found := f.marks[peer]
if !found || mark.VerifierBotID != verifierBotID {
return false, nil
}
delete(f.marks, peer)
for id, req := range f.requests {
if req.Peer == peer && req.Status == domain.CustomVerificationApproved {
req.Status = domain.CustomVerificationRevoked
req.Version++
f.requests[id] = req
}
}
return true, nil
}
func (f *fakeCustomVerification) Marks(_ context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
out := make([]domain.CustomVerification, 0, len(f.marks))
for peer, mark := range f.marks {
if filter.VerifierBotID != 0 && mark.VerifierBotID != filter.VerifierBotID {
continue
}
if filter.PeerType != "" && peer.Type != filter.PeerType {
continue
}
if filter.PeerID != 0 && peer.ID != filter.PeerID {
continue
}
out = append(out, mark)
}
sort.Slice(out, func(i, j int) bool { return out[i].Peer.ID < out[j].Peer.ID })
if filter.Limit > 0 && len(out) > filter.Limit {
out = out[:filter.Limit]
}
return out, nil
}
// grantMark is what an operator's approval leaves behind: an approved application
// and the mark itself.
func (f *fakeCustomVerification) grantMark(applicantUserID int64, peer domain.Peer, title, username string) domain.CustomVerificationRequest {
f.nextID++
req := domain.CustomVerificationRequest{
ID: f.nextID,
VerifierBotID: domain.VerifierBotUserID,
ApplicantUserID: applicantUserID,
Peer: peer,
PeerTitle: title,
PeerUsername: username,
Reason: "the newsroom of the Example Foundation",
Status: domain.CustomVerificationApproved,
DecidedBy: "operator",
InternalNote: "operator note: applicant escalated by email, watch this one",
CreatedAt: time.Date(2026, 7, 18, 9, 0, 0, 0, time.UTC),
ApprovedAt: time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC),
Version: 2,
}
f.requests[req.ID] = req
f.marks[peer] = domain.CustomVerification{
ID: req.ID,
VerifierBotID: domain.VerifierBotUserID,
Peer: peer,
IconDocumentID: 990001,
Description: "Identity checked by Example Trust",
GrantedByUserID: 1,
Version: 1,
}
return req
}
var _ customVerifications = (*fakeCustomVerification)(nil)
// fakeVerifierTargets is the directory of the applicant's own peers.
type fakeVerifierTargets struct {
targets []domain.VerificationTarget
err error
calls int
}
func (f *fakeVerifierTargets) EligibleTargets(_ context.Context, applicantUserID int64) ([]domain.VerificationTarget, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
if applicantUserID <= 0 {
return nil, domain.ErrCustomVerificationRequestInvalid
}
return append([]domain.VerificationTarget(nil), f.targets...), nil
}
var _ verifierBotTargets = (*fakeVerifierTargets)(nil)
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
func verifierTestTargets(owner domain.User) *fakeVerifierTargets {
return &fakeVerifierTargets{targets: []domain.VerificationTarget{
// Eligible=false throughout on purpose: that flag answers "may this peer be
// filed for the official checkmark", which is a different mechanism, and the
// third-party picker must not be filtered by it.
{Type: domain.VerificationTargetChannel, ID: 7001, Title: "Example News", Username: "examplenews"},
{Type: domain.VerificationTargetBot, ID: 8002, Title: "Example Bot", Username: "examplebot"},
{Type: domain.VerificationTargetUser, ID: owner.ID, Title: "Owner"},
}}
}
const verifierNewsPeerID = 7001
func verifierNewsPeer() domain.Peer {
return domain.Peer{Type: domain.PeerTypeChannel, ID: verifierNewsPeerID}
}
func newVerifierBotTestService(t *testing.T, cv customVerifications, 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{WithCustomVerification(cv)}, opts...)
return NewService(users, bots, messages, all...), users, messages
}
// verifierReplies returns every @verifierbot message in the user's box, oldest
// first.
func verifierReplies(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.VerifierBotUserID},
Limit: 200,
})
if err != nil {
t.Fatalf("list @verifierbot history: %v", err)
}
out := make([]domain.Message, 0, len(list.Messages))
for _, msg := range list.Messages {
if msg.From.ID == domain.VerifierBotUserID {
out = append(out, msg)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func latestVerifierReply(t *testing.T, messages *memory.MessageStore, userID int64) domain.Message {
t.Helper()
replies := verifierReplies(t, messages, userID)
if len(replies) == 0 {
t.Fatal("no @verifierbot 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 verifierRowsOf(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 verifierRowsOf(msg domain.Message) [][]domain.MarkupButton {
if msg.ReplyMarkup == nil {
return nil
}
return msg.ReplyMarkup.Inline
}
// sendToVerifierBot drives the responder synchronously, bypassing the
// OnPrivateMessage goroutine dispatch for determinism (the same shortcut the
// BotFather, @Stickers and @verifybot tests take).
func sendToVerifierBot(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64, text string) domain.Message {
t.Helper()
svc.respondAsVerifier(userID, domain.Message{
From: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.VerifierBotUserID},
Body: text,
})
return latestVerifierReply(t, messages, userID)
}
func verifierDataOf(msg domain.Message, label string) ([]byte, bool) {
for _, row := range verifierRowsOf(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
}
// pressVerifierData drives the internal callback path with raw data, the way
// rpc.Router does once it has validated the click.
func pressVerifierData(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.VerifierBotUserID,
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 @verifierbot")
}
return answer
}
func pressVerifierButton(t *testing.T, svc *Service, userID int64, msg domain.Message, label string) domain.BotCallbackAnswer {
t.Helper()
data, found := verifierDataOf(msg, label)
if !found {
t.Fatalf("button %q is not in the keyboard of message %d: %+v", label, msg.ID, msg.ReplyMarkup)
}
return pressVerifierData(t, svc, userID, msg, data)
}
const verifierTestReason = "Example News is the daily newsroom of the Example Foundation and has published since 2015."
// runVerifierApplication walks the dialog up to (but not including) Confirm and
// returns the summary message.
func runVerifierApplication(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64) domain.Message {
t.Helper()
intro := sendToVerifierBot(t, svc, messages, userID, "/start")
pressVerifierButton(t, svc, userID, intro, verifierApplyButtonText)
picker := latestVerifierReply(t, messages, userID)
pressVerifierButton(t, svc, userID, picker, "@examplenews")
prompt := latestVerifierReply(t, messages, userID)
if !strings.Contains(prompt.Body, "why Example Trust should vouch for") {
t.Fatalf("after picking the subject, reply = %q", prompt.Body)
}
return sendToVerifierBot(t, svc, messages, userID, verifierTestReason)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
// With no verifier row granted by an operator the bot still explains the mechanism,
// but it must not pretend an application is possible.
func TestVerifierBotStartWithoutVerifierStatusIsHonest(t *testing.T) {
cv := newFakeCustomVerification()
svc, users, messages := newVerifierBotTestService(t, cv)
owner := newOwner(t, users, "+7200")
svc.SetVerifierTargets(verifierTestTargets(owner))
if !svc.HandlesBot(domain.VerifierBotUserID) {
t.Fatal("service should handle @verifierbot")
}
reply := sendToVerifierBot(t, svc, messages, owner.ID, "/start")
for _, want := range []string{
"THIRD-PARTY",
"NOT the official",
"cannot accept applications yet",
"an operator has to give me an icon",
} {
if !strings.Contains(reply.Body, want) {
t.Fatalf("/start without verifier status is missing %q: %q", want, reply.Body)
}
}
if len(verifierRowsOf(reply)) != 0 {
t.Fatalf("/start without verifier status offered buttons: %+v", reply.ReplyMarkup)
}
// /verify is refused the same way, and nothing is filed.
verify := sendToVerifierBot(t, svc, messages, owner.ID, "/verify")
if !strings.Contains(verify.Body, "cannot accept applications yet") {
t.Fatalf("/verify without verifier status = %q", verify.Body)
}
if cv.creates != 0 {
t.Fatalf("CreateRequest called %d times without verifier status", cv.creates)
}
// An operator kill switch on an otherwise complete row reads the same way.
cv.activated()
cv.settings.Enabled = false
disabled := sendToVerifierBot(t, svc, messages, owner.ID, "/start")
if !strings.Contains(disabled.Body, "cannot accept applications yet") || len(verifierRowsOf(disabled)) != 0 {
t.Fatalf("/start with a disabled verifier row = %q (%+v)", disabled.Body, disabled.ReplyMarkup)
}
}
// TestVerifierBotHiddenByThirdPartyVerificationFlag proves
// config.HideThirdPartyVerification actually silences @marksbot: with the
// option set, HandlesBot must refuse the bot's id entirely, so
// OnPrivateMessage never even reaches the dialog logic -- not just an error
// reply, no reply at all, matching every other unhandled bot id.
func TestVerifierBotHiddenByThirdPartyVerificationFlag(t *testing.T) {
cv := newFakeCustomVerification()
svc, users, messages := newVerifierBotTestService(t, cv, WithHideThirdPartyVerification(true))
owner := newOwner(t, users, "+7201")
if svc.HandlesBot(domain.VerifierBotUserID) {
t.Fatal("service should refuse @verifierbot while third-party verification is hidden")
}
svc.OnPrivateMessage(context.Background(), domain.VerifierBotUserID, domain.Message{
From: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
Body: "/start",
}, domain.ClientSessionMetadata{})
if replies := verifierReplies(t, messages, owner.ID); len(replies) != 0 {
t.Fatalf("hidden @verifierbot replied: %+v", replies)
}
}
func TestVerifierBotStartWithActiveVerifierShowsCompanyAndMark(t *testing.T) {
cv := newFakeCustomVerification().activated()
svc, users, messages := newVerifierBotTestService(t, cv)
owner := newOwner(t, users, "+7201")
svc.SetVerifierTargets(verifierTestTargets(owner))
reply := sendToVerifierBot(t, svc, messages, owner.ID, "/start")
for _, want := range []string{
"THIRD-PARTY",
"NOT the official",
"Example Trust",
"Identity checked by Example Trust",
"/verify",
} {
if !strings.Contains(reply.Body, want) {
t.Fatalf("/start missing %q: %q", want, reply.Body)
}
}
data, found := verifierDataOf(reply, verifierApplyButtonText)
if !found {
t.Fatalf("/start has no apply button: %+v", reply.ReplyMarkup)
}
// The button is an opaque token and nothing else: it cannot name a peer at all.
token, ok := strings.CutPrefix(string(data), verifierCallbackDataPrefix)
if !ok || len(token) != 2*verifierOptionTokenBytes {
t.Fatalf("callback data %q is not <prefix><token>", data)
}
if cv.creates != 0 {
t.Fatalf("CreateRequest called %d times on /start", cv.creates)
}
}
func TestVerifierBotFullFlowFilesExactlyOneRequest(t *testing.T) {
cv := newFakeCustomVerification().activated()
svc, users, messages := newVerifierBotTestService(t, cv)
owner := newOwner(t, users, "+7202")
targets := verifierTestTargets(owner)
svc.SetVerifierTargets(targets)
summary := runVerifierApplication(t, svc, messages, owner.ID)
for _, want := range []string{
"Example Trust",
"Example News (@examplenews)",
"Identity checked by Example Trust",
verifierTestReason,
"not the official",
verifierConfirmButtonText,
} {
if !strings.Contains(summary.Body, want) {
t.Fatalf("summary missing %q: %q", want, summary.Body)
}
}
pressVerifierButton(t, svc, owner.ID, summary, verifierConfirmButtonText)
filed := latestVerifierReply(t, messages, owner.ID)
for _, want := range []string{"#701", "Example News", "operator", "/status"} {
if !strings.Contains(filed.Body, want) {
t.Fatalf("filed reply missing %q: %q", want, filed.Body)
}
}
if cv.creates != 1 || len(cv.requests) != 1 {
t.Fatalf("creates=%d requests=%d, want exactly one of each", cv.creates, len(cv.requests))
}
req := cv.requests[701]
if req.VerifierBotID != domain.VerifierBotUserID || req.ApplicantUserID != owner.ID {
t.Fatalf("stored request identity = %+v", req)
}
if req.Peer != verifierNewsPeer() {
t.Fatalf("stored peer = %+v, want channel 7001", req.Peer)
}
if req.PeerUsername != "examplenews" || req.Reason != verifierTestReason {
t.Fatalf("stored payload = %+v", req)
}
if req.Status != domain.CustomVerificationPending {
t.Fatalf("stored status = %q, want pending", req.Status)
}
// The description is the verifier's own, resolved at grant time; the application
// must not freeze a copy of it.
if req.RequestedDescription != "" {
t.Fatalf("request carries a requested description %q", req.RequestedDescription)
}
if req.CorrelationID == "" {
t.Fatalf("request carries no correlation id: %+v", req)
}
}
// Both the closed choices and the terminal action must survive a double tap: the
// same answer, and no second application.
func TestVerifierBotRepeatedPressesAreIdempotent(t *testing.T) {
cv := newFakeCustomVerification().activated()
svc, users, messages := newVerifierBotTestService(t, cv)
owner := newOwner(t, users, "+7203")
svc.SetVerifierTargets(verifierTestTargets(owner))
intro := sendToVerifierBot(t, svc, messages, owner.ID, "/start")
pressVerifierButton(t, svc, owner.ID, intro, verifierApplyButtonText)
picker := latestVerifierReply(t, messages, owner.ID)
pressVerifierButton(t, svc, owner.ID, picker, "@examplenews")
first := latestVerifierReply(t, messages, owner.ID)
pressVerifierButton(t, svc, owner.ID, picker, "@examplenews")
second := latestVerifierReply(t, messages, owner.ID)
if first.Body != second.Body {
t.Fatalf("repeat subject press changed the answer:\nfirst = %q\nsecond = %q", first.Body, second.Body)
}
summary := sendToVerifierBot(t, svc, messages, owner.ID, verifierTestReason)
pressVerifierButton(t, svc, owner.ID, summary, verifierConfirmButtonText)
firstFiled := latestVerifierReply(t, messages, owner.ID)
pressVerifierButton(t, svc, owner.ID, summary, verifierConfirmButtonText)
secondFiled := latestVerifierReply(t, messages, owner.ID)
if firstFiled.Body != secondFiled.Body {
t.Fatalf("repeat confirm changed the answer:\nfirst = %q\nsecond = %q", firstFiled.Body, secondFiled.Body)
}
if cv.creates != 1 || len(cv.requests) != 1 {
t.Fatalf("creates=%d requests=%d after a double confirm, want 1/1", cv.creates, len(cv.requests))
}
// Picking the same subject again once it is pending reports the open application
// instead of filing a second one.
again := sendToVerifierBot(t, svc, messages, owner.ID, "/verify")
pressVerifierButton(t, svc, owner.ID, again, "@examplenews")
pendingReply := latestVerifierReply(t, messages, owner.ID)
if !strings.Contains(pendingReply.Body, "#701") || !strings.Contains(pendingReply.Body, "already with the operator") {
t.Fatalf("re-picking a pending subject = %q", pendingReply.Body)
}
if cv.creates != 1 {
t.Fatalf("creates=%d after re-picking a pending subject", cv.creates)
}
}
func TestVerifierBotForgedCallbackTokenIsRefused(t *testing.T) {
cv := newFakeCustomVerification().activated()
svc, users, messages := newVerifierBotTestService(t, cv)
owner := newOwner(t, users, "+7204")
svc.SetVerifierTargets(verifierTestTargets(owner))
intro := sendToVerifierBot(t, svc, messages, owner.ID, "/start")
before := len(verifierReplies(t, messages, owner.ID))
for _, data := range [][]byte{
[]byte(verifierCallbackDataPrefix + "deadbeefcafe"),
[]byte("tgt:channel:7001"),
[]byte(verifierCallbackDataPrefix),
[]byte(verifyCallbackDataPrefix + "deadbeefcafe"),
} {
answer := pressVerifierData(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(verifierReplies(t, messages, owner.ID)); got != before {
t.Fatalf("forged callbacks produced %d new messages", got-before)
}
if cv.creates != 0 || cv.revokes != 0 || len(cv.requests) != 0 {
t.Fatalf("forged callbacks touched the service: creates=%d revokes=%d requests=%d",
cv.creates, cv.revokes, len(cv.requests))
}
// A token minted for one applicant is meaningless for another.
attacker := newOwner(t, users, "+7205")
stolen, found := verifierDataOf(intro, verifierApplyButtonText)
if !found {
t.Fatal("intro has no apply button")
}
attackerIntro := sendToVerifierBot(t, svc, messages, attacker.ID, "/start")
if answer := pressVerifierData(t, svc, attacker.ID, attackerIntro, stolen); !answer.Alert {
t.Fatalf("stolen token answered %+v, want an alert", answer)
}
for _, req := range cv.requests {
if req.ApplicantUserID == attacker.ID {
t.Fatalf("stolen token created an application for the attacker: %+v", req)
}
}
}
func TestVerifierBotStatusListsRequestsAndMarksWithoutInternalNotes(t *testing.T) {
cv := newFakeCustomVerification().activated()
svc, users, messages := newVerifierBotTestService(t, cv)
owner := newOwner(t, users, "+7206")
svc.SetVerifierTargets(verifierTestTargets(owner))
if empty := sendToVerifierBot(t, svc, messages, owner.ID, "/status"); empty.Body != verifierNoRequestsText {
t.Fatalf("/status with nothing filed = %q", empty.Body)
}
cv.grantMark(owner.ID, verifierNewsPeer(), "Example News", "examplenews")
cv.requests[810] = domain.CustomVerificationRequest{
ID: 810, VerifierBotID: domain.VerifierBotUserID, ApplicantUserID: owner.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 8002}, PeerUsername: "examplebot",
Status: domain.CustomVerificationPending, CreatedAt: time.Date(2026, 7, 25, 9, 0, 0, 0, time.UTC),
}
cv.requests[811] = domain.CustomVerificationRequest{
ID: 811, VerifierBotID: domain.VerifierBotUserID, ApplicantUserID: owner.ID,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 7009}, PeerTitle: "Side Project",
Status: domain.CustomVerificationRejected, DecisionReason: "the company behind it could not be confirmed",
InternalNote: "operator note: obvious reseller, do not engage",
RejectedAt: time.Date(2026, 7, 22, 9, 0, 0, 0, time.UTC),
}
cv.requests[812] = domain.CustomVerificationRequest{
ID: 812, VerifierBotID: domain.VerifierBotUserID, ApplicantUserID: owner.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, PeerTitle: "Owner",
Status: domain.CustomVerificationRevoked, DecisionReason: "the account changed hands",
InternalNote: "operator note: internal escalation thread 4711",
CreatedAt: time.Date(2026, 7, 10, 9, 0, 0, 0, time.UTC),
}
reply := sendToVerifierBot(t, svc, messages, owner.ID, "/status")
for _, want := range []string{
"#701", "Example News (@examplenews)", "approved, the mark was granted", "Mark: live",
"Identity checked by Example Trust", "granted 2026-07-20",
"#810", "@examplebot", "waiting for an operator",
"#811", "Side Project", "not approved", "the company behind it could not be confirmed",
"#812", "the mark was taken away", "the account changed hands",
"/revoke",
} {
if !strings.Contains(reply.Body, want) {
t.Fatalf("/status missing %q: %q", want, reply.Body)
}
}
for _, forbidden := range []string{"obvious reseller", "escalation thread", "operator note"} {
if strings.Contains(reply.Body, forbidden) {
t.Fatalf("/status leaked an internal note (%q): %q", forbidden, reply.Body)
}
}
// An approved application whose mark is gone is reported as such rather than
// claiming a badge the peer does not carry.
delete(cv.marks, verifierNewsPeer())
stale := sendToVerifierBot(t, svc, messages, owner.ID, "/status")
if !strings.Contains(stale.Body, "Mark: not on the peer any more") {
t.Fatalf("/status after the mark went away = %q", stale.Body)
}
}
func TestVerifierBotRevokeAsksForConfirmationThenRemovesTheMark(t *testing.T) {
cv := newFakeCustomVerification().activated()
svc, users, messages := newVerifierBotTestService(t, cv)
owner := newOwner(t, users, "+7207")
svc.SetVerifierTargets(verifierTestTargets(owner))
if nothing := sendToVerifierBot(t, svc, messages, owner.ID, "/revoke"); nothing.Body != verifierNothingToRevokeText {
t.Fatalf("/revoke with no marks = %q", nothing.Body)
}
granted := cv.grantMark(owner.ID, verifierNewsPeer(), "Example News", "examplenews")
picker := sendToVerifierBot(t, svc, messages, owner.ID, "/revoke")
if !strings.Contains(picker.Body, "Which mark should I remove") {
t.Fatalf("/revoke = %q", picker.Body)
}
pressVerifierButton(t, svc, owner.ID, picker, "@examplenews")
confirm := latestVerifierReply(t, messages, owner.ID)
rendered := confirm.Body + "\n" + strings.Join(buttonLabels(confirm), "\n")
for _, want := range []string{"Remove my mark from", "Example News", verifierRevokeButtonText, verifierRevokeCancelButtonText} {
if !strings.Contains(rendered, want) {
t.Fatalf("revoke confirmation missing %q: %q", want, rendered)
}
}
// Nothing is removed until the confirmation is pressed.
if cv.revokes != 0 {
t.Fatalf("RevokeMark called %d times before confirmation", cv.revokes)
}
pressVerifierButton(t, svc, owner.ID, confirm, verifierRevokeButtonText)
removed := latestVerifierReply(t, messages, owner.ID)
if !strings.Contains(removed.Body, "removed from") || !strings.Contains(removed.Body, "Example News") {
t.Fatalf("revoked reply = %q", removed.Body)
}
if cv.revokes != 1 {
t.Fatalf("RevokeMark called %d times, want 1", cv.revokes)
}
// The store signature carries no actor or reason: 0155 has no revocation audit
// column, so the dialog logs them instead. What must be true here is that the
// mark of this verifier on this peer is gone.
if len(cv.revokedPeers) != 1 {
t.Fatalf("revoked peers = %+v, want exactly the picked peer", cv.revokedPeers)
}
if _, still := cv.marks[verifierNewsPeer()]; still {
t.Fatal("the mark is still granted after a confirmed revoke")
}
if got := cv.requests[granted.ID].Status; got != domain.CustomVerificationRevoked {
t.Fatalf("application status after revoke = %q", got)
}
// A second press of the same confirmation replays the same answer and does not
// call the service again.
pressVerifierButton(t, svc, owner.ID, confirm, verifierRevokeButtonText)
repeat := latestVerifierReply(t, messages, owner.ID)
if repeat.Body != removed.Body {
t.Fatalf("repeat revoke changed the answer:\nfirst = %q\nsecond = %q", removed.Body, repeat.Body)
}
if cv.revokes != 1 {
t.Fatalf("RevokeMark called %d times after a double press, want 1", cv.revokes)
}
}
// A confirmation button names the peer it was rendered for, so pressing an old one
// acts on that peer and never on whatever the dialog is pointing at now.
func TestVerifierBotStaleRevokeConfirmationActsOnItsOwnPeer(t *testing.T) {
cv := newFakeCustomVerification().activated()
svc, users, messages := newVerifierBotTestService(t, cv)
owner := newOwner(t, users, "+7214")
svc.SetVerifierTargets(verifierTestTargets(owner))
botPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 8002}
cv.grantMark(owner.ID, verifierNewsPeer(), "Example News", "examplenews")
cv.grantMark(owner.ID, botPeer, "Example Bot", "examplebot")
// Confirm screen for the channel, then walk away and open the one for the bot.
pressVerifierButton(t, svc, owner.ID, sendToVerifierBot(t, svc, messages, owner.ID, "/revoke"), "@examplenews")
channelConfirm := latestVerifierReply(t, messages, owner.ID)
pressVerifierButton(t, svc, owner.ID, sendToVerifierBot(t, svc, messages, owner.ID, "/revoke"), "@examplebot")
// The dialog now points at the bot; the stale button still means the channel.
pressVerifierButton(t, svc, owner.ID, channelConfirm, verifierRevokeButtonText)
removed := latestVerifierReply(t, messages, owner.ID)
if !strings.Contains(removed.Body, "@examplenews") || strings.Contains(removed.Body, "@examplebot") {
t.Fatalf("stale confirmation removed the wrong peer: %q", removed.Body)
}
if len(cv.revokedPeers) != 1 || cv.revokedPeers[0] != verifierNewsPeer() {
t.Fatalf("revoked peers = %+v, want only the channel", cv.revokedPeers)
}
if _, still := cv.marks[botPeer]; !still {
t.Fatal("the bot lost its mark to a confirmation rendered for the channel")
}
}
// A subject that already carries the mark is told so, and offered the only action
// that makes sense on it.
func TestVerifierBotAlreadyMarkedSubjectOffersRemoval(t *testing.T) {
cv := newFakeCustomVerification().activated()
svc, users, messages := newVerifierBotTestService(t, cv)
owner := newOwner(t, users, "+7215")
svc.SetVerifierTargets(verifierTestTargets(owner))
cv.grantMark(owner.ID, verifierNewsPeer(), "Example News", "examplenews")
picker := sendToVerifierBot(t, svc, messages, owner.ID, "/verify")
pressVerifierButton(t, svc, owner.ID, picker, "@examplenews")
reply := latestVerifierReply(t, messages, owner.ID)
for _, want := range []string{"already carries my mark", "Identity checked by Example Trust", "/revoke"} {
if !strings.Contains(reply.Body, want) {
t.Fatalf("already-marked reply missing %q: %q", want, reply.Body)
}
}
if cv.creates != 0 {
t.Fatalf("creates=%d for an already-marked subject", cv.creates)
}
pressVerifierButton(t, svc, owner.ID, reply, verifierRevokeMenuButtonText)
if got := latestVerifierReply(t, messages, owner.ID); !strings.Contains(got.Body, "Which mark should I remove") {
t.Fatalf("removal button did not open the picker: %q", got.Body)
}
}
// buttonLabels flattens a message's keyboard so a test can assert on the labels
// without caring about the row layout.
func buttonLabels(msg domain.Message) []string {
var out []string
for _, row := range verifierRowsOf(msg) {
for _, button := range row {
out = append(out, button.Text)
}
}
return out
}
func TestVerifierBotHelpAndIdleText(t *testing.T) {
// Nothing injected at all: this is the deployment that never wired third-party
// verification.
svc, users, messages := newVerifierBotTestService(t, nil)
owner := newOwner(t, users, "+7208")
// /help answers even with no verification service wired at all: it describes the
// bot rather than reading any state.
help := sendToVerifierBot(t, svc, messages, owner.ID, "/help")
if help.Body != verifierBotHelpText() {
t.Fatalf("/help = %q", help.Body)
}
for _, want := range []string{"/start", "/verify", "/status", "/revoke", "/help", "not the official"} {
if !strings.Contains(help.Body, want) {
t.Fatalf("/help missing %q", want)
}
}
if idle := sendToVerifierBot(t, svc, messages, owner.ID, "hello there"); idle.Body != verifierBotIdleText {
t.Fatalf("idle chatter = %q", idle.Body)
}
unknown := sendToVerifierBot(t, svc, messages, owner.ID, "/teleport")
if !strings.Contains(unknown.Body, "do not know that command") {
t.Fatalf("unknown command = %q", unknown.Body)
}
// With no service wired, every stateful command says so instead of half-running.
for _, cmd := range []string{"/start", "/verify", "/status", "/revoke"} {
if reply := sendToVerifierBot(t, svc, messages, owner.ID, cmd); reply.Body != verifierUnavailableText {
t.Fatalf("%s without a service = %q", cmd, reply.Body)
}
}
}
func TestVerifierBotGlobalCommandsWorkMidStep(t *testing.T) {
cv := newFakeCustomVerification().activated()
svc, users, messages := newVerifierBotTestService(t, cv)
owner := newOwner(t, users, "+7209")
svc.SetVerifierTargets(verifierTestTargets(owner))
intro := sendToVerifierBot(t, svc, messages, owner.ID, "/start")
pressVerifierButton(t, svc, owner.ID, intro, verifierApplyButtonText)
pressVerifierButton(t, svc, owner.ID, latestVerifierReply(t, messages, owner.ID), "@examplenews")
// /help and /status in the middle of the reason step answer and keep the step.
if help := sendToVerifierBot(t, svc, messages, owner.ID, "/help"); help.Body != verifierBotHelpText() {
t.Fatalf("/help mid-step = %q", help.Body)
}
if status := sendToVerifierBot(t, svc, messages, owner.ID, "/status"); status.Body != verifierNoRequestsText {
t.Fatalf("/status mid-step = %q", status.Body)
}
// An unknown command is never swallowed as the reason.
if unknown := sendToVerifierBot(t, svc, messages, owner.ID, "/nope"); !strings.Contains(unknown.Body, "do not know that command") {
t.Fatalf("unknown command mid-step = %q", unknown.Body)
}
// A too-short reason is explained rather than accepted.
if short := sendToVerifierBot(t, svc, messages, owner.ID, "please"); !strings.Contains(short.Body, "at least") {
t.Fatalf("short reason = %q", short.Body)
}
summary := sendToVerifierBot(t, svc, messages, owner.ID, verifierTestReason)
if !strings.Contains(summary.Body, verifierTestReason) || !strings.Contains(summary.Body, "Example Trust") {
t.Fatalf("reason not accepted after the global commands: %q", summary.Body)
}
// A button-only step says so instead of eating the text as a field.
if chatter := sendToVerifierBot(t, svc, messages, owner.ID, "yes do it"); chatter.Body != verifierPickButtonsText {
t.Fatalf("text at the confirm step = %q", chatter.Body)
}
// /cancel drops the dialog without filing anything.
cancelled := sendToVerifierBot(t, svc, messages, owner.ID, "/cancel")
if !strings.Contains(cancelled.Body, "nothing was filed") {
t.Fatalf("/cancel mid-dialog = %q", cancelled.Body)
}
if cv.creates != 0 {
t.Fatalf("creates=%d after cancelling", cv.creates)
}
if again := sendToVerifierBot(t, svc, messages, owner.ID, "/cancel"); again.Body != verifierNothingToCancelText {
t.Fatalf("second /cancel = %q", again.Body)
}
}
func TestVerifierBotWithoutTargetsExplainsInsteadOfFailing(t *testing.T) {
cv := newFakeCustomVerification().activated()
svc, users, messages := newVerifierBotTestService(t, cv, WithVerifierTargets(&fakeVerifierTargets{}))
owner := newOwner(t, users, "+7210")
if reply := sendToVerifierBot(t, svc, messages, owner.ID, "/verify"); reply.Body != verifierNoTargetsText {
t.Fatalf("/verify with no candidates = %q", reply.Body)
}
// A directory outage is reported honestly, not as an empty picker.
broken := &fakeVerifierTargets{err: context.DeadlineExceeded}
svc.SetVerifierTargets(broken)
if reply := sendToVerifierBot(t, svc, messages, owner.ID, "/verify"); reply.Body != verifierNoTargetSourceText {
t.Fatalf("/verify with a broken directory = %q", reply.Body)
}
if cv.creates != 0 {
t.Fatalf("creates=%d, want 0", cv.creates)
}
}
// A built-in service account is never offered as a subject, and the enumeration's
// official-verification verdicts are ignored: they answer a different question.
func TestVerifierBotPickerFiltersSystemPeersAndIgnoresOfficialEligibility(t *testing.T) {
cv := newFakeCustomVerification().activated()
svc, users, messages := newVerifierBotTestService(t, cv, WithVerifierTargets(&fakeVerifierTargets{targets: []domain.VerificationTarget{
{Type: domain.VerificationTargetBot, ID: domain.VerifyBotUserID, Username: "verifybot", Eligible: true},
{Type: domain.VerificationTargetChannel, ID: 7001, Title: "Example News", Username: "examplenews",
Eligible: false, Reason: domain.ErrVerificationTargetAlreadyVerified.Error()},
}}))
owner := newOwner(t, users, "+7211")
picker := sendToVerifierBot(t, svc, messages, owner.ID, "/verify")
labels := buttonLabels(picker)
for _, label := range labels {
if strings.Contains(label, "verifybot") {
t.Fatalf("picker offered a built-in service account: %v", labels)
}
}
if _, found := verifierDataOf(picker, "@examplenews"); !found {
t.Fatalf("picker dropped an officially ineligible peer: %v", labels)
}
}
func TestVerifierBotSendVerificationDecisionCoversEveryOutcome(t *testing.T) {
cv := newFakeCustomVerification().activated()
svc, users, messages := newVerifierBotTestService(t, cv)
owner := newOwner(t, users, "+7212")
ctx := context.Background()
req := domain.CustomVerificationRequest{
ID: 909, VerifierBotID: domain.VerifierBotUserID, ApplicantUserID: owner.ID,
Peer: verifierNewsPeer(), PeerTitle: "Example News", PeerUsername: "examplenews",
DecisionReason: "the company behind it could not be confirmed",
InternalNote: "operator note: repeat applicant, escalate next time",
}
req.Status = domain.CustomVerificationApproved
if err := svc.SendVerificationDecision(ctx, owner.ID, req); err != nil {
t.Fatalf("approved decision: %v", err)
}
approved := latestVerifierReply(t, messages, owner.ID)
for _, want := range []string{"#909", "Example News", "@examplenews", "approved", "not the official", "/revoke"} {
if !strings.Contains(approved.Body, want) {
t.Fatalf("approved decision missing %q: %q", want, approved.Body)
}
}
req.Status = domain.CustomVerificationRejected
if err := svc.SendVerificationDecision(ctx, owner.ID, req); err != nil {
t.Fatalf("rejected decision: %v", err)
}
rejected := latestVerifierReply(t, messages, owner.ID)
for _, want := range []string{"#909", "not approved", "could not be confirmed", "/verify"} {
if !strings.Contains(rejected.Body, want) {
t.Fatalf("rejected decision missing %q: %q", want, rejected.Body)
}
}
req.Status = domain.CustomVerificationRevoked
if err := svc.SendVerificationDecision(ctx, owner.ID, req); err != nil {
t.Fatalf("revoked decision: %v", err)
}
revoked := latestVerifierReply(t, messages, owner.ID)
for _, want := range []string{"taken off", "Example News", "could not be confirmed"} {
if !strings.Contains(revoked.Body, want) {
t.Fatalf("revoked decision missing %q: %q", want, revoked.Body)
}
}
// The operator's private note never reaches the applicant, under any outcome.
for _, msg := range verifierReplies(t, messages, owner.ID) {
for _, forbidden := range []string{"repeat applicant", "escalate next time", "operator note"} {
if strings.Contains(msg.Body, forbidden) {
t.Fatalf("decision message leaked the internal note (%q): %q", forbidden, msg.Body)
}
}
}
// Pending is not a decision, and neither is an unmodelled status: both are
// reported so the queued notification stays pending instead of being marked
// delivered as an empty message.
before := len(verifierReplies(t, messages, owner.ID))
for _, status := range []domain.CustomVerificationRequestStatus{domain.CustomVerificationPending, "teleported"} {
req.Status = status
if err := svc.SendVerificationDecision(ctx, owner.ID, req); err == nil {
t.Fatalf("status %q reported a delivered decision", status)
}
}
req.Status = domain.CustomVerificationApproved
if err := svc.SendVerificationDecision(ctx, 0, req); err == nil {
t.Fatal("empty recipient reported success")
}
if got := len(verifierReplies(t, messages, owner.ID)); got != before {
t.Fatalf("undeliverable decisions sent %d messages", got-before)
}
}
// The callback router must claim @verifierbot clicks and only those.
func TestVerifierBotCallbackRouting(t *testing.T) {
cv := newFakeCustomVerification().activated()
svc, users, messages := newVerifierBotTestService(t, cv)
owner := newOwner(t, users, "+7213")
svc.SetVerifierTargets(verifierTestTargets(owner))
// No dialog state at all: the click is claimed and explained, never left hanging.
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
BotUserID: domain.VerifierBotUserID, UserID: owner.ID,
Data: []byte(verifierCallbackDataPrefix + "0011223344"),
})
if err != nil || !handled {
t.Fatalf("callback without state: handled=%v err=%v", handled, err)
}
if !answer.Alert || !strings.Contains(answer.Message, "no longer active") {
t.Fatalf("callback without state = %+v", answer)
}
if _, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
BotUserID: 555111, UserID: owner.ID, Data: []byte(verifierCallbackDataPrefix + "x"),
}); handled || err != nil {
t.Fatalf("foreign bot callback handled=%v err=%v, want (false, nil)", handled, err)
}
// A click from the bot itself is not a dialog action.
if _, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
BotUserID: domain.VerifierBotUserID, UserID: domain.VerifierBotUserID,
Data: []byte(verifierCallbackDataPrefix + "x"),
}); handled || err != nil {
t.Fatalf("self callback handled=%v err=%v, want (false, nil)", handled, err)
}
if len(verifierReplies(t, messages, owner.ID)) != 0 {
t.Fatal("refused callbacks wrote messages")
}
}