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

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

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

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

1416 lines
48 KiB
Go

package verification
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
func targetKey(targetType domain.VerificationTargetType, targetID int64) string {
return fmt.Sprintf("%s:%d", targetType, targetID)
}
type fakeStore struct {
apps map[int64]domain.VerificationApplication
events map[int64][]domain.VerificationApplicationEvent
rejections map[string]domain.VerificationApplication
pending []store.VerificationNotification
nextID int64
decideCalls int
revokeCalls int
deliveredIDs []int64
failedIDs []int64
failedReasons []string
createRequests []domain.SubmitVerificationApplicationRequest
}
func newFakeStore() *fakeStore {
return &fakeStore{
apps: map[int64]domain.VerificationApplication{},
events: map[int64][]domain.VerificationApplicationEvent{},
rejections: map[string]domain.VerificationApplication{},
}
}
func (f *fakeStore) put(app domain.VerificationApplication) domain.VerificationApplication {
if app.ID == 0 {
f.nextID++
app.ID = f.nextID
}
if app.ID > f.nextID {
f.nextID = app.ID
}
if app.Version == 0 {
app.Version = 1
}
f.apps[app.ID] = app
return app
}
func (f *fakeStore) CreateVerificationDraft(_ context.Context, req domain.SubmitVerificationApplicationRequest) (domain.VerificationApplication, bool, error) {
f.createRequests = append(f.createRequests, req)
for _, app := range f.apps {
if app.Status.Active() && app.TargetType == req.TargetType && app.TargetID == req.TargetID {
return domain.VerificationApplication{}, false, domain.ErrVerificationApplicationExists
}
if app.Status == domain.VerificationStatusDraft && app.ApplicantUserID == req.ApplicantUserID {
return app, false, nil
}
}
app := f.put(domain.VerificationApplication{
ApplicantUserID: req.ApplicantUserID,
TargetType: req.TargetType,
TargetID: req.TargetID,
TargetTitle: req.TargetTitle,
TargetUsername: req.TargetUsername,
Category: req.Draft.Category,
Description: req.Draft.Description,
OfficialWebsite: req.Draft.OfficialWebsite,
SocialLinks: req.Draft.SocialLinks,
PressLinks: req.Draft.PressLinks,
AdditionalNote: req.Draft.AdditionalNote,
Status: domain.VerificationStatusDraft,
CorrelationID: req.CorrelationID,
Version: 1,
})
return app, true, nil
}
func (f *fakeStore) SaveVerificationDraft(_ context.Context, applicationID, version int64, draft domain.VerificationDraftInput) (domain.VerificationApplication, error) {
app, ok := f.apps[applicationID]
if !ok {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if app.Version != version {
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
}
app.Category = draft.Category
app.Description = draft.Description
app.OfficialWebsite = draft.OfficialWebsite
app.SocialLinks = draft.SocialLinks
app.PressLinks = draft.PressLinks
app.AdditionalNote = draft.AdditionalNote
app.Version++
f.apps[applicationID] = app
return app, nil
}
func (f *fakeStore) SubmitVerificationApplication(_ context.Context, applicationID, version int64) (domain.VerificationApplication, error) {
app, ok := f.apps[applicationID]
if !ok {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if app.Version != version {
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
}
app.Status = domain.VerificationStatusSubmitted
app.Version++
f.apps[applicationID] = app
return app, nil
}
func (f *fakeStore) CancelVerificationApplication(_ context.Context, applicationID, version int64, reason string) (domain.VerificationApplication, error) {
app, ok := f.apps[applicationID]
if !ok {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if app.Version != version {
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
}
app.Status = domain.VerificationStatusCancelled
app.DecisionReason = reason
app.Version++
f.apps[applicationID] = app
return app, nil
}
func (f *fakeStore) ClaimVerificationApplication(_ context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, error) {
app, ok := f.apps[decision.ApplicationID]
if !ok {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if app.Version != decision.Version {
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
}
if !domain.CanTransitionVerificationStatus(app.Status, domain.VerificationStatusInReview) {
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
}
app.Status = domain.VerificationStatusInReview
app.ReviewerAdminID = decision.Reviewer
app.Version++
f.apps[decision.ApplicationID] = app
return app, nil
}
func (f *fakeStore) DecideVerificationApplication(ctx context.Context, decision domain.VerificationDecision, approve bool, applyVerified func(ctx context.Context, app domain.VerificationApplication) error) (domain.VerificationApplication, bool, error) {
f.decideCalls++
app, ok := f.apps[decision.ApplicationID]
if !ok {
return domain.VerificationApplication{}, false, domain.ErrVerificationApplicationNotFound
}
target := domain.VerificationStatusRejected
if approve {
target = domain.VerificationStatusApproved
}
if app.Status == target {
return app, false, nil
}
if app.Version != decision.Version {
return domain.VerificationApplication{}, false, domain.ErrVerificationVersionConflict
}
next := app
next.Status = target
next.ReviewerAdminID = decision.Reviewer
next.DecisionReason = decision.Reason
next.InternalNote = decision.InternalNote
next.ReviewedAt = time.Now().UTC()
next.Version++
if approve && applyVerified != nil {
if err := applyVerified(ctx, next); err != nil {
return domain.VerificationApplication{}, false, err
}
}
f.apps[decision.ApplicationID] = next
kind := NoticeKindRejected
if approve {
kind = NoticeKindApproved
} else {
f.rejections[targetKey(next.TargetType, next.TargetID)] = next
}
f.pending = append(f.pending, store.VerificationNotification{
ID: int64(len(f.pending) + 1),
ApplicationID: next.ID,
RecipientUserID: next.ApplicantUserID,
Kind: kind,
Application: next,
})
return next, true, nil
}
func (f *fakeStore) RevokeVerification(ctx context.Context, req domain.VerificationRevocation, clearVerified func(ctx context.Context, target domain.Peer) error) (domain.VerificationApplication, bool, error) {
f.revokeCalls++
var found domain.VerificationApplication
for _, app := range f.apps {
if app.Status == domain.VerificationStatusApproved && app.TargetType == req.TargetType && app.TargetID == req.TargetID {
if app.ID > found.ID {
found = app
}
}
}
if found.ID == 0 {
return domain.VerificationApplication{}, false, domain.ErrVerificationApplicationNotFound
}
if clearVerified != nil {
if err := clearVerified(ctx, found.Target()); err != nil {
return domain.VerificationApplication{}, false, err
}
}
f.pending = append(f.pending, store.VerificationNotification{
ID: int64(len(f.pending) + 1),
ApplicationID: found.ID,
RecipientUserID: found.ApplicantUserID,
Kind: NoticeKindRevoked,
Application: found,
})
return found, true, nil
}
func (f *fakeStore) VerificationApplication(_ context.Context, applicationID int64) (domain.VerificationApplication, error) {
app, ok := f.apps[applicationID]
if !ok {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
return app, nil
}
func (f *fakeStore) ActiveVerificationApplicationForTarget(_ context.Context, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationApplication, error) {
for _, app := range f.apps {
if app.Status.Active() && app.TargetType == targetType && app.TargetID == targetID {
return app, nil
}
}
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
func (f *fakeStore) VerificationDraftForApplicant(_ context.Context, applicantUserID int64) (domain.VerificationApplication, error) {
for _, app := range f.apps {
if app.Status == domain.VerificationStatusDraft && app.ApplicantUserID == applicantUserID {
return app, nil
}
}
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
func (f *fakeStore) ListVerificationApplications(_ context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
out := make([]domain.VerificationApplication, 0, len(f.apps))
for _, app := range f.apps {
out = append(out, app)
}
if filter.Limit > 0 && len(out) > filter.Limit {
out = out[:filter.Limit]
}
return out, nil
}
func (f *fakeStore) VerificationApplicationsForApplicant(_ context.Context, applicantUserID int64, limit int) ([]domain.VerificationApplication, error) {
out := make([]domain.VerificationApplication, 0, len(f.apps))
for _, app := range f.apps {
if app.ApplicantUserID == applicantUserID {
out = append(out, app)
}
}
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (f *fakeStore) VerificationStatusCounts(_ context.Context) (domain.VerificationStatusCounts, error) {
counts := domain.VerificationStatusCounts{}
for _, app := range f.apps {
counts[app.Status]++
}
return counts, nil
}
func (f *fakeStore) VerificationApplicationEvents(_ context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error) {
events := f.events[applicationID]
if limit > 0 && len(events) > limit {
events = events[:limit]
}
return events, nil
}
func (f *fakeStore) LastVerificationRejection(_ context.Context, _ int64, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationApplication, error) {
app, ok := f.rejections[targetKey(targetType, targetID)]
if !ok {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
return app, nil
}
func (f *fakeStore) PendingVerificationNotifications(_ context.Context, limit int) ([]store.VerificationNotification, error) {
out := append([]store.VerificationNotification(nil), f.pending...)
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (f *fakeStore) MarkVerificationNotificationDelivered(_ context.Context, id int64) error {
f.deliveredIDs = append(f.deliveredIDs, id)
kept := f.pending[:0]
for _, item := range f.pending {
if item.ID != id {
kept = append(kept, item)
}
}
f.pending = kept
return nil
}
func (f *fakeStore) MarkVerificationNotificationFailed(_ context.Context, id int64, reason string) error {
f.failedIDs = append(f.failedIDs, id)
f.failedReasons = append(f.failedReasons, reason)
for i := range f.pending {
if f.pending[i].ID == id {
f.pending[i].Attempts++
}
}
return nil
}
var _ store.VerificationStore = (*fakeStore)(nil)
type fakeDirectory struct {
users map[int64]domain.User
channels map[int64]domain.Channel
ownedBots map[int64][]int64
adminedChannels map[int64][]int64
}
func (d *fakeDirectory) AdminUser(_ context.Context, userID int64) (domain.User, bool, error) {
user, ok := d.users[userID]
return user, ok, nil
}
func (d *fakeDirectory) ListOwnedBots(_ context.Context, ownerUserID int64) ([]domain.User, error) {
out := make([]domain.User, 0, len(d.ownedBots[ownerUserID]))
for _, botID := range d.ownedBots[ownerUserID] {
if user, ok := d.users[botID]; ok {
out = append(out, user)
}
}
return out, nil
}
func (d *fakeDirectory) OwnsBot(_ context.Context, ownerUserID, botUserID int64) (bool, error) {
for _, id := range d.ownedBots[ownerUserID] {
if id == botUserID {
return true, nil
}
}
return false, nil
}
func (d *fakeDirectory) GetChannelByID(_ context.Context, channelID int64) (domain.Channel, error) {
channel, ok := d.channels[channelID]
if !ok {
return domain.Channel{}, domain.ErrChannelInvalid
}
return channel, nil
}
func (d *fakeDirectory) ListAdminedPublicChannels(_ context.Context, userID int64) ([]domain.Channel, error) {
out := make([]domain.Channel, 0, len(d.adminedChannels[userID]))
for _, channelID := range d.adminedChannels[userID] {
if channel, ok := d.channels[channelID]; ok && channel.Username != "" && !channel.Deleted {
out = append(out, channel)
}
}
return out, nil
}
var _ PeerDirectory = (*fakeDirectory)(nil)
type fakeVerifier struct {
userCalls []int64
channelCalls []int64
values []bool
err error
}
func (v *fakeVerifier) SetUserVerified(_ context.Context, userID int64, verified bool) error {
if v.err != nil {
return v.err
}
v.userCalls = append(v.userCalls, userID)
v.values = append(v.values, verified)
return nil
}
func (v *fakeVerifier) SetChannelVerified(_ context.Context, channelID int64, verified bool) error {
if v.err != nil {
return v.err
}
v.channelCalls = append(v.channelCalls, channelID)
v.values = append(v.values, verified)
return nil
}
func (v *fakeVerifier) calls() int { return len(v.userCalls) + len(v.channelCalls) }
type fakePeerNotifier struct {
peers []domain.Peer
err error
}
func (n *fakePeerNotifier) NotifyPeerVerified(_ context.Context, peer domain.Peer) error {
n.peers = append(n.peers, peer)
return n.err
}
type sentNotice struct {
recipient int64
kind string
appID int64
}
type fakeApplicantNotifier struct {
sent []sentNotice
err error
}
func (n *fakeApplicantNotifier) SendVerificationNotice(_ context.Context, recipientUserID int64, app domain.VerificationApplication, kind string) error {
if n.err != nil {
return n.err
}
n.sent = append(n.sent, sentNotice{recipient: recipientUserID, kind: kind, appID: app.ID})
return nil
}
// fakeLimiter allows `budget` calls and refuses everything after that.
type fakeLimiter struct {
budget int
calls int
err error
}
func (l *fakeLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, int, error) {
return l.AllowN(ctx, key, 1, limit, window)
}
func (l *fakeLimiter) AllowN(_ context.Context, _ string, cost, _ int, _ time.Duration) (bool, int, error) {
if l.err != nil {
return false, 0, l.err
}
l.calls += cost
if l.calls > l.budget {
return false, 60, nil
}
return true, 0, nil
}
type fakeFreezes struct {
frozen map[int64]bool
}
func (f *fakeFreezes) AccountFreeze(_ context.Context, userID int64) (domain.AccountFreeze, bool, error) {
frozen, ok := f.frozen[userID]
if !ok {
return domain.AccountFreeze{}, false, nil
}
return domain.AccountFreeze{UserID: userID, Frozen: frozen}, true, nil
}
// ---------------------------------------------------------------------------
// Fixture
// ---------------------------------------------------------------------------
const (
applicantID = int64(1000)
strangerID = int64(1001)
botID = int64(5000)
channelID = int64(7000)
groupID = int64(7001)
)
type fixture struct {
svc *Service
st *fakeStore
dir *fakeDirectory
verifier *fakeVerifier
peers *fakePeerNotifier
notifier *fakeApplicantNotifier
limiter *fakeLimiter
freezes *fakeFreezes
now time.Time
}
func newFixture(t *testing.T, opts ...Option) *fixture {
t.Helper()
fx := &fixture{
st: newFakeStore(),
dir: &fakeDirectory{
users: map[int64]domain.User{
applicantID: {ID: applicantID, AccessHash: 11, FirstName: "App", LastName: "Licant", Username: "applicant"},
strangerID: {ID: strangerID, FirstName: "Stranger", Username: "stranger"},
botID: {ID: botID, AccessHash: 22, FirstName: "My Bot", Username: "mybot", Bot: true, BotInfoVersion: 1},
domain.VerifyBotUserID: domain.VerifyBotUser(),
},
channels: map[int64]domain.Channel{
channelID: {ID: channelID, AccessHash: 33, Title: "My Channel", Username: "mychannel", Broadcast: true, CreatorUserID: applicantID},
groupID: {ID: groupID, AccessHash: 34, Title: "My Group", Username: "mygroup", Megagroup: true, CreatorUserID: applicantID},
},
ownedBots: map[int64][]int64{applicantID: {botID}},
adminedChannels: map[int64][]int64{applicantID: {channelID, groupID}},
},
verifier: &fakeVerifier{},
peers: &fakePeerNotifier{},
notifier: &fakeApplicantNotifier{},
limiter: &fakeLimiter{budget: 100},
freezes: &fakeFreezes{frozen: map[int64]bool{}},
now: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC),
}
base := []Option{
WithStore(fx.st),
WithPeerDirectory(fx.dir),
WithAccountFreezeProvider(fx.freezes),
WithPeerVerifier(fx.verifier),
WithPeerNotifier(fx.peers),
WithApplicantNotifier(fx.notifier),
WithRateLimiter(fx.limiter, 3, 24*time.Hour),
WithClock(func() time.Time { return fx.now }),
}
fx.svc = NewService(append(base, opts...)...)
return fx
}
func validDraft() domain.VerificationDraftInput {
return domain.VerificationDraftInput{
Category: "media",
Description: "An independent regional newsroom publishing daily coverage since 2014.",
OfficialWebsite: "https://example.org",
SocialLinks: []string{"https://example.org/social"},
PressLinks: []string{"https://press.example.org/a", "https://press.example.com/b"},
}
}
func botRequest() domain.SubmitVerificationApplicationRequest {
return domain.SubmitVerificationApplicationRequest{
ApplicantUserID: applicantID,
TargetType: domain.VerificationTargetBot,
TargetID: botID,
Draft: validDraft(),
}
}
func channelRequest() domain.SubmitVerificationApplicationRequest {
return domain.SubmitVerificationApplicationRequest{
ApplicantUserID: applicantID,
TargetType: domain.VerificationTargetChannel,
TargetID: channelID,
Draft: validDraft(),
}
}
// submitted files and submits an application, returning the submitted record.
func (fx *fixture) submitted(t *testing.T, req domain.SubmitVerificationApplicationRequest) domain.VerificationApplication {
t.Helper()
ctx := context.Background()
app, created, err := fx.svc.StartDraft(ctx, req)
if err != nil {
t.Fatalf("StartDraft: %v", err)
}
if !created {
t.Fatalf("StartDraft created = false, want a fresh draft")
}
submitted, err := fx.svc.Submit(ctx, req.ApplicantUserID, app.ID, app.Version)
if err != nil {
t.Fatalf("Submit: %v", err)
}
return submitted
}
// ---------------------------------------------------------------------------
// Security checks, one test per refusal
// ---------------------------------------------------------------------------
func TestStartDraftAcceptsOwnedPublicBot(t *testing.T) {
fx := newFixture(t)
app, created, err := fx.svc.StartDraft(context.Background(), botRequest())
if err != nil {
t.Fatalf("StartDraft: %v", err)
}
if !created {
t.Fatal("created = false, want true")
}
// The stored snapshot must come from the resolved peer, not from the request.
if app.TargetUsername != "mybot" || app.TargetTitle != "My Bot" {
t.Fatalf("target snapshot = %q/%q, want My Bot/mybot", app.TargetTitle, app.TargetUsername)
}
}
func TestStartDraftRejectsMissingTarget(t *testing.T) {
fx := newFixture(t)
req := botRequest()
req.TargetID = 999999
if _, _, err := fx.svc.StartDraft(context.Background(), req); !errors.Is(err, domain.ErrVerificationTargetInvalid) {
t.Fatalf("err = %v, want ErrVerificationTargetInvalid", err)
}
}
func TestStartDraftRejectsUserTargetWhenDisabled(t *testing.T) {
fx := newFixture(t)
req := botRequest()
req.TargetType = domain.VerificationTargetUser
req.TargetID = applicantID
if _, _, err := fx.svc.StartDraft(context.Background(), req); !errors.Is(err, domain.ErrVerificationUserTargetsDisabled) {
t.Fatalf("err = %v, want ErrVerificationUserTargetsDisabled", err)
}
}
func TestStartDraftAcceptsUserTargetWhenEnabled(t *testing.T) {
fx := newFixture(t, WithAllowUserTargets(true))
req := botRequest()
req.TargetType = domain.VerificationTargetUser
req.TargetID = applicantID
app, created, err := fx.svc.StartDraft(context.Background(), req)
if err != nil || !created {
t.Fatalf("StartDraft = %v, created=%v", err, created)
}
if app.TargetUsername != "applicant" {
t.Fatalf("target username = %q", app.TargetUsername)
}
}
func TestStartDraftRejectsNonBotFiledAsBot(t *testing.T) {
// A plain account filed under the bot kind would bypass the user-targets
// switch, so the namespaces are pinned.
fx := newFixture(t)
req := botRequest()
req.TargetID = applicantID
if _, _, err := fx.svc.StartDraft(context.Background(), req); !errors.Is(err, domain.ErrVerificationTargetInvalid) {
t.Fatalf("err = %v, want ErrVerificationTargetInvalid", err)
}
}
func TestStartDraftRejectsTargetWithoutUsername(t *testing.T) {
fx := newFixture(t)
channel := fx.dir.channels[channelID]
channel.Username = ""
fx.dir.channels[channelID] = channel
if _, _, err := fx.svc.StartDraft(context.Background(), channelRequest()); !errors.Is(err, domain.ErrVerificationTargetNotPublic) {
t.Fatalf("err = %v, want ErrVerificationTargetNotPublic", err)
}
}
func TestStartDraftRejectsForeignBot(t *testing.T) {
fx := newFixture(t)
req := botRequest()
req.ApplicantUserID = strangerID
if _, _, err := fx.svc.StartDraft(context.Background(), req); !errors.Is(err, domain.ErrVerificationNotOwner) {
t.Fatalf("err = %v, want ErrVerificationNotOwner", err)
}
}
func TestStartDraftRejectsChannelTheApplicantDoesNotAdminister(t *testing.T) {
fx := newFixture(t)
fx.dir.adminedChannels[applicantID] = nil
if _, _, err := fx.svc.StartDraft(context.Background(), channelRequest()); !errors.Is(err, domain.ErrVerificationNotOwner) {
t.Fatalf("err = %v, want ErrVerificationNotOwner", err)
}
}
func TestStartDraftRejectsAlreadyVerifiedTarget(t *testing.T) {
fx := newFixture(t)
channel := fx.dir.channels[channelID]
channel.Verified = true
fx.dir.channels[channelID] = channel
if _, _, err := fx.svc.StartDraft(context.Background(), channelRequest()); !errors.Is(err, domain.ErrVerificationTargetAlreadyVerified) {
t.Fatalf("err = %v, want ErrVerificationTargetAlreadyVerified", err)
}
}
func TestStartDraftRejectsRestrictedTarget(t *testing.T) {
t.Run("scam channel", func(t *testing.T) {
fx := newFixture(t)
channel := fx.dir.channels[channelID]
channel.Scam = true
fx.dir.channels[channelID] = channel
if _, _, err := fx.svc.StartDraft(context.Background(), channelRequest()); !errors.Is(err, domain.ErrVerificationTargetRestricted) {
t.Fatalf("err = %v, want ErrVerificationTargetRestricted", err)
}
})
t.Run("fake channel", func(t *testing.T) {
fx := newFixture(t)
channel := fx.dir.channels[channelID]
channel.Fake = true
fx.dir.channels[channelID] = channel
if _, _, err := fx.svc.StartDraft(context.Background(), channelRequest()); !errors.Is(err, domain.ErrVerificationTargetRestricted) {
t.Fatalf("err = %v, want ErrVerificationTargetRestricted", err)
}
})
t.Run("deleted bot", func(t *testing.T) {
fx := newFixture(t)
bot := fx.dir.users[botID]
bot.Deleted = true
fx.dir.users[botID] = bot
if _, _, err := fx.svc.StartDraft(context.Background(), botRequest()); !errors.Is(err, domain.ErrVerificationTargetRestricted) {
t.Fatalf("err = %v, want ErrVerificationTargetRestricted", err)
}
})
t.Run("frozen bot", func(t *testing.T) {
fx := newFixture(t)
fx.freezes.frozen[botID] = true
if _, _, err := fx.svc.StartDraft(context.Background(), botRequest()); !errors.Is(err, domain.ErrVerificationTargetRestricted) {
t.Fatalf("err = %v, want ErrVerificationTargetRestricted", err)
}
})
}
func TestStartDraftRejectsSystemTarget(t *testing.T) {
fx := newFixture(t)
fx.dir.ownedBots[applicantID] = append(fx.dir.ownedBots[applicantID], domain.VerifyBotUserID)
req := botRequest()
req.TargetID = domain.VerifyBotUserID
if _, _, err := fx.svc.StartDraft(context.Background(), req); !errors.Is(err, domain.ErrVerificationTargetSystem) {
t.Fatalf("err = %v, want ErrVerificationTargetSystem", err)
}
}
func TestStartDraftRejectsSecondApplicationForTarget(t *testing.T) {
fx := newFixture(t)
// Another applicant already holds the target's single active slot.
fx.st.put(domain.VerificationApplication{
ApplicantUserID: strangerID,
TargetType: domain.VerificationTargetChannel,
TargetID: channelID,
Status: domain.VerificationStatusSubmitted,
Version: 1,
})
if _, _, err := fx.svc.StartDraft(context.Background(), channelRequest()); !errors.Is(err, domain.ErrVerificationApplicationExists) {
t.Fatalf("err = %v, want ErrVerificationApplicationExists", err)
}
}
func TestStartDraftEnforcesRejectCooldown(t *testing.T) {
fx := newFixture(t, WithRejectCooldown(720*time.Hour))
fx.st.rejections[targetKey(domain.VerificationTargetChannel, channelID)] = domain.VerificationApplication{
ID: 99,
ApplicantUserID: applicantID,
TargetType: domain.VerificationTargetChannel,
TargetID: channelID,
Status: domain.VerificationStatusRejected,
ReviewedAt: fx.now.Add(-24 * time.Hour),
}
if _, _, err := fx.svc.StartDraft(context.Background(), channelRequest()); !errors.Is(err, domain.ErrVerificationCooldown) {
t.Fatalf("err = %v, want ErrVerificationCooldown", err)
}
// Past the cooldown the same pair is accepted again.
fx.now = fx.now.Add(721 * time.Hour)
if _, created, err := fx.svc.StartDraft(context.Background(), channelRequest()); err != nil || !created {
t.Fatalf("StartDraft after cooldown = %v, created=%v", err, created)
}
}
func TestStartDraftRateLimitsCreation(t *testing.T) {
fx := newFixture(t)
fx.limiter.budget = 0
if _, _, err := fx.svc.StartDraft(context.Background(), channelRequest()); !errors.Is(err, domain.ErrVerificationRateLimited) {
t.Fatalf("err = %v, want ErrVerificationRateLimited", err)
}
if len(fx.st.createRequests) != 0 {
t.Fatalf("store was written despite the rate limit: %d creates", len(fx.st.createRequests))
}
}
func TestStartDraftDoesNotSpendBudgetOnRefusedTarget(t *testing.T) {
fx := newFixture(t)
req := botRequest()
req.ApplicantUserID = strangerID
if _, _, err := fx.svc.StartDraft(context.Background(), req); !errors.Is(err, domain.ErrVerificationNotOwner) {
t.Fatalf("err = %v, want ErrVerificationNotOwner", err)
}
if fx.limiter.calls != 0 {
t.Fatalf("limiter calls = %d, want 0: probing must not cost budget", fx.limiter.calls)
}
}
func TestStartDraftEnforcesActiveApplicationCap(t *testing.T) {
fx := newFixture(t, WithMaxActivePerUser(1))
fx.st.put(domain.VerificationApplication{
ApplicantUserID: applicantID,
TargetType: domain.VerificationTargetSupergroup,
TargetID: groupID,
Status: domain.VerificationStatusSubmitted,
Version: 1,
})
if _, _, err := fx.svc.StartDraft(context.Background(), channelRequest()); !errors.Is(err, domain.ErrVerificationRateLimited) {
t.Fatalf("err = %v, want ErrVerificationRateLimited", err)
}
}
func TestStartDraftResumesExistingDraftWithoutBudget(t *testing.T) {
fx := newFixture(t)
ctx := context.Background()
first, created, err := fx.svc.StartDraft(ctx, channelRequest())
if err != nil || !created {
t.Fatalf("first StartDraft = %v created=%v", err, created)
}
spent := fx.limiter.calls
again, created, err := fx.svc.StartDraft(ctx, channelRequest())
if err != nil {
t.Fatalf("resume StartDraft: %v", err)
}
if created {
t.Fatal("created = true on resume, want false")
}
if again.ID != first.ID {
t.Fatalf("resumed application %d, want %d", again.ID, first.ID)
}
if fx.limiter.calls != spent {
t.Fatalf("limiter calls = %d, want %d: resuming must not cost budget", fx.limiter.calls, spent)
}
}
func TestStartDraftRejectsPrivateLink(t *testing.T) {
// Link validation is the domain's; the service must call it and never fetch.
fx := newFixture(t)
req := channelRequest()
req.Draft.OfficialWebsite = "http://169.254.169.254/latest/meta-data/"
if _, _, err := fx.svc.StartDraft(context.Background(), req); !errors.Is(err, domain.ErrVerificationURLInvalid) {
t.Fatalf("err = %v, want ErrVerificationURLInvalid", err)
}
}
func TestSubmitRequiresCompletePayload(t *testing.T) {
fx := newFixture(t)
ctx := context.Background()
req := channelRequest()
req.Draft.PressLinks = []string{"https://press.example.org/only-one"}
app, _, err := fx.svc.StartDraft(ctx, req)
if err != nil {
t.Fatalf("StartDraft: %v", err)
}
if _, err := fx.svc.Submit(ctx, applicantID, app.ID, app.Version); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("err = %v, want ErrVerificationApplicationInvalid", err)
}
}
func TestSubmitRechecksTargetState(t *testing.T) {
fx := newFixture(t)
ctx := context.Background()
app, _, err := fx.svc.StartDraft(ctx, channelRequest())
if err != nil {
t.Fatalf("StartDraft: %v", err)
}
channel := fx.dir.channels[channelID]
channel.Scam = true
fx.dir.channels[channelID] = channel
if _, err := fx.svc.Submit(ctx, applicantID, app.ID, app.Version); !errors.Is(err, domain.ErrVerificationTargetRestricted) {
t.Fatalf("err = %v, want ErrVerificationTargetRestricted", err)
}
}
func TestApplicantPathsScopeByApplicant(t *testing.T) {
fx := newFixture(t)
ctx := context.Background()
app, _, err := fx.svc.StartDraft(ctx, channelRequest())
if err != nil {
t.Fatalf("StartDraft: %v", err)
}
if _, err := fx.svc.Submit(ctx, strangerID, app.ID, app.Version); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("err = %v, want ErrVerificationApplicationNotFound", err)
}
if _, err := fx.svc.SaveDraft(ctx, strangerID, app.ID, app.Version, validDraft()); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("SaveDraft err = %v, want ErrVerificationApplicationNotFound", err)
}
if _, err := fx.svc.SaveDraft(ctx, applicantID, app.ID, app.Version+7, validDraft()); !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("SaveDraft err = %v, want ErrVerificationVersionConflict", err)
}
}
// ---------------------------------------------------------------------------
// Reviewer side
// ---------------------------------------------------------------------------
func decision(app domain.VerificationApplication) domain.VerificationDecision {
return domain.VerificationDecision{
ApplicationID: app.ID,
Version: app.Version,
Reviewer: "admin@example.org",
}
}
func TestApproveSetsFlagAndNotifiesExactlyOnce(t *testing.T) {
fx := newFixture(t)
ctx := context.Background()
app := fx.submitted(t, channelRequest())
stored, changed, err := fx.svc.Approve(ctx, decision(app))
if err != nil {
t.Fatalf("Approve: %v", err)
}
if !changed {
t.Fatal("changed = false, want true")
}
if stored.Status != domain.VerificationStatusApproved {
t.Fatalf("status = %s", stored.Status)
}
if fx.verifier.calls() != 1 || len(fx.verifier.channelCalls) != 1 || fx.verifier.channelCalls[0] != channelID {
t.Fatalf("verifier calls = %+v/%+v", fx.verifier.userCalls, fx.verifier.channelCalls)
}
if len(fx.verifier.values) != 1 || !fx.verifier.values[0] {
t.Fatalf("verifier values = %v, want [true]", fx.verifier.values)
}
if len(fx.peers.peers) != 1 || fx.peers.peers[0] != (domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) {
t.Fatalf("peer notifications = %+v", fx.peers.peers)
}
}
func TestApproveRepeatIsIdempotent(t *testing.T) {
fx := newFixture(t)
ctx := context.Background()
app := fx.submitted(t, channelRequest())
approved, _, err := fx.svc.Approve(ctx, decision(app))
if err != nil {
t.Fatalf("Approve: %v", err)
}
verifierCalls, pushCalls, decideCalls := fx.verifier.calls(), len(fx.peers.peers), fx.st.decideCalls
stored, changed, err := fx.svc.Approve(ctx, decision(approved))
if err != nil {
t.Fatalf("second Approve: %v", err)
}
if changed {
t.Fatal("changed = true on repeat, want false")
}
if stored.ID != approved.ID {
t.Fatalf("stored id = %d, want %d", stored.ID, approved.ID)
}
if fx.verifier.calls() != verifierCalls {
t.Fatalf("verifier called again: %d -> %d", verifierCalls, fx.verifier.calls())
}
if len(fx.peers.peers) != pushCalls {
t.Fatalf("peer notifier called again: %d -> %d", pushCalls, len(fx.peers.peers))
}
if fx.st.decideCalls != decideCalls {
t.Fatalf("store decide called again: %d -> %d", decideCalls, fx.st.decideCalls)
}
}
func TestApproveRefusesTargetThatBecameScam(t *testing.T) {
fx := newFixture(t)
ctx := context.Background()
app := fx.submitted(t, channelRequest())
// The target is flagged between submission and review.
channel := fx.dir.channels[channelID]
channel.Scam = true
fx.dir.channels[channelID] = channel
if _, _, err := fx.svc.Approve(ctx, decision(app)); !errors.Is(err, domain.ErrVerificationTargetRestricted) {
t.Fatalf("err = %v, want ErrVerificationTargetRestricted", err)
}
if fx.verifier.calls() != 0 || len(fx.peers.peers) != 0 || fx.st.decideCalls != 0 {
t.Fatalf("approve reached the write path: verifier=%d push=%d decide=%d",
fx.verifier.calls(), len(fx.peers.peers), fx.st.decideCalls)
}
}
func TestApproveRefusesTargetVerifiedByAnotherRoute(t *testing.T) {
fx := newFixture(t)
ctx := context.Background()
app := fx.submitted(t, channelRequest())
channel := fx.dir.channels[channelID]
channel.Verified = true
fx.dir.channels[channelID] = channel
if _, _, err := fx.svc.Approve(ctx, decision(app)); !errors.Is(err, domain.ErrVerificationTargetAlreadyVerified) {
t.Fatalf("err = %v, want ErrVerificationTargetAlreadyVerified", err)
}
}
func TestApproveRefusesTargetTheApplicantLost(t *testing.T) {
fx := newFixture(t)
ctx := context.Background()
app := fx.submitted(t, channelRequest())
fx.dir.adminedChannels[applicantID] = nil
if _, _, err := fx.svc.Approve(ctx, decision(app)); !errors.Is(err, domain.ErrVerificationNotOwner) {
t.Fatalf("err = %v, want ErrVerificationNotOwner", err)
}
}
func TestApproveRefusesStaleVersion(t *testing.T) {
fx := newFixture(t)
app := fx.submitted(t, channelRequest())
stale := decision(app)
stale.Version = app.Version - 1
if _, _, err := fx.svc.Approve(context.Background(), stale); !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("err = %v, want ErrVerificationVersionConflict", err)
}
if fx.verifier.calls() != 0 {
t.Fatalf("verifier called on a stale decision")
}
}
func TestApproveRollsBackWhenFlagWriteFails(t *testing.T) {
fx := newFixture(t)
fx.verifier.err = errors.New("peer store down")
app := fx.submitted(t, channelRequest())
if _, _, err := fx.svc.Approve(context.Background(), decision(app)); err == nil {
t.Fatal("Approve succeeded despite a failing flag write")
}
if len(fx.peers.peers) != 0 {
t.Fatalf("pushed an update for a rolled-back decision: %+v", fx.peers.peers)
}
stored, err := fx.svc.Application(context.Background(), app.ID)
if err != nil {
t.Fatalf("Application: %v", err)
}
if stored.Status == domain.VerificationStatusApproved {
t.Fatal("application approved even though the flag write failed")
}
}
func TestApproveTolerantOfPushFailure(t *testing.T) {
fx := newFixture(t)
fx.peers.err = errors.New("router offline")
app := fx.submitted(t, channelRequest())
stored, changed, err := fx.svc.Approve(context.Background(), decision(app))
if err != nil {
t.Fatalf("Approve: %v", err)
}
if !changed || stored.Status != domain.VerificationStatusApproved {
t.Fatalf("changed=%v status=%s, want a landed decision", changed, stored.Status)
}
}
func TestRejectRequiresReason(t *testing.T) {
fx := newFixture(t)
app := fx.submitted(t, channelRequest())
if _, _, err := fx.svc.Reject(context.Background(), decision(app)); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("err = %v, want ErrVerificationReasonRequired", err)
}
if fx.st.decideCalls != 0 {
t.Fatalf("store decide called for a reasonless rejection")
}
}
func TestRejectRecordsReasonAndTouchesNoPeer(t *testing.T) {
fx := newFixture(t)
app := fx.submitted(t, channelRequest())
dec := decision(app)
dec.Reason = "no independent coverage"
stored, changed, err := fx.svc.Reject(context.Background(), dec)
if err != nil || !changed {
t.Fatalf("Reject = %v changed=%v", err, changed)
}
if stored.Status != domain.VerificationStatusRejected || stored.DecisionReason != dec.Reason {
t.Fatalf("stored = %s/%q", stored.Status, stored.DecisionReason)
}
if fx.verifier.calls() != 0 || len(fx.peers.peers) != 0 {
t.Fatal("a rejection must not touch the peer record")
}
}
func TestClaimMovesApplicationIntoReview(t *testing.T) {
fx := newFixture(t)
app := fx.submitted(t, channelRequest())
claimed, err := fx.svc.Claim(context.Background(), decision(app))
if err != nil {
t.Fatalf("Claim: %v", err)
}
if claimed.Status != domain.VerificationStatusInReview || claimed.ReviewerAdminID != "admin@example.org" {
t.Fatalf("claimed = %s/%q", claimed.Status, claimed.ReviewerAdminID)
}
if _, err := fx.svc.Claim(context.Background(), domain.VerificationDecision{ApplicationID: app.ID, Version: app.Version}); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("Claim without reviewer err = %v, want ErrVerificationApplicationInvalid", err)
}
}
func TestRevokeClearsFlagAndRequiresReason(t *testing.T) {
fx := newFixture(t)
ctx := context.Background()
app := fx.submitted(t, channelRequest())
if _, _, err := fx.svc.Approve(ctx, decision(app)); err != nil {
t.Fatalf("Approve: %v", err)
}
if _, _, err := fx.svc.Revoke(ctx, domain.VerificationRevocation{
TargetType: domain.VerificationTargetChannel,
TargetID: channelID,
Reviewer: "admin@example.org",
}); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("err = %v, want ErrVerificationReasonRequired", err)
}
stored, changed, err := fx.svc.Revoke(ctx, domain.VerificationRevocation{
TargetType: domain.VerificationTargetChannel,
TargetID: channelID,
Reviewer: "admin@example.org",
Reason: "impersonation report upheld",
})
if err != nil || !changed {
t.Fatalf("Revoke = %v changed=%v", err, changed)
}
// The application stays approved: it is history.
if stored.Status != domain.VerificationStatusApproved {
t.Fatalf("application status = %s, want approved history", stored.Status)
}
if len(fx.verifier.values) != 2 || fx.verifier.values[1] {
t.Fatalf("verifier values = %v, want the second write to clear the flag", fx.verifier.values)
}
if len(fx.peers.peers) != 2 {
t.Fatalf("peer notifications = %d, want one per committed change", len(fx.peers.peers))
}
}
func TestRevokeRefusesSystemTarget(t *testing.T) {
fx := newFixture(t)
_, _, err := fx.svc.Revoke(context.Background(), domain.VerificationRevocation{
TargetType: domain.VerificationTargetBot,
TargetID: domain.VerifyBotUserID,
Reviewer: "admin@example.org",
Reason: "testing",
})
if !errors.Is(err, domain.ErrVerificationTargetSystem) {
t.Fatalf("err = %v, want ErrVerificationTargetSystem", err)
}
if fx.st.revokeCalls != 0 {
t.Fatal("store revoke called for a system target")
}
}
// ---------------------------------------------------------------------------
// Target picker and snapshot
// ---------------------------------------------------------------------------
func TestEligibleTargetsExplainsIneligibleCandidates(t *testing.T) {
fx := newFixture(t)
channel := fx.dir.channels[channelID]
channel.Verified = true
fx.dir.channels[channelID] = channel
targets, err := fx.svc.EligibleTargets(context.Background(), applicantID)
if err != nil {
t.Fatalf("EligibleTargets: %v", err)
}
if len(targets) != 3 {
t.Fatalf("targets = %d (%+v), want bot + channel + supergroup", len(targets), targets)
}
byID := map[int64]domain.VerificationTarget{}
for _, target := range targets {
byID[target.ID] = target
}
if got := byID[botID]; !got.Eligible || got.Type != domain.VerificationTargetBot {
t.Fatalf("bot target = %+v, want eligible bot", got)
}
if got := byID[groupID]; !got.Eligible || got.Type != domain.VerificationTargetSupergroup {
t.Fatalf("group target = %+v, want eligible supergroup", got)
}
if got := byID[channelID]; got.Eligible || got.Reason != domain.ErrVerificationTargetAlreadyVerified.Error() {
t.Fatalf("channel target = %+v, want ineligible with the already-verified reason", got)
}
}
func TestEligibleTargetsOffersOwnAccountOnlyWhenEnabled(t *testing.T) {
fx := newFixture(t)
targets, err := fx.svc.EligibleTargets(context.Background(), applicantID)
if err != nil {
t.Fatalf("EligibleTargets: %v", err)
}
for _, target := range targets {
if target.Type == domain.VerificationTargetUser {
t.Fatalf("user target offered while disabled: %+v", target)
}
}
fx = newFixture(t, WithAllowUserTargets(true))
targets, err = fx.svc.EligibleTargets(context.Background(), applicantID)
if err != nil {
t.Fatalf("EligibleTargets: %v", err)
}
found := false
for _, target := range targets {
if target.Type == domain.VerificationTargetUser && target.ID == applicantID && target.Eligible {
found = true
}
}
if !found {
t.Fatalf("own account not offered with user targets enabled: %+v", targets)
}
}
func TestEligibleTargetsReportsActiveApplicationAndCap(t *testing.T) {
fx := newFixture(t)
fx.st.put(domain.VerificationApplication{
ApplicantUserID: strangerID,
TargetType: domain.VerificationTargetChannel,
TargetID: channelID,
Status: domain.VerificationStatusSubmitted,
Version: 1,
})
targets, err := fx.svc.EligibleTargets(context.Background(), applicantID)
if err != nil {
t.Fatalf("EligibleTargets: %v", err)
}
for _, target := range targets {
if target.ID != channelID {
continue
}
if target.Eligible || target.Reason != domain.ErrVerificationApplicationExists.Error() {
t.Fatalf("channel target = %+v, want the already-active reason", target)
}
}
capped := newFixture(t, WithMaxActivePerUser(1))
capped.st.put(domain.VerificationApplication{
ApplicantUserID: applicantID,
TargetType: domain.VerificationTargetSupergroup,
TargetID: groupID,
Status: domain.VerificationStatusInReview,
Version: 1,
})
targets, err = capped.svc.EligibleTargets(context.Background(), applicantID)
if err != nil {
t.Fatalf("EligibleTargets: %v", err)
}
for _, target := range targets {
if target.Eligible {
t.Fatalf("target %+v eligible while the applicant is at the cap", target)
}
if target.Reason != domain.ErrVerificationRateLimited.Error() {
t.Fatalf("target %+v reason = %q, want the cap reason", target, target.Reason)
}
}
}
func TestTargetSnapshotReportsCurrentState(t *testing.T) {
fx := newFixture(t)
snapshot, err := fx.svc.TargetSnapshot(context.Background(), domain.VerificationTargetChannel, channelID)
if err != nil {
t.Fatalf("TargetSnapshot: %v", err)
}
if !snapshot.Eligible || snapshot.Username != "mychannel" || snapshot.Title != "My Channel" || snapshot.AccessHash != 33 {
t.Fatalf("snapshot = %+v", snapshot)
}
channel := fx.dir.channels[channelID]
channel.Scam = true
fx.dir.channels[channelID] = channel
snapshot, err = fx.svc.TargetSnapshot(context.Background(), domain.VerificationTargetChannel, channelID)
if err != nil {
t.Fatalf("TargetSnapshot: %v", err)
}
if snapshot.Eligible || snapshot.Reason != domain.ErrVerificationTargetRestricted.Error() {
t.Fatalf("snapshot = %+v, want ineligible/restricted", snapshot)
}
}
// ---------------------------------------------------------------------------
// Notification delivery
// ---------------------------------------------------------------------------
func TestRunNotificationCycleDeliversPending(t *testing.T) {
fx := newFixture(t)
ctx := context.Background()
app := fx.submitted(t, channelRequest())
if _, _, err := fx.svc.Approve(ctx, decision(app)); err != nil {
t.Fatalf("Approve: %v", err)
}
delivered, err := fx.svc.RunNotificationCycle(ctx, 10)
if err != nil {
t.Fatalf("RunNotificationCycle: %v", err)
}
if delivered != 1 {
t.Fatalf("delivered = %d, want 1", delivered)
}
if len(fx.notifier.sent) != 1 || fx.notifier.sent[0].recipient != applicantID || fx.notifier.sent[0].kind != NoticeKindApproved {
t.Fatalf("sent = %+v", fx.notifier.sent)
}
if len(fx.st.deliveredIDs) != 1 {
t.Fatalf("delivered ids = %v", fx.st.deliveredIDs)
}
// A second cycle has nothing left to do.
delivered, err = fx.svc.RunNotificationCycle(ctx, 10)
if err != nil || delivered != 0 {
t.Fatalf("second cycle = %d/%v, want 0/nil", delivered, err)
}
}
func TestRunNotificationCycleMarksFailureAndRetries(t *testing.T) {
fx := newFixture(t)
ctx := context.Background()
app := fx.submitted(t, channelRequest())
if _, _, err := fx.svc.Approve(ctx, decision(app)); err != nil {
t.Fatalf("Approve: %v", err)
}
fx.notifier.err = errors.New("bot blocked by the user")
delivered, err := fx.svc.RunNotificationCycle(ctx, 10)
if err != nil {
t.Fatalf("RunNotificationCycle: %v", err)
}
if delivered != 0 {
t.Fatalf("delivered = %d, want 0", delivered)
}
if len(fx.st.failedIDs) != 1 || len(fx.st.deliveredIDs) != 0 {
t.Fatalf("failed = %v delivered = %v", fx.st.failedIDs, fx.st.deliveredIDs)
}
if !strings.Contains(fx.st.failedReasons[0], "bot blocked") {
t.Fatalf("failure reason = %q", fx.st.failedReasons[0])
}
// The row stays queued, so the next cycle retries it and can succeed.
fx.notifier.err = nil
delivered, err = fx.svc.RunNotificationCycle(ctx, 10)
if err != nil {
t.Fatalf("retry cycle: %v", err)
}
if delivered != 1 || len(fx.st.deliveredIDs) != 1 {
t.Fatalf("retry delivered = %d, ids = %v", delivered, fx.st.deliveredIDs)
}
}
func TestRunNotificationCycleFailsRowWithoutRecipient(t *testing.T) {
fx := newFixture(t)
fx.st.pending = append(fx.st.pending, store.VerificationNotification{ID: 7, ApplicationID: 3, Kind: NoticeKindApproved})
delivered, err := fx.svc.RunNotificationCycle(context.Background(), 10)
if err != nil {
t.Fatalf("RunNotificationCycle: %v", err)
}
if delivered != 0 || len(fx.st.failedIDs) != 1 || fx.st.failedIDs[0] != 7 {
t.Fatalf("delivered=%d failed=%v", delivered, fx.st.failedIDs)
}
if len(fx.notifier.sent) != 0 {
t.Fatalf("sent a notice with no recipient: %+v", fx.notifier.sent)
}
}
func TestRunNotificationCycleWithoutNotifierReportsConfigError(t *testing.T) {
fx := newFixture(t)
svc := NewService(WithStore(fx.st), WithPeerDirectory(fx.dir))
if _, err := svc.RunNotificationCycle(context.Background(), 10); err == nil {
t.Fatal("RunNotificationCycle succeeded without an applicant notifier")
}
}
// ---------------------------------------------------------------------------
// Feature switch and wiring guards
// ---------------------------------------------------------------------------
func TestDisabledServiceRefusesEverything(t *testing.T) {
fx := newFixture(t, WithEnabled(false))
ctx := context.Background()
if fx.svc.Enabled() || fx.svc.Ready() {
t.Fatal("service reports enabled while disabled")
}
if _, err := fx.svc.EligibleTargets(ctx, applicantID); !errors.Is(err, ErrDisabled) {
t.Fatalf("EligibleTargets err = %v", err)
}
if _, _, err := fx.svc.StartDraft(ctx, channelRequest()); !errors.Is(err, ErrDisabled) {
t.Fatalf("StartDraft err = %v", err)
}
if _, _, err := fx.svc.Approve(ctx, domain.VerificationDecision{ApplicationID: 1, Version: 1, Reviewer: "a"}); !errors.Is(err, ErrDisabled) {
t.Fatalf("Approve err = %v", err)
}
if _, err := fx.svc.TargetSnapshot(ctx, domain.VerificationTargetChannel, channelID); !errors.Is(err, ErrDisabled) {
t.Fatalf("TargetSnapshot err = %v", err)
}
// The worker cycle is a cadence, not a user action: it stays silent.
if delivered, err := fx.svc.RunNotificationCycle(ctx, 10); err != nil || delivered != 0 {
t.Fatalf("RunNotificationCycle = %d/%v, want 0/nil", delivered, err)
}
}
func TestApproveWithoutVerifierRefuses(t *testing.T) {
fx := newFixture(t)
svc := NewService(WithStore(fx.st), WithPeerDirectory(fx.dir), WithRateLimiter(fx.limiter, 3, time.Hour))
app := fx.submitted(t, channelRequest())
if _, _, err := svc.Approve(context.Background(), decision(app)); err == nil {
t.Fatal("Approve succeeded without a peer verifier")
}
if fx.st.decideCalls != 0 {
t.Fatal("store decide called without a peer verifier")
}
}
func TestNewServiceDefaults(t *testing.T) {
svc := NewService()
if !svc.Enabled() {
t.Fatal("verification must default to enabled")
}
if svc.AllowsUserTargets() {
t.Fatal("user targets must default to off")
}
if svc.rejectCooldown != defaultRejectCooldown || svc.maxActivePerUser != defaultMaxActivePerUser {
t.Fatalf("cooldown=%v maxActive=%d", svc.rejectCooldown, svc.maxActivePerUser)
}
if svc.Ready() {
t.Fatal("a store-less service must not report ready")
}
if _, err := svc.Counts(context.Background()); err == nil {
t.Fatal("Counts succeeded without a store")
}
}
func TestNotificationWorkerStopsWhenNotReady(t *testing.T) {
// Run must return immediately instead of ticking over a no-op.
worker := NewNotificationWorker(NewService(WithEnabled(false)), nil, 0, 0)
done := make(chan struct{})
go func() {
worker.Run(context.Background())
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("worker did not exit for a disabled service")
}
}