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>
1012 lines
41 KiB
Go
1012 lines
41 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)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|