package postgres import ( "context" "errors" "fmt" "strconv" "strings" "testing" "time" "unicode/utf8" "github.com/jackc/pgx/v5/pgxpool" "telesrv/internal/domain" "telesrv/internal/observability/dbtrace" ) // botVerificationTestUser inserts a throwaway user row and registers the cleanup // for everything the third-party verification tables may hang off it. Marks // cascade from the verifier row, but the applications reference users directly, // so they have to go first. func botVerificationTestUser(t *testing.T, pool *pgxpool.Pool) int64 { t.Helper() ctx := context.Background() var id int64 if err := pool.QueryRow(ctx, ` INSERT INTO users (access_hash, phone, first_name) VALUES ($1, $2, 'bot verification test') RETURNING id`, time.Now().UnixNano()&0x7fffffffffffffff, "9"+randomSuffix(t)).Scan(&id); err != nil { t.Fatalf("insert bot verification test user: %v", err) } t.Cleanup(func() { cleanupCtx := context.Background() _, _ = pool.Exec(cleanupCtx, ` DELETE FROM custom_verification_requests WHERE verifier_bot_id = $1 OR applicant_user_id = $1`, id) _, _ = pool.Exec(cleanupCtx, ` DELETE FROM custom_verifications WHERE verifier_bot_id = $1`, id) _, _ = pool.Exec(cleanupCtx, ` DELETE FROM bot_verifier_settings WHERE bot_id = $1`, id) _, _ = pool.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, id) }) return id } // botVerificationTestDocumentID hands out a custom emoji document id no other // test run collides on, because verification_icons.document_id is unique. func botVerificationTestDocumentID(t *testing.T) int64 { t.Helper() raw, err := strconv.ParseInt(randomSuffix(t), 16, 64) if err != nil { t.Fatalf("parse random document id: %v", err) } return raw + 1 } // botVerificationTestIcon adds a catalogue entry and removes it afterwards; the // catalogue is deployment-wide, so it is not covered by the user cleanup. func botVerificationTestIcon(t *testing.T, pool *pgxpool.Pool, s *BotVerificationStore, name string, ownerBotID int64) domain.VerificationIcon { t.Helper() icon, err := s.UpsertVerificationIcon(context.Background(), domain.VerificationIcon{ DocumentID: botVerificationTestDocumentID(t), OwnerBotID: ownerBotID, Name: name, Active: true, }) if err != nil { t.Fatalf("upsert verification icon %q: %v", name, err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM verification_icons WHERE id = $1`, icon.ID) }) return icon } // botVerificationTestVerifier grants verifier status the way the admin edge does. func botVerificationTestVerifier(t *testing.T, s *BotVerificationStore, botID, iconDocumentID int64) domain.BotVerifierSettings { t.Helper() settings, err := s.UpsertBotVerifierSettings(context.Background(), domain.BotVerifierSettings{ BotID: botID, IconDocumentID: iconDocumentID, CompanyName: fmt.Sprintf("Verifier %d", botID), DefaultDescription: "verified by the test verifier", CanModifyCustomDescription: true, Enabled: true, GrantedBy: "operator", GrantReason: "test fixture", }) if err != nil { t.Fatalf("grant verifier %d: %v", botID, err) } return settings } func botVerificationTestRequest(verifier, applicant int64, peer domain.Peer, username string) domain.CustomVerificationRequest { return domain.CustomVerificationRequest{ VerifierBotID: verifier, ApplicantUserID: applicant, Peer: peer, PeerTitle: "Target " + username, PeerUsername: username, Reason: "we run the official account for this brand", RequestedDescription: "official brand account", CorrelationID: fmt.Sprintf("corr-%d", peer.ID), } } // botVerificationTxGrant is the callback shape the app layer is expected to use: // the mark is written through the transaction that is deciding the application, // so the two writes commit or roll back together. func botVerificationTxGrant() func(context.Context, domain.CustomVerificationRequest) error { return func(ctx context.Context, req domain.CustomVerificationRequest) error { tx, ok := VerificationTxFromContext(ctx) if !ok { return fmt.Errorf("decision context carries no transaction") } _, _, err := NewBotVerificationStore(tx).GrantCustomVerification(ctx, domain.CustomVerification{ VerifierBotID: req.VerifierBotID, Peer: req.Peer, Description: req.RequestedDescription, GrantedByUserID: req.ApplicantUserID, }) return err } } func botVerificationTxRevoke() func(context.Context, domain.CustomVerificationRequest) error { return func(ctx context.Context, req domain.CustomVerificationRequest) error { tx, ok := VerificationTxFromContext(ctx) if !ok { return fmt.Errorf("decision context carries no transaction") } _, err := NewBotVerificationStore(tx).RevokeCustomVerification(ctx, req.VerifierBotID, req.Peer) return err } } func botVerificationUserPeer(id int64) domain.Peer { return domain.Peer{Type: domain.PeerTypeUser, ID: id} } func botVerificationChannelPeer(id int64) domain.Peer { return domain.Peer{Type: domain.PeerTypeChannel, ID: id} } // botVerificationIconIndex locates an entry in a catalogue listing. The // catalogue is deployment-wide, so a test asserts on positions of its own rows // rather than on the length of the page. func botVerificationIconIndex(icons []domain.VerificationIcon, iconID int64) int { for i, icon := range icons { if icon.ID == iconID { return i } } return -1 } // TestBotVerificationIconCataloguePostgres covers the catalogue against the real // schema: an entry is keyed by document id, retiring one keeps it readable, and // the listing pages newest first with the activeOnly filter honoured. func TestBotVerificationIconCataloguePostgres(t *testing.T) { pool := testPool(t) ctx := context.Background() s := NewBotVerificationStore(pool) first := botVerificationTestIcon(t, pool, s, " Blue check ", 0) if first.ID == 0 || first.Name != "Blue check" || !first.Active { t.Fatalf("stored icon = %+v", first) } if first.CreatedAt.IsZero() || !first.UpdatedAt.Equal(first.CreatedAt) || first.CreatedAt.Location() != time.UTC { t.Fatalf("icon timestamps = %v / %v", first.CreatedAt, first.UpdatedAt) } second := botVerificationTestIcon(t, pool, s, "Reserved", 777) if second.OwnerBotID != 777 { t.Fatalf("reserved icon = %+v", second) } // document_id is the identity of an entry: a second upsert edits in place. edited, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{ DocumentID: first.DocumentID, OwnerBotID: 42, Name: "Blue check v2", Active: true, }) if err != nil { t.Fatalf("re-upsert icon: %v", err) } if edited.ID != first.ID || edited.Name != "Blue check v2" || edited.OwnerBotID != 42 { t.Fatalf("edited icon = %+v, want id %d", edited, first.ID) } if !edited.CreatedAt.Equal(first.CreatedAt) || !edited.UpdatedAt.After(first.UpdatedAt) { t.Fatalf("edited timestamps = %v / %v", edited.CreatedAt, edited.UpdatedAt) } if _, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{ DocumentID: 0, Name: "no document", }); !errors.Is(err, domain.ErrVerificationIconInvalid) { t.Fatalf("upsert without document err = %v, want ErrVerificationIconInvalid", err) } retired, err := s.SetVerificationIconActive(ctx, second.ID, false) if err != nil { t.Fatalf("retire icon: %v", err) } if retired.Active || !retired.UpdatedAt.After(second.UpdatedAt) { t.Fatalf("retired icon = %+v", retired) } if _, err := s.SetVerificationIconActive(ctx, 0, false); !errors.Is(err, domain.ErrVerificationIconNotFound) { t.Fatalf("retire icon 0 err = %v, want ErrVerificationIconNotFound", err) } if _, err := s.SetVerificationIconActive(ctx, second.ID+1<<40, false); !errors.Is(err, domain.ErrVerificationIconNotFound) { t.Fatalf("retire unknown err = %v, want ErrVerificationIconNotFound", err) } byDocument, err := s.VerificationIconByDocument(ctx, second.DocumentID) if err != nil || byDocument.ID != second.ID || byDocument.Active { t.Fatalf("icon by document = %+v err=%v", byDocument, err) } if _, err := s.VerificationIconByDocument(ctx, -1); !errors.Is(err, domain.ErrVerificationIconNotFound) { t.Fatalf("icon by bad document err = %v, want ErrVerificationIconNotFound", err) } byID, err := s.VerificationIcon(ctx, first.ID) if err != nil || byID.DocumentID != first.DocumentID { t.Fatalf("icon by id = %+v err=%v", byID, err) } if _, err := s.VerificationIcon(ctx, first.ID+1<<40); !errors.Is(err, domain.ErrVerificationIconNotFound) { t.Fatalf("unknown icon err = %v, want ErrVerificationIconNotFound", err) } all, err := s.ListVerificationIcons(ctx, false, maxBotVerificationListLimit) if err != nil { t.Fatalf("list icons: %v", err) } firstAt, secondAt := botVerificationIconIndex(all, first.ID), botVerificationIconIndex(all, second.ID) if firstAt < 0 || secondAt < 0 || secondAt >= firstAt { t.Fatalf("catalogue order: first at %d, second at %d, want newest first", firstAt, secondAt) } active, err := s.ListVerificationIcons(ctx, true, maxBotVerificationListLimit) if err != nil { t.Fatalf("list active icons: %v", err) } if botVerificationIconIndex(active, first.ID) < 0 { t.Fatal("active catalogue lost the active icon") } if botVerificationIconIndex(active, second.ID) >= 0 { t.Fatal("active catalogue kept the retired icon") } if page, err := s.ListVerificationIcons(ctx, false, 1); err != nil || len(page) != 1 { t.Fatalf("icon page = %+v err=%v", page, err) } } // TestBotVerifierSettingsLifecyclePostgres covers verifier status: optimistic // locking on version, the idempotent kill switch and the cascade that takes the // marks with the verifier row while the applications stay as history. func TestBotVerifierSettingsLifecyclePostgres(t *testing.T) { pool := testPool(t) ctx := context.Background() s := NewBotVerificationStore(pool) bot := botVerificationTestUser(t, pool) otherBot := botVerificationTestUser(t, pool) applicant := botVerificationTestUser(t, pool) icon := botVerificationTestIcon(t, pool, s, "verifier icon", 0) if _, err := s.BotVerifierSettings(ctx, bot); !errors.Is(err, domain.ErrVerifierNotFound) { t.Fatalf("unknown verifier err = %v, want ErrVerifierNotFound", err) } if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{ BotID: bot, IconDocumentID: icon.DocumentID, CompanyName: "Acme", Version: 3, }); !errors.Is(err, domain.ErrVerifierNotFound) { t.Fatalf("versioned upsert of missing row err = %v, want ErrVerifierNotFound", err) } if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{ BotID: bot, CompanyName: "Acme", }); !errors.Is(err, domain.ErrVerifierSettingsInvalid) { t.Fatalf("iconless upsert err = %v, want ErrVerifierSettingsInvalid", err) } created := botVerificationTestVerifier(t, s, bot, icon.DocumentID) if created.Version != 1 || !created.Enabled || !created.CanModifyCustomDescription { t.Fatalf("created verifier = %+v", created) } if created.CreatedAt.IsZero() || !created.UpdatedAt.Equal(created.CreatedAt) { t.Fatalf("verifier timestamps = %v / %v", created.CreatedAt, created.UpdatedAt) } // Version 0 means "there is no row yet", so it loses against the stored row. if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{ BotID: bot, IconDocumentID: icon.DocumentID, CompanyName: "Acme", }); !errors.Is(err, domain.ErrCustomVerificationVersionConflict) { t.Fatalf("re-create err = %v, want ErrCustomVerificationVersionConflict", err) } if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{ BotID: bot, IconDocumentID: icon.DocumentID, CompanyName: "Acme", Version: 99, }); !errors.Is(err, domain.ErrCustomVerificationVersionConflict) { t.Fatalf("stale upsert err = %v, want ErrCustomVerificationVersionConflict", err) } edited, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{ BotID: bot, IconDocumentID: icon.DocumentID + 1, CompanyName: "Acme Media", DefaultDescription: "checked by Acme", Enabled: true, GrantedBy: "operator", Version: created.Version, }) if err != nil { t.Fatalf("edit verifier: %v", err) } if edited.Version != 2 || edited.CanModifyCustomDescription || edited.DefaultDescription != "checked by Acme" { t.Fatalf("edited verifier = %+v", edited) } if !edited.CreatedAt.Equal(created.CreatedAt) || !edited.UpdatedAt.After(created.UpdatedAt) { t.Fatalf("edited timestamps = %v / %v", edited.CreatedAt, edited.UpdatedAt) } // DescriptionFor is the single place the description rule lives; the stored // row is what feeds it. if _, err := edited.DescriptionFor("per-peer text"); !errors.Is(err, domain.ErrVerifierDescriptionForbidden) { t.Fatalf("custom description on a locked verifier err = %v, want ErrVerifierDescriptionForbidden", err) } if got, err := edited.DescriptionFor(""); err != nil || got != "checked by Acme" { t.Fatalf("default description = %q err=%v", got, err) } disabled, err := s.SetBotVerifierEnabled(ctx, bot, false) if err != nil { t.Fatalf("disable verifier: %v", err) } if disabled.Enabled || disabled.Version != edited.Version+1 { t.Fatalf("disabled verifier = %+v", disabled) } again, err := s.SetBotVerifierEnabled(ctx, bot, false) if err != nil || again.Version != disabled.Version { t.Fatalf("re-disable = v%d err=%v, want v%d", again.Version, err, disabled.Version) } if _, err := s.SetBotVerifierEnabled(ctx, otherBot, true); !errors.Is(err, domain.ErrVerifierNotFound) { t.Fatalf("disable non-verifier err = %v, want ErrVerifierNotFound", err) } if stored, err := s.BotVerifierSettings(ctx, bot); err != nil || stored.Enabled { t.Fatalf("read disabled verifier = %+v err=%v", stored, err) } enabledOther := botVerificationTestVerifier(t, s, otherBot, icon.DocumentID) batch, err := s.BotVerifierSettingsBatch(ctx, []int64{bot, otherBot, applicant, 0, bot}) if err != nil { t.Fatalf("batch verifiers: %v", err) } if len(batch) != 2 || batch[bot].Enabled || !batch[otherBot].Enabled { t.Fatalf("verifier batch = %+v", batch) } if _, present := batch[applicant]; present { t.Fatal("batch invented verifier status for a plain user") } if empty, err := s.BotVerifierSettingsBatch(ctx, nil); err != nil || len(empty) != 0 { t.Fatalf("empty verifier batch = %+v err=%v", empty, err) } listed, err := s.ListBotVerifiers(ctx, false, maxBotVerificationListLimit) if err != nil { t.Fatalf("list verifiers: %v", err) } seenDisabled, seenEnabled := false, false for _, settings := range listed { seenDisabled = seenDisabled || settings.BotID == bot seenEnabled = seenEnabled || settings.BotID == otherBot } if !seenDisabled || !seenEnabled { t.Fatalf("verifier list lost a row: %+v", listed) } enabledOnly, err := s.ListBotVerifiers(ctx, true, maxBotVerificationListLimit) if err != nil { t.Fatalf("list enabled verifiers: %v", err) } for _, settings := range enabledOnly { if settings.BotID == bot { t.Fatal("enabled-only list kept a disabled verifier") } } // Marks cascade with the verifier row; applications do not. peer := botVerificationChannelPeer(applicant) if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{ VerifierBotID: enabledOther.BotID, Peer: peer, Description: "cascade me", }); err != nil { t.Fatalf("grant before delete: %v", err) } req, err := s.CreateCustomVerificationRequest(ctx, botVerificationTestRequest(enabledOther.BotID, applicant, peer, "CascadeChannel")) if err != nil { t.Fatalf("file application before delete: %v", err) } removed, err := s.DeleteBotVerifierSettings(ctx, enabledOther.BotID) if err != nil || !removed { t.Fatalf("delete verifier: removed=%v err=%v", removed, err) } if removed, err := s.DeleteBotVerifierSettings(ctx, enabledOther.BotID); err != nil || removed { t.Fatalf("repeated delete: removed=%v err=%v", removed, err) } if _, err := s.CustomVerification(ctx, enabledOther.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) { t.Fatalf("mark after cascade err = %v, want ErrCustomVerificationNotFound", err) } if count, err := s.CountCustomVerifications(ctx, enabledOther.BotID); err != nil || count != 0 { t.Fatalf("mark count after cascade = %d err=%v", count, err) } if kept, err := s.CustomVerificationRequest(ctx, req.ID); err != nil || kept.ID != req.ID { t.Fatalf("application after cascade = %+v err=%v", kept, err) } } // TestCustomVerificationProjectionPostgres is the projection contract: exactly // one mark exists per peer, a later verifier replaces the former one, a disabled // verifier projects nothing while its row survives, and the batch form resolves // every peer in a single query. func TestCustomVerificationProjectionPostgres(t *testing.T) { pool := testPool(t) ctx := context.Background() s := NewBotVerificationStore(pool) alphaBot := botVerificationTestUser(t, pool) betaBot := botVerificationTestUser(t, pool) target := botVerificationTestUser(t, pool) icon := botVerificationTestIcon(t, pool, s, "projection icon", 0) alpha := botVerificationTestVerifier(t, s, alphaBot, icon.DocumentID) beta := botVerificationTestVerifier(t, s, betaBot, icon.DocumentID+1) peer := botVerificationUserPeer(target) if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{ VerifierBotID: target, Peer: peer, }); !errors.Is(err, domain.ErrVerifierNotFound) { t.Fatalf("grant by non-verifier err = %v, want ErrVerifierNotFound", err) } if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{ VerifierBotID: alpha.BotID, Peer: domain.Peer{Type: domain.PeerTypeCommunity, ID: 5}, }); !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) { t.Fatalf("grant on community err = %v, want ErrCustomVerificationTargetInvalid", err) } if _, err := s.PeerVerification(ctx, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) { t.Fatalf("projection of an unmarked peer err = %v, want ErrCustomVerificationNotFound", err) } // The icon is denormalised from the verifier when the caller leaves it unset. alphaMark, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{ VerifierBotID: alpha.BotID, Peer: peer, Description: "checked by alpha", GrantedByUserID: target, }) if err != nil || !created { t.Fatalf("grant alpha mark: created=%v err=%v", created, err) } if alphaMark.IconDocumentID != alpha.IconDocumentID || alphaMark.Version != 1 || alphaMark.Peer != peer { t.Fatalf("alpha mark = %+v", alphaMark) } betaMark, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{ VerifierBotID: beta.BotID, Peer: peer, Description: "checked by beta", }) if err != nil || !created { t.Fatalf("grant beta mark: created=%v err=%v", created, err) } if betaMark.ID != alphaMark.ID || betaMark.Version != alphaMark.Version+1 { t.Fatalf("replacement mark = %+v, want id %d v%d", betaMark, alphaMark.ID, alphaMark.Version+1) } if _, err := s.CustomVerification(ctx, alpha.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) { t.Fatalf("replaced alpha mark err = %v, want ErrCustomVerificationNotFound", err) } // One peer has one wire-visible mark, irrespective of how often it is read. for i := 0; i < 3; i++ { got, err := s.PeerVerification(ctx, peer) if err != nil { t.Fatalf("projection after replacement: %v", err) } if got.ID != betaMark.ID || got.Projection().Icon != beta.IconDocumentID { t.Fatalf("projection = %+v, want replacement mark %d", got, betaMark.ID) } } // Kill switch: disabling the current verifier hides the badge. The replaced // alpha mark must not silently reappear. if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, false); err != nil { t.Fatalf("disable beta: %v", err) } if _, err := s.PeerVerification(ctx, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) { t.Fatalf("projection after disabling beta err=%v, want ErrCustomVerificationNotFound", err) } if stored, err := s.CustomVerification(ctx, beta.BotID, peer); err != nil || stored.ID != betaMark.ID { t.Fatalf("disabled verifier lost its mark: %+v err=%v", stored, err) } if batch, err := s.PeerVerificationBatch(ctx, []domain.Peer{peer}); err != nil || len(batch) != 0 { t.Fatalf("batch with current verifier disabled = %+v err=%v", batch, err) } if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, true); err != nil { t.Fatalf("re-enable beta: %v", err) } if got, err := s.PeerVerification(ctx, peer); err != nil || got.ID != betaMark.ID { t.Fatalf("projection after re-enabling = %+v err=%v", got, err) } // Granting through alpha replaces beta's mark on the same peer. regranted, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{ VerifierBotID: alpha.BotID, Peer: peer, IconDocumentID: icon.DocumentID + 99, Description: "checked by alpha, again", }) if err != nil || !created { t.Fatalf("re-grant alpha mark: created=%v err=%v", created, err) } if regranted.ID != betaMark.ID || regranted.Version != betaMark.Version+1 || regranted.IconDocumentID != icon.DocumentID+99 || regranted.Description != "checked by alpha, again" { t.Fatalf("re-granted mark = %+v", regranted) } if regranted.CreatedAt.Before(betaMark.CreatedAt) || !regranted.UpdatedAt.After(betaMark.UpdatedAt) { t.Fatalf("re-granted timestamps = %v / %v", regranted.CreatedAt, regranted.UpdatedAt) } if count, err := s.CountCustomVerifications(ctx, alpha.BotID); err != nil || count != 1 { t.Fatalf("alpha mark count = %d err=%v", count, err) } if got, err := s.PeerVerification(ctx, peer); err != nil || got.VerifierBotID != alpha.BotID { t.Fatalf("projection after re-grant = %+v err=%v", got, err) } // The batch form resolves many peers with one round trip. second := botVerificationChannelPeer(target + 1) third := botVerificationUserPeer(target + 2) fourth := botVerificationChannelPeer(target + 3) unmarked := botVerificationChannelPeer(target + 4) secondMark, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{ VerifierBotID: alpha.BotID, Peer: second, Description: "second", }) if err != nil { t.Fatalf("grant second: %v", err) } thirdMark, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{ VerifierBotID: beta.BotID, Peer: third, Description: "third", }) if err != nil { t.Fatalf("grant third: %v", err) } fourthMark, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{ VerifierBotID: beta.BotID, Peer: fourth, Description: "fourth", }) if err != nil { t.Fatalf("grant fourth: %v", err) } tracedCtx, stats := dbtrace.WithStats(ctx) before := stats.Snapshot() batch, err := s.PeerVerificationBatch(tracedCtx, []domain.Peer{ peer, second, third, fourth, unmarked, peer, {Type: domain.PeerTypeUser, ID: 0}, }) if err != nil { t.Fatalf("batch projection: %v", err) } // The point of the batch form: five distinct peers, one query. An N+1 // implementation would put a round trip per peer on every serialisation. if spent := stats.Snapshot().Sub(before); spent.Queries != 1 { t.Fatalf("batch projection ran %d queries, want exactly 1", spent.Queries) } if len(batch) != 4 { t.Fatalf("batch projection = %+v, want 4 peers", batch) } if batch[peer].ID != regranted.ID || batch[second].ID != secondMark.ID || batch[third].ID != thirdMark.ID || batch[fourth].ID != fourthMark.ID { t.Fatalf("batch projection picked %+v", batch) } if _, present := batch[unmarked]; present { t.Fatal("batch projected an unmarked peer") } if empty, err := s.PeerVerificationBatch(ctx, nil); err != nil || len(empty) != 0 { t.Fatalf("empty batch = %+v err=%v", empty, err) } // Disabling a verifier drops its peers from the batch too, in the same query. if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, false); err != nil { t.Fatalf("disable beta again: %v", err) } batch, err = s.PeerVerificationBatch(ctx, []domain.Peer{peer, second, third, fourth}) if err != nil { t.Fatalf("batch projection after disable: %v", err) } if len(batch) != 2 || batch[peer].ID != regranted.ID || batch[second].ID != secondMark.ID { t.Fatalf("batch after disable = %+v", batch) } if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, true); err != nil { t.Fatalf("re-enable beta again: %v", err) } // Listing, filtering and keyset paging over the marks. mine, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{ VerifierBotID: beta.BotID, }) if err != nil || len(mine) != 2 || mine[0].ID != fourthMark.ID { t.Fatalf("verifier-filtered marks = %+v err=%v", mine, err) } page, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{ VerifierBotID: beta.BotID, Limit: 2, }) if err != nil || len(page) != 2 { t.Fatalf("mark page = %+v err=%v", page, err) } next, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{ VerifierBotID: beta.BotID, Limit: 2, BeforeID: page[len(page)-1].ID, }) if err != nil || len(next) != 0 { t.Fatalf("mark keyset tail = %+v err=%v", next, err) } channels, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{ VerifierBotID: beta.BotID, PeerType: domain.PeerTypeChannel, }) if err != nil || len(channels) != 1 || channels[0].Peer != fourth { t.Fatalf("channel-filtered marks = %+v err=%v", channels, err) } byPeer, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{ PeerType: peer.Type, PeerID: peer.ID, }) if err != nil || len(byPeer) != 1 { t.Fatalf("peer-filtered marks = %+v err=%v", byPeer, err) } byQuery, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{ VerifierBotID: alpha.BotID, Query: fmt.Sprintf("%d", second.ID), }) if err != nil || len(byQuery) != 1 || byQuery[0].ID != secondMark.ID { t.Fatalf("numeric mark query = %+v err=%v", byQuery, err) } byText, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{ VerifierBotID: alpha.BotID, Query: "AGAIN", }) if err != nil || len(byText) != 1 || byText[0].ID != regranted.ID { t.Fatalf("text mark query = %+v err=%v", byText, err) } if _, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{ PeerType: domain.PeerTypeFolder, }); !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) { t.Fatalf("bad peer-type filter err = %v, want ErrCustomVerificationTargetInvalid", err) } // A replaced verifier cannot revoke the current mark. revoked, err := s.RevokeCustomVerification(ctx, beta.BotID, peer) if err != nil || revoked { t.Fatalf("revoke replaced beta mark: revoked=%v err=%v", revoked, err) } if revoked, err := s.RevokeCustomVerification(ctx, beta.BotID, peer); err != nil || revoked { t.Fatalf("repeated revoke: revoked=%v err=%v", revoked, err) } if got, err := s.PeerVerification(ctx, peer); err != nil || got.ID != regranted.ID { t.Fatalf("projection after rejected revoke = %+v err=%v", got, err) } if revoked, err := s.RevokeCustomVerification(ctx, alpha.BotID, peer); err != nil || !revoked { t.Fatalf("revoke alpha mark: revoked=%v err=%v", revoked, err) } if _, err := s.RevokeCustomVerification(ctx, 0, peer); !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) { t.Fatalf("revoke without verifier err = %v, want ErrCustomVerificationTargetInvalid", err) } } // TestCustomVerificationLimitPostgres pins the per-verifier bound: a new mark is // refused at the limit while an existing one can still be re-described. func TestCustomVerificationLimitPostgres(t *testing.T) { pool := testPool(t) ctx := context.Background() s := NewBotVerificationStore(pool) bot := botVerificationTestUser(t, pool) icon := botVerificationTestIcon(t, pool, s, "limit icon", 0) verifier := botVerificationTestVerifier(t, s, bot, icon.DocumentID) // The bound is 10k marks; filling it through the store would be 10k round // trips, so the fixture is seeded in one statement. peer_id has no foreign // key, which is what makes that safe. if _, err := pool.Exec(ctx, ` INSERT INTO custom_verifications ( verifier_bot_id, peer_type, peer_id, icon_document_id, description, created_at, updated_at, version ) SELECT $1, 'channel', g, $2, '', $3, $3, 1 FROM generate_series(1, $4::bigint) AS g`, verifier.BotID, verifier.IconDocumentID, time.Now().UTC(), domain.MaxCustomVerificationsPerVerifier); err != nil { t.Fatalf("seed marks: %v", err) } count, err := s.CountCustomVerifications(ctx, verifier.BotID) if err != nil || count != domain.MaxCustomVerificationsPerVerifier { t.Fatalf("mark count = %d err=%v", count, err) } if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{ VerifierBotID: verifier.BotID, Peer: botVerificationUserPeer(bot), }); !errors.Is(err, domain.ErrCustomVerificationLimit) { t.Fatalf("grant past the limit err = %v, want ErrCustomVerificationLimit", err) } // The bound is on creating marks, not on editing them. updated, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{ VerifierBotID: verifier.BotID, Peer: botVerificationChannelPeer(7), Description: "still editable at the limit", }) if err != nil || created { t.Fatalf("re-grant at the limit: created=%v err=%v", created, err) } if updated.Description != "still editable at the limit" || updated.Version != 2 { t.Fatalf("re-granted mark at the limit = %+v", updated) } // Freeing one slot lets the next grant through. if revoked, err := s.RevokeCustomVerification(ctx, verifier.BotID, botVerificationChannelPeer(1)); err != nil || !revoked { t.Fatalf("free a slot: revoked=%v err=%v", revoked, err) } if _, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{ VerifierBotID: verifier.BotID, Peer: botVerificationUserPeer(bot), }); err != nil || !created { t.Fatalf("grant into the freed slot: created=%v err=%v", created, err) } } // TestCustomVerificationRequestQueuePostgres covers the application queue // against the real schema: one pending application per (verifier, peer), the // decision status machine, and the transaction that keeps an approved // application and its mark together -- including the rollback when the callback // fails after it has already written through the decision's transaction. func TestCustomVerificationRequestQueuePostgres(t *testing.T) { pool := testPool(t) ctx := context.Background() s := NewBotVerificationStore(pool) bot := botVerificationTestUser(t, pool) applicant := botVerificationTestUser(t, pool) target := botVerificationTestUser(t, pool) icon := botVerificationTestIcon(t, pool, s, "queue icon", 0) verifier := botVerificationTestVerifier(t, s, bot, icon.DocumentID) peer := botVerificationChannelPeer(target) // A distinct peer id, so the numeric queue search below addresses one peer // rather than both shapes of the same number. other := botVerificationUserPeer(target + 1) baseline, err := s.CustomVerificationRequestCounts(ctx) if err != nil { t.Fatalf("baseline queue counts: %v", err) } filed, err := s.CreateCustomVerificationRequest(ctx, botVerificationTestRequest(verifier.BotID, applicant, peer, "AcmeNews")) if err != nil { t.Fatalf("file application: %v", err) } if filed.Status != domain.CustomVerificationPending || filed.Version != 1 { t.Fatalf("filed application = %s v%d", filed.Status, filed.Version) } if !filed.ApprovedAt.IsZero() || !filed.RejectedAt.IsZero() || filed.DecidedBy != "" { t.Fatalf("filed application carries a decision: %+v", filed) } if filed.Peer != peer || filed.PeerUsername != "AcmeNews" || filed.CorrelationID != fmt.Sprintf("corr-%d", peer.ID) { t.Fatalf("filed payload did not round-trip: %+v", filed) } // custom_verification_requests_pending_idx: one live application per pair. if _, err := s.CreateCustomVerificationRequest(ctx, botVerificationTestRequest(verifier.BotID, applicant, peer, "AcmeNews")); !errors.Is(err, domain.ErrCustomVerificationRequestExists) { t.Fatalf("duplicate pending err = %v, want ErrCustomVerificationRequestExists", err) } if _, err := s.CreateCustomVerificationRequest(ctx, domain.CustomVerificationRequest{ VerifierBotID: verifier.BotID, ApplicantUserID: applicant, Peer: peer, Status: domain.CustomVerificationApproved, }); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) { t.Fatalf("pre-decided application err = %v, want ErrCustomVerificationRequestInvalid", err) } // The SQL byte bound reserves the worst-case UTF-8 width for the domain's // rune limit, so valid multi-byte text must behave like ASCII. wide := botVerificationTestRequest(verifier.BotID, applicant, botVerificationUserPeer(target+9), "Wide") wide.Reason = strings.Repeat("é", domain.MaxCustomVerificationReasonLength-1) wideFiled, err := s.CreateCustomVerificationRequest(ctx, wide) if err != nil { t.Fatalf("valid multi-byte reason: %v", err) } if _, err := pool.Exec(ctx, `DELETE FROM custom_verification_requests WHERE id = $1`, wideFiled.ID); err != nil { t.Fatalf("clean wide application fixture: %v", err) } if pending, err := s.PendingCustomVerificationRequest(ctx, verifier.BotID, peer); err != nil || pending.ID != filed.ID { t.Fatalf("pending application = %+v err=%v", pending, err) } // A failing callback rolls the whole decision back, including the mark the // callback wrote through the decision's own transaction before it failed. boom := errors.New("apply exploded") grant := botVerificationTxGrant() if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version, domain.CustomVerificationApproved, "operator", "looks good", "", func(ctx context.Context, req domain.CustomVerificationRequest) error { if err := grant(ctx, req); err != nil { return err } return boom }); !errors.Is(err, boom) { t.Fatalf("failing apply err = %v, want the callback error", err) } rolledBack, err := s.CustomVerificationRequest(ctx, filed.ID) if err != nil { t.Fatalf("read after rollback: %v", err) } if rolledBack.Status != domain.CustomVerificationPending || rolledBack.Version != filed.Version || !rolledBack.ApprovedAt.IsZero() || rolledBack.DecidedBy != "" { t.Fatalf("application after rollback = %+v, want pending v%d", rolledBack, filed.Version) } if _, err := s.CustomVerification(ctx, verifier.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) { t.Fatalf("mark after rollback err = %v, want ErrCustomVerificationNotFound", err) } if _, err := s.PeerVerification(ctx, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) { t.Fatalf("projection after rollback err = %v, want ErrCustomVerificationNotFound", err) } if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version, domain.CustomVerificationApproved, "operator", "", "", nil); err == nil { t.Fatal("approve without a callback succeeded") } if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version+7, domain.CustomVerificationApproved, "operator", "", "", grant); !errors.Is(err, domain.ErrCustomVerificationVersionConflict) { t.Fatalf("stale decision err = %v, want ErrCustomVerificationVersionConflict", err) } if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version, domain.CustomVerificationPending, "operator", "", "", nil); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) { t.Fatalf("decision back to pending err = %v, want ErrCustomVerificationRequestInvalid", err) } if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID+1<<40, filed.Version, domain.CustomVerificationApproved, "operator", "", "", grant); !errors.Is(err, domain.ErrCustomVerificationRequestNotFound) { t.Fatalf("decision on unknown application err = %v, want ErrCustomVerificationRequestNotFound", err) } approved, changed, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version, domain.CustomVerificationApproved, "operator", "brand confirmed", "ticket 12", grant) if err != nil || !changed { t.Fatalf("approve: changed=%v err=%v", changed, err) } if approved.Status != domain.CustomVerificationApproved || approved.Version != filed.Version+1 || approved.ApprovedAt.IsZero() || !approved.RejectedAt.IsZero() { t.Fatalf("approved application = %+v", approved) } if approved.DecidedBy != "operator" || approved.DecisionReason != "brand confirmed" || approved.InternalNote != "ticket 12" { t.Fatalf("approved decision metadata = %+v", approved) } mark, err := s.PeerVerification(ctx, peer) if err != nil { t.Fatalf("projection after approve: %v", err) } if mark.VerifierBotID != verifier.BotID || mark.Description != filed.RequestedDescription || mark.IconDocumentID != verifier.IconDocumentID || mark.GrantedByUserID != applicant { t.Fatalf("mark after approve = %+v", mark) } if _, err := s.PendingCustomVerificationRequest(ctx, verifier.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationRequestNotFound) { t.Fatalf("pending after approve err = %v, want ErrCustomVerificationRequestNotFound", err) } // Re-issuing the decision that already holds moves nothing and does not run // the callback a second time. repeat, changed, err := s.DecideCustomVerificationRequest(ctx, approved.ID, approved.Version, domain.CustomVerificationApproved, "someone else", "again", "", func(context.Context, domain.CustomVerificationRequest) error { t.Error("apply ran for a decision that already held") return nil }) if err != nil || changed { t.Fatalf("repeated approve: changed=%v err=%v", changed, err) } if repeat.Version != approved.Version || repeat.DecidedBy != "operator" { t.Fatalf("repeated approve mutated the row: %+v", repeat) } // approved -> rejected is not in the status machine; approved -> revoked is. if _, _, err := s.DecideCustomVerificationRequest(ctx, approved.ID, approved.Version, domain.CustomVerificationRejected, "operator", "changed my mind", "", nil); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) { t.Fatalf("approved -> rejected err = %v, want ErrCustomVerificationRequestInvalid", err) } revoked, changed, err := s.DecideCustomVerificationRequest(ctx, approved.ID, approved.Version, domain.CustomVerificationRevoked, "operator", "brand asked us to", "", botVerificationTxRevoke()) if err != nil || !changed { t.Fatalf("revoke: changed=%v err=%v", changed, err) } // The stamps are paired with the status, so leaving approved clears approved_at. if revoked.Status != domain.CustomVerificationRevoked || revoked.Version != approved.Version+1 || !revoked.ApprovedAt.IsZero() || !revoked.RejectedAt.IsZero() { t.Fatalf("revoked application = %+v", revoked) } if _, err := s.PeerVerification(ctx, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) { t.Fatalf("projection after revoke err = %v, want ErrCustomVerificationNotFound", err) } if _, _, err := s.DecideCustomVerificationRequest(ctx, revoked.ID, revoked.Version, domain.CustomVerificationApproved, "operator", "", "", grant); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) { t.Fatalf("revoked -> approved err = %v, want ErrCustomVerificationRequestInvalid", err) } // A rejection needs a reason, and the domain is what says so. second, err := s.CreateCustomVerificationRequest(ctx, botVerificationTestRequest(verifier.BotID, applicant, other, "AcmeCEO")) if err != nil { t.Fatalf("file second application: %v", err) } if _, _, err := s.DecideCustomVerificationRequest(ctx, second.ID, second.Version, domain.CustomVerificationRejected, "operator", " ", "", nil); !errors.Is(err, domain.ErrVerificationReasonRequired) { t.Fatalf("reject without a reason err = %v, want ErrVerificationReasonRequired", err) } if stillPending, err := s.CustomVerificationRequest(ctx, second.ID); err != nil || stillPending.Status != domain.CustomVerificationPending || stillPending.Version != second.Version { t.Fatalf("application after refused rejection = %+v err=%v", stillPending, err) } rejected, changed, err := s.DecideCustomVerificationRequest(ctx, second.ID, second.Version, domain.CustomVerificationRejected, "operator", "not a public figure", "", nil) if err != nil || !changed { t.Fatalf("reject: changed=%v err=%v", changed, err) } if rejected.Status != domain.CustomVerificationRejected || rejected.RejectedAt.IsZero() || !rejected.ApprovedAt.IsZero() { t.Fatalf("rejected application = %+v", rejected) } if _, err := s.CustomVerification(ctx, verifier.BotID, other); !errors.Is(err, domain.ErrCustomVerificationNotFound) { t.Fatalf("rejection granted a mark: %v", err) } // A decided pair is free again: history keeps the rejection. reapplied, err := s.CreateCustomVerificationRequest(ctx, botVerificationTestRequest(verifier.BotID, applicant, other, "AcmeCEO")) if err != nil { t.Fatalf("re-apply after rejection: %v", err) } counts, err := s.CustomVerificationRequestCounts(ctx) if err != nil { t.Fatalf("queue counts: %v", err) } for status, want := range map[domain.CustomVerificationRequestStatus]int64{ domain.CustomVerificationPending: 1, domain.CustomVerificationRejected: 1, domain.CustomVerificationRevoked: 1, domain.CustomVerificationApproved: 0, } { if got := counts[status] - baseline[status]; got != want { t.Fatalf("queue count delta for %s = %d, want %d", status, got, want) } } listed, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{ VerifierBotID: verifier.BotID, }) if err != nil || len(listed) != 3 || listed[0].ID != reapplied.ID { t.Fatalf("queue list = %+v err=%v", listed, err) } pendingOnly, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{ VerifierBotID: verifier.BotID, Statuses: []domain.CustomVerificationRequestStatus{domain.CustomVerificationPending}, }) if err != nil || len(pendingOnly) != 1 || pendingOnly[0].ID != reapplied.ID { t.Fatalf("pending queue = %+v err=%v", pendingOnly, err) } page, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{ VerifierBotID: verifier.BotID, Limit: 2, }) if err != nil || len(page) != 2 { t.Fatalf("queue page = %+v err=%v", page, err) } next, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{ VerifierBotID: verifier.BotID, Limit: 2, BeforeID: page[len(page)-1].ID, }) if err != nil || len(next) != 1 || next[0].ID >= page[len(page)-1].ID { t.Fatalf("queue keyset page = %+v err=%v", next, err) } byUsername, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{ VerifierBotID: verifier.BotID, Query: "@acmec", }) if err != nil || len(byUsername) != 2 { t.Fatalf("username query = %+v err=%v", byUsername, err) } byPeerID, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{ VerifierBotID: verifier.BotID, Query: fmt.Sprintf("%d", peer.ID), }) if err != nil || len(byPeerID) != 1 || byPeerID[0].ID != revoked.ID { t.Fatalf("numeric query = %+v err=%v", byPeerID, err) } channelsOnly, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{ VerifierBotID: verifier.BotID, PeerType: domain.PeerTypeChannel, }) if err != nil || len(channelsOnly) != 1 || channelsOnly[0].ID != revoked.ID { t.Fatalf("channel queue = %+v err=%v", channelsOnly, err) } if _, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{ Statuses: []domain.CustomVerificationRequestStatus{"nonsense"}, }); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) { t.Fatalf("bad status filter err = %v, want ErrCustomVerificationRequestInvalid", err) } history, err := s.CustomVerificationRequestsForApplicant(ctx, applicant, 0) if err != nil || len(history) != 3 || history[0].ID != reapplied.ID { t.Fatalf("applicant history = %+v err=%v", history, err) } if empty, err := s.CustomVerificationRequestsForApplicant(ctx, target, 0); err != nil || len(empty) != 0 { t.Fatalf("other applicant history = %+v err=%v", empty, err) } if _, err := s.CustomVerificationRequestsForApplicant(ctx, 0, 0); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) { t.Fatalf("history for applicant 0 err = %v, want ErrCustomVerificationRequestInvalid", err) } if _, err := s.CustomVerificationRequest(ctx, reapplied.ID+1<<40); !errors.Is(err, domain.ErrCustomVerificationRequestNotFound) { t.Fatalf("unknown application err = %v, want ErrCustomVerificationRequestNotFound", err) } } // TestBotVerificationDescriptionsAcceptEmojiPostgres pins the app-configured // 70-rune custom-description limit against UTF-8 byte constraints. func TestBotVerificationDescriptionsAcceptEmojiPostgres(t *testing.T) { pool := testPool(t) ctx := context.Background() s := NewBotVerificationStore(pool) bot := botVerificationTestUser(t, pool) peer := domain.Peer{Type: domain.PeerTypeUser, ID: botVerificationTestUser(t, pool)} icon := botVerificationTestIcon(t, pool, s, "emoji icon", 0) emoji := strings.Repeat("🛡", domain.MaxCustomVerificationDescriptionLength) if utf8.RuneCountInString(emoji) > domain.MaxCustomVerificationDescriptionLength { t.Fatalf("test description is %d runes, over the domain limit", utf8.RuneCountInString(emoji)) } if len(emoji) != 4*domain.MaxCustomVerificationDescriptionLength { t.Fatalf("test description is %d bytes, want UTF-8 worst case", len(emoji)) } settings, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{ BotID: bot, IconDocumentID: icon.DocumentID, CompanyName: "Acme", DefaultDescription: emoji, Enabled: true, CanModifyCustomDescription: true, }) if err != nil { t.Fatalf("verifier settings with an emoji description: %v", err) } if settings.DefaultDescription != emoji { t.Fatalf("stored default description was altered: %d runes back, %d in", utf8.RuneCountInString(settings.DefaultDescription), utf8.RuneCountInString(emoji)) } // The granted mark carries the same text, so widening only the settings column // would move the failure one step later instead of fixing it. mark, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{ VerifierBotID: bot, Peer: peer, IconDocumentID: icon.DocumentID, Description: emoji, }) if err != nil { t.Fatalf("grant with an emoji description: %v", err) } if mark.Description != emoji { t.Fatalf("stored mark description was altered: %d runes back, %d in", utf8.RuneCountInString(mark.Description), utf8.RuneCountInString(emoji)) } // And so does an application, which is where an applicant types one. if _, err := s.CreateCustomVerificationRequest(ctx, domain.CustomVerificationRequest{ VerifierBotID: bot, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: botVerificationTestUser(t, pool)}, ApplicantUserID: bot, RequestedDescription: emoji, }); err != nil { t.Fatalf("application with an emoji description: %v", err) } // Past the rune limit it is still a domain error, not a constraint error: the // bound moved, it did not disappear. tooLong := strings.Repeat("🛡", domain.MaxCustomVerificationDescriptionLength+1) if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{ BotID: bot, IconDocumentID: icon.DocumentID, CompanyName: "Acme", DefaultDescription: tooLong, Version: settings.Version, }); !errors.Is(err, domain.ErrVerifierSettingsInvalid) { t.Fatalf("over-long description err = %v, want ErrVerifierSettingsInvalid", err) } }