owpengram-server/internal/adminapi/botverification_test.go
Egor Egorov fff8de783a
feat: add NFT usernames and bot verification (#22)
Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review.

The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation.

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
2026-07-28 01:18:00 +08:00

826 lines
38 KiB
Go

package adminapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
// fakeService gains the third-party verification surface here so the shared fake
// keeps satisfying Service without touching the existing test files.
func (fakeService) GrantBotVerifier(_ context.Context, req admin.GrantBotVerifierRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetBotVerifierEnabled(_ context.Context, req admin.SetBotVerifierEnabledRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeBotVerifier(_ context.Context, req admin.RevokeBotVerifierRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) UpsertVerificationIcon(_ context.Context, req admin.UpsertVerificationIconRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetVerificationIconActive(_ context.Context, req admin.SetVerificationIconActiveRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeCustomVerification(_ context.Context, req admin.RevokeCustomVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) ApproveBotVerification(_ context.Context, req admin.ApproveBotVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RejectBotVerification(_ context.Context, req admin.RejectBotVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeBotVerification(_ context.Context, req admin.RevokeBotVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) BotVerifiers(context.Context, bool, int) ([]domain.BotVerifierSettings, error) {
return nil, nil
}
func (fakeService) BotVerifier(context.Context, int64) (domain.BotVerifierSettings, error) {
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
}
func (fakeService) VerificationIcons(context.Context, bool, int) ([]domain.VerificationIcon, error) {
return nil, nil
}
func (fakeService) CustomVerifications(context.Context, domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
return nil, nil
}
func (fakeService) CustomVerificationRequests(context.Context, domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
return nil, nil
}
func (fakeService) CustomVerificationRequest(context.Context, int64) (domain.CustomVerificationRequest, error) {
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
}
func (fakeService) CustomVerificationRequestCounts(context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
return nil, nil
}
func (fakeService) CustomVerificationMarkActive(context.Context, int64, domain.Peer) (bool, error) {
return false, nil
}
type captureBotVerificationService struct {
fakeService
verifier domain.BotVerifierSettings
hasRow bool
icons []domain.VerificationIcon
marks []domain.CustomVerification
request domain.CustomVerificationRequest
counts map[domain.CustomVerificationRequestStatus]int64
markActive bool
iconFilterActiveOnly bool
verifierFilterEnabled bool
verifierFilterLimit int
markFilter domain.CustomVerificationFilter
requestFilter domain.CustomVerificationRequestFilter
grant admin.GrantBotVerifierRequest
setEnabled admin.SetBotVerifierEnabledRequest
revokeVerifier admin.RevokeBotVerifierRequest
upsertIcon admin.UpsertVerificationIconRequest
setIconActive admin.SetVerificationIconActiveRequest
revokeMark admin.RevokeCustomVerificationRequest
approve admin.ApproveBotVerificationRequest
reject admin.RejectBotVerificationRequest
revokeRequest admin.RevokeBotVerificationRequest
commandErr error
}
func (s *captureBotVerificationService) BotVerifiers(_ context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error) {
s.verifierFilterEnabled = enabledOnly
s.verifierFilterLimit = limit
if !s.hasRow {
return nil, nil
}
return []domain.BotVerifierSettings{s.verifier}, nil
}
func (s *captureBotVerificationService) BotVerifier(_ context.Context, botID int64) (domain.BotVerifierSettings, error) {
if !s.hasRow || s.verifier.BotID != botID {
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
}
return s.verifier, nil
}
func (s *captureBotVerificationService) VerificationIcons(_ context.Context, activeOnly bool, _ int) ([]domain.VerificationIcon, error) {
s.iconFilterActiveOnly = activeOnly
return s.icons, nil
}
func (s *captureBotVerificationService) CustomVerifications(_ context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
s.markFilter = filter
return s.marks, nil
}
func (s *captureBotVerificationService) CustomVerificationRequests(_ context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
s.requestFilter = filter
return []domain.CustomVerificationRequest{s.request}, nil
}
func (s *captureBotVerificationService) CustomVerificationRequest(_ context.Context, requestID int64) (domain.CustomVerificationRequest, error) {
if s.request.ID != requestID {
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
}
return s.request, nil
}
func (s *captureBotVerificationService) CustomVerificationRequestCounts(context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
return s.counts, nil
}
func (s *captureBotVerificationService) CustomVerificationMarkActive(context.Context, int64, domain.Peer) (bool, error) {
return s.markActive, nil
}
func (s *captureBotVerificationService) commandResult(commandID string, dryRun bool) (admin.CommandResult, error) {
if s.commandErr != nil {
return admin.CommandResult{CommandID: commandID, Status: "failed", Error: s.commandErr.Error()}, s.commandErr
}
return admin.CommandResult{CommandID: commandID, Status: "completed", DryRun: dryRun}, nil
}
func (s *captureBotVerificationService) GrantBotVerifier(_ context.Context, req admin.GrantBotVerifierRequest) (admin.CommandResult, error) {
s.grant = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) SetBotVerifierEnabled(_ context.Context, req admin.SetBotVerifierEnabledRequest) (admin.CommandResult, error) {
s.setEnabled = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) RevokeBotVerifier(_ context.Context, req admin.RevokeBotVerifierRequest) (admin.CommandResult, error) {
s.revokeVerifier = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) UpsertVerificationIcon(_ context.Context, req admin.UpsertVerificationIconRequest) (admin.CommandResult, error) {
s.upsertIcon = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) SetVerificationIconActive(_ context.Context, req admin.SetVerificationIconActiveRequest) (admin.CommandResult, error) {
s.setIconActive = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) RevokeCustomVerification(_ context.Context, req admin.RevokeCustomVerificationRequest) (admin.CommandResult, error) {
s.revokeMark = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) ApproveBotVerification(_ context.Context, req admin.ApproveBotVerificationRequest) (admin.CommandResult, error) {
s.approve = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) RejectBotVerification(_ context.Context, req admin.RejectBotVerificationRequest) (admin.CommandResult, error) {
s.reject = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) RevokeBotVerification(_ context.Context, req admin.RevokeBotVerificationRequest) (admin.CommandResult, error) {
s.revokeRequest = req
return s.commandResult(req.CommandID, req.DryRun)
}
// botVerificationServer is the deployment shape the permission model exists for:
// one master token plus bounded tokens that can review, manage, or neither.
func botVerificationServer(svc Service) *Server {
return &Server{
token: "master",
scoped: []ScopedToken{
{Name: "queue-bot", Token: "scoped-review", Permissions: []string{PermissionBotVerificationReview}},
{Name: "trust-and-safety", Token: "scoped-manage", Permissions: []string{PermissionBotVerificationManage}},
{Name: "both", Token: "scoped-both", Permissions: []string{
PermissionBotVerificationReview, PermissionBotVerificationManage,
}},
// A token for the *official* review surface: it must not reach this one.
{Name: "official-review", Token: "scoped-official", Permissions: []string{PermissionVerificationReview}},
},
svc: svc,
}
}
// botVerificationRoute is one route with a body the handler accepts, so an
// authorisation test cannot pass by accident on a malformed payload.
type botVerificationRoute struct {
method string
path string
body string
}
const decisionBody = `{"command_id":"c1","actor":"ops","reason":"decided","version":2}`
var botVerificationReadRoutes = []botVerificationRoute{
{http.MethodGet, "/v1/botverification/verifiers", ""},
{http.MethodGet, "/v1/botverification/icons", ""},
{http.MethodGet, "/v1/botverification/marks", ""},
{http.MethodGet, "/v1/botverification/requests", ""},
{http.MethodGet, "/v1/botverification/requests/7", ""},
{http.MethodGet, "/v1/botverification/counts", ""},
{http.MethodPost, "/v1/botverification/requests/7/approve", decisionBody},
{http.MethodPost, "/v1/botverification/requests/7/reject", decisionBody},
{http.MethodPost, "/v1/botverification/requests/7/revoke", decisionBody},
}
var botVerificationManageRoutes = []botVerificationRoute{
{http.MethodPost, "/v1/botverification/verifiers/grant",
`{"command_id":"c1","actor":"ops","reason":"partner","bot_id":3003,"icon_document_id":900,"company_name":"Example Trust","version":4}`},
{http.MethodPost, "/v1/botverification/verifiers/set-enabled",
`{"command_id":"c1","actor":"ops","reason":"abuse","bot_id":3003,"enabled":false}`},
{http.MethodPost, "/v1/botverification/verifiers/revoke",
`{"command_id":"c1","actor":"ops","reason":"programme ended","bot_id":3003}`},
{http.MethodPost, "/v1/botverification/icons/upsert",
`{"command_id":"c1","actor":"ops","reason":"new icon","document_id":900,"name":"blue check"}`},
{http.MethodPost, "/v1/botverification/icons/set-active",
`{"command_id":"c1","actor":"ops","reason":"retired","icon_id":501,"active":false}`},
{http.MethodPost, "/v1/botverification/marks/revoke",
`{"command_id":"c1","actor":"ops","reason":"impersonation","verifier_bot_id":3003,"peer_type":"channel","peer_id":5005}`},
}
// botVerificationRoutes is every route in the section.
func botVerificationRoutes() []botVerificationRoute {
out := make([]botVerificationRoute, 0, len(botVerificationReadRoutes)+len(botVerificationManageRoutes))
out = append(out, botVerificationReadRoutes...)
return append(out, botVerificationManageRoutes...)
}
func TestBotVerificationRoutesRejectMissingAndUnknownTokens(t *testing.T) {
srv := botVerificationServer(fakeService{})
for _, item := range botVerificationRoutes() {
for _, token := range []string{"", "not-a-configured-token"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s %s token=%q status=%d, want 401", item.method, item.path, token, rec.Code)
}
}
}
}
func TestBotVerificationRoutesRefuseScopedTokenWithoutThePermission(t *testing.T) {
srv := botVerificationServer(fakeService{})
// The official-verification token is the interesting negative: the two
// mechanisms are separate, so verification.review must not open this surface.
for _, token := range []string{"scoped-official", "scoped-manage"} {
for _, item := range botVerificationReadRoutes {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
if rec.Code != http.StatusForbidden {
t.Fatalf("%s %s token=%q status=%d body=%s, want 403", item.method, item.path, token, rec.Code, rec.Body.String())
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode 403 body: %v", err)
}
if body["code"] != CodeForbidden || body["permission"] != PermissionBotVerificationReview {
t.Fatalf("403 body=%+v, want botverification.review named", body)
}
}
}
// And the review right alone does not reach the configuration half.
for _, token := range []string{"scoped-official", "scoped-review"} {
for _, item := range botVerificationManageRoutes {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
if rec.Code != http.StatusForbidden {
t.Fatalf("%s %s token=%q status=%d body=%s, want 403", item.method, item.path, token, rec.Code, rec.Body.String())
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode 403 body: %v", err)
}
if body["permission"] != PermissionBotVerificationManage {
t.Fatalf("403 body=%+v, want botverification.manage named", body)
}
}
}
}
// A token holding the third-party rights must not reach the official queue either:
// the separation is symmetric.
func TestBotVerificationTokenCannotReachTheOfficialVerificationSurface(t *testing.T) {
srv := botVerificationServer(fakeService{})
for _, path := range []string{"/v1/verification/applications", "/v1/verification/counts"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, path, "scoped-both", ""))
if rec.Code != http.StatusForbidden {
t.Fatalf("%s status=%d body=%s, want 403", path, rec.Code, rec.Body.String())
}
}
// Nor the legacy surface that predates permissions.
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/accounts/set-verified", "scoped-both",
`{"command_id":"c1","actor":"ops","reason":"x","user_id":1001,"verified":true}`))
if rec.Code != http.StatusForbidden {
t.Fatalf("legacy surface status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
}
func TestBotVerificationScopedTokensReachTheirOwnHalf(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 7, Version: 2}}
srv := botVerificationServer(svc)
for _, item := range botVerificationReadRoutes {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "scoped-review", item.body))
if rec.Code != http.StatusOK {
t.Fatalf("%s %s status=%d body=%s", item.method, item.path, rec.Code, rec.Body.String())
}
}
for _, item := range botVerificationManageRoutes {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "scoped-manage", item.body))
if rec.Code != http.StatusOK {
t.Fatalf("%s %s status=%d body=%s", item.method, item.path, rec.Code, rec.Body.String())
}
}
}
func TestMasterTokenReachesTheBotVerificationSurface(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 7, Version: 2}}
srv := botVerificationServer(svc)
for _, item := range botVerificationRoutes() {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "master", item.body))
if rec.Code != http.StatusOK {
t.Fatalf("master on %s %s status=%d body=%s", item.method, item.path, rec.Code, rec.Body.String())
}
}
}
func TestBotVerifierListRendersInt64AsDecimalStrings(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
svc := &captureBotVerificationService{
hasRow: true,
verifier: domain.BotVerifierSettings{
BotID: maxInt64,
IconDocumentID: maxInt64,
CompanyName: "Example Trust",
DefaultDescription: "verified by Example Trust",
CanModifyCustomDescription: true,
Enabled: true,
GrantedBy: "alice",
GrantReason: "partner programme",
Version: maxInt64,
CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
UpdatedAt: time.Unix(1_700_000_000, 0).UTC(),
},
}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
"/v1/botverification/verifiers?enabled_only=1&limit=25", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !svc.verifierFilterEnabled || svc.verifierFilterLimit != 25 {
t.Fatalf("enabledOnly=%v limit=%d, want the query honoured", svc.verifierFilterEnabled, svc.verifierFilterLimit)
}
var body struct {
Rows []map[string]any `json:"rows"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode verifiers: %v", err)
}
if len(body.Rows) != 1 {
t.Fatalf("rows=%+v", body.Rows)
}
for _, field := range []string{"BotID", "IconDocumentID", "Version"} {
if body.Rows[0][field] != "9223372036854775807" {
t.Fatalf("%s = %#v, want an exact decimal string", field, body.Rows[0][field])
}
}
if body.Rows[0]["CanModifyCustomDescription"] != true || body.Rows[0]["Enabled"] != true {
t.Fatalf("row=%+v, want the booleans as booleans", body.Rows[0])
}
}
func TestVerificationIconAndMarkListingsRenderInt64AsDecimalStrings(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
svc := &captureBotVerificationService{
icons: []domain.VerificationIcon{{
ID: maxInt64, DocumentID: maxInt64, OwnerBotID: maxInt64, Name: "blue check", Active: true,
CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
}},
marks: []domain.CustomVerification{{
ID: maxInt64, VerifierBotID: maxInt64,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: maxInt64},
IconDocumentID: maxInt64, Description: "verified partner", Version: maxInt64,
}},
}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
"/v1/botverification/icons?active_only=true", "scoped-review", ""))
if rec.Code != http.StatusOK || !svc.iconFilterActiveOnly {
t.Fatalf("icons status=%d activeOnly=%v body=%s", rec.Code, svc.iconFilterActiveOnly, rec.Body.String())
}
var icons struct {
Rows []map[string]any `json:"rows"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &icons); err != nil {
t.Fatalf("decode icons: %v", err)
}
for _, field := range []string{"ID", "DocumentID", "OwnerBotID"} {
if icons.Rows[0][field] != "9223372036854775807" {
t.Fatalf("icon %s = %#v", field, icons.Rows[0][field])
}
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
"/v1/botverification/marks?verifier_bot_id=9223372036854775807&peer_type=channel&q=news&limit=1&before_id=99",
"scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("marks status=%d body=%s", rec.Code, rec.Body.String())
}
if svc.markFilter.VerifierBotID != maxInt64 || svc.markFilter.PeerType != domain.PeerTypeChannel ||
svc.markFilter.Query != "news" || svc.markFilter.Limit != 1 || svc.markFilter.BeforeID != 99 {
t.Fatalf("mark filter=%+v", svc.markFilter)
}
var marks struct {
Rows []map[string]any `json:"rows"`
HasMore bool `json:"has_more"`
NextBeforeID string `json:"next_before_id"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &marks); err != nil {
t.Fatalf("decode marks: %v", err)
}
for _, field := range []string{"ID", "VerifierBotID", "PeerID", "IconDocumentID", "Version"} {
if marks.Rows[0][field] != "9223372036854775807" {
t.Fatalf("mark %s = %#v", field, marks.Rows[0][field])
}
}
// A full page reports more, and the cursor is the last id as a decimal string.
if !marks.HasMore || marks.NextBeforeID != "9223372036854775807" {
t.Fatalf("paging hasMore=%v next=%q", marks.HasMore, marks.NextBeforeID)
}
}
func TestBotVerificationQueueFilterAndUnmodelledValues(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{
ID: 88, VerifierBotID: 3003, ApplicantUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 5005},
Status: domain.CustomVerificationPending, Version: 3,
}}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
"/v1/botverification/requests?status=pending,approved&verifier_bot_id=3003&peer_type=channel&q=news&limit=25&before_id=99",
"scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if len(svc.requestFilter.Statuses) != 2 ||
svc.requestFilter.Statuses[0] != domain.CustomVerificationPending ||
svc.requestFilter.Statuses[1] != domain.CustomVerificationApproved ||
svc.requestFilter.VerifierBotID != 3003 || svc.requestFilter.PeerType != domain.PeerTypeChannel ||
svc.requestFilter.Query != "news" || svc.requestFilter.Limit != 25 || svc.requestFilter.BeforeID != 99 {
t.Fatalf("filter=%+v", svc.requestFilter)
}
// An unmodelled status or peer type is a 400 rather than an empty result, so a
// typo is reported instead of silently returning nothing.
for _, query := range []string{"?status=in_review", "?peer_type=chat", "?verifier_bot_id=abc", "?before_id=-1"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests"+query, "scoped-review", ""))
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s status=%d body=%s, want 400", query, rec.Code, rec.Body.String())
}
}
for _, query := range []string{"?peer_type=chat"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/marks"+query, "scoped-review", ""))
if rec.Code != http.StatusBadRequest {
t.Fatalf("marks %s status=%d, want 400", query, rec.Code)
}
}
}
func TestBotVerificationRequestDetailAndCounts(t *testing.T) {
svc := &captureBotVerificationService{
hasRow: true,
verifier: domain.BotVerifierSettings{
BotID: 3003, IconDocumentID: 900, CompanyName: "Example Trust", Enabled: true, Version: 4,
},
request: domain.CustomVerificationRequest{
ID: 88, VerifierBotID: 3003, ApplicantUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 5005},
PeerTitle: "Example News",
PeerUsername: "examplenews",
InternalNote: "operator only",
Status: domain.CustomVerificationApproved, Version: 5,
ApprovedAt: time.Unix(1_700_000_000, 0).UTC(),
},
markActive: true,
counts: map[domain.CustomVerificationRequestStatus]int64{domain.CustomVerificationPending: 3, domain.CustomVerificationApproved: 1},
}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests/88", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("detail status=%d body=%s", rec.Code, rec.Body.String())
}
var detail struct {
Request map[string]any `json:"request"`
Verifier map[string]any `json:"verifier"`
MarkActive bool `json:"mark_active"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &detail); err != nil {
t.Fatalf("decode detail: %v", err)
}
if detail.Request["ID"] != "88" || detail.Request["PeerID"] != "5005" || detail.Request["Version"] != "5" ||
detail.Request["InternalNote"] != "operator only" || detail.Request["ApprovedAt"] == nil {
t.Fatalf("request=%+v", detail.Request)
}
if detail.Verifier["BotID"] != "3003" || detail.Verifier["CompanyName"] != "Example Trust" || !detail.MarkActive {
t.Fatalf("verifier=%+v markActive=%v", detail.Verifier, detail.MarkActive)
}
// A verifier revoked since the application was filed must not turn the audit
// record into a 500: the row is reported with only its id.
svc.hasRow = false
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests/88", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("detail without a verifier status=%d body=%s", rec.Code, rec.Body.String())
}
if err := json.Unmarshal(rec.Body.Bytes(), &detail); err != nil {
t.Fatalf("decode detail: %v", err)
}
if detail.Verifier["BotID"] != "3003" || detail.Verifier["Enabled"] != false {
t.Fatalf("verifier=%+v, want the bot named and no status claimed", detail.Verifier)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests/89", "scoped-review", ""))
if rec.Code != http.StatusNotFound {
t.Fatalf("missing application status=%d body=%s, want 404", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/counts", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("counts status=%d body=%s", rec.Code, rec.Body.String())
}
var counts struct {
Counts map[string]string `json:"counts"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &counts); err != nil {
t.Fatalf("decode counts: %v", err)
}
// Every modelled status is present so the panel never tells "zero" from
// "absent", and the values are decimal strings.
if counts.Counts["pending"] != "3" || counts.Counts["approved"] != "1" ||
counts.Counts["rejected"] != "0" || counts.Counts["revoked"] != "0" || len(counts.Counts) != 4 {
t.Fatalf("counts=%+v", counts.Counts)
}
}
func TestBotVerificationDecisionTakesTheRequestIDFromThePath(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 88, Version: 3}}
srv := botVerificationServer(svc)
// The body names a different application on purpose: the path has to win, or
// the URL would lie to the audit trail.
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
`{"command_id":"c1","actor":"alice","reason":"verified","request_id":99,"version":3,"internal_note":"handover"}`))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if svc.approve.RequestID != 88 || svc.approve.Version != 3 ||
svc.approve.InternalNote != "handover" || svc.approve.Actor != "alice" {
t.Fatalf("forwarded approval=%+v", svc.approve)
}
}
func TestBotVerificationDryRunIsForwardedAndEchoed(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 88, Version: 3}}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/reject", "scoped-review",
`{"command_id":"dry-1","actor":"alice","reason":"not an outlet","dry_run":true,"version":3}`))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !svc.reject.DryRun || svc.reject.Reason != "not an outlet" {
t.Fatalf("forwarded rejection=%+v", svc.reject)
}
if !strings.Contains(rec.Body.String(), `"dry_run":true`) {
t.Fatalf("body=%s, want the dry run echoed", rec.Body.String())
}
// Also on the manage half: appointing a verifier is rehearsable too.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/grant", "scoped-manage",
`{"command_id":"dry-2","actor":"alice","reason":"partner","dry_run":true,
"bot_id":3003,"icon_document_id":900,"company_name":"Example Trust","version":4}`))
if rec.Code != http.StatusOK {
t.Fatalf("grant status=%d body=%s", rec.Code, rec.Body.String())
}
if !svc.grant.DryRun || svc.grant.BotID != 3003 || svc.grant.IconDocumentID != 900 || svc.grant.Version != 4 {
t.Fatalf("forwarded grant=%+v, want the exact int64s from decimal strings", svc.grant)
}
}
func TestBotVerificationVersionConflictIsAnswered409(t *testing.T) {
svc := &captureBotVerificationService{
request: domain.CustomVerificationRequest{ID: 88, Version: 5},
// The shape admin.codedError produces for a lost race.
commandErr: fmt.Errorf("%s: %w", admin.CodeCustomVerificationConflict, domain.ErrCustomVerificationVersionConflict),
}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
`{"command_id":"c1","actor":"alice","reason":"verified","version":4}`))
if rec.Code != http.StatusConflict {
t.Fatalf("status=%d body=%s, want 409 for a lost optimistic-locking race", rec.Code, rec.Body.String())
}
var result admin.CommandResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode conflict: %v", err)
}
if !strings.Contains(result.Error, admin.CodeCustomVerificationConflict) {
t.Fatalf("result=%+v, want the stable conflict code", result)
}
if !strings.Contains(result.Message, "reload") {
t.Fatalf("result message=%q, want an actionable message", result.Message)
}
// The same on the manage half, where two operators can race a verifier row.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/grant", "scoped-manage",
`{"command_id":"c2","actor":"alice","reason":"partner","bot_id":3003,"icon_document_id":900,"company_name":"x","version":3}`))
if rec.Code != http.StatusConflict {
t.Fatalf("grant conflict status=%d body=%s, want 409", rec.Code, rec.Body.String())
}
}
func TestBotVerificationErrorStatusMapping(t *testing.T) {
cases := map[string]int{
admin.CodeBotVerifierNotFound: http.StatusNotFound,
admin.CodeBotVerifierBotNotFound: http.StatusNotFound,
admin.CodeVerificationIconNotFound: http.StatusNotFound,
admin.CodeCustomVerificationNotFound: http.StatusNotFound,
admin.CodeCustomVerificationRequestNotFound: http.StatusNotFound,
admin.CodeCustomVerificationConflict: http.StatusConflict,
admin.CodeCustomVerificationLimit: http.StatusConflict,
admin.CodeCustomVerificationRequestExists: http.StatusConflict,
admin.CodeCustomVerificationRateLimited: http.StatusTooManyRequests,
admin.CodeBotVerifierForbidden: http.StatusBadRequest,
admin.CodeBotVerifierInvalid: http.StatusBadRequest,
admin.CodeVerificationIconInactive: http.StatusBadRequest,
admin.CodeVerificationIconInvalid: http.StatusBadRequest,
admin.CodeCustomVerificationStatusInvalid: http.StatusBadRequest,
admin.CodeCustomVerificationReasonRequired: http.StatusBadRequest,
admin.CodeCustomVerificationTargetInvalid: http.StatusBadRequest,
admin.CodeCustomVerificationInvalid: http.StatusBadRequest,
"": http.StatusInternalServerError,
}
for code, want := range cases {
if got := botVerificationErrorStatus(code); got != want {
t.Fatalf("botVerificationErrorStatus(%q) = %d, want %d", code, got, want)
}
}
}
func TestBotVerificationActionsForwardTheirPayloads(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
svc := &captureBotVerificationService{}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/set-enabled", "scoped-manage",
`{"command_id":"c1","actor":"ops","reason":"abuse","bot_id":9223372036854775807,"enabled":false}`))
if rec.Code != http.StatusOK || svc.setEnabled.BotID != maxInt64 || svc.setEnabled.Enabled {
t.Fatalf("set-enabled status=%d req=%+v", rec.Code, svc.setEnabled)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/revoke", "scoped-manage",
`{"command_id":"c2","actor":"ops","reason":"programme ended","bot_id":3003}`))
if rec.Code != http.StatusOK || svc.revokeVerifier.BotID != 3003 {
t.Fatalf("revoke-verifier status=%d req=%+v", rec.Code, svc.revokeVerifier)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/icons/upsert", "scoped-manage",
`{"command_id":"c3","actor":"ops","reason":"new icon","document_id":9223372036854775807,"name":"blue check","owner_bot_id":3003}`))
if rec.Code != http.StatusOK || svc.upsertIcon.DocumentID != maxInt64 ||
svc.upsertIcon.Name != "blue check" || svc.upsertIcon.OwnerBotID != 3003 {
t.Fatalf("upsert-icon status=%d req=%+v", rec.Code, svc.upsertIcon)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/icons/set-active", "scoped-manage",
`{"command_id":"c4","actor":"ops","reason":"retired","icon_id":501,"active":false}`))
if rec.Code != http.StatusOK || svc.setIconActive.IconID != 501 || svc.setIconActive.Active {
t.Fatalf("set-icon-active status=%d req=%+v", rec.Code, svc.setIconActive)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/marks/revoke", "scoped-manage",
`{"command_id":"c5","actor":"ops","reason":"impersonation","verifier_bot_id":3003,"peer_type":"channel","peer_id":9223372036854775807}`))
if rec.Code != http.StatusOK || svc.revokeMark.VerifierBotID != 3003 ||
svc.revokeMark.PeerType != domain.PeerTypeChannel || svc.revokeMark.PeerID != maxInt64 {
t.Fatalf("revoke-mark status=%d req=%+v", rec.Code, svc.revokeMark)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/revoke", "scoped-review",
`{"command_id":"c6","actor":"ops","reason":"licence withdrawn","version":9223372036854775807}`))
if rec.Code != http.StatusOK || svc.revokeRequest.RequestID != 88 || svc.revokeRequest.Version != maxInt64 {
t.Fatalf("revoke-request status=%d req=%+v", rec.Code, svc.revokeRequest)
}
}
func TestBotVerificationScopedTokenNameBecomesTheAuditActor(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 88, Version: 3}}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
`{"command_id":"c1","reason":"queue sweep","version":3}`))
if rec.Code != http.StatusOK || svc.approve.Actor != "queue-bot" {
t.Fatalf("status=%d actor=%q, want the scoped token name", rec.Code, svc.approve.Actor)
}
// A stated actor is never overwritten, which is how the panel attributes an
// action to the signed-in operator.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
`{"command_id":"c2","actor":"alice","reason":"queue sweep","version":3}`))
if rec.Code != http.StatusOK || svc.approve.Actor != "alice" {
t.Fatalf("status=%d actor=%q", rec.Code, svc.approve.Actor)
}
// The master token has no name, so the caller keeps having to say who acts.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "master",
`{"command_id":"c3","reason":"queue sweep","version":3}`))
if rec.Code != http.StatusOK || svc.approve.Actor != "" {
t.Fatalf("master token status=%d actor=%q, want no invented identity", rec.Code, svc.approve.Actor)
}
}
func TestBotVerificationCommandsRejectUnknownFields(t *testing.T) {
srv := botVerificationServer(&captureBotVerificationService{})
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/grant", "scoped-manage",
`{"command_id":"c1","actor":"ops","reason":"x","bot_id":3003,"icon_document_id":900,"company_name":"y","enabled":true}`))
// enabled is not part of the grant payload: the kill switch is its own action,
// and a silently ignored field would hide that from the operator.
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "enabled") {
t.Fatalf("status=%d body=%s, want 400 naming the unknown field", rec.Code, rec.Body.String())
}
}
func TestBotVerificationPermissionNamesAreDistinctFromTheOfficialOnes(t *testing.T) {
// The permission model's whole point here: appointing verifiers is not implied
// by reviewing the official queue, in either direction.
bounded := newPermissionSet([]string{PermissionBotVerificationReview})
if !bounded.Has(PermissionBotVerificationReview) {
t.Fatal("bounded set dropped its own permission")
}
if bounded.Has(PermissionBotVerificationManage) || bounded.Has(PermissionVerificationReview) {
t.Fatalf("botverification.review leaked into another right")
}
manage := newPermissionSet([]string{PermissionBotVerificationManage})
if manage.Has(PermissionBotVerificationReview) {
t.Fatal("botverification.manage implied the review right")
}
all := newPermissionSet([]string{PermissionAll})
if !all.Has(PermissionBotVerificationReview) || !all.Has(PermissionBotVerificationManage) {
t.Fatal("the wildcard refused a third-party permission")
}
}