feat: add NFT usernames and bot verification (#22)

Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review.

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

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
This commit is contained in:
Egor Egorov 2026-07-27 20:18:00 +03:00 committed by GitHub
parent b0fd3976f1
commit fff8de783a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
169 changed files with 55769 additions and 282 deletions

View 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))
}

File diff suppressed because it is too large Load diff

View file

@ -45,6 +45,34 @@ const (
ActionGiveGift = "gifts.give"
ActionCreateBot = "bot.create"
ActionDeleteBot = "bot.delete"
// Collectible (Fragment-style) username lifecycle.
ActionMintCollectibleUsername = "usernames.collectible.mint"
ActionTransferCollectibleUsername = "usernames.collectible.transfer"
ActionRevokeCollectibleUsername = "usernames.collectible.revoke"
ActionDeleteCollectibleUsername = "usernames.collectible.delete"
// Composite account rating.
ActionRecomputeAccountRating = "rating.recompute"
ActionAdjustAccountRating = "rating.adjust"
// Official platform verification review. Claim/approve/reject act on one
// application; revoke acts on a target, because clearing a badge is not a
// decision on the application that granted it.
ActionClaimVerification = "verification.claim"
ActionApproveVerification = "verification.approve"
ActionRejectVerification = "verification.reject"
ActionRevokeVerification = "verification.revoke"
// Third-party bot verification (see botverification.go). A namespace of its own
// on purpose: these actions write the verifier catalogue and the attributed
// marks, never the platform checkmark, and the audit trail has to keep the two
// mechanisms apart at a glance.
ActionGrantBotVerifier = "botverification.grant_verifier"
ActionSetBotVerifierEnabled = "botverification.set_verifier_enabled"
ActionRevokeBotVerifier = "botverification.revoke_verifier"
ActionUpsertVerificationIcon = "botverification.upsert_icon"
ActionSetVerificationIconActive = "botverification.set_icon_active"
ActionRevokeCustomVerification = "botverification.revoke_mark"
ActionApproveBotVerification = "botverification.approve"
ActionRejectBotVerification = "botverification.reject"
ActionRevokeBotVerification = "botverification.revoke_request"
maxCommandIDLength = 128
maxActorLength = 128
@ -53,6 +81,79 @@ const (
maxPremiumMonths = 120
maxStarsGrant = 1_000_000_000
maxFreezeAppealURLLength = 2048
// maxAccountRatingAdjustment bounds one manual rating adjustment in either
// direction. The domain only rejects a zero delta, so the operator-facing
// bound lives here next to the other grant limits.
maxAccountRatingAdjustment = 1_000_000_000
)
// Stable admin error codes for the collectible-username and account-rating
// subsystems. The panel switches on the code, so a command failure and a read
// failure describe the same condition with the same token instead of leaking a
// Go error string the UI would have to pattern-match.
const (
CodeUsernameOccupied = "USERNAME_OCCUPIED"
CodeUsernameInvalid = "USERNAME_INVALID"
CodeUsernameNotCollectible = "USERNAME_NOT_COLLECTIBLE"
CodeCollectibleNotFound = "COLLECTIBLE_NOT_FOUND"
CodeCollectibleNotOwned = "COLLECTIBLE_NOT_OWNED"
CodeCollectibleBurned = "COLLECTIBLE_BURNED"
CodeCollectiblePeerLimit = "COLLECTIBLE_PEER_LIMIT"
CodeCollectibleCurrencyInvalid = "COLLECTIBLE_CURRENCY_INVALID"
CodeCollectibleStateInvalid = "COLLECTIBLE_STATE_INVALID"
CodeRatingNotFound = "RATING_NOT_FOUND"
CodeRatingAdjustmentInvalid = "RATING_ADJUSTMENT_INVALID"
CodeRatingWeightsInvalid = "RATING_WEIGHTS_INVALID"
// Official platform verification review. CodeVerificationConflict is the lost
// optimistic-locking race -- two reviewers deciding at once -- and is the one
// the panel must render as "reload and look again" rather than as a bad
// request.
CodeVerificationNotFound = "VERIFICATION_NOT_FOUND"
CodeVerificationConflict = "VERIFICATION_CONFLICT"
CodeVerificationStatusInvalid = "VERIFICATION_STATUS_INVALID"
CodeVerificationReasonRequired = "VERIFICATION_REASON_REQUIRED"
CodeVerificationTargetInvalid = "VERIFICATION_TARGET_INVALID"
CodeVerificationTargetOccupied = "VERIFICATION_TARGET_OCCUPIED"
CodeVerificationTargetVerified = "VERIFICATION_TARGET_ALREADY_VERIFIED"
CodeVerificationTargetNotPublic = "VERIFICATION_TARGET_NOT_PUBLIC"
CodeVerificationTargetRestricted = "VERIFICATION_TARGET_RESTRICTED"
CodeVerificationTargetSystem = "VERIFICATION_TARGET_SYSTEM"
CodeVerificationNotOwner = "VERIFICATION_NOT_OWNER"
CodeVerificationUserTargetsDisabled = "VERIFICATION_USER_TARGETS_DISABLED"
CodeVerificationInvalid = "VERIFICATION_INVALID"
)
// Stable admin error codes for third-party bot verification (see
// botverification.go). They are a separate set from the official verification
// codes above: the two mechanisms own separate tables and fail for separate
// reasons, and the panel renders them in separate sections, so one shared token
// would land a message in the wrong place.
//
// CodeCustomVerificationConflict is the lost optimistic-locking race -- two
// operators deciding at once -- and is the one the panel must render as "reload
// and look again" rather than as a bad request.
const (
CodeBotVerifierNotFound = "BOTVERIFIER_NOT_FOUND"
CodeBotVerifierForbidden = "BOTVERIFIER_FORBIDDEN"
CodeBotVerifierInvalid = "BOTVERIFIER_INVALID"
CodeBotVerifierBotNotFound = "BOTVERIFIER_BOT_NOT_FOUND"
CodeBotVerifierDescriptionForbidden = "BOTVERIFIER_DESCRIPTION_FORBIDDEN"
CodeVerificationIconNotFound = "VERIFICATION_ICON_NOT_FOUND"
CodeVerificationIconInactive = "VERIFICATION_ICON_INACTIVE"
CodeVerificationIconInvalid = "VERIFICATION_ICON_INVALID"
CodeCustomVerificationNotFound = "CUSTOM_VERIFICATION_NOT_FOUND"
CodeCustomVerificationRequestNotFound = "CUSTOM_VERIFICATION_REQUEST_NOT_FOUND"
CodeCustomVerificationRequestExists = "CUSTOM_VERIFICATION_REQUEST_EXISTS"
CodeCustomVerificationConflict = "CUSTOM_VERIFICATION_CONFLICT"
CodeCustomVerificationLimit = "CUSTOM_VERIFICATION_LIMIT"
CodeCustomVerificationStatusInvalid = "CUSTOM_VERIFICATION_STATUS_INVALID"
CodeCustomVerificationReasonRequired = "CUSTOM_VERIFICATION_REASON_REQUIRED"
CodeCustomVerificationTargetInvalid = "CUSTOM_VERIFICATION_TARGET_INVALID"
CodeCustomVerificationTargetSystem = "CUSTOM_VERIFICATION_TARGET_SYSTEM"
CodeCustomVerificationRateLimited = "CUSTOM_VERIFICATION_RATE_LIMITED"
CodeCustomVerificationInvalid = "CUSTOM_VERIFICATION_INVALID"
)
type CommandRepository interface {
@ -179,6 +280,39 @@ type ModerationService interface {
ReviewAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
}
// CollectibleUsernamesService is the operator-facing slice of the collectible
// username use cases: the mint/transfer/revoke lifecycle plus the reads the
// admin panel explains an asset with. It is deliberately narrow -- the client
// facing toggle/reorder entry points stay out of the admin surface, because the
// editable slot and the row order belong to the peer, not to the operator.
type CollectibleUsernamesService interface {
Mint(ctx context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error)
Transfer(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error)
Revoke(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error)
Delete(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error)
Collectible(ctx context.Context, username string) (domain.CollectibleUsername, error)
List(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
Transfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
}
// collectibleUsernameByIDLookup is the optional by-identity read. Stores that
// expose it answer a detail request in one round trip; the keyset fallback in
// CollectibleUsernameByID keeps a service without it correct.
type collectibleUsernameByIDLookup interface {
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
}
// AccountRatingService is the operator-facing slice of the composite account
// rating use cases: read the stored projection, force a recompute, adjust the
// manual component and page the ledger that explains a level.
type AccountRatingService interface {
Rating(ctx context.Context, userID int64) (domain.AccountRating, error)
Recompute(ctx context.Context, userID int64) (domain.AccountRating, error)
Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error)
List(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error)
Events(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error)
}
// GiftGranter delivers a catalog gift to a recipient peer on behalf of a sender
// without charging Stars. Implemented by the RPC router, it reuses the standard
// gift-delivery path (service message for users, saved-gift + admin log for
@ -207,7 +341,13 @@ type Dependencies struct {
Bots BotService
Emoji EmojiService
Moderation ModerationService
Now func() time.Time
Usernames CollectibleUsernamesService
Rating AccountRatingService
Verification VerificationService
// BotVerification is the third-party mechanism, wired separately from
// Verification: the two never read each other's state.
BotVerification BotVerificationService
Now func() time.Time
}
type Service struct {
@ -230,6 +370,10 @@ type Service struct {
bots BotService
emoji EmojiService
moderation ModerationService
usernames CollectibleUsernamesService
rating AccountRatingService
verification VerificationService
botVerification BotVerificationService
now func() time.Time
}
@ -296,6 +440,18 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.Moderation != nil {
s.moderation = deps.Moderation
}
if deps.Usernames != nil {
s.usernames = deps.Usernames
}
if deps.Rating != nil {
s.rating = deps.Rating
}
if deps.Verification != nil {
s.verification = deps.Verification
}
if deps.BotVerification != nil {
s.botVerification = deps.BotVerification
}
if deps.Now != nil {
s.now = deps.Now
}
@ -360,6 +516,87 @@ func (s *Service) ReviewModerationAppeal(ctx context.Context, request domain.Mod
return s.moderation.ReviewAppeal(ctx, request)
}
// CollectibleUsernames is the admin listing read. The filter is passed through
// unchanged: the use-case layer owns normalisation and the page bound, so the
// admin API and the RPC edge page the registry identically.
func (s *Service) CollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
if s == nil || s.usernames == nil {
return nil, fmt.Errorf("collectible username dependency is not configured")
}
return s.usernames.List(ctx, filter)
}
// CollectibleUsername resolves one asset by name.
func (s *Service) CollectibleUsername(ctx context.Context, username string) (domain.CollectibleUsername, error) {
if s == nil || s.usernames == nil {
return domain.CollectibleUsername{}, fmt.Errorf("collectible username dependency is not configured")
}
return s.usernames.Collectible(ctx, username)
}
// CollectibleUsernameByID resolves one asset by identity, which is how the admin
// panel links a row to its detail view.
//
// A service exposing the direct by-identity read is used as-is. Otherwise the
// bounded keyset listing answers it: the listing is ordered by descending id, so
// the single row taken before id+1 is the asset itself whenever it exists, and
// any other id proves the asset is gone.
func (s *Service) CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error) {
if s == nil || s.usernames == nil {
return domain.CollectibleUsername{}, fmt.Errorf("collectible username dependency is not configured")
}
if id <= 0 {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
if lookup, ok := s.usernames.(collectibleUsernameByIDLookup); ok {
return lookup.CollectibleUsernameByID(ctx, id)
}
before := int64(0)
if id < math.MaxInt64 {
before = id + 1
}
items, err := s.usernames.List(ctx, domain.CollectibleUsernameFilter{BeforeID: before, Limit: 1})
if err != nil {
return domain.CollectibleUsername{}, err
}
if len(items) == 0 || items[0].ID != id {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
return items[0], nil
}
// CollectibleUsernameTransfers returns one asset's provenance log, newest first.
func (s *Service) CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
if s == nil || s.usernames == nil {
return nil, fmt.Errorf("collectible username dependency is not configured")
}
return s.usernames.Transfers(ctx, collectibleID, limit)
}
// AccountRating returns one user's stored composite rating projection.
func (s *Service) AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error) {
if s == nil || s.rating == nil {
return domain.AccountRating{}, fmt.Errorf("account rating dependency is not configured")
}
return s.rating.Rating(ctx, userID)
}
// AccountRatings is the admin leaderboard read.
func (s *Service) AccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
if s == nil || s.rating == nil {
return nil, fmt.Errorf("account rating dependency is not configured")
}
return s.rating.List(ctx, filter)
}
// AccountRatingEvents returns the contribution ledger that explains a level.
func (s *Service) AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
if s == nil || s.rating == nil {
return nil, fmt.Errorf("account rating dependency is not configured")
}
return s.rating.Events(ctx, userID, limit)
}
type CommandMeta struct {
CommandID string `json:"command_id"`
Actor string `json:"actor"`
@ -599,6 +836,67 @@ type DeleteBotRequest struct {
BotUserID int64 `json:"bot_user_id"`
}
// MintCollectibleUsernameRequest mints a collectible username asset. At most one
// of OwnerUserID / OwnerChannelID may be set: neither mints into the operator
// vault, one assigns the asset to that holder in the same command.
//
// Amount and CryptoAmount are minor units (nanotons for TON), so they cross the
// JSON boundary as decimal strings and stay exact. PurchaseDate is an optional
// Unix timestamp; zero is stamped with the command clock.
type MintCollectibleUsernameRequest struct {
CommandMeta
Username string `json:"username"`
OwnerUserID int64 `json:"owner_user_id,string,omitempty"`
OwnerChannelID int64 `json:"owner_channel_id,string,omitempty"`
Currency string `json:"currency"`
Amount int64 `json:"amount,string"`
CryptoCurrency string `json:"crypto_currency,omitempty"`
CryptoAmount int64 `json:"crypto_amount,string,omitempty"`
URL string `json:"url,omitempty"`
PurchaseDate int64 `json:"purchase_date,omitempty"`
}
// TransferCollectibleUsernameRequest moves an asset out of the vault or between
// holders. Exactly one of ToUserID / ToChannelID identifies the new holder.
type TransferCollectibleUsernameRequest struct {
CommandMeta
Username string `json:"username"`
ToUserID int64 `json:"to_user_id,string,omitempty"`
ToChannelID int64 `json:"to_channel_id,string,omitempty"`
}
// RevokeCollectibleUsernameRequest returns an asset to the vault, or retires it
// permanently when Burn is set.
type RevokeCollectibleUsernameRequest struct {
CommandMeta
Username string `json:"username"`
Burn bool `json:"burn"`
}
// DeleteCollectibleUsernameRequest erases a collectible asset entirely. Unlike a
// burn, which retires the asset and keeps its provenance, this drops the record
// and frees the name for a fresh issue -- the escape hatch for a mistaken mint.
type DeleteCollectibleUsernameRequest struct {
CommandMeta
Username string `json:"username"`
}
// RecomputeAccountRatingRequest forces one user's composite rating to be
// recomputed from the current contribution signals.
type RecomputeAccountRatingRequest struct {
CommandMeta
UserID int64 `json:"user_id,string"`
}
// AdjustAccountRatingRequest moves one user's manual rating component by a
// signed delta. The delta survives recomputes, so it is the operator's durable
// override rather than a one-off nudge.
type AdjustAccountRatingRequest struct {
CommandMeta
UserID int64 `json:"user_id,string"`
Amount int64 `json:"amount,string"`
}
type RevokeSessionsRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
@ -1314,6 +1612,467 @@ func (s *Service) DeleteBot(ctx context.Context, req DeleteBotRequest) (CommandR
})
}
// MintCollectibleUsername creates a collectible username asset and optionally
// assigns it in the same command. Shape validation runs before the command is
// journalled; occupancy is checked inside it, so a dry-run reports a taken name
// without minting and a replay of a completed command stays idempotent.
func (s *Service) MintCollectibleUsername(ctx context.Context, req MintCollectibleUsernameRequest) (CommandResult, error) {
if s == nil || s.usernames == nil {
return CommandResult{}, fmt.Errorf("admin collectible username dependency is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
if !domain.ValidCollectibleUsername(req.Username) {
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
}
owner, err := collectibleOwnerPeer(req.OwnerUserID, req.OwnerChannelID)
if err != nil {
return CommandResult{}, err
}
req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency))
req.CryptoCurrency = strings.ToUpper(strings.TrimSpace(req.CryptoCurrency))
if err := domain.ValidateCollectibleAmounts(req.Currency, req.Amount, req.CryptoCurrency, req.CryptoAmount); err != nil {
return CommandResult{}, codedError(CodeCollectibleCurrencyInvalid, err)
}
req.URL = strings.TrimSpace(req.URL)
if len(req.URL) > domain.MaxCollectibleUsernameURLLength {
return CommandResult{}, fmt.Errorf("url must be <= %d bytes", domain.MaxCollectibleUsernameURLLength)
}
purchase, err := collectiblePurchaseDate(req.PurchaseDate, s.now)
if err != nil {
return CommandResult{}, err
}
req.PurchaseDate = purchase.Unix()
return s.runCommand(ctx, req.CommandMeta, ActionMintCollectibleUsername, req.OwnerUserID, owner, req, func() (CommandResult, error) {
details := map[string]any{
"username": req.Username,
"owner_type": string(owner.Type),
"owner_id": strconv.FormatInt(owner.ID, 10),
"currency": req.Currency,
"amount": strconv.FormatInt(req.Amount, 10),
"crypto_currency": req.CryptoCurrency,
"crypto_amount": strconv.FormatInt(req.CryptoAmount, 10),
"purchase_date": purchase.Format(time.RFC3339),
"url": req.URL,
}
existing, err := s.usernames.Collectible(ctx, req.Username)
switch {
case err == nil:
details["existing_collectible_id"] = strconv.FormatInt(existing.ID, 10)
details["existing_status"] = string(existing.Status)
return CommandResult{Details: details}, codedError(CodeUsernameOccupied, domain.ErrUsernameOccupied)
case !errors.Is(err, domain.ErrCollectibleUsernameNotFound):
return CommandResult{Details: details}, collectibleUsernameError(err)
}
if req.DryRun {
return CommandResult{Message: "collectible username mint validated", Details: details}, nil
}
asset, created, err := s.usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
Username: req.Username,
Owner: owner,
PurchaseDate: purchase,
Currency: req.Currency,
Amount: req.Amount,
CryptoCurrency: req.CryptoCurrency,
CryptoAmount: req.CryptoAmount,
URL: req.URL,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: "admin-collectible-mint:" + req.CommandID,
})
if err != nil {
return CommandResult{Details: details}, collectibleUsernameError(err)
}
details["collectible_id"] = strconv.FormatInt(asset.ID, 10)
details["status"] = string(asset.Status)
details["url"] = asset.URL
details["created"] = created
message := "collectible username minted"
if !created {
message = "collectible username mint replayed"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// TransferCollectibleUsername moves an asset to a new holder. The asset must
// exist and must not be burned; the store keeps the move atomic with the
// receiving peer's username registry row.
func (s *Service) TransferCollectibleUsername(ctx context.Context, req TransferCollectibleUsernameRequest) (CommandResult, error) {
if s == nil || s.usernames == nil {
return CommandResult{}, fmt.Errorf("admin collectible username dependency is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
if !domain.ValidCollectibleUsername(req.Username) {
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
}
to, err := collectibleOwnerPeer(req.ToUserID, req.ToChannelID)
if err != nil {
return CommandResult{}, err
}
if to.Type == "" {
return CommandResult{}, fmt.Errorf("exactly one of to_user_id or to_channel_id is required")
}
return s.runCommand(ctx, req.CommandMeta, ActionTransferCollectibleUsername, req.ToUserID, to, req, func() (CommandResult, error) {
details := map[string]any{
"username": req.Username,
"to_type": string(to.Type),
"to_id": strconv.FormatInt(to.ID, 10),
}
asset, err := s.usernames.Collectible(ctx, req.Username)
if err != nil {
return CommandResult{Details: details}, collectibleUsernameError(err)
}
details["collectible_id"] = strconv.FormatInt(asset.ID, 10)
details["previous_status"] = string(asset.Status)
details["previous_owner_type"] = string(asset.Owner.Type)
details["previous_owner_id"] = strconv.FormatInt(asset.Owner.ID, 10)
details["transfer_count"] = asset.TransferCount
if asset.Status == domain.CollectibleUsernameStatusBurned {
return CommandResult{Details: details}, codedError(CodeCollectibleBurned, domain.ErrCollectibleUsernameBurned)
}
details["would_change"] = !asset.Owned() || asset.Owner != to
if req.DryRun {
return CommandResult{Message: "collectible username transfer validated", Details: details}, nil
}
updated, changed, err := s.usernames.Transfer(ctx, domain.TransferCollectibleUsernameRequest{
Username: req.Username,
To: to,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: "admin-collectible-transfer:" + req.CommandID,
})
if err != nil {
return CommandResult{Details: details}, collectibleUsernameError(err)
}
details["status"] = string(updated.Status)
details["owner_type"] = string(updated.Owner.Type)
details["owner_id"] = strconv.FormatInt(updated.Owner.ID, 10)
details["changed"] = changed
message := "collectible username transferred"
if !changed {
message = "collectible username transfer was a no-op"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// RevokeCollectibleUsername returns an asset to the operator vault, or burns it
// permanently when Burn is set. Revoking an asset nobody holds is rejected:
// there is nothing to take back, and a silent no-op would read as success.
func (s *Service) RevokeCollectibleUsername(ctx context.Context, req RevokeCollectibleUsernameRequest) (CommandResult, error) {
if s == nil || s.usernames == nil {
return CommandResult{}, fmt.Errorf("admin collectible username dependency is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
if !domain.ValidCollectibleUsername(req.Username) {
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
}
return s.runCommand(ctx, req.CommandMeta, ActionRevokeCollectibleUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"username": req.Username, "burn": req.Burn}
asset, err := s.usernames.Collectible(ctx, req.Username)
if err != nil {
return CommandResult{Details: details}, collectibleUsernameError(err)
}
details["collectible_id"] = strconv.FormatInt(asset.ID, 10)
details["previous_status"] = string(asset.Status)
details["previous_owner_type"] = string(asset.Owner.Type)
details["previous_owner_id"] = strconv.FormatInt(asset.Owner.ID, 10)
if asset.Status == domain.CollectibleUsernameStatusBurned {
return CommandResult{Details: details}, codedError(CodeCollectibleBurned, domain.ErrCollectibleUsernameBurned)
}
if !req.Burn && !asset.Owned() {
return CommandResult{Details: details}, codedError(CodeCollectibleNotOwned, domain.ErrCollectibleUsernameNotOwned)
}
if req.DryRun {
return CommandResult{Message: "collectible username revoke validated", Details: details}, nil
}
updated, changed, err := s.usernames.Revoke(ctx, domain.RevokeCollectibleUsernameRequest{
Username: req.Username,
Burn: req.Burn,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: "admin-collectible-revoke:" + req.CommandID,
})
if err != nil {
return CommandResult{Details: details}, collectibleUsernameError(err)
}
details["status"] = string(updated.Status)
details["changed"] = changed
message := "collectible username returned to vault"
if req.Burn {
message = "collectible username burned"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// DeleteCollectibleUsername erases the asset and its provenance, releasing the
// name. Because the history disappears with the record, the command journal is
// the only remaining trace: the details below are captured before the delete so
// the entry still says what was removed and from whom.
func (s *Service) DeleteCollectibleUsername(ctx context.Context, req DeleteCollectibleUsernameRequest) (CommandResult, error) {
if s == nil || s.usernames == nil {
return CommandResult{}, fmt.Errorf("admin collectible username dependency is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
if !domain.ValidCollectibleUsername(req.Username) {
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
}
return s.runCommand(ctx, req.CommandMeta, ActionDeleteCollectibleUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"username": req.Username}
asset, err := s.usernames.Collectible(ctx, req.Username)
if err != nil {
return CommandResult{Details: details}, collectibleUsernameError(err)
}
details["collectible_id"] = strconv.FormatInt(asset.ID, 10)
details["previous_status"] = string(asset.Status)
details["previous_owner_type"] = string(asset.Owner.Type)
details["previous_owner_id"] = strconv.FormatInt(asset.Owner.ID, 10)
details["transfer_count"] = asset.TransferCount
details["currency"] = asset.Currency
details["amount"] = strconv.FormatInt(asset.Amount, 10)
if asset.Status == domain.CollectibleUsernameStatusBurned {
// Only live assets can be deleted; burned rows are history and are
// released by re-issuing the name instead.
return CommandResult{Details: details}, codedError(CodeCollectibleBurned, domain.ErrCollectibleUsernameBurned)
}
if req.DryRun {
return CommandResult{Message: "collectible username delete validated", Details: details}, nil
}
deleted, err := s.usernames.Delete(ctx, domain.DeleteCollectibleUsernameRequest{
Username: req.Username,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: "admin-collectible-delete:" + req.CommandID,
})
if err != nil {
return CommandResult{Details: details}, collectibleUsernameError(err)
}
details["deleted"] = deleted
if !deleted {
return CommandResult{Message: "collectible username already absent", Details: details}, nil
}
return CommandResult{Message: "collectible username deleted", Details: details}, nil
})
}
// RecomputeAccountRating rebuilds one user's composite rating from the current
// contribution signals. A dry-run only reports the stored projection, so the
// operator can see what a recompute would start from without writing.
func (s *Service) RecomputeAccountRating(ctx context.Context, req RecomputeAccountRatingRequest) (CommandResult, error) {
if s == nil || s.rating == nil {
return CommandResult{}, fmt.Errorf("admin account rating dependency is not configured")
}
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
return s.runCommand(ctx, req.CommandMeta, ActionRecomputeAccountRating, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"user_id": strconv.FormatInt(req.UserID, 10)}
previous, err := s.rating.Rating(ctx, req.UserID)
switch {
case err == nil:
details["previous_found"] = true
details["previous_level"] = previous.Level
details["previous_stars"] = strconv.FormatInt(previous.Stars, 10)
case errors.Is(err, domain.ErrAccountRatingNotFound):
details["previous_found"] = false
default:
return CommandResult{Details: details}, accountRatingError(err)
}
if req.DryRun {
return CommandResult{Message: "account rating recompute validated", Details: details}, nil
}
rating, err := s.rating.Recompute(ctx, req.UserID)
if err != nil {
return CommandResult{Details: details}, accountRatingError(err)
}
mergeAccountRatingDetails(details, rating)
return CommandResult{Message: "account rating recomputed", Details: details}, nil
})
}
// AdjustAccountRating moves the manual component of one user's rating by a
// signed delta and recomputes the projection so the change is visible at once.
// The command id doubles as the ledger key, so a retried command records the
// adjustment exactly once.
func (s *Service) AdjustAccountRating(ctx context.Context, req AdjustAccountRatingRequest) (CommandResult, error) {
if s == nil || s.rating == nil {
return CommandResult{}, fmt.Errorf("admin account rating dependency is not configured")
}
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if req.Amount == 0 || req.Amount < -maxAccountRatingAdjustment || req.Amount > maxAccountRatingAdjustment {
return CommandResult{}, codedError(CodeRatingAdjustmentInvalid, domain.ErrAccountRatingAdjustmentInvalid)
}
return s.runCommand(ctx, req.CommandMeta, ActionAdjustAccountRating, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"user_id": strconv.FormatInt(req.UserID, 10),
"amount": strconv.FormatInt(req.Amount, 10),
}
previous, err := s.rating.Rating(ctx, req.UserID)
switch {
case err == nil:
details["previous_found"] = true
details["previous_level"] = previous.Level
details["previous_stars"] = strconv.FormatInt(previous.Stars, 10)
details["previous_manual_component"] = strconv.FormatInt(previous.ManualComponent, 10)
case errors.Is(err, domain.ErrAccountRatingNotFound):
details["previous_found"] = false
default:
return CommandResult{Details: details}, accountRatingError(err)
}
if req.DryRun {
return CommandResult{Message: "account rating adjustment validated", Details: details}, nil
}
rating, applied, err := s.rating.Adjust(ctx, domain.AdjustAccountRatingRequest{
UserID: req.UserID,
Amount: req.Amount,
Reason: req.Reason,
Actor: req.Actor,
CommandKey: "admin-rating-adjust:" + req.CommandID,
})
if err != nil {
return CommandResult{Details: details}, accountRatingError(err)
}
details["applied"] = applied
mergeAccountRatingDetails(details, rating)
message := "account rating adjusted"
if !applied {
message = "account rating adjustment replayed"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// collectibleOwnerPeer resolves the optional owner of a collectible asset. At
// most one identifier may be set; neither yields the zero peer, which the
// lifecycle reads as "the operator vault".
func collectibleOwnerPeer(userID, channelID int64) (domain.Peer, error) {
if userID < 0 || channelID < 0 {
return domain.Peer{}, fmt.Errorf("owner id must be positive")
}
if userID > 0 && channelID > 0 {
return domain.Peer{}, fmt.Errorf("at most one of user id or channel id is allowed")
}
switch {
case userID > 0:
return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, nil
case channelID > 0:
return domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, nil
default:
return domain.Peer{}, nil
}
}
// collectiblePurchaseDate resolves the optional Unix purchase timestamp. Zero
// means "now", so a mint always records a complete, reproducible provenance
// entry; the int32 bound matches the TL date field clients render.
func collectiblePurchaseDate(unix int64, now func() time.Time) (time.Time, error) {
if unix == 0 {
return now().UTC(), nil
}
if unix < 0 || unix > math.MaxInt32 {
return time.Time{}, fmt.Errorf("purchase_date must be a non-negative int32 Unix timestamp")
}
return time.Unix(unix, 0).UTC(), nil
}
// mergeAccountRatingDetails records the computed projection in command details.
// Every score component crosses the JSON boundary as a decimal string so an
// audit entry reproduces the exact int64 the store holds.
func mergeAccountRatingDetails(details map[string]any, rating domain.AccountRating) {
details["level"] = rating.Level
details["stars"] = strconv.FormatInt(rating.Stars, 10)
details["current_level_stars"] = strconv.FormatInt(rating.CurrentLevelStars, 10)
details["has_next_level"] = rating.HasNextLevel
if rating.HasNextLevel {
details["next_level_stars"] = strconv.FormatInt(rating.NextLevelStars, 10)
}
details["stars_component"] = strconv.FormatInt(rating.StarsComponent, 10)
details["activity_component"] = strconv.FormatInt(rating.ActivityComponent, 10)
details["penalty_component"] = strconv.FormatInt(rating.PenaltyComponent, 10)
details["manual_component"] = strconv.FormatInt(rating.ManualComponent, 10)
details["pending_stars"] = strconv.FormatInt(rating.PendingStars, 10)
if !rating.PendingDate.IsZero() {
details["pending_date"] = rating.PendingDate.UTC().Format(time.RFC3339)
}
details["version"] = strconv.FormatInt(rating.Version, 10)
}
// CollectibleUsernameErrorCode maps a collectible-username domain error onto the
// stable code the admin panel switches on. An unmapped error returns "" so the
// caller can fall back to a generic failure instead of inventing a code.
func CollectibleUsernameErrorCode(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, domain.ErrUsernameOccupied):
return CodeUsernameOccupied
case errors.Is(err, domain.ErrCollectibleUsernameNotFound), errors.Is(err, domain.ErrUsernameNotOccupied):
return CodeCollectibleNotFound
case errors.Is(err, domain.ErrCollectibleUsernameBurned):
return CodeCollectibleBurned
case errors.Is(err, domain.ErrCollectibleUsernameLimit):
return CodeCollectiblePeerLimit
case errors.Is(err, domain.ErrCollectibleUsernameNotOwned):
return CodeCollectibleNotOwned
case errors.Is(err, domain.ErrCollectibleCurrencyInvalid):
return CodeCollectibleCurrencyInvalid
case errors.Is(err, domain.ErrUsernameNotCollectible), errors.Is(err, domain.ErrUsernameNotEditable):
return CodeUsernameNotCollectible
case errors.Is(err, domain.ErrUsernameInvalid):
return CodeUsernameInvalid
case errors.Is(err, domain.ErrCollectibleUsernameStateInvalid), errors.Is(err, domain.ErrUsernameOrderInvalid):
return CodeCollectibleStateInvalid
default:
return ""
}
}
// AccountRatingErrorCode maps an account-rating domain error onto its stable
// admin code. An unmapped error returns "".
func AccountRatingErrorCode(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, domain.ErrAccountRatingNotFound):
return CodeRatingNotFound
case errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid):
return CodeRatingAdjustmentInvalid
case errors.Is(err, domain.ErrAccountRatingWeightsInvalid):
return CodeRatingWeightsInvalid
default:
return ""
}
}
// collectibleUsernameError / accountRatingError prefix a recognised domain error
// with its stable code, so the journalled command result and the operator both
// see "CODE: message" instead of a bare Go string. Unrecognised errors are
// returned untouched: inventing a code for an unknown failure would be worse
// than reporting it verbatim.
func collectibleUsernameError(err error) error {
if code := CollectibleUsernameErrorCode(err); code != "" {
return codedError(code, err)
}
return err
}
func accountRatingError(err error) error {
if code := AccountRatingErrorCode(err); code != "" {
return codedError(code, err)
}
return err
}
func codedError(code string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", code, err)
}
func (s *Service) SetChannelVerified(ctx context.Context, req SetChannelVerifiedRequest) (CommandResult, error) {
if req.ChannelID <= 0 {
return CommandResult{}, fmt.Errorf("channel_id is required")

View file

@ -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()
@ -1311,3 +1323,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")
}
}

View 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))
}

View 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)
}
}
}