Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877
This commit is contained in:
commit
ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions
994
internal/admin/botverification.go
Normal file
994
internal/admin/botverification.go
Normal file
|
|
@ -0,0 +1,994 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Third-party bot verification for the operator (core.telegram.org/api/bots/verification).
|
||||
//
|
||||
// This is NOT the official platform badge. The official review lives in
|
||||
// verification.go behind the verification.* permissions and writes
|
||||
// verification_applications / the peer's `verified` flag; this file drives the
|
||||
// third-party mechanism, whose state is verification_icons,
|
||||
// bot_verifier_settings, custom_verifications and custom_verification_requests,
|
||||
// behind the botverification.* permissions. Neither section reads the other's
|
||||
// tables, and a third-party verifier must never be able to mint a platform
|
||||
// checkmark -- so nothing here ever touches domain.User.Verified.
|
||||
//
|
||||
// The operator surface has two halves, and they carry different rights:
|
||||
//
|
||||
// - Configuration (botverification.manage): who is a verifier at all, which
|
||||
// icons exist, and stripping a granted mark. These are the actions that
|
||||
// decide how much a mark is worth, so they are deliberately not implied by
|
||||
// the right to work the queue.
|
||||
// - Review (botverification.review): deciding the applications filed with a
|
||||
// verifier bot.
|
||||
//
|
||||
// Every mutation goes through runCommand, so a decision is journalled in
|
||||
// admin_commands / admin_audit_logs, replayable by command id and rehearsable
|
||||
// with a dry run. A dry run never mutates and never predicts an outcome stricter
|
||||
// than the real command's: where a check can only run inside the use-case layer
|
||||
// (the icon's custom emoji document is resolved against the document store) the
|
||||
// dry run says so in its details instead of guessing.
|
||||
|
||||
// BotVerificationService is the operator-facing slice of the third-party
|
||||
// verification use cases. It is the exact method set *app/botverification.Service
|
||||
// exposes for this surface, so the admin layer never reaches into the store.
|
||||
type BotVerificationService interface {
|
||||
// Icon catalogue.
|
||||
Icons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error)
|
||||
UpsertIcon(ctx context.Context, icon domain.VerificationIcon) (domain.VerificationIcon, error)
|
||||
SetIconActive(ctx context.Context, iconID int64, active bool) (domain.VerificationIcon, error)
|
||||
|
||||
// Verifier status.
|
||||
Verifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error)
|
||||
VerifierSettings(ctx context.Context, botID int64) (domain.BotVerifierSettings, error)
|
||||
GrantVerifier(ctx context.Context, settings domain.BotVerifierSettings) (domain.BotVerifierSettings, error)
|
||||
SetVerifierEnabled(ctx context.Context, botID int64, enabled bool) (domain.BotVerifierSettings, error)
|
||||
RevokeVerifier(ctx context.Context, botID int64) (bool, error)
|
||||
|
||||
// Granted marks.
|
||||
Marks(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error)
|
||||
RevokeMark(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error)
|
||||
|
||||
// Application queue.
|
||||
Requests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error)
|
||||
Request(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error)
|
||||
RequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error)
|
||||
Approve(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error)
|
||||
Reject(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error)
|
||||
RevokeRequest(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error)
|
||||
}
|
||||
|
||||
// botVerificationCatalogueScan bounds the catalogue page a dry run reads to
|
||||
// pre-check an icon. The catalogue is operator-curated and small; the check is
|
||||
// advisory precisely because this bound may not cover it (see grantIconPreflight).
|
||||
const botVerificationCatalogueScan = 200
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command payloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GrantBotVerifierRequest grants or reconfigures a bot's verifier status.
|
||||
//
|
||||
// Version is the optimistic-locking token: 0 means "this is a new grant" and a
|
||||
// positive value means "update the row I read", so two operators editing the same
|
||||
// verifier cannot clobber each other. Enabled is deliberately absent -- the kill
|
||||
// switch is its own action, so reconfiguring a switched-off verifier does not
|
||||
// quietly switch it back on.
|
||||
type GrantBotVerifierRequest struct {
|
||||
CommandMeta
|
||||
BotID int64 `json:"bot_id"`
|
||||
IconDocumentID int64 `json:"icon_document_id"`
|
||||
CompanyName string `json:"company_name"`
|
||||
DefaultDescription string `json:"default_description"`
|
||||
CanModifyCustomDescription bool `json:"can_modify_custom_description"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
// SetBotVerifierEnabledRequest flips the operator kill switch. The verifier keeps
|
||||
// its row and the marks it granted, so flipping it back restores exactly what was
|
||||
// there.
|
||||
type SetBotVerifierEnabledRequest struct {
|
||||
CommandMeta
|
||||
BotID int64 `json:"bot_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// RevokeBotVerifierRequest removes verifier status entirely. Its marks cascade
|
||||
// away with it in the store, because a mark whose verifier no longer exists has
|
||||
// nothing to render.
|
||||
type RevokeBotVerifierRequest struct {
|
||||
CommandMeta
|
||||
BotID int64 `json:"bot_id"`
|
||||
}
|
||||
|
||||
// UpsertVerificationIconRequest adds or updates a catalogue entry, keyed by custom
|
||||
// emoji document id. OwnerBotID is zero for a shared entry and a bot id when the
|
||||
// operator reserves the icon for one verifier.
|
||||
type UpsertVerificationIconRequest struct {
|
||||
CommandMeta
|
||||
DocumentID int64 `json:"document_id"`
|
||||
Name string `json:"name"`
|
||||
OwnerBotID int64 `json:"owner_bot_id,omitempty"`
|
||||
}
|
||||
|
||||
// SetVerificationIconActiveRequest retires or restores a catalogue entry. Marks
|
||||
// already granted with it keep rendering -- the icon id is denormalised onto the
|
||||
// mark -- so retiring an entry stops new grants without blanking existing badges.
|
||||
type SetVerificationIconActiveRequest struct {
|
||||
CommandMeta
|
||||
IconID int64 `json:"icon_id"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// RevokeCustomVerificationRequest strips one verifier's mark from a peer on the
|
||||
// operator's behalf. It addresses the (verifier, peer) pair rather than an
|
||||
// application, because the operator may have to strip a mark that no application
|
||||
// ever produced.
|
||||
type RevokeCustomVerificationRequest struct {
|
||||
CommandMeta
|
||||
VerifierBotID int64 `json:"verifier_bot_id"`
|
||||
PeerType domain.PeerType `json:"peer_type"`
|
||||
PeerID int64 `json:"peer_id"`
|
||||
}
|
||||
|
||||
// ApproveBotVerificationRequest grants the mark an application asked for. The
|
||||
// mark and the approved status commit together in the use-case layer, so an
|
||||
// approved application without its mark is not a reachable state.
|
||||
type ApproveBotVerificationRequest struct {
|
||||
CommandMeta
|
||||
RequestID int64 `json:"request_id"`
|
||||
Version int64 `json:"version"`
|
||||
// InternalNote is operator-only: it is journalled and stored on the
|
||||
// application, and it is never part of what the applicant is told.
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// RejectBotVerificationRequest closes an application against the applicant.
|
||||
// Reason is mandatory: it is the text the applicant receives.
|
||||
type RejectBotVerificationRequest struct {
|
||||
CommandMeta
|
||||
RequestID int64 `json:"request_id"`
|
||||
Version int64 `json:"version"`
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// RevokeBotVerificationRequest withdraws a granted mark through the application it
|
||||
// came from. The application stays as history -- revoked is reachable only from
|
||||
// approved, so it keeps meaning "was verified once". Reason is mandatory for the
|
||||
// same reason it is on rejection.
|
||||
type RevokeBotVerificationRequest struct {
|
||||
CommandMeta
|
||||
RequestID int64 `json:"request_id"`
|
||||
Version int64 `json:"version"`
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BotVerifiers lists verifier bots.
|
||||
func (s *Service) BotVerifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return nil, errBotVerificationNotConfigured
|
||||
}
|
||||
return s.botVerification.Verifiers(ctx, enabledOnly, limit)
|
||||
}
|
||||
|
||||
// BotVerifier resolves one verifier's status, enabled or not: the panel needs the
|
||||
// disabled row too, to render the kill switch.
|
||||
func (s *Service) BotVerifier(ctx context.Context, botID int64) (domain.BotVerifierSettings, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return domain.BotVerifierSettings{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if botID <= 0 {
|
||||
return domain.BotVerifierSettings{}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
return s.botVerification.VerifierSettings(ctx, botID)
|
||||
}
|
||||
|
||||
// VerificationIcons lists the icon catalogue, newest first.
|
||||
func (s *Service) VerificationIcons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return nil, errBotVerificationNotConfigured
|
||||
}
|
||||
return s.botVerification.Icons(ctx, activeOnly, limit)
|
||||
}
|
||||
|
||||
// CustomVerifications lists granted third-party marks with keyset paging.
|
||||
func (s *Service) CustomVerifications(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return nil, errBotVerificationNotConfigured
|
||||
}
|
||||
return s.botVerification.Marks(ctx, filter)
|
||||
}
|
||||
|
||||
// CustomVerificationRequests is the third-party review queue.
|
||||
func (s *Service) CustomVerificationRequests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return nil, errBotVerificationNotConfigured
|
||||
}
|
||||
return s.botVerification.Requests(ctx, filter)
|
||||
}
|
||||
|
||||
// CustomVerificationRequest resolves one application by identity.
|
||||
func (s *Service) CustomVerificationRequest(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return domain.CustomVerificationRequest{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if requestID <= 0 {
|
||||
return domain.CustomVerificationRequest{}, botVerificationCoded(domain.ErrCustomVerificationRequestNotFound)
|
||||
}
|
||||
return s.botVerification.Request(ctx, requestID)
|
||||
}
|
||||
|
||||
// CustomVerificationRequestCounts is the queue summary rendered above the list.
|
||||
func (s *Service) CustomVerificationRequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return nil, errBotVerificationNotConfigured
|
||||
}
|
||||
return s.botVerification.RequestCounts(ctx)
|
||||
}
|
||||
|
||||
// CustomVerificationMarkActive reports whether a (verifier, peer) pair currently
|
||||
// carries a mark. The request detail view needs it to tell "approved" apart from
|
||||
// "approved and since stripped by the operator".
|
||||
func (s *Service) CustomVerificationMarkActive(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return false, errBotVerificationNotConfigured
|
||||
}
|
||||
if verifierBotID <= 0 || peer.ID <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
marks, err := s.botVerification.Marks(ctx, domain.CustomVerificationFilter{
|
||||
VerifierBotID: verifierBotID,
|
||||
PeerType: peer.Type,
|
||||
PeerID: peer.ID,
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return false, botVerificationError(err)
|
||||
}
|
||||
return len(marks) > 0, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands: verifier status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GrantBotVerifier grants or reconfigures verifier status.
|
||||
//
|
||||
// The dry run predicts everything it can without writing: the payload shape, the
|
||||
// optimistic lock against the row as it is now, and -- when the icon is visible in
|
||||
// the catalogue page it reads -- that the icon is active and usable by this bot.
|
||||
// It deliberately does not fail an icon it cannot see: the use-case layer resolves
|
||||
// the icon's custom emoji document against the document store, and a dry run that
|
||||
// refused more than the real command would make a rehearsal useless.
|
||||
func (s *Service) GrantBotVerifier(ctx context.Context, req GrantBotVerifierRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if req.BotID <= 0 {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
if req.Version < 0 {
|
||||
return CommandResult{}, botVerifierInvalid("version must not be negative")
|
||||
}
|
||||
settings := domain.BotVerifierSettings{
|
||||
BotID: req.BotID,
|
||||
IconDocumentID: req.IconDocumentID,
|
||||
CompanyName: strings.TrimSpace(req.CompanyName),
|
||||
DefaultDescription: strings.TrimSpace(req.DefaultDescription),
|
||||
CanModifyCustomDescription: req.CanModifyCustomDescription,
|
||||
GrantedBy: strings.TrimSpace(req.Actor),
|
||||
GrantReason: strings.TrimSpace(req.Reason),
|
||||
Enabled: true,
|
||||
Version: req.Version,
|
||||
}
|
||||
if err := settings.Validate(); err != nil {
|
||||
return CommandResult{}, botVerificationError(err)
|
||||
}
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: req.BotID}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionGrantBotVerifier, req.BotID, target, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"bot_id": strconv.FormatInt(req.BotID, 10),
|
||||
"icon_document_id": strconv.FormatInt(req.IconDocumentID, 10),
|
||||
"company_name": settings.CompanyName,
|
||||
"can_modify_custom_description": req.CanModifyCustomDescription,
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
current, exists, err := s.botVerifierState(ctx, req.BotID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["created"] = !exists
|
||||
if exists {
|
||||
details["previous_version"] = strconv.FormatInt(current.Version, 10)
|
||||
details["previous_icon_document_id"] = strconv.FormatInt(current.IconDocumentID, 10)
|
||||
details["previous_enabled"] = current.Enabled
|
||||
// The kill switch is its own action, so reconfiguring a switched-off
|
||||
// verifier must not switch it back on.
|
||||
settings.Enabled = current.Enabled
|
||||
if req.Version != current.Version {
|
||||
return CommandResult{Details: details}, codedError(CodeCustomVerificationConflict, domain.ErrCustomVerificationVersionConflict)
|
||||
}
|
||||
} else if req.Version != 0 {
|
||||
// The operator is editing a row that is no longer there.
|
||||
return CommandResult{Details: details}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
details["enabled"] = settings.Enabled
|
||||
if err := s.grantIconPreflight(ctx, details, req.BotID, req.IconDocumentID); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if req.DryRun {
|
||||
message := "bot verifier grant validated"
|
||||
if exists {
|
||||
message = "bot verifier update validated"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
}
|
||||
stored, err := s.botVerification.GrantVerifier(ctx, settings)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["version"] = strconv.FormatInt(stored.Version, 10)
|
||||
details["enabled"] = stored.Enabled
|
||||
details["changed"] = true
|
||||
message := "bot verifier status granted"
|
||||
if exists {
|
||||
message = "bot verifier status updated"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetBotVerifierEnabled flips the operator kill switch.
|
||||
func (s *Service) SetBotVerifierEnabled(ctx context.Context, req SetBotVerifierEnabledRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if req.BotID <= 0 {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: req.BotID}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetBotVerifierEnabled, req.BotID, target, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"bot_id": strconv.FormatInt(req.BotID, 10),
|
||||
"enabled": req.Enabled,
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
current, exists, err := s.botVerifierState(ctx, req.BotID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if !exists {
|
||||
return CommandResult{Details: details}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
details["previous_enabled"] = current.Enabled
|
||||
details["previous_version"] = strconv.FormatInt(current.Version, 10)
|
||||
details["company_name"] = current.CompanyName
|
||||
// A no-op flip neither burns a version nor pushes an update, so the audit
|
||||
// trail does not claim a change that never happened.
|
||||
noop := current.Enabled == req.Enabled
|
||||
details["changed"] = !noop
|
||||
if req.DryRun {
|
||||
message := "bot verifier switch validated"
|
||||
if noop {
|
||||
message = "bot verifier switch validated; already in that state"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
}
|
||||
stored, err := s.botVerification.SetVerifierEnabled(ctx, req.BotID, req.Enabled)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["version"] = strconv.FormatInt(stored.Version, 10)
|
||||
details["enabled"] = stored.Enabled
|
||||
message := "bot verifier enabled"
|
||||
if !req.Enabled {
|
||||
message = "bot verifier disabled"
|
||||
}
|
||||
if noop {
|
||||
message = "bot verifier was already in that state"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeBotVerifier removes verifier status entirely.
|
||||
//
|
||||
// A missing row is not an error: the command answers changed=false, so a panel
|
||||
// retry after a lost response is harmless and the dry run never refuses what the
|
||||
// real command accepts.
|
||||
func (s *Service) RevokeBotVerifier(ctx context.Context, req RevokeBotVerifierRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if req.BotID <= 0 {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: req.BotID}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRevokeBotVerifier, req.BotID, target, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"bot_id": strconv.FormatInt(req.BotID, 10),
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
current, exists, err := s.botVerifierState(ctx, req.BotID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["present"] = exists
|
||||
if exists {
|
||||
details["previous_version"] = strconv.FormatInt(current.Version, 10)
|
||||
details["previous_enabled"] = current.Enabled
|
||||
details["company_name"] = current.CompanyName
|
||||
// The marks this verifier granted cascade away with the row, which is
|
||||
// the fact an operator most needs stated before confirming.
|
||||
details["mark_count"] = s.botVerifierMarkCount(ctx, req.BotID)
|
||||
}
|
||||
if req.DryRun {
|
||||
message := "bot verifier revoke validated"
|
||||
if !exists {
|
||||
message = "bot verifier revoke validated; the bot is not a verifier"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
}
|
||||
removed, err := s.botVerification.RevokeVerifier(ctx, req.BotID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["changed"] = removed
|
||||
message := "bot verifier status revoked"
|
||||
if !removed {
|
||||
message = "bot was not a verifier"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands: icon catalogue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UpsertVerificationIcon adds or updates a catalogue entry.
|
||||
//
|
||||
// The use-case layer resolves the custom emoji document before writing, because an
|
||||
// entry pointing at a document no client can fetch renders as *nothing*: the peer
|
||||
// looks unverified while the server insists it is marked. That probe needs the
|
||||
// document store, so it runs on execution only; the dry run validates the shape.
|
||||
func (s *Service) UpsertVerificationIcon(ctx context.Context, req UpsertVerificationIconRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
icon := domain.VerificationIcon{
|
||||
DocumentID: req.DocumentID,
|
||||
OwnerBotID: req.OwnerBotID,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Active: true,
|
||||
}
|
||||
if err := icon.Validate(); err != nil {
|
||||
return CommandResult{}, botVerificationError(err)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionUpsertVerificationIcon, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"document_id": strconv.FormatInt(req.DocumentID, 10),
|
||||
"owner_bot_id": strconv.FormatInt(req.OwnerBotID, 10),
|
||||
"name": icon.Name,
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "verification icon validated", Details: details}, nil
|
||||
}
|
||||
stored, err := s.botVerification.UpsertIcon(ctx, icon)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["icon_id"] = strconv.FormatInt(stored.ID, 10)
|
||||
details["active"] = stored.Active
|
||||
details["changed"] = true
|
||||
return CommandResult{Message: "verification icon stored", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetVerificationIconActive retires or restores a catalogue entry.
|
||||
func (s *Service) SetVerificationIconActive(ctx context.Context, req SetVerificationIconActiveRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if req.IconID <= 0 {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrVerificationIconNotFound)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetVerificationIconActive, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"icon_id": strconv.FormatInt(req.IconID, 10),
|
||||
"active": req.Active,
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "verification icon switch validated", Details: details}, nil
|
||||
}
|
||||
stored, err := s.botVerification.SetIconActive(ctx, req.IconID, req.Active)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["document_id"] = strconv.FormatInt(stored.DocumentID, 10)
|
||||
details["name"] = stored.Name
|
||||
details["active"] = stored.Active
|
||||
details["changed"] = true
|
||||
message := "verification icon activated"
|
||||
if !req.Active {
|
||||
// Marks already granted with it keep rendering: the icon id is
|
||||
// denormalised onto the mark.
|
||||
message = "verification icon retired"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands: granted marks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RevokeCustomVerification strips one verifier's mark from a peer.
|
||||
//
|
||||
// The peer is checked for shape only, never for existence: an operator must still
|
||||
// be able to strip a mark from a peer that has since been deleted or become
|
||||
// unresolvable, which is exactly when stripping it matters most.
|
||||
func (s *Service) RevokeCustomVerification(ctx context.Context, req RevokeCustomVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if req.VerifierBotID <= 0 {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
peer := domain.Peer{Type: req.PeerType, ID: req.PeerID}
|
||||
if !markableAdminPeer(peer) {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrCustomVerificationTargetInvalid)
|
||||
}
|
||||
targetUserID := int64(0)
|
||||
if peer.Type == domain.PeerTypeUser {
|
||||
targetUserID = peer.ID
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRevokeCustomVerification, targetUserID, peer, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"verifier_bot_id": strconv.FormatInt(req.VerifierBotID, 10),
|
||||
"peer_type": string(peer.Type),
|
||||
"peer_id": strconv.FormatInt(peer.ID, 10),
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
present, err := s.CustomVerificationMarkActive(ctx, req.VerifierBotID, peer)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["mark_present"] = present
|
||||
if req.DryRun {
|
||||
message := "custom verification revoke validated"
|
||||
if !present {
|
||||
message = "custom verification revoke validated; the peer carries no mark from this verifier"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
}
|
||||
removed, err := s.botVerification.RevokeMark(ctx, req.VerifierBotID, peer)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["changed"] = removed
|
||||
message := "custom verification revoked"
|
||||
if !removed {
|
||||
message = "custom verification was already absent"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands: application queue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ApproveBotVerification grants the mark an application asked for.
|
||||
func (s *Service) ApproveBotVerification(ctx context.Context, req ApproveBotVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if err := validateBotVerificationDecisionShape(req.RequestID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionApproveBotVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
current, details, err := s.botVerificationSubject(ctx, req.CommandMeta, req.RequestID, domain.CustomVerificationApproved, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := botVerificationTransition(current, req.Version, domain.CustomVerificationApproved); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
// An application that is already approved is a replay: the use-case layer
|
||||
// answers changed=false without re-running the verifier gate, so the dry run
|
||||
// must not re-run it either.
|
||||
replay := current.Status == domain.CustomVerificationApproved
|
||||
if !replay {
|
||||
if err := s.mergeVerifierDecisionDetails(ctx, details, current.VerifierBotID, true); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "bot verification approve validated", Details: details}, nil
|
||||
}
|
||||
stored, changed, err := s.botVerification.Approve(ctx, req.RequestID, req.Version,
|
||||
strings.TrimSpace(req.Actor), strings.TrimSpace(req.Reason), strings.TrimSpace(req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
mergeBotVerificationDetails(details, stored, changed)
|
||||
message := "bot verification application approved"
|
||||
if !changed {
|
||||
message = "bot verification application already approved"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RejectBotVerification closes an application against the applicant. The reason is
|
||||
// mandatory and is the text the applicant is shown; the internal note stays
|
||||
// operator-side.
|
||||
func (s *Service) RejectBotVerification(ctx context.Context, req RejectBotVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if err := validateBotVerificationDecisionShape(req.RequestID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
// Refused before the journal is touched: the audit trail must never contain a
|
||||
// decision nobody can explain.
|
||||
if strings.TrimSpace(req.Reason) == "" {
|
||||
return CommandResult{}, codedError(CodeCustomVerificationReasonRequired, domain.ErrVerificationReasonRequired)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRejectBotVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
current, details, err := s.botVerificationSubject(ctx, req.CommandMeta, req.RequestID, domain.CustomVerificationRejected, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := botVerificationTransition(current, req.Version, domain.CustomVerificationRejected); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "bot verification reject validated", Details: details}, nil
|
||||
}
|
||||
stored, changed, err := s.botVerification.Reject(ctx, req.RequestID, req.Version,
|
||||
strings.TrimSpace(req.Actor), strings.TrimSpace(req.Reason), strings.TrimSpace(req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
mergeBotVerificationDetails(details, stored, changed)
|
||||
message := "bot verification application rejected"
|
||||
if !changed {
|
||||
message = "bot verification application already rejected"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeBotVerification withdraws a granted mark through the application it came
|
||||
// from. A reason is mandatory for the same reason it is on rejection.
|
||||
func (s *Service) RevokeBotVerification(ctx context.Context, req RevokeBotVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if err := validateBotVerificationDecisionShape(req.RequestID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(req.Reason) == "" {
|
||||
return CommandResult{}, codedError(CodeCustomVerificationReasonRequired, domain.ErrVerificationReasonRequired)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRevokeBotVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
current, details, err := s.botVerificationSubject(ctx, req.CommandMeta, req.RequestID, domain.CustomVerificationRevoked, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := botVerificationTransition(current, req.Version, domain.CustomVerificationRevoked); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
// The mark may already be gone -- the operator can strip one directly -- and
|
||||
// that is not an error: the application still has to reach "revoked".
|
||||
present, err := s.CustomVerificationMarkActive(ctx, current.VerifierBotID, current.Peer)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["mark_present"] = present
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "bot verification revoke validated", Details: details}, nil
|
||||
}
|
||||
stored, changed, err := s.botVerification.RevokeRequest(ctx, req.RequestID, req.Version,
|
||||
strings.TrimSpace(req.Actor), strings.TrimSpace(req.Reason), strings.TrimSpace(req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
mergeBotVerificationDetails(details, stored, changed)
|
||||
message := "bot verification mark revoked"
|
||||
if !changed {
|
||||
message = "bot verification application already revoked"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var errBotVerificationNotConfigured = errors.New("admin bot verification dependency is not configured")
|
||||
|
||||
// botVerifierState loads a verifier row and reports whether it exists at all,
|
||||
// because "not a verifier" is a normal answer for several of these commands
|
||||
// rather than a failure.
|
||||
func (s *Service) botVerifierState(ctx context.Context, botID int64) (domain.BotVerifierSettings, bool, error) {
|
||||
settings, err := s.botVerification.VerifierSettings(ctx, botID)
|
||||
switch {
|
||||
case err == nil:
|
||||
return settings, settings.BotID == botID, nil
|
||||
case errors.Is(err, domain.ErrVerifierNotFound):
|
||||
return domain.BotVerifierSettings{}, false, nil
|
||||
default:
|
||||
return domain.BotVerifierSettings{}, false, botVerificationError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// botVerifierMarkCount reports how many marks a verifier holds, bounded by the
|
||||
// listing page. It is advisory audit detail, so a read failure is reported as an
|
||||
// unknown count rather than failing the command it is describing.
|
||||
func (s *Service) botVerifierMarkCount(ctx context.Context, botID int64) any {
|
||||
marks, err := s.botVerification.Marks(ctx, domain.CustomVerificationFilter{
|
||||
VerifierBotID: botID,
|
||||
Limit: botVerificationCatalogueScan,
|
||||
})
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
if len(marks) >= botVerificationCatalogueScan {
|
||||
return strconv.Itoa(len(marks)) + "+"
|
||||
}
|
||||
return strconv.Itoa(len(marks))
|
||||
}
|
||||
|
||||
// grantIconPreflight records what the catalogue says about the icon and refuses
|
||||
// the definite failures early.
|
||||
//
|
||||
// The check is deliberately one-sided. An icon found in the page and unusable is
|
||||
// a certain failure and is refused with its own code; an icon the page does not
|
||||
// cover is left to the use-case layer, which reads the entry by document id and
|
||||
// additionally resolves the custom emoji document. So this can only ever make the
|
||||
// dry run *more* informative, never stricter than the command it rehearses.
|
||||
func (s *Service) grantIconPreflight(ctx context.Context, details map[string]any, botID, documentID int64) error {
|
||||
icons, err := s.botVerification.Icons(ctx, false, botVerificationCatalogueScan)
|
||||
if err != nil {
|
||||
details["icon_catalogue_checked"] = false
|
||||
return nil
|
||||
}
|
||||
for _, icon := range icons {
|
||||
if icon.DocumentID != documentID {
|
||||
continue
|
||||
}
|
||||
details["icon_catalogue_checked"] = true
|
||||
details["icon_id"] = strconv.FormatInt(icon.ID, 10)
|
||||
details["icon_name"] = icon.Name
|
||||
details["icon_active"] = icon.Active
|
||||
details["icon_owner_bot_id"] = strconv.FormatInt(icon.OwnerBotID, 10)
|
||||
if !icon.Active {
|
||||
return botVerificationCoded(domain.ErrVerificationIconInactive)
|
||||
}
|
||||
if !icon.UsableBy(botID) {
|
||||
// A reserved entry belongs to one verifier; for anybody else it does
|
||||
// not exist, which is why this is "not found" and not "forbidden".
|
||||
return botVerificationCoded(domain.ErrVerificationIconNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
details["icon_catalogue_checked"] = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// botVerificationSubject loads the application under decision and seeds the
|
||||
// command details with everything the audit entry must state even when the command
|
||||
// then fails: which application, whose, which verifier, which peer, the status it
|
||||
// is leaving and the status it was asked to reach.
|
||||
func (s *Service) botVerificationSubject(
|
||||
ctx context.Context,
|
||||
meta CommandMeta,
|
||||
requestID int64,
|
||||
next domain.CustomVerificationRequestStatus,
|
||||
internalNote string,
|
||||
) (domain.CustomVerificationRequest, map[string]any, error) {
|
||||
details := map[string]any{
|
||||
"request_id": strconv.FormatInt(requestID, 10),
|
||||
"next_status": string(next),
|
||||
"correlation_id": strings.TrimSpace(meta.CommandID),
|
||||
}
|
||||
if note := strings.TrimSpace(internalNote); note != "" {
|
||||
details["internal_note"] = note
|
||||
}
|
||||
current, err := s.botVerification.Request(ctx, requestID)
|
||||
if err != nil {
|
||||
return domain.CustomVerificationRequest{}, details, botVerificationError(err)
|
||||
}
|
||||
if current.ID != requestID {
|
||||
return domain.CustomVerificationRequest{}, details, botVerificationCoded(domain.ErrCustomVerificationRequestNotFound)
|
||||
}
|
||||
details["verifier_bot_id"] = strconv.FormatInt(current.VerifierBotID, 10)
|
||||
details["applicant_user_id"] = strconv.FormatInt(current.ApplicantUserID, 10)
|
||||
details["peer_type"] = string(current.Peer.Type)
|
||||
details["peer_id"] = strconv.FormatInt(current.Peer.ID, 10)
|
||||
details["peer_username"] = current.PeerUsername
|
||||
details["previous_status"] = string(current.Status)
|
||||
details["previous_version"] = strconv.FormatInt(current.Version, 10)
|
||||
return current, details, nil
|
||||
}
|
||||
|
||||
// mergeVerifierDecisionDetails records the verifier behind an application and,
|
||||
// for a decision that would grant a mark, refuses a verifier that may not verify
|
||||
// right now.
|
||||
//
|
||||
// An application can sit in the queue for days: a verifier switched off in the
|
||||
// meantime must not be able to grant through the review path what the RPC path
|
||||
// would refuse. "No row" and "switched off" answer the same code on purpose --
|
||||
// that is the distinction BOTVERIFIER_FORBIDDEN deliberately hides.
|
||||
func (s *Service) mergeVerifierDecisionDetails(ctx context.Context, details map[string]any, botID int64, requireEnabled bool) error {
|
||||
settings, exists, err := s.botVerifierState(ctx, botID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
details["verifier_present"] = exists
|
||||
if exists {
|
||||
details["verifier_enabled"] = settings.Enabled
|
||||
details["verifier_company_name"] = settings.CompanyName
|
||||
details["verifier_icon_document_id"] = strconv.FormatInt(settings.IconDocumentID, 10)
|
||||
details["verifier_can_modify_custom_description"] = settings.CanModifyCustomDescription
|
||||
}
|
||||
if requireEnabled && (!exists || !settings.Enabled) {
|
||||
return botVerificationCoded(domain.ErrVerifierForbidden)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeBotVerificationDetails records the decided state.
|
||||
func mergeBotVerificationDetails(details map[string]any, req domain.CustomVerificationRequest, changed bool) {
|
||||
details["status"] = string(req.Status)
|
||||
details["version"] = strconv.FormatInt(req.Version, 10)
|
||||
details["changed"] = changed
|
||||
details["decided_by"] = req.DecidedBy
|
||||
if req.CorrelationID != "" {
|
||||
details["correlation_id"] = req.CorrelationID
|
||||
}
|
||||
if req.DecisionReason != "" {
|
||||
details["decision_reason"] = req.DecisionReason
|
||||
}
|
||||
}
|
||||
|
||||
// validateBotVerificationDecisionShape rejects a malformed decision before the
|
||||
// command journal is touched.
|
||||
func validateBotVerificationDecisionShape(requestID, version int64, internalNote string) error {
|
||||
if requestID <= 0 {
|
||||
return botVerificationCoded(domain.ErrCustomVerificationRequestNotFound)
|
||||
}
|
||||
if version <= 0 {
|
||||
// Without the version the reviewer never read the row, so the optimistic
|
||||
// lock could not protect a concurrent decision.
|
||||
return botVerificationInvalid("version is required")
|
||||
}
|
||||
if utf8.RuneCountInString(internalNote) > domain.MaxCustomVerificationNoteLength {
|
||||
return botVerificationInvalid("internal_note is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// botVerificationTransition checks the status machine and the optimistic lock in
|
||||
// the order the use-case layer does, so a dry run predicts the real outcome
|
||||
// exactly.
|
||||
//
|
||||
// A request already in the target status is a replay the service answers as a
|
||||
// no-op, so it passes without consulting the version: re-sending a decision whose
|
||||
// response was lost must not turn into a spurious conflict.
|
||||
func botVerificationTransition(req domain.CustomVerificationRequest, version int64, next domain.CustomVerificationRequestStatus) error {
|
||||
if req.Status == next {
|
||||
return nil
|
||||
}
|
||||
if !domain.CanTransitionCustomVerificationStatus(req.Status, next) {
|
||||
return codedError(CodeCustomVerificationStatusInvalid,
|
||||
fmt.Errorf("%w: %s -> %s", domain.ErrCustomVerificationRequestInvalid, req.Status, next))
|
||||
}
|
||||
if req.Version != version {
|
||||
return codedError(CodeCustomVerificationConflict, domain.ErrCustomVerificationVersionConflict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// markableAdminPeer mirrors the store's peer_type CHECK: only users (bots
|
||||
// included) and channels can carry a third-party mark.
|
||||
func markableAdminPeer(peer domain.Peer) bool {
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||||
return peer.ID > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// BotVerificationErrorCode maps a third-party verification failure onto the stable
|
||||
// code the admin panel switches on. An unmapped error returns "" so the caller can
|
||||
// report it verbatim instead of inventing a code.
|
||||
//
|
||||
// The codes are separate from the official verification ones by design: the two
|
||||
// mechanisms fail for different reasons and the panel renders them in different
|
||||
// sections, so sharing a token would make a message land in the wrong place.
|
||||
func BotVerificationErrorCode(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case errors.Is(err, domain.ErrVerifierNotFound):
|
||||
return CodeBotVerifierNotFound
|
||||
case errors.Is(err, domain.ErrVerifierForbidden):
|
||||
return CodeBotVerifierForbidden
|
||||
case errors.Is(err, domain.ErrVerifierDescriptionForbidden):
|
||||
return CodeBotVerifierDescriptionForbidden
|
||||
case errors.Is(err, domain.ErrVerifierSettingsInvalid):
|
||||
return CodeBotVerifierInvalid
|
||||
case errors.Is(err, domain.ErrVerificationIconNotFound):
|
||||
return CodeVerificationIconNotFound
|
||||
case errors.Is(err, domain.ErrVerificationIconInactive):
|
||||
return CodeVerificationIconInactive
|
||||
case errors.Is(err, domain.ErrVerificationIconInvalid):
|
||||
return CodeVerificationIconInvalid
|
||||
case errors.Is(err, domain.ErrCustomVerificationVersionConflict):
|
||||
return CodeCustomVerificationConflict
|
||||
case errors.Is(err, domain.ErrCustomVerificationLimit):
|
||||
return CodeCustomVerificationLimit
|
||||
case errors.Is(err, domain.ErrCustomVerificationNotFound):
|
||||
return CodeCustomVerificationNotFound
|
||||
case errors.Is(err, domain.ErrCustomVerificationRequestNotFound):
|
||||
return CodeCustomVerificationRequestNotFound
|
||||
case errors.Is(err, domain.ErrCustomVerificationRequestExists):
|
||||
return CodeCustomVerificationRequestExists
|
||||
case errors.Is(err, domain.ErrCustomVerificationTargetInvalid):
|
||||
return CodeCustomVerificationTargetInvalid
|
||||
case errors.Is(err, domain.ErrVerificationTargetSystem):
|
||||
return CodeCustomVerificationTargetSystem
|
||||
case errors.Is(err, domain.ErrVerificationReasonRequired):
|
||||
return CodeCustomVerificationReasonRequired
|
||||
case errors.Is(err, domain.ErrVerificationRateLimited):
|
||||
return CodeCustomVerificationRateLimited
|
||||
case errors.Is(err, domain.ErrBotNotFound):
|
||||
return CodeBotVerifierBotNotFound
|
||||
case errors.Is(err, domain.ErrCustomVerificationRequestInvalid):
|
||||
return CodeCustomVerificationInvalid
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// botVerificationError prefixes a recognised failure with its stable code, the way
|
||||
// verificationError does for the official review.
|
||||
func botVerificationError(err error) error {
|
||||
if code := BotVerificationErrorCode(err); code != "" {
|
||||
return codedError(code, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func botVerificationCoded(err error) error {
|
||||
return codedError(BotVerificationErrorCode(err), err)
|
||||
}
|
||||
|
||||
func botVerificationInvalid(message string) error {
|
||||
return codedError(CodeCustomVerificationInvalid, fmt.Errorf("%s: %w", message, domain.ErrCustomVerificationRequestInvalid))
|
||||
}
|
||||
|
||||
func botVerifierInvalid(message string) error {
|
||||
return codedError(CodeBotVerifierInvalid, fmt.Errorf("%s: %w", message, domain.ErrVerifierSettingsInvalid))
|
||||
}
|
||||
1045
internal/admin/botverification_test.go
Normal file
1045
internal/admin/botverification_test.go
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -7,16 +7,28 @@ import (
|
|||
"encoding/hex"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ratingapp "telesrv/internal/app/rating"
|
||||
stargiftapp "telesrv/internal/app/stargifts"
|
||||
usernamesapp "telesrv/internal/app/usernames"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// Compile-time proof that the shipped use-case services satisfy the admin ports.
|
||||
// cmd/telesrv wires *usernames.Service and *rating.Service into
|
||||
// Dependencies.Usernames / Dependencies.Rating directly, so a drifting method set
|
||||
// has to fail here rather than at integration time.
|
||||
var (
|
||||
_ CollectibleUsernamesService = (*usernamesapp.Service)(nil)
|
||||
_ AccountRatingService = (*ratingapp.Service)(nil)
|
||||
)
|
||||
|
||||
func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
|
|
@ -143,6 +155,39 @@ func TestModerationFlagsRejectImpossibleScamFakeState(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSetUserFlagsUsesNonPTSModerationNotifier(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := &fakeUsersService{users: map[int64]domain.User{
|
||||
1001: {ID: 1001, FirstName: "Alice"},
|
||||
}}
|
||||
ordinaryNotifier := &fakeUserNotifier{}
|
||||
moderationNotifier := &fakeUserModerationNotifier{}
|
||||
svc := NewService(Dependencies{
|
||||
Commands: newMemoryCommandRepo(),
|
||||
Users: users,
|
||||
UserNotifier: ordinaryNotifier,
|
||||
UserModerationNotifier: moderationNotifier,
|
||||
Now: fixedNow,
|
||||
})
|
||||
|
||||
if _, err := svc.SetUserFlags(ctx, SetUserFlagsRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "set-user-scam", Actor: "ops", Reason: "confirmed report"},
|
||||
UserID: 1001,
|
||||
Scam: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("SetUserFlags: %v", err)
|
||||
}
|
||||
if got := users.users[1001]; !got.Scam || got.Fake {
|
||||
t.Fatalf("updated user = %+v", got)
|
||||
}
|
||||
if len(moderationNotifier.users) != 1 || moderationNotifier.users[0] != 1001 {
|
||||
t.Fatalf("moderation notifications = %v", moderationNotifier.users)
|
||||
}
|
||||
if len(ordinaryNotifier.users) != 0 {
|
||||
t.Fatalf("ordinary notifications = %v, want dedicated non-PTS path", ordinaryNotifier.users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountFreezesBatchesAndReturnsOnlyActiveFacts(t *testing.T) {
|
||||
now := fixedNow()
|
||||
store := &fakeBatchRestrictionStore{fakeRestrictionStore: fakeRestrictionStore{items: map[int64]domain.AccountFreeze{
|
||||
|
|
@ -863,6 +908,15 @@ func (f *fakeUserNotifier) NotifyUserChanged(_ context.Context, u domain.User) e
|
|||
return nil
|
||||
}
|
||||
|
||||
type fakeUserModerationNotifier struct {
|
||||
users []int64
|
||||
}
|
||||
|
||||
func (f *fakeUserModerationNotifier) NotifyUserModerationFlagsChanged(_ context.Context, u domain.User) error {
|
||||
f.users = append(f.users, u.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeChannelsService struct {
|
||||
channels map[int64]domain.Channel
|
||||
verifiedCalls int
|
||||
|
|
@ -1373,3 +1427,653 @@ func (f *fakeChannelNotifier) NotifyChannelChanged(_ context.Context, ch domain.
|
|||
f.channels = append(f.channels, ch.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeCollectibleUsernamesService is an in-memory collectible lifecycle: enough
|
||||
// state to observe occupancy, replay-by-command-key and the burned terminal
|
||||
// state, which is exactly what the admin commands are expected to reason about.
|
||||
type fakeCollectibleUsernamesService struct {
|
||||
assets map[string]domain.CollectibleUsername
|
||||
log map[int64][]domain.CollectibleUsernameTransfer
|
||||
commandKeys map[string]int64
|
||||
nextID int64
|
||||
mintCalls int
|
||||
transferCalls int
|
||||
revokeCalls int
|
||||
deleteCalls int
|
||||
}
|
||||
|
||||
func newFakeCollectibleUsernames() *fakeCollectibleUsernamesService {
|
||||
return &fakeCollectibleUsernamesService{
|
||||
assets: map[string]domain.CollectibleUsername{},
|
||||
log: map[int64][]domain.CollectibleUsernameTransfer{},
|
||||
commandKeys: map[string]int64{},
|
||||
nextID: 100,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Mint(_ context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.mintCalls++
|
||||
if id, ok := f.commandKeys[req.CommandKey]; ok && req.CommandKey != "" {
|
||||
return f.byID(id), false, nil
|
||||
}
|
||||
key := strings.ToLower(req.Username)
|
||||
if _, ok := f.assets[key]; ok {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
||||
}
|
||||
f.nextID++
|
||||
asset := domain.CollectibleUsername{
|
||||
ID: f.nextID, Username: req.Username, Status: domain.CollectibleUsernameStatusVault,
|
||||
PurchaseDate: req.PurchaseDate, Currency: req.Currency, Amount: req.Amount,
|
||||
CryptoCurrency: req.CryptoCurrency, CryptoAmount: req.CryptoAmount, URL: req.URL,
|
||||
Version: 1,
|
||||
}
|
||||
if req.Owner.Type != "" {
|
||||
asset.Status = domain.CollectibleUsernameStatusOwned
|
||||
asset.Owner = req.Owner
|
||||
asset.OriginalOwner = req.Owner
|
||||
}
|
||||
f.assets[key] = asset
|
||||
f.commandKeys[req.CommandKey] = asset.ID
|
||||
f.log[asset.ID] = append(f.log[asset.ID], domain.CollectibleUsernameTransfer{
|
||||
ID: asset.ID, CollectibleID: asset.ID, Kind: domain.CollectibleUsernameKindMint,
|
||||
To: req.Owner, Actor: req.Actor, Reason: req.Reason, CommandKey: req.CommandKey,
|
||||
})
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Transfer(_ context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.transferCalls++
|
||||
key := strings.ToLower(req.Username)
|
||||
asset, ok := f.assets[key]
|
||||
if !ok {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
if asset.Status == domain.CollectibleUsernameStatusBurned {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
|
||||
}
|
||||
if asset.Owned() && asset.Owner == req.To {
|
||||
return asset, false, nil
|
||||
}
|
||||
asset.Status = domain.CollectibleUsernameStatusOwned
|
||||
asset.Owner = req.To
|
||||
if asset.OriginalOwner.Type == "" {
|
||||
asset.OriginalOwner = req.To
|
||||
}
|
||||
asset.TransferCount++
|
||||
asset.Version++
|
||||
f.assets[key] = asset
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Delete(_ context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
|
||||
f.deleteCalls++
|
||||
key := strings.ToLower(req.Username)
|
||||
asset, ok := f.assets[key]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if asset.Status == domain.CollectibleUsernameStatusBurned {
|
||||
return false, nil
|
||||
}
|
||||
delete(f.assets, key)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Revoke(_ context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.revokeCalls++
|
||||
key := strings.ToLower(req.Username)
|
||||
asset, ok := f.assets[key]
|
||||
if !ok {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
if asset.Status == domain.CollectibleUsernameStatusBurned {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
|
||||
}
|
||||
asset.Owner = domain.Peer{}
|
||||
asset.Status = domain.CollectibleUsernameStatusVault
|
||||
if req.Burn {
|
||||
asset.Status = domain.CollectibleUsernameStatusBurned
|
||||
}
|
||||
asset.Version++
|
||||
f.assets[key] = asset
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Collectible(_ context.Context, username string) (domain.CollectibleUsername, error) {
|
||||
asset, ok := f.assets[strings.ToLower(username)]
|
||||
if !ok {
|
||||
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) List(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
|
||||
out := make([]domain.CollectibleUsername, 0, len(f.assets))
|
||||
for _, asset := range f.assets {
|
||||
if filter.Status != "" && asset.Status != filter.Status {
|
||||
continue
|
||||
}
|
||||
if filter.BeforeID != 0 && asset.ID >= filter.BeforeID {
|
||||
continue
|
||||
}
|
||||
out = append(out, asset)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
if filter.Limit > 0 && len(out) > filter.Limit {
|
||||
out = out[:filter.Limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Transfers(_ context.Context, collectibleID int64, _ int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
return f.log[collectibleID], nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) byID(id int64) domain.CollectibleUsername {
|
||||
for _, asset := range f.assets {
|
||||
if asset.ID == id {
|
||||
return asset
|
||||
}
|
||||
}
|
||||
return domain.CollectibleUsername{}
|
||||
}
|
||||
|
||||
// fakeAccountRatingService recomputes from the manual component only, which
|
||||
// keeps the arithmetic in the domain and the fake focused on ledger replay.
|
||||
type fakeAccountRatingService struct {
|
||||
ratings map[int64]domain.AccountRating
|
||||
manual map[int64]int64
|
||||
commandKeys map[string]bool
|
||||
recomputeCalls int
|
||||
adjustCalls int
|
||||
}
|
||||
|
||||
func newFakeAccountRating() *fakeAccountRatingService {
|
||||
return &fakeAccountRatingService{
|
||||
ratings: map[int64]domain.AccountRating{},
|
||||
manual: map[int64]int64{},
|
||||
commandKeys: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Rating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
rating, ok := f.ratings[userID]
|
||||
if !ok {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Recompute(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
f.recomputeCalls++
|
||||
rating := domain.ComputeAccountRating(domain.AccountRatingSignals{
|
||||
UserID: userID, StarsReceived: 5000, Manual: f.manual[userID],
|
||||
}, domain.DefaultAccountRatingWeights(), fixedNow())
|
||||
rating.Version = f.ratings[userID].Version + 1
|
||||
f.ratings[userID] = rating
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error) {
|
||||
f.adjustCalls++
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.AccountRating{}, false, err
|
||||
}
|
||||
applied := true
|
||||
if f.commandKeys[req.CommandKey] {
|
||||
applied = false
|
||||
} else {
|
||||
f.commandKeys[req.CommandKey] = true
|
||||
f.manual[req.UserID] += req.Amount
|
||||
}
|
||||
rating, err := f.Recompute(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, applied, err
|
||||
}
|
||||
return rating, applied, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) List(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
out := make([]domain.AccountRating, 0, len(f.ratings))
|
||||
for _, rating := range f.ratings {
|
||||
if rating.Level < filter.MinLevel {
|
||||
continue
|
||||
}
|
||||
out = append(out, rating)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].UserID < out[j].UserID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Events(_ context.Context, userID int64, _ int) ([]domain.AccountRatingEvent, error) {
|
||||
if f.manual[userID] == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []domain.AccountRatingEvent{{
|
||||
ID: 1, UserID: userID, Kind: domain.AccountRatingEventManual, Amount: f.manual[userID],
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func TestMintCollectibleUsernameDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
|
||||
|
||||
dry, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-mint", Actor: "ops", Reason: "fragment import", DryRun: true},
|
||||
Username: "@Durov", OwnerUserID: 1001, Currency: domain.CollectibleCurrencyTON,
|
||||
Amount: 250_000_000_000, CryptoCurrency: domain.CollectibleCryptoCurrencyTON, CryptoAmount: 250_000_000_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run mint: %v", err)
|
||||
}
|
||||
if !dry.DryRun || dry.Status != string(domain.AdminCommandCompleted) || usernames.mintCalls != 0 {
|
||||
t.Fatalf("dry-run result=%+v mintCalls=%d, want validation without mutation", dry, usernames.mintCalls)
|
||||
}
|
||||
if dry.Details["username"] != "Durov" || dry.Details["purchase_date"] != fixedNow().Format(time.RFC3339) {
|
||||
t.Fatalf("dry-run details=%+v, want a normalised name and a stamped purchase date", dry.Details)
|
||||
}
|
||||
|
||||
execReq := MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-mint", Actor: "ops", Reason: "fragment import"},
|
||||
Username: "Durov", OwnerUserID: 1001, Currency: domain.CollectibleCurrencyTON,
|
||||
Amount: 250_000_000_000, CryptoCurrency: domain.CollectibleCryptoCurrencyTON, CryptoAmount: 250_000_000_000,
|
||||
}
|
||||
exec, err := svc.MintCollectibleUsername(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("execute mint: %v", err)
|
||||
}
|
||||
if exec.Status != string(domain.AdminCommandCompleted) || usernames.mintCalls != 1 ||
|
||||
exec.Details["status"] != string(domain.CollectibleUsernameStatusOwned) {
|
||||
t.Fatalf("execute result=%+v mintCalls=%d", exec, usernames.mintCalls)
|
||||
}
|
||||
|
||||
again, err := svc.MintCollectibleUsername(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("replay mint: %v", err)
|
||||
}
|
||||
if !again.AlreadyExecuted || usernames.mintCalls != 1 {
|
||||
t.Fatalf("replay result=%+v mintCalls=%d, want idempotent replay", again, usernames.mintCalls)
|
||||
}
|
||||
|
||||
occupied, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-mint-2", Actor: "ops", Reason: "duplicate"},
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), CodeUsernameOccupied) {
|
||||
t.Fatalf("duplicate mint err=%v, want %s", err, CodeUsernameOccupied)
|
||||
}
|
||||
if occupied.Status != string(domain.AdminCommandFailed) || usernames.mintCalls != 1 {
|
||||
t.Fatalf("duplicate result=%+v mintCalls=%d, want a journalled failure without mutation", occupied, usernames.mintCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintCollectibleUsernameValidatesBeforeJournallingCommand(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req MintCollectibleUsernameRequest
|
||||
code string
|
||||
}{
|
||||
{
|
||||
name: "short username",
|
||||
req: MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "bad-1", Actor: "ops", Reason: "invalid"},
|
||||
Username: "ab", Currency: domain.CollectibleCurrencyUSD, Amount: 1,
|
||||
},
|
||||
code: CodeUsernameInvalid,
|
||||
},
|
||||
{
|
||||
name: "unsupported currency",
|
||||
req: MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "bad-2", Actor: "ops", Reason: "invalid"},
|
||||
Username: "durov", Currency: "EUR", Amount: 1,
|
||||
},
|
||||
code: CodeCollectibleCurrencyInvalid,
|
||||
},
|
||||
{
|
||||
name: "crypto amount without currency",
|
||||
req: MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "bad-3", Actor: "ops", Reason: "invalid"},
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CryptoAmount: 5,
|
||||
},
|
||||
code: CodeCollectibleCurrencyInvalid,
|
||||
},
|
||||
}
|
||||
for _, item := range cases {
|
||||
if _, err := svc.MintCollectibleUsername(ctx, item.req); err == nil || !strings.Contains(err.Error(), item.code) {
|
||||
t.Fatalf("%s err=%v, want %s", item.name, err, item.code)
|
||||
}
|
||||
if _, journalled := repo.items[item.req.CommandID]; journalled {
|
||||
t.Fatalf("%s journalled a rejected command", item.name)
|
||||
}
|
||||
}
|
||||
if usernames.mintCalls != 0 {
|
||||
t.Fatalf("rejected requests reached the lifecycle: mintCalls=%d", usernames.mintCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferCollectibleUsernameRequiresRecipientAndRejectsBurned(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
|
||||
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed mint: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-none", Actor: "ops", Reason: "sold"}, Username: "durov",
|
||||
}); err == nil || !strings.Contains(err.Error(), "to_user_id") {
|
||||
t.Fatalf("transfer without recipient err=%v", err)
|
||||
}
|
||||
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-both", Actor: "ops", Reason: "sold"},
|
||||
Username: "durov", ToUserID: 1001, ToChannelID: 2002,
|
||||
}); err == nil {
|
||||
t.Fatal("transfer accepted two recipients")
|
||||
}
|
||||
|
||||
dry, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-dry", Actor: "ops", Reason: "sold", DryRun: true},
|
||||
Username: "durov", ToUserID: 1001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run transfer: %v", err)
|
||||
}
|
||||
if usernames.transferCalls != 0 || dry.Details["would_change"] != true {
|
||||
t.Fatalf("dry-run transfer result=%+v transferCalls=%d", dry, usernames.transferCalls)
|
||||
}
|
||||
|
||||
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-exec", Actor: "ops", Reason: "sold"},
|
||||
Username: "durov", ToUserID: 1001,
|
||||
}); err != nil {
|
||||
t.Fatalf("execute transfer: %v", err)
|
||||
}
|
||||
if usernames.transferCalls != 1 {
|
||||
t.Fatalf("transferCalls=%d, want one mutation", usernames.transferCalls)
|
||||
}
|
||||
|
||||
if _, err := svc.RevokeCollectibleUsername(ctx, RevokeCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "burn-1", Actor: "ops", Reason: "fraud"},
|
||||
Username: "durov", Burn: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("burn: %v", err)
|
||||
}
|
||||
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-burned", Actor: "ops", Reason: "sold"},
|
||||
Username: "durov", ToUserID: 1002,
|
||||
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleBurned) {
|
||||
t.Fatalf("transfer of a burned asset err=%v, want %s", err, CodeCollectibleBurned)
|
||||
}
|
||||
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-missing", Actor: "ops", Reason: "sold"},
|
||||
Username: "nobody_holds_this", ToUserID: 1002,
|
||||
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleNotFound) {
|
||||
t.Fatalf("transfer of a missing asset err=%v, want %s", err, CodeCollectibleNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeCollectibleUsernameRejectsVaultAssetWithoutBurn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
|
||||
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed mint: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.RevokeCollectibleUsername(ctx, RevokeCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rv-vault", Actor: "ops", Reason: "nothing to take"},
|
||||
Username: "durov",
|
||||
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleNotOwned) {
|
||||
t.Fatalf("revoke of a vault asset err=%v, want %s", err, CodeCollectibleNotOwned)
|
||||
}
|
||||
if usernames.revokeCalls != 0 {
|
||||
t.Fatalf("revokeCalls=%d, want no mutation", usernames.revokeCalls)
|
||||
}
|
||||
|
||||
burnDry, err := svc.RevokeCollectibleUsername(ctx, RevokeCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rv-burn-dry", Actor: "ops", Reason: "fraud", DryRun: true},
|
||||
Username: "durov", Burn: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run burn: %v", err)
|
||||
}
|
||||
if usernames.revokeCalls != 0 || burnDry.Details["burn"] != true {
|
||||
t.Fatalf("dry-run burn result=%+v revokeCalls=%d", burnDry, usernames.revokeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleUsernameByIDUsesKeysetFallback(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Usernames: usernames, Now: fixedNow})
|
||||
first, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed mint: %v", err)
|
||||
}
|
||||
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "telegram", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed-2",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed mint: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.CollectibleUsernameByID(ctx, first.ID)
|
||||
if err != nil || got.ID != first.ID || got.Username != "durov" {
|
||||
t.Fatalf("CollectibleUsernameByID(%d) = %+v err=%v", first.ID, got, err)
|
||||
}
|
||||
if _, err := svc.CollectibleUsernameByID(ctx, first.ID-1); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
|
||||
t.Fatalf("missing id err=%v, want ErrCollectibleUsernameNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustAccountRatingDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
rating := newFakeAccountRating()
|
||||
svc := NewService(Dependencies{Commands: repo, Rating: rating, Now: fixedNow})
|
||||
|
||||
dry, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-adjust", Actor: "ops", Reason: "penalty", DryRun: true},
|
||||
UserID: 1001, Amount: -2500,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run adjust: %v", err)
|
||||
}
|
||||
if rating.adjustCalls != 0 || dry.Details["previous_found"] != false || dry.Details["amount"] != "-2500" {
|
||||
t.Fatalf("dry-run adjust result=%+v adjustCalls=%d", dry, rating.adjustCalls)
|
||||
}
|
||||
|
||||
execReq := AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-adjust", Actor: "ops", Reason: "penalty"},
|
||||
UserID: 1001, Amount: -2500,
|
||||
}
|
||||
exec, err := svc.AdjustAccountRating(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("execute adjust: %v", err)
|
||||
}
|
||||
if rating.adjustCalls != 1 || exec.Details["applied"] != true ||
|
||||
exec.Details["manual_component"] != "-2500" || exec.Details["stars"] != "2500" {
|
||||
t.Fatalf("execute adjust result=%+v adjustCalls=%d", exec, rating.adjustCalls)
|
||||
}
|
||||
|
||||
again, err := svc.AdjustAccountRating(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("replay adjust: %v", err)
|
||||
}
|
||||
if !again.AlreadyExecuted || rating.adjustCalls != 1 || rating.manual[1001] != -2500 {
|
||||
t.Fatalf("replay adjust result=%+v adjustCalls=%d manual=%d", again, rating.adjustCalls, rating.manual[1001])
|
||||
}
|
||||
|
||||
for _, amount := range []int64{0, maxAccountRatingAdjustment + 1, -maxAccountRatingAdjustment - 1} {
|
||||
if _, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "bad-adjust", Actor: "ops", Reason: "invalid"},
|
||||
UserID: 1001, Amount: amount,
|
||||
}); err == nil || !strings.Contains(err.Error(), CodeRatingAdjustmentInvalid) {
|
||||
t.Fatalf("adjust by %d err=%v, want %s", amount, err, CodeRatingAdjustmentInvalid)
|
||||
}
|
||||
}
|
||||
if _, journalled := repo.items["bad-adjust"]; journalled {
|
||||
t.Fatal("journalled a rejected adjustment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeAccountRatingDryRunAndExecute(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
rating := newFakeAccountRating()
|
||||
svc := NewService(Dependencies{Commands: repo, Rating: rating, Now: fixedNow})
|
||||
|
||||
if _, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rc-invalid", Actor: "ops", Reason: "support"},
|
||||
}); err == nil || !strings.Contains(err.Error(), "user_id") {
|
||||
t.Fatalf("recompute without user err=%v", err)
|
||||
}
|
||||
|
||||
dry, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rc-dry", Actor: "ops", Reason: "support", DryRun: true},
|
||||
UserID: 1001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run recompute: %v", err)
|
||||
}
|
||||
if rating.recomputeCalls != 0 || dry.Details["previous_found"] != false {
|
||||
t.Fatalf("dry-run recompute result=%+v recomputeCalls=%d", dry, rating.recomputeCalls)
|
||||
}
|
||||
|
||||
exec, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rc-exec", Actor: "ops", Reason: "support"},
|
||||
UserID: 1001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute recompute: %v", err)
|
||||
}
|
||||
if rating.recomputeCalls != 1 || exec.Details["stars"] != "5000" || exec.Details["version"] != "1" {
|
||||
t.Fatalf("execute recompute result=%+v recomputeCalls=%d", exec, rating.recomputeCalls)
|
||||
}
|
||||
|
||||
stored, err := svc.AccountRating(ctx, 1001)
|
||||
if err != nil || stored.Stars != 5000 {
|
||||
t.Fatalf("AccountRating = %+v err=%v", stored, err)
|
||||
}
|
||||
if events, err := svc.AccountRatingEvents(ctx, 1001, 10); err != nil || len(events) != 0 {
|
||||
t.Fatalf("AccountRatingEvents = %+v err=%v, want an empty ledger", events, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleAndRatingCommandsRequireConfiguredDependencies(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Now: fixedNow})
|
||||
if _, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "c-1", Actor: "ops", Reason: "x"},
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1,
|
||||
}); err == nil || !strings.Contains(err.Error(), "collectible username dependency") {
|
||||
t.Fatalf("mint without dependency err=%v", err)
|
||||
}
|
||||
if _, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "c-2", Actor: "ops", Reason: "x"},
|
||||
UserID: 1001, Amount: 5,
|
||||
}); err == nil || !strings.Contains(err.Error(), "account rating dependency") {
|
||||
t.Fatalf("adjust without dependency err=%v", err)
|
||||
}
|
||||
if _, err := svc.CollectibleUsernames(ctx, domain.CollectibleUsernameFilter{}); err == nil {
|
||||
t.Fatal("listing without dependency succeeded")
|
||||
}
|
||||
if _, err := svc.AccountRatings(ctx, domain.AccountRatingFilter{}); err == nil {
|
||||
t.Fatal("rating listing without dependency succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteCollectibleUsernameCommand covers the hard-delete command: the
|
||||
// journal captures what was removed before the record disappears, a dry-run
|
||||
// mutates nothing, and a burned asset is refused.
|
||||
func TestDeleteCollectibleUsernameCommand(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
repo := newMemoryCommandRepo()
|
||||
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
|
||||
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 8801}
|
||||
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "wrongname", Owner: holder, Currency: domain.CollectibleCurrencyStars, Amount: 100,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed mint: %v", err)
|
||||
}
|
||||
|
||||
dry, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "del-dry", Actor: "ops", Reason: "mistake", DryRun: true},
|
||||
Username: "@wrongname",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if !dry.DryRun || usernames.deleteCalls != 0 {
|
||||
t.Fatalf("dry run mutated: result=%+v calls=%d", dry, usernames.deleteCalls)
|
||||
}
|
||||
if dry.Details["previous_owner_id"] != "8801" {
|
||||
t.Fatalf("dry run details = %+v, want the holder captured", dry.Details)
|
||||
}
|
||||
|
||||
exec, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "del-exec", Actor: "ops", Reason: "mistake"},
|
||||
Username: "wrongname",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("exec: %v", err)
|
||||
}
|
||||
if usernames.deleteCalls != 1 || exec.Details["deleted"] != true {
|
||||
t.Fatalf("exec = %+v calls=%d", exec, usernames.deleteCalls)
|
||||
}
|
||||
if exec.Details["previous_status"] != string(domain.CollectibleUsernameStatusOwned) {
|
||||
t.Fatalf("journal lost the pre-delete state: %+v", exec.Details)
|
||||
}
|
||||
|
||||
// Replaying the same command id must not touch the store again.
|
||||
if _, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "del-exec", Actor: "ops", Reason: "mistake"},
|
||||
Username: "wrongname",
|
||||
}); err != nil {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
if usernames.deleteCalls != 1 {
|
||||
t.Fatalf("replay called the store again: calls=%d", usernames.deleteCalls)
|
||||
}
|
||||
|
||||
// A burned asset is history: it is released by re-issuing the name, not deleted.
|
||||
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "burnedname", Owner: holder, Currency: domain.CollectibleCurrencyStars, Amount: 100,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed burned mint: %v", err)
|
||||
}
|
||||
if _, _, err := usernames.Revoke(ctx, domain.RevokeCollectibleUsernameRequest{
|
||||
Username: "burnedname", Burn: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed burn: %v", err)
|
||||
}
|
||||
if _, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "del-burned", Actor: "ops", Reason: "cleanup"},
|
||||
Username: "burnedname",
|
||||
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleBurned) {
|
||||
t.Fatalf("delete of burned asset err = %v, want %s", err, CodeCollectibleBurned)
|
||||
}
|
||||
|
||||
// A short name is rejected before a command is journalled at all.
|
||||
if _, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "del-short", Actor: "ops", Reason: "cleanup"},
|
||||
Username: "no",
|
||||
}); err == nil {
|
||||
t.Fatalf("delete of invalid name = nil error, want rejection")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
529
internal/admin/verification.go
Normal file
529
internal/admin/verification.go
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Official platform verification review.
|
||||
//
|
||||
// The review actions live behind the same command journal as every other
|
||||
// operator write: a decision is auditable, replayable by command id and
|
||||
// rehearsable with a dry run. The application record itself is the durable audit
|
||||
// subject, so the details captured here name the application, its target and the
|
||||
// status it moved between, and they carry the correlation id that ties the
|
||||
// command journal entry to the immutable application event.
|
||||
|
||||
// VerificationService is the operator-facing slice of the official verification
|
||||
// use cases. It is the exact method set *app/verification.Service exposes for the
|
||||
// reviewer side, so the admin layer never reaches into the store.
|
||||
type VerificationService interface {
|
||||
List(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error)
|
||||
Counts(ctx context.Context) (domain.VerificationStatusCounts, error)
|
||||
Events(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error)
|
||||
Application(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
|
||||
TargetSnapshot(ctx context.Context, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationTarget, error)
|
||||
Claim(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, error)
|
||||
Approve(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error)
|
||||
Reject(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error)
|
||||
Revoke(ctx context.Context, req domain.VerificationRevocation) (domain.VerificationApplication, bool, error)
|
||||
}
|
||||
|
||||
// ClaimVerificationRequest assigns a reviewer to a submitted application.
|
||||
type ClaimVerificationRequest struct {
|
||||
CommandMeta
|
||||
ApplicationID int64 `json:"application_id"`
|
||||
// Version is the optimistic-locking token the reviewer read. Two reviewers
|
||||
// opening the same row submit the same version and exactly one wins.
|
||||
Version int64 `json:"version"`
|
||||
// InternalNote is optional on a claim: a reviewer picking a case up may want to
|
||||
// record why ("waiting on legal") without deciding it yet.
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// ApproveVerificationRequest grants the platform badge.
|
||||
type ApproveVerificationRequest struct {
|
||||
CommandMeta
|
||||
ApplicationID int64 `json:"application_id"`
|
||||
Version int64 `json:"version"`
|
||||
// InternalNote is operator-only. It is journalled and appended to the
|
||||
// application history, and it is never part of what the applicant is told.
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// RejectVerificationRequest closes an application against the applicant. Reason
|
||||
// is mandatory: it is the text the applicant receives.
|
||||
type RejectVerificationRequest struct {
|
||||
CommandMeta
|
||||
ApplicationID int64 `json:"application_id"`
|
||||
Version int64 `json:"version"`
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// RevokeVerificationRequest clears the badge of a previously approved target. It
|
||||
// addresses the target rather than an application, because a revocation is not a
|
||||
// decision on the application: the application stays approved as history.
|
||||
type RevokeVerificationRequest struct {
|
||||
CommandMeta
|
||||
TargetType domain.VerificationTargetType `json:"target_type"`
|
||||
TargetID int64 `json:"target_id"`
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// VerificationApplications is the review-queue listing. The filter is passed
|
||||
// through unchanged: the use-case layer owns normalisation and the page bound.
|
||||
func (s *Service) VerificationApplications(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return nil, errVerificationNotConfigured
|
||||
}
|
||||
return s.verification.List(ctx, filter)
|
||||
}
|
||||
|
||||
// VerificationCounts is the queue summary rendered above the list.
|
||||
func (s *Service) VerificationCounts(ctx context.Context) (domain.VerificationStatusCounts, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return nil, errVerificationNotConfigured
|
||||
}
|
||||
return s.verification.Counts(ctx)
|
||||
}
|
||||
|
||||
// VerificationApplication resolves one application by identity.
|
||||
func (s *Service) VerificationApplication(ctx context.Context, applicationID int64) (domain.VerificationApplication, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return domain.VerificationApplication{}, errVerificationNotConfigured
|
||||
}
|
||||
if applicationID <= 0 {
|
||||
return domain.VerificationApplication{}, verificationCoded(domain.ErrVerificationApplicationNotFound)
|
||||
}
|
||||
return s.verification.Application(ctx, applicationID)
|
||||
}
|
||||
|
||||
// VerificationApplicationEvents returns one application's immutable history.
|
||||
func (s *Service) VerificationApplicationEvents(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return nil, errVerificationNotConfigured
|
||||
}
|
||||
return s.verification.Events(ctx, applicationID, limit)
|
||||
}
|
||||
|
||||
// VerificationTargetSnapshot returns the target's state as it is now: title,
|
||||
// username, badge, and whether it would pass the eligibility checks today.
|
||||
func (s *Service) VerificationTargetSnapshot(ctx context.Context, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationTarget, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return domain.VerificationTarget{}, errVerificationNotConfigured
|
||||
}
|
||||
return s.verification.TargetSnapshot(ctx, targetType, targetID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ClaimVerification takes ownership of a submitted application.
|
||||
//
|
||||
// Claiming an application that somebody else already claimed is not idempotent:
|
||||
// the status machine has no in_review -> in_review edge, so the second reviewer
|
||||
// is told the row is taken instead of silently stealing it.
|
||||
func (s *Service) ClaimVerification(ctx context.Context, req ClaimVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return CommandResult{}, errVerificationNotConfigured
|
||||
}
|
||||
if err := validateVerificationDecisionShape(req.ApplicationID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionClaimVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
app, details, err := s.verificationSubject(ctx, req.CommandMeta, req.ApplicationID, domain.VerificationStatusInReview, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := verificationTransition(app, req.Version, domain.VerificationStatusInReview, false); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "verification claim validated", Details: details}, nil
|
||||
}
|
||||
claimed, err := s.verification.Claim(ctx, verificationDecision(req.CommandMeta, req.ApplicationID, req.Version, req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, verificationError(err)
|
||||
}
|
||||
mergeVerificationDetails(details, claimed, true)
|
||||
return CommandResult{Message: "verification application claimed", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ApproveVerification grants the platform badge.
|
||||
//
|
||||
// The dry run reloads the target snapshot and refuses in advance whatever the
|
||||
// real approval would refuse: the snapshot re-runs every eligibility check
|
||||
// except the ownership probe, and a missing ownership can only make the real run
|
||||
// stricter, never more permissive. So a passing dry run never turns into a
|
||||
// surprise, and a failing one names the reason before anything is written.
|
||||
func (s *Service) ApproveVerification(ctx context.Context, req ApproveVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return CommandResult{}, errVerificationNotConfigured
|
||||
}
|
||||
if err := validateVerificationDecisionShape(req.ApplicationID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionApproveVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
app, details, err := s.verificationSubject(ctx, req.CommandMeta, req.ApplicationID, domain.VerificationStatusApproved, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := verificationTransition(app, req.Version, domain.VerificationStatusApproved, true); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
// An application that is already approved is a replay: the use-case layer
|
||||
// returns it untouched without re-running the target checks, so the target
|
||||
// gate must not fire here either -- otherwise a retry would fail a dry run
|
||||
// that the real command answers as a no-op.
|
||||
replay := app.Status == domain.VerificationStatusApproved
|
||||
if err := s.mergeVerificationTargetDetails(ctx, details, app, !replay); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "verification approve validated", Details: details}, nil
|
||||
}
|
||||
approved, changed, err := s.verification.Approve(ctx, verificationDecision(req.CommandMeta, req.ApplicationID, req.Version, req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, verificationError(err)
|
||||
}
|
||||
mergeVerificationDetails(details, approved, changed)
|
||||
message := "verification application approved"
|
||||
if !changed {
|
||||
message = "verification application already approved"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RejectVerification closes an application against the applicant. The reason is
|
||||
// mandatory and is the text the applicant is shown; the internal note stays
|
||||
// operator-side.
|
||||
func (s *Service) RejectVerification(ctx context.Context, req RejectVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return CommandResult{}, errVerificationNotConfigured
|
||||
}
|
||||
if err := validateVerificationDecisionShape(req.ApplicationID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
// A rejection without a stated reason is refused before the journal is
|
||||
// touched: the audit trail must never contain a decision nobody can explain.
|
||||
if strings.TrimSpace(req.Reason) == "" {
|
||||
return CommandResult{}, codedError(CodeVerificationReasonRequired, domain.ErrVerificationReasonRequired)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRejectVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
app, details, err := s.verificationSubject(ctx, req.CommandMeta, req.ApplicationID, domain.VerificationStatusRejected, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := verificationTransition(app, req.Version, domain.VerificationStatusRejected, true); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "verification reject validated", Details: details}, nil
|
||||
}
|
||||
rejected, changed, err := s.verification.Reject(ctx, verificationDecision(req.CommandMeta, req.ApplicationID, req.Version, req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, verificationError(err)
|
||||
}
|
||||
mergeVerificationDetails(details, rejected, changed)
|
||||
message := "verification application rejected"
|
||||
if !changed {
|
||||
message = "verification application already rejected"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeVerification clears the platform badge from a target.
|
||||
//
|
||||
// It addresses the peer, not an application: the approved application stays
|
||||
// approved as history and the revocation is its own audit event. A reason is
|
||||
// mandatory for the same reason a rejection needs one.
|
||||
func (s *Service) RevokeVerification(ctx context.Context, req RevokeVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return CommandResult{}, errVerificationNotConfigured
|
||||
}
|
||||
if req.TargetID <= 0 || !req.TargetType.Valid() {
|
||||
return CommandResult{}, codedError(CodeVerificationTargetInvalid, domain.ErrVerificationTargetInvalid)
|
||||
}
|
||||
if utf8.RuneCountInString(req.InternalNote) > domain.MaxVerificationNoteLength {
|
||||
return CommandResult{}, verificationInvalid("internal_note is too long")
|
||||
}
|
||||
if strings.TrimSpace(req.Reason) == "" {
|
||||
return CommandResult{}, codedError(CodeVerificationReasonRequired, domain.ErrVerificationReasonRequired)
|
||||
}
|
||||
targetPeer := domain.Peer{Type: req.TargetType.PeerType(), ID: req.TargetID}
|
||||
targetUserID := int64(0)
|
||||
if targetPeer.Type == domain.PeerTypeUser {
|
||||
targetUserID = req.TargetID
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRevokeVerification, targetUserID, targetPeer, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"target_type": string(req.TargetType),
|
||||
"target_id": strconv.FormatInt(req.TargetID, 10),
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
if note := strings.TrimSpace(req.InternalNote); note != "" {
|
||||
details["internal_note"] = note
|
||||
}
|
||||
// A built-in system account carries its badge by construction, so revoking
|
||||
// it would desynchronise the seeded record from domain.SystemUserByID.
|
||||
if targetPeer.Type == domain.PeerTypeUser && domain.IsSystemUserID(req.TargetID) {
|
||||
return CommandResult{Details: details}, codedError(CodeVerificationTargetSystem, domain.ErrVerificationTargetSystem)
|
||||
}
|
||||
target, err := s.verification.TargetSnapshot(ctx, req.TargetType, req.TargetID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, verificationError(err)
|
||||
}
|
||||
details["target_title"] = target.Title
|
||||
details["target_username"] = target.Username
|
||||
details["target_verified"] = target.Verified
|
||||
if req.DryRun {
|
||||
message := "verification revoke validated"
|
||||
if !target.Verified {
|
||||
message = "verification revoke validated; target carries no badge"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
}
|
||||
app, changed, err := s.verification.Revoke(ctx, domain.VerificationRevocation{
|
||||
TargetType: req.TargetType,
|
||||
TargetID: req.TargetID,
|
||||
Reviewer: strings.TrimSpace(req.Actor),
|
||||
Reason: strings.TrimSpace(req.Reason),
|
||||
InternalNote: strings.TrimSpace(req.InternalNote),
|
||||
CorrelationID: strings.TrimSpace(req.CommandID),
|
||||
})
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, verificationError(err)
|
||||
}
|
||||
details["changed"] = changed
|
||||
details["target_verified"] = false
|
||||
if app.ID > 0 {
|
||||
// The revoked target usually has an approved application behind it; it
|
||||
// stays approved, which is why previous_status and status match here.
|
||||
details["application_id"] = strconv.FormatInt(app.ID, 10)
|
||||
details["applicant_user_id"] = strconv.FormatInt(app.ApplicantUserID, 10)
|
||||
details["previous_status"] = string(app.Status)
|
||||
details["status"] = string(app.Status)
|
||||
details["version"] = strconv.FormatInt(app.Version, 10)
|
||||
}
|
||||
message := "verification badge revoked"
|
||||
if !changed {
|
||||
message = "verification badge was already absent"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var errVerificationNotConfigured = errors.New("admin verification dependency is not configured")
|
||||
|
||||
// verificationSubject loads the application under decision and seeds the command
|
||||
// details with everything the audit entry must state even when the command then
|
||||
// fails: which application, whose, which target, the status it is leaving and the
|
||||
// status it was asked to reach.
|
||||
func (s *Service) verificationSubject(
|
||||
ctx context.Context,
|
||||
meta CommandMeta,
|
||||
applicationID int64,
|
||||
next domain.VerificationStatus,
|
||||
internalNote string,
|
||||
) (domain.VerificationApplication, map[string]any, error) {
|
||||
details := map[string]any{
|
||||
"application_id": strconv.FormatInt(applicationID, 10),
|
||||
"next_status": string(next),
|
||||
"correlation_id": strings.TrimSpace(meta.CommandID),
|
||||
}
|
||||
if note := strings.TrimSpace(internalNote); note != "" {
|
||||
details["internal_note"] = note
|
||||
}
|
||||
app, err := s.verification.Application(ctx, applicationID)
|
||||
if err != nil {
|
||||
return domain.VerificationApplication{}, details, verificationError(err)
|
||||
}
|
||||
details["applicant_user_id"] = strconv.FormatInt(app.ApplicantUserID, 10)
|
||||
details["target_type"] = string(app.TargetType)
|
||||
details["target_id"] = strconv.FormatInt(app.TargetID, 10)
|
||||
details["target_username"] = app.TargetUsername
|
||||
details["previous_status"] = string(app.Status)
|
||||
details["previous_version"] = strconv.FormatInt(app.Version, 10)
|
||||
return app, details, nil
|
||||
}
|
||||
|
||||
// mergeVerificationTargetDetails records the current target snapshot and, for a
|
||||
// decision that flips the badge, refuses a target the platform may not verify.
|
||||
func (s *Service) mergeVerificationTargetDetails(ctx context.Context, details map[string]any, app domain.VerificationApplication, requireEligible bool) error {
|
||||
target, err := s.verification.TargetSnapshot(ctx, app.TargetType, app.TargetID)
|
||||
if err != nil {
|
||||
return verificationError(err)
|
||||
}
|
||||
details["target_title"] = target.Title
|
||||
details["target_current_username"] = target.Username
|
||||
details["target_verified"] = target.Verified
|
||||
details["target_eligible"] = target.Eligible
|
||||
if target.Reason != "" {
|
||||
details["target_reason"] = target.Reason
|
||||
}
|
||||
if requireEligible && !target.Eligible {
|
||||
return verificationError(verificationTargetReasonError(target.Reason))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeVerificationDetails records the decided state.
|
||||
func mergeVerificationDetails(details map[string]any, app domain.VerificationApplication, changed bool) {
|
||||
details["status"] = string(app.Status)
|
||||
details["version"] = strconv.FormatInt(app.Version, 10)
|
||||
details["changed"] = changed
|
||||
details["reviewer_admin_id"] = app.ReviewerAdminID
|
||||
if app.CorrelationID != "" {
|
||||
details["correlation_id"] = app.CorrelationID
|
||||
}
|
||||
if app.DecisionReason != "" {
|
||||
details["decision_reason"] = app.DecisionReason
|
||||
}
|
||||
}
|
||||
|
||||
// verificationDecision builds the domain decision.
|
||||
//
|
||||
// The admin command id doubles as the correlation id, so one token links the
|
||||
// command journal entry, the immutable application event and the applicant
|
||||
// notification. The internal note travels in its own field and never in Reason,
|
||||
// which is what the applicant is shown.
|
||||
func verificationDecision(meta CommandMeta, applicationID, version int64, internalNote string) domain.VerificationDecision {
|
||||
return domain.VerificationDecision{
|
||||
ApplicationID: applicationID,
|
||||
Version: version,
|
||||
Reviewer: strings.TrimSpace(meta.Actor),
|
||||
Reason: strings.TrimSpace(meta.Reason),
|
||||
InternalNote: strings.TrimSpace(internalNote),
|
||||
CorrelationID: strings.TrimSpace(meta.CommandID),
|
||||
}
|
||||
}
|
||||
|
||||
// validateVerificationDecisionShape rejects a malformed decision before the
|
||||
// command journal is touched.
|
||||
func validateVerificationDecisionShape(applicationID, version int64, internalNote string) error {
|
||||
if applicationID <= 0 {
|
||||
return verificationCoded(domain.ErrVerificationApplicationNotFound)
|
||||
}
|
||||
if version <= 0 {
|
||||
// Without the version the reviewer never read the row, so the optimistic
|
||||
// lock could not protect a concurrent decision.
|
||||
return verificationInvalid("version is required")
|
||||
}
|
||||
if utf8.RuneCountInString(internalNote) > domain.MaxVerificationNoteLength {
|
||||
return verificationInvalid("internal_note is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verificationTransition checks the status machine and the optimistic lock in the
|
||||
// order the use-case layer does, so a dry run predicts the real outcome exactly.
|
||||
//
|
||||
// idempotent marks the decisions the service treats as a no-op on replay
|
||||
// (approve/reject of an already decided application). A claim is not among them:
|
||||
// there is no in_review -> in_review edge.
|
||||
func verificationTransition(app domain.VerificationApplication, version int64, next domain.VerificationStatus, idempotent bool) error {
|
||||
if idempotent && app.Status == next {
|
||||
return nil
|
||||
}
|
||||
if !domain.CanTransitionVerificationStatus(app.Status, next) {
|
||||
return codedError(CodeVerificationStatusInvalid, fmt.Errorf("%w: %s -> %s", domain.ErrVerificationStatusInvalid, app.Status, next))
|
||||
}
|
||||
if app.Version != version {
|
||||
return codedError(CodeVerificationConflict, domain.ErrVerificationVersionConflict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verificationTargetReasonError maps the snapshot's rendered ineligibility reason
|
||||
// back onto its domain sentinel. VerificationTarget.Reason is a string by
|
||||
// design -- it is rendered to applicants by the bot -- so the reverse lookup is
|
||||
// what lets the admin layer answer with a stable code instead of a bare message.
|
||||
func verificationTargetReasonError(reason string) error {
|
||||
for _, candidate := range []error{
|
||||
domain.ErrVerificationTargetAlreadyVerified,
|
||||
domain.ErrVerificationTargetRestricted,
|
||||
domain.ErrVerificationTargetNotPublic,
|
||||
domain.ErrVerificationTargetSystem,
|
||||
domain.ErrVerificationUserTargetsDisabled,
|
||||
domain.ErrVerificationTargetInvalid,
|
||||
} {
|
||||
if candidate.Error() == reason {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: %s", domain.ErrVerificationTargetInvalid, reason)
|
||||
}
|
||||
|
||||
// VerificationErrorCode maps a verification failure onto the stable code the
|
||||
// admin panel switches on. An unmapped error returns "" so the caller can report
|
||||
// it verbatim instead of inventing a code.
|
||||
func VerificationErrorCode(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case errors.Is(err, domain.ErrVerificationApplicationNotFound):
|
||||
return CodeVerificationNotFound
|
||||
case errors.Is(err, domain.ErrVerificationVersionConflict):
|
||||
return CodeVerificationConflict
|
||||
case errors.Is(err, domain.ErrVerificationApplicationExists):
|
||||
return CodeVerificationTargetOccupied
|
||||
case errors.Is(err, domain.ErrVerificationStatusInvalid):
|
||||
return CodeVerificationStatusInvalid
|
||||
case errors.Is(err, domain.ErrVerificationReasonRequired):
|
||||
return CodeVerificationReasonRequired
|
||||
case errors.Is(err, domain.ErrVerificationTargetAlreadyVerified):
|
||||
return CodeVerificationTargetVerified
|
||||
case errors.Is(err, domain.ErrVerificationTargetNotPublic):
|
||||
return CodeVerificationTargetNotPublic
|
||||
case errors.Is(err, domain.ErrVerificationTargetRestricted):
|
||||
return CodeVerificationTargetRestricted
|
||||
case errors.Is(err, domain.ErrVerificationTargetSystem):
|
||||
return CodeVerificationTargetSystem
|
||||
case errors.Is(err, domain.ErrVerificationNotOwner):
|
||||
return CodeVerificationNotOwner
|
||||
case errors.Is(err, domain.ErrVerificationUserTargetsDisabled):
|
||||
return CodeVerificationUserTargetsDisabled
|
||||
case errors.Is(err, domain.ErrVerificationTargetInvalid):
|
||||
return CodeVerificationTargetInvalid
|
||||
case errors.Is(err, domain.ErrVerificationURLInvalid),
|
||||
errors.Is(err, domain.ErrVerificationApplicationInvalid):
|
||||
return CodeVerificationInvalid
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// verificationError prefixes a recognised verification error with its stable
|
||||
// code, the way collectibleUsernameError does for the username registry.
|
||||
func verificationError(err error) error {
|
||||
if code := VerificationErrorCode(err); code != "" {
|
||||
return codedError(code, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func verificationCoded(err error) error {
|
||||
return codedError(VerificationErrorCode(err), err)
|
||||
}
|
||||
|
||||
func verificationInvalid(message string) error {
|
||||
return codedError(CodeVerificationInvalid, fmt.Errorf("%s: %w", message, domain.ErrVerificationApplicationInvalid))
|
||||
}
|
||||
629
internal/admin/verification_test.go
Normal file
629
internal/admin/verification_test.go
Normal file
|
|
@ -0,0 +1,629 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
verificationapp "telesrv/internal/app/verification"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Compile-time proof that the shipped use-case service satisfies the admin port.
|
||||
// cmd/telesrv wires *verification.Service into Dependencies.Verification
|
||||
// directly, so a drifting method set has to fail here rather than at integration
|
||||
// time.
|
||||
var _ VerificationService = (*verificationapp.Service)(nil)
|
||||
|
||||
type fakeVerificationService struct {
|
||||
app domain.VerificationApplication
|
||||
target domain.VerificationTarget
|
||||
events []domain.VerificationApplicationEvent
|
||||
counts domain.VerificationStatusCounts
|
||||
|
||||
appErr error
|
||||
targetErr error
|
||||
decideErr error
|
||||
|
||||
claimCalls int
|
||||
approveCalls int
|
||||
rejectCalls int
|
||||
revokeCalls int
|
||||
|
||||
claimed domain.VerificationDecision
|
||||
approved domain.VerificationDecision
|
||||
rejected domain.VerificationDecision
|
||||
revoked domain.VerificationRevocation
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) List(context.Context, domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
|
||||
return []domain.VerificationApplication{f.app}, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Counts(context.Context) (domain.VerificationStatusCounts, error) {
|
||||
return f.counts, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Events(context.Context, int64, int) ([]domain.VerificationApplicationEvent, error) {
|
||||
return f.events, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Application(_ context.Context, applicationID int64) (domain.VerificationApplication, error) {
|
||||
if f.appErr != nil {
|
||||
return domain.VerificationApplication{}, f.appErr
|
||||
}
|
||||
if f.app.ID != applicationID {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
||||
}
|
||||
return f.app, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) TargetSnapshot(context.Context, domain.VerificationTargetType, int64) (domain.VerificationTarget, error) {
|
||||
if f.targetErr != nil {
|
||||
return domain.VerificationTarget{}, f.targetErr
|
||||
}
|
||||
return f.target, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Claim(_ context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, error) {
|
||||
f.claimCalls++
|
||||
f.claimed = decision
|
||||
if f.decideErr != nil {
|
||||
return domain.VerificationApplication{}, f.decideErr
|
||||
}
|
||||
f.app.Status = domain.VerificationStatusInReview
|
||||
f.app.ReviewerAdminID = decision.Reviewer
|
||||
f.app.Version++
|
||||
f.app.CorrelationID = decision.CorrelationID
|
||||
return f.app, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Approve(_ context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error) {
|
||||
f.approveCalls++
|
||||
f.approved = decision
|
||||
if f.decideErr != nil {
|
||||
return domain.VerificationApplication{}, false, f.decideErr
|
||||
}
|
||||
f.app.Status = domain.VerificationStatusApproved
|
||||
f.app.ReviewerAdminID = decision.Reviewer
|
||||
f.app.InternalNote = decision.InternalNote
|
||||
f.app.Version++
|
||||
f.app.CorrelationID = decision.CorrelationID
|
||||
return f.app, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Reject(_ context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error) {
|
||||
f.rejectCalls++
|
||||
f.rejected = decision
|
||||
if f.decideErr != nil {
|
||||
return domain.VerificationApplication{}, false, f.decideErr
|
||||
}
|
||||
f.app.Status = domain.VerificationStatusRejected
|
||||
f.app.ReviewerAdminID = decision.Reviewer
|
||||
f.app.DecisionReason = decision.Reason
|
||||
f.app.InternalNote = decision.InternalNote
|
||||
f.app.Version++
|
||||
f.app.CorrelationID = decision.CorrelationID
|
||||
return f.app, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Revoke(_ context.Context, req domain.VerificationRevocation) (domain.VerificationApplication, bool, error) {
|
||||
f.revokeCalls++
|
||||
f.revoked = req
|
||||
if f.decideErr != nil {
|
||||
return domain.VerificationApplication{}, false, f.decideErr
|
||||
}
|
||||
f.target.Verified = false
|
||||
return f.app, true, nil
|
||||
}
|
||||
|
||||
func submittedVerificationApplication() domain.VerificationApplication {
|
||||
return domain.VerificationApplication{
|
||||
ID: 77,
|
||||
ApplicantUserID: 1001,
|
||||
TargetType: domain.VerificationTargetChannel,
|
||||
TargetID: 5005,
|
||||
TargetTitle: "Example News",
|
||||
TargetUsername: "examplenews",
|
||||
Category: "media",
|
||||
Status: domain.VerificationStatusSubmitted,
|
||||
SubmittedAt: fixedNow(),
|
||||
Version: 3,
|
||||
}
|
||||
}
|
||||
|
||||
func newVerificationFixture() (*Service, *fakeVerificationService, *memoryCommandRepo) {
|
||||
repo := newMemoryCommandRepo()
|
||||
verification := &fakeVerificationService{
|
||||
app: submittedVerificationApplication(),
|
||||
target: domain.VerificationTarget{
|
||||
Type: domain.VerificationTargetChannel, ID: 5005,
|
||||
Title: "Example News", Username: "examplenews", Eligible: true,
|
||||
},
|
||||
}
|
||||
svc := NewService(Dependencies{Commands: repo, Verification: verification, Now: fixedNow})
|
||||
return svc, verification, repo
|
||||
}
|
||||
|
||||
func TestClaimVerificationDryRunExecuteAndIdempotentReplay(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, repo := newVerificationFixture()
|
||||
|
||||
dry, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-claim", Actor: "alice", Reason: "picking up the queue", DryRun: true},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run claim: %v", err)
|
||||
}
|
||||
if !dry.DryRun || dry.Status != string(domain.AdminCommandCompleted) || verification.claimCalls != 0 {
|
||||
t.Fatalf("dry-run result=%+v claimCalls=%d, want a completed dry run without mutation", dry, verification.claimCalls)
|
||||
}
|
||||
if dry.Details["previous_status"] != string(domain.VerificationStatusSubmitted) ||
|
||||
dry.Details["next_status"] != string(domain.VerificationStatusInReview) ||
|
||||
dry.Details["application_id"] != "77" || dry.Details["target_id"] != "5005" ||
|
||||
dry.Details["target_type"] != string(domain.VerificationTargetChannel) ||
|
||||
dry.Details["correlation_id"] != "dry-claim" {
|
||||
t.Fatalf("dry-run details=%+v, want the audit facts seeded before execution", dry.Details)
|
||||
}
|
||||
|
||||
execReq := ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-claim", Actor: "alice", Reason: "picking up the queue"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
}
|
||||
exec, err := svc.ClaimVerification(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("execute claim: %v", err)
|
||||
}
|
||||
if verification.claimCalls != 1 || exec.Status != string(domain.AdminCommandCompleted) {
|
||||
t.Fatalf("execute result=%+v claimCalls=%d", exec, verification.claimCalls)
|
||||
}
|
||||
if exec.Details["status"] != string(domain.VerificationStatusInReview) ||
|
||||
exec.Details["reviewer_admin_id"] != "alice" || exec.Details["version"] != "4" {
|
||||
t.Fatalf("execute details=%+v", exec.Details)
|
||||
}
|
||||
// The command id is the correlation id, so the journal entry and the
|
||||
// application event can be matched up afterwards.
|
||||
if verification.claimed.CorrelationID != "exec-claim" || verification.claimed.Reviewer != "alice" {
|
||||
t.Fatalf("claim decision=%+v", verification.claimed)
|
||||
}
|
||||
if _, ok := repo.items["exec-claim"]; !ok {
|
||||
t.Fatal("claim was not journalled")
|
||||
}
|
||||
|
||||
replay, err := svc.ClaimVerification(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("replay claim: %v", err)
|
||||
}
|
||||
if !replay.AlreadyExecuted || verification.claimCalls != 1 {
|
||||
t.Fatalf("replay result=%+v claimCalls=%d, want an idempotent replay", replay, verification.claimCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimVerificationRefusesAlreadyClaimedApplication(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusInReview
|
||||
|
||||
_, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "claim-taken", Actor: "bob", Reason: "second reviewer"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationStatusInvalid) {
|
||||
t.Fatalf("claim of a claimed application err=%v, want ErrVerificationStatusInvalid", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), CodeVerificationStatusInvalid) || verification.claimCalls != 0 {
|
||||
t.Fatalf("err=%v claimCalls=%d, want the stable code and no mutation", err, verification.claimCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveVerificationVersionConflictIsNotAMutation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusInReview
|
||||
|
||||
_, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-stale", Actor: "alice", Reason: "docs check out"},
|
||||
ApplicationID: 77,
|
||||
// The reviewer read version 3 but somebody else already advanced the row.
|
||||
Version: 2,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationVersionConflict) {
|
||||
t.Fatalf("stale approve err=%v, want ErrVerificationVersionConflict", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), CodeVerificationConflict) || verification.approveCalls != 0 {
|
||||
t.Fatalf("err=%v approveCalls=%d", err, verification.approveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveVerificationRefusesIneligibleTargetBeforeWriting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusInReview
|
||||
verification.target.Eligible = false
|
||||
verification.target.Verified = true
|
||||
verification.target.Reason = domain.ErrVerificationTargetAlreadyVerified.Error()
|
||||
|
||||
result, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-verified", Actor: "alice", Reason: "docs check out", DryRun: true},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationTargetAlreadyVerified) {
|
||||
t.Fatalf("approve of a verified target err=%v, want ErrVerificationTargetAlreadyVerified", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), CodeVerificationTargetVerified) {
|
||||
t.Fatalf("err=%v, want the stable code", err)
|
||||
}
|
||||
if result.Details["target_eligible"] != false || result.Details["target_verified"] != true {
|
||||
t.Fatalf("details=%+v, want the snapshot recorded on the failed command", result.Details)
|
||||
}
|
||||
if verification.approveCalls != 0 {
|
||||
t.Fatalf("approveCalls=%d, want the dry run to predict the refusal", verification.approveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveVerificationKeepsInternalNoteOutOfTheApplicantReason(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, repo := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusInReview
|
||||
|
||||
result, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-77", Actor: "alice", Reason: "press coverage verified"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
InternalNote: "contact reached us through the press office; do not quote",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("approve: %v", err)
|
||||
}
|
||||
if verification.approved.Reason != "press coverage verified" {
|
||||
t.Fatalf("applicant-facing reason=%q", verification.approved.Reason)
|
||||
}
|
||||
if verification.approved.InternalNote != "contact reached us through the press office; do not quote" {
|
||||
t.Fatalf("internal note=%q, want it carried in its own field", verification.approved.InternalNote)
|
||||
}
|
||||
if strings.Contains(verification.approved.Reason, "do not quote") {
|
||||
t.Fatal("internal note leaked into the applicant-facing reason")
|
||||
}
|
||||
// The note is operator-only but must still be auditable.
|
||||
if result.Details["internal_note"] != "contact reached us through the press office; do not quote" {
|
||||
t.Fatalf("details=%+v, want the internal note journalled", result.Details)
|
||||
}
|
||||
if stored := repo.items["approve-77"].ResultJSON; !strings.Contains(string(stored), "do not quote") {
|
||||
t.Fatalf("journalled result=%s, want the internal note persisted", stored)
|
||||
}
|
||||
}
|
||||
|
||||
// A retried approval of an already approved application is a no-op in the
|
||||
// use-case layer, so the dry run must not fail it on the "already verified"
|
||||
// target gate the first approval passed.
|
||||
func TestApproveVerificationReplayIsNotBlockedByTheTargetGate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusApproved
|
||||
verification.app.ReviewerAdminID = "alice"
|
||||
verification.target.Verified = true
|
||||
verification.target.Eligible = false
|
||||
verification.target.Reason = domain.ErrVerificationTargetAlreadyVerified.Error()
|
||||
|
||||
dry, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-replay-dry", Actor: "alice", Reason: "docs check out", DryRun: true},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run replay approve: %v", err)
|
||||
}
|
||||
if verification.approveCalls != 0 || dry.Details["target_verified"] != true {
|
||||
t.Fatalf("dry-run replay details=%+v approveCalls=%d", dry.Details, verification.approveCalls)
|
||||
}
|
||||
|
||||
exec, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-replay", Actor: "alice", Reason: "docs check out"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("replay approve: %v", err)
|
||||
}
|
||||
if exec.Details["changed"] != true {
|
||||
// The fake always reports a change; the point is that the command reached
|
||||
// the service instead of being refused by the pre-check.
|
||||
t.Fatalf("replay approve details=%+v", exec.Details)
|
||||
}
|
||||
if verification.approveCalls != 1 {
|
||||
t.Fatalf("approveCalls=%d, want the replay handed to the use-case layer", verification.approveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectVerificationRequiresReason(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, repo := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusInReview
|
||||
|
||||
_, err := svc.RejectVerification(ctx, RejectVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "reject-no-reason", Actor: "alice", Reason: " "},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationReasonRequired) {
|
||||
t.Fatalf("reject without reason err=%v, want ErrVerificationReasonRequired", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), CodeVerificationReasonRequired) {
|
||||
t.Fatalf("err=%v, want the stable code", err)
|
||||
}
|
||||
if verification.rejectCalls != 0 || len(repo.items) != 0 {
|
||||
t.Fatalf("rejectCalls=%d journalled=%d, want a refusal before the journal is touched",
|
||||
verification.rejectCalls, len(repo.items))
|
||||
}
|
||||
|
||||
ok, err := svc.RejectVerification(ctx, RejectVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "reject-77", Actor: "alice", Reason: "press links are self-published"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reject: %v", err)
|
||||
}
|
||||
if verification.rejectCalls != 1 || verification.rejected.Reason != "press links are self-published" {
|
||||
t.Fatalf("rejectCalls=%d decision=%+v", verification.rejectCalls, verification.rejected)
|
||||
}
|
||||
if ok.Details["status"] != string(domain.VerificationStatusRejected) ||
|
||||
ok.Details["decision_reason"] != "press links are self-published" {
|
||||
t.Fatalf("details=%+v", ok.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeVerificationRequiresReasonAndTargetShape(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, repo := newVerificationFixture()
|
||||
verification.target.Verified = true
|
||||
|
||||
_, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "revoke-no-reason", Actor: "alice"},
|
||||
TargetType: domain.VerificationTargetChannel,
|
||||
TargetID: 5005,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationReasonRequired) {
|
||||
t.Fatalf("revoke without reason err=%v, want ErrVerificationReasonRequired", err)
|
||||
}
|
||||
|
||||
_, err = svc.RevokeVerification(ctx, RevokeVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "revoke-bad-target", Actor: "alice", Reason: "impersonation"},
|
||||
TargetType: domain.VerificationTargetType("group"),
|
||||
TargetID: 5005,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationTargetInvalid) {
|
||||
t.Fatalf("revoke of an unmodelled target err=%v, want ErrVerificationTargetInvalid", err)
|
||||
}
|
||||
if verification.revokeCalls != 0 || len(repo.items) != 0 {
|
||||
t.Fatalf("revokeCalls=%d journalled=%d, want refusals before the journal", verification.revokeCalls, len(repo.items))
|
||||
}
|
||||
|
||||
dry, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-revoke", Actor: "alice", Reason: "impersonation confirmed", DryRun: true},
|
||||
TargetType: domain.VerificationTargetChannel,
|
||||
TargetID: 5005,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run revoke: %v", err)
|
||||
}
|
||||
if verification.revokeCalls != 0 || dry.Details["target_verified"] != true {
|
||||
t.Fatalf("dry-run revoke details=%+v revokeCalls=%d", dry.Details, verification.revokeCalls)
|
||||
}
|
||||
|
||||
exec, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-revoke", Actor: "alice", Reason: "impersonation confirmed"},
|
||||
TargetType: domain.VerificationTargetChannel,
|
||||
TargetID: 5005,
|
||||
InternalNote: "legal asked for the takedown",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute revoke: %v", err)
|
||||
}
|
||||
if verification.revokeCalls != 1 || verification.revoked.Reason != "impersonation confirmed" ||
|
||||
verification.revoked.InternalNote != "legal asked for the takedown" ||
|
||||
verification.revoked.CorrelationID != "exec-revoke" {
|
||||
t.Fatalf("revocation=%+v", verification.revoked)
|
||||
}
|
||||
if exec.Details["target_verified"] != false || exec.Details["changed"] != true ||
|
||||
exec.Details["application_id"] != "77" {
|
||||
t.Fatalf("execute revoke details=%+v", exec.Details)
|
||||
}
|
||||
if exec.TargetPeer != (domain.Peer{Type: domain.PeerTypeChannel, ID: 5005}) {
|
||||
t.Fatalf("journalled target peer=%+v", exec.TargetPeer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeVerificationRefusesSystemAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
|
||||
result, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "revoke-system", Actor: "alice", Reason: "cleanup"},
|
||||
TargetType: domain.VerificationTargetBot,
|
||||
TargetID: domain.BotFatherUserID,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationTargetSystem) {
|
||||
t.Fatalf("revoke of a system account err=%v, want ErrVerificationTargetSystem", err)
|
||||
}
|
||||
if verification.revokeCalls != 0 || result.Details["target_id"] == nil {
|
||||
t.Fatalf("revokeCalls=%d details=%+v", verification.revokeCalls, result.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationDecisionShapeIsValidatedBeforeTheJournal(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, _, repo := newVerificationFixture()
|
||||
|
||||
if _, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "claim-no-id", Actor: "alice", Reason: "queue"},
|
||||
ApplicationID: 0,
|
||||
Version: 3,
|
||||
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
||||
t.Fatalf("claim without an application err=%v", err)
|
||||
}
|
||||
if _, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "claim-no-version", Actor: "alice", Reason: "queue"},
|
||||
ApplicationID: 77,
|
||||
Version: 0,
|
||||
}); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
|
||||
t.Fatalf("claim without a version err=%v", err)
|
||||
}
|
||||
if _, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-long-note", Actor: "alice", Reason: "queue"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
InternalNote: strings.Repeat("x", domain.MaxVerificationNoteLength+1),
|
||||
}); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
|
||||
t.Fatalf("approve with an oversized note err=%v", err)
|
||||
}
|
||||
if len(repo.items) != 0 {
|
||||
t.Fatalf("journalled=%d, want malformed decisions refused before the journal", len(repo.items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationMissingApplicationIsJournalledAsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, repo := newVerificationFixture()
|
||||
verification.appErr = domain.ErrVerificationApplicationNotFound
|
||||
|
||||
result, err := svc.RejectVerification(ctx, RejectVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "reject-missing", Actor: "alice", Reason: "not eligible"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
||||
t.Fatalf("reject of a missing application err=%v", err)
|
||||
}
|
||||
if VerificationErrorCode(err) != CodeVerificationNotFound {
|
||||
t.Fatalf("code=%q", VerificationErrorCode(err))
|
||||
}
|
||||
if result.Status != string(domain.AdminCommandFailed) {
|
||||
t.Fatalf("result=%+v, want a failed command", result)
|
||||
}
|
||||
if cmd, ok := repo.items["reject-missing"]; !ok || cmd.Status != domain.AdminCommandFailed {
|
||||
t.Fatalf("journalled command=%+v ok=%v, want the failure recorded", cmd, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationReadsRequireTheDependency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Now: fixedNow})
|
||||
if _, err := svc.VerificationApplications(ctx, domain.VerificationApplicationFilter{}); err == nil {
|
||||
t.Fatal("listing without the dependency succeeded")
|
||||
}
|
||||
if _, err := svc.VerificationCounts(ctx); err == nil {
|
||||
t.Fatal("counting without the dependency succeeded")
|
||||
}
|
||||
if _, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{ApplicationID: 1, Version: 1}); err == nil {
|
||||
t.Fatal("claiming without the dependency succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationReadsPassThrough(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.counts = domain.VerificationStatusCounts{domain.VerificationStatusSubmitted: 3}
|
||||
verification.events = []domain.VerificationApplicationEvent{{
|
||||
ID: 9, ApplicationID: 77, Kind: domain.VerificationEventSubmitted,
|
||||
ToStatus: domain.VerificationStatusSubmitted, CreatedAt: fixedNow(),
|
||||
}}
|
||||
|
||||
items, err := svc.VerificationApplications(ctx, domain.VerificationApplicationFilter{Limit: 10})
|
||||
if err != nil || len(items) != 1 || items[0].ID != 77 {
|
||||
t.Fatalf("applications=%+v err=%v", items, err)
|
||||
}
|
||||
counts, err := svc.VerificationCounts(ctx)
|
||||
if err != nil || counts[domain.VerificationStatusSubmitted] != 3 {
|
||||
t.Fatalf("counts=%+v err=%v", counts, err)
|
||||
}
|
||||
events, err := svc.VerificationApplicationEvents(ctx, 77, 10)
|
||||
if err != nil || len(events) != 1 || events[0].ID != 9 {
|
||||
t.Fatalf("events=%+v err=%v", events, err)
|
||||
}
|
||||
app, err := svc.VerificationApplication(ctx, 77)
|
||||
if err != nil || app.Version != 3 {
|
||||
t.Fatalf("application=%+v err=%v", app, err)
|
||||
}
|
||||
if _, err := svc.VerificationApplication(ctx, 0); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
||||
t.Fatalf("application(0) err=%v", err)
|
||||
}
|
||||
target, err := svc.VerificationTargetSnapshot(ctx, domain.VerificationTargetChannel, 5005)
|
||||
if err != nil || target.ID != 5005 {
|
||||
t.Fatalf("target=%+v err=%v", target, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationErrorCodeCoversTheDomainSentinels(t *testing.T) {
|
||||
cases := map[error]string{
|
||||
domain.ErrVerificationApplicationNotFound: CodeVerificationNotFound,
|
||||
domain.ErrVerificationVersionConflict: CodeVerificationConflict,
|
||||
domain.ErrVerificationApplicationExists: CodeVerificationTargetOccupied,
|
||||
domain.ErrVerificationStatusInvalid: CodeVerificationStatusInvalid,
|
||||
domain.ErrVerificationReasonRequired: CodeVerificationReasonRequired,
|
||||
domain.ErrVerificationTargetAlreadyVerified: CodeVerificationTargetVerified,
|
||||
domain.ErrVerificationTargetNotPublic: CodeVerificationTargetNotPublic,
|
||||
domain.ErrVerificationTargetRestricted: CodeVerificationTargetRestricted,
|
||||
domain.ErrVerificationTargetSystem: CodeVerificationTargetSystem,
|
||||
domain.ErrVerificationNotOwner: CodeVerificationNotOwner,
|
||||
domain.ErrVerificationUserTargetsDisabled: CodeVerificationUserTargetsDisabled,
|
||||
domain.ErrVerificationTargetInvalid: CodeVerificationTargetInvalid,
|
||||
domain.ErrVerificationApplicationInvalid: CodeVerificationInvalid,
|
||||
errors.New("some transport failure nobody mapped"): "",
|
||||
}
|
||||
for err, want := range cases {
|
||||
if got := VerificationErrorCode(err); got != want {
|
||||
t.Fatalf("VerificationErrorCode(%v) = %q, want %q", err, got, want)
|
||||
}
|
||||
}
|
||||
if got := VerificationErrorCode(nil); got != "" {
|
||||
t.Fatalf("VerificationErrorCode(nil) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationTargetReasonErrorRoundTripsTheSnapshotString(t *testing.T) {
|
||||
for _, sentinel := range []error{
|
||||
domain.ErrVerificationTargetAlreadyVerified,
|
||||
domain.ErrVerificationTargetRestricted,
|
||||
domain.ErrVerificationTargetNotPublic,
|
||||
domain.ErrVerificationTargetSystem,
|
||||
domain.ErrVerificationUserTargetsDisabled,
|
||||
domain.ErrVerificationTargetInvalid,
|
||||
} {
|
||||
if err := verificationTargetReasonError(sentinel.Error()); !errors.Is(err, sentinel) {
|
||||
t.Fatalf("verificationTargetReasonError(%q) = %v", sentinel.Error(), err)
|
||||
}
|
||||
}
|
||||
// An unrecognised reason must still be a target failure rather than a panic or
|
||||
// a silent pass.
|
||||
if err := verificationTargetReasonError("something new"); !errors.Is(err, domain.ErrVerificationTargetInvalid) {
|
||||
t.Fatalf("unknown reason mapped to %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationClockIsNotRequiredForDetails(t *testing.T) {
|
||||
// The details are pure projections of the application, so a service without a
|
||||
// wall clock still produces a complete audit entry.
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.app.UpdatedAt = time.Time{}
|
||||
result, err := svc.ClaimVerification(context.Background(), ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "claim-clockless", Actor: "alice", Reason: "queue"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
for _, key := range []string{"application_id", "applicant_user_id", "target_type", "target_id", "previous_status", "status", "correlation_id"} {
|
||||
if _, ok := result.Details[key]; !ok {
|
||||
t.Fatalf("details %+v missing %q", result.Details, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue