feat: add NFT usernames and bot verification (#22)
Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review. The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation. Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9 Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
This commit is contained in:
parent
b0fd3976f1
commit
fff8de783a
169 changed files with 55769 additions and 282 deletions
994
internal/admin/botverification.go
Normal file
994
internal/admin/botverification.go
Normal file
|
|
@ -0,0 +1,994 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Third-party bot verification for the operator (core.telegram.org/api/bots/verification).
|
||||
//
|
||||
// This is NOT the official platform badge. The official review lives in
|
||||
// verification.go behind the verification.* permissions and writes
|
||||
// verification_applications / the peer's `verified` flag; this file drives the
|
||||
// third-party mechanism, whose state is verification_icons,
|
||||
// bot_verifier_settings, custom_verifications and custom_verification_requests,
|
||||
// behind the botverification.* permissions. Neither section reads the other's
|
||||
// tables, and a third-party verifier must never be able to mint a platform
|
||||
// checkmark -- so nothing here ever touches domain.User.Verified.
|
||||
//
|
||||
// The operator surface has two halves, and they carry different rights:
|
||||
//
|
||||
// - Configuration (botverification.manage): who is a verifier at all, which
|
||||
// icons exist, and stripping a granted mark. These are the actions that
|
||||
// decide how much a mark is worth, so they are deliberately not implied by
|
||||
// the right to work the queue.
|
||||
// - Review (botverification.review): deciding the applications filed with a
|
||||
// verifier bot.
|
||||
//
|
||||
// Every mutation goes through runCommand, so a decision is journalled in
|
||||
// admin_commands / admin_audit_logs, replayable by command id and rehearsable
|
||||
// with a dry run. A dry run never mutates and never predicts an outcome stricter
|
||||
// than the real command's: where a check can only run inside the use-case layer
|
||||
// (the icon's custom emoji document is resolved against the document store) the
|
||||
// dry run says so in its details instead of guessing.
|
||||
|
||||
// BotVerificationService is the operator-facing slice of the third-party
|
||||
// verification use cases. It is the exact method set *app/botverification.Service
|
||||
// exposes for this surface, so the admin layer never reaches into the store.
|
||||
type BotVerificationService interface {
|
||||
// Icon catalogue.
|
||||
Icons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error)
|
||||
UpsertIcon(ctx context.Context, icon domain.VerificationIcon) (domain.VerificationIcon, error)
|
||||
SetIconActive(ctx context.Context, iconID int64, active bool) (domain.VerificationIcon, error)
|
||||
|
||||
// Verifier status.
|
||||
Verifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error)
|
||||
VerifierSettings(ctx context.Context, botID int64) (domain.BotVerifierSettings, error)
|
||||
GrantVerifier(ctx context.Context, settings domain.BotVerifierSettings) (domain.BotVerifierSettings, error)
|
||||
SetVerifierEnabled(ctx context.Context, botID int64, enabled bool) (domain.BotVerifierSettings, error)
|
||||
RevokeVerifier(ctx context.Context, botID int64) (bool, error)
|
||||
|
||||
// Granted marks.
|
||||
Marks(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error)
|
||||
RevokeMark(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error)
|
||||
|
||||
// Application queue.
|
||||
Requests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error)
|
||||
Request(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error)
|
||||
RequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error)
|
||||
Approve(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error)
|
||||
Reject(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error)
|
||||
RevokeRequest(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error)
|
||||
}
|
||||
|
||||
// botVerificationCatalogueScan bounds the catalogue page a dry run reads to
|
||||
// pre-check an icon. The catalogue is operator-curated and small; the check is
|
||||
// advisory precisely because this bound may not cover it (see grantIconPreflight).
|
||||
const botVerificationCatalogueScan = 200
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command payloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GrantBotVerifierRequest grants or reconfigures a bot's verifier status.
|
||||
//
|
||||
// Version is the optimistic-locking token: 0 means "this is a new grant" and a
|
||||
// positive value means "update the row I read", so two operators editing the same
|
||||
// verifier cannot clobber each other. Enabled is deliberately absent -- the kill
|
||||
// switch is its own action, so reconfiguring a switched-off verifier does not
|
||||
// quietly switch it back on.
|
||||
type GrantBotVerifierRequest struct {
|
||||
CommandMeta
|
||||
BotID int64 `json:"bot_id"`
|
||||
IconDocumentID int64 `json:"icon_document_id"`
|
||||
CompanyName string `json:"company_name"`
|
||||
DefaultDescription string `json:"default_description"`
|
||||
CanModifyCustomDescription bool `json:"can_modify_custom_description"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
// SetBotVerifierEnabledRequest flips the operator kill switch. The verifier keeps
|
||||
// its row and the marks it granted, so flipping it back restores exactly what was
|
||||
// there.
|
||||
type SetBotVerifierEnabledRequest struct {
|
||||
CommandMeta
|
||||
BotID int64 `json:"bot_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// RevokeBotVerifierRequest removes verifier status entirely. Its marks cascade
|
||||
// away with it in the store, because a mark whose verifier no longer exists has
|
||||
// nothing to render.
|
||||
type RevokeBotVerifierRequest struct {
|
||||
CommandMeta
|
||||
BotID int64 `json:"bot_id"`
|
||||
}
|
||||
|
||||
// UpsertVerificationIconRequest adds or updates a catalogue entry, keyed by custom
|
||||
// emoji document id. OwnerBotID is zero for a shared entry and a bot id when the
|
||||
// operator reserves the icon for one verifier.
|
||||
type UpsertVerificationIconRequest struct {
|
||||
CommandMeta
|
||||
DocumentID int64 `json:"document_id"`
|
||||
Name string `json:"name"`
|
||||
OwnerBotID int64 `json:"owner_bot_id,omitempty"`
|
||||
}
|
||||
|
||||
// SetVerificationIconActiveRequest retires or restores a catalogue entry. Marks
|
||||
// already granted with it keep rendering -- the icon id is denormalised onto the
|
||||
// mark -- so retiring an entry stops new grants without blanking existing badges.
|
||||
type SetVerificationIconActiveRequest struct {
|
||||
CommandMeta
|
||||
IconID int64 `json:"icon_id"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// RevokeCustomVerificationRequest strips one verifier's mark from a peer on the
|
||||
// operator's behalf. It addresses the (verifier, peer) pair rather than an
|
||||
// application, because the operator may have to strip a mark that no application
|
||||
// ever produced.
|
||||
type RevokeCustomVerificationRequest struct {
|
||||
CommandMeta
|
||||
VerifierBotID int64 `json:"verifier_bot_id"`
|
||||
PeerType domain.PeerType `json:"peer_type"`
|
||||
PeerID int64 `json:"peer_id"`
|
||||
}
|
||||
|
||||
// ApproveBotVerificationRequest grants the mark an application asked for. The
|
||||
// mark and the approved status commit together in the use-case layer, so an
|
||||
// approved application without its mark is not a reachable state.
|
||||
type ApproveBotVerificationRequest struct {
|
||||
CommandMeta
|
||||
RequestID int64 `json:"request_id"`
|
||||
Version int64 `json:"version"`
|
||||
// InternalNote is operator-only: it is journalled and stored on the
|
||||
// application, and it is never part of what the applicant is told.
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// RejectBotVerificationRequest closes an application against the applicant.
|
||||
// Reason is mandatory: it is the text the applicant receives.
|
||||
type RejectBotVerificationRequest struct {
|
||||
CommandMeta
|
||||
RequestID int64 `json:"request_id"`
|
||||
Version int64 `json:"version"`
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// RevokeBotVerificationRequest withdraws a granted mark through the application it
|
||||
// came from. The application stays as history -- revoked is reachable only from
|
||||
// approved, so it keeps meaning "was verified once". Reason is mandatory for the
|
||||
// same reason it is on rejection.
|
||||
type RevokeBotVerificationRequest struct {
|
||||
CommandMeta
|
||||
RequestID int64 `json:"request_id"`
|
||||
Version int64 `json:"version"`
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BotVerifiers lists verifier bots.
|
||||
func (s *Service) BotVerifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return nil, errBotVerificationNotConfigured
|
||||
}
|
||||
return s.botVerification.Verifiers(ctx, enabledOnly, limit)
|
||||
}
|
||||
|
||||
// BotVerifier resolves one verifier's status, enabled or not: the panel needs the
|
||||
// disabled row too, to render the kill switch.
|
||||
func (s *Service) BotVerifier(ctx context.Context, botID int64) (domain.BotVerifierSettings, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return domain.BotVerifierSettings{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if botID <= 0 {
|
||||
return domain.BotVerifierSettings{}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
return s.botVerification.VerifierSettings(ctx, botID)
|
||||
}
|
||||
|
||||
// VerificationIcons lists the icon catalogue, newest first.
|
||||
func (s *Service) VerificationIcons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return nil, errBotVerificationNotConfigured
|
||||
}
|
||||
return s.botVerification.Icons(ctx, activeOnly, limit)
|
||||
}
|
||||
|
||||
// CustomVerifications lists granted third-party marks with keyset paging.
|
||||
func (s *Service) CustomVerifications(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return nil, errBotVerificationNotConfigured
|
||||
}
|
||||
return s.botVerification.Marks(ctx, filter)
|
||||
}
|
||||
|
||||
// CustomVerificationRequests is the third-party review queue.
|
||||
func (s *Service) CustomVerificationRequests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return nil, errBotVerificationNotConfigured
|
||||
}
|
||||
return s.botVerification.Requests(ctx, filter)
|
||||
}
|
||||
|
||||
// CustomVerificationRequest resolves one application by identity.
|
||||
func (s *Service) CustomVerificationRequest(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return domain.CustomVerificationRequest{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if requestID <= 0 {
|
||||
return domain.CustomVerificationRequest{}, botVerificationCoded(domain.ErrCustomVerificationRequestNotFound)
|
||||
}
|
||||
return s.botVerification.Request(ctx, requestID)
|
||||
}
|
||||
|
||||
// CustomVerificationRequestCounts is the queue summary rendered above the list.
|
||||
func (s *Service) CustomVerificationRequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return nil, errBotVerificationNotConfigured
|
||||
}
|
||||
return s.botVerification.RequestCounts(ctx)
|
||||
}
|
||||
|
||||
// CustomVerificationMarkActive reports whether a (verifier, peer) pair currently
|
||||
// carries a mark. The request detail view needs it to tell "approved" apart from
|
||||
// "approved and since stripped by the operator".
|
||||
func (s *Service) CustomVerificationMarkActive(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return false, errBotVerificationNotConfigured
|
||||
}
|
||||
if verifierBotID <= 0 || peer.ID <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
marks, err := s.botVerification.Marks(ctx, domain.CustomVerificationFilter{
|
||||
VerifierBotID: verifierBotID,
|
||||
PeerType: peer.Type,
|
||||
PeerID: peer.ID,
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return false, botVerificationError(err)
|
||||
}
|
||||
return len(marks) > 0, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands: verifier status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GrantBotVerifier grants or reconfigures verifier status.
|
||||
//
|
||||
// The dry run predicts everything it can without writing: the payload shape, the
|
||||
// optimistic lock against the row as it is now, and -- when the icon is visible in
|
||||
// the catalogue page it reads -- that the icon is active and usable by this bot.
|
||||
// It deliberately does not fail an icon it cannot see: the use-case layer resolves
|
||||
// the icon's custom emoji document against the document store, and a dry run that
|
||||
// refused more than the real command would make a rehearsal useless.
|
||||
func (s *Service) GrantBotVerifier(ctx context.Context, req GrantBotVerifierRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if req.BotID <= 0 {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
if req.Version < 0 {
|
||||
return CommandResult{}, botVerifierInvalid("version must not be negative")
|
||||
}
|
||||
settings := domain.BotVerifierSettings{
|
||||
BotID: req.BotID,
|
||||
IconDocumentID: req.IconDocumentID,
|
||||
CompanyName: strings.TrimSpace(req.CompanyName),
|
||||
DefaultDescription: strings.TrimSpace(req.DefaultDescription),
|
||||
CanModifyCustomDescription: req.CanModifyCustomDescription,
|
||||
GrantedBy: strings.TrimSpace(req.Actor),
|
||||
GrantReason: strings.TrimSpace(req.Reason),
|
||||
Enabled: true,
|
||||
Version: req.Version,
|
||||
}
|
||||
if err := settings.Validate(); err != nil {
|
||||
return CommandResult{}, botVerificationError(err)
|
||||
}
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: req.BotID}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionGrantBotVerifier, req.BotID, target, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"bot_id": strconv.FormatInt(req.BotID, 10),
|
||||
"icon_document_id": strconv.FormatInt(req.IconDocumentID, 10),
|
||||
"company_name": settings.CompanyName,
|
||||
"can_modify_custom_description": req.CanModifyCustomDescription,
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
current, exists, err := s.botVerifierState(ctx, req.BotID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["created"] = !exists
|
||||
if exists {
|
||||
details["previous_version"] = strconv.FormatInt(current.Version, 10)
|
||||
details["previous_icon_document_id"] = strconv.FormatInt(current.IconDocumentID, 10)
|
||||
details["previous_enabled"] = current.Enabled
|
||||
// The kill switch is its own action, so reconfiguring a switched-off
|
||||
// verifier must not switch it back on.
|
||||
settings.Enabled = current.Enabled
|
||||
if req.Version != current.Version {
|
||||
return CommandResult{Details: details}, codedError(CodeCustomVerificationConflict, domain.ErrCustomVerificationVersionConflict)
|
||||
}
|
||||
} else if req.Version != 0 {
|
||||
// The operator is editing a row that is no longer there.
|
||||
return CommandResult{Details: details}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
details["enabled"] = settings.Enabled
|
||||
if err := s.grantIconPreflight(ctx, details, req.BotID, req.IconDocumentID); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if req.DryRun {
|
||||
message := "bot verifier grant validated"
|
||||
if exists {
|
||||
message = "bot verifier update validated"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
}
|
||||
stored, err := s.botVerification.GrantVerifier(ctx, settings)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["version"] = strconv.FormatInt(stored.Version, 10)
|
||||
details["enabled"] = stored.Enabled
|
||||
details["changed"] = true
|
||||
message := "bot verifier status granted"
|
||||
if exists {
|
||||
message = "bot verifier status updated"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetBotVerifierEnabled flips the operator kill switch.
|
||||
func (s *Service) SetBotVerifierEnabled(ctx context.Context, req SetBotVerifierEnabledRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if req.BotID <= 0 {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: req.BotID}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetBotVerifierEnabled, req.BotID, target, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"bot_id": strconv.FormatInt(req.BotID, 10),
|
||||
"enabled": req.Enabled,
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
current, exists, err := s.botVerifierState(ctx, req.BotID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if !exists {
|
||||
return CommandResult{Details: details}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
details["previous_enabled"] = current.Enabled
|
||||
details["previous_version"] = strconv.FormatInt(current.Version, 10)
|
||||
details["company_name"] = current.CompanyName
|
||||
// A no-op flip neither burns a version nor pushes an update, so the audit
|
||||
// trail does not claim a change that never happened.
|
||||
noop := current.Enabled == req.Enabled
|
||||
details["changed"] = !noop
|
||||
if req.DryRun {
|
||||
message := "bot verifier switch validated"
|
||||
if noop {
|
||||
message = "bot verifier switch validated; already in that state"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
}
|
||||
stored, err := s.botVerification.SetVerifierEnabled(ctx, req.BotID, req.Enabled)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["version"] = strconv.FormatInt(stored.Version, 10)
|
||||
details["enabled"] = stored.Enabled
|
||||
message := "bot verifier enabled"
|
||||
if !req.Enabled {
|
||||
message = "bot verifier disabled"
|
||||
}
|
||||
if noop {
|
||||
message = "bot verifier was already in that state"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeBotVerifier removes verifier status entirely.
|
||||
//
|
||||
// A missing row is not an error: the command answers changed=false, so a panel
|
||||
// retry after a lost response is harmless and the dry run never refuses what the
|
||||
// real command accepts.
|
||||
func (s *Service) RevokeBotVerifier(ctx context.Context, req RevokeBotVerifierRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if req.BotID <= 0 {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: req.BotID}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRevokeBotVerifier, req.BotID, target, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"bot_id": strconv.FormatInt(req.BotID, 10),
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
current, exists, err := s.botVerifierState(ctx, req.BotID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["present"] = exists
|
||||
if exists {
|
||||
details["previous_version"] = strconv.FormatInt(current.Version, 10)
|
||||
details["previous_enabled"] = current.Enabled
|
||||
details["company_name"] = current.CompanyName
|
||||
// The marks this verifier granted cascade away with the row, which is
|
||||
// the fact an operator most needs stated before confirming.
|
||||
details["mark_count"] = s.botVerifierMarkCount(ctx, req.BotID)
|
||||
}
|
||||
if req.DryRun {
|
||||
message := "bot verifier revoke validated"
|
||||
if !exists {
|
||||
message = "bot verifier revoke validated; the bot is not a verifier"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
}
|
||||
removed, err := s.botVerification.RevokeVerifier(ctx, req.BotID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["changed"] = removed
|
||||
message := "bot verifier status revoked"
|
||||
if !removed {
|
||||
message = "bot was not a verifier"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands: icon catalogue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UpsertVerificationIcon adds or updates a catalogue entry.
|
||||
//
|
||||
// The use-case layer resolves the custom emoji document before writing, because an
|
||||
// entry pointing at a document no client can fetch renders as *nothing*: the peer
|
||||
// looks unverified while the server insists it is marked. That probe needs the
|
||||
// document store, so it runs on execution only; the dry run validates the shape.
|
||||
func (s *Service) UpsertVerificationIcon(ctx context.Context, req UpsertVerificationIconRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
icon := domain.VerificationIcon{
|
||||
DocumentID: req.DocumentID,
|
||||
OwnerBotID: req.OwnerBotID,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Active: true,
|
||||
}
|
||||
if err := icon.Validate(); err != nil {
|
||||
return CommandResult{}, botVerificationError(err)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionUpsertVerificationIcon, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"document_id": strconv.FormatInt(req.DocumentID, 10),
|
||||
"owner_bot_id": strconv.FormatInt(req.OwnerBotID, 10),
|
||||
"name": icon.Name,
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "verification icon validated", Details: details}, nil
|
||||
}
|
||||
stored, err := s.botVerification.UpsertIcon(ctx, icon)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["icon_id"] = strconv.FormatInt(stored.ID, 10)
|
||||
details["active"] = stored.Active
|
||||
details["changed"] = true
|
||||
return CommandResult{Message: "verification icon stored", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetVerificationIconActive retires or restores a catalogue entry.
|
||||
func (s *Service) SetVerificationIconActive(ctx context.Context, req SetVerificationIconActiveRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if req.IconID <= 0 {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrVerificationIconNotFound)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetVerificationIconActive, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"icon_id": strconv.FormatInt(req.IconID, 10),
|
||||
"active": req.Active,
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "verification icon switch validated", Details: details}, nil
|
||||
}
|
||||
stored, err := s.botVerification.SetIconActive(ctx, req.IconID, req.Active)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["document_id"] = strconv.FormatInt(stored.DocumentID, 10)
|
||||
details["name"] = stored.Name
|
||||
details["active"] = stored.Active
|
||||
details["changed"] = true
|
||||
message := "verification icon activated"
|
||||
if !req.Active {
|
||||
// Marks already granted with it keep rendering: the icon id is
|
||||
// denormalised onto the mark.
|
||||
message = "verification icon retired"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands: granted marks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RevokeCustomVerification strips one verifier's mark from a peer.
|
||||
//
|
||||
// The peer is checked for shape only, never for existence: an operator must still
|
||||
// be able to strip a mark from a peer that has since been deleted or become
|
||||
// unresolvable, which is exactly when stripping it matters most.
|
||||
func (s *Service) RevokeCustomVerification(ctx context.Context, req RevokeCustomVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if req.VerifierBotID <= 0 {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
peer := domain.Peer{Type: req.PeerType, ID: req.PeerID}
|
||||
if !markableAdminPeer(peer) {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrCustomVerificationTargetInvalid)
|
||||
}
|
||||
targetUserID := int64(0)
|
||||
if peer.Type == domain.PeerTypeUser {
|
||||
targetUserID = peer.ID
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRevokeCustomVerification, targetUserID, peer, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"verifier_bot_id": strconv.FormatInt(req.VerifierBotID, 10),
|
||||
"peer_type": string(peer.Type),
|
||||
"peer_id": strconv.FormatInt(peer.ID, 10),
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
present, err := s.CustomVerificationMarkActive(ctx, req.VerifierBotID, peer)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["mark_present"] = present
|
||||
if req.DryRun {
|
||||
message := "custom verification revoke validated"
|
||||
if !present {
|
||||
message = "custom verification revoke validated; the peer carries no mark from this verifier"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
}
|
||||
removed, err := s.botVerification.RevokeMark(ctx, req.VerifierBotID, peer)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["changed"] = removed
|
||||
message := "custom verification revoked"
|
||||
if !removed {
|
||||
message = "custom verification was already absent"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands: application queue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ApproveBotVerification grants the mark an application asked for.
|
||||
func (s *Service) ApproveBotVerification(ctx context.Context, req ApproveBotVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if err := validateBotVerificationDecisionShape(req.RequestID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionApproveBotVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
current, details, err := s.botVerificationSubject(ctx, req.CommandMeta, req.RequestID, domain.CustomVerificationApproved, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := botVerificationTransition(current, req.Version, domain.CustomVerificationApproved); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
// An application that is already approved is a replay: the use-case layer
|
||||
// answers changed=false without re-running the verifier gate, so the dry run
|
||||
// must not re-run it either.
|
||||
replay := current.Status == domain.CustomVerificationApproved
|
||||
if !replay {
|
||||
if err := s.mergeVerifierDecisionDetails(ctx, details, current.VerifierBotID, true); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "bot verification approve validated", Details: details}, nil
|
||||
}
|
||||
stored, changed, err := s.botVerification.Approve(ctx, req.RequestID, req.Version,
|
||||
strings.TrimSpace(req.Actor), strings.TrimSpace(req.Reason), strings.TrimSpace(req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
mergeBotVerificationDetails(details, stored, changed)
|
||||
message := "bot verification application approved"
|
||||
if !changed {
|
||||
message = "bot verification application already approved"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RejectBotVerification closes an application against the applicant. The reason is
|
||||
// mandatory and is the text the applicant is shown; the internal note stays
|
||||
// operator-side.
|
||||
func (s *Service) RejectBotVerification(ctx context.Context, req RejectBotVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if err := validateBotVerificationDecisionShape(req.RequestID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
// Refused before the journal is touched: the audit trail must never contain a
|
||||
// decision nobody can explain.
|
||||
if strings.TrimSpace(req.Reason) == "" {
|
||||
return CommandResult{}, codedError(CodeCustomVerificationReasonRequired, domain.ErrVerificationReasonRequired)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRejectBotVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
current, details, err := s.botVerificationSubject(ctx, req.CommandMeta, req.RequestID, domain.CustomVerificationRejected, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := botVerificationTransition(current, req.Version, domain.CustomVerificationRejected); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "bot verification reject validated", Details: details}, nil
|
||||
}
|
||||
stored, changed, err := s.botVerification.Reject(ctx, req.RequestID, req.Version,
|
||||
strings.TrimSpace(req.Actor), strings.TrimSpace(req.Reason), strings.TrimSpace(req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
mergeBotVerificationDetails(details, stored, changed)
|
||||
message := "bot verification application rejected"
|
||||
if !changed {
|
||||
message = "bot verification application already rejected"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeBotVerification withdraws a granted mark through the application it came
|
||||
// from. A reason is mandatory for the same reason it is on rejection.
|
||||
func (s *Service) RevokeBotVerification(ctx context.Context, req RevokeBotVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if err := validateBotVerificationDecisionShape(req.RequestID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(req.Reason) == "" {
|
||||
return CommandResult{}, codedError(CodeCustomVerificationReasonRequired, domain.ErrVerificationReasonRequired)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRevokeBotVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
current, details, err := s.botVerificationSubject(ctx, req.CommandMeta, req.RequestID, domain.CustomVerificationRevoked, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := botVerificationTransition(current, req.Version, domain.CustomVerificationRevoked); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
// The mark may already be gone -- the operator can strip one directly -- and
|
||||
// that is not an error: the application still has to reach "revoked".
|
||||
present, err := s.CustomVerificationMarkActive(ctx, current.VerifierBotID, current.Peer)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["mark_present"] = present
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "bot verification revoke validated", Details: details}, nil
|
||||
}
|
||||
stored, changed, err := s.botVerification.RevokeRequest(ctx, req.RequestID, req.Version,
|
||||
strings.TrimSpace(req.Actor), strings.TrimSpace(req.Reason), strings.TrimSpace(req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
mergeBotVerificationDetails(details, stored, changed)
|
||||
message := "bot verification mark revoked"
|
||||
if !changed {
|
||||
message = "bot verification application already revoked"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var errBotVerificationNotConfigured = errors.New("admin bot verification dependency is not configured")
|
||||
|
||||
// botVerifierState loads a verifier row and reports whether it exists at all,
|
||||
// because "not a verifier" is a normal answer for several of these commands
|
||||
// rather than a failure.
|
||||
func (s *Service) botVerifierState(ctx context.Context, botID int64) (domain.BotVerifierSettings, bool, error) {
|
||||
settings, err := s.botVerification.VerifierSettings(ctx, botID)
|
||||
switch {
|
||||
case err == nil:
|
||||
return settings, settings.BotID == botID, nil
|
||||
case errors.Is(err, domain.ErrVerifierNotFound):
|
||||
return domain.BotVerifierSettings{}, false, nil
|
||||
default:
|
||||
return domain.BotVerifierSettings{}, false, botVerificationError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// botVerifierMarkCount reports how many marks a verifier holds, bounded by the
|
||||
// listing page. It is advisory audit detail, so a read failure is reported as an
|
||||
// unknown count rather than failing the command it is describing.
|
||||
func (s *Service) botVerifierMarkCount(ctx context.Context, botID int64) any {
|
||||
marks, err := s.botVerification.Marks(ctx, domain.CustomVerificationFilter{
|
||||
VerifierBotID: botID,
|
||||
Limit: botVerificationCatalogueScan,
|
||||
})
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
if len(marks) >= botVerificationCatalogueScan {
|
||||
return strconv.Itoa(len(marks)) + "+"
|
||||
}
|
||||
return strconv.Itoa(len(marks))
|
||||
}
|
||||
|
||||
// grantIconPreflight records what the catalogue says about the icon and refuses
|
||||
// the definite failures early.
|
||||
//
|
||||
// The check is deliberately one-sided. An icon found in the page and unusable is
|
||||
// a certain failure and is refused with its own code; an icon the page does not
|
||||
// cover is left to the use-case layer, which reads the entry by document id and
|
||||
// additionally resolves the custom emoji document. So this can only ever make the
|
||||
// dry run *more* informative, never stricter than the command it rehearses.
|
||||
func (s *Service) grantIconPreflight(ctx context.Context, details map[string]any, botID, documentID int64) error {
|
||||
icons, err := s.botVerification.Icons(ctx, false, botVerificationCatalogueScan)
|
||||
if err != nil {
|
||||
details["icon_catalogue_checked"] = false
|
||||
return nil
|
||||
}
|
||||
for _, icon := range icons {
|
||||
if icon.DocumentID != documentID {
|
||||
continue
|
||||
}
|
||||
details["icon_catalogue_checked"] = true
|
||||
details["icon_id"] = strconv.FormatInt(icon.ID, 10)
|
||||
details["icon_name"] = icon.Name
|
||||
details["icon_active"] = icon.Active
|
||||
details["icon_owner_bot_id"] = strconv.FormatInt(icon.OwnerBotID, 10)
|
||||
if !icon.Active {
|
||||
return botVerificationCoded(domain.ErrVerificationIconInactive)
|
||||
}
|
||||
if !icon.UsableBy(botID) {
|
||||
// A reserved entry belongs to one verifier; for anybody else it does
|
||||
// not exist, which is why this is "not found" and not "forbidden".
|
||||
return botVerificationCoded(domain.ErrVerificationIconNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
details["icon_catalogue_checked"] = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// botVerificationSubject loads the application under decision and seeds the
|
||||
// command details with everything the audit entry must state even when the command
|
||||
// then fails: which application, whose, which verifier, which peer, the status it
|
||||
// is leaving and the status it was asked to reach.
|
||||
func (s *Service) botVerificationSubject(
|
||||
ctx context.Context,
|
||||
meta CommandMeta,
|
||||
requestID int64,
|
||||
next domain.CustomVerificationRequestStatus,
|
||||
internalNote string,
|
||||
) (domain.CustomVerificationRequest, map[string]any, error) {
|
||||
details := map[string]any{
|
||||
"request_id": strconv.FormatInt(requestID, 10),
|
||||
"next_status": string(next),
|
||||
"correlation_id": strings.TrimSpace(meta.CommandID),
|
||||
}
|
||||
if note := strings.TrimSpace(internalNote); note != "" {
|
||||
details["internal_note"] = note
|
||||
}
|
||||
current, err := s.botVerification.Request(ctx, requestID)
|
||||
if err != nil {
|
||||
return domain.CustomVerificationRequest{}, details, botVerificationError(err)
|
||||
}
|
||||
if current.ID != requestID {
|
||||
return domain.CustomVerificationRequest{}, details, botVerificationCoded(domain.ErrCustomVerificationRequestNotFound)
|
||||
}
|
||||
details["verifier_bot_id"] = strconv.FormatInt(current.VerifierBotID, 10)
|
||||
details["applicant_user_id"] = strconv.FormatInt(current.ApplicantUserID, 10)
|
||||
details["peer_type"] = string(current.Peer.Type)
|
||||
details["peer_id"] = strconv.FormatInt(current.Peer.ID, 10)
|
||||
details["peer_username"] = current.PeerUsername
|
||||
details["previous_status"] = string(current.Status)
|
||||
details["previous_version"] = strconv.FormatInt(current.Version, 10)
|
||||
return current, details, nil
|
||||
}
|
||||
|
||||
// mergeVerifierDecisionDetails records the verifier behind an application and,
|
||||
// for a decision that would grant a mark, refuses a verifier that may not verify
|
||||
// right now.
|
||||
//
|
||||
// An application can sit in the queue for days: a verifier switched off in the
|
||||
// meantime must not be able to grant through the review path what the RPC path
|
||||
// would refuse. "No row" and "switched off" answer the same code on purpose --
|
||||
// that is the distinction BOTVERIFIER_FORBIDDEN deliberately hides.
|
||||
func (s *Service) mergeVerifierDecisionDetails(ctx context.Context, details map[string]any, botID int64, requireEnabled bool) error {
|
||||
settings, exists, err := s.botVerifierState(ctx, botID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
details["verifier_present"] = exists
|
||||
if exists {
|
||||
details["verifier_enabled"] = settings.Enabled
|
||||
details["verifier_company_name"] = settings.CompanyName
|
||||
details["verifier_icon_document_id"] = strconv.FormatInt(settings.IconDocumentID, 10)
|
||||
details["verifier_can_modify_custom_description"] = settings.CanModifyCustomDescription
|
||||
}
|
||||
if requireEnabled && (!exists || !settings.Enabled) {
|
||||
return botVerificationCoded(domain.ErrVerifierForbidden)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeBotVerificationDetails records the decided state.
|
||||
func mergeBotVerificationDetails(details map[string]any, req domain.CustomVerificationRequest, changed bool) {
|
||||
details["status"] = string(req.Status)
|
||||
details["version"] = strconv.FormatInt(req.Version, 10)
|
||||
details["changed"] = changed
|
||||
details["decided_by"] = req.DecidedBy
|
||||
if req.CorrelationID != "" {
|
||||
details["correlation_id"] = req.CorrelationID
|
||||
}
|
||||
if req.DecisionReason != "" {
|
||||
details["decision_reason"] = req.DecisionReason
|
||||
}
|
||||
}
|
||||
|
||||
// validateBotVerificationDecisionShape rejects a malformed decision before the
|
||||
// command journal is touched.
|
||||
func validateBotVerificationDecisionShape(requestID, version int64, internalNote string) error {
|
||||
if requestID <= 0 {
|
||||
return botVerificationCoded(domain.ErrCustomVerificationRequestNotFound)
|
||||
}
|
||||
if version <= 0 {
|
||||
// Without the version the reviewer never read the row, so the optimistic
|
||||
// lock could not protect a concurrent decision.
|
||||
return botVerificationInvalid("version is required")
|
||||
}
|
||||
if utf8.RuneCountInString(internalNote) > domain.MaxCustomVerificationNoteLength {
|
||||
return botVerificationInvalid("internal_note is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// botVerificationTransition checks the status machine and the optimistic lock in
|
||||
// the order the use-case layer does, so a dry run predicts the real outcome
|
||||
// exactly.
|
||||
//
|
||||
// A request already in the target status is a replay the service answers as a
|
||||
// no-op, so it passes without consulting the version: re-sending a decision whose
|
||||
// response was lost must not turn into a spurious conflict.
|
||||
func botVerificationTransition(req domain.CustomVerificationRequest, version int64, next domain.CustomVerificationRequestStatus) error {
|
||||
if req.Status == next {
|
||||
return nil
|
||||
}
|
||||
if !domain.CanTransitionCustomVerificationStatus(req.Status, next) {
|
||||
return codedError(CodeCustomVerificationStatusInvalid,
|
||||
fmt.Errorf("%w: %s -> %s", domain.ErrCustomVerificationRequestInvalid, req.Status, next))
|
||||
}
|
||||
if req.Version != version {
|
||||
return codedError(CodeCustomVerificationConflict, domain.ErrCustomVerificationVersionConflict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// markableAdminPeer mirrors the store's peer_type CHECK: only users (bots
|
||||
// included) and channels can carry a third-party mark.
|
||||
func markableAdminPeer(peer domain.Peer) bool {
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||||
return peer.ID > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// BotVerificationErrorCode maps a third-party verification failure onto the stable
|
||||
// code the admin panel switches on. An unmapped error returns "" so the caller can
|
||||
// report it verbatim instead of inventing a code.
|
||||
//
|
||||
// The codes are separate from the official verification ones by design: the two
|
||||
// mechanisms fail for different reasons and the panel renders them in different
|
||||
// sections, so sharing a token would make a message land in the wrong place.
|
||||
func BotVerificationErrorCode(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case errors.Is(err, domain.ErrVerifierNotFound):
|
||||
return CodeBotVerifierNotFound
|
||||
case errors.Is(err, domain.ErrVerifierForbidden):
|
||||
return CodeBotVerifierForbidden
|
||||
case errors.Is(err, domain.ErrVerifierDescriptionForbidden):
|
||||
return CodeBotVerifierDescriptionForbidden
|
||||
case errors.Is(err, domain.ErrVerifierSettingsInvalid):
|
||||
return CodeBotVerifierInvalid
|
||||
case errors.Is(err, domain.ErrVerificationIconNotFound):
|
||||
return CodeVerificationIconNotFound
|
||||
case errors.Is(err, domain.ErrVerificationIconInactive):
|
||||
return CodeVerificationIconInactive
|
||||
case errors.Is(err, domain.ErrVerificationIconInvalid):
|
||||
return CodeVerificationIconInvalid
|
||||
case errors.Is(err, domain.ErrCustomVerificationVersionConflict):
|
||||
return CodeCustomVerificationConflict
|
||||
case errors.Is(err, domain.ErrCustomVerificationLimit):
|
||||
return CodeCustomVerificationLimit
|
||||
case errors.Is(err, domain.ErrCustomVerificationNotFound):
|
||||
return CodeCustomVerificationNotFound
|
||||
case errors.Is(err, domain.ErrCustomVerificationRequestNotFound):
|
||||
return CodeCustomVerificationRequestNotFound
|
||||
case errors.Is(err, domain.ErrCustomVerificationRequestExists):
|
||||
return CodeCustomVerificationRequestExists
|
||||
case errors.Is(err, domain.ErrCustomVerificationTargetInvalid):
|
||||
return CodeCustomVerificationTargetInvalid
|
||||
case errors.Is(err, domain.ErrVerificationTargetSystem):
|
||||
return CodeCustomVerificationTargetSystem
|
||||
case errors.Is(err, domain.ErrVerificationReasonRequired):
|
||||
return CodeCustomVerificationReasonRequired
|
||||
case errors.Is(err, domain.ErrVerificationRateLimited):
|
||||
return CodeCustomVerificationRateLimited
|
||||
case errors.Is(err, domain.ErrBotNotFound):
|
||||
return CodeBotVerifierBotNotFound
|
||||
case errors.Is(err, domain.ErrCustomVerificationRequestInvalid):
|
||||
return CodeCustomVerificationInvalid
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// botVerificationError prefixes a recognised failure with its stable code, the way
|
||||
// verificationError does for the official review.
|
||||
func botVerificationError(err error) error {
|
||||
if code := BotVerificationErrorCode(err); code != "" {
|
||||
return codedError(code, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func botVerificationCoded(err error) error {
|
||||
return codedError(BotVerificationErrorCode(err), err)
|
||||
}
|
||||
|
||||
func botVerificationInvalid(message string) error {
|
||||
return codedError(CodeCustomVerificationInvalid, fmt.Errorf("%s: %w", message, domain.ErrCustomVerificationRequestInvalid))
|
||||
}
|
||||
|
||||
func botVerifierInvalid(message string) error {
|
||||
return codedError(CodeBotVerifierInvalid, fmt.Errorf("%s: %w", message, domain.ErrVerifierSettingsInvalid))
|
||||
}
|
||||
1045
internal/admin/botverification_test.go
Normal file
1045
internal/admin/botverification_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -45,6 +45,34 @@ const (
|
|||
ActionGiveGift = "gifts.give"
|
||||
ActionCreateBot = "bot.create"
|
||||
ActionDeleteBot = "bot.delete"
|
||||
// Collectible (Fragment-style) username lifecycle.
|
||||
ActionMintCollectibleUsername = "usernames.collectible.mint"
|
||||
ActionTransferCollectibleUsername = "usernames.collectible.transfer"
|
||||
ActionRevokeCollectibleUsername = "usernames.collectible.revoke"
|
||||
ActionDeleteCollectibleUsername = "usernames.collectible.delete"
|
||||
// Composite account rating.
|
||||
ActionRecomputeAccountRating = "rating.recompute"
|
||||
ActionAdjustAccountRating = "rating.adjust"
|
||||
// Official platform verification review. Claim/approve/reject act on one
|
||||
// application; revoke acts on a target, because clearing a badge is not a
|
||||
// decision on the application that granted it.
|
||||
ActionClaimVerification = "verification.claim"
|
||||
ActionApproveVerification = "verification.approve"
|
||||
ActionRejectVerification = "verification.reject"
|
||||
ActionRevokeVerification = "verification.revoke"
|
||||
// Third-party bot verification (see botverification.go). A namespace of its own
|
||||
// on purpose: these actions write the verifier catalogue and the attributed
|
||||
// marks, never the platform checkmark, and the audit trail has to keep the two
|
||||
// mechanisms apart at a glance.
|
||||
ActionGrantBotVerifier = "botverification.grant_verifier"
|
||||
ActionSetBotVerifierEnabled = "botverification.set_verifier_enabled"
|
||||
ActionRevokeBotVerifier = "botverification.revoke_verifier"
|
||||
ActionUpsertVerificationIcon = "botverification.upsert_icon"
|
||||
ActionSetVerificationIconActive = "botverification.set_icon_active"
|
||||
ActionRevokeCustomVerification = "botverification.revoke_mark"
|
||||
ActionApproveBotVerification = "botverification.approve"
|
||||
ActionRejectBotVerification = "botverification.reject"
|
||||
ActionRevokeBotVerification = "botverification.revoke_request"
|
||||
|
||||
maxCommandIDLength = 128
|
||||
maxActorLength = 128
|
||||
|
|
@ -53,6 +81,79 @@ const (
|
|||
maxPremiumMonths = 120
|
||||
maxStarsGrant = 1_000_000_000
|
||||
maxFreezeAppealURLLength = 2048
|
||||
// maxAccountRatingAdjustment bounds one manual rating adjustment in either
|
||||
// direction. The domain only rejects a zero delta, so the operator-facing
|
||||
// bound lives here next to the other grant limits.
|
||||
maxAccountRatingAdjustment = 1_000_000_000
|
||||
)
|
||||
|
||||
// Stable admin error codes for the collectible-username and account-rating
|
||||
// subsystems. The panel switches on the code, so a command failure and a read
|
||||
// failure describe the same condition with the same token instead of leaking a
|
||||
// Go error string the UI would have to pattern-match.
|
||||
const (
|
||||
CodeUsernameOccupied = "USERNAME_OCCUPIED"
|
||||
CodeUsernameInvalid = "USERNAME_INVALID"
|
||||
CodeUsernameNotCollectible = "USERNAME_NOT_COLLECTIBLE"
|
||||
CodeCollectibleNotFound = "COLLECTIBLE_NOT_FOUND"
|
||||
CodeCollectibleNotOwned = "COLLECTIBLE_NOT_OWNED"
|
||||
CodeCollectibleBurned = "COLLECTIBLE_BURNED"
|
||||
CodeCollectiblePeerLimit = "COLLECTIBLE_PEER_LIMIT"
|
||||
CodeCollectibleCurrencyInvalid = "COLLECTIBLE_CURRENCY_INVALID"
|
||||
CodeCollectibleStateInvalid = "COLLECTIBLE_STATE_INVALID"
|
||||
CodeRatingNotFound = "RATING_NOT_FOUND"
|
||||
CodeRatingAdjustmentInvalid = "RATING_ADJUSTMENT_INVALID"
|
||||
CodeRatingWeightsInvalid = "RATING_WEIGHTS_INVALID"
|
||||
// Official platform verification review. CodeVerificationConflict is the lost
|
||||
// optimistic-locking race -- two reviewers deciding at once -- and is the one
|
||||
// the panel must render as "reload and look again" rather than as a bad
|
||||
// request.
|
||||
CodeVerificationNotFound = "VERIFICATION_NOT_FOUND"
|
||||
CodeVerificationConflict = "VERIFICATION_CONFLICT"
|
||||
CodeVerificationStatusInvalid = "VERIFICATION_STATUS_INVALID"
|
||||
CodeVerificationReasonRequired = "VERIFICATION_REASON_REQUIRED"
|
||||
CodeVerificationTargetInvalid = "VERIFICATION_TARGET_INVALID"
|
||||
CodeVerificationTargetOccupied = "VERIFICATION_TARGET_OCCUPIED"
|
||||
CodeVerificationTargetVerified = "VERIFICATION_TARGET_ALREADY_VERIFIED"
|
||||
CodeVerificationTargetNotPublic = "VERIFICATION_TARGET_NOT_PUBLIC"
|
||||
CodeVerificationTargetRestricted = "VERIFICATION_TARGET_RESTRICTED"
|
||||
CodeVerificationTargetSystem = "VERIFICATION_TARGET_SYSTEM"
|
||||
CodeVerificationNotOwner = "VERIFICATION_NOT_OWNER"
|
||||
CodeVerificationUserTargetsDisabled = "VERIFICATION_USER_TARGETS_DISABLED"
|
||||
CodeVerificationInvalid = "VERIFICATION_INVALID"
|
||||
)
|
||||
|
||||
// Stable admin error codes for third-party bot verification (see
|
||||
// botverification.go). They are a separate set from the official verification
|
||||
// codes above: the two mechanisms own separate tables and fail for separate
|
||||
// reasons, and the panel renders them in separate sections, so one shared token
|
||||
// would land a message in the wrong place.
|
||||
//
|
||||
// CodeCustomVerificationConflict is the lost optimistic-locking race -- two
|
||||
// operators deciding at once -- and is the one the panel must render as "reload
|
||||
// and look again" rather than as a bad request.
|
||||
const (
|
||||
CodeBotVerifierNotFound = "BOTVERIFIER_NOT_FOUND"
|
||||
CodeBotVerifierForbidden = "BOTVERIFIER_FORBIDDEN"
|
||||
CodeBotVerifierInvalid = "BOTVERIFIER_INVALID"
|
||||
CodeBotVerifierBotNotFound = "BOTVERIFIER_BOT_NOT_FOUND"
|
||||
CodeBotVerifierDescriptionForbidden = "BOTVERIFIER_DESCRIPTION_FORBIDDEN"
|
||||
|
||||
CodeVerificationIconNotFound = "VERIFICATION_ICON_NOT_FOUND"
|
||||
CodeVerificationIconInactive = "VERIFICATION_ICON_INACTIVE"
|
||||
CodeVerificationIconInvalid = "VERIFICATION_ICON_INVALID"
|
||||
|
||||
CodeCustomVerificationNotFound = "CUSTOM_VERIFICATION_NOT_FOUND"
|
||||
CodeCustomVerificationRequestNotFound = "CUSTOM_VERIFICATION_REQUEST_NOT_FOUND"
|
||||
CodeCustomVerificationRequestExists = "CUSTOM_VERIFICATION_REQUEST_EXISTS"
|
||||
CodeCustomVerificationConflict = "CUSTOM_VERIFICATION_CONFLICT"
|
||||
CodeCustomVerificationLimit = "CUSTOM_VERIFICATION_LIMIT"
|
||||
CodeCustomVerificationStatusInvalid = "CUSTOM_VERIFICATION_STATUS_INVALID"
|
||||
CodeCustomVerificationReasonRequired = "CUSTOM_VERIFICATION_REASON_REQUIRED"
|
||||
CodeCustomVerificationTargetInvalid = "CUSTOM_VERIFICATION_TARGET_INVALID"
|
||||
CodeCustomVerificationTargetSystem = "CUSTOM_VERIFICATION_TARGET_SYSTEM"
|
||||
CodeCustomVerificationRateLimited = "CUSTOM_VERIFICATION_RATE_LIMITED"
|
||||
CodeCustomVerificationInvalid = "CUSTOM_VERIFICATION_INVALID"
|
||||
)
|
||||
|
||||
type CommandRepository interface {
|
||||
|
|
@ -179,6 +280,39 @@ type ModerationService interface {
|
|||
ReviewAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
|
||||
}
|
||||
|
||||
// CollectibleUsernamesService is the operator-facing slice of the collectible
|
||||
// username use cases: the mint/transfer/revoke lifecycle plus the reads the
|
||||
// admin panel explains an asset with. It is deliberately narrow -- the client
|
||||
// facing toggle/reorder entry points stay out of the admin surface, because the
|
||||
// editable slot and the row order belong to the peer, not to the operator.
|
||||
type CollectibleUsernamesService interface {
|
||||
Mint(ctx context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error)
|
||||
Transfer(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error)
|
||||
Revoke(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error)
|
||||
Delete(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error)
|
||||
Collectible(ctx context.Context, username string) (domain.CollectibleUsername, error)
|
||||
List(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
|
||||
Transfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
|
||||
}
|
||||
|
||||
// collectibleUsernameByIDLookup is the optional by-identity read. Stores that
|
||||
// expose it answer a detail request in one round trip; the keyset fallback in
|
||||
// CollectibleUsernameByID keeps a service without it correct.
|
||||
type collectibleUsernameByIDLookup interface {
|
||||
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
|
||||
}
|
||||
|
||||
// AccountRatingService is the operator-facing slice of the composite account
|
||||
// rating use cases: read the stored projection, force a recompute, adjust the
|
||||
// manual component and page the ledger that explains a level.
|
||||
type AccountRatingService interface {
|
||||
Rating(ctx context.Context, userID int64) (domain.AccountRating, error)
|
||||
Recompute(ctx context.Context, userID int64) (domain.AccountRating, error)
|
||||
Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error)
|
||||
List(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error)
|
||||
Events(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error)
|
||||
}
|
||||
|
||||
// GiftGranter delivers a catalog gift to a recipient peer on behalf of a sender
|
||||
// without charging Stars. Implemented by the RPC router, it reuses the standard
|
||||
// gift-delivery path (service message for users, saved-gift + admin log for
|
||||
|
|
@ -207,7 +341,13 @@ type Dependencies struct {
|
|||
Bots BotService
|
||||
Emoji EmojiService
|
||||
Moderation ModerationService
|
||||
Now func() time.Time
|
||||
Usernames CollectibleUsernamesService
|
||||
Rating AccountRatingService
|
||||
Verification VerificationService
|
||||
// BotVerification is the third-party mechanism, wired separately from
|
||||
// Verification: the two never read each other's state.
|
||||
BotVerification BotVerificationService
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
|
|
@ -230,6 +370,10 @@ type Service struct {
|
|||
bots BotService
|
||||
emoji EmojiService
|
||||
moderation ModerationService
|
||||
usernames CollectibleUsernamesService
|
||||
rating AccountRatingService
|
||||
verification VerificationService
|
||||
botVerification BotVerificationService
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
|
|
@ -296,6 +440,18 @@ func (s *Service) Configure(deps Dependencies) *Service {
|
|||
if deps.Moderation != nil {
|
||||
s.moderation = deps.Moderation
|
||||
}
|
||||
if deps.Usernames != nil {
|
||||
s.usernames = deps.Usernames
|
||||
}
|
||||
if deps.Rating != nil {
|
||||
s.rating = deps.Rating
|
||||
}
|
||||
if deps.Verification != nil {
|
||||
s.verification = deps.Verification
|
||||
}
|
||||
if deps.BotVerification != nil {
|
||||
s.botVerification = deps.BotVerification
|
||||
}
|
||||
if deps.Now != nil {
|
||||
s.now = deps.Now
|
||||
}
|
||||
|
|
@ -360,6 +516,87 @@ func (s *Service) ReviewModerationAppeal(ctx context.Context, request domain.Mod
|
|||
return s.moderation.ReviewAppeal(ctx, request)
|
||||
}
|
||||
|
||||
// CollectibleUsernames is the admin listing read. The filter is passed through
|
||||
// unchanged: the use-case layer owns normalisation and the page bound, so the
|
||||
// admin API and the RPC edge page the registry identically.
|
||||
func (s *Service) CollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
|
||||
if s == nil || s.usernames == nil {
|
||||
return nil, fmt.Errorf("collectible username dependency is not configured")
|
||||
}
|
||||
return s.usernames.List(ctx, filter)
|
||||
}
|
||||
|
||||
// CollectibleUsername resolves one asset by name.
|
||||
func (s *Service) CollectibleUsername(ctx context.Context, username string) (domain.CollectibleUsername, error) {
|
||||
if s == nil || s.usernames == nil {
|
||||
return domain.CollectibleUsername{}, fmt.Errorf("collectible username dependency is not configured")
|
||||
}
|
||||
return s.usernames.Collectible(ctx, username)
|
||||
}
|
||||
|
||||
// CollectibleUsernameByID resolves one asset by identity, which is how the admin
|
||||
// panel links a row to its detail view.
|
||||
//
|
||||
// A service exposing the direct by-identity read is used as-is. Otherwise the
|
||||
// bounded keyset listing answers it: the listing is ordered by descending id, so
|
||||
// the single row taken before id+1 is the asset itself whenever it exists, and
|
||||
// any other id proves the asset is gone.
|
||||
func (s *Service) CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error) {
|
||||
if s == nil || s.usernames == nil {
|
||||
return domain.CollectibleUsername{}, fmt.Errorf("collectible username dependency is not configured")
|
||||
}
|
||||
if id <= 0 {
|
||||
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
if lookup, ok := s.usernames.(collectibleUsernameByIDLookup); ok {
|
||||
return lookup.CollectibleUsernameByID(ctx, id)
|
||||
}
|
||||
before := int64(0)
|
||||
if id < math.MaxInt64 {
|
||||
before = id + 1
|
||||
}
|
||||
items, err := s.usernames.List(ctx, domain.CollectibleUsernameFilter{BeforeID: before, Limit: 1})
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, err
|
||||
}
|
||||
if len(items) == 0 || items[0].ID != id {
|
||||
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
return items[0], nil
|
||||
}
|
||||
|
||||
// CollectibleUsernameTransfers returns one asset's provenance log, newest first.
|
||||
func (s *Service) CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
if s == nil || s.usernames == nil {
|
||||
return nil, fmt.Errorf("collectible username dependency is not configured")
|
||||
}
|
||||
return s.usernames.Transfers(ctx, collectibleID, limit)
|
||||
}
|
||||
|
||||
// AccountRating returns one user's stored composite rating projection.
|
||||
func (s *Service) AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error) {
|
||||
if s == nil || s.rating == nil {
|
||||
return domain.AccountRating{}, fmt.Errorf("account rating dependency is not configured")
|
||||
}
|
||||
return s.rating.Rating(ctx, userID)
|
||||
}
|
||||
|
||||
// AccountRatings is the admin leaderboard read.
|
||||
func (s *Service) AccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
if s == nil || s.rating == nil {
|
||||
return nil, fmt.Errorf("account rating dependency is not configured")
|
||||
}
|
||||
return s.rating.List(ctx, filter)
|
||||
}
|
||||
|
||||
// AccountRatingEvents returns the contribution ledger that explains a level.
|
||||
func (s *Service) AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
|
||||
if s == nil || s.rating == nil {
|
||||
return nil, fmt.Errorf("account rating dependency is not configured")
|
||||
}
|
||||
return s.rating.Events(ctx, userID, limit)
|
||||
}
|
||||
|
||||
type CommandMeta struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Actor string `json:"actor"`
|
||||
|
|
@ -599,6 +836,67 @@ type DeleteBotRequest struct {
|
|||
BotUserID int64 `json:"bot_user_id"`
|
||||
}
|
||||
|
||||
// MintCollectibleUsernameRequest mints a collectible username asset. At most one
|
||||
// of OwnerUserID / OwnerChannelID may be set: neither mints into the operator
|
||||
// vault, one assigns the asset to that holder in the same command.
|
||||
//
|
||||
// Amount and CryptoAmount are minor units (nanotons for TON), so they cross the
|
||||
// JSON boundary as decimal strings and stay exact. PurchaseDate is an optional
|
||||
// Unix timestamp; zero is stamped with the command clock.
|
||||
type MintCollectibleUsernameRequest struct {
|
||||
CommandMeta
|
||||
Username string `json:"username"`
|
||||
OwnerUserID int64 `json:"owner_user_id,string,omitempty"`
|
||||
OwnerChannelID int64 `json:"owner_channel_id,string,omitempty"`
|
||||
Currency string `json:"currency"`
|
||||
Amount int64 `json:"amount,string"`
|
||||
CryptoCurrency string `json:"crypto_currency,omitempty"`
|
||||
CryptoAmount int64 `json:"crypto_amount,string,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
PurchaseDate int64 `json:"purchase_date,omitempty"`
|
||||
}
|
||||
|
||||
// TransferCollectibleUsernameRequest moves an asset out of the vault or between
|
||||
// holders. Exactly one of ToUserID / ToChannelID identifies the new holder.
|
||||
type TransferCollectibleUsernameRequest struct {
|
||||
CommandMeta
|
||||
Username string `json:"username"`
|
||||
ToUserID int64 `json:"to_user_id,string,omitempty"`
|
||||
ToChannelID int64 `json:"to_channel_id,string,omitempty"`
|
||||
}
|
||||
|
||||
// RevokeCollectibleUsernameRequest returns an asset to the vault, or retires it
|
||||
// permanently when Burn is set.
|
||||
type RevokeCollectibleUsernameRequest struct {
|
||||
CommandMeta
|
||||
Username string `json:"username"`
|
||||
Burn bool `json:"burn"`
|
||||
}
|
||||
|
||||
// DeleteCollectibleUsernameRequest erases a collectible asset entirely. Unlike a
|
||||
// burn, which retires the asset and keeps its provenance, this drops the record
|
||||
// and frees the name for a fresh issue -- the escape hatch for a mistaken mint.
|
||||
type DeleteCollectibleUsernameRequest struct {
|
||||
CommandMeta
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// RecomputeAccountRatingRequest forces one user's composite rating to be
|
||||
// recomputed from the current contribution signals.
|
||||
type RecomputeAccountRatingRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id,string"`
|
||||
}
|
||||
|
||||
// AdjustAccountRatingRequest moves one user's manual rating component by a
|
||||
// signed delta. The delta survives recomputes, so it is the operator's durable
|
||||
// override rather than a one-off nudge.
|
||||
type AdjustAccountRatingRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id,string"`
|
||||
Amount int64 `json:"amount,string"`
|
||||
}
|
||||
|
||||
type RevokeSessionsRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
|
|
@ -1314,6 +1612,467 @@ func (s *Service) DeleteBot(ctx context.Context, req DeleteBotRequest) (CommandR
|
|||
})
|
||||
}
|
||||
|
||||
// MintCollectibleUsername creates a collectible username asset and optionally
|
||||
// assigns it in the same command. Shape validation runs before the command is
|
||||
// journalled; occupancy is checked inside it, so a dry-run reports a taken name
|
||||
// without minting and a replay of a completed command stays idempotent.
|
||||
func (s *Service) MintCollectibleUsername(ctx context.Context, req MintCollectibleUsernameRequest) (CommandResult, error) {
|
||||
if s == nil || s.usernames == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin collectible username dependency is not configured")
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
if !domain.ValidCollectibleUsername(req.Username) {
|
||||
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
|
||||
}
|
||||
owner, err := collectibleOwnerPeer(req.OwnerUserID, req.OwnerChannelID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency))
|
||||
req.CryptoCurrency = strings.ToUpper(strings.TrimSpace(req.CryptoCurrency))
|
||||
if err := domain.ValidateCollectibleAmounts(req.Currency, req.Amount, req.CryptoCurrency, req.CryptoAmount); err != nil {
|
||||
return CommandResult{}, codedError(CodeCollectibleCurrencyInvalid, err)
|
||||
}
|
||||
req.URL = strings.TrimSpace(req.URL)
|
||||
if len(req.URL) > domain.MaxCollectibleUsernameURLLength {
|
||||
return CommandResult{}, fmt.Errorf("url must be <= %d bytes", domain.MaxCollectibleUsernameURLLength)
|
||||
}
|
||||
purchase, err := collectiblePurchaseDate(req.PurchaseDate, s.now)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
req.PurchaseDate = purchase.Unix()
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionMintCollectibleUsername, req.OwnerUserID, owner, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"username": req.Username,
|
||||
"owner_type": string(owner.Type),
|
||||
"owner_id": strconv.FormatInt(owner.ID, 10),
|
||||
"currency": req.Currency,
|
||||
"amount": strconv.FormatInt(req.Amount, 10),
|
||||
"crypto_currency": req.CryptoCurrency,
|
||||
"crypto_amount": strconv.FormatInt(req.CryptoAmount, 10),
|
||||
"purchase_date": purchase.Format(time.RFC3339),
|
||||
"url": req.URL,
|
||||
}
|
||||
existing, err := s.usernames.Collectible(ctx, req.Username)
|
||||
switch {
|
||||
case err == nil:
|
||||
details["existing_collectible_id"] = strconv.FormatInt(existing.ID, 10)
|
||||
details["existing_status"] = string(existing.Status)
|
||||
return CommandResult{Details: details}, codedError(CodeUsernameOccupied, domain.ErrUsernameOccupied)
|
||||
case !errors.Is(err, domain.ErrCollectibleUsernameNotFound):
|
||||
return CommandResult{Details: details}, collectibleUsernameError(err)
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "collectible username mint validated", Details: details}, nil
|
||||
}
|
||||
asset, created, err := s.usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: req.Username,
|
||||
Owner: owner,
|
||||
PurchaseDate: purchase,
|
||||
Currency: req.Currency,
|
||||
Amount: req.Amount,
|
||||
CryptoCurrency: req.CryptoCurrency,
|
||||
CryptoAmount: req.CryptoAmount,
|
||||
URL: req.URL,
|
||||
Actor: req.Actor,
|
||||
Reason: req.Reason,
|
||||
CommandKey: "admin-collectible-mint:" + req.CommandID,
|
||||
})
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, collectibleUsernameError(err)
|
||||
}
|
||||
details["collectible_id"] = strconv.FormatInt(asset.ID, 10)
|
||||
details["status"] = string(asset.Status)
|
||||
details["url"] = asset.URL
|
||||
details["created"] = created
|
||||
message := "collectible username minted"
|
||||
if !created {
|
||||
message = "collectible username mint replayed"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// TransferCollectibleUsername moves an asset to a new holder. The asset must
|
||||
// exist and must not be burned; the store keeps the move atomic with the
|
||||
// receiving peer's username registry row.
|
||||
func (s *Service) TransferCollectibleUsername(ctx context.Context, req TransferCollectibleUsernameRequest) (CommandResult, error) {
|
||||
if s == nil || s.usernames == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin collectible username dependency is not configured")
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
if !domain.ValidCollectibleUsername(req.Username) {
|
||||
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
|
||||
}
|
||||
to, err := collectibleOwnerPeer(req.ToUserID, req.ToChannelID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if to.Type == "" {
|
||||
return CommandResult{}, fmt.Errorf("exactly one of to_user_id or to_channel_id is required")
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionTransferCollectibleUsername, req.ToUserID, to, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"username": req.Username,
|
||||
"to_type": string(to.Type),
|
||||
"to_id": strconv.FormatInt(to.ID, 10),
|
||||
}
|
||||
asset, err := s.usernames.Collectible(ctx, req.Username)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, collectibleUsernameError(err)
|
||||
}
|
||||
details["collectible_id"] = strconv.FormatInt(asset.ID, 10)
|
||||
details["previous_status"] = string(asset.Status)
|
||||
details["previous_owner_type"] = string(asset.Owner.Type)
|
||||
details["previous_owner_id"] = strconv.FormatInt(asset.Owner.ID, 10)
|
||||
details["transfer_count"] = asset.TransferCount
|
||||
if asset.Status == domain.CollectibleUsernameStatusBurned {
|
||||
return CommandResult{Details: details}, codedError(CodeCollectibleBurned, domain.ErrCollectibleUsernameBurned)
|
||||
}
|
||||
details["would_change"] = !asset.Owned() || asset.Owner != to
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "collectible username transfer validated", Details: details}, nil
|
||||
}
|
||||
updated, changed, err := s.usernames.Transfer(ctx, domain.TransferCollectibleUsernameRequest{
|
||||
Username: req.Username,
|
||||
To: to,
|
||||
Actor: req.Actor,
|
||||
Reason: req.Reason,
|
||||
CommandKey: "admin-collectible-transfer:" + req.CommandID,
|
||||
})
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, collectibleUsernameError(err)
|
||||
}
|
||||
details["status"] = string(updated.Status)
|
||||
details["owner_type"] = string(updated.Owner.Type)
|
||||
details["owner_id"] = strconv.FormatInt(updated.Owner.ID, 10)
|
||||
details["changed"] = changed
|
||||
message := "collectible username transferred"
|
||||
if !changed {
|
||||
message = "collectible username transfer was a no-op"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeCollectibleUsername returns an asset to the operator vault, or burns it
|
||||
// permanently when Burn is set. Revoking an asset nobody holds is rejected:
|
||||
// there is nothing to take back, and a silent no-op would read as success.
|
||||
func (s *Service) RevokeCollectibleUsername(ctx context.Context, req RevokeCollectibleUsernameRequest) (CommandResult, error) {
|
||||
if s == nil || s.usernames == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin collectible username dependency is not configured")
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
if !domain.ValidCollectibleUsername(req.Username) {
|
||||
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRevokeCollectibleUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"username": req.Username, "burn": req.Burn}
|
||||
asset, err := s.usernames.Collectible(ctx, req.Username)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, collectibleUsernameError(err)
|
||||
}
|
||||
details["collectible_id"] = strconv.FormatInt(asset.ID, 10)
|
||||
details["previous_status"] = string(asset.Status)
|
||||
details["previous_owner_type"] = string(asset.Owner.Type)
|
||||
details["previous_owner_id"] = strconv.FormatInt(asset.Owner.ID, 10)
|
||||
if asset.Status == domain.CollectibleUsernameStatusBurned {
|
||||
return CommandResult{Details: details}, codedError(CodeCollectibleBurned, domain.ErrCollectibleUsernameBurned)
|
||||
}
|
||||
if !req.Burn && !asset.Owned() {
|
||||
return CommandResult{Details: details}, codedError(CodeCollectibleNotOwned, domain.ErrCollectibleUsernameNotOwned)
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "collectible username revoke validated", Details: details}, nil
|
||||
}
|
||||
updated, changed, err := s.usernames.Revoke(ctx, domain.RevokeCollectibleUsernameRequest{
|
||||
Username: req.Username,
|
||||
Burn: req.Burn,
|
||||
Actor: req.Actor,
|
||||
Reason: req.Reason,
|
||||
CommandKey: "admin-collectible-revoke:" + req.CommandID,
|
||||
})
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, collectibleUsernameError(err)
|
||||
}
|
||||
details["status"] = string(updated.Status)
|
||||
details["changed"] = changed
|
||||
message := "collectible username returned to vault"
|
||||
if req.Burn {
|
||||
message = "collectible username burned"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteCollectibleUsername erases the asset and its provenance, releasing the
|
||||
// name. Because the history disappears with the record, the command journal is
|
||||
// the only remaining trace: the details below are captured before the delete so
|
||||
// the entry still says what was removed and from whom.
|
||||
func (s *Service) DeleteCollectibleUsername(ctx context.Context, req DeleteCollectibleUsernameRequest) (CommandResult, error) {
|
||||
if s == nil || s.usernames == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin collectible username dependency is not configured")
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
if !domain.ValidCollectibleUsername(req.Username) {
|
||||
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionDeleteCollectibleUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"username": req.Username}
|
||||
asset, err := s.usernames.Collectible(ctx, req.Username)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, collectibleUsernameError(err)
|
||||
}
|
||||
details["collectible_id"] = strconv.FormatInt(asset.ID, 10)
|
||||
details["previous_status"] = string(asset.Status)
|
||||
details["previous_owner_type"] = string(asset.Owner.Type)
|
||||
details["previous_owner_id"] = strconv.FormatInt(asset.Owner.ID, 10)
|
||||
details["transfer_count"] = asset.TransferCount
|
||||
details["currency"] = asset.Currency
|
||||
details["amount"] = strconv.FormatInt(asset.Amount, 10)
|
||||
if asset.Status == domain.CollectibleUsernameStatusBurned {
|
||||
// Only live assets can be deleted; burned rows are history and are
|
||||
// released by re-issuing the name instead.
|
||||
return CommandResult{Details: details}, codedError(CodeCollectibleBurned, domain.ErrCollectibleUsernameBurned)
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "collectible username delete validated", Details: details}, nil
|
||||
}
|
||||
deleted, err := s.usernames.Delete(ctx, domain.DeleteCollectibleUsernameRequest{
|
||||
Username: req.Username,
|
||||
Actor: req.Actor,
|
||||
Reason: req.Reason,
|
||||
CommandKey: "admin-collectible-delete:" + req.CommandID,
|
||||
})
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, collectibleUsernameError(err)
|
||||
}
|
||||
details["deleted"] = deleted
|
||||
if !deleted {
|
||||
return CommandResult{Message: "collectible username already absent", Details: details}, nil
|
||||
}
|
||||
return CommandResult{Message: "collectible username deleted", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RecomputeAccountRating rebuilds one user's composite rating from the current
|
||||
// contribution signals. A dry-run only reports the stored projection, so the
|
||||
// operator can see what a recompute would start from without writing.
|
||||
func (s *Service) RecomputeAccountRating(ctx context.Context, req RecomputeAccountRatingRequest) (CommandResult, error) {
|
||||
if s == nil || s.rating == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin account rating dependency is not configured")
|
||||
}
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRecomputeAccountRating, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"user_id": strconv.FormatInt(req.UserID, 10)}
|
||||
previous, err := s.rating.Rating(ctx, req.UserID)
|
||||
switch {
|
||||
case err == nil:
|
||||
details["previous_found"] = true
|
||||
details["previous_level"] = previous.Level
|
||||
details["previous_stars"] = strconv.FormatInt(previous.Stars, 10)
|
||||
case errors.Is(err, domain.ErrAccountRatingNotFound):
|
||||
details["previous_found"] = false
|
||||
default:
|
||||
return CommandResult{Details: details}, accountRatingError(err)
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "account rating recompute validated", Details: details}, nil
|
||||
}
|
||||
rating, err := s.rating.Recompute(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, accountRatingError(err)
|
||||
}
|
||||
mergeAccountRatingDetails(details, rating)
|
||||
return CommandResult{Message: "account rating recomputed", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// AdjustAccountRating moves the manual component of one user's rating by a
|
||||
// signed delta and recomputes the projection so the change is visible at once.
|
||||
// The command id doubles as the ledger key, so a retried command records the
|
||||
// adjustment exactly once.
|
||||
func (s *Service) AdjustAccountRating(ctx context.Context, req AdjustAccountRatingRequest) (CommandResult, error) {
|
||||
if s == nil || s.rating == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin account rating dependency is not configured")
|
||||
}
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if req.Amount == 0 || req.Amount < -maxAccountRatingAdjustment || req.Amount > maxAccountRatingAdjustment {
|
||||
return CommandResult{}, codedError(CodeRatingAdjustmentInvalid, domain.ErrAccountRatingAdjustmentInvalid)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionAdjustAccountRating, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"user_id": strconv.FormatInt(req.UserID, 10),
|
||||
"amount": strconv.FormatInt(req.Amount, 10),
|
||||
}
|
||||
previous, err := s.rating.Rating(ctx, req.UserID)
|
||||
switch {
|
||||
case err == nil:
|
||||
details["previous_found"] = true
|
||||
details["previous_level"] = previous.Level
|
||||
details["previous_stars"] = strconv.FormatInt(previous.Stars, 10)
|
||||
details["previous_manual_component"] = strconv.FormatInt(previous.ManualComponent, 10)
|
||||
case errors.Is(err, domain.ErrAccountRatingNotFound):
|
||||
details["previous_found"] = false
|
||||
default:
|
||||
return CommandResult{Details: details}, accountRatingError(err)
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "account rating adjustment validated", Details: details}, nil
|
||||
}
|
||||
rating, applied, err := s.rating.Adjust(ctx, domain.AdjustAccountRatingRequest{
|
||||
UserID: req.UserID,
|
||||
Amount: req.Amount,
|
||||
Reason: req.Reason,
|
||||
Actor: req.Actor,
|
||||
CommandKey: "admin-rating-adjust:" + req.CommandID,
|
||||
})
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, accountRatingError(err)
|
||||
}
|
||||
details["applied"] = applied
|
||||
mergeAccountRatingDetails(details, rating)
|
||||
message := "account rating adjusted"
|
||||
if !applied {
|
||||
message = "account rating adjustment replayed"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// collectibleOwnerPeer resolves the optional owner of a collectible asset. At
|
||||
// most one identifier may be set; neither yields the zero peer, which the
|
||||
// lifecycle reads as "the operator vault".
|
||||
func collectibleOwnerPeer(userID, channelID int64) (domain.Peer, error) {
|
||||
if userID < 0 || channelID < 0 {
|
||||
return domain.Peer{}, fmt.Errorf("owner id must be positive")
|
||||
}
|
||||
if userID > 0 && channelID > 0 {
|
||||
return domain.Peer{}, fmt.Errorf("at most one of user id or channel id is allowed")
|
||||
}
|
||||
switch {
|
||||
case userID > 0:
|
||||
return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, nil
|
||||
case channelID > 0:
|
||||
return domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, nil
|
||||
default:
|
||||
return domain.Peer{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// collectiblePurchaseDate resolves the optional Unix purchase timestamp. Zero
|
||||
// means "now", so a mint always records a complete, reproducible provenance
|
||||
// entry; the int32 bound matches the TL date field clients render.
|
||||
func collectiblePurchaseDate(unix int64, now func() time.Time) (time.Time, error) {
|
||||
if unix == 0 {
|
||||
return now().UTC(), nil
|
||||
}
|
||||
if unix < 0 || unix > math.MaxInt32 {
|
||||
return time.Time{}, fmt.Errorf("purchase_date must be a non-negative int32 Unix timestamp")
|
||||
}
|
||||
return time.Unix(unix, 0).UTC(), nil
|
||||
}
|
||||
|
||||
// mergeAccountRatingDetails records the computed projection in command details.
|
||||
// Every score component crosses the JSON boundary as a decimal string so an
|
||||
// audit entry reproduces the exact int64 the store holds.
|
||||
func mergeAccountRatingDetails(details map[string]any, rating domain.AccountRating) {
|
||||
details["level"] = rating.Level
|
||||
details["stars"] = strconv.FormatInt(rating.Stars, 10)
|
||||
details["current_level_stars"] = strconv.FormatInt(rating.CurrentLevelStars, 10)
|
||||
details["has_next_level"] = rating.HasNextLevel
|
||||
if rating.HasNextLevel {
|
||||
details["next_level_stars"] = strconv.FormatInt(rating.NextLevelStars, 10)
|
||||
}
|
||||
details["stars_component"] = strconv.FormatInt(rating.StarsComponent, 10)
|
||||
details["activity_component"] = strconv.FormatInt(rating.ActivityComponent, 10)
|
||||
details["penalty_component"] = strconv.FormatInt(rating.PenaltyComponent, 10)
|
||||
details["manual_component"] = strconv.FormatInt(rating.ManualComponent, 10)
|
||||
details["pending_stars"] = strconv.FormatInt(rating.PendingStars, 10)
|
||||
if !rating.PendingDate.IsZero() {
|
||||
details["pending_date"] = rating.PendingDate.UTC().Format(time.RFC3339)
|
||||
}
|
||||
details["version"] = strconv.FormatInt(rating.Version, 10)
|
||||
}
|
||||
|
||||
// CollectibleUsernameErrorCode maps a collectible-username domain error onto the
|
||||
// stable code the admin panel switches on. An unmapped error returns "" so the
|
||||
// caller can fall back to a generic failure instead of inventing a code.
|
||||
func CollectibleUsernameErrorCode(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case errors.Is(err, domain.ErrUsernameOccupied):
|
||||
return CodeUsernameOccupied
|
||||
case errors.Is(err, domain.ErrCollectibleUsernameNotFound), errors.Is(err, domain.ErrUsernameNotOccupied):
|
||||
return CodeCollectibleNotFound
|
||||
case errors.Is(err, domain.ErrCollectibleUsernameBurned):
|
||||
return CodeCollectibleBurned
|
||||
case errors.Is(err, domain.ErrCollectibleUsernameLimit):
|
||||
return CodeCollectiblePeerLimit
|
||||
case errors.Is(err, domain.ErrCollectibleUsernameNotOwned):
|
||||
return CodeCollectibleNotOwned
|
||||
case errors.Is(err, domain.ErrCollectibleCurrencyInvalid):
|
||||
return CodeCollectibleCurrencyInvalid
|
||||
case errors.Is(err, domain.ErrUsernameNotCollectible), errors.Is(err, domain.ErrUsernameNotEditable):
|
||||
return CodeUsernameNotCollectible
|
||||
case errors.Is(err, domain.ErrUsernameInvalid):
|
||||
return CodeUsernameInvalid
|
||||
case errors.Is(err, domain.ErrCollectibleUsernameStateInvalid), errors.Is(err, domain.ErrUsernameOrderInvalid):
|
||||
return CodeCollectibleStateInvalid
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// AccountRatingErrorCode maps an account-rating domain error onto its stable
|
||||
// admin code. An unmapped error returns "".
|
||||
func AccountRatingErrorCode(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case errors.Is(err, domain.ErrAccountRatingNotFound):
|
||||
return CodeRatingNotFound
|
||||
case errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid):
|
||||
return CodeRatingAdjustmentInvalid
|
||||
case errors.Is(err, domain.ErrAccountRatingWeightsInvalid):
|
||||
return CodeRatingWeightsInvalid
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// collectibleUsernameError / accountRatingError prefix a recognised domain error
|
||||
// with its stable code, so the journalled command result and the operator both
|
||||
// see "CODE: message" instead of a bare Go string. Unrecognised errors are
|
||||
// returned untouched: inventing a code for an unknown failure would be worse
|
||||
// than reporting it verbatim.
|
||||
func collectibleUsernameError(err error) error {
|
||||
if code := CollectibleUsernameErrorCode(err); code != "" {
|
||||
return codedError(code, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func accountRatingError(err error) error {
|
||||
if code := AccountRatingErrorCode(err); code != "" {
|
||||
return codedError(code, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func codedError(code string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s: %w", code, err)
|
||||
}
|
||||
|
||||
func (s *Service) SetChannelVerified(ctx context.Context, req SetChannelVerifiedRequest) (CommandResult, error) {
|
||||
if req.ChannelID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("channel_id is required")
|
||||
|
|
|
|||
|
|
@ -7,16 +7,28 @@ import (
|
|||
"encoding/hex"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ratingapp "telesrv/internal/app/rating"
|
||||
stargiftapp "telesrv/internal/app/stargifts"
|
||||
usernamesapp "telesrv/internal/app/usernames"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// Compile-time proof that the shipped use-case services satisfy the admin ports.
|
||||
// cmd/telesrv wires *usernames.Service and *rating.Service into
|
||||
// Dependencies.Usernames / Dependencies.Rating directly, so a drifting method set
|
||||
// has to fail here rather than at integration time.
|
||||
var (
|
||||
_ CollectibleUsernamesService = (*usernamesapp.Service)(nil)
|
||||
_ AccountRatingService = (*ratingapp.Service)(nil)
|
||||
)
|
||||
|
||||
func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
|
|
@ -1311,3 +1323,653 @@ func (f *fakeChannelNotifier) NotifyChannelChanged(_ context.Context, ch domain.
|
|||
f.channels = append(f.channels, ch.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeCollectibleUsernamesService is an in-memory collectible lifecycle: enough
|
||||
// state to observe occupancy, replay-by-command-key and the burned terminal
|
||||
// state, which is exactly what the admin commands are expected to reason about.
|
||||
type fakeCollectibleUsernamesService struct {
|
||||
assets map[string]domain.CollectibleUsername
|
||||
log map[int64][]domain.CollectibleUsernameTransfer
|
||||
commandKeys map[string]int64
|
||||
nextID int64
|
||||
mintCalls int
|
||||
transferCalls int
|
||||
revokeCalls int
|
||||
deleteCalls int
|
||||
}
|
||||
|
||||
func newFakeCollectibleUsernames() *fakeCollectibleUsernamesService {
|
||||
return &fakeCollectibleUsernamesService{
|
||||
assets: map[string]domain.CollectibleUsername{},
|
||||
log: map[int64][]domain.CollectibleUsernameTransfer{},
|
||||
commandKeys: map[string]int64{},
|
||||
nextID: 100,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Mint(_ context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.mintCalls++
|
||||
if id, ok := f.commandKeys[req.CommandKey]; ok && req.CommandKey != "" {
|
||||
return f.byID(id), false, nil
|
||||
}
|
||||
key := strings.ToLower(req.Username)
|
||||
if _, ok := f.assets[key]; ok {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
||||
}
|
||||
f.nextID++
|
||||
asset := domain.CollectibleUsername{
|
||||
ID: f.nextID, Username: req.Username, Status: domain.CollectibleUsernameStatusVault,
|
||||
PurchaseDate: req.PurchaseDate, Currency: req.Currency, Amount: req.Amount,
|
||||
CryptoCurrency: req.CryptoCurrency, CryptoAmount: req.CryptoAmount, URL: req.URL,
|
||||
Version: 1,
|
||||
}
|
||||
if req.Owner.Type != "" {
|
||||
asset.Status = domain.CollectibleUsernameStatusOwned
|
||||
asset.Owner = req.Owner
|
||||
asset.OriginalOwner = req.Owner
|
||||
}
|
||||
f.assets[key] = asset
|
||||
f.commandKeys[req.CommandKey] = asset.ID
|
||||
f.log[asset.ID] = append(f.log[asset.ID], domain.CollectibleUsernameTransfer{
|
||||
ID: asset.ID, CollectibleID: asset.ID, Kind: domain.CollectibleUsernameKindMint,
|
||||
To: req.Owner, Actor: req.Actor, Reason: req.Reason, CommandKey: req.CommandKey,
|
||||
})
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Transfer(_ context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.transferCalls++
|
||||
key := strings.ToLower(req.Username)
|
||||
asset, ok := f.assets[key]
|
||||
if !ok {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
if asset.Status == domain.CollectibleUsernameStatusBurned {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
|
||||
}
|
||||
if asset.Owned() && asset.Owner == req.To {
|
||||
return asset, false, nil
|
||||
}
|
||||
asset.Status = domain.CollectibleUsernameStatusOwned
|
||||
asset.Owner = req.To
|
||||
if asset.OriginalOwner.Type == "" {
|
||||
asset.OriginalOwner = req.To
|
||||
}
|
||||
asset.TransferCount++
|
||||
asset.Version++
|
||||
f.assets[key] = asset
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Delete(_ context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
|
||||
f.deleteCalls++
|
||||
key := strings.ToLower(req.Username)
|
||||
asset, ok := f.assets[key]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if asset.Status == domain.CollectibleUsernameStatusBurned {
|
||||
return false, nil
|
||||
}
|
||||
delete(f.assets, key)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Revoke(_ context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.revokeCalls++
|
||||
key := strings.ToLower(req.Username)
|
||||
asset, ok := f.assets[key]
|
||||
if !ok {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
if asset.Status == domain.CollectibleUsernameStatusBurned {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
|
||||
}
|
||||
asset.Owner = domain.Peer{}
|
||||
asset.Status = domain.CollectibleUsernameStatusVault
|
||||
if req.Burn {
|
||||
asset.Status = domain.CollectibleUsernameStatusBurned
|
||||
}
|
||||
asset.Version++
|
||||
f.assets[key] = asset
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Collectible(_ context.Context, username string) (domain.CollectibleUsername, error) {
|
||||
asset, ok := f.assets[strings.ToLower(username)]
|
||||
if !ok {
|
||||
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) List(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
|
||||
out := make([]domain.CollectibleUsername, 0, len(f.assets))
|
||||
for _, asset := range f.assets {
|
||||
if filter.Status != "" && asset.Status != filter.Status {
|
||||
continue
|
||||
}
|
||||
if filter.BeforeID != 0 && asset.ID >= filter.BeforeID {
|
||||
continue
|
||||
}
|
||||
out = append(out, asset)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
if filter.Limit > 0 && len(out) > filter.Limit {
|
||||
out = out[:filter.Limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Transfers(_ context.Context, collectibleID int64, _ int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
return f.log[collectibleID], nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) byID(id int64) domain.CollectibleUsername {
|
||||
for _, asset := range f.assets {
|
||||
if asset.ID == id {
|
||||
return asset
|
||||
}
|
||||
}
|
||||
return domain.CollectibleUsername{}
|
||||
}
|
||||
|
||||
// fakeAccountRatingService recomputes from the manual component only, which
|
||||
// keeps the arithmetic in the domain and the fake focused on ledger replay.
|
||||
type fakeAccountRatingService struct {
|
||||
ratings map[int64]domain.AccountRating
|
||||
manual map[int64]int64
|
||||
commandKeys map[string]bool
|
||||
recomputeCalls int
|
||||
adjustCalls int
|
||||
}
|
||||
|
||||
func newFakeAccountRating() *fakeAccountRatingService {
|
||||
return &fakeAccountRatingService{
|
||||
ratings: map[int64]domain.AccountRating{},
|
||||
manual: map[int64]int64{},
|
||||
commandKeys: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Rating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
rating, ok := f.ratings[userID]
|
||||
if !ok {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Recompute(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
f.recomputeCalls++
|
||||
rating := domain.ComputeAccountRating(domain.AccountRatingSignals{
|
||||
UserID: userID, StarsReceived: 5000, Manual: f.manual[userID],
|
||||
}, domain.DefaultAccountRatingWeights(), fixedNow())
|
||||
rating.Version = f.ratings[userID].Version + 1
|
||||
f.ratings[userID] = rating
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error) {
|
||||
f.adjustCalls++
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.AccountRating{}, false, err
|
||||
}
|
||||
applied := true
|
||||
if f.commandKeys[req.CommandKey] {
|
||||
applied = false
|
||||
} else {
|
||||
f.commandKeys[req.CommandKey] = true
|
||||
f.manual[req.UserID] += req.Amount
|
||||
}
|
||||
rating, err := f.Recompute(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, applied, err
|
||||
}
|
||||
return rating, applied, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) List(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
out := make([]domain.AccountRating, 0, len(f.ratings))
|
||||
for _, rating := range f.ratings {
|
||||
if rating.Level < filter.MinLevel {
|
||||
continue
|
||||
}
|
||||
out = append(out, rating)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].UserID < out[j].UserID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Events(_ context.Context, userID int64, _ int) ([]domain.AccountRatingEvent, error) {
|
||||
if f.manual[userID] == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []domain.AccountRatingEvent{{
|
||||
ID: 1, UserID: userID, Kind: domain.AccountRatingEventManual, Amount: f.manual[userID],
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func TestMintCollectibleUsernameDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
|
||||
|
||||
dry, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-mint", Actor: "ops", Reason: "fragment import", DryRun: true},
|
||||
Username: "@Durov", OwnerUserID: 1001, Currency: domain.CollectibleCurrencyTON,
|
||||
Amount: 250_000_000_000, CryptoCurrency: domain.CollectibleCryptoCurrencyTON, CryptoAmount: 250_000_000_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run mint: %v", err)
|
||||
}
|
||||
if !dry.DryRun || dry.Status != string(domain.AdminCommandCompleted) || usernames.mintCalls != 0 {
|
||||
t.Fatalf("dry-run result=%+v mintCalls=%d, want validation without mutation", dry, usernames.mintCalls)
|
||||
}
|
||||
if dry.Details["username"] != "Durov" || dry.Details["purchase_date"] != fixedNow().Format(time.RFC3339) {
|
||||
t.Fatalf("dry-run details=%+v, want a normalised name and a stamped purchase date", dry.Details)
|
||||
}
|
||||
|
||||
execReq := MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-mint", Actor: "ops", Reason: "fragment import"},
|
||||
Username: "Durov", OwnerUserID: 1001, Currency: domain.CollectibleCurrencyTON,
|
||||
Amount: 250_000_000_000, CryptoCurrency: domain.CollectibleCryptoCurrencyTON, CryptoAmount: 250_000_000_000,
|
||||
}
|
||||
exec, err := svc.MintCollectibleUsername(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("execute mint: %v", err)
|
||||
}
|
||||
if exec.Status != string(domain.AdminCommandCompleted) || usernames.mintCalls != 1 ||
|
||||
exec.Details["status"] != string(domain.CollectibleUsernameStatusOwned) {
|
||||
t.Fatalf("execute result=%+v mintCalls=%d", exec, usernames.mintCalls)
|
||||
}
|
||||
|
||||
again, err := svc.MintCollectibleUsername(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("replay mint: %v", err)
|
||||
}
|
||||
if !again.AlreadyExecuted || usernames.mintCalls != 1 {
|
||||
t.Fatalf("replay result=%+v mintCalls=%d, want idempotent replay", again, usernames.mintCalls)
|
||||
}
|
||||
|
||||
occupied, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-mint-2", Actor: "ops", Reason: "duplicate"},
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), CodeUsernameOccupied) {
|
||||
t.Fatalf("duplicate mint err=%v, want %s", err, CodeUsernameOccupied)
|
||||
}
|
||||
if occupied.Status != string(domain.AdminCommandFailed) || usernames.mintCalls != 1 {
|
||||
t.Fatalf("duplicate result=%+v mintCalls=%d, want a journalled failure without mutation", occupied, usernames.mintCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintCollectibleUsernameValidatesBeforeJournallingCommand(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req MintCollectibleUsernameRequest
|
||||
code string
|
||||
}{
|
||||
{
|
||||
name: "short username",
|
||||
req: MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "bad-1", Actor: "ops", Reason: "invalid"},
|
||||
Username: "ab", Currency: domain.CollectibleCurrencyUSD, Amount: 1,
|
||||
},
|
||||
code: CodeUsernameInvalid,
|
||||
},
|
||||
{
|
||||
name: "unsupported currency",
|
||||
req: MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "bad-2", Actor: "ops", Reason: "invalid"},
|
||||
Username: "durov", Currency: "EUR", Amount: 1,
|
||||
},
|
||||
code: CodeCollectibleCurrencyInvalid,
|
||||
},
|
||||
{
|
||||
name: "crypto amount without currency",
|
||||
req: MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "bad-3", Actor: "ops", Reason: "invalid"},
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CryptoAmount: 5,
|
||||
},
|
||||
code: CodeCollectibleCurrencyInvalid,
|
||||
},
|
||||
}
|
||||
for _, item := range cases {
|
||||
if _, err := svc.MintCollectibleUsername(ctx, item.req); err == nil || !strings.Contains(err.Error(), item.code) {
|
||||
t.Fatalf("%s err=%v, want %s", item.name, err, item.code)
|
||||
}
|
||||
if _, journalled := repo.items[item.req.CommandID]; journalled {
|
||||
t.Fatalf("%s journalled a rejected command", item.name)
|
||||
}
|
||||
}
|
||||
if usernames.mintCalls != 0 {
|
||||
t.Fatalf("rejected requests reached the lifecycle: mintCalls=%d", usernames.mintCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferCollectibleUsernameRequiresRecipientAndRejectsBurned(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
|
||||
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed mint: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-none", Actor: "ops", Reason: "sold"}, Username: "durov",
|
||||
}); err == nil || !strings.Contains(err.Error(), "to_user_id") {
|
||||
t.Fatalf("transfer without recipient err=%v", err)
|
||||
}
|
||||
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-both", Actor: "ops", Reason: "sold"},
|
||||
Username: "durov", ToUserID: 1001, ToChannelID: 2002,
|
||||
}); err == nil {
|
||||
t.Fatal("transfer accepted two recipients")
|
||||
}
|
||||
|
||||
dry, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-dry", Actor: "ops", Reason: "sold", DryRun: true},
|
||||
Username: "durov", ToUserID: 1001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run transfer: %v", err)
|
||||
}
|
||||
if usernames.transferCalls != 0 || dry.Details["would_change"] != true {
|
||||
t.Fatalf("dry-run transfer result=%+v transferCalls=%d", dry, usernames.transferCalls)
|
||||
}
|
||||
|
||||
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-exec", Actor: "ops", Reason: "sold"},
|
||||
Username: "durov", ToUserID: 1001,
|
||||
}); err != nil {
|
||||
t.Fatalf("execute transfer: %v", err)
|
||||
}
|
||||
if usernames.transferCalls != 1 {
|
||||
t.Fatalf("transferCalls=%d, want one mutation", usernames.transferCalls)
|
||||
}
|
||||
|
||||
if _, err := svc.RevokeCollectibleUsername(ctx, RevokeCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "burn-1", Actor: "ops", Reason: "fraud"},
|
||||
Username: "durov", Burn: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("burn: %v", err)
|
||||
}
|
||||
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-burned", Actor: "ops", Reason: "sold"},
|
||||
Username: "durov", ToUserID: 1002,
|
||||
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleBurned) {
|
||||
t.Fatalf("transfer of a burned asset err=%v, want %s", err, CodeCollectibleBurned)
|
||||
}
|
||||
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-missing", Actor: "ops", Reason: "sold"},
|
||||
Username: "nobody_holds_this", ToUserID: 1002,
|
||||
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleNotFound) {
|
||||
t.Fatalf("transfer of a missing asset err=%v, want %s", err, CodeCollectibleNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeCollectibleUsernameRejectsVaultAssetWithoutBurn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
|
||||
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed mint: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.RevokeCollectibleUsername(ctx, RevokeCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rv-vault", Actor: "ops", Reason: "nothing to take"},
|
||||
Username: "durov",
|
||||
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleNotOwned) {
|
||||
t.Fatalf("revoke of a vault asset err=%v, want %s", err, CodeCollectibleNotOwned)
|
||||
}
|
||||
if usernames.revokeCalls != 0 {
|
||||
t.Fatalf("revokeCalls=%d, want no mutation", usernames.revokeCalls)
|
||||
}
|
||||
|
||||
burnDry, err := svc.RevokeCollectibleUsername(ctx, RevokeCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rv-burn-dry", Actor: "ops", Reason: "fraud", DryRun: true},
|
||||
Username: "durov", Burn: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run burn: %v", err)
|
||||
}
|
||||
if usernames.revokeCalls != 0 || burnDry.Details["burn"] != true {
|
||||
t.Fatalf("dry-run burn result=%+v revokeCalls=%d", burnDry, usernames.revokeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleUsernameByIDUsesKeysetFallback(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Usernames: usernames, Now: fixedNow})
|
||||
first, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed mint: %v", err)
|
||||
}
|
||||
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "telegram", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed-2",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed mint: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.CollectibleUsernameByID(ctx, first.ID)
|
||||
if err != nil || got.ID != first.ID || got.Username != "durov" {
|
||||
t.Fatalf("CollectibleUsernameByID(%d) = %+v err=%v", first.ID, got, err)
|
||||
}
|
||||
if _, err := svc.CollectibleUsernameByID(ctx, first.ID-1); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
|
||||
t.Fatalf("missing id err=%v, want ErrCollectibleUsernameNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustAccountRatingDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
rating := newFakeAccountRating()
|
||||
svc := NewService(Dependencies{Commands: repo, Rating: rating, Now: fixedNow})
|
||||
|
||||
dry, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-adjust", Actor: "ops", Reason: "penalty", DryRun: true},
|
||||
UserID: 1001, Amount: -2500,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run adjust: %v", err)
|
||||
}
|
||||
if rating.adjustCalls != 0 || dry.Details["previous_found"] != false || dry.Details["amount"] != "-2500" {
|
||||
t.Fatalf("dry-run adjust result=%+v adjustCalls=%d", dry, rating.adjustCalls)
|
||||
}
|
||||
|
||||
execReq := AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-adjust", Actor: "ops", Reason: "penalty"},
|
||||
UserID: 1001, Amount: -2500,
|
||||
}
|
||||
exec, err := svc.AdjustAccountRating(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("execute adjust: %v", err)
|
||||
}
|
||||
if rating.adjustCalls != 1 || exec.Details["applied"] != true ||
|
||||
exec.Details["manual_component"] != "-2500" || exec.Details["stars"] != "2500" {
|
||||
t.Fatalf("execute adjust result=%+v adjustCalls=%d", exec, rating.adjustCalls)
|
||||
}
|
||||
|
||||
again, err := svc.AdjustAccountRating(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("replay adjust: %v", err)
|
||||
}
|
||||
if !again.AlreadyExecuted || rating.adjustCalls != 1 || rating.manual[1001] != -2500 {
|
||||
t.Fatalf("replay adjust result=%+v adjustCalls=%d manual=%d", again, rating.adjustCalls, rating.manual[1001])
|
||||
}
|
||||
|
||||
for _, amount := range []int64{0, maxAccountRatingAdjustment + 1, -maxAccountRatingAdjustment - 1} {
|
||||
if _, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "bad-adjust", Actor: "ops", Reason: "invalid"},
|
||||
UserID: 1001, Amount: amount,
|
||||
}); err == nil || !strings.Contains(err.Error(), CodeRatingAdjustmentInvalid) {
|
||||
t.Fatalf("adjust by %d err=%v, want %s", amount, err, CodeRatingAdjustmentInvalid)
|
||||
}
|
||||
}
|
||||
if _, journalled := repo.items["bad-adjust"]; journalled {
|
||||
t.Fatal("journalled a rejected adjustment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeAccountRatingDryRunAndExecute(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
rating := newFakeAccountRating()
|
||||
svc := NewService(Dependencies{Commands: repo, Rating: rating, Now: fixedNow})
|
||||
|
||||
if _, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rc-invalid", Actor: "ops", Reason: "support"},
|
||||
}); err == nil || !strings.Contains(err.Error(), "user_id") {
|
||||
t.Fatalf("recompute without user err=%v", err)
|
||||
}
|
||||
|
||||
dry, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rc-dry", Actor: "ops", Reason: "support", DryRun: true},
|
||||
UserID: 1001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run recompute: %v", err)
|
||||
}
|
||||
if rating.recomputeCalls != 0 || dry.Details["previous_found"] != false {
|
||||
t.Fatalf("dry-run recompute result=%+v recomputeCalls=%d", dry, rating.recomputeCalls)
|
||||
}
|
||||
|
||||
exec, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rc-exec", Actor: "ops", Reason: "support"},
|
||||
UserID: 1001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute recompute: %v", err)
|
||||
}
|
||||
if rating.recomputeCalls != 1 || exec.Details["stars"] != "5000" || exec.Details["version"] != "1" {
|
||||
t.Fatalf("execute recompute result=%+v recomputeCalls=%d", exec, rating.recomputeCalls)
|
||||
}
|
||||
|
||||
stored, err := svc.AccountRating(ctx, 1001)
|
||||
if err != nil || stored.Stars != 5000 {
|
||||
t.Fatalf("AccountRating = %+v err=%v", stored, err)
|
||||
}
|
||||
if events, err := svc.AccountRatingEvents(ctx, 1001, 10); err != nil || len(events) != 0 {
|
||||
t.Fatalf("AccountRatingEvents = %+v err=%v, want an empty ledger", events, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleAndRatingCommandsRequireConfiguredDependencies(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Now: fixedNow})
|
||||
if _, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "c-1", Actor: "ops", Reason: "x"},
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1,
|
||||
}); err == nil || !strings.Contains(err.Error(), "collectible username dependency") {
|
||||
t.Fatalf("mint without dependency err=%v", err)
|
||||
}
|
||||
if _, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "c-2", Actor: "ops", Reason: "x"},
|
||||
UserID: 1001, Amount: 5,
|
||||
}); err == nil || !strings.Contains(err.Error(), "account rating dependency") {
|
||||
t.Fatalf("adjust without dependency err=%v", err)
|
||||
}
|
||||
if _, err := svc.CollectibleUsernames(ctx, domain.CollectibleUsernameFilter{}); err == nil {
|
||||
t.Fatal("listing without dependency succeeded")
|
||||
}
|
||||
if _, err := svc.AccountRatings(ctx, domain.AccountRatingFilter{}); err == nil {
|
||||
t.Fatal("rating listing without dependency succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteCollectibleUsernameCommand covers the hard-delete command: the
|
||||
// journal captures what was removed before the record disappears, a dry-run
|
||||
// mutates nothing, and a burned asset is refused.
|
||||
func TestDeleteCollectibleUsernameCommand(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
repo := newMemoryCommandRepo()
|
||||
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
|
||||
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 8801}
|
||||
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "wrongname", Owner: holder, Currency: domain.CollectibleCurrencyStars, Amount: 100,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed mint: %v", err)
|
||||
}
|
||||
|
||||
dry, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "del-dry", Actor: "ops", Reason: "mistake", DryRun: true},
|
||||
Username: "@wrongname",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if !dry.DryRun || usernames.deleteCalls != 0 {
|
||||
t.Fatalf("dry run mutated: result=%+v calls=%d", dry, usernames.deleteCalls)
|
||||
}
|
||||
if dry.Details["previous_owner_id"] != "8801" {
|
||||
t.Fatalf("dry run details = %+v, want the holder captured", dry.Details)
|
||||
}
|
||||
|
||||
exec, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "del-exec", Actor: "ops", Reason: "mistake"},
|
||||
Username: "wrongname",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("exec: %v", err)
|
||||
}
|
||||
if usernames.deleteCalls != 1 || exec.Details["deleted"] != true {
|
||||
t.Fatalf("exec = %+v calls=%d", exec, usernames.deleteCalls)
|
||||
}
|
||||
if exec.Details["previous_status"] != string(domain.CollectibleUsernameStatusOwned) {
|
||||
t.Fatalf("journal lost the pre-delete state: %+v", exec.Details)
|
||||
}
|
||||
|
||||
// Replaying the same command id must not touch the store again.
|
||||
if _, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "del-exec", Actor: "ops", Reason: "mistake"},
|
||||
Username: "wrongname",
|
||||
}); err != nil {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
if usernames.deleteCalls != 1 {
|
||||
t.Fatalf("replay called the store again: calls=%d", usernames.deleteCalls)
|
||||
}
|
||||
|
||||
// A burned asset is history: it is released by re-issuing the name, not deleted.
|
||||
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "burnedname", Owner: holder, Currency: domain.CollectibleCurrencyStars, Amount: 100,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed burned mint: %v", err)
|
||||
}
|
||||
if _, _, err := usernames.Revoke(ctx, domain.RevokeCollectibleUsernameRequest{
|
||||
Username: "burnedname", Burn: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed burn: %v", err)
|
||||
}
|
||||
if _, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "del-burned", Actor: "ops", Reason: "cleanup"},
|
||||
Username: "burnedname",
|
||||
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleBurned) {
|
||||
t.Fatalf("delete of burned asset err = %v, want %s", err, CodeCollectibleBurned)
|
||||
}
|
||||
|
||||
// A short name is rejected before a command is journalled at all.
|
||||
if _, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "del-short", Actor: "ops", Reason: "cleanup"},
|
||||
Username: "no",
|
||||
}); err == nil {
|
||||
t.Fatalf("delete of invalid name = nil error, want rejection")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
529
internal/admin/verification.go
Normal file
529
internal/admin/verification.go
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Official platform verification review.
|
||||
//
|
||||
// The review actions live behind the same command journal as every other
|
||||
// operator write: a decision is auditable, replayable by command id and
|
||||
// rehearsable with a dry run. The application record itself is the durable audit
|
||||
// subject, so the details captured here name the application, its target and the
|
||||
// status it moved between, and they carry the correlation id that ties the
|
||||
// command journal entry to the immutable application event.
|
||||
|
||||
// VerificationService is the operator-facing slice of the official verification
|
||||
// use cases. It is the exact method set *app/verification.Service exposes for the
|
||||
// reviewer side, so the admin layer never reaches into the store.
|
||||
type VerificationService interface {
|
||||
List(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error)
|
||||
Counts(ctx context.Context) (domain.VerificationStatusCounts, error)
|
||||
Events(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error)
|
||||
Application(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
|
||||
TargetSnapshot(ctx context.Context, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationTarget, error)
|
||||
Claim(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, error)
|
||||
Approve(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error)
|
||||
Reject(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error)
|
||||
Revoke(ctx context.Context, req domain.VerificationRevocation) (domain.VerificationApplication, bool, error)
|
||||
}
|
||||
|
||||
// ClaimVerificationRequest assigns a reviewer to a submitted application.
|
||||
type ClaimVerificationRequest struct {
|
||||
CommandMeta
|
||||
ApplicationID int64 `json:"application_id"`
|
||||
// Version is the optimistic-locking token the reviewer read. Two reviewers
|
||||
// opening the same row submit the same version and exactly one wins.
|
||||
Version int64 `json:"version"`
|
||||
// InternalNote is optional on a claim: a reviewer picking a case up may want to
|
||||
// record why ("waiting on legal") without deciding it yet.
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// ApproveVerificationRequest grants the platform badge.
|
||||
type ApproveVerificationRequest struct {
|
||||
CommandMeta
|
||||
ApplicationID int64 `json:"application_id"`
|
||||
Version int64 `json:"version"`
|
||||
// InternalNote is operator-only. It is journalled and appended to the
|
||||
// application history, and it is never part of what the applicant is told.
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// RejectVerificationRequest closes an application against the applicant. Reason
|
||||
// is mandatory: it is the text the applicant receives.
|
||||
type RejectVerificationRequest struct {
|
||||
CommandMeta
|
||||
ApplicationID int64 `json:"application_id"`
|
||||
Version int64 `json:"version"`
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// RevokeVerificationRequest clears the badge of a previously approved target. It
|
||||
// addresses the target rather than an application, because a revocation is not a
|
||||
// decision on the application: the application stays approved as history.
|
||||
type RevokeVerificationRequest struct {
|
||||
CommandMeta
|
||||
TargetType domain.VerificationTargetType `json:"target_type"`
|
||||
TargetID int64 `json:"target_id"`
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// VerificationApplications is the review-queue listing. The filter is passed
|
||||
// through unchanged: the use-case layer owns normalisation and the page bound.
|
||||
func (s *Service) VerificationApplications(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return nil, errVerificationNotConfigured
|
||||
}
|
||||
return s.verification.List(ctx, filter)
|
||||
}
|
||||
|
||||
// VerificationCounts is the queue summary rendered above the list.
|
||||
func (s *Service) VerificationCounts(ctx context.Context) (domain.VerificationStatusCounts, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return nil, errVerificationNotConfigured
|
||||
}
|
||||
return s.verification.Counts(ctx)
|
||||
}
|
||||
|
||||
// VerificationApplication resolves one application by identity.
|
||||
func (s *Service) VerificationApplication(ctx context.Context, applicationID int64) (domain.VerificationApplication, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return domain.VerificationApplication{}, errVerificationNotConfigured
|
||||
}
|
||||
if applicationID <= 0 {
|
||||
return domain.VerificationApplication{}, verificationCoded(domain.ErrVerificationApplicationNotFound)
|
||||
}
|
||||
return s.verification.Application(ctx, applicationID)
|
||||
}
|
||||
|
||||
// VerificationApplicationEvents returns one application's immutable history.
|
||||
func (s *Service) VerificationApplicationEvents(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return nil, errVerificationNotConfigured
|
||||
}
|
||||
return s.verification.Events(ctx, applicationID, limit)
|
||||
}
|
||||
|
||||
// VerificationTargetSnapshot returns the target's state as it is now: title,
|
||||
// username, badge, and whether it would pass the eligibility checks today.
|
||||
func (s *Service) VerificationTargetSnapshot(ctx context.Context, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationTarget, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return domain.VerificationTarget{}, errVerificationNotConfigured
|
||||
}
|
||||
return s.verification.TargetSnapshot(ctx, targetType, targetID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ClaimVerification takes ownership of a submitted application.
|
||||
//
|
||||
// Claiming an application that somebody else already claimed is not idempotent:
|
||||
// the status machine has no in_review -> in_review edge, so the second reviewer
|
||||
// is told the row is taken instead of silently stealing it.
|
||||
func (s *Service) ClaimVerification(ctx context.Context, req ClaimVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return CommandResult{}, errVerificationNotConfigured
|
||||
}
|
||||
if err := validateVerificationDecisionShape(req.ApplicationID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionClaimVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
app, details, err := s.verificationSubject(ctx, req.CommandMeta, req.ApplicationID, domain.VerificationStatusInReview, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := verificationTransition(app, req.Version, domain.VerificationStatusInReview, false); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "verification claim validated", Details: details}, nil
|
||||
}
|
||||
claimed, err := s.verification.Claim(ctx, verificationDecision(req.CommandMeta, req.ApplicationID, req.Version, req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, verificationError(err)
|
||||
}
|
||||
mergeVerificationDetails(details, claimed, true)
|
||||
return CommandResult{Message: "verification application claimed", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ApproveVerification grants the platform badge.
|
||||
//
|
||||
// The dry run reloads the target snapshot and refuses in advance whatever the
|
||||
// real approval would refuse: the snapshot re-runs every eligibility check
|
||||
// except the ownership probe, and a missing ownership can only make the real run
|
||||
// stricter, never more permissive. So a passing dry run never turns into a
|
||||
// surprise, and a failing one names the reason before anything is written.
|
||||
func (s *Service) ApproveVerification(ctx context.Context, req ApproveVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return CommandResult{}, errVerificationNotConfigured
|
||||
}
|
||||
if err := validateVerificationDecisionShape(req.ApplicationID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionApproveVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
app, details, err := s.verificationSubject(ctx, req.CommandMeta, req.ApplicationID, domain.VerificationStatusApproved, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := verificationTransition(app, req.Version, domain.VerificationStatusApproved, true); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
// An application that is already approved is a replay: the use-case layer
|
||||
// returns it untouched without re-running the target checks, so the target
|
||||
// gate must not fire here either -- otherwise a retry would fail a dry run
|
||||
// that the real command answers as a no-op.
|
||||
replay := app.Status == domain.VerificationStatusApproved
|
||||
if err := s.mergeVerificationTargetDetails(ctx, details, app, !replay); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "verification approve validated", Details: details}, nil
|
||||
}
|
||||
approved, changed, err := s.verification.Approve(ctx, verificationDecision(req.CommandMeta, req.ApplicationID, req.Version, req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, verificationError(err)
|
||||
}
|
||||
mergeVerificationDetails(details, approved, changed)
|
||||
message := "verification application approved"
|
||||
if !changed {
|
||||
message = "verification application already approved"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RejectVerification closes an application against the applicant. The reason is
|
||||
// mandatory and is the text the applicant is shown; the internal note stays
|
||||
// operator-side.
|
||||
func (s *Service) RejectVerification(ctx context.Context, req RejectVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return CommandResult{}, errVerificationNotConfigured
|
||||
}
|
||||
if err := validateVerificationDecisionShape(req.ApplicationID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
// A rejection without a stated reason is refused before the journal is
|
||||
// touched: the audit trail must never contain a decision nobody can explain.
|
||||
if strings.TrimSpace(req.Reason) == "" {
|
||||
return CommandResult{}, codedError(CodeVerificationReasonRequired, domain.ErrVerificationReasonRequired)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRejectVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
app, details, err := s.verificationSubject(ctx, req.CommandMeta, req.ApplicationID, domain.VerificationStatusRejected, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := verificationTransition(app, req.Version, domain.VerificationStatusRejected, true); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "verification reject validated", Details: details}, nil
|
||||
}
|
||||
rejected, changed, err := s.verification.Reject(ctx, verificationDecision(req.CommandMeta, req.ApplicationID, req.Version, req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, verificationError(err)
|
||||
}
|
||||
mergeVerificationDetails(details, rejected, changed)
|
||||
message := "verification application rejected"
|
||||
if !changed {
|
||||
message = "verification application already rejected"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeVerification clears the platform badge from a target.
|
||||
//
|
||||
// It addresses the peer, not an application: the approved application stays
|
||||
// approved as history and the revocation is its own audit event. A reason is
|
||||
// mandatory for the same reason a rejection needs one.
|
||||
func (s *Service) RevokeVerification(ctx context.Context, req RevokeVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return CommandResult{}, errVerificationNotConfigured
|
||||
}
|
||||
if req.TargetID <= 0 || !req.TargetType.Valid() {
|
||||
return CommandResult{}, codedError(CodeVerificationTargetInvalid, domain.ErrVerificationTargetInvalid)
|
||||
}
|
||||
if utf8.RuneCountInString(req.InternalNote) > domain.MaxVerificationNoteLength {
|
||||
return CommandResult{}, verificationInvalid("internal_note is too long")
|
||||
}
|
||||
if strings.TrimSpace(req.Reason) == "" {
|
||||
return CommandResult{}, codedError(CodeVerificationReasonRequired, domain.ErrVerificationReasonRequired)
|
||||
}
|
||||
targetPeer := domain.Peer{Type: req.TargetType.PeerType(), ID: req.TargetID}
|
||||
targetUserID := int64(0)
|
||||
if targetPeer.Type == domain.PeerTypeUser {
|
||||
targetUserID = req.TargetID
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRevokeVerification, targetUserID, targetPeer, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"target_type": string(req.TargetType),
|
||||
"target_id": strconv.FormatInt(req.TargetID, 10),
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
if note := strings.TrimSpace(req.InternalNote); note != "" {
|
||||
details["internal_note"] = note
|
||||
}
|
||||
// A built-in system account carries its badge by construction, so revoking
|
||||
// it would desynchronise the seeded record from domain.SystemUserByID.
|
||||
if targetPeer.Type == domain.PeerTypeUser && domain.IsSystemUserID(req.TargetID) {
|
||||
return CommandResult{Details: details}, codedError(CodeVerificationTargetSystem, domain.ErrVerificationTargetSystem)
|
||||
}
|
||||
target, err := s.verification.TargetSnapshot(ctx, req.TargetType, req.TargetID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, verificationError(err)
|
||||
}
|
||||
details["target_title"] = target.Title
|
||||
details["target_username"] = target.Username
|
||||
details["target_verified"] = target.Verified
|
||||
if req.DryRun {
|
||||
message := "verification revoke validated"
|
||||
if !target.Verified {
|
||||
message = "verification revoke validated; target carries no badge"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
}
|
||||
app, changed, err := s.verification.Revoke(ctx, domain.VerificationRevocation{
|
||||
TargetType: req.TargetType,
|
||||
TargetID: req.TargetID,
|
||||
Reviewer: strings.TrimSpace(req.Actor),
|
||||
Reason: strings.TrimSpace(req.Reason),
|
||||
InternalNote: strings.TrimSpace(req.InternalNote),
|
||||
CorrelationID: strings.TrimSpace(req.CommandID),
|
||||
})
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, verificationError(err)
|
||||
}
|
||||
details["changed"] = changed
|
||||
details["target_verified"] = false
|
||||
if app.ID > 0 {
|
||||
// The revoked target usually has an approved application behind it; it
|
||||
// stays approved, which is why previous_status and status match here.
|
||||
details["application_id"] = strconv.FormatInt(app.ID, 10)
|
||||
details["applicant_user_id"] = strconv.FormatInt(app.ApplicantUserID, 10)
|
||||
details["previous_status"] = string(app.Status)
|
||||
details["status"] = string(app.Status)
|
||||
details["version"] = strconv.FormatInt(app.Version, 10)
|
||||
}
|
||||
message := "verification badge revoked"
|
||||
if !changed {
|
||||
message = "verification badge was already absent"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var errVerificationNotConfigured = errors.New("admin verification dependency is not configured")
|
||||
|
||||
// verificationSubject loads the application under decision and seeds the command
|
||||
// details with everything the audit entry must state even when the command then
|
||||
// fails: which application, whose, which target, the status it is leaving and the
|
||||
// status it was asked to reach.
|
||||
func (s *Service) verificationSubject(
|
||||
ctx context.Context,
|
||||
meta CommandMeta,
|
||||
applicationID int64,
|
||||
next domain.VerificationStatus,
|
||||
internalNote string,
|
||||
) (domain.VerificationApplication, map[string]any, error) {
|
||||
details := map[string]any{
|
||||
"application_id": strconv.FormatInt(applicationID, 10),
|
||||
"next_status": string(next),
|
||||
"correlation_id": strings.TrimSpace(meta.CommandID),
|
||||
}
|
||||
if note := strings.TrimSpace(internalNote); note != "" {
|
||||
details["internal_note"] = note
|
||||
}
|
||||
app, err := s.verification.Application(ctx, applicationID)
|
||||
if err != nil {
|
||||
return domain.VerificationApplication{}, details, verificationError(err)
|
||||
}
|
||||
details["applicant_user_id"] = strconv.FormatInt(app.ApplicantUserID, 10)
|
||||
details["target_type"] = string(app.TargetType)
|
||||
details["target_id"] = strconv.FormatInt(app.TargetID, 10)
|
||||
details["target_username"] = app.TargetUsername
|
||||
details["previous_status"] = string(app.Status)
|
||||
details["previous_version"] = strconv.FormatInt(app.Version, 10)
|
||||
return app, details, nil
|
||||
}
|
||||
|
||||
// mergeVerificationTargetDetails records the current target snapshot and, for a
|
||||
// decision that flips the badge, refuses a target the platform may not verify.
|
||||
func (s *Service) mergeVerificationTargetDetails(ctx context.Context, details map[string]any, app domain.VerificationApplication, requireEligible bool) error {
|
||||
target, err := s.verification.TargetSnapshot(ctx, app.TargetType, app.TargetID)
|
||||
if err != nil {
|
||||
return verificationError(err)
|
||||
}
|
||||
details["target_title"] = target.Title
|
||||
details["target_current_username"] = target.Username
|
||||
details["target_verified"] = target.Verified
|
||||
details["target_eligible"] = target.Eligible
|
||||
if target.Reason != "" {
|
||||
details["target_reason"] = target.Reason
|
||||
}
|
||||
if requireEligible && !target.Eligible {
|
||||
return verificationError(verificationTargetReasonError(target.Reason))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeVerificationDetails records the decided state.
|
||||
func mergeVerificationDetails(details map[string]any, app domain.VerificationApplication, changed bool) {
|
||||
details["status"] = string(app.Status)
|
||||
details["version"] = strconv.FormatInt(app.Version, 10)
|
||||
details["changed"] = changed
|
||||
details["reviewer_admin_id"] = app.ReviewerAdminID
|
||||
if app.CorrelationID != "" {
|
||||
details["correlation_id"] = app.CorrelationID
|
||||
}
|
||||
if app.DecisionReason != "" {
|
||||
details["decision_reason"] = app.DecisionReason
|
||||
}
|
||||
}
|
||||
|
||||
// verificationDecision builds the domain decision.
|
||||
//
|
||||
// The admin command id doubles as the correlation id, so one token links the
|
||||
// command journal entry, the immutable application event and the applicant
|
||||
// notification. The internal note travels in its own field and never in Reason,
|
||||
// which is what the applicant is shown.
|
||||
func verificationDecision(meta CommandMeta, applicationID, version int64, internalNote string) domain.VerificationDecision {
|
||||
return domain.VerificationDecision{
|
||||
ApplicationID: applicationID,
|
||||
Version: version,
|
||||
Reviewer: strings.TrimSpace(meta.Actor),
|
||||
Reason: strings.TrimSpace(meta.Reason),
|
||||
InternalNote: strings.TrimSpace(internalNote),
|
||||
CorrelationID: strings.TrimSpace(meta.CommandID),
|
||||
}
|
||||
}
|
||||
|
||||
// validateVerificationDecisionShape rejects a malformed decision before the
|
||||
// command journal is touched.
|
||||
func validateVerificationDecisionShape(applicationID, version int64, internalNote string) error {
|
||||
if applicationID <= 0 {
|
||||
return verificationCoded(domain.ErrVerificationApplicationNotFound)
|
||||
}
|
||||
if version <= 0 {
|
||||
// Without the version the reviewer never read the row, so the optimistic
|
||||
// lock could not protect a concurrent decision.
|
||||
return verificationInvalid("version is required")
|
||||
}
|
||||
if utf8.RuneCountInString(internalNote) > domain.MaxVerificationNoteLength {
|
||||
return verificationInvalid("internal_note is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verificationTransition checks the status machine and the optimistic lock in the
|
||||
// order the use-case layer does, so a dry run predicts the real outcome exactly.
|
||||
//
|
||||
// idempotent marks the decisions the service treats as a no-op on replay
|
||||
// (approve/reject of an already decided application). A claim is not among them:
|
||||
// there is no in_review -> in_review edge.
|
||||
func verificationTransition(app domain.VerificationApplication, version int64, next domain.VerificationStatus, idempotent bool) error {
|
||||
if idempotent && app.Status == next {
|
||||
return nil
|
||||
}
|
||||
if !domain.CanTransitionVerificationStatus(app.Status, next) {
|
||||
return codedError(CodeVerificationStatusInvalid, fmt.Errorf("%w: %s -> %s", domain.ErrVerificationStatusInvalid, app.Status, next))
|
||||
}
|
||||
if app.Version != version {
|
||||
return codedError(CodeVerificationConflict, domain.ErrVerificationVersionConflict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verificationTargetReasonError maps the snapshot's rendered ineligibility reason
|
||||
// back onto its domain sentinel. VerificationTarget.Reason is a string by
|
||||
// design -- it is rendered to applicants by the bot -- so the reverse lookup is
|
||||
// what lets the admin layer answer with a stable code instead of a bare message.
|
||||
func verificationTargetReasonError(reason string) error {
|
||||
for _, candidate := range []error{
|
||||
domain.ErrVerificationTargetAlreadyVerified,
|
||||
domain.ErrVerificationTargetRestricted,
|
||||
domain.ErrVerificationTargetNotPublic,
|
||||
domain.ErrVerificationTargetSystem,
|
||||
domain.ErrVerificationUserTargetsDisabled,
|
||||
domain.ErrVerificationTargetInvalid,
|
||||
} {
|
||||
if candidate.Error() == reason {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: %s", domain.ErrVerificationTargetInvalid, reason)
|
||||
}
|
||||
|
||||
// VerificationErrorCode maps a verification failure onto the stable code the
|
||||
// admin panel switches on. An unmapped error returns "" so the caller can report
|
||||
// it verbatim instead of inventing a code.
|
||||
func VerificationErrorCode(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case errors.Is(err, domain.ErrVerificationApplicationNotFound):
|
||||
return CodeVerificationNotFound
|
||||
case errors.Is(err, domain.ErrVerificationVersionConflict):
|
||||
return CodeVerificationConflict
|
||||
case errors.Is(err, domain.ErrVerificationApplicationExists):
|
||||
return CodeVerificationTargetOccupied
|
||||
case errors.Is(err, domain.ErrVerificationStatusInvalid):
|
||||
return CodeVerificationStatusInvalid
|
||||
case errors.Is(err, domain.ErrVerificationReasonRequired):
|
||||
return CodeVerificationReasonRequired
|
||||
case errors.Is(err, domain.ErrVerificationTargetAlreadyVerified):
|
||||
return CodeVerificationTargetVerified
|
||||
case errors.Is(err, domain.ErrVerificationTargetNotPublic):
|
||||
return CodeVerificationTargetNotPublic
|
||||
case errors.Is(err, domain.ErrVerificationTargetRestricted):
|
||||
return CodeVerificationTargetRestricted
|
||||
case errors.Is(err, domain.ErrVerificationTargetSystem):
|
||||
return CodeVerificationTargetSystem
|
||||
case errors.Is(err, domain.ErrVerificationNotOwner):
|
||||
return CodeVerificationNotOwner
|
||||
case errors.Is(err, domain.ErrVerificationUserTargetsDisabled):
|
||||
return CodeVerificationUserTargetsDisabled
|
||||
case errors.Is(err, domain.ErrVerificationTargetInvalid):
|
||||
return CodeVerificationTargetInvalid
|
||||
case errors.Is(err, domain.ErrVerificationURLInvalid),
|
||||
errors.Is(err, domain.ErrVerificationApplicationInvalid):
|
||||
return CodeVerificationInvalid
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// verificationError prefixes a recognised verification error with its stable
|
||||
// code, the way collectibleUsernameError does for the username registry.
|
||||
func verificationError(err error) error {
|
||||
if code := VerificationErrorCode(err); code != "" {
|
||||
return codedError(code, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func verificationCoded(err error) error {
|
||||
return codedError(VerificationErrorCode(err), err)
|
||||
}
|
||||
|
||||
func verificationInvalid(message string) error {
|
||||
return codedError(CodeVerificationInvalid, fmt.Errorf("%s: %w", message, domain.ErrVerificationApplicationInvalid))
|
||||
}
|
||||
629
internal/admin/verification_test.go
Normal file
629
internal/admin/verification_test.go
Normal file
|
|
@ -0,0 +1,629 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
verificationapp "telesrv/internal/app/verification"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Compile-time proof that the shipped use-case service satisfies the admin port.
|
||||
// cmd/telesrv wires *verification.Service into Dependencies.Verification
|
||||
// directly, so a drifting method set has to fail here rather than at integration
|
||||
// time.
|
||||
var _ VerificationService = (*verificationapp.Service)(nil)
|
||||
|
||||
type fakeVerificationService struct {
|
||||
app domain.VerificationApplication
|
||||
target domain.VerificationTarget
|
||||
events []domain.VerificationApplicationEvent
|
||||
counts domain.VerificationStatusCounts
|
||||
|
||||
appErr error
|
||||
targetErr error
|
||||
decideErr error
|
||||
|
||||
claimCalls int
|
||||
approveCalls int
|
||||
rejectCalls int
|
||||
revokeCalls int
|
||||
|
||||
claimed domain.VerificationDecision
|
||||
approved domain.VerificationDecision
|
||||
rejected domain.VerificationDecision
|
||||
revoked domain.VerificationRevocation
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) List(context.Context, domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
|
||||
return []domain.VerificationApplication{f.app}, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Counts(context.Context) (domain.VerificationStatusCounts, error) {
|
||||
return f.counts, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Events(context.Context, int64, int) ([]domain.VerificationApplicationEvent, error) {
|
||||
return f.events, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Application(_ context.Context, applicationID int64) (domain.VerificationApplication, error) {
|
||||
if f.appErr != nil {
|
||||
return domain.VerificationApplication{}, f.appErr
|
||||
}
|
||||
if f.app.ID != applicationID {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
||||
}
|
||||
return f.app, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) TargetSnapshot(context.Context, domain.VerificationTargetType, int64) (domain.VerificationTarget, error) {
|
||||
if f.targetErr != nil {
|
||||
return domain.VerificationTarget{}, f.targetErr
|
||||
}
|
||||
return f.target, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Claim(_ context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, error) {
|
||||
f.claimCalls++
|
||||
f.claimed = decision
|
||||
if f.decideErr != nil {
|
||||
return domain.VerificationApplication{}, f.decideErr
|
||||
}
|
||||
f.app.Status = domain.VerificationStatusInReview
|
||||
f.app.ReviewerAdminID = decision.Reviewer
|
||||
f.app.Version++
|
||||
f.app.CorrelationID = decision.CorrelationID
|
||||
return f.app, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Approve(_ context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error) {
|
||||
f.approveCalls++
|
||||
f.approved = decision
|
||||
if f.decideErr != nil {
|
||||
return domain.VerificationApplication{}, false, f.decideErr
|
||||
}
|
||||
f.app.Status = domain.VerificationStatusApproved
|
||||
f.app.ReviewerAdminID = decision.Reviewer
|
||||
f.app.InternalNote = decision.InternalNote
|
||||
f.app.Version++
|
||||
f.app.CorrelationID = decision.CorrelationID
|
||||
return f.app, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Reject(_ context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error) {
|
||||
f.rejectCalls++
|
||||
f.rejected = decision
|
||||
if f.decideErr != nil {
|
||||
return domain.VerificationApplication{}, false, f.decideErr
|
||||
}
|
||||
f.app.Status = domain.VerificationStatusRejected
|
||||
f.app.ReviewerAdminID = decision.Reviewer
|
||||
f.app.DecisionReason = decision.Reason
|
||||
f.app.InternalNote = decision.InternalNote
|
||||
f.app.Version++
|
||||
f.app.CorrelationID = decision.CorrelationID
|
||||
return f.app, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Revoke(_ context.Context, req domain.VerificationRevocation) (domain.VerificationApplication, bool, error) {
|
||||
f.revokeCalls++
|
||||
f.revoked = req
|
||||
if f.decideErr != nil {
|
||||
return domain.VerificationApplication{}, false, f.decideErr
|
||||
}
|
||||
f.target.Verified = false
|
||||
return f.app, true, nil
|
||||
}
|
||||
|
||||
func submittedVerificationApplication() domain.VerificationApplication {
|
||||
return domain.VerificationApplication{
|
||||
ID: 77,
|
||||
ApplicantUserID: 1001,
|
||||
TargetType: domain.VerificationTargetChannel,
|
||||
TargetID: 5005,
|
||||
TargetTitle: "Example News",
|
||||
TargetUsername: "examplenews",
|
||||
Category: "media",
|
||||
Status: domain.VerificationStatusSubmitted,
|
||||
SubmittedAt: fixedNow(),
|
||||
Version: 3,
|
||||
}
|
||||
}
|
||||
|
||||
func newVerificationFixture() (*Service, *fakeVerificationService, *memoryCommandRepo) {
|
||||
repo := newMemoryCommandRepo()
|
||||
verification := &fakeVerificationService{
|
||||
app: submittedVerificationApplication(),
|
||||
target: domain.VerificationTarget{
|
||||
Type: domain.VerificationTargetChannel, ID: 5005,
|
||||
Title: "Example News", Username: "examplenews", Eligible: true,
|
||||
},
|
||||
}
|
||||
svc := NewService(Dependencies{Commands: repo, Verification: verification, Now: fixedNow})
|
||||
return svc, verification, repo
|
||||
}
|
||||
|
||||
func TestClaimVerificationDryRunExecuteAndIdempotentReplay(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, repo := newVerificationFixture()
|
||||
|
||||
dry, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-claim", Actor: "alice", Reason: "picking up the queue", DryRun: true},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run claim: %v", err)
|
||||
}
|
||||
if !dry.DryRun || dry.Status != string(domain.AdminCommandCompleted) || verification.claimCalls != 0 {
|
||||
t.Fatalf("dry-run result=%+v claimCalls=%d, want a completed dry run without mutation", dry, verification.claimCalls)
|
||||
}
|
||||
if dry.Details["previous_status"] != string(domain.VerificationStatusSubmitted) ||
|
||||
dry.Details["next_status"] != string(domain.VerificationStatusInReview) ||
|
||||
dry.Details["application_id"] != "77" || dry.Details["target_id"] != "5005" ||
|
||||
dry.Details["target_type"] != string(domain.VerificationTargetChannel) ||
|
||||
dry.Details["correlation_id"] != "dry-claim" {
|
||||
t.Fatalf("dry-run details=%+v, want the audit facts seeded before execution", dry.Details)
|
||||
}
|
||||
|
||||
execReq := ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-claim", Actor: "alice", Reason: "picking up the queue"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
}
|
||||
exec, err := svc.ClaimVerification(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("execute claim: %v", err)
|
||||
}
|
||||
if verification.claimCalls != 1 || exec.Status != string(domain.AdminCommandCompleted) {
|
||||
t.Fatalf("execute result=%+v claimCalls=%d", exec, verification.claimCalls)
|
||||
}
|
||||
if exec.Details["status"] != string(domain.VerificationStatusInReview) ||
|
||||
exec.Details["reviewer_admin_id"] != "alice" || exec.Details["version"] != "4" {
|
||||
t.Fatalf("execute details=%+v", exec.Details)
|
||||
}
|
||||
// The command id is the correlation id, so the journal entry and the
|
||||
// application event can be matched up afterwards.
|
||||
if verification.claimed.CorrelationID != "exec-claim" || verification.claimed.Reviewer != "alice" {
|
||||
t.Fatalf("claim decision=%+v", verification.claimed)
|
||||
}
|
||||
if _, ok := repo.items["exec-claim"]; !ok {
|
||||
t.Fatal("claim was not journalled")
|
||||
}
|
||||
|
||||
replay, err := svc.ClaimVerification(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("replay claim: %v", err)
|
||||
}
|
||||
if !replay.AlreadyExecuted || verification.claimCalls != 1 {
|
||||
t.Fatalf("replay result=%+v claimCalls=%d, want an idempotent replay", replay, verification.claimCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimVerificationRefusesAlreadyClaimedApplication(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusInReview
|
||||
|
||||
_, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "claim-taken", Actor: "bob", Reason: "second reviewer"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationStatusInvalid) {
|
||||
t.Fatalf("claim of a claimed application err=%v, want ErrVerificationStatusInvalid", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), CodeVerificationStatusInvalid) || verification.claimCalls != 0 {
|
||||
t.Fatalf("err=%v claimCalls=%d, want the stable code and no mutation", err, verification.claimCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveVerificationVersionConflictIsNotAMutation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusInReview
|
||||
|
||||
_, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-stale", Actor: "alice", Reason: "docs check out"},
|
||||
ApplicationID: 77,
|
||||
// The reviewer read version 3 but somebody else already advanced the row.
|
||||
Version: 2,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationVersionConflict) {
|
||||
t.Fatalf("stale approve err=%v, want ErrVerificationVersionConflict", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), CodeVerificationConflict) || verification.approveCalls != 0 {
|
||||
t.Fatalf("err=%v approveCalls=%d", err, verification.approveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveVerificationRefusesIneligibleTargetBeforeWriting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusInReview
|
||||
verification.target.Eligible = false
|
||||
verification.target.Verified = true
|
||||
verification.target.Reason = domain.ErrVerificationTargetAlreadyVerified.Error()
|
||||
|
||||
result, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-verified", Actor: "alice", Reason: "docs check out", DryRun: true},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationTargetAlreadyVerified) {
|
||||
t.Fatalf("approve of a verified target err=%v, want ErrVerificationTargetAlreadyVerified", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), CodeVerificationTargetVerified) {
|
||||
t.Fatalf("err=%v, want the stable code", err)
|
||||
}
|
||||
if result.Details["target_eligible"] != false || result.Details["target_verified"] != true {
|
||||
t.Fatalf("details=%+v, want the snapshot recorded on the failed command", result.Details)
|
||||
}
|
||||
if verification.approveCalls != 0 {
|
||||
t.Fatalf("approveCalls=%d, want the dry run to predict the refusal", verification.approveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveVerificationKeepsInternalNoteOutOfTheApplicantReason(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, repo := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusInReview
|
||||
|
||||
result, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-77", Actor: "alice", Reason: "press coverage verified"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
InternalNote: "contact reached us through the press office; do not quote",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("approve: %v", err)
|
||||
}
|
||||
if verification.approved.Reason != "press coverage verified" {
|
||||
t.Fatalf("applicant-facing reason=%q", verification.approved.Reason)
|
||||
}
|
||||
if verification.approved.InternalNote != "contact reached us through the press office; do not quote" {
|
||||
t.Fatalf("internal note=%q, want it carried in its own field", verification.approved.InternalNote)
|
||||
}
|
||||
if strings.Contains(verification.approved.Reason, "do not quote") {
|
||||
t.Fatal("internal note leaked into the applicant-facing reason")
|
||||
}
|
||||
// The note is operator-only but must still be auditable.
|
||||
if result.Details["internal_note"] != "contact reached us through the press office; do not quote" {
|
||||
t.Fatalf("details=%+v, want the internal note journalled", result.Details)
|
||||
}
|
||||
if stored := repo.items["approve-77"].ResultJSON; !strings.Contains(string(stored), "do not quote") {
|
||||
t.Fatalf("journalled result=%s, want the internal note persisted", stored)
|
||||
}
|
||||
}
|
||||
|
||||
// A retried approval of an already approved application is a no-op in the
|
||||
// use-case layer, so the dry run must not fail it on the "already verified"
|
||||
// target gate the first approval passed.
|
||||
func TestApproveVerificationReplayIsNotBlockedByTheTargetGate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusApproved
|
||||
verification.app.ReviewerAdminID = "alice"
|
||||
verification.target.Verified = true
|
||||
verification.target.Eligible = false
|
||||
verification.target.Reason = domain.ErrVerificationTargetAlreadyVerified.Error()
|
||||
|
||||
dry, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-replay-dry", Actor: "alice", Reason: "docs check out", DryRun: true},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run replay approve: %v", err)
|
||||
}
|
||||
if verification.approveCalls != 0 || dry.Details["target_verified"] != true {
|
||||
t.Fatalf("dry-run replay details=%+v approveCalls=%d", dry.Details, verification.approveCalls)
|
||||
}
|
||||
|
||||
exec, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-replay", Actor: "alice", Reason: "docs check out"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("replay approve: %v", err)
|
||||
}
|
||||
if exec.Details["changed"] != true {
|
||||
// The fake always reports a change; the point is that the command reached
|
||||
// the service instead of being refused by the pre-check.
|
||||
t.Fatalf("replay approve details=%+v", exec.Details)
|
||||
}
|
||||
if verification.approveCalls != 1 {
|
||||
t.Fatalf("approveCalls=%d, want the replay handed to the use-case layer", verification.approveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectVerificationRequiresReason(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, repo := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusInReview
|
||||
|
||||
_, err := svc.RejectVerification(ctx, RejectVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "reject-no-reason", Actor: "alice", Reason: " "},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationReasonRequired) {
|
||||
t.Fatalf("reject without reason err=%v, want ErrVerificationReasonRequired", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), CodeVerificationReasonRequired) {
|
||||
t.Fatalf("err=%v, want the stable code", err)
|
||||
}
|
||||
if verification.rejectCalls != 0 || len(repo.items) != 0 {
|
||||
t.Fatalf("rejectCalls=%d journalled=%d, want a refusal before the journal is touched",
|
||||
verification.rejectCalls, len(repo.items))
|
||||
}
|
||||
|
||||
ok, err := svc.RejectVerification(ctx, RejectVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "reject-77", Actor: "alice", Reason: "press links are self-published"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reject: %v", err)
|
||||
}
|
||||
if verification.rejectCalls != 1 || verification.rejected.Reason != "press links are self-published" {
|
||||
t.Fatalf("rejectCalls=%d decision=%+v", verification.rejectCalls, verification.rejected)
|
||||
}
|
||||
if ok.Details["status"] != string(domain.VerificationStatusRejected) ||
|
||||
ok.Details["decision_reason"] != "press links are self-published" {
|
||||
t.Fatalf("details=%+v", ok.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeVerificationRequiresReasonAndTargetShape(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, repo := newVerificationFixture()
|
||||
verification.target.Verified = true
|
||||
|
||||
_, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "revoke-no-reason", Actor: "alice"},
|
||||
TargetType: domain.VerificationTargetChannel,
|
||||
TargetID: 5005,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationReasonRequired) {
|
||||
t.Fatalf("revoke without reason err=%v, want ErrVerificationReasonRequired", err)
|
||||
}
|
||||
|
||||
_, err = svc.RevokeVerification(ctx, RevokeVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "revoke-bad-target", Actor: "alice", Reason: "impersonation"},
|
||||
TargetType: domain.VerificationTargetType("group"),
|
||||
TargetID: 5005,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationTargetInvalid) {
|
||||
t.Fatalf("revoke of an unmodelled target err=%v, want ErrVerificationTargetInvalid", err)
|
||||
}
|
||||
if verification.revokeCalls != 0 || len(repo.items) != 0 {
|
||||
t.Fatalf("revokeCalls=%d journalled=%d, want refusals before the journal", verification.revokeCalls, len(repo.items))
|
||||
}
|
||||
|
||||
dry, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-revoke", Actor: "alice", Reason: "impersonation confirmed", DryRun: true},
|
||||
TargetType: domain.VerificationTargetChannel,
|
||||
TargetID: 5005,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run revoke: %v", err)
|
||||
}
|
||||
if verification.revokeCalls != 0 || dry.Details["target_verified"] != true {
|
||||
t.Fatalf("dry-run revoke details=%+v revokeCalls=%d", dry.Details, verification.revokeCalls)
|
||||
}
|
||||
|
||||
exec, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-revoke", Actor: "alice", Reason: "impersonation confirmed"},
|
||||
TargetType: domain.VerificationTargetChannel,
|
||||
TargetID: 5005,
|
||||
InternalNote: "legal asked for the takedown",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute revoke: %v", err)
|
||||
}
|
||||
if verification.revokeCalls != 1 || verification.revoked.Reason != "impersonation confirmed" ||
|
||||
verification.revoked.InternalNote != "legal asked for the takedown" ||
|
||||
verification.revoked.CorrelationID != "exec-revoke" {
|
||||
t.Fatalf("revocation=%+v", verification.revoked)
|
||||
}
|
||||
if exec.Details["target_verified"] != false || exec.Details["changed"] != true ||
|
||||
exec.Details["application_id"] != "77" {
|
||||
t.Fatalf("execute revoke details=%+v", exec.Details)
|
||||
}
|
||||
if exec.TargetPeer != (domain.Peer{Type: domain.PeerTypeChannel, ID: 5005}) {
|
||||
t.Fatalf("journalled target peer=%+v", exec.TargetPeer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeVerificationRefusesSystemAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
|
||||
result, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "revoke-system", Actor: "alice", Reason: "cleanup"},
|
||||
TargetType: domain.VerificationTargetBot,
|
||||
TargetID: domain.BotFatherUserID,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationTargetSystem) {
|
||||
t.Fatalf("revoke of a system account err=%v, want ErrVerificationTargetSystem", err)
|
||||
}
|
||||
if verification.revokeCalls != 0 || result.Details["target_id"] == nil {
|
||||
t.Fatalf("revokeCalls=%d details=%+v", verification.revokeCalls, result.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationDecisionShapeIsValidatedBeforeTheJournal(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, _, repo := newVerificationFixture()
|
||||
|
||||
if _, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "claim-no-id", Actor: "alice", Reason: "queue"},
|
||||
ApplicationID: 0,
|
||||
Version: 3,
|
||||
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
||||
t.Fatalf("claim without an application err=%v", err)
|
||||
}
|
||||
if _, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "claim-no-version", Actor: "alice", Reason: "queue"},
|
||||
ApplicationID: 77,
|
||||
Version: 0,
|
||||
}); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
|
||||
t.Fatalf("claim without a version err=%v", err)
|
||||
}
|
||||
if _, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-long-note", Actor: "alice", Reason: "queue"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
InternalNote: strings.Repeat("x", domain.MaxVerificationNoteLength+1),
|
||||
}); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
|
||||
t.Fatalf("approve with an oversized note err=%v", err)
|
||||
}
|
||||
if len(repo.items) != 0 {
|
||||
t.Fatalf("journalled=%d, want malformed decisions refused before the journal", len(repo.items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationMissingApplicationIsJournalledAsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, repo := newVerificationFixture()
|
||||
verification.appErr = domain.ErrVerificationApplicationNotFound
|
||||
|
||||
result, err := svc.RejectVerification(ctx, RejectVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "reject-missing", Actor: "alice", Reason: "not eligible"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
||||
t.Fatalf("reject of a missing application err=%v", err)
|
||||
}
|
||||
if VerificationErrorCode(err) != CodeVerificationNotFound {
|
||||
t.Fatalf("code=%q", VerificationErrorCode(err))
|
||||
}
|
||||
if result.Status != string(domain.AdminCommandFailed) {
|
||||
t.Fatalf("result=%+v, want a failed command", result)
|
||||
}
|
||||
if cmd, ok := repo.items["reject-missing"]; !ok || cmd.Status != domain.AdminCommandFailed {
|
||||
t.Fatalf("journalled command=%+v ok=%v, want the failure recorded", cmd, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationReadsRequireTheDependency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Now: fixedNow})
|
||||
if _, err := svc.VerificationApplications(ctx, domain.VerificationApplicationFilter{}); err == nil {
|
||||
t.Fatal("listing without the dependency succeeded")
|
||||
}
|
||||
if _, err := svc.VerificationCounts(ctx); err == nil {
|
||||
t.Fatal("counting without the dependency succeeded")
|
||||
}
|
||||
if _, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{ApplicationID: 1, Version: 1}); err == nil {
|
||||
t.Fatal("claiming without the dependency succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationReadsPassThrough(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.counts = domain.VerificationStatusCounts{domain.VerificationStatusSubmitted: 3}
|
||||
verification.events = []domain.VerificationApplicationEvent{{
|
||||
ID: 9, ApplicationID: 77, Kind: domain.VerificationEventSubmitted,
|
||||
ToStatus: domain.VerificationStatusSubmitted, CreatedAt: fixedNow(),
|
||||
}}
|
||||
|
||||
items, err := svc.VerificationApplications(ctx, domain.VerificationApplicationFilter{Limit: 10})
|
||||
if err != nil || len(items) != 1 || items[0].ID != 77 {
|
||||
t.Fatalf("applications=%+v err=%v", items, err)
|
||||
}
|
||||
counts, err := svc.VerificationCounts(ctx)
|
||||
if err != nil || counts[domain.VerificationStatusSubmitted] != 3 {
|
||||
t.Fatalf("counts=%+v err=%v", counts, err)
|
||||
}
|
||||
events, err := svc.VerificationApplicationEvents(ctx, 77, 10)
|
||||
if err != nil || len(events) != 1 || events[0].ID != 9 {
|
||||
t.Fatalf("events=%+v err=%v", events, err)
|
||||
}
|
||||
app, err := svc.VerificationApplication(ctx, 77)
|
||||
if err != nil || app.Version != 3 {
|
||||
t.Fatalf("application=%+v err=%v", app, err)
|
||||
}
|
||||
if _, err := svc.VerificationApplication(ctx, 0); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
||||
t.Fatalf("application(0) err=%v", err)
|
||||
}
|
||||
target, err := svc.VerificationTargetSnapshot(ctx, domain.VerificationTargetChannel, 5005)
|
||||
if err != nil || target.ID != 5005 {
|
||||
t.Fatalf("target=%+v err=%v", target, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationErrorCodeCoversTheDomainSentinels(t *testing.T) {
|
||||
cases := map[error]string{
|
||||
domain.ErrVerificationApplicationNotFound: CodeVerificationNotFound,
|
||||
domain.ErrVerificationVersionConflict: CodeVerificationConflict,
|
||||
domain.ErrVerificationApplicationExists: CodeVerificationTargetOccupied,
|
||||
domain.ErrVerificationStatusInvalid: CodeVerificationStatusInvalid,
|
||||
domain.ErrVerificationReasonRequired: CodeVerificationReasonRequired,
|
||||
domain.ErrVerificationTargetAlreadyVerified: CodeVerificationTargetVerified,
|
||||
domain.ErrVerificationTargetNotPublic: CodeVerificationTargetNotPublic,
|
||||
domain.ErrVerificationTargetRestricted: CodeVerificationTargetRestricted,
|
||||
domain.ErrVerificationTargetSystem: CodeVerificationTargetSystem,
|
||||
domain.ErrVerificationNotOwner: CodeVerificationNotOwner,
|
||||
domain.ErrVerificationUserTargetsDisabled: CodeVerificationUserTargetsDisabled,
|
||||
domain.ErrVerificationTargetInvalid: CodeVerificationTargetInvalid,
|
||||
domain.ErrVerificationApplicationInvalid: CodeVerificationInvalid,
|
||||
errors.New("some transport failure nobody mapped"): "",
|
||||
}
|
||||
for err, want := range cases {
|
||||
if got := VerificationErrorCode(err); got != want {
|
||||
t.Fatalf("VerificationErrorCode(%v) = %q, want %q", err, got, want)
|
||||
}
|
||||
}
|
||||
if got := VerificationErrorCode(nil); got != "" {
|
||||
t.Fatalf("VerificationErrorCode(nil) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationTargetReasonErrorRoundTripsTheSnapshotString(t *testing.T) {
|
||||
for _, sentinel := range []error{
|
||||
domain.ErrVerificationTargetAlreadyVerified,
|
||||
domain.ErrVerificationTargetRestricted,
|
||||
domain.ErrVerificationTargetNotPublic,
|
||||
domain.ErrVerificationTargetSystem,
|
||||
domain.ErrVerificationUserTargetsDisabled,
|
||||
domain.ErrVerificationTargetInvalid,
|
||||
} {
|
||||
if err := verificationTargetReasonError(sentinel.Error()); !errors.Is(err, sentinel) {
|
||||
t.Fatalf("verificationTargetReasonError(%q) = %v", sentinel.Error(), err)
|
||||
}
|
||||
}
|
||||
// An unrecognised reason must still be a target failure rather than a panic or
|
||||
// a silent pass.
|
||||
if err := verificationTargetReasonError("something new"); !errors.Is(err, domain.ErrVerificationTargetInvalid) {
|
||||
t.Fatalf("unknown reason mapped to %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationClockIsNotRequiredForDetails(t *testing.T) {
|
||||
// The details are pure projections of the application, so a service without a
|
||||
// wall clock still produces a complete audit entry.
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.app.UpdatedAt = time.Time{}
|
||||
result, err := svc.ClaimVerification(context.Background(), ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "claim-clockless", Actor: "alice", Reason: "queue"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
for _, key := range []string{"application_id", "applicant_user_id", "target_type", "target_id", "previous_status", "status", "correlation_id"} {
|
||||
if _, ok := result.Details[key]; !ok {
|
||||
t.Fatalf("details %+v missing %q", result.Details, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue