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>
1045 lines
42 KiB
Go
1045 lines
42 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
botverificationapp "telesrv/internal/app/botverification"
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
// Compile-time proof that the shipped use-case service satisfies the admin port.
|
|
// cmd/telesrv wires *botverification.Service into Dependencies.BotVerification
|
|
// directly, so a drifting method set has to fail here rather than at integration
|
|
// time.
|
|
var _ BotVerificationService = (*botverificationapp.Service)(nil)
|
|
|
|
type fakeBotVerificationService struct {
|
|
icons []domain.VerificationIcon
|
|
verifier domain.BotVerifierSettings
|
|
hasRow bool
|
|
marks []domain.CustomVerification
|
|
request domain.CustomVerificationRequest
|
|
counts map[domain.CustomVerificationRequestStatus]int64
|
|
|
|
iconsErr error
|
|
verifierErr error
|
|
requestErr error
|
|
marksErr error
|
|
decideErr error
|
|
writeErr error
|
|
|
|
upsertIconCalls int
|
|
setIconCalls int
|
|
grantCalls int
|
|
setEnabledCalls int
|
|
revokeVerifCalls int
|
|
revokeMarkCalls int
|
|
approveCalls int
|
|
rejectCalls int
|
|
revokeReqCalls int
|
|
granted domain.BotVerifierSettings
|
|
upsertedIcon domain.VerificationIcon
|
|
setIconActive bool
|
|
setEnabledValue bool
|
|
revokedMarkPeer domain.Peer
|
|
revokedMarkBotID int64
|
|
decidedReason string
|
|
decidedNote string
|
|
decidedBy string
|
|
decidedVersion int64
|
|
decidedRequestID int64
|
|
markFilterCapture domain.CustomVerificationFilter
|
|
}
|
|
|
|
func (f *fakeBotVerificationService) Icons(context.Context, bool, int) ([]domain.VerificationIcon, error) {
|
|
if f.iconsErr != nil {
|
|
return nil, f.iconsErr
|
|
}
|
|
return f.icons, nil
|
|
}
|
|
|
|
func (f *fakeBotVerificationService) UpsertIcon(_ context.Context, icon domain.VerificationIcon) (domain.VerificationIcon, error) {
|
|
f.upsertIconCalls++
|
|
f.upsertedIcon = icon
|
|
if f.writeErr != nil {
|
|
return domain.VerificationIcon{}, f.writeErr
|
|
}
|
|
icon.ID = 501
|
|
return icon, nil
|
|
}
|
|
|
|
func (f *fakeBotVerificationService) SetIconActive(_ context.Context, iconID int64, active bool) (domain.VerificationIcon, error) {
|
|
f.setIconCalls++
|
|
f.setIconActive = active
|
|
if f.writeErr != nil {
|
|
return domain.VerificationIcon{}, f.writeErr
|
|
}
|
|
return domain.VerificationIcon{ID: iconID, DocumentID: 900, Name: "blue check", Active: active}, nil
|
|
}
|
|
|
|
func (f *fakeBotVerificationService) Verifiers(context.Context, bool, int) ([]domain.BotVerifierSettings, error) {
|
|
if !f.hasRow {
|
|
return nil, nil
|
|
}
|
|
return []domain.BotVerifierSettings{f.verifier}, nil
|
|
}
|
|
|
|
func (f *fakeBotVerificationService) VerifierSettings(_ context.Context, botID int64) (domain.BotVerifierSettings, error) {
|
|
if f.verifierErr != nil {
|
|
return domain.BotVerifierSettings{}, f.verifierErr
|
|
}
|
|
if !f.hasRow || f.verifier.BotID != botID {
|
|
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
|
|
}
|
|
return f.verifier, nil
|
|
}
|
|
|
|
func (f *fakeBotVerificationService) GrantVerifier(_ context.Context, settings domain.BotVerifierSettings) (domain.BotVerifierSettings, error) {
|
|
f.grantCalls++
|
|
f.granted = settings
|
|
if f.writeErr != nil {
|
|
return domain.BotVerifierSettings{}, f.writeErr
|
|
}
|
|
settings.Version++
|
|
if settings.Version == 0 {
|
|
settings.Version = 1
|
|
}
|
|
f.verifier = settings
|
|
f.hasRow = true
|
|
return settings, nil
|
|
}
|
|
|
|
func (f *fakeBotVerificationService) SetVerifierEnabled(_ context.Context, botID int64, enabled bool) (domain.BotVerifierSettings, error) {
|
|
f.setEnabledCalls++
|
|
f.setEnabledValue = enabled
|
|
if f.writeErr != nil {
|
|
return domain.BotVerifierSettings{}, f.writeErr
|
|
}
|
|
f.verifier.BotID = botID
|
|
f.verifier.Enabled = enabled
|
|
f.verifier.Version++
|
|
return f.verifier, nil
|
|
}
|
|
|
|
func (f *fakeBotVerificationService) RevokeVerifier(context.Context, int64) (bool, error) {
|
|
f.revokeVerifCalls++
|
|
if f.writeErr != nil {
|
|
return false, f.writeErr
|
|
}
|
|
removed := f.hasRow
|
|
f.hasRow = false
|
|
return removed, nil
|
|
}
|
|
|
|
func (f *fakeBotVerificationService) Marks(_ context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
|
|
f.markFilterCapture = filter
|
|
if f.marksErr != nil {
|
|
return nil, f.marksErr
|
|
}
|
|
out := make([]domain.CustomVerification, 0, len(f.marks))
|
|
for _, mark := range f.marks {
|
|
if filter.VerifierBotID != 0 && mark.VerifierBotID != filter.VerifierBotID {
|
|
continue
|
|
}
|
|
if filter.PeerID != 0 && mark.Peer.ID != filter.PeerID {
|
|
continue
|
|
}
|
|
out = append(out, mark)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeBotVerificationService) RevokeMark(_ context.Context, verifierBotID int64, peer domain.Peer) (bool, error) {
|
|
f.revokeMarkCalls++
|
|
f.revokedMarkBotID = verifierBotID
|
|
f.revokedMarkPeer = peer
|
|
if f.writeErr != nil {
|
|
return false, f.writeErr
|
|
}
|
|
return len(f.marks) > 0, nil
|
|
}
|
|
|
|
func (f *fakeBotVerificationService) Requests(context.Context, domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
|
|
return []domain.CustomVerificationRequest{f.request}, nil
|
|
}
|
|
|
|
func (f *fakeBotVerificationService) Request(_ context.Context, requestID int64) (domain.CustomVerificationRequest, error) {
|
|
if f.requestErr != nil {
|
|
return domain.CustomVerificationRequest{}, f.requestErr
|
|
}
|
|
if f.request.ID != requestID {
|
|
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
|
|
}
|
|
return f.request, nil
|
|
}
|
|
|
|
func (f *fakeBotVerificationService) RequestCounts(context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
|
|
return f.counts, nil
|
|
}
|
|
|
|
func (f *fakeBotVerificationService) decide(status domain.CustomVerificationRequestStatus, requestID, version int64, by, reason, note string) (domain.CustomVerificationRequest, bool, error) {
|
|
f.decidedRequestID = requestID
|
|
f.decidedVersion = version
|
|
f.decidedBy = by
|
|
f.decidedReason = reason
|
|
f.decidedNote = note
|
|
if f.decideErr != nil {
|
|
return domain.CustomVerificationRequest{}, false, f.decideErr
|
|
}
|
|
f.request.Status = status
|
|
f.request.DecidedBy = by
|
|
f.request.DecisionReason = reason
|
|
f.request.InternalNote = note
|
|
f.request.Version++
|
|
return f.request, true, nil
|
|
}
|
|
|
|
func (f *fakeBotVerificationService) Approve(_ context.Context, requestID, version int64, by, reason, note string) (domain.CustomVerificationRequest, bool, error) {
|
|
f.approveCalls++
|
|
return f.decide(domain.CustomVerificationApproved, requestID, version, by, reason, note)
|
|
}
|
|
|
|
func (f *fakeBotVerificationService) Reject(_ context.Context, requestID, version int64, by, reason, note string) (domain.CustomVerificationRequest, bool, error) {
|
|
f.rejectCalls++
|
|
return f.decide(domain.CustomVerificationRejected, requestID, version, by, reason, note)
|
|
}
|
|
|
|
func (f *fakeBotVerificationService) RevokeRequest(_ context.Context, requestID, version int64, by, reason, note string) (domain.CustomVerificationRequest, bool, error) {
|
|
f.revokeReqCalls++
|
|
return f.decide(domain.CustomVerificationRevoked, requestID, version, by, reason, note)
|
|
}
|
|
|
|
func pendingCustomVerificationRequest() domain.CustomVerificationRequest {
|
|
return domain.CustomVerificationRequest{
|
|
ID: 88,
|
|
VerifierBotID: 3003,
|
|
ApplicantUserID: 1001,
|
|
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 5005},
|
|
PeerTitle: "Example News",
|
|
PeerUsername: "examplenews",
|
|
Reason: "we are the outlet",
|
|
RequestedDescription: "verified partner",
|
|
Status: domain.CustomVerificationPending,
|
|
Version: 3,
|
|
CreatedAt: fixedNow(),
|
|
}
|
|
}
|
|
|
|
func enabledVerifierSettings() domain.BotVerifierSettings {
|
|
return domain.BotVerifierSettings{
|
|
BotID: 3003,
|
|
IconDocumentID: 900,
|
|
CompanyName: "Example Trust",
|
|
DefaultDescription: "verified by Example Trust",
|
|
CanModifyCustomDescription: true,
|
|
Enabled: true,
|
|
Version: 4,
|
|
CreatedAt: fixedNow(),
|
|
UpdatedAt: fixedNow(),
|
|
}
|
|
}
|
|
|
|
func newBotVerificationFixture() (*Service, *fakeBotVerificationService, *memoryCommandRepo) {
|
|
repo := newMemoryCommandRepo()
|
|
fake := &fakeBotVerificationService{
|
|
verifier: enabledVerifierSettings(),
|
|
hasRow: true,
|
|
request: pendingCustomVerificationRequest(),
|
|
icons: []domain.VerificationIcon{
|
|
{ID: 501, DocumentID: 900, Name: "blue check", Active: true},
|
|
{ID: 502, DocumentID: 901, Name: "retired", Active: false},
|
|
{ID: 503, DocumentID: 902, Name: "reserved", Active: true, OwnerBotID: 4004},
|
|
},
|
|
counts: map[domain.CustomVerificationRequestStatus]int64{domain.CustomVerificationPending: 3},
|
|
}
|
|
svc := NewService(Dependencies{Commands: repo, BotVerification: fake, Now: fixedNow})
|
|
return svc, fake, repo
|
|
}
|
|
|
|
func meta(commandID string, dryRun bool) CommandMeta {
|
|
return CommandMeta{CommandID: commandID, Actor: "alice", Reason: "operator decision", DryRun: dryRun}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Verifier status
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestGrantBotVerifierDryRunExecuteAndIdempotentReplay(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, fake, repo := newBotVerificationFixture()
|
|
|
|
dry, err := svc.GrantBotVerifier(ctx, GrantBotVerifierRequest{
|
|
CommandMeta: meta("dry-grant", true),
|
|
BotID: 3003,
|
|
IconDocumentID: 900,
|
|
CompanyName: "Example Trust",
|
|
Version: 4,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("dry-run grant: %v", err)
|
|
}
|
|
if !dry.DryRun || dry.Status != string(domain.AdminCommandCompleted) || fake.grantCalls != 0 {
|
|
t.Fatalf("dry-run result=%+v grantCalls=%d, want a completed dry run without mutation", dry, fake.grantCalls)
|
|
}
|
|
if dry.Details["bot_id"] != "3003" || dry.Details["previous_version"] != "4" ||
|
|
dry.Details["created"] != false || dry.Details["icon_active"] != true ||
|
|
dry.Details["correlation_id"] != "dry-grant" {
|
|
t.Fatalf("dry-run details=%+v, want the audit facts seeded before execution", dry.Details)
|
|
}
|
|
|
|
execReq := GrantBotVerifierRequest{
|
|
CommandMeta: meta("exec-grant", false),
|
|
BotID: 3003,
|
|
IconDocumentID: 900,
|
|
CompanyName: "Example Trust",
|
|
DefaultDescription: "verified by Example Trust",
|
|
CanModifyCustomDescription: true,
|
|
Version: 4,
|
|
}
|
|
exec, err := svc.GrantBotVerifier(ctx, execReq)
|
|
if err != nil {
|
|
t.Fatalf("execute grant: %v", err)
|
|
}
|
|
if fake.grantCalls != 1 || exec.Details["version"] != "5" {
|
|
t.Fatalf("execute result=%+v grantCalls=%d", exec, fake.grantCalls)
|
|
}
|
|
// The operator identity and the reason are journalled onto the verifier row, so
|
|
// the grant is attributable without joining the command log.
|
|
if fake.granted.GrantedBy != "alice" || fake.granted.GrantReason != "operator decision" {
|
|
t.Fatalf("granted settings=%+v, want the actor and reason recorded", fake.granted)
|
|
}
|
|
if _, ok := repo.items["exec-grant"]; !ok {
|
|
t.Fatal("grant was not journalled")
|
|
}
|
|
|
|
replay, err := svc.GrantBotVerifier(ctx, execReq)
|
|
if err != nil {
|
|
t.Fatalf("replay grant: %v", err)
|
|
}
|
|
if !replay.AlreadyExecuted || fake.grantCalls != 1 {
|
|
t.Fatalf("replay result=%+v grantCalls=%d, want one execution", replay, fake.grantCalls)
|
|
}
|
|
}
|
|
|
|
// Reconfiguring a switched-off verifier must not switch it back on: the kill
|
|
// switch is its own action, and a grant form that silently re-enabled would undo a
|
|
// deliberate operator decision.
|
|
func TestGrantBotVerifierPreservesTheKillSwitch(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, fake, _ := newBotVerificationFixture()
|
|
fake.verifier.Enabled = false
|
|
|
|
result, err := svc.GrantBotVerifier(ctx, GrantBotVerifierRequest{
|
|
CommandMeta: meta("exec-grant", false),
|
|
BotID: 3003,
|
|
IconDocumentID: 900,
|
|
CompanyName: "Example Trust",
|
|
Version: 4,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("grant: %v", err)
|
|
}
|
|
if fake.granted.Enabled {
|
|
t.Fatalf("granted settings=%+v, want the disabled state preserved", fake.granted)
|
|
}
|
|
if result.Details["previous_enabled"] != false || result.Details["enabled"] != false {
|
|
t.Fatalf("details=%+v", result.Details)
|
|
}
|
|
}
|
|
|
|
func TestGrantBotVerifierVersionRules(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// An existing row demands the version the operator read.
|
|
svc, fake, _ := newBotVerificationFixture()
|
|
_, err := svc.GrantBotVerifier(ctx, GrantBotVerifierRequest{
|
|
CommandMeta: meta("stale", false),
|
|
BotID: 3003,
|
|
IconDocumentID: 900,
|
|
CompanyName: "Example Trust",
|
|
Version: 3,
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), CodeCustomVerificationConflict) {
|
|
t.Fatalf("stale version err=%v, want %s", err, CodeCustomVerificationConflict)
|
|
}
|
|
if fake.grantCalls != 0 {
|
|
t.Fatalf("grantCalls=%d, want no write on a lost race", fake.grantCalls)
|
|
}
|
|
|
|
// version=0 on an existing row is "I did not read this row": also a conflict.
|
|
svc, _, _ = newBotVerificationFixture()
|
|
if _, err := svc.GrantBotVerifier(ctx, GrantBotVerifierRequest{
|
|
CommandMeta: meta("fresh-on-existing", false), BotID: 3003,
|
|
IconDocumentID: 900, CompanyName: "Example Trust", Version: 0,
|
|
}); err == nil || !strings.Contains(err.Error(), CodeCustomVerificationConflict) {
|
|
t.Fatalf("version=0 on an existing row err=%v, want a conflict", err)
|
|
}
|
|
|
|
// A positive version against no row is an edit of something that is gone.
|
|
svc, fake, _ = newBotVerificationFixture()
|
|
fake.hasRow = false
|
|
if _, err := svc.GrantBotVerifier(ctx, GrantBotVerifierRequest{
|
|
CommandMeta: meta("edit-missing", false), BotID: 3003,
|
|
IconDocumentID: 900, CompanyName: "Example Trust", Version: 4,
|
|
}); err == nil || !strings.Contains(err.Error(), CodeBotVerifierNotFound) {
|
|
t.Fatalf("edit of a missing row err=%v, want %s", err, CodeBotVerifierNotFound)
|
|
}
|
|
|
|
// A fresh grant with version=0 goes through and is marked as a creation.
|
|
svc, fake, _ = newBotVerificationFixture()
|
|
fake.hasRow = false
|
|
result, err := svc.GrantBotVerifier(ctx, GrantBotVerifierRequest{
|
|
CommandMeta: meta("fresh", false), BotID: 3003,
|
|
IconDocumentID: 900, CompanyName: "Example Trust", Version: 0,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("fresh grant: %v", err)
|
|
}
|
|
if result.Details["created"] != true || fake.grantCalls != 1 || !fake.granted.Enabled {
|
|
t.Fatalf("fresh grant details=%+v granted=%+v", result.Details, fake.granted)
|
|
}
|
|
}
|
|
|
|
// The dry run refuses an icon the catalogue definitely rejects, so the operator is
|
|
// told before anything is written.
|
|
func TestGrantBotVerifierRefusesAnUnusableIcon(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
svc, fake, _ := newBotVerificationFixture()
|
|
fake.hasRow = false
|
|
if _, err := svc.GrantBotVerifier(ctx, GrantBotVerifierRequest{
|
|
CommandMeta: meta("retired-icon", true), BotID: 3003,
|
|
IconDocumentID: 901, CompanyName: "Example Trust",
|
|
}); err == nil || !strings.Contains(err.Error(), CodeVerificationIconInactive) {
|
|
t.Fatalf("retired icon err=%v, want %s", err, CodeVerificationIconInactive)
|
|
}
|
|
|
|
// A reserved entry belongs to one verifier; for anybody else it does not exist.
|
|
svc, fake, _ = newBotVerificationFixture()
|
|
fake.hasRow = false
|
|
if _, err := svc.GrantBotVerifier(ctx, GrantBotVerifierRequest{
|
|
CommandMeta: meta("reserved-icon", true), BotID: 3003,
|
|
IconDocumentID: 902, CompanyName: "Example Trust",
|
|
}); err == nil || !strings.Contains(err.Error(), CodeVerificationIconNotFound) {
|
|
t.Fatalf("reserved icon err=%v, want %s", err, CodeVerificationIconNotFound)
|
|
}
|
|
|
|
// An icon the catalogue page does not cover is left to the use-case layer: a
|
|
// dry run must never refuse more than the command it rehearses.
|
|
svc, fake, _ = newBotVerificationFixture()
|
|
fake.hasRow = false
|
|
result, err := svc.GrantBotVerifier(ctx, GrantBotVerifierRequest{
|
|
CommandMeta: meta("unknown-icon", true), BotID: 3003,
|
|
IconDocumentID: 999, CompanyName: "Example Trust",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("uncovered icon: %v", err)
|
|
}
|
|
if result.Details["icon_catalogue_checked"] != false {
|
|
t.Fatalf("details=%+v, want the unchecked icon reported rather than refused", result.Details)
|
|
}
|
|
}
|
|
|
|
func TestGrantBotVerifierValidatesShapeBeforeTheJournal(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, _, repo := newBotVerificationFixture()
|
|
cases := []struct {
|
|
name string
|
|
req GrantBotVerifierRequest
|
|
code string
|
|
}{
|
|
{"no bot", GrantBotVerifierRequest{CommandMeta: meta("c1", false), IconDocumentID: 900, CompanyName: "x"}, CodeBotVerifierNotFound},
|
|
{"no icon", GrantBotVerifierRequest{CommandMeta: meta("c2", false), BotID: 3003, CompanyName: "x"}, CodeBotVerifierInvalid},
|
|
{"no company", GrantBotVerifierRequest{CommandMeta: meta("c3", false), BotID: 3003, IconDocumentID: 900}, CodeBotVerifierInvalid},
|
|
{"negative version", GrantBotVerifierRequest{CommandMeta: meta("c4", false), BotID: 3003, IconDocumentID: 900, CompanyName: "x", Version: -1}, CodeBotVerifierInvalid},
|
|
{"company too long", GrantBotVerifierRequest{CommandMeta: meta("c5", false), BotID: 3003, IconDocumentID: 900,
|
|
CompanyName: strings.Repeat("x", domain.MaxVerifierCompanyLength+1)}, CodeBotVerifierInvalid},
|
|
}
|
|
for _, item := range cases {
|
|
if _, err := svc.GrantBotVerifier(ctx, item.req); err == nil || !strings.Contains(err.Error(), item.code) {
|
|
t.Fatalf("%s err=%v, want %s", item.name, err, item.code)
|
|
}
|
|
}
|
|
// Nothing reached the journal: a malformed command is not an audit subject.
|
|
if len(repo.items) != 0 {
|
|
t.Fatalf("journal=%+v, want a refusal before BeginCommand", repo.items)
|
|
}
|
|
}
|
|
|
|
func TestSetBotVerifierEnabledNoOpAndMissingRow(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// A no-op flip is reported as unchanged rather than burning a version.
|
|
svc, fake, _ := newBotVerificationFixture()
|
|
result, err := svc.SetBotVerifierEnabled(ctx, SetBotVerifierEnabledRequest{
|
|
CommandMeta: meta("noop", false), BotID: 3003, Enabled: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("no-op flip: %v", err)
|
|
}
|
|
if result.Details["changed"] != false || !strings.Contains(result.Message, "already") {
|
|
t.Fatalf("no-op result=%+v", result)
|
|
}
|
|
|
|
// A real flip writes once and reports the previous state.
|
|
svc, fake, _ = newBotVerificationFixture()
|
|
result, err = svc.SetBotVerifierEnabled(ctx, SetBotVerifierEnabledRequest{
|
|
CommandMeta: meta("disable", false), BotID: 3003, Enabled: false,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("flip: %v", err)
|
|
}
|
|
if fake.setEnabledCalls != 1 || fake.setEnabledValue {
|
|
t.Fatalf("setEnabledCalls=%d value=%v", fake.setEnabledCalls, fake.setEnabledValue)
|
|
}
|
|
if result.Details["previous_enabled"] != true || result.Details["changed"] != true {
|
|
t.Fatalf("details=%+v", result.Details)
|
|
}
|
|
|
|
// The dry run does not mutate.
|
|
svc, fake, _ = newBotVerificationFixture()
|
|
if _, err := svc.SetBotVerifierEnabled(ctx, SetBotVerifierEnabledRequest{
|
|
CommandMeta: meta("dry", true), BotID: 3003, Enabled: false,
|
|
}); err != nil {
|
|
t.Fatalf("dry flip: %v", err)
|
|
}
|
|
if fake.setEnabledCalls != 0 {
|
|
t.Fatalf("setEnabledCalls=%d, want no mutation on a dry run", fake.setEnabledCalls)
|
|
}
|
|
|
|
// A bot that is not a verifier has no switch to flip.
|
|
svc, fake, _ = newBotVerificationFixture()
|
|
fake.hasRow = false
|
|
if _, err := svc.SetBotVerifierEnabled(ctx, SetBotVerifierEnabledRequest{
|
|
CommandMeta: meta("missing", false), BotID: 3003, Enabled: false,
|
|
}); err == nil || !strings.Contains(err.Error(), CodeBotVerifierNotFound) {
|
|
t.Fatalf("missing verifier err=%v, want %s", err, CodeBotVerifierNotFound)
|
|
}
|
|
}
|
|
|
|
func TestRevokeBotVerifierIsIdempotentAndStatesTheCascade(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, fake, _ := newBotVerificationFixture()
|
|
fake.marks = []domain.CustomVerification{
|
|
{ID: 1, VerifierBotID: 3003, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 5005}},
|
|
}
|
|
|
|
dry, err := svc.RevokeBotVerifier(ctx, RevokeBotVerifierRequest{CommandMeta: meta("dry", true), BotID: 3003})
|
|
if err != nil {
|
|
t.Fatalf("dry revoke: %v", err)
|
|
}
|
|
// The marks that would cascade away are stated before the operator confirms.
|
|
if dry.Details["present"] != true || dry.Details["mark_count"] != "1" || fake.revokeVerifCalls != 0 {
|
|
t.Fatalf("dry details=%+v revokeCalls=%d", dry.Details, fake.revokeVerifCalls)
|
|
}
|
|
|
|
exec, err := svc.RevokeBotVerifier(ctx, RevokeBotVerifierRequest{CommandMeta: meta("exec", false), BotID: 3003})
|
|
if err != nil {
|
|
t.Fatalf("revoke: %v", err)
|
|
}
|
|
if exec.Details["changed"] != true || fake.revokeVerifCalls != 1 {
|
|
t.Fatalf("details=%+v revokeCalls=%d", exec.Details, fake.revokeVerifCalls)
|
|
}
|
|
|
|
// A bot that is not a verifier answers changed=false rather than an error, so a
|
|
// panel retry after a lost response is harmless.
|
|
svc, fake, _ = newBotVerificationFixture()
|
|
fake.hasRow = false
|
|
again, err := svc.RevokeBotVerifier(ctx, RevokeBotVerifierRequest{CommandMeta: meta("gone", false), BotID: 3003})
|
|
if err != nil {
|
|
t.Fatalf("revoke of a non-verifier: %v", err)
|
|
}
|
|
if again.Details["changed"] != false || !strings.Contains(again.Message, "not a verifier") {
|
|
t.Fatalf("result=%+v", again)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Icon catalogue
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestUpsertVerificationIconValidationAndDryRun(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, fake, repo := newBotVerificationFixture()
|
|
|
|
for _, item := range []struct {
|
|
name string
|
|
req UpsertVerificationIconRequest
|
|
}{
|
|
{"no document", UpsertVerificationIconRequest{CommandMeta: meta("c1", false), Name: "x"}},
|
|
{"no name", UpsertVerificationIconRequest{CommandMeta: meta("c2", false), DocumentID: 900}},
|
|
{"blank name", UpsertVerificationIconRequest{CommandMeta: meta("c3", false), DocumentID: 900, Name: " "}},
|
|
{"negative owner", UpsertVerificationIconRequest{CommandMeta: meta("c4", false), DocumentID: 900, Name: "x", OwnerBotID: -1}},
|
|
{"name too long", UpsertVerificationIconRequest{CommandMeta: meta("c5", false), DocumentID: 900,
|
|
Name: strings.Repeat("x", domain.MaxVerificationIconNameLength+1)}},
|
|
} {
|
|
if _, err := svc.UpsertVerificationIcon(ctx, item.req); err == nil ||
|
|
!strings.Contains(err.Error(), CodeVerificationIconInvalid) {
|
|
t.Fatalf("%s err=%v, want %s", item.name, err, CodeVerificationIconInvalid)
|
|
}
|
|
}
|
|
if len(repo.items) != 0 {
|
|
t.Fatalf("journal=%+v, want a refusal before BeginCommand", repo.items)
|
|
}
|
|
|
|
if _, err := svc.UpsertVerificationIcon(ctx, UpsertVerificationIconRequest{
|
|
CommandMeta: meta("dry", true), DocumentID: 900, Name: " blue check ",
|
|
}); err != nil {
|
|
t.Fatalf("dry upsert: %v", err)
|
|
}
|
|
if fake.upsertIconCalls != 0 {
|
|
t.Fatalf("upsertIconCalls=%d, want no mutation on a dry run", fake.upsertIconCalls)
|
|
}
|
|
|
|
result, err := svc.UpsertVerificationIcon(ctx, UpsertVerificationIconRequest{
|
|
CommandMeta: meta("exec", false), DocumentID: 900, Name: " blue check ", OwnerBotID: 3003,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("upsert: %v", err)
|
|
}
|
|
// A new catalogue entry is active: retiring one is its own action.
|
|
if fake.upsertedIcon.Name != "blue check" || !fake.upsertedIcon.Active || fake.upsertedIcon.OwnerBotID != 3003 {
|
|
t.Fatalf("upserted icon=%+v, want a trimmed active entry", fake.upsertedIcon)
|
|
}
|
|
if result.Details["icon_id"] != "501" {
|
|
t.Fatalf("details=%+v", result.Details)
|
|
}
|
|
}
|
|
|
|
func TestSetVerificationIconActiveRequiresAnIconAndHonoursDryRun(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, fake, _ := newBotVerificationFixture()
|
|
|
|
if _, err := svc.SetVerificationIconActive(ctx, SetVerificationIconActiveRequest{
|
|
CommandMeta: meta("c1", false), IconID: 0, Active: false,
|
|
}); err == nil || !strings.Contains(err.Error(), CodeVerificationIconNotFound) {
|
|
t.Fatalf("missing icon err=%v, want %s", err, CodeVerificationIconNotFound)
|
|
}
|
|
|
|
if _, err := svc.SetVerificationIconActive(ctx, SetVerificationIconActiveRequest{
|
|
CommandMeta: meta("dry", true), IconID: 501, Active: false,
|
|
}); err != nil {
|
|
t.Fatalf("dry switch: %v", err)
|
|
}
|
|
if fake.setIconCalls != 0 {
|
|
t.Fatalf("setIconCalls=%d, want no mutation on a dry run", fake.setIconCalls)
|
|
}
|
|
|
|
result, err := svc.SetVerificationIconActive(ctx, SetVerificationIconActiveRequest{
|
|
CommandMeta: meta("exec", false), IconID: 501, Active: false,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("switch: %v", err)
|
|
}
|
|
if fake.setIconCalls != 1 || fake.setIconActive || result.Details["active"] != false {
|
|
t.Fatalf("setIconCalls=%d active=%v details=%+v", fake.setIconCalls, fake.setIconActive, result.Details)
|
|
}
|
|
if !strings.Contains(result.Message, "retired") {
|
|
t.Fatalf("message=%q", result.Message)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Granted marks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestRevokeCustomVerificationShapeDryRunAndIdempotence(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, fake, repo := newBotVerificationFixture()
|
|
|
|
for _, item := range []struct {
|
|
name string
|
|
req RevokeCustomVerificationRequest
|
|
code string
|
|
}{
|
|
{"no verifier", RevokeCustomVerificationRequest{CommandMeta: meta("c1", false),
|
|
PeerType: domain.PeerTypeChannel, PeerID: 5005}, CodeBotVerifierNotFound},
|
|
{"no peer id", RevokeCustomVerificationRequest{CommandMeta: meta("c2", false),
|
|
VerifierBotID: 3003, PeerType: domain.PeerTypeChannel}, CodeCustomVerificationTargetInvalid},
|
|
{"unmodelled peer", RevokeCustomVerificationRequest{CommandMeta: meta("c3", false),
|
|
VerifierBotID: 3003, PeerType: domain.PeerType("chat"), PeerID: 5005}, CodeCustomVerificationTargetInvalid},
|
|
} {
|
|
if _, err := svc.RevokeCustomVerification(ctx, item.req); err == nil || !strings.Contains(err.Error(), item.code) {
|
|
t.Fatalf("%s err=%v, want %s", item.name, err, item.code)
|
|
}
|
|
}
|
|
if len(repo.items) != 0 {
|
|
t.Fatalf("journal=%+v, want a refusal before BeginCommand", repo.items)
|
|
}
|
|
|
|
fake.marks = []domain.CustomVerification{
|
|
{ID: 1, VerifierBotID: 3003, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 5005}},
|
|
}
|
|
dry, err := svc.RevokeCustomVerification(ctx, RevokeCustomVerificationRequest{
|
|
CommandMeta: meta("dry", true), VerifierBotID: 3003, PeerType: domain.PeerTypeChannel, PeerID: 5005,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("dry revoke: %v", err)
|
|
}
|
|
if dry.Details["mark_present"] != true || fake.revokeMarkCalls != 0 {
|
|
t.Fatalf("dry details=%+v revokeMarkCalls=%d", dry.Details, fake.revokeMarkCalls)
|
|
}
|
|
|
|
exec, err := svc.RevokeCustomVerification(ctx, RevokeCustomVerificationRequest{
|
|
CommandMeta: meta("exec", false), VerifierBotID: 3003, PeerType: domain.PeerTypeChannel, PeerID: 5005,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("revoke: %v", err)
|
|
}
|
|
if exec.Details["changed"] != true || fake.revokedMarkBotID != 3003 ||
|
|
fake.revokedMarkPeer != (domain.Peer{Type: domain.PeerTypeChannel, ID: 5005}) {
|
|
t.Fatalf("details=%+v revokedBot=%d peer=%+v", exec.Details, fake.revokedMarkBotID, fake.revokedMarkPeer)
|
|
}
|
|
|
|
// A peer that carries no mark from this verifier is a no-op, not an error: the
|
|
// operator must be able to retry a revoke whose response was lost.
|
|
svc, fake, _ = newBotVerificationFixture()
|
|
absent, err := svc.RevokeCustomVerification(ctx, RevokeCustomVerificationRequest{
|
|
CommandMeta: meta("absent", false), VerifierBotID: 3003, PeerType: domain.PeerTypeUser, PeerID: 7007,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("revoke of an absent mark: %v", err)
|
|
}
|
|
if absent.Details["changed"] != false || absent.Details["mark_present"] != false {
|
|
t.Fatalf("details=%+v", absent.Details)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Queue decisions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestApproveBotVerificationDryRunExecuteAndReplay(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, fake, repo := newBotVerificationFixture()
|
|
|
|
dry, err := svc.ApproveBotVerification(ctx, ApproveBotVerificationRequest{
|
|
CommandMeta: meta("dry-approve", true), RequestID: 88, Version: 3, InternalNote: "checked the outlet",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("dry approve: %v", err)
|
|
}
|
|
if !dry.DryRun || fake.approveCalls != 0 {
|
|
t.Fatalf("dry result=%+v approveCalls=%d", dry, fake.approveCalls)
|
|
}
|
|
if dry.Details["previous_status"] != string(domain.CustomVerificationPending) ||
|
|
dry.Details["next_status"] != string(domain.CustomVerificationApproved) ||
|
|
dry.Details["request_id"] != "88" || dry.Details["verifier_bot_id"] != "3003" ||
|
|
dry.Details["peer_type"] != "channel" || dry.Details["peer_id"] != "5005" ||
|
|
dry.Details["applicant_user_id"] != "1001" ||
|
|
dry.Details["internal_note"] != "checked the outlet" ||
|
|
dry.Details["verifier_enabled"] != true {
|
|
t.Fatalf("dry details=%+v, want the audit facts seeded before execution", dry.Details)
|
|
}
|
|
|
|
execReq := ApproveBotVerificationRequest{
|
|
CommandMeta: meta("exec-approve", false), RequestID: 88, Version: 3, InternalNote: "checked the outlet",
|
|
}
|
|
exec, err := svc.ApproveBotVerification(ctx, execReq)
|
|
if err != nil {
|
|
t.Fatalf("approve: %v", err)
|
|
}
|
|
if fake.approveCalls != 1 || exec.Details["status"] != string(domain.CustomVerificationApproved) ||
|
|
exec.Details["version"] != "4" || exec.Details["changed"] != true {
|
|
t.Fatalf("exec result=%+v approveCalls=%d", exec, fake.approveCalls)
|
|
}
|
|
// The internal note travels in its own field and never in the reason, which is
|
|
// what the applicant is shown.
|
|
if fake.decidedNote != "checked the outlet" || fake.decidedReason != "operator decision" ||
|
|
fake.decidedBy != "alice" || fake.decidedVersion != 3 || fake.decidedRequestID != 88 {
|
|
t.Fatalf("forwarded decision by=%q reason=%q note=%q version=%d id=%d",
|
|
fake.decidedBy, fake.decidedReason, fake.decidedNote, fake.decidedVersion, fake.decidedRequestID)
|
|
}
|
|
if _, ok := repo.items["exec-approve"]; !ok {
|
|
t.Fatal("approval was not journalled")
|
|
}
|
|
|
|
replay, err := svc.ApproveBotVerification(ctx, execReq)
|
|
if err != nil {
|
|
t.Fatalf("replay approve: %v", err)
|
|
}
|
|
if !replay.AlreadyExecuted || fake.approveCalls != 1 {
|
|
t.Fatalf("replay result=%+v approveCalls=%d, want one execution", replay, fake.approveCalls)
|
|
}
|
|
}
|
|
|
|
// An already-approved application is a replay the use-case layer answers as a
|
|
// no-op, so the dry run must not re-run the verifier gate and must not demand the
|
|
// version again.
|
|
func TestApproveBotVerificationReplayOfADecidedApplicationSkipsTheVerifierGate(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, fake, _ := newBotVerificationFixture()
|
|
fake.request.Status = domain.CustomVerificationApproved
|
|
fake.request.Version = 9
|
|
// The verifier was switched off after the approval; that must not make a replay
|
|
// fail.
|
|
fake.verifier.Enabled = false
|
|
|
|
result, err := svc.ApproveBotVerification(ctx, ApproveBotVerificationRequest{
|
|
CommandMeta: meta("dry", true), RequestID: 88, Version: 3,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("dry replay: %v", err)
|
|
}
|
|
if result.Details["previous_status"] != string(domain.CustomVerificationApproved) {
|
|
t.Fatalf("details=%+v", result.Details)
|
|
}
|
|
}
|
|
|
|
// An application can sit in the queue for days: a verifier switched off in the
|
|
// meantime must not be able to grant through the review path what the RPC path
|
|
// would refuse.
|
|
func TestApproveBotVerificationRefusesADisabledOrMissingVerifier(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
svc, fake, _ := newBotVerificationFixture()
|
|
fake.verifier.Enabled = false
|
|
if _, err := svc.ApproveBotVerification(ctx, ApproveBotVerificationRequest{
|
|
CommandMeta: meta("disabled", true), RequestID: 88, Version: 3,
|
|
}); err == nil || !strings.Contains(err.Error(), CodeBotVerifierForbidden) {
|
|
t.Fatalf("disabled verifier err=%v, want %s", err, CodeBotVerifierForbidden)
|
|
}
|
|
|
|
// "Never was a verifier" answers the same code on purpose: that is the
|
|
// distinction BOTVERIFIER_FORBIDDEN deliberately hides.
|
|
svc, fake, _ = newBotVerificationFixture()
|
|
fake.hasRow = false
|
|
if _, err := svc.ApproveBotVerification(ctx, ApproveBotVerificationRequest{
|
|
CommandMeta: meta("missing", true), RequestID: 88, Version: 3,
|
|
}); err == nil || !strings.Contains(err.Error(), CodeBotVerifierForbidden) {
|
|
t.Fatalf("missing verifier err=%v, want %s", err, CodeBotVerifierForbidden)
|
|
}
|
|
}
|
|
|
|
func TestBotVerificationDecisionsDemandTheVersionAndAValidTransition(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// A stale version loses the race.
|
|
svc, fake, _ := newBotVerificationFixture()
|
|
if _, err := svc.ApproveBotVerification(ctx, ApproveBotVerificationRequest{
|
|
CommandMeta: meta("stale", false), RequestID: 88, Version: 2,
|
|
}); err == nil || !strings.Contains(err.Error(), CodeCustomVerificationConflict) {
|
|
t.Fatalf("stale version err=%v, want %s", err, CodeCustomVerificationConflict)
|
|
}
|
|
if fake.approveCalls != 0 {
|
|
t.Fatalf("approveCalls=%d, want no write on a lost race", fake.approveCalls)
|
|
}
|
|
|
|
// A missing version never read the row at all, so the optimistic lock could not
|
|
// have protected anything.
|
|
svc, _, repo := newBotVerificationFixture()
|
|
if _, err := svc.ApproveBotVerification(ctx, ApproveBotVerificationRequest{
|
|
CommandMeta: meta("noversion", false), RequestID: 88,
|
|
}); err == nil || !strings.Contains(err.Error(), CodeCustomVerificationInvalid) {
|
|
t.Fatalf("missing version err=%v, want %s", err, CodeCustomVerificationInvalid)
|
|
}
|
|
if len(repo.items) != 0 {
|
|
t.Fatalf("journal=%+v, want a refusal before BeginCommand", repo.items)
|
|
}
|
|
|
|
// A rejected application cannot be revoked: revoked is reachable only from
|
|
// approved, which keeps it meaning "was verified once".
|
|
svc, fake, _ = newBotVerificationFixture()
|
|
fake.request.Status = domain.CustomVerificationRejected
|
|
if _, err := svc.RevokeBotVerification(ctx, RevokeBotVerificationRequest{
|
|
CommandMeta: meta("badedge", false), RequestID: 88, Version: 3,
|
|
}); err == nil || !strings.Contains(err.Error(), CodeCustomVerificationStatusInvalid) {
|
|
t.Fatalf("illegal transition err=%v, want %s", err, CodeCustomVerificationStatusInvalid)
|
|
}
|
|
|
|
// A note nobody could store is refused before the journal.
|
|
svc, _, repo = newBotVerificationFixture()
|
|
if _, err := svc.ApproveBotVerification(ctx, ApproveBotVerificationRequest{
|
|
CommandMeta: meta("longnote", false), RequestID: 88, Version: 3,
|
|
InternalNote: strings.Repeat("x", domain.MaxCustomVerificationNoteLength+1),
|
|
}); err == nil || !strings.Contains(err.Error(), CodeCustomVerificationInvalid) {
|
|
t.Fatalf("long note err=%v, want %s", err, CodeCustomVerificationInvalid)
|
|
}
|
|
if len(repo.items) != 0 {
|
|
t.Fatalf("journal=%+v", repo.items)
|
|
}
|
|
|
|
// A missing application is a 404-shaped failure.
|
|
svc, fake, _ = newBotVerificationFixture()
|
|
fake.request.ID = 99
|
|
if _, err := svc.ApproveBotVerification(ctx, ApproveBotVerificationRequest{
|
|
CommandMeta: meta("missing", false), RequestID: 88, Version: 3,
|
|
}); err == nil || !strings.Contains(err.Error(), CodeCustomVerificationRequestNotFound) {
|
|
t.Fatalf("missing application err=%v, want %s", err, CodeCustomVerificationRequestNotFound)
|
|
}
|
|
}
|
|
|
|
// A decision nobody can explain must never reach the audit trail, so reject and
|
|
// revoke refuse an empty reason before the journal is touched.
|
|
func TestRejectAndRevokeBotVerificationRequireAReason(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, fake, repo := newBotVerificationFixture()
|
|
|
|
for _, reason := range []string{"", " "} {
|
|
if _, err := svc.RejectBotVerification(ctx, RejectBotVerificationRequest{
|
|
CommandMeta: CommandMeta{CommandID: "c1", Actor: "alice", Reason: reason}, RequestID: 88, Version: 3,
|
|
}); err == nil || !strings.Contains(err.Error(), CodeCustomVerificationReasonRequired) {
|
|
t.Fatalf("reject with reason %q err=%v, want %s", reason, err, CodeCustomVerificationReasonRequired)
|
|
}
|
|
if _, err := svc.RevokeBotVerification(ctx, RevokeBotVerificationRequest{
|
|
CommandMeta: CommandMeta{CommandID: "c2", Actor: "alice", Reason: reason}, RequestID: 88, Version: 3,
|
|
}); err == nil || !strings.Contains(err.Error(), CodeCustomVerificationReasonRequired) {
|
|
t.Fatalf("revoke with reason %q err=%v, want %s", reason, err, CodeCustomVerificationReasonRequired)
|
|
}
|
|
}
|
|
if fake.rejectCalls != 0 || fake.revokeReqCalls != 0 || len(repo.items) != 0 {
|
|
t.Fatalf("rejectCalls=%d revokeCalls=%d journal=%+v, want nothing attempted",
|
|
fake.rejectCalls, fake.revokeReqCalls, repo.items)
|
|
}
|
|
|
|
// With a reason it goes through, and the reason is what the applicant is told.
|
|
result, err := svc.RejectBotVerification(ctx, RejectBotVerificationRequest{
|
|
CommandMeta: meta("exec-reject", false), RequestID: 88, Version: 3, InternalNote: "self-published only",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("reject: %v", err)
|
|
}
|
|
if fake.rejectCalls != 1 || fake.decidedReason != "operator decision" || fake.decidedNote != "self-published only" {
|
|
t.Fatalf("rejectCalls=%d reason=%q note=%q", fake.rejectCalls, fake.decidedReason, fake.decidedNote)
|
|
}
|
|
if result.Details["status"] != string(domain.CustomVerificationRejected) {
|
|
t.Fatalf("details=%+v", result.Details)
|
|
}
|
|
}
|
|
|
|
// Revoking through the application reports whether the mark was still there: the
|
|
// operator may have stripped it directly, and the application still has to reach
|
|
// "revoked".
|
|
func TestRevokeBotVerificationReportsTheMarkStateAndDoesNotMutateOnDryRun(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, fake, _ := newBotVerificationFixture()
|
|
fake.request.Status = domain.CustomVerificationApproved
|
|
fake.marks = []domain.CustomVerification{
|
|
{ID: 1, VerifierBotID: 3003, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 5005}},
|
|
}
|
|
|
|
dry, err := svc.RevokeBotVerification(ctx, RevokeBotVerificationRequest{
|
|
CommandMeta: meta("dry", true), RequestID: 88, Version: 3,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("dry revoke: %v", err)
|
|
}
|
|
if dry.Details["mark_present"] != true || fake.revokeReqCalls != 0 {
|
|
t.Fatalf("dry details=%+v revokeCalls=%d", dry.Details, fake.revokeReqCalls)
|
|
}
|
|
|
|
fake.marks = nil
|
|
exec, err := svc.RevokeBotVerification(ctx, RevokeBotVerificationRequest{
|
|
CommandMeta: meta("exec", false), RequestID: 88, Version: 3,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("revoke: %v", err)
|
|
}
|
|
if exec.Details["mark_present"] != false || fake.revokeReqCalls != 1 ||
|
|
exec.Details["status"] != string(domain.CustomVerificationRevoked) {
|
|
t.Fatalf("details=%+v revokeCalls=%d", exec.Details, fake.revokeReqCalls)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reads, wiring and error mapping
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestBotVerificationReadsRequireTheDependency(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Now: fixedNow})
|
|
if _, err := svc.BotVerifiers(ctx, false, 0); !errors.Is(err, errBotVerificationNotConfigured) {
|
|
t.Fatalf("verifiers err=%v", err)
|
|
}
|
|
if _, err := svc.VerificationIcons(ctx, false, 0); !errors.Is(err, errBotVerificationNotConfigured) {
|
|
t.Fatalf("icons err=%v", err)
|
|
}
|
|
if _, err := svc.CustomVerificationRequestCounts(ctx); !errors.Is(err, errBotVerificationNotConfigured) {
|
|
t.Fatalf("counts err=%v", err)
|
|
}
|
|
if _, err := svc.ApproveBotVerification(ctx, ApproveBotVerificationRequest{
|
|
CommandMeta: meta("c1", false), RequestID: 1, Version: 1,
|
|
}); !errors.Is(err, errBotVerificationNotConfigured) {
|
|
t.Fatalf("approve err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestCustomVerificationMarkActiveFiltersByTheExactPair(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, fake, _ := newBotVerificationFixture()
|
|
fake.marks = []domain.CustomVerification{
|
|
{ID: 1, VerifierBotID: 3003, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 5005}},
|
|
}
|
|
active, err := svc.CustomVerificationMarkActive(ctx, 3003, domain.Peer{Type: domain.PeerTypeChannel, ID: 5005})
|
|
if err != nil || !active {
|
|
t.Fatalf("active=%v err=%v", active, err)
|
|
}
|
|
if fake.markFilterCapture.VerifierBotID != 3003 || fake.markFilterCapture.PeerID != 5005 ||
|
|
fake.markFilterCapture.PeerType != domain.PeerTypeChannel {
|
|
t.Fatalf("filter=%+v, want the exact pair queried", fake.markFilterCapture)
|
|
}
|
|
// A different verifier's mark on the same peer is not this verifier's mark.
|
|
active, err = svc.CustomVerificationMarkActive(ctx, 4004, domain.Peer{Type: domain.PeerTypeChannel, ID: 5005})
|
|
if err != nil || active {
|
|
t.Fatalf("foreign verifier active=%v err=%v", active, err)
|
|
}
|
|
// A shapeless pair is answered false rather than queried.
|
|
if active, err := svc.CustomVerificationMarkActive(ctx, 0, domain.Peer{}); err != nil || active {
|
|
t.Fatalf("empty pair active=%v err=%v", active, err)
|
|
}
|
|
}
|
|
|
|
func TestBotVerificationErrorCodeMapping(t *testing.T) {
|
|
cases := map[error]string{
|
|
domain.ErrVerifierNotFound: CodeBotVerifierNotFound,
|
|
domain.ErrVerifierForbidden: CodeBotVerifierForbidden,
|
|
domain.ErrVerifierSettingsInvalid: CodeBotVerifierInvalid,
|
|
domain.ErrVerifierDescriptionForbidden: CodeBotVerifierDescriptionForbidden,
|
|
domain.ErrVerificationIconNotFound: CodeVerificationIconNotFound,
|
|
domain.ErrVerificationIconInactive: CodeVerificationIconInactive,
|
|
domain.ErrVerificationIconInvalid: CodeVerificationIconInvalid,
|
|
domain.ErrCustomVerificationNotFound: CodeCustomVerificationNotFound,
|
|
domain.ErrCustomVerificationRequestNotFound: CodeCustomVerificationRequestNotFound,
|
|
domain.ErrCustomVerificationRequestExists: CodeCustomVerificationRequestExists,
|
|
domain.ErrCustomVerificationVersionConflict: CodeCustomVerificationConflict,
|
|
domain.ErrCustomVerificationLimit: CodeCustomVerificationLimit,
|
|
domain.ErrCustomVerificationTargetInvalid: CodeCustomVerificationTargetInvalid,
|
|
domain.ErrCustomVerificationRequestInvalid: CodeCustomVerificationInvalid,
|
|
domain.ErrVerificationReasonRequired: CodeCustomVerificationReasonRequired,
|
|
domain.ErrVerificationTargetSystem: CodeCustomVerificationTargetSystem,
|
|
domain.ErrBotNotFound: CodeBotVerifierBotNotFound,
|
|
errors.New("something nobody has modelled"): "",
|
|
}
|
|
for err, want := range cases {
|
|
if got := BotVerificationErrorCode(err); got != want {
|
|
t.Fatalf("BotVerificationErrorCode(%v) = %q, want %q", err, got, want)
|
|
}
|
|
}
|
|
if BotVerificationErrorCode(nil) != "" {
|
|
t.Fatal("nil error carries a code")
|
|
}
|
|
// The third-party codes are distinct tokens from the official ones: the panel
|
|
// renders the two mechanisms in separate sections.
|
|
if CodeCustomVerificationConflict == CodeVerificationConflict ||
|
|
CodeCustomVerificationReasonRequired == CodeVerificationReasonRequired {
|
|
t.Fatal("third-party codes collide with the official verification ones")
|
|
}
|
|
}
|
|
|
|
func TestBotVerificationErrorsFromTheServiceAreCoded(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, fake, _ := newBotVerificationFixture()
|
|
fake.writeErr = domain.ErrCustomVerificationLimit
|
|
fake.request.Status = domain.CustomVerificationPending
|
|
fake.decideErr = domain.ErrCustomVerificationLimit
|
|
|
|
_, err := svc.ApproveBotVerification(ctx, ApproveBotVerificationRequest{
|
|
CommandMeta: meta("limit", false), RequestID: 88, Version: 3,
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), CodeCustomVerificationLimit) {
|
|
t.Fatalf("err=%v, want the per-verifier bound code", err)
|
|
}
|
|
}
|