Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877

This commit is contained in:
onysd 2026-08-03 23:29:20 +03:00
commit ac6a50c5ff
697 changed files with 100880 additions and 8052 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

File diff suppressed because it is too large Load diff

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()
@ -143,6 +155,39 @@ func TestModerationFlagsRejectImpossibleScamFakeState(t *testing.T) {
}
}
func TestSetUserFlagsUsesNonPTSModerationNotifier(t *testing.T) {
ctx := context.Background()
users := &fakeUsersService{users: map[int64]domain.User{
1001: {ID: 1001, FirstName: "Alice"},
}}
ordinaryNotifier := &fakeUserNotifier{}
moderationNotifier := &fakeUserModerationNotifier{}
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(),
Users: users,
UserNotifier: ordinaryNotifier,
UserModerationNotifier: moderationNotifier,
Now: fixedNow,
})
if _, err := svc.SetUserFlags(ctx, SetUserFlagsRequest{
CommandMeta: CommandMeta{CommandID: "set-user-scam", Actor: "ops", Reason: "confirmed report"},
UserID: 1001,
Scam: true,
}); err != nil {
t.Fatalf("SetUserFlags: %v", err)
}
if got := users.users[1001]; !got.Scam || got.Fake {
t.Fatalf("updated user = %+v", got)
}
if len(moderationNotifier.users) != 1 || moderationNotifier.users[0] != 1001 {
t.Fatalf("moderation notifications = %v", moderationNotifier.users)
}
if len(ordinaryNotifier.users) != 0 {
t.Fatalf("ordinary notifications = %v, want dedicated non-PTS path", ordinaryNotifier.users)
}
}
func TestAccountFreezesBatchesAndReturnsOnlyActiveFacts(t *testing.T) {
now := fixedNow()
store := &fakeBatchRestrictionStore{fakeRestrictionStore: fakeRestrictionStore{items: map[int64]domain.AccountFreeze{
@ -863,6 +908,15 @@ func (f *fakeUserNotifier) NotifyUserChanged(_ context.Context, u domain.User) e
return nil
}
type fakeUserModerationNotifier struct {
users []int64
}
func (f *fakeUserModerationNotifier) NotifyUserModerationFlagsChanged(_ context.Context, u domain.User) error {
f.users = append(f.users, u.ID)
return nil
}
type fakeChannelsService struct {
channels map[int64]domain.Channel
verifiedCalls int
@ -1373,3 +1427,653 @@ func (f *fakeChannelNotifier) NotifyChannelChanged(_ context.Context, ch domain.
f.channels = append(f.channels, ch.ID)
return nil
}
// fakeCollectibleUsernamesService is an in-memory collectible lifecycle: enough
// state to observe occupancy, replay-by-command-key and the burned terminal
// state, which is exactly what the admin commands are expected to reason about.
type fakeCollectibleUsernamesService struct {
assets map[string]domain.CollectibleUsername
log map[int64][]domain.CollectibleUsernameTransfer
commandKeys map[string]int64
nextID int64
mintCalls int
transferCalls int
revokeCalls int
deleteCalls int
}
func newFakeCollectibleUsernames() *fakeCollectibleUsernamesService {
return &fakeCollectibleUsernamesService{
assets: map[string]domain.CollectibleUsername{},
log: map[int64][]domain.CollectibleUsernameTransfer{},
commandKeys: map[string]int64{},
nextID: 100,
}
}
func (f *fakeCollectibleUsernamesService) Mint(_ context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
f.mintCalls++
if id, ok := f.commandKeys[req.CommandKey]; ok && req.CommandKey != "" {
return f.byID(id), false, nil
}
key := strings.ToLower(req.Username)
if _, ok := f.assets[key]; ok {
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
}
f.nextID++
asset := domain.CollectibleUsername{
ID: f.nextID, Username: req.Username, Status: domain.CollectibleUsernameStatusVault,
PurchaseDate: req.PurchaseDate, Currency: req.Currency, Amount: req.Amount,
CryptoCurrency: req.CryptoCurrency, CryptoAmount: req.CryptoAmount, URL: req.URL,
Version: 1,
}
if req.Owner.Type != "" {
asset.Status = domain.CollectibleUsernameStatusOwned
asset.Owner = req.Owner
asset.OriginalOwner = req.Owner
}
f.assets[key] = asset
f.commandKeys[req.CommandKey] = asset.ID
f.log[asset.ID] = append(f.log[asset.ID], domain.CollectibleUsernameTransfer{
ID: asset.ID, CollectibleID: asset.ID, Kind: domain.CollectibleUsernameKindMint,
To: req.Owner, Actor: req.Actor, Reason: req.Reason, CommandKey: req.CommandKey,
})
return asset, true, nil
}
func (f *fakeCollectibleUsernamesService) Transfer(_ context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
f.transferCalls++
key := strings.ToLower(req.Username)
asset, ok := f.assets[key]
if !ok {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameNotFound
}
if asset.Status == domain.CollectibleUsernameStatusBurned {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
}
if asset.Owned() && asset.Owner == req.To {
return asset, false, nil
}
asset.Status = domain.CollectibleUsernameStatusOwned
asset.Owner = req.To
if asset.OriginalOwner.Type == "" {
asset.OriginalOwner = req.To
}
asset.TransferCount++
asset.Version++
f.assets[key] = asset
return asset, true, nil
}
func (f *fakeCollectibleUsernamesService) Delete(_ context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
f.deleteCalls++
key := strings.ToLower(req.Username)
asset, ok := f.assets[key]
if !ok {
return false, nil
}
if asset.Status == domain.CollectibleUsernameStatusBurned {
return false, nil
}
delete(f.assets, key)
return true, nil
}
func (f *fakeCollectibleUsernamesService) Revoke(_ context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
f.revokeCalls++
key := strings.ToLower(req.Username)
asset, ok := f.assets[key]
if !ok {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameNotFound
}
if asset.Status == domain.CollectibleUsernameStatusBurned {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
}
asset.Owner = domain.Peer{}
asset.Status = domain.CollectibleUsernameStatusVault
if req.Burn {
asset.Status = domain.CollectibleUsernameStatusBurned
}
asset.Version++
f.assets[key] = asset
return asset, true, nil
}
func (f *fakeCollectibleUsernamesService) Collectible(_ context.Context, username string) (domain.CollectibleUsername, error) {
asset, ok := f.assets[strings.ToLower(username)]
if !ok {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
return asset, nil
}
func (f *fakeCollectibleUsernamesService) List(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
out := make([]domain.CollectibleUsername, 0, len(f.assets))
for _, asset := range f.assets {
if filter.Status != "" && asset.Status != filter.Status {
continue
}
if filter.BeforeID != 0 && asset.ID >= filter.BeforeID {
continue
}
out = append(out, asset)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if filter.Limit > 0 && len(out) > filter.Limit {
out = out[:filter.Limit]
}
return out, nil
}
func (f *fakeCollectibleUsernamesService) Transfers(_ context.Context, collectibleID int64, _ int) ([]domain.CollectibleUsernameTransfer, error) {
return f.log[collectibleID], nil
}
func (f *fakeCollectibleUsernamesService) byID(id int64) domain.CollectibleUsername {
for _, asset := range f.assets {
if asset.ID == id {
return asset
}
}
return domain.CollectibleUsername{}
}
// fakeAccountRatingService recomputes from the manual component only, which
// keeps the arithmetic in the domain and the fake focused on ledger replay.
type fakeAccountRatingService struct {
ratings map[int64]domain.AccountRating
manual map[int64]int64
commandKeys map[string]bool
recomputeCalls int
adjustCalls int
}
func newFakeAccountRating() *fakeAccountRatingService {
return &fakeAccountRatingService{
ratings: map[int64]domain.AccountRating{},
manual: map[int64]int64{},
commandKeys: map[string]bool{},
}
}
func (f *fakeAccountRatingService) Rating(_ context.Context, userID int64) (domain.AccountRating, error) {
rating, ok := f.ratings[userID]
if !ok {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
return rating, nil
}
func (f *fakeAccountRatingService) Recompute(_ context.Context, userID int64) (domain.AccountRating, error) {
f.recomputeCalls++
rating := domain.ComputeAccountRating(domain.AccountRatingSignals{
UserID: userID, StarsReceived: 5000, Manual: f.manual[userID],
}, domain.DefaultAccountRatingWeights(), fixedNow())
rating.Version = f.ratings[userID].Version + 1
f.ratings[userID] = rating
return rating, nil
}
func (f *fakeAccountRatingService) Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error) {
f.adjustCalls++
if err := req.Validate(); err != nil {
return domain.AccountRating{}, false, err
}
applied := true
if f.commandKeys[req.CommandKey] {
applied = false
} else {
f.commandKeys[req.CommandKey] = true
f.manual[req.UserID] += req.Amount
}
rating, err := f.Recompute(ctx, req.UserID)
if err != nil {
return domain.AccountRating{}, applied, err
}
return rating, applied, nil
}
func (f *fakeAccountRatingService) List(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
out := make([]domain.AccountRating, 0, len(f.ratings))
for _, rating := range f.ratings {
if rating.Level < filter.MinLevel {
continue
}
out = append(out, rating)
}
sort.Slice(out, func(i, j int) bool { return out[i].UserID < out[j].UserID })
return out, nil
}
func (f *fakeAccountRatingService) Events(_ context.Context, userID int64, _ int) ([]domain.AccountRatingEvent, error) {
if f.manual[userID] == 0 {
return nil, nil
}
return []domain.AccountRatingEvent{{
ID: 1, UserID: userID, Kind: domain.AccountRatingEventManual, Amount: f.manual[userID],
}}, nil
}
func TestMintCollectibleUsernameDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
usernames := newFakeCollectibleUsernames()
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
dry, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "dry-mint", Actor: "ops", Reason: "fragment import", DryRun: true},
Username: "@Durov", OwnerUserID: 1001, Currency: domain.CollectibleCurrencyTON,
Amount: 250_000_000_000, CryptoCurrency: domain.CollectibleCryptoCurrencyTON, CryptoAmount: 250_000_000_000,
})
if err != nil {
t.Fatalf("dry-run mint: %v", err)
}
if !dry.DryRun || dry.Status != string(domain.AdminCommandCompleted) || usernames.mintCalls != 0 {
t.Fatalf("dry-run result=%+v mintCalls=%d, want validation without mutation", dry, usernames.mintCalls)
}
if dry.Details["username"] != "Durov" || dry.Details["purchase_date"] != fixedNow().Format(time.RFC3339) {
t.Fatalf("dry-run details=%+v, want a normalised name and a stamped purchase date", dry.Details)
}
execReq := MintCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "exec-mint", Actor: "ops", Reason: "fragment import"},
Username: "Durov", OwnerUserID: 1001, Currency: domain.CollectibleCurrencyTON,
Amount: 250_000_000_000, CryptoCurrency: domain.CollectibleCryptoCurrencyTON, CryptoAmount: 250_000_000_000,
}
exec, err := svc.MintCollectibleUsername(ctx, execReq)
if err != nil {
t.Fatalf("execute mint: %v", err)
}
if exec.Status != string(domain.AdminCommandCompleted) || usernames.mintCalls != 1 ||
exec.Details["status"] != string(domain.CollectibleUsernameStatusOwned) {
t.Fatalf("execute result=%+v mintCalls=%d", exec, usernames.mintCalls)
}
again, err := svc.MintCollectibleUsername(ctx, execReq)
if err != nil {
t.Fatalf("replay mint: %v", err)
}
if !again.AlreadyExecuted || usernames.mintCalls != 1 {
t.Fatalf("replay result=%+v mintCalls=%d, want idempotent replay", again, usernames.mintCalls)
}
occupied, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "exec-mint-2", Actor: "ops", Reason: "duplicate"},
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1,
})
if err == nil || !strings.Contains(err.Error(), CodeUsernameOccupied) {
t.Fatalf("duplicate mint err=%v, want %s", err, CodeUsernameOccupied)
}
if occupied.Status != string(domain.AdminCommandFailed) || usernames.mintCalls != 1 {
t.Fatalf("duplicate result=%+v mintCalls=%d, want a journalled failure without mutation", occupied, usernames.mintCalls)
}
}
func TestMintCollectibleUsernameValidatesBeforeJournallingCommand(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
usernames := newFakeCollectibleUsernames()
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
cases := []struct {
name string
req MintCollectibleUsernameRequest
code string
}{
{
name: "short username",
req: MintCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "bad-1", Actor: "ops", Reason: "invalid"},
Username: "ab", Currency: domain.CollectibleCurrencyUSD, Amount: 1,
},
code: CodeUsernameInvalid,
},
{
name: "unsupported currency",
req: MintCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "bad-2", Actor: "ops", Reason: "invalid"},
Username: "durov", Currency: "EUR", Amount: 1,
},
code: CodeCollectibleCurrencyInvalid,
},
{
name: "crypto amount without currency",
req: MintCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "bad-3", Actor: "ops", Reason: "invalid"},
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CryptoAmount: 5,
},
code: CodeCollectibleCurrencyInvalid,
},
}
for _, item := range cases {
if _, err := svc.MintCollectibleUsername(ctx, item.req); err == nil || !strings.Contains(err.Error(), item.code) {
t.Fatalf("%s err=%v, want %s", item.name, err, item.code)
}
if _, journalled := repo.items[item.req.CommandID]; journalled {
t.Fatalf("%s journalled a rejected command", item.name)
}
}
if usernames.mintCalls != 0 {
t.Fatalf("rejected requests reached the lifecycle: mintCalls=%d", usernames.mintCalls)
}
}
func TestTransferCollectibleUsernameRequiresRecipientAndRejectsBurned(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
usernames := newFakeCollectibleUsernames()
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed",
}); err != nil {
t.Fatalf("seed mint: %v", err)
}
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "t-none", Actor: "ops", Reason: "sold"}, Username: "durov",
}); err == nil || !strings.Contains(err.Error(), "to_user_id") {
t.Fatalf("transfer without recipient err=%v", err)
}
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "t-both", Actor: "ops", Reason: "sold"},
Username: "durov", ToUserID: 1001, ToChannelID: 2002,
}); err == nil {
t.Fatal("transfer accepted two recipients")
}
dry, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "t-dry", Actor: "ops", Reason: "sold", DryRun: true},
Username: "durov", ToUserID: 1001,
})
if err != nil {
t.Fatalf("dry-run transfer: %v", err)
}
if usernames.transferCalls != 0 || dry.Details["would_change"] != true {
t.Fatalf("dry-run transfer result=%+v transferCalls=%d", dry, usernames.transferCalls)
}
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "t-exec", Actor: "ops", Reason: "sold"},
Username: "durov", ToUserID: 1001,
}); err != nil {
t.Fatalf("execute transfer: %v", err)
}
if usernames.transferCalls != 1 {
t.Fatalf("transferCalls=%d, want one mutation", usernames.transferCalls)
}
if _, err := svc.RevokeCollectibleUsername(ctx, RevokeCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "burn-1", Actor: "ops", Reason: "fraud"},
Username: "durov", Burn: true,
}); err != nil {
t.Fatalf("burn: %v", err)
}
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "t-burned", Actor: "ops", Reason: "sold"},
Username: "durov", ToUserID: 1002,
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleBurned) {
t.Fatalf("transfer of a burned asset err=%v, want %s", err, CodeCollectibleBurned)
}
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "t-missing", Actor: "ops", Reason: "sold"},
Username: "nobody_holds_this", ToUserID: 1002,
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleNotFound) {
t.Fatalf("transfer of a missing asset err=%v, want %s", err, CodeCollectibleNotFound)
}
}
func TestRevokeCollectibleUsernameRejectsVaultAssetWithoutBurn(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
usernames := newFakeCollectibleUsernames()
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed",
}); err != nil {
t.Fatalf("seed mint: %v", err)
}
if _, err := svc.RevokeCollectibleUsername(ctx, RevokeCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "rv-vault", Actor: "ops", Reason: "nothing to take"},
Username: "durov",
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleNotOwned) {
t.Fatalf("revoke of a vault asset err=%v, want %s", err, CodeCollectibleNotOwned)
}
if usernames.revokeCalls != 0 {
t.Fatalf("revokeCalls=%d, want no mutation", usernames.revokeCalls)
}
burnDry, err := svc.RevokeCollectibleUsername(ctx, RevokeCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "rv-burn-dry", Actor: "ops", Reason: "fraud", DryRun: true},
Username: "durov", Burn: true,
})
if err != nil {
t.Fatalf("dry-run burn: %v", err)
}
if usernames.revokeCalls != 0 || burnDry.Details["burn"] != true {
t.Fatalf("dry-run burn result=%+v revokeCalls=%d", burnDry, usernames.revokeCalls)
}
}
func TestCollectibleUsernameByIDUsesKeysetFallback(t *testing.T) {
ctx := context.Background()
usernames := newFakeCollectibleUsernames()
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Usernames: usernames, Now: fixedNow})
first, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed-1",
})
if err != nil {
t.Fatalf("seed mint: %v", err)
}
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
Username: "telegram", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed-2",
}); err != nil {
t.Fatalf("seed mint: %v", err)
}
got, err := svc.CollectibleUsernameByID(ctx, first.ID)
if err != nil || got.ID != first.ID || got.Username != "durov" {
t.Fatalf("CollectibleUsernameByID(%d) = %+v err=%v", first.ID, got, err)
}
if _, err := svc.CollectibleUsernameByID(ctx, first.ID-1); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("missing id err=%v, want ErrCollectibleUsernameNotFound", err)
}
}
func TestAdjustAccountRatingDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
rating := newFakeAccountRating()
svc := NewService(Dependencies{Commands: repo, Rating: rating, Now: fixedNow})
dry, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "dry-adjust", Actor: "ops", Reason: "penalty", DryRun: true},
UserID: 1001, Amount: -2500,
})
if err != nil {
t.Fatalf("dry-run adjust: %v", err)
}
if rating.adjustCalls != 0 || dry.Details["previous_found"] != false || dry.Details["amount"] != "-2500" {
t.Fatalf("dry-run adjust result=%+v adjustCalls=%d", dry, rating.adjustCalls)
}
execReq := AdjustAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "exec-adjust", Actor: "ops", Reason: "penalty"},
UserID: 1001, Amount: -2500,
}
exec, err := svc.AdjustAccountRating(ctx, execReq)
if err != nil {
t.Fatalf("execute adjust: %v", err)
}
if rating.adjustCalls != 1 || exec.Details["applied"] != true ||
exec.Details["manual_component"] != "-2500" || exec.Details["stars"] != "2500" {
t.Fatalf("execute adjust result=%+v adjustCalls=%d", exec, rating.adjustCalls)
}
again, err := svc.AdjustAccountRating(ctx, execReq)
if err != nil {
t.Fatalf("replay adjust: %v", err)
}
if !again.AlreadyExecuted || rating.adjustCalls != 1 || rating.manual[1001] != -2500 {
t.Fatalf("replay adjust result=%+v adjustCalls=%d manual=%d", again, rating.adjustCalls, rating.manual[1001])
}
for _, amount := range []int64{0, maxAccountRatingAdjustment + 1, -maxAccountRatingAdjustment - 1} {
if _, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "bad-adjust", Actor: "ops", Reason: "invalid"},
UserID: 1001, Amount: amount,
}); err == nil || !strings.Contains(err.Error(), CodeRatingAdjustmentInvalid) {
t.Fatalf("adjust by %d err=%v, want %s", amount, err, CodeRatingAdjustmentInvalid)
}
}
if _, journalled := repo.items["bad-adjust"]; journalled {
t.Fatal("journalled a rejected adjustment")
}
}
func TestRecomputeAccountRatingDryRunAndExecute(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
rating := newFakeAccountRating()
svc := NewService(Dependencies{Commands: repo, Rating: rating, Now: fixedNow})
if _, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "rc-invalid", Actor: "ops", Reason: "support"},
}); err == nil || !strings.Contains(err.Error(), "user_id") {
t.Fatalf("recompute without user err=%v", err)
}
dry, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "rc-dry", Actor: "ops", Reason: "support", DryRun: true},
UserID: 1001,
})
if err != nil {
t.Fatalf("dry-run recompute: %v", err)
}
if rating.recomputeCalls != 0 || dry.Details["previous_found"] != false {
t.Fatalf("dry-run recompute result=%+v recomputeCalls=%d", dry, rating.recomputeCalls)
}
exec, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "rc-exec", Actor: "ops", Reason: "support"},
UserID: 1001,
})
if err != nil {
t.Fatalf("execute recompute: %v", err)
}
if rating.recomputeCalls != 1 || exec.Details["stars"] != "5000" || exec.Details["version"] != "1" {
t.Fatalf("execute recompute result=%+v recomputeCalls=%d", exec, rating.recomputeCalls)
}
stored, err := svc.AccountRating(ctx, 1001)
if err != nil || stored.Stars != 5000 {
t.Fatalf("AccountRating = %+v err=%v", stored, err)
}
if events, err := svc.AccountRatingEvents(ctx, 1001, 10); err != nil || len(events) != 0 {
t.Fatalf("AccountRatingEvents = %+v err=%v, want an empty ledger", events, err)
}
}
func TestCollectibleAndRatingCommandsRequireConfiguredDependencies(t *testing.T) {
ctx := context.Background()
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Now: fixedNow})
if _, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "c-1", Actor: "ops", Reason: "x"},
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1,
}); err == nil || !strings.Contains(err.Error(), "collectible username dependency") {
t.Fatalf("mint without dependency err=%v", err)
}
if _, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "c-2", Actor: "ops", Reason: "x"},
UserID: 1001, Amount: 5,
}); err == nil || !strings.Contains(err.Error(), "account rating dependency") {
t.Fatalf("adjust without dependency err=%v", err)
}
if _, err := svc.CollectibleUsernames(ctx, domain.CollectibleUsernameFilter{}); err == nil {
t.Fatal("listing without dependency succeeded")
}
if _, err := svc.AccountRatings(ctx, domain.AccountRatingFilter{}); err == nil {
t.Fatal("rating listing without dependency succeeded")
}
}
// TestDeleteCollectibleUsernameCommand covers the hard-delete command: the
// journal captures what was removed before the record disappears, a dry-run
// mutates nothing, and a burned asset is refused.
func TestDeleteCollectibleUsernameCommand(t *testing.T) {
ctx := context.Background()
usernames := newFakeCollectibleUsernames()
repo := newMemoryCommandRepo()
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 8801}
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
Username: "wrongname", Owner: holder, Currency: domain.CollectibleCurrencyStars, Amount: 100,
}); err != nil {
t.Fatalf("seed mint: %v", err)
}
dry, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "del-dry", Actor: "ops", Reason: "mistake", DryRun: true},
Username: "@wrongname",
})
if err != nil {
t.Fatalf("dry run: %v", err)
}
if !dry.DryRun || usernames.deleteCalls != 0 {
t.Fatalf("dry run mutated: result=%+v calls=%d", dry, usernames.deleteCalls)
}
if dry.Details["previous_owner_id"] != "8801" {
t.Fatalf("dry run details = %+v, want the holder captured", dry.Details)
}
exec, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "del-exec", Actor: "ops", Reason: "mistake"},
Username: "wrongname",
})
if err != nil {
t.Fatalf("exec: %v", err)
}
if usernames.deleteCalls != 1 || exec.Details["deleted"] != true {
t.Fatalf("exec = %+v calls=%d", exec, usernames.deleteCalls)
}
if exec.Details["previous_status"] != string(domain.CollectibleUsernameStatusOwned) {
t.Fatalf("journal lost the pre-delete state: %+v", exec.Details)
}
// Replaying the same command id must not touch the store again.
if _, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "del-exec", Actor: "ops", Reason: "mistake"},
Username: "wrongname",
}); err != nil {
t.Fatalf("replay: %v", err)
}
if usernames.deleteCalls != 1 {
t.Fatalf("replay called the store again: calls=%d", usernames.deleteCalls)
}
// A burned asset is history: it is released by re-issuing the name, not deleted.
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
Username: "burnedname", Owner: holder, Currency: domain.CollectibleCurrencyStars, Amount: 100,
}); err != nil {
t.Fatalf("seed burned mint: %v", err)
}
if _, _, err := usernames.Revoke(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "burnedname", Burn: true,
}); err != nil {
t.Fatalf("seed burn: %v", err)
}
if _, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "del-burned", Actor: "ops", Reason: "cleanup"},
Username: "burnedname",
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleBurned) {
t.Fatalf("delete of burned asset err = %v, want %s", err, CodeCollectibleBurned)
}
// A short name is rejected before a command is journalled at all.
if _, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "del-short", Actor: "ops", Reason: "cleanup"},
Username: "no",
}); err == nil {
t.Fatalf("delete of invalid name = nil error, want rejection")
}
}

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