Ctrl K
ComunidadGuías…

internal/app/botverification/service_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package botverification

import (
	"context"
	"errors"
	"strings"
	"testing"
	"time"

	"telesrv/internal/domain"
	"telesrv/internal/store/memory"
)

// The fixtures are one deployment: a verifier bot owned by one account, a second
// bot owned by the same account, a stranger, a plain user peer and a channel that
// account created.
const (
	fixtureVerifierBot = int64(100)
	fixtureOwnedBot    = int64(101)
	fixtureOwner       = int64(7)
	fixtureStranger    = int64(8)
	fixtureUserPeer    = int64(500)
	fixtureChannel     = int64(900)
	fixtureIconDoc     = int64(4242)
	fixtureOtherDoc    = int64(4343)
)

// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------

type memberKey struct {
	channelID int64
	userID    int64
}

// fakeDirectory is the peer-resolution port. It answers exactly like the
// aggregates do: an unknown channel is domain.ErrChannelInvalid and a
// non-membership is domain.ErrUserNotParticipant, so the service's error folding
// is exercised rather than bypassed.
type fakeDirectory struct {
	users    map[int64]domain.User
	channels map[int64]domain.Channel
	owners   map[int64]int64
	members  map[memberKey]domain.ChannelMember
}

func (f *fakeDirectory) AdminUser(_ context.Context, userID int64) (domain.User, bool, error) {
	user, ok := f.users[userID]
	return user, ok, nil
}

func (f *fakeDirectory) OwnsBot(_ context.Context, ownerUserID, botUserID int64) (bool, error) {
	owner, ok := f.owners[botUserID]
	return ok && owner == ownerUserID, nil
}

func (f *fakeDirectory) GetChannelByID(_ context.Context, channelID int64) (domain.Channel, error) {
	channel, ok := f.channels[channelID]
	if !ok {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return channel, nil
}

func (f *fakeDirectory) GetParticipant(_ context.Context, _, channelID, participantUserID int64) (domain.ChannelMember, error) {
	member, ok := f.members[memberKey{channelID: channelID, userID: participantUserID}]
	if !ok {
		return domain.ChannelMember{}, domain.ErrUserNotParticipant
	}
	return member, nil
}

type fakePeerNotifier struct {
	peers []domain.Peer
	err   error
}

func (f *fakePeerNotifier) NotifyPeerBotVerification(_ context.Context, peer domain.Peer) error {
	f.peers = append(f.peers, peer)
	return f.err
}

func (f *fakePeerNotifier) count() int { return len(f.peers) }

type fakeIconResolver struct {
	documents map[int64]domain.Document
	calls     int
	err       error
}

func (f *fakeIconResolver) GetDocuments(_ context.Context, ids []int64) ([]domain.Document, error) {
	f.calls++
	if f.err != nil {
		return nil, f.err
	}
	out := make([]domain.Document, 0, len(ids))
	for _, id := range ids {
		if document, ok := f.documents[id]; ok {
			out = append(out, document)
		}
	}
	return out, nil
}

type applicantCall struct {
	recipientUserID int64
	request         domain.CustomVerificationRequest
}

type fakeApplicantNotifier struct {
	calls []applicantCall
	err   error
}

func (f *fakeApplicantNotifier) SendVerificationDecision(_ context.Context, recipientUserID int64, req domain.CustomVerificationRequest) error {
	f.calls = append(f.calls, applicantCall{recipientUserID: recipientUserID, request: req})
	return f.err
}

type fakeLimiter struct {
	allow bool
	keys  []string
	err   error
}

func (f *fakeLimiter) Allow(_ context.Context, key string, _ int, _ time.Duration) (bool, int, error) {
	f.keys = append(f.keys, key)
	if f.err != nil {
		return false, 0, f.err
	}
	if f.allow {
		return true, 0, nil
	}
	return false, 60, nil
}

func (f *fakeLimiter) AllowN(ctx context.Context, key string, _, limit int, window time.Duration) (bool, int, error) {
	return f.Allow(ctx, key, limit, window)
}

// fakeMarkApplier stands in for the process's transaction-aware writer: it
// records that the decision callbacks went through the port and then delegates to
// the store, which is what a real adapter does when the context carries no
// transaction.
type fakeMarkApplier struct {
	store   *memory.BotVerificationStore
	grants  int
	revokes int
}

func (f *fakeMarkApplier) GrantCustomVerification(ctx context.Context, mark domain.CustomVerification) (domain.CustomVerification, bool, error) {
	f.grants++
	return f.store.GrantCustomVerification(ctx, mark)
}

func (f *fakeMarkApplier) RevokeCustomVerification(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error) {
	f.revokes++
	return f.store.RevokeCustomVerification(ctx, verifierBotID, peer)
}

func customEmojiDocument(id int64) domain.Document {
	return domain.Document{
		ID:         id,
		AccessHash: id * 3,
		MimeType:   "application/x-tgsticker",
		Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrCustomEmoji, Alt: "✅"}},
	}
}

// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------

type harness struct {
	t         *testing.T
	svc       *Service
	store     *memory.BotVerificationStore
	dir       *fakeDirectory
	peers     *fakePeerNotifier
	docs      *fakeIconResolver
	applicant *fakeApplicantNotifier
	limiter   *fakeLimiter
}

func newHarness(t *testing.T, opts ...Option) *harness {
	t.Helper()
	h := &harness{
		t:     t,
		store: memory.NewBotVerificationStore(),
		dir: &fakeDirectory{
			users: map[int64]domain.User{
				fixtureVerifierBot: {ID: fixtureVerifierBot, FirstName: "Acme Verifier", Username: "acmeverifierbot", Bot: true, BotInfoVersion: 1},
				fixtureOwnedBot:    {ID: fixtureOwnedBot, FirstName: "Acme Helper", Username: "acmehelperbot", Bot: true, BotInfoVersion: 1},
				fixtureOwner:       {ID: fixtureOwner, FirstName: "Ann", LastName: "Owner", Username: "annowner"},
				fixtureStranger:    {ID: fixtureStranger, FirstName: "Sam", LastName: "Stranger", Username: "samstranger"},
				fixtureUserPeer:    {ID: fixtureUserPeer, FirstName: "Target", LastName: "Account", Username: "targetaccount"},
			},
			channels: map[int64]domain.Channel{
				fixtureChannel: {ID: fixtureChannel, CreatorUserID: fixtureOwner, Title: "Acme Channel", Username: "acmechannel", Broadcast: true},
			},
			owners: map[int64]int64{
				fixtureVerifierBot: fixtureOwner,
				fixtureOwnedBot:    fixtureOwner,
			},
			members: map[memberKey]domain.ChannelMember{},
		},
		peers:     &fakePeerNotifier{},
		docs:      &fakeIconResolver{documents: map[int64]domain.Document{fixtureIconDoc: customEmojiDocument(fixtureIconDoc)}},
		applicant: &fakeApplicantNotifier{},
		limiter:   &fakeLimiter{allow: true},
	}
	base := []Option{
		WithStore(h.store),
		WithPeerDirectory(h.dir),
		WithPeerNotifier(h.peers),
		WithIconResolver(h.docs),
		WithApplicantNotifier(h.applicant),
		WithRateLimiter(h.limiter, 5, 24*time.Hour),
	}
	h.svc = NewService(append(base, opts...)...)
	return h
}

// seedIcon writes a catalogue entry straight to the store, so a test can set up
// an icon state the service itself would refuse to create.
func (h *harness) seedIcon(documentID, ownerBotID int64, active bool) domain.VerificationIcon {
	h.t.Helper()
	icon, err := h.store.UpsertVerificationIcon(context.Background(), domain.VerificationIcon{
		DocumentID: documentID,
		OwnerBotID: ownerBotID,
		Name:       "Acme badge",
		Active:     active,
	})
	if err != nil {
		h.t.Fatalf("seed icon: %v", err)
	}
	return icon
}

func verifierSettings(enabled, canModify bool) domain.BotVerifierSettings {
	return domain.BotVerifierSettings{
		BotID:                      fixtureVerifierBot,
		IconDocumentID:             fixtureIconDoc,
		CompanyName:                "Acme Inc",
		DefaultDescription:         "Verified by Acme",
		CanModifyCustomDescription: canModify,
		Enabled:                    enabled,
		GrantedBy:                  "operator",
		GrantReason:                "partner programme",
	}
}

// seedVerifier writes verifier status straight to the store, bypassing
// GrantVerifier's own checks.
func (h *harness) seedVerifier(settings domain.BotVerifierSettings) domain.BotVerifierSettings {
	h.t.Helper()
	stored, err := h.store.UpsertBotVerifierSettings(context.Background(), settings)
	if err != nil {
		h.t.Fatalf("seed verifier: %v", err)
	}
	return stored
}

func (h *harness) mark(verifierBotID int64, peer domain.Peer) (domain.CustomVerification, bool) {
	h.t.Helper()
	mark, err := h.store.CustomVerification(context.Background(), verifierBotID, peer)
	if errors.Is(err, domain.ErrCustomVerificationNotFound) {
		return domain.CustomVerification{}, false
	}
	if err != nil {
		h.t.Fatalf("read mark: %v", err)
	}
	return mark, true
}

func userPeer(id int64) domain.Peer { return domain.Peer{Type: domain.PeerTypeUser, ID: id} }
func chanPeer(id int64) domain.Peer { return domain.Peer{Type: domain.PeerTypeChannel, ID: id} }

// ---------------------------------------------------------------------------
// bots.setCustomVerification: one refusal per test
// ---------------------------------------------------------------------------

func TestSetCustomVerificationRefusesNonVerifier(t *testing.T) {
	h := newHarness(t)

	changed, err := h.svc.SetCustomVerification(context.Background(), domain.SetCustomVerificationRequest{
		VerifierBotID: fixtureVerifierBot,
		Peer:          userPeer(fixtureUserPeer),
		Enabled:       true,
		CallerUserID:  fixtureVerifierBot,
	})
	if !errors.Is(err, domain.ErrVerifierForbidden) {
		t.Fatalf("err = %v, want ErrVerifierForbidden", err)
	}
	if changed {
		t.Fatal("changed = true for a bot that is not a verifier")
	}
	if _, found := h.mark(fixtureVerifierBot, userPeer(fixtureUserPeer)); found {
		t.Fatal("a mark was written by a non-verifier")
	}
}

func TestSetCustomVerificationRefusesDisabledVerifier(t *testing.T) {
	h := newHarness(t)
	h.seedIcon(fixtureIconDoc, 0, true)
	h.seedVerifier(verifierSettings(false, true))

	_, err := h.svc.SetCustomVerification(context.Background(), domain.SetCustomVerificationRequest{
		VerifierBotID: fixtureVerifierBot,
		Peer:          userPeer(fixtureUserPeer),
		Enabled:       true,
		CallerUserID:  fixtureVerifierBot,
	})
	// The kill switch is indistinguishable from "never was a verifier", by design.
	if !errors.Is(err, domain.ErrVerifierForbidden) {
		t.Fatalf("err = %v, want ErrVerifierForbidden", err)
	}
	if _, found := h.mark(fixtureVerifierBot, userPeer(fixtureUserPeer)); found {
		t.Fatal("a disabled verifier granted a mark")
	}
}

func TestSetCustomVerificationRefusesForeignCaller(t *testing.T) {
	h := newHarness(t)
	h.seedIcon(fixtureIconDoc, 0, true)
	h.seedVerifier(verifierSettings(true, true))

	_, err := h.svc.SetCustomVerification(context.Background(), domain.SetCustomVerificationRequest{
		VerifierBotID: fixtureVerifierBot,
		Peer:          userPeer(fixtureUserPeer),
		Enabled:       true,
		// Neither the bot itself nor its owner.
		CallerUserID: fixtureStranger,
	})
	if !errors.Is(err, domain.ErrVerifierForbidden) {
		t.Fatalf("err = %v, want ErrVerifierForbidden", err)
	}
	if _, found := h.mark(fixtureVerifierBot, userPeer(fixtureUserPeer)); found {
		t.Fatal("a stranger granted a mark through somebody else's verifier")
	}
}

func TestSetCustomVerificationAcceptsOwnerCaller(t *testing.T) {
	h := newHarness(t)
	h.seedIcon(fixtureIconDoc, 0, true)
	h.seedVerifier(verifierSettings(true, true))

	changed, err := h.svc.SetCustomVerification(context.Background(), domain.SetCustomVerificationRequest{
		VerifierBotID: fixtureVerifierBot,
		Peer:          userPeer(fixtureUserPeer),
		Enabled:       true,
		CallerUserID:  fixtureOwner,
	})
	if err != nil || !changed {
		t.Fatalf("SetCustomVerification by owner = %v/%v, want true/nil", changed, err)
	}
	mark, found := h.mark(fixtureVerifierBot, userPeer(fixtureUserPeer))
	if !found {
		t.Fatal("owner call did not grant the mark")
	}
	if mark.GrantedByUserID != fixtureOwner {
		t.Fatalf("GrantedByUserID = %d, want the calling owner %d", mark.GrantedByUserID, fixtureOwner)
	}
}

func TestSetCustomVerificationRefusesForbiddenDescription(t *testing.T) {
	h := newHarness(t)
	h.seedIcon(fixtureIconDoc, 0, true)
	h.seedVerifier(verifierSettings(true, false))

	_, err := h.svc.SetCustomVerification(context.Background(), domain.SetCustomVerificationRequest{
		VerifierBotID:     fixtureVerifierBot,
		Peer:              userPeer(fixtureUserPeer),
		Enabled:           true,
		CustomDescription: "hand-written text",
		CallerUserID:      fixtureVerifierBot,
	})
	if !errors.Is(err, domain.ErrVerifierDescriptionForbidden) {
		t.Fatalf("err = %v, want ErrVerifierDescriptionForbidden", err)
	}
	if _, found := h.mark(fixtureVerifierBot, userPeer(fixtureUserPeer)); found {
		t.Fatal("the mark was written despite the forbidden description")
	}
}

func TestSetCustomVerificationRefusesInvalidPeer(t *testing.T) {
	for name, peer := range map[string]domain.Peer{
		"unknown user":    userPeer(4242),
		"unknown channel": chanPeer(4242),
		// A built-in account's identity is seeded, not granted by a third party.
		"system account": userPeer(domain.VerifyBotUserID),
		// Not a peer kind that can carry a mark at all (peer_type CHECK).
		"community peer": {Type: domain.PeerTypeCommunity, ID: 12},
		"zero id":        userPeer(0),
	} {
		t.Run(name, func(t *testing.T) {
			h := newHarness(t)
			h.seedIcon(fixtureIconDoc, 0, true)
			h.seedVerifier(verifierSettings(true, true))

			_, err := h.svc.SetCustomVerification(context.Background(), domain.SetCustomVerificationRequest{
				VerifierBotID: fixtureVerifierBot,
				Peer:          peer,
				Enabled:       true,
				CallerUserID:  fixtureVerifierBot,
			})
			if !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) {
				t.Fatalf("err = %v, want ErrCustomVerificationTargetInvalid", err)
			}
			if h.peers.count() != 0 {
				t.Fatalf("peer pushes = %d, want none", h.peers.count())
			}
		})
	}
}

// TestSetCustomVerificationGrantsRevokesAndRepeats walks the whole RPC contract:
// the icon comes from the settings, an identical re-apply reports changed=false,
// an edited description does move, and a revoke is idempotent.
func TestSetCustomVerificationGrantsRevokesAndRepeats(t *testing.T) {
	h := newHarness(t)
	h.seedIcon(fixtureIconDoc, 0, true)
	h.seedVerifier(verifierSettings(true, true))
	ctx := context.Background()
	peer := chanPeer(fixtureChannel)
	request := domain.SetCustomVerificationRequest{
		VerifierBotID: fixtureVerifierBot,
		Peer:          peer,
		Enabled:       true,
		CallerUserID:  fixtureVerifierBot,
	}

	changed, err := h.svc.SetCustomVerification(ctx, request)
	if err != nil || !changed {
		t.Fatalf("first grant = %v/%v, want true/nil", changed, err)
	}
	mark, found := h.mark(fixtureVerifierBot, peer)
	if !found {
		t.Fatal("first grant stored no mark")
	}
	if mark.IconDocumentID != fixtureIconDoc {
		t.Fatalf("IconDocumentID = %d, want the verifier's configured icon %d", mark.IconDocumentID, fixtureIconDoc)
	}
	if mark.Description != "Verified by Acme" {
		t.Fatalf("Description = %q, want the operator default", mark.Description)
	}
	if h.peers.count() != 1 || h.peers.peers[0] != peer {
		t.Fatalf("peer pushes = %v, want exactly one for %v", h.peers.peers, peer)
	}

	// Identical re-apply: nothing moved, so the RPC answers Bool false and no push
	// is emitted.
	changed, err = h.svc.SetCustomVerification(ctx, request)
	if err != nil {
		t.Fatalf("repeat grant: %v", err)
	}
	if changed {
		t.Fatal("repeat grant reported changed = true")
	}
	if h.peers.count() != 1 {
		t.Fatalf("peer pushes = %d after a no-op repeat, want 1", h.peers.count())
	}

	// A different description is a real change.
	edited := request
	edited.CustomDescription = "Acme partner since 2019"
	changed, err = h.svc.SetCustomVerification(ctx, edited)
	if err != nil || !changed {
		t.Fatalf("description edit = %v/%v, want true/nil", changed, err)
	}
	if mark, _ := h.mark(fixtureVerifierBot, peer); mark.Description != "Acme partner since 2019" {
		t.Fatalf("Description = %q after the edit", mark.Description)
	}
	if h.peers.count() != 2 {
		t.Fatalf("peer pushes = %d after the edit, want 2", h.peers.count())
	}

	revoke := domain.SetCustomVerificationRequest{
		VerifierBotID: fixtureVerifierBot,
		Peer:          peer,
		CallerUserID:  fixtureVerifierBot,
	}
	changed, err = h.svc.SetCustomVerification(ctx, revoke)
	if err != nil || !changed {
		t.Fatalf("revoke = %v/%v, want true/nil", changed, err)
	}
	if _, found := h.mark(fixtureVerifierBot, peer); found {
		t.Fatal("the mark survived the revoke")
	}
	if h.peers.count() != 3 {
		t.Fatalf("peer pushes = %d after the revoke, want 3", h.peers.count())
	}

	changed, err = h.svc.SetCustomVerification(ctx, revoke)
	if err != nil {
		t.Fatalf("repeat revoke: %v", err)
	}
	if changed {
		t.Fatal("repeat revoke reported changed = true")
	}
	if h.peers.count() != 3 {
		t.Fatalf("peer pushes = %d after the repeat revoke, want 3", h.peers.count())
	}
}

// TestSetCustomVerificationRevokesVanishedPeer pins the asymmetry between the two
// directions: granting needs a resolvable peer, stripping does not. A peer that was
// deleted after being marked is exactly the case where a verifier most needs to be
// able to take its badge back.
func TestSetCustomVerificationRevokesVanishedPeer(t *testing.T) {
	h := newHarness(t)
	h.seedIcon(fixtureIconDoc, 0, true)
	h.seedVerifier(verifierSettings(true, true))
	ctx := context.Background()

	if _, err := h.svc.SetCustomVerification(ctx, domain.SetCustomVerificationRequest{
		VerifierBotID: fixtureVerifierBot,
		Peer:          userPeer(fixtureUserPeer),
		Enabled:       true,
		CallerUserID:  fixtureVerifierBot,
	}); err != nil {
		t.Fatalf("grant: %v", err)
	}
	// The account is gone from the directory by the time the verifier revokes.
	delete(h.dir.users, fixtureUserPeer)

	changed, err := h.svc.SetCustomVerification(ctx, domain.SetCustomVerificationRequest{
		VerifierBotID: fixtureVerifierBot,
		Peer:          userPeer(fixtureUserPeer),
		CallerUserID:  fixtureVerifierBot,
	})
	if err != nil || !changed {
		t.Fatalf("revoke of a vanished peer = %v/%v, want true/nil", changed, err)
	}
	if _, found := h.mark(fixtureVerifierBot, userPeer(fixtureUserPeer)); found {
		t.Fatal("the mark survived")
	}
}

// TestSetCustomVerificationSurvivesPushFailure pins the "data first, push second"
// rule: a broken notifier must not turn a committed mark into an RPC error.
func TestSetCustomVerificationSurvivesPushFailure(t *testing.T) {
	h := newHarness(t)
	h.seedIcon(fixtureIconDoc, 0, true)
	h.seedVerifier(verifierSettings(true, true))
	h.peers.err = errors.New("router is gone")

	changed, err := h.svc.SetCustomVerification(context.Background(), domain.SetCustomVerificationRequest{
		VerifierBotID: fixtureVerifierBot,
		Peer:          userPeer(fixtureUserPeer),
		Enabled:       true,
		CallerUserID:  fixtureVerifierBot,
	})
	if err != nil || !changed {
		t.Fatalf("grant with a failing push = %v/%v, want true/nil", changed, err)
	}
	if _, found := h.mark(fixtureVerifierBot, userPeer(fixtureUserPeer)); !found {
		t.Fatal("the mark was not stored")
	}
}

func TestSetCustomVerificationEnforcesPerVerifierBound(t *testing.T) {
	h := newHarness(t, WithMaxPerVerifier(1))
	h.seedIcon(fixtureIconDoc, 0, true)
	h.seedVerifier(verifierSettings(true, true))
	ctx := context.Background()

	if _, err := h.svc.SetCustomVerification(ctx, domain.SetCustomVerificationRequest{
		VerifierBotID: fixtureVerifierBot,
		Peer:          userPeer(fixtureUserPeer),
		Enabled:       true,
		CallerUserID:  fixtureVerifierBot,
	}); err != nil {
		t.Fatalf("first grant: %v", err)
	}
	_, err := h.svc.SetCustomVerification(ctx, domain.SetCustomVerificationRequest{
		VerifierBotID: fixtureVerifierBot,
		Peer:          chanPeer(fixtureChannel),
		Enabled:       true,
		CallerUserID:  fixtureVerifierBot,
	})
	if !errors.Is(err, domain.ErrCustomVerificationLimit) {
		t.Fatalf("err = %v, want ErrCustomVerificationLimit", err)
	}
	// An existing mark stays re-describable at the bound: the budget is spent on
	// new marks only.
	if _, err := h.svc.SetCustomVerification(ctx, domain.SetCustomVerificationRequest{
		VerifierBotID:     fixtureVerifierBot,
		Peer:              userPeer(fixtureUserPeer),
		Enabled:           true,
		CustomDescription: "still a partner",
		CallerUserID:      fixtureVerifierBot,
	}); err != nil {
		t.Fatalf("re-describe at the bound: %v", err)
	}
}

// ---------------------------------------------------------------------------
// Verifier status
// ---------------------------------------------------------------------------

// TestGrantVerifierRefusesBeforeWriting is the ordering guarantee: every check
// runs before the store is touched, so a refused grant leaves no verifier row and
// pushes nothing.
func TestGrantVerifierRefusesBeforeWriting(t *testing.T) {
	for name, test := range map[string]struct {
		setup    func(h *harness)
		settings domain.BotVerifierSettings
		want     error
	}{
		"not a bot": {
			setup: func(h *harness) { h.seedIcon(fixtureIconDoc, 0, true) },
			settings: func() domain.BotVerifierSettings {
				settings := verifierSettings(true, true)
				settings.BotID = fixtureUserPeer
				return settings
			}(),
			want: domain.ErrBotNotFound,
		},
		"unknown account": {
			setup: func(h *harness) { h.seedIcon(fixtureIconDoc, 0, true) },
			settings: func() domain.BotVerifierSettings {
				settings := verifierSettings(true, true)
				settings.BotID = 31337
				return settings
			}(),
			want: domain.ErrBotNotFound,
		},
		"built-in system bot": {
			setup: func(h *harness) { h.seedIcon(fixtureIconDoc, 0, true) },
			settings: func() domain.BotVerifierSettings {
				settings := verifierSettings(true, true)
				settings.BotID = domain.VerifyBotUserID
				return settings
			}(),
			want: domain.ErrVerificationTargetSystem,
		},
		"icon absent from the catalogue": {
			setup:    func(h *harness) {},
			settings: verifierSettings(true, true),
			want:     domain.ErrVerificationIconNotFound,
		},
		"icon retired": {
			setup:    func(h *harness) { h.seedIcon(fixtureIconDoc, 0, false) },
			settings: verifierSettings(true, true),
			want:     domain.ErrVerificationIconInactive,
		},
		"icon reserved for another verifier": {
			setup:    func(h *harness) { h.seedIcon(fixtureIconDoc, fixtureOwnedBot, true) },
			settings: verifierSettings(true, true),
			want:     domain.ErrVerificationIconNotFound,
		},
		"document does not exist": {
			setup: func(h *harness) {
				h.seedIcon(fixtureIconDoc, 0, true)
				// The catalogue entry is fine; the document it names is gone, so the
				// badge would render as nothing at all.
				h.docs.documents = map[int64]domain.Document{}
			},
			settings: verifierSettings(true, true),
			want:     domain.ErrVerificationIconInvalid,
		},
		"document is not a custom emoji": {
			setup: func(h *harness) {
				h.seedIcon(fixtureIconDoc, 0, true)
				h.docs.documents = map[int64]domain.Document{
					fixtureIconDoc: {ID: fixtureIconDoc, MimeType: "video/mp4"},
				}
			},
			settings: verifierSettings(true, true),
			want:     domain.ErrVerificationIconInvalid,
		},
	} {
		t.Run(name, func(t *testing.T) {
			h := newHarness(t)
			test.setup(h)

			_, err := h.svc.GrantVerifier(context.Background(), test.settings)
			if !errors.Is(err, test.want) {
				t.Fatalf("err = %v, want %v", err, test.want)
			}
			if _, err := h.store.BotVerifierSettings(context.Background(), test.settings.BotID); !errors.Is(err, domain.ErrVerifierNotFound) {
				t.Fatalf("verifier row after a refused grant: %v, want ErrVerifierNotFound", err)
			}
			if h.peers.count() != 0 {
				t.Fatalf("peer pushes = %d after a refused grant, want none", h.peers.count())
			}
		})
	}
}

func TestGrantVerifierStoresAndPushesTheBot(t *testing.T) {
	h := newHarness(t)
	h.seedIcon(fixtureIconDoc, 0, true)
	ctx := context.Background()

	stored, err := h.svc.GrantVerifier(ctx, verifierSettings(true, true))
	if err != nil {
		t.Fatalf("GrantVerifier: %v", err)
	}
	if stored.BotID != fixtureVerifierBot || stored.Version != 1 || !stored.Enabled {
		t.Fatalf("stored = %+v, want the first version of an enabled verifier", stored)
	}
	if h.docs.calls != 1 {
		t.Fatalf("icon resolver calls = %d, want 1", h.docs.calls)
	}
	// Only the verifier's own botInfo is pushed; its marked peers converge on their
	// next authoritative read.
	if h.peers.count() != 1 || h.peers.peers[0] != userPeer(fixtureVerifierBot) {
		t.Fatalf("peer pushes = %v, want exactly one for the bot itself", h.peers.peers)
	}

	// The built-in @verifierbot is the one system account this status exists for.
	verifier := verifierSettings(true, true)
	verifier.BotID = domain.VerifierBotUserID
	if _, err := h.svc.GrantVerifier(ctx, verifier); err != nil {
		t.Fatalf("GrantVerifier(@verifierbot): %v", err)
	}
}

func TestSetVerifierEnabledAndRevoke(t *testing.T) {
	h := newHarness(t)
	h.seedIcon(fixtureIconDoc, 0, true)
	granted := h.seedVerifier(verifierSettings(true, true))
	ctx := context.Background()

	// A flip to the value already stored burns no version and pushes nothing.
	same, err := h.svc.SetVerifierEnabled(ctx, fixtureVerifierBot, true)
	if err != nil {
		t.Fatalf("SetVerifierEnabled(no-op): %v", err)
	}
	if same.Version != granted.Version || h.peers.count() != 0 {
		t.Fatalf("no-op flip: version %d->%d, pushes %d", granted.Version, same.Version, h.peers.count())
	}

	disabled, err := h.svc.SetVerifierEnabled(ctx, fixtureVerifierBot, false)
	if err != nil {
		t.Fatalf("SetVerifierEnabled(false): %v", err)
	}
	if disabled.Enabled || disabled.Version == granted.Version {
		t.Fatalf("disabled = %+v, want enabled=false at a new version", disabled)
	}
	if h.peers.count() != 1 || h.peers.peers[0] != userPeer(fixtureVerifierBot) {
		t.Fatalf("peer pushes = %v, want one for the bot", h.peers.peers)
	}

	removed, err := h.svc.RevokeVerifier(ctx, fixtureVerifierBot)
	if err != nil || !removed {
		t.Fatalf("RevokeVerifier = %v/%v, want true/nil", removed, err)
	}
	if _, err := h.store.BotVerifierSettings(ctx, fixtureVerifierBot); !errors.Is(err, domain.ErrVerifierNotFound) {
		t.Fatalf("verifier row after revoke: %v", err)
	}
	// A repeated revoke is a no-op rather than an error.
	removed, err = h.svc.RevokeVerifier(ctx, fixtureVerifierBot)
	if err != nil || removed {
		t.Fatalf("repeat RevokeVerifier = %v/%v, want false/nil", removed, err)
	}
	if h.peers.count() != 2 {
		t.Fatalf("peer pushes = %d, want 2 (disable + revoke)", h.peers.count())
	}
}

// ---------------------------------------------------------------------------
// Icon catalogue
// ---------------------------------------------------------------------------

func TestUpsertIconRequiresAnExistingDocument(t *testing.T) {
	h := newHarness(t)
	h.docs.documents = map[int64]domain.Document{}
	ctx := context.Background()

	_, err := h.svc.UpsertIcon(ctx, domain.VerificationIcon{DocumentID: fixtureIconDoc, Name: "Acme badge", Active: true})
	if !errors.Is(err, domain.ErrVerificationIconInvalid) {
		t.Fatalf("err = %v, want ErrVerificationIconInvalid", err)
	}
	icons, err := h.store.ListVerificationIcons(ctx, false, 0)
	if err != nil {
		t.Fatalf("ListVerificationIcons: %v", err)
	}
	if len(icons) != 0 {
		t.Fatalf("catalogue = %+v, want nothing written for an unresolvable document", icons)
	}

	h.docs.documents[fixtureIconDoc] = customEmojiDocument(fixtureIconDoc)
	stored, err := h.svc.UpsertIcon(ctx, domain.VerificationIcon{DocumentID: fixtureIconDoc, Name: "  Acme badge  ", Active: true})
	if err != nil {
		t.Fatalf("UpsertIcon: %v", err)
	}
	if stored.ID <= 0 || stored.Name != "Acme badge" || !stored.Active {
		t.Fatalf("stored = %+v, want a trimmed active entry", stored)
	}

	retired, err := h.svc.SetIconActive(ctx, stored.ID, false)
	if err != nil {
		t.Fatalf("SetIconActive: %v", err)
	}
	if retired.Active {
		t.Fatal("SetIconActive(false) left the entry active")
	}
	active, err := h.svc.Icons(ctx, true, 0)
	if err != nil {
		t.Fatalf("Icons: %v", err)
	}
	if len(active) != 0 {
		t.Fatalf("active icons = %+v, want none after retiring the only entry", active)
	}
}

func TestUpsertIconWithoutResolverIsAConfigurationError(t *testing.T) {
	svc := NewService(WithStore(memory.NewBotVerificationStore()))

	_, err := svc.UpsertIcon(context.Background(), domain.VerificationIcon{DocumentID: fixtureIconDoc, Name: "Acme", Active: true})
	if err == nil || !strings.Contains(err.Error(), "icon resolver is not configured") {
		t.Fatalf("err = %v, want the resolver configuration error", err)
	}
}

// ---------------------------------------------------------------------------
// Applications
// ---------------------------------------------------------------------------

func (h *harness) createRequest(applicantUserID int64, peer domain.Peer, description string) (domain.CustomVerificationRequest, error) {
	return h.svc.CreateRequest(context.Background(), domain.CustomVerificationRequest{
		VerifierBotID:        fixtureVerifierBot,
		ApplicantUserID:      applicantUserID,
		Peer:                 peer,
		PeerTitle:            "TITLE THE CLIENT MADE UP",
		PeerUsername:         "clientmadethisup",
		Reason:               "we are a partner",
		RequestedDescription: description,
	})
}

func TestCreateRequestChecks(t *testing.T) {
	t.Run("unknown verifier", func(t *testing.T) {
		h := newHarness(t)
		if _, err := h.createRequest(fixtureOwner, chanPeer(fixtureChannel), ""); !errors.Is(err, domain.ErrVerifierForbidden) {
			t.Fatalf("err = %v, want ErrVerifierForbidden", err)
		}
	})

	t.Run("disabled verifier", func(t *testing.T) {
		h := newHarness(t)
		h.seedIcon(fixtureIconDoc, 0, true)
		h.seedVerifier(verifierSettings(false, true))
		if _, err := h.createRequest(fixtureOwner, chanPeer(fixtureChannel), ""); !errors.Is(err, domain.ErrVerifierForbidden) {
			t.Fatalf("err = %v, want ErrVerifierForbidden", err)
		}
	})

	t.Run("invalid target", func(t *testing.T) {
		h := newHarness(t)
		h.seedIcon(fixtureIconDoc, 0, true)
		h.seedVerifier(verifierSettings(true, true))
		if _, err := h.createRequest(fixtureOwner, chanPeer(1234), ""); !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) {
			t.Fatalf("err = %v, want ErrCustomVerificationTargetInvalid", err)
		}
	})

	t.Run("applicant does not control the channel", func(t *testing.T) {
		h := newHarness(t)
		h.seedIcon(fixtureIconDoc, 0, true)
		h.seedVerifier(verifierSettings(true, true))
		if _, err := h.createRequest(fixtureStranger, chanPeer(fixtureChannel), ""); !errors.Is(err, domain.ErrVerificationNotOwner) {
			t.Fatalf("err = %v, want ErrVerificationNotOwner", err)
		}
	})

	t.Run("plain member does not control the channel", func(t *testing.T) {
		h := newHarness(t)
		h.seedIcon(fixtureIconDoc, 0, true)
		h.seedVerifier(verifierSettings(true, true))
		h.dir.members[memberKey{channelID: fixtureChannel, userID: fixtureStranger}] = domain.ChannelMember{
			ChannelID: fixtureChannel,
			UserID:    fixtureStranger,
			Role:      domain.ChannelRoleMember,
			Status:    domain.ChannelMemberActive,
		}
		if _, err := h.createRequest(fixtureStranger, chanPeer(fixtureChannel), ""); !errors.Is(err, domain.ErrVerificationNotOwner) {
			t.Fatalf("err = %v, want ErrVerificationNotOwner", err)
		}
	})

	t.Run("administrator with change_info controls the channel", func(t *testing.T) {
		h := newHarness(t)
		h.seedIcon(fixtureIconDoc, 0, true)
		h.seedVerifier(verifierSettings(true, true))
		h.dir.members[memberKey{channelID: fixtureChannel, userID: fixtureStranger}] = domain.ChannelMember{
			ChannelID:   fixtureChannel,
			UserID:      fixtureStranger,
			Role:        domain.ChannelRoleAdmin,
			Status:      domain.ChannelMemberActive,
			AdminRights: domain.ChannelAdminRights{ChangeInfo: true},
		}
		if _, err := h.createRequest(fixtureStranger, chanPeer(fixtureChannel), ""); err != nil {
			t.Fatalf("CreateRequest by a change_info admin: %v", err)
		}
	})

	t.Run("bot owner controls the bot", func(t *testing.T) {
		h := newHarness(t)
		h.seedIcon(fixtureIconDoc, 0, true)
		h.seedVerifier(verifierSettings(true, true))
		if _, err := h.createRequest(fixtureOwner, userPeer(fixtureOwnedBot), ""); err != nil {
			t.Fatalf("CreateRequest for an owned bot: %v", err)
		}
	})

	t.Run("stranger does not control the bot", func(t *testing.T) {
		h := newHarness(t)
		h.seedIcon(fixtureIconDoc, 0, true)
		h.seedVerifier(verifierSettings(true, true))
		if _, err := h.createRequest(fixtureStranger, userPeer(fixtureOwnedBot), ""); !errors.Is(err, domain.ErrVerificationNotOwner) {
			t.Fatalf("err = %v, want ErrVerificationNotOwner", err)
		}
	})

	t.Run("user files for their own account", func(t *testing.T) {
		h := newHarness(t)
		h.seedIcon(fixtureIconDoc, 0, true)
		h.seedVerifier(verifierSettings(true, true))
		if _, err := h.createRequest(fixtureUserPeer, userPeer(fixtureUserPeer), ""); err != nil {
			t.Fatalf("CreateRequest for own account: %v", err)
		}
	})

	t.Run("peer already marked by this verifier", func(t *testing.T) {
		h := newHarness(t)
		h.seedIcon(fixtureIconDoc, 0, true)
		h.seedVerifier(verifierSettings(true, true))
		if _, _, err := h.store.GrantCustomVerification(context.Background(), domain.CustomVerification{
			VerifierBotID:  fixtureVerifierBot,
			Peer:           chanPeer(fixtureChannel),
			IconDocumentID: fixtureIconDoc,
			Description:    "Verified by Acme",
		}); err != nil {
			t.Fatalf("seed mark: %v", err)
		}
		if _, err := h.createRequest(fixtureOwner, chanPeer(fixtureChannel), ""); !errors.Is(err, domain.ErrCustomVerificationRequestExists) {
			t.Fatalf("err = %v, want ErrCustomVerificationRequestExists", err)
		}
	})

	t.Run("second pending application", func(t *testing.T) {
		h := newHarness(t)
		h.seedIcon(fixtureIconDoc, 0, true)
		h.seedVerifier(verifierSettings(true, true))
		if _, err := h.createRequest(fixtureOwner, chanPeer(fixtureChannel), ""); err != nil {
			t.Fatalf("first CreateRequest: %v", err)
		}
		if _, err := h.createRequest(fixtureOwner, chanPeer(fixtureChannel), ""); !errors.Is(err, domain.ErrCustomVerificationRequestExists) {
			t.Fatalf("err = %v, want ErrCustomVerificationRequestExists", err)
		}
	})

	t.Run("description this verifier may not apply", func(t *testing.T) {
		h := newHarness(t)
		h.seedIcon(fixtureIconDoc, 0, true)
		h.seedVerifier(verifierSettings(true, false))
		if _, err := h.createRequest(fixtureOwner, chanPeer(fixtureChannel), "our own wording"); !errors.Is(err, domain.ErrVerifierDescriptionForbidden) {
			t.Fatalf("err = %v, want ErrVerifierDescriptionForbidden", err)
		}
	})

	t.Run("rate limited", func(t *testing.T) {
		h := newHarness(t)
		h.seedIcon(fixtureIconDoc, 0, true)
		h.seedVerifier(verifierSettings(true, true))
		h.limiter.allow = false

		if _, err := h.createRequest(fixtureOwner, chanPeer(fixtureChannel), ""); !errors.Is(err, domain.ErrVerificationRateLimited) {
			t.Fatalf("err = %v, want ErrVerificationRateLimited", err)
		}
		if len(h.limiter.keys) != 1 || !strings.HasPrefix(h.limiter.keys[0], requestRateLimitKeyPrefix) {
			t.Fatalf("limiter keys = %v, want one namespaced per-applicant key", h.limiter.keys)
		}
		pending, err := h.store.ListCustomVerificationRequests(context.Background(), domain.CustomVerificationRequestFilter{})
		if err != nil {
			t.Fatalf("ListCustomVerificationRequests: %v", err)
		}
		if len(pending) != 0 {
			t.Fatalf("stored applications = %+v, want none when the budget is spent", pending)
		}
	})

	t.Run("a refused application spends no budget", func(t *testing.T) {
		h := newHarness(t)
		h.seedIcon(fixtureIconDoc, 0, true)
		h.seedVerifier(verifierSettings(true, true))

		if _, err := h.createRequest(fixtureStranger, chanPeer(fixtureChannel), ""); !errors.Is(err, domain.ErrVerificationNotOwner) {
			t.Fatalf("err = %v, want ErrVerificationNotOwner", err)
		}
		if len(h.limiter.keys) != 0 {
			t.Fatalf("limiter keys = %v, want the budget untouched by a refusal", h.limiter.keys)
		}
	})

	t.Run("snapshot comes from the directory", func(t *testing.T) {
		h := newHarness(t)
		h.seedIcon(fixtureIconDoc, 0, true)
		h.seedVerifier(verifierSettings(true, true))

		stored, err := h.createRequest(fixtureOwner, chanPeer(fixtureChannel), "")
		if err != nil {
			t.Fatalf("CreateRequest: %v", err)
		}
		if stored.PeerTitle != "Acme Channel" || stored.PeerUsername != "acmechannel" {
			t.Fatalf("snapshot = %q/%q, want the resolved channel identity", stored.PeerTitle, stored.PeerUsername)
		}
		if stored.Status != domain.CustomVerificationPending || stored.Version != 1 {
			t.Fatalf("stored = %+v, want a pending first version", stored)
		}
	})
}

// TestApproveGrantsMarkAndNotifiesOnce is the decision contract: the mark and the
// approval land together, each notifier fires exactly once, and a retry does
// neither again.
func TestApproveGrantsMarkAndNotifiesOnce(t *testing.T) {
	h := newHarness(t)
	h.seedIcon(fixtureIconDoc, 0, true)
	h.seedVerifier(verifierSettings(true, true))
	ctx := context.Background()

	filed, err := h.createRequest(fixtureOwner, chanPeer(fixtureChannel), "Acme partner")
	if err != nil {
		t.Fatalf("CreateRequest: %v", err)
	}

	decided, changed, err := h.svc.Approve(ctx, filed.ID, filed.Version, "operator", "checked the docs", "internal note")
	if err != nil || !changed {
		t.Fatalf("Approve = %v/%v, want true/nil", changed, err)
	}
	if decided.Status != domain.CustomVerificationApproved {
		t.Fatalf("status = %q, want approved", decided.Status)
	}
	mark, found := h.mark(fixtureVerifierBot, chanPeer(fixtureChannel))
	if !found {
		t.Fatal("approval did not grant the mark")
	}
	if mark.IconDocumentID != fixtureIconDoc || mark.Description != "Acme partner" {
		t.Fatalf("mark = %+v, want the verifier icon and the requested description", mark)
	}
	if h.peers.count() != 1 || h.peers.peers[0] != chanPeer(fixtureChannel) {
		t.Fatalf("peer pushes = %v, want exactly one for the target", h.peers.peers)
	}
	if len(h.applicant.calls) != 1 {
		t.Fatalf("applicant notifications = %d, want 1", len(h.applicant.calls))
	}
	if h.applicant.calls[0].recipientUserID != fixtureOwner ||
		h.applicant.calls[0].request.Status != domain.CustomVerificationApproved {
		t.Fatalf("applicant call = %+v, want the approved decision to the applicant", h.applicant.calls[0])
	}

	// Retried approval: already approved, so nothing is written and nobody is told
	// twice.
	again, changed, err := h.svc.Approve(ctx, filed.ID, decided.Version, "operator", "checked the docs", "")
	if err != nil {
		t.Fatalf("repeat Approve: %v", err)
	}
	if changed {
		t.Fatal("repeat Approve reported changed = true")
	}
	if again.Version != decided.Version {
		t.Fatalf("version %d -> %d on a repeat approval", decided.Version, again.Version)
	}
	if h.peers.count() != 1 || len(h.applicant.calls) != 1 {
		t.Fatalf("notifications after the repeat: %d peer / %d applicant, want 1/1", h.peers.count(), len(h.applicant.calls))
	}
}

func TestApproveRefusesDisabledVerifier(t *testing.T) {
	h := newHarness(t)
	h.seedIcon(fixtureIconDoc, 0, true)
	h.seedVerifier(verifierSettings(true, true))
	ctx := context.Background()

	filed, err := h.createRequest(fixtureOwner, chanPeer(fixtureChannel), "")
	if err != nil {
		t.Fatalf("CreateRequest: %v", err)
	}
	// The operator switches the verifier off while the application waits.
	if _, err := h.svc.SetVerifierEnabled(ctx, fixtureVerifierBot, false); err != nil {
		t.Fatalf("SetVerifierEnabled: %v", err)
	}

	if _, _, err := h.svc.Approve(ctx, filed.ID, filed.Version, "operator", "", ""); !errors.Is(err, domain.ErrVerifierForbidden) {
		t.Fatalf("err = %v, want ErrVerifierForbidden", err)
	}
	if _, found := h.mark(fixtureVerifierBot, chanPeer(fixtureChannel)); found {
		t.Fatal("a disabled verifier granted a mark through the review path")
	}
	if stored, err := h.store.CustomVerificationRequest(ctx, filed.ID); err != nil || stored.Status != domain.CustomVerificationPending {
		t.Fatalf("application = %+v / %v, want it still pending", stored, err)
	}
}

func TestApproveIsVersionChecked(t *testing.T) {
	h := newHarness(t)
	h.seedIcon(fixtureIconDoc, 0, true)
	h.seedVerifier(verifierSettings(true, true))
	ctx := context.Background()

	filed, err := h.createRequest(fixtureOwner, chanPeer(fixtureChannel), "")
	if err != nil {
		t.Fatalf("CreateRequest: %v", err)
	}
	if _, _, err := h.svc.Approve(ctx, filed.ID, filed.Version+1, "operator", "", ""); !errors.Is(err, domain.ErrCustomVerificationVersionConflict) {
		t.Fatalf("err = %v, want ErrCustomVerificationVersionConflict", err)
	}
	if _, found := h.mark(fixtureVerifierBot, chanPeer(fixtureChannel)); found {
		t.Fatal("a stale approval granted the mark")
	}
}

func TestRejectRequiresAReason(t *testing.T) {
	h := newHarness(t)
	h.seedIcon(fixtureIconDoc, 0, true)
	h.seedVerifier(verifierSettings(true, true))
	ctx := context.Background()

	filed, err := h.createRequest(fixtureOwner, chanPeer(fixtureChannel), "")
	if err != nil {
		t.Fatalf("CreateRequest: %v", err)
	}

	if _, _, err := h.svc.Reject(ctx, filed.ID, filed.Version, "operator", "   ", "note"); !errors.Is(err, domain.ErrVerificationReasonRequired) {
		t.Fatalf("err = %v, want ErrVerificationReasonRequired", err)
	}
	if stored, err := h.store.CustomVerificationRequest(ctx, filed.ID); err != nil || stored.Status != domain.CustomVerificationPending {
		t.Fatalf("application = %+v / %v, want it untouched", stored, err)
	}
	if len(h.applicant.calls) != 0 {
		t.Fatalf("applicant notifications = %d, want none", len(h.applicant.calls))
	}

	decided, changed, err := h.svc.Reject(ctx, filed.ID, filed.Version, "operator", "not a partner", "note")
	if err != nil || !changed {
		t.Fatalf("Reject = %v/%v, want true/nil", changed, err)
	}
	if decided.Status != domain.CustomVerificationRejected || decided.DecisionReason != "not a partner" {
		t.Fatalf("decided = %+v, want a rejection carrying its reason", decided)
	}
	// A rejection touches no peer state, so only the applicant hears about it.
	if h.peers.count() != 0 {
		t.Fatalf("peer pushes = %d, want none for a rejection", h.peers.count())
	}
	if len(h.applicant.calls) != 1 || h.applicant.calls[0].request.Status != domain.CustomVerificationRejected {
		t.Fatalf("applicant calls = %+v, want one rejection notice", h.applicant.calls)
	}
	if _, found := h.mark(fixtureVerifierBot, chanPeer(fixtureChannel)); found {
		t.Fatal("a rejection granted a mark")
	}
}

// TestDecisionsWriteThroughTheMarkApplier pins the wiring the atomicity of a
// decision depends on: the grant and the revocation both go through the injected
// writer, which in production is the one that picks the decision's transaction out
// of the context.
func TestDecisionsWriteThroughTheMarkApplier(t *testing.T) {
	h := newHarness(t)
	applier := &fakeMarkApplier{store: h.store}
	h.svc = NewService(
		WithStore(h.store),
		WithPeerDirectory(h.dir),
		WithPeerNotifier(h.peers),
		WithIconResolver(h.docs),
		WithApplicantNotifier(h.applicant),
		WithRateLimiter(h.limiter, 5, 24*time.Hour),
		WithMarkApplier(applier),
	)
	h.seedIcon(fixtureIconDoc, 0, true)
	h.seedVerifier(verifierSettings(true, true))
	ctx := context.Background()

	filed, err := h.createRequest(fixtureOwner, chanPeer(fixtureChannel), "")
	if err != nil {
		t.Fatalf("CreateRequest: %v", err)
	}
	approved, _, err := h.svc.Approve(ctx, filed.ID, filed.Version, "operator", "ok", "")
	if err != nil {
		t.Fatalf("Approve: %v", err)
	}
	if applier.grants != 1 {
		t.Fatalf("applier grants = %d, want 1", applier.grants)
	}
	if _, _, err := h.svc.RevokeRequest(ctx, approved.ID, approved.Version, "operator", "ended", ""); err != nil {
		t.Fatalf("RevokeRequest: %v", err)
	}
	if applier.revokes != 1 {
		t.Fatalf("applier revokes = %d, want 1", applier.revokes)
	}
	// The plain RPC path is not a decision, so it does not go through the applier.
	if _, err := h.svc.SetCustomVerification(ctx, domain.SetCustomVerificationRequest{
		VerifierBotID: fixtureVerifierBot,
		Peer:          userPeer(fixtureUserPeer),
		Enabled:       true,
		CallerUserID:  fixtureVerifierBot,
	}); err != nil {
		t.Fatalf("SetCustomVerification: %v", err)
	}
	if applier.grants != 1 {
		t.Fatalf("applier grants = %d after the RPC path, want it untouched", applier.grants)
	}
}

func TestRevokeRequestRemovesTheMark(t *testing.T) {
	h := newHarness(t)
	h.seedIcon(fixtureIconDoc, 0, true)
	h.seedVerifier(verifierSettings(true, true))
	ctx := context.Background()

	filed, err := h.createRequest(fixtureOwner, chanPeer(fixtureChannel), "")
	if err != nil {
		t.Fatalf("CreateRequest: %v", err)
	}
	approved, _, err := h.svc.Approve(ctx, filed.ID, filed.Version, "operator", "ok", "")
	if err != nil {
		t.Fatalf("Approve: %v", err)
	}

	if _, _, err := h.svc.RevokeRequest(ctx, approved.ID, approved.Version, "operator", "", ""); !errors.Is(err, domain.ErrVerificationReasonRequired) {
		t.Fatalf("err = %v, want ErrVerificationReasonRequired", err)
	}

	revoked, changed, err := h.svc.RevokeRequest(ctx, approved.ID, approved.Version, "operator", "partner agreement ended", "")
	if err != nil || !changed {
		t.Fatalf("RevokeRequest = %v/%v, want true/nil", changed, err)
	}
	if revoked.Status != domain.CustomVerificationRevoked {
		t.Fatalf("status = %q, want revoked", revoked.Status)
	}
	if _, found := h.mark(fixtureVerifierBot, chanPeer(fixtureChannel)); found {
		t.Fatal("the mark survived the revocation")
	}
	if h.peers.count() != 2 {
		t.Fatalf("peer pushes = %d, want 2 (approve + revoke)", h.peers.count())
	}
	if len(h.applicant.calls) != 2 || h.applicant.calls[1].request.Status != domain.CustomVerificationRevoked {
		t.Fatalf("applicant calls = %+v, want an approval and a revocation notice", h.applicant.calls)
	}
}

func TestRevokeMarkAndListings(t *testing.T) {
	h := newHarness(t)
	h.seedIcon(fixtureIconDoc, 0, true)
	h.seedVerifier(verifierSettings(true, true))
	ctx := context.Background()

	if _, err := h.svc.SetCustomVerification(ctx, domain.SetCustomVerificationRequest{
		VerifierBotID: fixtureVerifierBot,
		Peer:          userPeer(fixtureUserPeer),
		Enabled:       true,
		CallerUserID:  fixtureVerifierBot,
	}); err != nil {
		t.Fatalf("SetCustomVerification: %v", err)
	}

	marks, err := h.svc.Marks(ctx, domain.CustomVerificationFilter{VerifierBotID: fixtureVerifierBot})
	if err != nil {
		t.Fatalf("Marks: %v", err)
	}
	if len(marks) != 1 {
		t.Fatalf("Marks = %+v, want one", marks)
	}
	verifiers, err := h.svc.Verifiers(ctx, true, 0)
	if err != nil {
		t.Fatalf("Verifiers: %v", err)
	}
	if len(verifiers) != 1 || verifiers[0].BotID != fixtureVerifierBot {
		t.Fatalf("Verifiers = %+v, want the one enabled verifier", verifiers)
	}

	removed, err := h.svc.RevokeMark(ctx, fixtureVerifierBot, userPeer(fixtureUserPeer))
	if err != nil || !removed {
		t.Fatalf("RevokeMark = %v/%v, want true/nil", removed, err)
	}
	removed, err = h.svc.RevokeMark(ctx, fixtureVerifierBot, userPeer(fixtureUserPeer))
	if err != nil || removed {
		t.Fatalf("repeat RevokeMark = %v/%v, want false/nil", removed, err)
	}
	if _, err := h.svc.Marks(ctx, domain.CustomVerificationFilter{PeerType: domain.PeerTypeCommunity}); !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) {
		t.Fatalf("Marks with an unmarkable peer type: %v", err)
	}
}

func TestProjectionAndQueueReads(t *testing.T) {
	h := newHarness(t)
	h.seedIcon(fixtureIconDoc, 0, true)
	h.seedVerifier(verifierSettings(true, true))
	ctx := context.Background()

	filed, err := h.createRequest(fixtureOwner, chanPeer(fixtureChannel), "")
	if err != nil {
		t.Fatalf("CreateRequest: %v", err)
	}
	if _, _, err := h.svc.Approve(ctx, filed.ID, filed.Version, "operator", "ok", ""); err != nil {
		t.Fatalf("Approve: %v", err)
	}

	mark, err := h.svc.PeerVerification(ctx, chanPeer(fixtureChannel))
	if err != nil {
		t.Fatalf("PeerVerification: %v", err)
	}
	if mark.Projection().Icon != fixtureIconDoc || mark.Projection().BotID != fixtureVerifierBot {
		t.Fatalf("projection = %+v", mark.Projection())
	}
	batch, err := h.svc.PeerVerificationBatch(ctx, []domain.Peer{chanPeer(fixtureChannel), userPeer(fixtureUserPeer)})
	if err != nil {
		t.Fatalf("PeerVerificationBatch: %v", err)
	}
	if len(batch) != 1 {
		t.Fatalf("batch = %+v, want only the marked peer", batch)
	}
	settings, err := h.svc.VerifierSettings(ctx, fixtureVerifierBot)
	if err != nil || settings.BotID != fixtureVerifierBot {
		t.Fatalf("VerifierSettings = %+v / %v", settings, err)
	}
	settingsBatch, err := h.svc.VerifierSettingsBatch(ctx, []int64{fixtureVerifierBot, fixtureOwnedBot})
	if err != nil {
		t.Fatalf("VerifierSettingsBatch: %v", err)
	}
	if len(settingsBatch) != 1 {
		t.Fatalf("settings batch = %+v, want only the verifier", settingsBatch)
	}

	counts, err := h.svc.RequestCounts(ctx)
	if err != nil {
		t.Fatalf("RequestCounts: %v", err)
	}
	if counts[domain.CustomVerificationApproved] != 1 {
		t.Fatalf("counts = %+v, want one approved", counts)
	}
	history, err := h.svc.ApplicantRequests(ctx, fixtureOwner, 0)
	if err != nil || len(history) != 1 {
		t.Fatalf("ApplicantRequests = %+v / %v", history, err)
	}
	if _, err := h.svc.Request(ctx, filed.ID); err != nil {
		t.Fatalf("Request: %v", err)
	}
	if _, err := h.svc.PendingRequest(ctx, fixtureVerifierBot, chanPeer(fixtureChannel)); !errors.Is(err, domain.ErrCustomVerificationRequestNotFound) {
		t.Fatalf("PendingRequest after the decision: %v, want not found", err)
	}
	if _, err := h.svc.Requests(ctx, domain.CustomVerificationRequestFilter{Statuses: []domain.CustomVerificationRequestStatus{"nonsense"}}); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
		t.Fatalf("Requests with a bogus status: %v", err)
	}
}

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

// TestDisabledConfigRefusesMutations pins the flag's scope: every write refuses
// with ErrDisabled, while the marks already granted keep projecting so a peer's
// badge does not silently vanish when an operator turns the feature off.
func TestDisabledConfigRefusesMutations(t *testing.T) {
	h := newHarness(t, WithEnabled(false))
	h.seedIcon(fixtureIconDoc, 0, true)
	h.seedVerifier(verifierSettings(true, true))
	ctx := context.Background()
	if _, _, err := h.store.GrantCustomVerification(ctx, domain.CustomVerification{
		VerifierBotID:  fixtureVerifierBot,
		Peer:           userPeer(fixtureUserPeer),
		IconDocumentID: fixtureIconDoc,
		Description:    "Verified by Acme",
	}); err != nil {
		t.Fatalf("seed mark: %v", err)
	}

	if h.svc.Enabled() || h.svc.Ready() {
		t.Fatal("a disabled service reports itself enabled")
	}
	for name, call := range map[string]func() error{
		"SetCustomVerification": func() error {
			_, err := h.svc.SetCustomVerification(ctx, domain.SetCustomVerificationRequest{
				VerifierBotID: fixtureVerifierBot,
				Peer:          chanPeer(fixtureChannel),
				Enabled:       true,
				CallerUserID:  fixtureVerifierBot,
			})
			return err
		},
		"GrantVerifier": func() error {
			_, err := h.svc.GrantVerifier(ctx, verifierSettings(true, true))
			return err
		},
		"SetVerifierEnabled": func() error {
			_, err := h.svc.SetVerifierEnabled(ctx, fixtureVerifierBot, false)
			return err
		},
		"RevokeVerifier": func() error {
			_, err := h.svc.RevokeVerifier(ctx, fixtureVerifierBot)
			return err
		},
		"UpsertIcon": func() error {
			_, err := h.svc.UpsertIcon(ctx, domain.VerificationIcon{DocumentID: fixtureIconDoc, Name: "Acme", Active: true})
			return err
		},
		"SetIconActive": func() error {
			_, err := h.svc.SetIconActive(ctx, 1, false)
			return err
		},
		"RevokeMark": func() error {
			_, err := h.svc.RevokeMark(ctx, fixtureVerifierBot, userPeer(fixtureUserPeer))
			return err
		},
		"CreateRequest": func() error {
			_, err := h.createRequest(fixtureOwner, chanPeer(fixtureChannel), "")
			return err
		},
		"Approve": func() error {
			_, _, err := h.svc.Approve(ctx, 1, 1, "operator", "", "")
			return err
		},
		"Reject": func() error {
			_, _, err := h.svc.Reject(ctx, 1, 1, "operator", "no", "")
			return err
		},
		"RevokeRequest": func() error {
			_, _, err := h.svc.RevokeRequest(ctx, 1, 1, "operator", "no", "")
			return err
		},
	} {
		t.Run(name, func(t *testing.T) {
			if err := call(); !errors.Is(err, ErrDisabled) {
				t.Fatalf("err = %v, want ErrDisabled", err)
			}
		})
	}
	if h.peers.count() != 0 || len(h.applicant.calls) != 0 {
		t.Fatalf("a disabled service notified: %d peer / %d applicant", h.peers.count(), len(h.applicant.calls))
	}

	// Reads still answer: the badge already granted keeps rendering, and the panel
	// can still audit what exists.
	if _, err := h.svc.PeerVerification(ctx, userPeer(fixtureUserPeer)); err != nil {
		t.Fatalf("PeerVerification while disabled: %v", err)
	}
	if icons, err := h.svc.Icons(ctx, false, 0); err != nil || len(icons) != 1 {
		t.Fatalf("Icons while disabled = %+v / %v", icons, err)
	}
	if verifiers, err := h.svc.Verifiers(ctx, false, 0); err != nil || len(verifiers) != 1 {
		t.Fatalf("Verifiers while disabled = %+v / %v", verifiers, err)
	}
}

// TestNilAndUnconfiguredServiceAreSafe covers the two shapes a process can hand
// out by accident: no service at all, and a service with no store.
func TestNilAndUnconfiguredServiceAreSafe(t *testing.T) {
	ctx := context.Background()
	var nilService *Service

	if nilService.Enabled() || nilService.Ready() {
		t.Fatal("a nil service reports itself enabled")
	}
	if _, err := nilService.PeerVerification(ctx, userPeer(fixtureUserPeer)); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
		t.Fatalf("nil PeerVerification: %v, want ErrCustomVerificationNotFound", err)
	}
	batch, err := nilService.PeerVerificationBatch(ctx, []domain.Peer{userPeer(fixtureUserPeer)})
	if err != nil || len(batch) != 0 {
		t.Fatalf("nil PeerVerificationBatch = %+v / %v, want an empty map", batch, err)
	}
	if _, err := nilService.VerifierSettings(ctx, fixtureVerifierBot); !errors.Is(err, domain.ErrVerifierNotFound) {
		t.Fatalf("nil VerifierSettings: %v, want ErrVerifierNotFound", err)
	}
	if settings, err := nilService.VerifierSettingsBatch(ctx, []int64{fixtureVerifierBot}); err != nil || len(settings) != 0 {
		t.Fatalf("nil VerifierSettingsBatch = %+v / %v", settings, err)
	}
	if _, err := nilService.SetCustomVerification(ctx, domain.SetCustomVerificationRequest{}); !errors.Is(err, ErrDisabled) {
		t.Fatalf("nil SetCustomVerification: %v, want ErrDisabled", err)
	}
	// Nil notifier hooks are ignored rather than panicking.
	nilService.SetPeerNotifier(&fakePeerNotifier{})
	nilService.SetApplicantNotifier(&fakeApplicantNotifier{})

	// Enabled but storeless: every entry point says what is missing instead of
	// dereferencing nil.
	bare := NewService(WithPeerDirectory(&fakeDirectory{}), WithIconResolver(&fakeIconResolver{}))
	if !bare.Enabled() || bare.Ready() {
		t.Fatal("a storeless service reports itself ready")
	}
	for name, call := range map[string]func() error{
		"SetCustomVerification": func() error {
			_, err := bare.SetCustomVerification(ctx, domain.SetCustomVerificationRequest{
				VerifierBotID: fixtureVerifierBot,
				Peer:          userPeer(fixtureUserPeer),
				Enabled:       true,
				CallerUserID:  fixtureVerifierBot,
			})
			return err
		},
		"GrantVerifier": func() error {
			_, err := bare.GrantVerifier(ctx, verifierSettings(true, true))
			return err
		},
		"Icons": func() error {
			_, err := bare.Icons(ctx, false, 0)
			return err
		},
		"Requests": func() error {
			_, err := bare.Requests(ctx, domain.CustomVerificationRequestFilter{})
			return err
		},
	} {
		t.Run(name, func(t *testing.T) {
			err := call()
			if err == nil || !strings.Contains(err.Error(), "store is not configured") {
				t.Fatalf("err = %v, want the store configuration error", err)
			}
		})
	}
}

// TestSetCustomVerificationWithoutDirectoryFails pins that a security check which
// cannot run refuses instead of passing.
func TestSetCustomVerificationWithoutDirectoryFails(t *testing.T) {
	st := memory.NewBotVerificationStore()
	if _, err := st.UpsertVerificationIcon(context.Background(), domain.VerificationIcon{DocumentID: fixtureIconDoc, Name: "Acme", Active: true}); err != nil {
		t.Fatalf("seed icon: %v", err)
	}
	if _, err := st.UpsertBotVerifierSettings(context.Background(), verifierSettings(true, true)); err != nil {
		t.Fatalf("seed verifier: %v", err)
	}
	svc := NewService(WithStore(st))

	_, err := svc.SetCustomVerification(context.Background(), domain.SetCustomVerificationRequest{
		VerifierBotID: fixtureVerifierBot,
		Peer:          userPeer(fixtureUserPeer),
		Enabled:       true,
		// Not the bot itself, so ownership has to be resolved -- and cannot be.
		CallerUserID: fixtureOwner,
	})
	if err == nil || !strings.Contains(err.Error(), "bot directory is not configured") {
		t.Fatalf("err = %v, want the bot directory configuration error", err)
	}
}