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>
1416 lines
48 KiB
Go
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")
|
|
}
|
|
}
|