owpengram-server/cmd/telesrv-admin/readstore_botverification_integration_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

571 lines
20 KiB
Go

package main
import (
"context"
"strconv"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// The third-party verification tables are read with hand-written SQL, so the only
// thing that can prove the column names, the CASE-per-peer-namespace projections
// and the `AND editable` username joins are right is running them against the real
// schema. Gated on TELESRV_TEST_POSTGRES_DSN, like every other integration test in
// the repo, and reusing verificationReadStore for the pool and the migration.
// botVerificationFixture seeds two verifier bots (one enabled, one switched off),
// a shared and a reserved icon, marks on a user peer and a channel peer, and
// applications in three states.
type botVerificationFixture struct {
verifierBot int64
disabledBot int64
applicant int64
userPeer int64
channel int64
sharedIcon int64
reservedIcon int64
sharedDoc int64
reservedDoc int64
userMark int64
channelMark int64
pendingReq int64
approvedReq int64
rejectedReq int64
suffix string
}
func seedBotVerificationFixture(t *testing.T, pool *pgxpool.Pool) botVerificationFixture {
t.Helper()
ctx := context.Background()
var fx botVerificationFixture
now := time.Now().UTC().Truncate(time.Microsecond)
// Usernames, channel ids and icon document ids are globally unique, so every run
// needs its own suffix: this database may still hold rows another run left.
unique := now.UnixNano() & 0x7fffffff
suffix := strconv.FormatInt(unique, 10)
fx.suffix = suffix
nextChannelID := 1_200_000_000 + unique%100_000_000
fx.sharedDoc = 7_000_000_000 + unique%1_000_000
fx.reservedDoc = fx.sharedDoc + 1
insertUser := func(first, username string, isBot bool) int64 {
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO users (access_hash, phone, first_name, last_name, username, is_bot)
VALUES ($1, $2, $3, 'Fixture', $4, $5)
RETURNING id`, unique, "71"+strconv.FormatInt(unique, 10), first, username, isBot).Scan(&id); err != nil {
t.Fatalf("insert user %s: %v", first, err)
}
unique++
return id
}
fx.verifierBot = insertUser("Verifierbot", "verifierbot"+suffix, true)
fx.disabledBot = insertUser("Disabledbot", "disabledbot"+suffix, true)
fx.applicant = insertUser("Applicant", "bvapplicant"+suffix, false)
fx.userPeer = insertUser("Marked", "markeduser"+suffix, false)
fx.channel = nextChannelID
if _, err := pool.Exec(ctx, `
INSERT INTO channels (
id, access_hash, creator_user_id, title, username, broadcast, megagroup,
participants_count, admins_count, top_message_id, pts, date
)
VALUES ($1, $2, $3, $4, $5, true, false, 1, 1, 1, 1, $6)`,
fx.channel, unique, fx.applicant, "Fixture Marked News", "markednews"+suffix, int32(now.Unix())); err != nil {
t.Fatalf("insert channel: %v", err)
}
unique++
insertIcon := func(documentID, ownerBotID int64, name string, active bool) int64 {
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO verification_icons (document_id, owner_bot_id, name, active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $5) RETURNING id`,
documentID, ownerBotID, name, active, now).Scan(&id); err != nil {
t.Fatalf("insert icon %s: %v", name, err)
}
return id
}
fx.sharedIcon = insertIcon(fx.sharedDoc, 0, "shared check "+suffix, true)
// Reserved to the verifier bot and retired, so both the owner join and the
// active filter have a case to answer.
fx.reservedIcon = insertIcon(fx.reservedDoc, fx.verifierBot, "reserved check "+suffix, false)
insertVerifier := func(botID, documentID int64, company string, enabled, canModify bool) {
if _, err := pool.Exec(ctx, `
INSERT INTO bot_verifier_settings (
bot_id, icon_document_id, company_name, default_description,
can_modify_custom_description, enabled, granted_by, grant_reason,
created_at, updated_at, version
) VALUES ($1, $2, $3, 'verified by the fixture', $4, $5, 'alice', 'partner programme', $6, $6, 4)`,
botID, documentID, company, canModify, enabled, now); err != nil {
t.Fatalf("insert verifier %d: %v", botID, err)
}
}
insertVerifier(fx.verifierBot, fx.sharedDoc, "Fixture Trust "+suffix, true, true)
insertVerifier(fx.disabledBot, fx.sharedDoc, "Switched Off "+suffix, false, false)
insertMark := func(peerType string, peerID int64, description string) int64 {
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO custom_verifications (
verifier_bot_id, peer_type, peer_id, icon_document_id, description,
granted_by_user_id, created_at, updated_at, version
) VALUES ($1, $2, $3, $4, $5, $1, $6, $6, 2) RETURNING id`,
fx.verifierBot, peerType, peerID, fx.sharedDoc, description, now).Scan(&id); err != nil {
t.Fatalf("insert %s mark: %v", peerType, err)
}
return id
}
fx.userMark = insertMark("user", fx.userPeer, "verified individual")
fx.channelMark = insertMark("channel", fx.channel, "verified outlet")
insertRequest := func(peerType string, peerID int64, status, reason string) int64 {
var approvedAt, rejectedAt *time.Time
decisionReason := ""
decidedBy := ""
switch status {
case "approved":
approvedAt = &now
decidedBy = "alice"
case "rejected":
rejectedAt = &now
decidedBy = "bob"
decisionReason = reason
}
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO custom_verification_requests (
verifier_bot_id, applicant_user_id, peer_type, peer_id, peer_title, peer_username,
reason, requested_description, status, decided_by, decision_reason, internal_note,
correlation_id, created_at, updated_at, approved_at, rejected_at, version
) VALUES (
$1, $2, $3, $4, $5, $6, 'we are the outlet', 'verified partner', $7, $8, $9,
'operator only', $10, $11, $11, $12, $13, 3
) RETURNING id`,
fx.verifierBot, fx.applicant, peerType, peerID,
"Snapshot "+peerType, "snapshot"+peerType+suffix,
status, decidedBy, decisionReason, "bvcorr-"+status,
now, approvedAt, rejectedAt,
).Scan(&id); err != nil {
t.Fatalf("insert %s request: %v", status, err)
}
return id
}
// One live application per (verifier, peer) pair, so the three seeded rows have
// to name three different peers: the partial unique index enforces it.
fx.pendingReq = insertRequest("channel", fx.channel, "pending", "")
fx.approvedReq = insertRequest("user", fx.userPeer, "approved", "")
// Filed against a peer that does not exist, so the live-peer join has a negative
// case and the snapshot fallback is exercised.
fx.rejectedReq = insertRequest("user", fx.userPeer+9_000_000, "rejected", "not an outlet")
t.Cleanup(func() {
reqIDs := []int64{fx.pendingReq, fx.approvedReq, fx.rejectedReq}
_, _ = pool.Exec(ctx, "DELETE FROM custom_verification_requests WHERE id = ANY($1::bigint[])", reqIDs)
_, _ = pool.Exec(ctx, "DELETE FROM custom_verifications WHERE id = ANY($1::bigint[])",
[]int64{fx.userMark, fx.channelMark})
_, _ = pool.Exec(ctx, "DELETE FROM bot_verifier_settings WHERE bot_id = ANY($1::bigint[])",
[]int64{fx.verifierBot, fx.disabledBot})
_, _ = pool.Exec(ctx, "DELETE FROM verification_icons WHERE id = ANY($1::bigint[])",
[]int64{fx.sharedIcon, fx.reservedIcon})
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", fx.channel)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])",
[]int64{fx.verifierBot, fx.disabledBot, fx.applicant, fx.userPeer})
})
return fx
}
func TestBotVerificationReadStoreVerifiers(t *testing.T) {
store, pool := verificationReadStore(t)
fx := seedBotVerificationFixture(t, pool)
ctx := context.Background()
rows, err := store.ListBotVerifiers(ctx, false, 200)
if err != nil {
t.Fatalf("list verifiers: %v", err)
}
byID := map[int64]BotVerifierRow{}
for _, row := range rows {
byID[row.BotID] = row
}
verifier, ok := byID[fx.verifierBot]
if !ok {
t.Fatal("enabled verifier missing from the listing")
}
// The bot account is resolved through the join, and the icon's catalogue label
// comes from the entry the document id points at.
if verifier.BotUsername != "verifierbot"+fx.suffix || verifier.IconName != "shared check "+fx.suffix {
t.Fatalf("verifier projection = %+v", verifier)
}
if verifier.BotName == "" || verifier.CompanyName != "Fixture Trust "+fx.suffix {
t.Fatalf("verifier names = %+v", verifier)
}
if !verifier.Enabled || !verifier.CanModifyCustomDescription || verifier.Version != 4 ||
verifier.GrantedBy != "alice" || verifier.GrantReason != "partner programme" {
t.Fatalf("verifier settings = %+v", verifier)
}
// Both seeded marks belong to this verifier, and mark_count is what would
// cascade away with a revocation.
if verifier.MarkCount != 2 {
t.Fatalf("mark count = %d, want 2", verifier.MarkCount)
}
if disabled := byID[fx.disabledBot]; disabled.Enabled || disabled.MarkCount != 0 {
t.Fatalf("disabled verifier = %+v", disabled)
}
// enabled_only hides the switched-off verifier without dropping its row.
enabled, err := store.ListBotVerifiers(ctx, true, 200)
if err != nil {
t.Fatalf("list enabled verifiers: %v", err)
}
for _, row := range enabled {
if !row.Enabled {
t.Fatalf("enabled_only leaked %+v", row)
}
if row.BotID == fx.disabledBot {
t.Fatal("enabled_only returned the switched-off verifier")
}
}
// The detail read reuses the list scanner, so one column order serves both.
one, err := store.BotVerifier(ctx, fx.verifierBot)
if err != nil {
t.Fatalf("get verifier: %v", err)
}
if one.BotID != fx.verifierBot || one.MarkCount != 2 || one.IconName != verifier.IconName {
t.Fatalf("verifier detail = %+v", one)
}
if _, err := store.BotVerifier(ctx, fx.applicant); err == nil {
t.Fatal("a non-verifier resolved as one")
}
// The page bound is honoured.
page, err := store.ListBotVerifiers(ctx, false, 1)
if err != nil {
t.Fatalf("bounded list: %v", err)
}
if len(page) != 1 {
t.Fatalf("bounded page len=%d", len(page))
}
}
func TestBotVerificationReadStoreIcons(t *testing.T) {
store, pool := verificationReadStore(t)
fx := seedBotVerificationFixture(t, pool)
ctx := context.Background()
rows, err := store.ListVerificationIcons(ctx, false, 200)
if err != nil {
t.Fatalf("list icons: %v", err)
}
byID := map[int64]VerificationIconRow{}
for _, row := range rows {
byID[row.ID] = row
}
shared, ok := byID[fx.sharedIcon]
if !ok {
t.Fatal("shared icon missing from the catalogue listing")
}
if shared.OwnerBotID != 0 || shared.OwnerBotUsername != "" || !shared.Active {
t.Fatalf("shared icon = %+v, want no owner", shared)
}
// Both seeded verifiers point at the shared document, so retiring it is a
// decision the operator has to make knowingly.
if shared.UsedByVerifiers != 2 {
t.Fatalf("shared icon used_by_verifiers = %d, want 2", shared.UsedByVerifiers)
}
reserved, ok := byID[fx.reservedIcon]
if !ok {
t.Fatal("reserved icon missing from the catalogue listing")
}
if reserved.OwnerBotID != fx.verifierBot || reserved.OwnerBotUsername != "verifierbot"+fx.suffix {
t.Fatalf("reserved icon = %+v, want the owner resolved", reserved)
}
if reserved.Active || reserved.UsedByVerifiers != 0 {
t.Fatalf("reserved icon = %+v", reserved)
}
// active_only hides the retired entry.
active, err := store.ListVerificationIcons(ctx, true, 200)
if err != nil {
t.Fatalf("list active icons: %v", err)
}
for _, row := range active {
if !row.Active {
t.Fatalf("active_only leaked %+v", row)
}
if row.ID == fx.reservedIcon {
t.Fatal("active_only returned the retired entry")
}
}
// Newest first.
if len(rows) >= 2 && rows[0].ID < rows[1].ID {
t.Fatalf("catalogue is not ordered newest first: %d before %d", rows[0].ID, rows[1].ID)
}
}
func TestBotVerificationReadStoreMarks(t *testing.T) {
store, pool := verificationReadStore(t)
fx := seedBotVerificationFixture(t, pool)
ctx := context.Background()
rows, _, err := store.ListCustomVerifications(ctx, fx.verifierBot, "", "", 0, 200)
if err != nil {
t.Fatalf("list marks: %v", err)
}
byID := map[int64]CustomVerificationRow{}
for _, row := range rows {
byID[row.ID] = row
}
// A user peer resolves through users; the verifier's company comes from its
// settings row.
userMark, ok := byID[fx.userMark]
if !ok {
t.Fatal("user mark missing from the listing")
}
if userMark.PeerType != "user" || userMark.PeerID != fx.userPeer ||
userMark.PeerUsername != "markeduser"+fx.suffix {
t.Fatalf("user mark peer = %+v", userMark)
}
if userMark.PeerTitle == "" || userMark.VerifierBotUsername != "verifierbot"+fx.suffix ||
userMark.CompanyName != "Fixture Trust "+fx.suffix {
t.Fatalf("user mark projection = %+v", userMark)
}
if userMark.IconDocumentID != fx.sharedDoc || userMark.Description != "verified individual" ||
userMark.Version != 2 {
t.Fatalf("user mark = %+v", userMark)
}
// A channel peer resolves through channels: the CASE picks the right namespace.
channelMark, ok := byID[fx.channelMark]
if !ok {
t.Fatal("channel mark missing from the listing")
}
if channelMark.PeerType != "channel" || channelMark.PeerTitle != "Fixture Marked News" ||
channelMark.PeerUsername != "markednews"+fx.suffix {
t.Fatalf("channel mark peer = %+v", channelMark)
}
// Filters.
typed, _, err := store.ListCustomVerifications(ctx, fx.verifierBot, "channel", "", 0, 50)
if err != nil {
t.Fatalf("peer_type list: %v", err)
}
for _, row := range typed {
if row.PeerType != "channel" {
t.Fatalf("peer_type filter leaked %+v", row)
}
}
other, _, err := store.ListCustomVerifications(ctx, fx.disabledBot, "", "", 0, 50)
if err != nil {
t.Fatalf("verifier filter list: %v", err)
}
for _, row := range other {
if row.VerifierBotID != fx.disabledBot {
t.Fatalf("verifier filter leaked %+v", row)
}
}
// q matches a mark id, a peer id, a verifier id and a username or title prefix.
for _, query := range []string{
strconv.FormatInt(fx.channelMark, 10),
strconv.FormatInt(fx.channel, 10),
strconv.FormatInt(fx.verifierBot, 10),
"markednews" + fx.suffix,
"@markeduser" + fx.suffix,
"fixture marked",
} {
found, _, err := store.ListCustomVerifications(ctx, 0, "", query, 0, 50)
if err != nil {
t.Fatalf("search %q: %v", query, err)
}
if len(found) == 0 {
t.Fatalf("search %q returned nothing", query)
}
}
// The keyset cursor excludes the row it points at.
after, _, err := store.ListCustomVerifications(ctx, fx.verifierBot, "", "", fx.channelMark, 50)
if err != nil {
t.Fatalf("keyset list: %v", err)
}
for _, row := range after {
if row.ID >= fx.channelMark {
t.Fatalf("keyset page leaked id %d at or after the cursor %d", row.ID, fx.channelMark)
}
}
// The page bound is honoured and reports more.
page, more, err := store.ListCustomVerifications(ctx, fx.verifierBot, "", "", 0, 1)
if err != nil {
t.Fatalf("bounded list: %v", err)
}
if len(page) != 1 || !more {
t.Fatalf("bounded page len=%d hasMore=%v", len(page), more)
}
}
func TestBotVerificationReadStoreRequestsAndDetail(t *testing.T) {
store, pool := verificationReadStore(t)
fx := seedBotVerificationFixture(t, pool)
ctx := context.Background()
rows, _, err := store.ListCustomVerificationRequests(ctx, "", fx.verifierBot, "", "", 0, 200)
if err != nil {
t.Fatalf("list requests: %v", err)
}
byID := map[int64]CustomVerificationRequestRow{}
for _, row := range rows {
byID[row.ID] = row
}
pending, ok := byID[fx.pendingReq]
if !ok {
t.Fatal("pending application missing from the queue")
}
if pending.ApplicantUserID != fx.applicant || pending.ApplicantUsername != "bvapplicant"+fx.suffix ||
pending.VerifierBotUsername != "verifierbot"+fx.suffix {
t.Fatalf("applicant/verifier projection = %+v", pending)
}
// The peer is read live, not from the snapshot columns: an operator has to see
// the peer as it is now.
if pending.PeerTitle != "Fixture Marked News" || pending.PeerUsername != "markednews"+fx.suffix {
t.Fatalf("live peer projection = %+v, want the channel as it is now", pending)
}
if pending.Status != "pending" || pending.InternalNote != "operator only" ||
pending.CorrelationID != "bvcorr-pending" || pending.Version != 3 {
t.Fatalf("operator fields = %+v", pending)
}
if !pending.ApprovedAt.IsZero() || !pending.RejectedAt.IsZero() {
t.Fatalf("timestamps approved=%v rejected=%v, want an undecided application",
pending.ApprovedAt, pending.RejectedAt)
}
if approved := byID[fx.approvedReq]; approved.ApprovedAt.IsZero() || approved.DecidedBy != "alice" {
t.Fatalf("approved application = %+v", approved)
}
if rejected := byID[fx.rejectedReq]; rejected.RejectedAt.IsZero() || rejected.DecisionReason == "" {
t.Fatalf("rejected application = %+v", rejected)
}
// A peer that does not exist falls back to the snapshot the applicant filed
// with, so the row still renders as something the reviewer recognises.
if gone := byID[fx.rejectedReq]; gone.PeerTitle != "Snapshot user" ||
gone.PeerUsername != "snapshotuser"+fx.suffix {
t.Fatalf("missing peer = %+v, want the snapshot fallback", gone)
}
// Filters.
filtered, _, err := store.ListCustomVerificationRequests(ctx, "pending", 0, "", "", 0, 50)
if err != nil {
t.Fatalf("status list: %v", err)
}
for _, row := range filtered {
if row.Status != "pending" {
t.Fatalf("status filter leaked %+v", row)
}
}
typed, _, err := store.ListCustomVerificationRequests(ctx, "", 0, "channel", "", 0, 50)
if err != nil {
t.Fatalf("peer_type list: %v", err)
}
for _, row := range typed {
if row.PeerType != "channel" {
t.Fatalf("peer_type filter leaked %+v", row)
}
}
// q matches an application id, a peer id, the applicant id, and username or
// title prefixes on both the live peer and the snapshot.
for _, query := range []string{
strconv.FormatInt(fx.pendingReq, 10),
strconv.FormatInt(fx.channel, 10),
strconv.FormatInt(fx.applicant, 10),
"markednews" + fx.suffix,
"snapshotchannel" + fx.suffix,
"@bvapplicant" + fx.suffix,
"snapshot ",
} {
found, _, err := store.ListCustomVerificationRequests(ctx, "", 0, "", query, 0, 50)
if err != nil {
t.Fatalf("search %q: %v", query, err)
}
if len(found) == 0 {
t.Fatalf("search %q returned nothing", query)
}
}
// The keyset cursor excludes the row it points at, and the bound reports more.
after, _, err := store.ListCustomVerificationRequests(ctx, "", fx.verifierBot, "", "", fx.pendingReq, 50)
if err != nil {
t.Fatalf("keyset list: %v", err)
}
for _, row := range after {
if row.ID >= fx.pendingReq {
t.Fatalf("keyset page leaked id %d at or after the cursor %d", row.ID, fx.pendingReq)
}
}
page, more, err := store.ListCustomVerificationRequests(ctx, "", fx.verifierBot, "", "", 0, 1)
if err != nil {
t.Fatalf("bounded list: %v", err)
}
if len(page) != 1 || !more {
t.Fatalf("bounded page len=%d hasMore=%v", len(page), more)
}
// The detail read carries the verifier and the live mark state.
detail, err := store.CustomVerificationRequestDetail(ctx, fx.approvedReq)
if err != nil {
t.Fatalf("detail: %v", err)
}
if detail.Request.ID != fx.approvedReq || detail.Verifier.BotID != fx.verifierBot ||
detail.Verifier.CompanyName != "Fixture Trust "+fx.suffix {
t.Fatalf("detail = %+v verifier=%+v", detail.Request, detail.Verifier)
}
// The approved application's peer really carries the mark.
if !detail.MarkActive {
t.Fatal("mark_active did not follow the granted mark")
}
// The pending application names the channel, which the fixture also marks, so
// the channel side of the EXISTS probe is covered too.
pendingDetail, err := store.CustomVerificationRequestDetail(ctx, fx.pendingReq)
if err != nil {
t.Fatalf("pending detail: %v", err)
}
if !pendingDetail.MarkActive {
t.Fatal("the channel mark was not seen by the detail read")
}
// The rejected one names a peer nobody marked: mark_active must say so, which is
// what tells "approved" apart from "approved and since stripped".
goneDetail, err := store.CustomVerificationRequestDetail(ctx, fx.rejectedReq)
if err != nil {
t.Fatalf("rejected detail: %v", err)
}
if goneDetail.MarkActive {
t.Fatal("an unmarked peer was reported as carrying a mark")
}
if _, err := store.CustomVerificationRequestDetail(ctx, 0); err == nil {
t.Fatal("detail of a missing application succeeded")
}
}
func TestBotVerificationReadStoreCounts(t *testing.T) {
store, pool := verificationReadStore(t)
fx := seedBotVerificationFixture(t, pool)
ctx := context.Background()
counts, err := store.CustomVerificationRequestCounts(ctx)
if err != nil {
t.Fatalf("counts: %v", err)
}
// Every modelled status is present, so the panel never tells "none" from
// "missing".
for _, status := range []string{"pending", "approved", "rejected", "revoked"} {
if _, ok := counts[status]; !ok {
t.Fatalf("counts %+v missing %q", counts, status)
}
}
if counts["pending"] == "0" || counts["approved"] == "0" || counts["rejected"] == "0" {
t.Fatalf("counts %+v did not see the seeded applications (%d/%d/%d)",
counts, fx.pendingReq, fx.approvedReq, fx.rejectedReq)
}
}