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

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

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

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

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

View file

@ -0,0 +1,994 @@
package admin
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"unicode/utf8"
"telesrv/internal/domain"
)
// Third-party bot verification for the operator (core.telegram.org/api/bots/verification).
//
// This is NOT the official platform badge. The official review lives in
// verification.go behind the verification.* permissions and writes
// verification_applications / the peer's `verified` flag; this file drives the
// third-party mechanism, whose state is verification_icons,
// bot_verifier_settings, custom_verifications and custom_verification_requests,
// behind the botverification.* permissions. Neither section reads the other's
// tables, and a third-party verifier must never be able to mint a platform
// checkmark -- so nothing here ever touches domain.User.Verified.
//
// The operator surface has two halves, and they carry different rights:
//
// - Configuration (botverification.manage): who is a verifier at all, which
// icons exist, and stripping a granted mark. These are the actions that
// decide how much a mark is worth, so they are deliberately not implied by
// the right to work the queue.
// - Review (botverification.review): deciding the applications filed with a
// verifier bot.
//
// Every mutation goes through runCommand, so a decision is journalled in
// admin_commands / admin_audit_logs, replayable by command id and rehearsable
// with a dry run. A dry run never mutates and never predicts an outcome stricter
// than the real command's: where a check can only run inside the use-case layer
// (the icon's custom emoji document is resolved against the document store) the
// dry run says so in its details instead of guessing.
// BotVerificationService is the operator-facing slice of the third-party
// verification use cases. It is the exact method set *app/botverification.Service
// exposes for this surface, so the admin layer never reaches into the store.
type BotVerificationService interface {
// Icon catalogue.
Icons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error)
UpsertIcon(ctx context.Context, icon domain.VerificationIcon) (domain.VerificationIcon, error)
SetIconActive(ctx context.Context, iconID int64, active bool) (domain.VerificationIcon, error)
// Verifier status.
Verifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error)
VerifierSettings(ctx context.Context, botID int64) (domain.BotVerifierSettings, error)
GrantVerifier(ctx context.Context, settings domain.BotVerifierSettings) (domain.BotVerifierSettings, error)
SetVerifierEnabled(ctx context.Context, botID int64, enabled bool) (domain.BotVerifierSettings, error)
RevokeVerifier(ctx context.Context, botID int64) (bool, error)
// Granted marks.
Marks(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error)
RevokeMark(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error)
// Application queue.
Requests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error)
Request(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error)
RequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error)
Approve(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error)
Reject(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error)
RevokeRequest(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error)
}
// botVerificationCatalogueScan bounds the catalogue page a dry run reads to
// pre-check an icon. The catalogue is operator-curated and small; the check is
// advisory precisely because this bound may not cover it (see grantIconPreflight).
const botVerificationCatalogueScan = 200
// ---------------------------------------------------------------------------
// Command payloads
// ---------------------------------------------------------------------------
// GrantBotVerifierRequest grants or reconfigures a bot's verifier status.
//
// Version is the optimistic-locking token: 0 means "this is a new grant" and a
// positive value means "update the row I read", so two operators editing the same
// verifier cannot clobber each other. Enabled is deliberately absent -- the kill
// switch is its own action, so reconfiguring a switched-off verifier does not
// quietly switch it back on.
type GrantBotVerifierRequest struct {
CommandMeta
BotID int64 `json:"bot_id"`
IconDocumentID int64 `json:"icon_document_id"`
CompanyName string `json:"company_name"`
DefaultDescription string `json:"default_description"`
CanModifyCustomDescription bool `json:"can_modify_custom_description"`
Version int64 `json:"version"`
}
// SetBotVerifierEnabledRequest flips the operator kill switch. The verifier keeps
// its row and the marks it granted, so flipping it back restores exactly what was
// there.
type SetBotVerifierEnabledRequest struct {
CommandMeta
BotID int64 `json:"bot_id"`
Enabled bool `json:"enabled"`
}
// RevokeBotVerifierRequest removes verifier status entirely. Its marks cascade
// away with it in the store, because a mark whose verifier no longer exists has
// nothing to render.
type RevokeBotVerifierRequest struct {
CommandMeta
BotID int64 `json:"bot_id"`
}
// UpsertVerificationIconRequest adds or updates a catalogue entry, keyed by custom
// emoji document id. OwnerBotID is zero for a shared entry and a bot id when the
// operator reserves the icon for one verifier.
type UpsertVerificationIconRequest struct {
CommandMeta
DocumentID int64 `json:"document_id"`
Name string `json:"name"`
OwnerBotID int64 `json:"owner_bot_id,omitempty"`
}
// SetVerificationIconActiveRequest retires or restores a catalogue entry. Marks
// already granted with it keep rendering -- the icon id is denormalised onto the
// mark -- so retiring an entry stops new grants without blanking existing badges.
type SetVerificationIconActiveRequest struct {
CommandMeta
IconID int64 `json:"icon_id"`
Active bool `json:"active"`
}
// RevokeCustomVerificationRequest strips one verifier's mark from a peer on the
// operator's behalf. It addresses the (verifier, peer) pair rather than an
// application, because the operator may have to strip a mark that no application
// ever produced.
type RevokeCustomVerificationRequest struct {
CommandMeta
VerifierBotID int64 `json:"verifier_bot_id"`
PeerType domain.PeerType `json:"peer_type"`
PeerID int64 `json:"peer_id"`
}
// ApproveBotVerificationRequest grants the mark an application asked for. The
// mark and the approved status commit together in the use-case layer, so an
// approved application without its mark is not a reachable state.
type ApproveBotVerificationRequest struct {
CommandMeta
RequestID int64 `json:"request_id"`
Version int64 `json:"version"`
// InternalNote is operator-only: it is journalled and stored on the
// application, and it is never part of what the applicant is told.
InternalNote string `json:"internal_note,omitempty"`
}
// RejectBotVerificationRequest closes an application against the applicant.
// Reason is mandatory: it is the text the applicant receives.
type RejectBotVerificationRequest struct {
CommandMeta
RequestID int64 `json:"request_id"`
Version int64 `json:"version"`
InternalNote string `json:"internal_note,omitempty"`
}
// RevokeBotVerificationRequest withdraws a granted mark through the application it
// came from. The application stays as history -- revoked is reachable only from
// approved, so it keeps meaning "was verified once". Reason is mandatory for the
// same reason it is on rejection.
type RevokeBotVerificationRequest struct {
CommandMeta
RequestID int64 `json:"request_id"`
Version int64 `json:"version"`
InternalNote string `json:"internal_note,omitempty"`
}
// ---------------------------------------------------------------------------
// Reads
// ---------------------------------------------------------------------------
// BotVerifiers lists verifier bots.
func (s *Service) BotVerifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error) {
if s == nil || s.botVerification == nil {
return nil, errBotVerificationNotConfigured
}
return s.botVerification.Verifiers(ctx, enabledOnly, limit)
}
// BotVerifier resolves one verifier's status, enabled or not: the panel needs the
// disabled row too, to render the kill switch.
func (s *Service) BotVerifier(ctx context.Context, botID int64) (domain.BotVerifierSettings, error) {
if s == nil || s.botVerification == nil {
return domain.BotVerifierSettings{}, errBotVerificationNotConfigured
}
if botID <= 0 {
return domain.BotVerifierSettings{}, botVerificationCoded(domain.ErrVerifierNotFound)
}
return s.botVerification.VerifierSettings(ctx, botID)
}
// VerificationIcons lists the icon catalogue, newest first.
func (s *Service) VerificationIcons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error) {
if s == nil || s.botVerification == nil {
return nil, errBotVerificationNotConfigured
}
return s.botVerification.Icons(ctx, activeOnly, limit)
}
// CustomVerifications lists granted third-party marks with keyset paging.
func (s *Service) CustomVerifications(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
if s == nil || s.botVerification == nil {
return nil, errBotVerificationNotConfigured
}
return s.botVerification.Marks(ctx, filter)
}
// CustomVerificationRequests is the third-party review queue.
func (s *Service) CustomVerificationRequests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
if s == nil || s.botVerification == nil {
return nil, errBotVerificationNotConfigured
}
return s.botVerification.Requests(ctx, filter)
}
// CustomVerificationRequest resolves one application by identity.
func (s *Service) CustomVerificationRequest(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error) {
if s == nil || s.botVerification == nil {
return domain.CustomVerificationRequest{}, errBotVerificationNotConfigured
}
if requestID <= 0 {
return domain.CustomVerificationRequest{}, botVerificationCoded(domain.ErrCustomVerificationRequestNotFound)
}
return s.botVerification.Request(ctx, requestID)
}
// CustomVerificationRequestCounts is the queue summary rendered above the list.
func (s *Service) CustomVerificationRequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
if s == nil || s.botVerification == nil {
return nil, errBotVerificationNotConfigured
}
return s.botVerification.RequestCounts(ctx)
}
// CustomVerificationMarkActive reports whether a (verifier, peer) pair currently
// carries a mark. The request detail view needs it to tell "approved" apart from
// "approved and since stripped by the operator".
func (s *Service) CustomVerificationMarkActive(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error) {
if s == nil || s.botVerification == nil {
return false, errBotVerificationNotConfigured
}
if verifierBotID <= 0 || peer.ID <= 0 {
return false, nil
}
marks, err := s.botVerification.Marks(ctx, domain.CustomVerificationFilter{
VerifierBotID: verifierBotID,
PeerType: peer.Type,
PeerID: peer.ID,
Limit: 1,
})
if err != nil {
return false, botVerificationError(err)
}
return len(marks) > 0, nil
}
// ---------------------------------------------------------------------------
// Commands: verifier status
// ---------------------------------------------------------------------------
// GrantBotVerifier grants or reconfigures verifier status.
//
// The dry run predicts everything it can without writing: the payload shape, the
// optimistic lock against the row as it is now, and -- when the icon is visible in
// the catalogue page it reads -- that the icon is active and usable by this bot.
// It deliberately does not fail an icon it cannot see: the use-case layer resolves
// the icon's custom emoji document against the document store, and a dry run that
// refused more than the real command would make a rehearsal useless.
func (s *Service) GrantBotVerifier(ctx context.Context, req GrantBotVerifierRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
if req.BotID <= 0 {
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
}
if req.Version < 0 {
return CommandResult{}, botVerifierInvalid("version must not be negative")
}
settings := domain.BotVerifierSettings{
BotID: req.BotID,
IconDocumentID: req.IconDocumentID,
CompanyName: strings.TrimSpace(req.CompanyName),
DefaultDescription: strings.TrimSpace(req.DefaultDescription),
CanModifyCustomDescription: req.CanModifyCustomDescription,
GrantedBy: strings.TrimSpace(req.Actor),
GrantReason: strings.TrimSpace(req.Reason),
Enabled: true,
Version: req.Version,
}
if err := settings.Validate(); err != nil {
return CommandResult{}, botVerificationError(err)
}
target := domain.Peer{Type: domain.PeerTypeUser, ID: req.BotID}
return s.runCommand(ctx, req.CommandMeta, ActionGrantBotVerifier, req.BotID, target, req, func() (CommandResult, error) {
details := map[string]any{
"bot_id": strconv.FormatInt(req.BotID, 10),
"icon_document_id": strconv.FormatInt(req.IconDocumentID, 10),
"company_name": settings.CompanyName,
"can_modify_custom_description": req.CanModifyCustomDescription,
"correlation_id": strings.TrimSpace(req.CommandID),
}
current, exists, err := s.botVerifierState(ctx, req.BotID)
if err != nil {
return CommandResult{Details: details}, err
}
details["created"] = !exists
if exists {
details["previous_version"] = strconv.FormatInt(current.Version, 10)
details["previous_icon_document_id"] = strconv.FormatInt(current.IconDocumentID, 10)
details["previous_enabled"] = current.Enabled
// The kill switch is its own action, so reconfiguring a switched-off
// verifier must not switch it back on.
settings.Enabled = current.Enabled
if req.Version != current.Version {
return CommandResult{Details: details}, codedError(CodeCustomVerificationConflict, domain.ErrCustomVerificationVersionConflict)
}
} else if req.Version != 0 {
// The operator is editing a row that is no longer there.
return CommandResult{Details: details}, botVerificationCoded(domain.ErrVerifierNotFound)
}
details["enabled"] = settings.Enabled
if err := s.grantIconPreflight(ctx, details, req.BotID, req.IconDocumentID); err != nil {
return CommandResult{Details: details}, err
}
if req.DryRun {
message := "bot verifier grant validated"
if exists {
message = "bot verifier update validated"
}
return CommandResult{Message: message, Details: details}, nil
}
stored, err := s.botVerification.GrantVerifier(ctx, settings)
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
details["version"] = strconv.FormatInt(stored.Version, 10)
details["enabled"] = stored.Enabled
details["changed"] = true
message := "bot verifier status granted"
if exists {
message = "bot verifier status updated"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// SetBotVerifierEnabled flips the operator kill switch.
func (s *Service) SetBotVerifierEnabled(ctx context.Context, req SetBotVerifierEnabledRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
if req.BotID <= 0 {
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
}
target := domain.Peer{Type: domain.PeerTypeUser, ID: req.BotID}
return s.runCommand(ctx, req.CommandMeta, ActionSetBotVerifierEnabled, req.BotID, target, req, func() (CommandResult, error) {
details := map[string]any{
"bot_id": strconv.FormatInt(req.BotID, 10),
"enabled": req.Enabled,
"correlation_id": strings.TrimSpace(req.CommandID),
}
current, exists, err := s.botVerifierState(ctx, req.BotID)
if err != nil {
return CommandResult{Details: details}, err
}
if !exists {
return CommandResult{Details: details}, botVerificationCoded(domain.ErrVerifierNotFound)
}
details["previous_enabled"] = current.Enabled
details["previous_version"] = strconv.FormatInt(current.Version, 10)
details["company_name"] = current.CompanyName
// A no-op flip neither burns a version nor pushes an update, so the audit
// trail does not claim a change that never happened.
noop := current.Enabled == req.Enabled
details["changed"] = !noop
if req.DryRun {
message := "bot verifier switch validated"
if noop {
message = "bot verifier switch validated; already in that state"
}
return CommandResult{Message: message, Details: details}, nil
}
stored, err := s.botVerification.SetVerifierEnabled(ctx, req.BotID, req.Enabled)
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
details["version"] = strconv.FormatInt(stored.Version, 10)
details["enabled"] = stored.Enabled
message := "bot verifier enabled"
if !req.Enabled {
message = "bot verifier disabled"
}
if noop {
message = "bot verifier was already in that state"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// RevokeBotVerifier removes verifier status entirely.
//
// A missing row is not an error: the command answers changed=false, so a panel
// retry after a lost response is harmless and the dry run never refuses what the
// real command accepts.
func (s *Service) RevokeBotVerifier(ctx context.Context, req RevokeBotVerifierRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
if req.BotID <= 0 {
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
}
target := domain.Peer{Type: domain.PeerTypeUser, ID: req.BotID}
return s.runCommand(ctx, req.CommandMeta, ActionRevokeBotVerifier, req.BotID, target, req, func() (CommandResult, error) {
details := map[string]any{
"bot_id": strconv.FormatInt(req.BotID, 10),
"correlation_id": strings.TrimSpace(req.CommandID),
}
current, exists, err := s.botVerifierState(ctx, req.BotID)
if err != nil {
return CommandResult{Details: details}, err
}
details["present"] = exists
if exists {
details["previous_version"] = strconv.FormatInt(current.Version, 10)
details["previous_enabled"] = current.Enabled
details["company_name"] = current.CompanyName
// The marks this verifier granted cascade away with the row, which is
// the fact an operator most needs stated before confirming.
details["mark_count"] = s.botVerifierMarkCount(ctx, req.BotID)
}
if req.DryRun {
message := "bot verifier revoke validated"
if !exists {
message = "bot verifier revoke validated; the bot is not a verifier"
}
return CommandResult{Message: message, Details: details}, nil
}
removed, err := s.botVerification.RevokeVerifier(ctx, req.BotID)
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
details["changed"] = removed
message := "bot verifier status revoked"
if !removed {
message = "bot was not a verifier"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// ---------------------------------------------------------------------------
// Commands: icon catalogue
// ---------------------------------------------------------------------------
// UpsertVerificationIcon adds or updates a catalogue entry.
//
// The use-case layer resolves the custom emoji document before writing, because an
// entry pointing at a document no client can fetch renders as *nothing*: the peer
// looks unverified while the server insists it is marked. That probe needs the
// document store, so it runs on execution only; the dry run validates the shape.
func (s *Service) UpsertVerificationIcon(ctx context.Context, req UpsertVerificationIconRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
icon := domain.VerificationIcon{
DocumentID: req.DocumentID,
OwnerBotID: req.OwnerBotID,
Name: strings.TrimSpace(req.Name),
Active: true,
}
if err := icon.Validate(); err != nil {
return CommandResult{}, botVerificationError(err)
}
return s.runCommand(ctx, req.CommandMeta, ActionUpsertVerificationIcon, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"document_id": strconv.FormatInt(req.DocumentID, 10),
"owner_bot_id": strconv.FormatInt(req.OwnerBotID, 10),
"name": icon.Name,
"correlation_id": strings.TrimSpace(req.CommandID),
}
if req.DryRun {
return CommandResult{Message: "verification icon validated", Details: details}, nil
}
stored, err := s.botVerification.UpsertIcon(ctx, icon)
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
details["icon_id"] = strconv.FormatInt(stored.ID, 10)
details["active"] = stored.Active
details["changed"] = true
return CommandResult{Message: "verification icon stored", Details: details}, nil
})
}
// SetVerificationIconActive retires or restores a catalogue entry.
func (s *Service) SetVerificationIconActive(ctx context.Context, req SetVerificationIconActiveRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
if req.IconID <= 0 {
return CommandResult{}, botVerificationCoded(domain.ErrVerificationIconNotFound)
}
return s.runCommand(ctx, req.CommandMeta, ActionSetVerificationIconActive, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"icon_id": strconv.FormatInt(req.IconID, 10),
"active": req.Active,
"correlation_id": strings.TrimSpace(req.CommandID),
}
if req.DryRun {
return CommandResult{Message: "verification icon switch validated", Details: details}, nil
}
stored, err := s.botVerification.SetIconActive(ctx, req.IconID, req.Active)
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
details["document_id"] = strconv.FormatInt(stored.DocumentID, 10)
details["name"] = stored.Name
details["active"] = stored.Active
details["changed"] = true
message := "verification icon activated"
if !req.Active {
// Marks already granted with it keep rendering: the icon id is
// denormalised onto the mark.
message = "verification icon retired"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// ---------------------------------------------------------------------------
// Commands: granted marks
// ---------------------------------------------------------------------------
// RevokeCustomVerification strips one verifier's mark from a peer.
//
// The peer is checked for shape only, never for existence: an operator must still
// be able to strip a mark from a peer that has since been deleted or become
// unresolvable, which is exactly when stripping it matters most.
func (s *Service) RevokeCustomVerification(ctx context.Context, req RevokeCustomVerificationRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
if req.VerifierBotID <= 0 {
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
}
peer := domain.Peer{Type: req.PeerType, ID: req.PeerID}
if !markableAdminPeer(peer) {
return CommandResult{}, botVerificationCoded(domain.ErrCustomVerificationTargetInvalid)
}
targetUserID := int64(0)
if peer.Type == domain.PeerTypeUser {
targetUserID = peer.ID
}
return s.runCommand(ctx, req.CommandMeta, ActionRevokeCustomVerification, targetUserID, peer, req, func() (CommandResult, error) {
details := map[string]any{
"verifier_bot_id": strconv.FormatInt(req.VerifierBotID, 10),
"peer_type": string(peer.Type),
"peer_id": strconv.FormatInt(peer.ID, 10),
"correlation_id": strings.TrimSpace(req.CommandID),
}
present, err := s.CustomVerificationMarkActive(ctx, req.VerifierBotID, peer)
if err != nil {
return CommandResult{Details: details}, err
}
details["mark_present"] = present
if req.DryRun {
message := "custom verification revoke validated"
if !present {
message = "custom verification revoke validated; the peer carries no mark from this verifier"
}
return CommandResult{Message: message, Details: details}, nil
}
removed, err := s.botVerification.RevokeMark(ctx, req.VerifierBotID, peer)
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
details["changed"] = removed
message := "custom verification revoked"
if !removed {
message = "custom verification was already absent"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// ---------------------------------------------------------------------------
// Commands: application queue
// ---------------------------------------------------------------------------
// ApproveBotVerification grants the mark an application asked for.
func (s *Service) ApproveBotVerification(ctx context.Context, req ApproveBotVerificationRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
if err := validateBotVerificationDecisionShape(req.RequestID, req.Version, req.InternalNote); err != nil {
return CommandResult{}, err
}
return s.runCommand(ctx, req.CommandMeta, ActionApproveBotVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
current, details, err := s.botVerificationSubject(ctx, req.CommandMeta, req.RequestID, domain.CustomVerificationApproved, req.InternalNote)
if err != nil {
return CommandResult{Details: details}, err
}
if err := botVerificationTransition(current, req.Version, domain.CustomVerificationApproved); err != nil {
return CommandResult{Details: details}, err
}
// An application that is already approved is a replay: the use-case layer
// answers changed=false without re-running the verifier gate, so the dry run
// must not re-run it either.
replay := current.Status == domain.CustomVerificationApproved
if !replay {
if err := s.mergeVerifierDecisionDetails(ctx, details, current.VerifierBotID, true); err != nil {
return CommandResult{Details: details}, err
}
}
if req.DryRun {
return CommandResult{Message: "bot verification approve validated", Details: details}, nil
}
stored, changed, err := s.botVerification.Approve(ctx, req.RequestID, req.Version,
strings.TrimSpace(req.Actor), strings.TrimSpace(req.Reason), strings.TrimSpace(req.InternalNote))
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
mergeBotVerificationDetails(details, stored, changed)
message := "bot verification application approved"
if !changed {
message = "bot verification application already approved"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// RejectBotVerification closes an application against the applicant. The reason is
// mandatory and is the text the applicant is shown; the internal note stays
// operator-side.
func (s *Service) RejectBotVerification(ctx context.Context, req RejectBotVerificationRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
if err := validateBotVerificationDecisionShape(req.RequestID, req.Version, req.InternalNote); err != nil {
return CommandResult{}, err
}
// Refused before the journal is touched: the audit trail must never contain a
// decision nobody can explain.
if strings.TrimSpace(req.Reason) == "" {
return CommandResult{}, codedError(CodeCustomVerificationReasonRequired, domain.ErrVerificationReasonRequired)
}
return s.runCommand(ctx, req.CommandMeta, ActionRejectBotVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
current, details, err := s.botVerificationSubject(ctx, req.CommandMeta, req.RequestID, domain.CustomVerificationRejected, req.InternalNote)
if err != nil {
return CommandResult{Details: details}, err
}
if err := botVerificationTransition(current, req.Version, domain.CustomVerificationRejected); err != nil {
return CommandResult{Details: details}, err
}
if req.DryRun {
return CommandResult{Message: "bot verification reject validated", Details: details}, nil
}
stored, changed, err := s.botVerification.Reject(ctx, req.RequestID, req.Version,
strings.TrimSpace(req.Actor), strings.TrimSpace(req.Reason), strings.TrimSpace(req.InternalNote))
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
mergeBotVerificationDetails(details, stored, changed)
message := "bot verification application rejected"
if !changed {
message = "bot verification application already rejected"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// RevokeBotVerification withdraws a granted mark through the application it came
// from. A reason is mandatory for the same reason it is on rejection.
func (s *Service) RevokeBotVerification(ctx context.Context, req RevokeBotVerificationRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
if err := validateBotVerificationDecisionShape(req.RequestID, req.Version, req.InternalNote); err != nil {
return CommandResult{}, err
}
if strings.TrimSpace(req.Reason) == "" {
return CommandResult{}, codedError(CodeCustomVerificationReasonRequired, domain.ErrVerificationReasonRequired)
}
return s.runCommand(ctx, req.CommandMeta, ActionRevokeBotVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
current, details, err := s.botVerificationSubject(ctx, req.CommandMeta, req.RequestID, domain.CustomVerificationRevoked, req.InternalNote)
if err != nil {
return CommandResult{Details: details}, err
}
if err := botVerificationTransition(current, req.Version, domain.CustomVerificationRevoked); err != nil {
return CommandResult{Details: details}, err
}
// The mark may already be gone -- the operator can strip one directly -- and
// that is not an error: the application still has to reach "revoked".
present, err := s.CustomVerificationMarkActive(ctx, current.VerifierBotID, current.Peer)
if err != nil {
return CommandResult{Details: details}, err
}
details["mark_present"] = present
if req.DryRun {
return CommandResult{Message: "bot verification revoke validated", Details: details}, nil
}
stored, changed, err := s.botVerification.RevokeRequest(ctx, req.RequestID, req.Version,
strings.TrimSpace(req.Actor), strings.TrimSpace(req.Reason), strings.TrimSpace(req.InternalNote))
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
mergeBotVerificationDetails(details, stored, changed)
message := "bot verification mark revoked"
if !changed {
message = "bot verification application already revoked"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
var errBotVerificationNotConfigured = errors.New("admin bot verification dependency is not configured")
// botVerifierState loads a verifier row and reports whether it exists at all,
// because "not a verifier" is a normal answer for several of these commands
// rather than a failure.
func (s *Service) botVerifierState(ctx context.Context, botID int64) (domain.BotVerifierSettings, bool, error) {
settings, err := s.botVerification.VerifierSettings(ctx, botID)
switch {
case err == nil:
return settings, settings.BotID == botID, nil
case errors.Is(err, domain.ErrVerifierNotFound):
return domain.BotVerifierSettings{}, false, nil
default:
return domain.BotVerifierSettings{}, false, botVerificationError(err)
}
}
// botVerifierMarkCount reports how many marks a verifier holds, bounded by the
// listing page. It is advisory audit detail, so a read failure is reported as an
// unknown count rather than failing the command it is describing.
func (s *Service) botVerifierMarkCount(ctx context.Context, botID int64) any {
marks, err := s.botVerification.Marks(ctx, domain.CustomVerificationFilter{
VerifierBotID: botID,
Limit: botVerificationCatalogueScan,
})
if err != nil {
return "unknown"
}
if len(marks) >= botVerificationCatalogueScan {
return strconv.Itoa(len(marks)) + "+"
}
return strconv.Itoa(len(marks))
}
// grantIconPreflight records what the catalogue says about the icon and refuses
// the definite failures early.
//
// The check is deliberately one-sided. An icon found in the page and unusable is
// a certain failure and is refused with its own code; an icon the page does not
// cover is left to the use-case layer, which reads the entry by document id and
// additionally resolves the custom emoji document. So this can only ever make the
// dry run *more* informative, never stricter than the command it rehearses.
func (s *Service) grantIconPreflight(ctx context.Context, details map[string]any, botID, documentID int64) error {
icons, err := s.botVerification.Icons(ctx, false, botVerificationCatalogueScan)
if err != nil {
details["icon_catalogue_checked"] = false
return nil
}
for _, icon := range icons {
if icon.DocumentID != documentID {
continue
}
details["icon_catalogue_checked"] = true
details["icon_id"] = strconv.FormatInt(icon.ID, 10)
details["icon_name"] = icon.Name
details["icon_active"] = icon.Active
details["icon_owner_bot_id"] = strconv.FormatInt(icon.OwnerBotID, 10)
if !icon.Active {
return botVerificationCoded(domain.ErrVerificationIconInactive)
}
if !icon.UsableBy(botID) {
// A reserved entry belongs to one verifier; for anybody else it does
// not exist, which is why this is "not found" and not "forbidden".
return botVerificationCoded(domain.ErrVerificationIconNotFound)
}
return nil
}
details["icon_catalogue_checked"] = false
return nil
}
// botVerificationSubject loads the application under decision and seeds the
// command details with everything the audit entry must state even when the command
// then fails: which application, whose, which verifier, which peer, the status it
// is leaving and the status it was asked to reach.
func (s *Service) botVerificationSubject(
ctx context.Context,
meta CommandMeta,
requestID int64,
next domain.CustomVerificationRequestStatus,
internalNote string,
) (domain.CustomVerificationRequest, map[string]any, error) {
details := map[string]any{
"request_id": strconv.FormatInt(requestID, 10),
"next_status": string(next),
"correlation_id": strings.TrimSpace(meta.CommandID),
}
if note := strings.TrimSpace(internalNote); note != "" {
details["internal_note"] = note
}
current, err := s.botVerification.Request(ctx, requestID)
if err != nil {
return domain.CustomVerificationRequest{}, details, botVerificationError(err)
}
if current.ID != requestID {
return domain.CustomVerificationRequest{}, details, botVerificationCoded(domain.ErrCustomVerificationRequestNotFound)
}
details["verifier_bot_id"] = strconv.FormatInt(current.VerifierBotID, 10)
details["applicant_user_id"] = strconv.FormatInt(current.ApplicantUserID, 10)
details["peer_type"] = string(current.Peer.Type)
details["peer_id"] = strconv.FormatInt(current.Peer.ID, 10)
details["peer_username"] = current.PeerUsername
details["previous_status"] = string(current.Status)
details["previous_version"] = strconv.FormatInt(current.Version, 10)
return current, details, nil
}
// mergeVerifierDecisionDetails records the verifier behind an application and,
// for a decision that would grant a mark, refuses a verifier that may not verify
// right now.
//
// An application can sit in the queue for days: a verifier switched off in the
// meantime must not be able to grant through the review path what the RPC path
// would refuse. "No row" and "switched off" answer the same code on purpose --
// that is the distinction BOTVERIFIER_FORBIDDEN deliberately hides.
func (s *Service) mergeVerifierDecisionDetails(ctx context.Context, details map[string]any, botID int64, requireEnabled bool) error {
settings, exists, err := s.botVerifierState(ctx, botID)
if err != nil {
return err
}
details["verifier_present"] = exists
if exists {
details["verifier_enabled"] = settings.Enabled
details["verifier_company_name"] = settings.CompanyName
details["verifier_icon_document_id"] = strconv.FormatInt(settings.IconDocumentID, 10)
details["verifier_can_modify_custom_description"] = settings.CanModifyCustomDescription
}
if requireEnabled && (!exists || !settings.Enabled) {
return botVerificationCoded(domain.ErrVerifierForbidden)
}
return nil
}
// mergeBotVerificationDetails records the decided state.
func mergeBotVerificationDetails(details map[string]any, req domain.CustomVerificationRequest, changed bool) {
details["status"] = string(req.Status)
details["version"] = strconv.FormatInt(req.Version, 10)
details["changed"] = changed
details["decided_by"] = req.DecidedBy
if req.CorrelationID != "" {
details["correlation_id"] = req.CorrelationID
}
if req.DecisionReason != "" {
details["decision_reason"] = req.DecisionReason
}
}
// validateBotVerificationDecisionShape rejects a malformed decision before the
// command journal is touched.
func validateBotVerificationDecisionShape(requestID, version int64, internalNote string) error {
if requestID <= 0 {
return botVerificationCoded(domain.ErrCustomVerificationRequestNotFound)
}
if version <= 0 {
// Without the version the reviewer never read the row, so the optimistic
// lock could not protect a concurrent decision.
return botVerificationInvalid("version is required")
}
if utf8.RuneCountInString(internalNote) > domain.MaxCustomVerificationNoteLength {
return botVerificationInvalid("internal_note is too long")
}
return nil
}
// botVerificationTransition checks the status machine and the optimistic lock in
// the order the use-case layer does, so a dry run predicts the real outcome
// exactly.
//
// A request already in the target status is a replay the service answers as a
// no-op, so it passes without consulting the version: re-sending a decision whose
// response was lost must not turn into a spurious conflict.
func botVerificationTransition(req domain.CustomVerificationRequest, version int64, next domain.CustomVerificationRequestStatus) error {
if req.Status == next {
return nil
}
if !domain.CanTransitionCustomVerificationStatus(req.Status, next) {
return codedError(CodeCustomVerificationStatusInvalid,
fmt.Errorf("%w: %s -> %s", domain.ErrCustomVerificationRequestInvalid, req.Status, next))
}
if req.Version != version {
return codedError(CodeCustomVerificationConflict, domain.ErrCustomVerificationVersionConflict)
}
return nil
}
// markableAdminPeer mirrors the store's peer_type CHECK: only users (bots
// included) and channels can carry a third-party mark.
func markableAdminPeer(peer domain.Peer) bool {
switch peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel:
return peer.ID > 0
default:
return false
}
}
// BotVerificationErrorCode maps a third-party verification failure onto the stable
// code the admin panel switches on. An unmapped error returns "" so the caller can
// report it verbatim instead of inventing a code.
//
// The codes are separate from the official verification ones by design: the two
// mechanisms fail for different reasons and the panel renders them in different
// sections, so sharing a token would make a message land in the wrong place.
func BotVerificationErrorCode(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, domain.ErrVerifierNotFound):
return CodeBotVerifierNotFound
case errors.Is(err, domain.ErrVerifierForbidden):
return CodeBotVerifierForbidden
case errors.Is(err, domain.ErrVerifierDescriptionForbidden):
return CodeBotVerifierDescriptionForbidden
case errors.Is(err, domain.ErrVerifierSettingsInvalid):
return CodeBotVerifierInvalid
case errors.Is(err, domain.ErrVerificationIconNotFound):
return CodeVerificationIconNotFound
case errors.Is(err, domain.ErrVerificationIconInactive):
return CodeVerificationIconInactive
case errors.Is(err, domain.ErrVerificationIconInvalid):
return CodeVerificationIconInvalid
case errors.Is(err, domain.ErrCustomVerificationVersionConflict):
return CodeCustomVerificationConflict
case errors.Is(err, domain.ErrCustomVerificationLimit):
return CodeCustomVerificationLimit
case errors.Is(err, domain.ErrCustomVerificationNotFound):
return CodeCustomVerificationNotFound
case errors.Is(err, domain.ErrCustomVerificationRequestNotFound):
return CodeCustomVerificationRequestNotFound
case errors.Is(err, domain.ErrCustomVerificationRequestExists):
return CodeCustomVerificationRequestExists
case errors.Is(err, domain.ErrCustomVerificationTargetInvalid):
return CodeCustomVerificationTargetInvalid
case errors.Is(err, domain.ErrVerificationTargetSystem):
return CodeCustomVerificationTargetSystem
case errors.Is(err, domain.ErrVerificationReasonRequired):
return CodeCustomVerificationReasonRequired
case errors.Is(err, domain.ErrVerificationRateLimited):
return CodeCustomVerificationRateLimited
case errors.Is(err, domain.ErrBotNotFound):
return CodeBotVerifierBotNotFound
case errors.Is(err, domain.ErrCustomVerificationRequestInvalid):
return CodeCustomVerificationInvalid
default:
return ""
}
}
// botVerificationError prefixes a recognised failure with its stable code, the way
// verificationError does for the official review.
func botVerificationError(err error) error {
if code := BotVerificationErrorCode(err); code != "" {
return codedError(code, err)
}
return err
}
func botVerificationCoded(err error) error {
return codedError(BotVerificationErrorCode(err), err)
}
func botVerificationInvalid(message string) error {
return codedError(CodeCustomVerificationInvalid, fmt.Errorf("%s: %w", message, domain.ErrCustomVerificationRequestInvalid))
}
func botVerifierInvalid(message string) error {
return codedError(CodeBotVerifierInvalid, fmt.Errorf("%s: %w", message, domain.ErrVerifierSettingsInvalid))
}

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -0,0 +1,529 @@
package admin
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"unicode/utf8"
"telesrv/internal/domain"
)
// Official platform verification review.
//
// The review actions live behind the same command journal as every other
// operator write: a decision is auditable, replayable by command id and
// rehearsable with a dry run. The application record itself is the durable audit
// subject, so the details captured here name the application, its target and the
// status it moved between, and they carry the correlation id that ties the
// command journal entry to the immutable application event.
// VerificationService is the operator-facing slice of the official verification
// use cases. It is the exact method set *app/verification.Service exposes for the
// reviewer side, so the admin layer never reaches into the store.
type VerificationService interface {
List(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error)
Counts(ctx context.Context) (domain.VerificationStatusCounts, error)
Events(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error)
Application(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
TargetSnapshot(ctx context.Context, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationTarget, error)
Claim(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, error)
Approve(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error)
Reject(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error)
Revoke(ctx context.Context, req domain.VerificationRevocation) (domain.VerificationApplication, bool, error)
}
// ClaimVerificationRequest assigns a reviewer to a submitted application.
type ClaimVerificationRequest struct {
CommandMeta
ApplicationID int64 `json:"application_id"`
// Version is the optimistic-locking token the reviewer read. Two reviewers
// opening the same row submit the same version and exactly one wins.
Version int64 `json:"version"`
// InternalNote is optional on a claim: a reviewer picking a case up may want to
// record why ("waiting on legal") without deciding it yet.
InternalNote string `json:"internal_note,omitempty"`
}
// ApproveVerificationRequest grants the platform badge.
type ApproveVerificationRequest struct {
CommandMeta
ApplicationID int64 `json:"application_id"`
Version int64 `json:"version"`
// InternalNote is operator-only. It is journalled and appended to the
// application history, and it is never part of what the applicant is told.
InternalNote string `json:"internal_note,omitempty"`
}
// RejectVerificationRequest closes an application against the applicant. Reason
// is mandatory: it is the text the applicant receives.
type RejectVerificationRequest struct {
CommandMeta
ApplicationID int64 `json:"application_id"`
Version int64 `json:"version"`
InternalNote string `json:"internal_note,omitempty"`
}
// RevokeVerificationRequest clears the badge of a previously approved target. It
// addresses the target rather than an application, because a revocation is not a
// decision on the application: the application stays approved as history.
type RevokeVerificationRequest struct {
CommandMeta
TargetType domain.VerificationTargetType `json:"target_type"`
TargetID int64 `json:"target_id"`
InternalNote string `json:"internal_note,omitempty"`
}
// ---------------------------------------------------------------------------
// Reads
// ---------------------------------------------------------------------------
// VerificationApplications is the review-queue listing. The filter is passed
// through unchanged: the use-case layer owns normalisation and the page bound.
func (s *Service) VerificationApplications(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
if s == nil || s.verification == nil {
return nil, errVerificationNotConfigured
}
return s.verification.List(ctx, filter)
}
// VerificationCounts is the queue summary rendered above the list.
func (s *Service) VerificationCounts(ctx context.Context) (domain.VerificationStatusCounts, error) {
if s == nil || s.verification == nil {
return nil, errVerificationNotConfigured
}
return s.verification.Counts(ctx)
}
// VerificationApplication resolves one application by identity.
func (s *Service) VerificationApplication(ctx context.Context, applicationID int64) (domain.VerificationApplication, error) {
if s == nil || s.verification == nil {
return domain.VerificationApplication{}, errVerificationNotConfigured
}
if applicationID <= 0 {
return domain.VerificationApplication{}, verificationCoded(domain.ErrVerificationApplicationNotFound)
}
return s.verification.Application(ctx, applicationID)
}
// VerificationApplicationEvents returns one application's immutable history.
func (s *Service) VerificationApplicationEvents(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error) {
if s == nil || s.verification == nil {
return nil, errVerificationNotConfigured
}
return s.verification.Events(ctx, applicationID, limit)
}
// VerificationTargetSnapshot returns the target's state as it is now: title,
// username, badge, and whether it would pass the eligibility checks today.
func (s *Service) VerificationTargetSnapshot(ctx context.Context, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationTarget, error) {
if s == nil || s.verification == nil {
return domain.VerificationTarget{}, errVerificationNotConfigured
}
return s.verification.TargetSnapshot(ctx, targetType, targetID)
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
// ClaimVerification takes ownership of a submitted application.
//
// Claiming an application that somebody else already claimed is not idempotent:
// the status machine has no in_review -> in_review edge, so the second reviewer
// is told the row is taken instead of silently stealing it.
func (s *Service) ClaimVerification(ctx context.Context, req ClaimVerificationRequest) (CommandResult, error) {
if s == nil || s.verification == nil {
return CommandResult{}, errVerificationNotConfigured
}
if err := validateVerificationDecisionShape(req.ApplicationID, req.Version, req.InternalNote); err != nil {
return CommandResult{}, err
}
return s.runCommand(ctx, req.CommandMeta, ActionClaimVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
app, details, err := s.verificationSubject(ctx, req.CommandMeta, req.ApplicationID, domain.VerificationStatusInReview, req.InternalNote)
if err != nil {
return CommandResult{Details: details}, err
}
if err := verificationTransition(app, req.Version, domain.VerificationStatusInReview, false); err != nil {
return CommandResult{Details: details}, err
}
if req.DryRun {
return CommandResult{Message: "verification claim validated", Details: details}, nil
}
claimed, err := s.verification.Claim(ctx, verificationDecision(req.CommandMeta, req.ApplicationID, req.Version, req.InternalNote))
if err != nil {
return CommandResult{Details: details}, verificationError(err)
}
mergeVerificationDetails(details, claimed, true)
return CommandResult{Message: "verification application claimed", Details: details}, nil
})
}
// ApproveVerification grants the platform badge.
//
// The dry run reloads the target snapshot and refuses in advance whatever the
// real approval would refuse: the snapshot re-runs every eligibility check
// except the ownership probe, and a missing ownership can only make the real run
// stricter, never more permissive. So a passing dry run never turns into a
// surprise, and a failing one names the reason before anything is written.
func (s *Service) ApproveVerification(ctx context.Context, req ApproveVerificationRequest) (CommandResult, error) {
if s == nil || s.verification == nil {
return CommandResult{}, errVerificationNotConfigured
}
if err := validateVerificationDecisionShape(req.ApplicationID, req.Version, req.InternalNote); err != nil {
return CommandResult{}, err
}
return s.runCommand(ctx, req.CommandMeta, ActionApproveVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
app, details, err := s.verificationSubject(ctx, req.CommandMeta, req.ApplicationID, domain.VerificationStatusApproved, req.InternalNote)
if err != nil {
return CommandResult{Details: details}, err
}
if err := verificationTransition(app, req.Version, domain.VerificationStatusApproved, true); err != nil {
return CommandResult{Details: details}, err
}
// An application that is already approved is a replay: the use-case layer
// returns it untouched without re-running the target checks, so the target
// gate must not fire here either -- otherwise a retry would fail a dry run
// that the real command answers as a no-op.
replay := app.Status == domain.VerificationStatusApproved
if err := s.mergeVerificationTargetDetails(ctx, details, app, !replay); err != nil {
return CommandResult{Details: details}, err
}
if req.DryRun {
return CommandResult{Message: "verification approve validated", Details: details}, nil
}
approved, changed, err := s.verification.Approve(ctx, verificationDecision(req.CommandMeta, req.ApplicationID, req.Version, req.InternalNote))
if err != nil {
return CommandResult{Details: details}, verificationError(err)
}
mergeVerificationDetails(details, approved, changed)
message := "verification application approved"
if !changed {
message = "verification application already approved"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// RejectVerification closes an application against the applicant. The reason is
// mandatory and is the text the applicant is shown; the internal note stays
// operator-side.
func (s *Service) RejectVerification(ctx context.Context, req RejectVerificationRequest) (CommandResult, error) {
if s == nil || s.verification == nil {
return CommandResult{}, errVerificationNotConfigured
}
if err := validateVerificationDecisionShape(req.ApplicationID, req.Version, req.InternalNote); err != nil {
return CommandResult{}, err
}
// A rejection without a stated reason is refused before the journal is
// touched: the audit trail must never contain a decision nobody can explain.
if strings.TrimSpace(req.Reason) == "" {
return CommandResult{}, codedError(CodeVerificationReasonRequired, domain.ErrVerificationReasonRequired)
}
return s.runCommand(ctx, req.CommandMeta, ActionRejectVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
app, details, err := s.verificationSubject(ctx, req.CommandMeta, req.ApplicationID, domain.VerificationStatusRejected, req.InternalNote)
if err != nil {
return CommandResult{Details: details}, err
}
if err := verificationTransition(app, req.Version, domain.VerificationStatusRejected, true); err != nil {
return CommandResult{Details: details}, err
}
if req.DryRun {
return CommandResult{Message: "verification reject validated", Details: details}, nil
}
rejected, changed, err := s.verification.Reject(ctx, verificationDecision(req.CommandMeta, req.ApplicationID, req.Version, req.InternalNote))
if err != nil {
return CommandResult{Details: details}, verificationError(err)
}
mergeVerificationDetails(details, rejected, changed)
message := "verification application rejected"
if !changed {
message = "verification application already rejected"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// RevokeVerification clears the platform badge from a target.
//
// It addresses the peer, not an application: the approved application stays
// approved as history and the revocation is its own audit event. A reason is
// mandatory for the same reason a rejection needs one.
func (s *Service) RevokeVerification(ctx context.Context, req RevokeVerificationRequest) (CommandResult, error) {
if s == nil || s.verification == nil {
return CommandResult{}, errVerificationNotConfigured
}
if req.TargetID <= 0 || !req.TargetType.Valid() {
return CommandResult{}, codedError(CodeVerificationTargetInvalid, domain.ErrVerificationTargetInvalid)
}
if utf8.RuneCountInString(req.InternalNote) > domain.MaxVerificationNoteLength {
return CommandResult{}, verificationInvalid("internal_note is too long")
}
if strings.TrimSpace(req.Reason) == "" {
return CommandResult{}, codedError(CodeVerificationReasonRequired, domain.ErrVerificationReasonRequired)
}
targetPeer := domain.Peer{Type: req.TargetType.PeerType(), ID: req.TargetID}
targetUserID := int64(0)
if targetPeer.Type == domain.PeerTypeUser {
targetUserID = req.TargetID
}
return s.runCommand(ctx, req.CommandMeta, ActionRevokeVerification, targetUserID, targetPeer, req, func() (CommandResult, error) {
details := map[string]any{
"target_type": string(req.TargetType),
"target_id": strconv.FormatInt(req.TargetID, 10),
"correlation_id": strings.TrimSpace(req.CommandID),
}
if note := strings.TrimSpace(req.InternalNote); note != "" {
details["internal_note"] = note
}
// A built-in system account carries its badge by construction, so revoking
// it would desynchronise the seeded record from domain.SystemUserByID.
if targetPeer.Type == domain.PeerTypeUser && domain.IsSystemUserID(req.TargetID) {
return CommandResult{Details: details}, codedError(CodeVerificationTargetSystem, domain.ErrVerificationTargetSystem)
}
target, err := s.verification.TargetSnapshot(ctx, req.TargetType, req.TargetID)
if err != nil {
return CommandResult{Details: details}, verificationError(err)
}
details["target_title"] = target.Title
details["target_username"] = target.Username
details["target_verified"] = target.Verified
if req.DryRun {
message := "verification revoke validated"
if !target.Verified {
message = "verification revoke validated; target carries no badge"
}
return CommandResult{Message: message, Details: details}, nil
}
app, changed, err := s.verification.Revoke(ctx, domain.VerificationRevocation{
TargetType: req.TargetType,
TargetID: req.TargetID,
Reviewer: strings.TrimSpace(req.Actor),
Reason: strings.TrimSpace(req.Reason),
InternalNote: strings.TrimSpace(req.InternalNote),
CorrelationID: strings.TrimSpace(req.CommandID),
})
if err != nil {
return CommandResult{Details: details}, verificationError(err)
}
details["changed"] = changed
details["target_verified"] = false
if app.ID > 0 {
// The revoked target usually has an approved application behind it; it
// stays approved, which is why previous_status and status match here.
details["application_id"] = strconv.FormatInt(app.ID, 10)
details["applicant_user_id"] = strconv.FormatInt(app.ApplicantUserID, 10)
details["previous_status"] = string(app.Status)
details["status"] = string(app.Status)
details["version"] = strconv.FormatInt(app.Version, 10)
}
message := "verification badge revoked"
if !changed {
message = "verification badge was already absent"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
var errVerificationNotConfigured = errors.New("admin verification dependency is not configured")
// verificationSubject loads the application under decision and seeds the command
// details with everything the audit entry must state even when the command then
// fails: which application, whose, which target, the status it is leaving and the
// status it was asked to reach.
func (s *Service) verificationSubject(
ctx context.Context,
meta CommandMeta,
applicationID int64,
next domain.VerificationStatus,
internalNote string,
) (domain.VerificationApplication, map[string]any, error) {
details := map[string]any{
"application_id": strconv.FormatInt(applicationID, 10),
"next_status": string(next),
"correlation_id": strings.TrimSpace(meta.CommandID),
}
if note := strings.TrimSpace(internalNote); note != "" {
details["internal_note"] = note
}
app, err := s.verification.Application(ctx, applicationID)
if err != nil {
return domain.VerificationApplication{}, details, verificationError(err)
}
details["applicant_user_id"] = strconv.FormatInt(app.ApplicantUserID, 10)
details["target_type"] = string(app.TargetType)
details["target_id"] = strconv.FormatInt(app.TargetID, 10)
details["target_username"] = app.TargetUsername
details["previous_status"] = string(app.Status)
details["previous_version"] = strconv.FormatInt(app.Version, 10)
return app, details, nil
}
// mergeVerificationTargetDetails records the current target snapshot and, for a
// decision that flips the badge, refuses a target the platform may not verify.
func (s *Service) mergeVerificationTargetDetails(ctx context.Context, details map[string]any, app domain.VerificationApplication, requireEligible bool) error {
target, err := s.verification.TargetSnapshot(ctx, app.TargetType, app.TargetID)
if err != nil {
return verificationError(err)
}
details["target_title"] = target.Title
details["target_current_username"] = target.Username
details["target_verified"] = target.Verified
details["target_eligible"] = target.Eligible
if target.Reason != "" {
details["target_reason"] = target.Reason
}
if requireEligible && !target.Eligible {
return verificationError(verificationTargetReasonError(target.Reason))
}
return nil
}
// mergeVerificationDetails records the decided state.
func mergeVerificationDetails(details map[string]any, app domain.VerificationApplication, changed bool) {
details["status"] = string(app.Status)
details["version"] = strconv.FormatInt(app.Version, 10)
details["changed"] = changed
details["reviewer_admin_id"] = app.ReviewerAdminID
if app.CorrelationID != "" {
details["correlation_id"] = app.CorrelationID
}
if app.DecisionReason != "" {
details["decision_reason"] = app.DecisionReason
}
}
// verificationDecision builds the domain decision.
//
// The admin command id doubles as the correlation id, so one token links the
// command journal entry, the immutable application event and the applicant
// notification. The internal note travels in its own field and never in Reason,
// which is what the applicant is shown.
func verificationDecision(meta CommandMeta, applicationID, version int64, internalNote string) domain.VerificationDecision {
return domain.VerificationDecision{
ApplicationID: applicationID,
Version: version,
Reviewer: strings.TrimSpace(meta.Actor),
Reason: strings.TrimSpace(meta.Reason),
InternalNote: strings.TrimSpace(internalNote),
CorrelationID: strings.TrimSpace(meta.CommandID),
}
}
// validateVerificationDecisionShape rejects a malformed decision before the
// command journal is touched.
func validateVerificationDecisionShape(applicationID, version int64, internalNote string) error {
if applicationID <= 0 {
return verificationCoded(domain.ErrVerificationApplicationNotFound)
}
if version <= 0 {
// Without the version the reviewer never read the row, so the optimistic
// lock could not protect a concurrent decision.
return verificationInvalid("version is required")
}
if utf8.RuneCountInString(internalNote) > domain.MaxVerificationNoteLength {
return verificationInvalid("internal_note is too long")
}
return nil
}
// verificationTransition checks the status machine and the optimistic lock in the
// order the use-case layer does, so a dry run predicts the real outcome exactly.
//
// idempotent marks the decisions the service treats as a no-op on replay
// (approve/reject of an already decided application). A claim is not among them:
// there is no in_review -> in_review edge.
func verificationTransition(app domain.VerificationApplication, version int64, next domain.VerificationStatus, idempotent bool) error {
if idempotent && app.Status == next {
return nil
}
if !domain.CanTransitionVerificationStatus(app.Status, next) {
return codedError(CodeVerificationStatusInvalid, fmt.Errorf("%w: %s -> %s", domain.ErrVerificationStatusInvalid, app.Status, next))
}
if app.Version != version {
return codedError(CodeVerificationConflict, domain.ErrVerificationVersionConflict)
}
return nil
}
// verificationTargetReasonError maps the snapshot's rendered ineligibility reason
// back onto its domain sentinel. VerificationTarget.Reason is a string by
// design -- it is rendered to applicants by the bot -- so the reverse lookup is
// what lets the admin layer answer with a stable code instead of a bare message.
func verificationTargetReasonError(reason string) error {
for _, candidate := range []error{
domain.ErrVerificationTargetAlreadyVerified,
domain.ErrVerificationTargetRestricted,
domain.ErrVerificationTargetNotPublic,
domain.ErrVerificationTargetSystem,
domain.ErrVerificationUserTargetsDisabled,
domain.ErrVerificationTargetInvalid,
} {
if candidate.Error() == reason {
return candidate
}
}
return fmt.Errorf("%w: %s", domain.ErrVerificationTargetInvalid, reason)
}
// VerificationErrorCode maps a verification failure onto the stable code the
// admin panel switches on. An unmapped error returns "" so the caller can report
// it verbatim instead of inventing a code.
func VerificationErrorCode(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, domain.ErrVerificationApplicationNotFound):
return CodeVerificationNotFound
case errors.Is(err, domain.ErrVerificationVersionConflict):
return CodeVerificationConflict
case errors.Is(err, domain.ErrVerificationApplicationExists):
return CodeVerificationTargetOccupied
case errors.Is(err, domain.ErrVerificationStatusInvalid):
return CodeVerificationStatusInvalid
case errors.Is(err, domain.ErrVerificationReasonRequired):
return CodeVerificationReasonRequired
case errors.Is(err, domain.ErrVerificationTargetAlreadyVerified):
return CodeVerificationTargetVerified
case errors.Is(err, domain.ErrVerificationTargetNotPublic):
return CodeVerificationTargetNotPublic
case errors.Is(err, domain.ErrVerificationTargetRestricted):
return CodeVerificationTargetRestricted
case errors.Is(err, domain.ErrVerificationTargetSystem):
return CodeVerificationTargetSystem
case errors.Is(err, domain.ErrVerificationNotOwner):
return CodeVerificationNotOwner
case errors.Is(err, domain.ErrVerificationUserTargetsDisabled):
return CodeVerificationUserTargetsDisabled
case errors.Is(err, domain.ErrVerificationTargetInvalid):
return CodeVerificationTargetInvalid
case errors.Is(err, domain.ErrVerificationURLInvalid),
errors.Is(err, domain.ErrVerificationApplicationInvalid):
return CodeVerificationInvalid
default:
return ""
}
}
// verificationError prefixes a recognised verification error with its stable
// code, the way collectibleUsernameError does for the username registry.
func verificationError(err error) error {
if code := VerificationErrorCode(err); code != "" {
return codedError(code, err)
}
return err
}
func verificationCoded(err error) error {
return codedError(VerificationErrorCode(err), err)
}
func verificationInvalid(message string) error {
return codedError(CodeVerificationInvalid, fmt.Errorf("%s: %w", message, domain.ErrVerificationApplicationInvalid))
}

View file

@ -0,0 +1,629 @@
package admin
import (
"context"
"errors"
"strings"
"testing"
"time"
verificationapp "telesrv/internal/app/verification"
"telesrv/internal/domain"
)
// Compile-time proof that the shipped use-case service satisfies the admin port.
// cmd/telesrv wires *verification.Service into Dependencies.Verification
// directly, so a drifting method set has to fail here rather than at integration
// time.
var _ VerificationService = (*verificationapp.Service)(nil)
type fakeVerificationService struct {
app domain.VerificationApplication
target domain.VerificationTarget
events []domain.VerificationApplicationEvent
counts domain.VerificationStatusCounts
appErr error
targetErr error
decideErr error
claimCalls int
approveCalls int
rejectCalls int
revokeCalls int
claimed domain.VerificationDecision
approved domain.VerificationDecision
rejected domain.VerificationDecision
revoked domain.VerificationRevocation
}
func (f *fakeVerificationService) List(context.Context, domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
return []domain.VerificationApplication{f.app}, nil
}
func (f *fakeVerificationService) Counts(context.Context) (domain.VerificationStatusCounts, error) {
return f.counts, nil
}
func (f *fakeVerificationService) Events(context.Context, int64, int) ([]domain.VerificationApplicationEvent, error) {
return f.events, nil
}
func (f *fakeVerificationService) Application(_ context.Context, applicationID int64) (domain.VerificationApplication, error) {
if f.appErr != nil {
return domain.VerificationApplication{}, f.appErr
}
if f.app.ID != applicationID {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
return f.app, nil
}
func (f *fakeVerificationService) TargetSnapshot(context.Context, domain.VerificationTargetType, int64) (domain.VerificationTarget, error) {
if f.targetErr != nil {
return domain.VerificationTarget{}, f.targetErr
}
return f.target, nil
}
func (f *fakeVerificationService) Claim(_ context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, error) {
f.claimCalls++
f.claimed = decision
if f.decideErr != nil {
return domain.VerificationApplication{}, f.decideErr
}
f.app.Status = domain.VerificationStatusInReview
f.app.ReviewerAdminID = decision.Reviewer
f.app.Version++
f.app.CorrelationID = decision.CorrelationID
return f.app, nil
}
func (f *fakeVerificationService) Approve(_ context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error) {
f.approveCalls++
f.approved = decision
if f.decideErr != nil {
return domain.VerificationApplication{}, false, f.decideErr
}
f.app.Status = domain.VerificationStatusApproved
f.app.ReviewerAdminID = decision.Reviewer
f.app.InternalNote = decision.InternalNote
f.app.Version++
f.app.CorrelationID = decision.CorrelationID
return f.app, true, nil
}
func (f *fakeVerificationService) Reject(_ context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error) {
f.rejectCalls++
f.rejected = decision
if f.decideErr != nil {
return domain.VerificationApplication{}, false, f.decideErr
}
f.app.Status = domain.VerificationStatusRejected
f.app.ReviewerAdminID = decision.Reviewer
f.app.DecisionReason = decision.Reason
f.app.InternalNote = decision.InternalNote
f.app.Version++
f.app.CorrelationID = decision.CorrelationID
return f.app, true, nil
}
func (f *fakeVerificationService) Revoke(_ context.Context, req domain.VerificationRevocation) (domain.VerificationApplication, bool, error) {
f.revokeCalls++
f.revoked = req
if f.decideErr != nil {
return domain.VerificationApplication{}, false, f.decideErr
}
f.target.Verified = false
return f.app, true, nil
}
func submittedVerificationApplication() domain.VerificationApplication {
return domain.VerificationApplication{
ID: 77,
ApplicantUserID: 1001,
TargetType: domain.VerificationTargetChannel,
TargetID: 5005,
TargetTitle: "Example News",
TargetUsername: "examplenews",
Category: "media",
Status: domain.VerificationStatusSubmitted,
SubmittedAt: fixedNow(),
Version: 3,
}
}
func newVerificationFixture() (*Service, *fakeVerificationService, *memoryCommandRepo) {
repo := newMemoryCommandRepo()
verification := &fakeVerificationService{
app: submittedVerificationApplication(),
target: domain.VerificationTarget{
Type: domain.VerificationTargetChannel, ID: 5005,
Title: "Example News", Username: "examplenews", Eligible: true,
},
}
svc := NewService(Dependencies{Commands: repo, Verification: verification, Now: fixedNow})
return svc, verification, repo
}
func TestClaimVerificationDryRunExecuteAndIdempotentReplay(t *testing.T) {
ctx := context.Background()
svc, verification, repo := newVerificationFixture()
dry, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
CommandMeta: CommandMeta{CommandID: "dry-claim", Actor: "alice", Reason: "picking up the queue", DryRun: true},
ApplicationID: 77,
Version: 3,
})
if err != nil {
t.Fatalf("dry-run claim: %v", err)
}
if !dry.DryRun || dry.Status != string(domain.AdminCommandCompleted) || verification.claimCalls != 0 {
t.Fatalf("dry-run result=%+v claimCalls=%d, want a completed dry run without mutation", dry, verification.claimCalls)
}
if dry.Details["previous_status"] != string(domain.VerificationStatusSubmitted) ||
dry.Details["next_status"] != string(domain.VerificationStatusInReview) ||
dry.Details["application_id"] != "77" || dry.Details["target_id"] != "5005" ||
dry.Details["target_type"] != string(domain.VerificationTargetChannel) ||
dry.Details["correlation_id"] != "dry-claim" {
t.Fatalf("dry-run details=%+v, want the audit facts seeded before execution", dry.Details)
}
execReq := ClaimVerificationRequest{
CommandMeta: CommandMeta{CommandID: "exec-claim", Actor: "alice", Reason: "picking up the queue"},
ApplicationID: 77,
Version: 3,
}
exec, err := svc.ClaimVerification(ctx, execReq)
if err != nil {
t.Fatalf("execute claim: %v", err)
}
if verification.claimCalls != 1 || exec.Status != string(domain.AdminCommandCompleted) {
t.Fatalf("execute result=%+v claimCalls=%d", exec, verification.claimCalls)
}
if exec.Details["status"] != string(domain.VerificationStatusInReview) ||
exec.Details["reviewer_admin_id"] != "alice" || exec.Details["version"] != "4" {
t.Fatalf("execute details=%+v", exec.Details)
}
// The command id is the correlation id, so the journal entry and the
// application event can be matched up afterwards.
if verification.claimed.CorrelationID != "exec-claim" || verification.claimed.Reviewer != "alice" {
t.Fatalf("claim decision=%+v", verification.claimed)
}
if _, ok := repo.items["exec-claim"]; !ok {
t.Fatal("claim was not journalled")
}
replay, err := svc.ClaimVerification(ctx, execReq)
if err != nil {
t.Fatalf("replay claim: %v", err)
}
if !replay.AlreadyExecuted || verification.claimCalls != 1 {
t.Fatalf("replay result=%+v claimCalls=%d, want an idempotent replay", replay, verification.claimCalls)
}
}
func TestClaimVerificationRefusesAlreadyClaimedApplication(t *testing.T) {
ctx := context.Background()
svc, verification, _ := newVerificationFixture()
verification.app.Status = domain.VerificationStatusInReview
_, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
CommandMeta: CommandMeta{CommandID: "claim-taken", Actor: "bob", Reason: "second reviewer"},
ApplicationID: 77,
Version: 3,
})
if !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("claim of a claimed application err=%v, want ErrVerificationStatusInvalid", err)
}
if !strings.Contains(err.Error(), CodeVerificationStatusInvalid) || verification.claimCalls != 0 {
t.Fatalf("err=%v claimCalls=%d, want the stable code and no mutation", err, verification.claimCalls)
}
}
func TestApproveVerificationVersionConflictIsNotAMutation(t *testing.T) {
ctx := context.Background()
svc, verification, _ := newVerificationFixture()
verification.app.Status = domain.VerificationStatusInReview
_, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
CommandMeta: CommandMeta{CommandID: "approve-stale", Actor: "alice", Reason: "docs check out"},
ApplicationID: 77,
// The reviewer read version 3 but somebody else already advanced the row.
Version: 2,
})
if !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("stale approve err=%v, want ErrVerificationVersionConflict", err)
}
if !strings.Contains(err.Error(), CodeVerificationConflict) || verification.approveCalls != 0 {
t.Fatalf("err=%v approveCalls=%d", err, verification.approveCalls)
}
}
func TestApproveVerificationRefusesIneligibleTargetBeforeWriting(t *testing.T) {
ctx := context.Background()
svc, verification, _ := newVerificationFixture()
verification.app.Status = domain.VerificationStatusInReview
verification.target.Eligible = false
verification.target.Verified = true
verification.target.Reason = domain.ErrVerificationTargetAlreadyVerified.Error()
result, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
CommandMeta: CommandMeta{CommandID: "approve-verified", Actor: "alice", Reason: "docs check out", DryRun: true},
ApplicationID: 77,
Version: 3,
})
if !errors.Is(err, domain.ErrVerificationTargetAlreadyVerified) {
t.Fatalf("approve of a verified target err=%v, want ErrVerificationTargetAlreadyVerified", err)
}
if !strings.Contains(err.Error(), CodeVerificationTargetVerified) {
t.Fatalf("err=%v, want the stable code", err)
}
if result.Details["target_eligible"] != false || result.Details["target_verified"] != true {
t.Fatalf("details=%+v, want the snapshot recorded on the failed command", result.Details)
}
if verification.approveCalls != 0 {
t.Fatalf("approveCalls=%d, want the dry run to predict the refusal", verification.approveCalls)
}
}
func TestApproveVerificationKeepsInternalNoteOutOfTheApplicantReason(t *testing.T) {
ctx := context.Background()
svc, verification, repo := newVerificationFixture()
verification.app.Status = domain.VerificationStatusInReview
result, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
CommandMeta: CommandMeta{CommandID: "approve-77", Actor: "alice", Reason: "press coverage verified"},
ApplicationID: 77,
Version: 3,
InternalNote: "contact reached us through the press office; do not quote",
})
if err != nil {
t.Fatalf("approve: %v", err)
}
if verification.approved.Reason != "press coverage verified" {
t.Fatalf("applicant-facing reason=%q", verification.approved.Reason)
}
if verification.approved.InternalNote != "contact reached us through the press office; do not quote" {
t.Fatalf("internal note=%q, want it carried in its own field", verification.approved.InternalNote)
}
if strings.Contains(verification.approved.Reason, "do not quote") {
t.Fatal("internal note leaked into the applicant-facing reason")
}
// The note is operator-only but must still be auditable.
if result.Details["internal_note"] != "contact reached us through the press office; do not quote" {
t.Fatalf("details=%+v, want the internal note journalled", result.Details)
}
if stored := repo.items["approve-77"].ResultJSON; !strings.Contains(string(stored), "do not quote") {
t.Fatalf("journalled result=%s, want the internal note persisted", stored)
}
}
// A retried approval of an already approved application is a no-op in the
// use-case layer, so the dry run must not fail it on the "already verified"
// target gate the first approval passed.
func TestApproveVerificationReplayIsNotBlockedByTheTargetGate(t *testing.T) {
ctx := context.Background()
svc, verification, _ := newVerificationFixture()
verification.app.Status = domain.VerificationStatusApproved
verification.app.ReviewerAdminID = "alice"
verification.target.Verified = true
verification.target.Eligible = false
verification.target.Reason = domain.ErrVerificationTargetAlreadyVerified.Error()
dry, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
CommandMeta: CommandMeta{CommandID: "approve-replay-dry", Actor: "alice", Reason: "docs check out", DryRun: true},
ApplicationID: 77,
Version: 3,
})
if err != nil {
t.Fatalf("dry-run replay approve: %v", err)
}
if verification.approveCalls != 0 || dry.Details["target_verified"] != true {
t.Fatalf("dry-run replay details=%+v approveCalls=%d", dry.Details, verification.approveCalls)
}
exec, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
CommandMeta: CommandMeta{CommandID: "approve-replay", Actor: "alice", Reason: "docs check out"},
ApplicationID: 77,
Version: 3,
})
if err != nil {
t.Fatalf("replay approve: %v", err)
}
if exec.Details["changed"] != true {
// The fake always reports a change; the point is that the command reached
// the service instead of being refused by the pre-check.
t.Fatalf("replay approve details=%+v", exec.Details)
}
if verification.approveCalls != 1 {
t.Fatalf("approveCalls=%d, want the replay handed to the use-case layer", verification.approveCalls)
}
}
func TestRejectVerificationRequiresReason(t *testing.T) {
ctx := context.Background()
svc, verification, repo := newVerificationFixture()
verification.app.Status = domain.VerificationStatusInReview
_, err := svc.RejectVerification(ctx, RejectVerificationRequest{
CommandMeta: CommandMeta{CommandID: "reject-no-reason", Actor: "alice", Reason: " "},
ApplicationID: 77,
Version: 3,
})
if !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("reject without reason err=%v, want ErrVerificationReasonRequired", err)
}
if !strings.Contains(err.Error(), CodeVerificationReasonRequired) {
t.Fatalf("err=%v, want the stable code", err)
}
if verification.rejectCalls != 0 || len(repo.items) != 0 {
t.Fatalf("rejectCalls=%d journalled=%d, want a refusal before the journal is touched",
verification.rejectCalls, len(repo.items))
}
ok, err := svc.RejectVerification(ctx, RejectVerificationRequest{
CommandMeta: CommandMeta{CommandID: "reject-77", Actor: "alice", Reason: "press links are self-published"},
ApplicationID: 77,
Version: 3,
})
if err != nil {
t.Fatalf("reject: %v", err)
}
if verification.rejectCalls != 1 || verification.rejected.Reason != "press links are self-published" {
t.Fatalf("rejectCalls=%d decision=%+v", verification.rejectCalls, verification.rejected)
}
if ok.Details["status"] != string(domain.VerificationStatusRejected) ||
ok.Details["decision_reason"] != "press links are self-published" {
t.Fatalf("details=%+v", ok.Details)
}
}
func TestRevokeVerificationRequiresReasonAndTargetShape(t *testing.T) {
ctx := context.Background()
svc, verification, repo := newVerificationFixture()
verification.target.Verified = true
_, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
CommandMeta: CommandMeta{CommandID: "revoke-no-reason", Actor: "alice"},
TargetType: domain.VerificationTargetChannel,
TargetID: 5005,
})
if !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("revoke without reason err=%v, want ErrVerificationReasonRequired", err)
}
_, err = svc.RevokeVerification(ctx, RevokeVerificationRequest{
CommandMeta: CommandMeta{CommandID: "revoke-bad-target", Actor: "alice", Reason: "impersonation"},
TargetType: domain.VerificationTargetType("group"),
TargetID: 5005,
})
if !errors.Is(err, domain.ErrVerificationTargetInvalid) {
t.Fatalf("revoke of an unmodelled target err=%v, want ErrVerificationTargetInvalid", err)
}
if verification.revokeCalls != 0 || len(repo.items) != 0 {
t.Fatalf("revokeCalls=%d journalled=%d, want refusals before the journal", verification.revokeCalls, len(repo.items))
}
dry, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
CommandMeta: CommandMeta{CommandID: "dry-revoke", Actor: "alice", Reason: "impersonation confirmed", DryRun: true},
TargetType: domain.VerificationTargetChannel,
TargetID: 5005,
})
if err != nil {
t.Fatalf("dry-run revoke: %v", err)
}
if verification.revokeCalls != 0 || dry.Details["target_verified"] != true {
t.Fatalf("dry-run revoke details=%+v revokeCalls=%d", dry.Details, verification.revokeCalls)
}
exec, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
CommandMeta: CommandMeta{CommandID: "exec-revoke", Actor: "alice", Reason: "impersonation confirmed"},
TargetType: domain.VerificationTargetChannel,
TargetID: 5005,
InternalNote: "legal asked for the takedown",
})
if err != nil {
t.Fatalf("execute revoke: %v", err)
}
if verification.revokeCalls != 1 || verification.revoked.Reason != "impersonation confirmed" ||
verification.revoked.InternalNote != "legal asked for the takedown" ||
verification.revoked.CorrelationID != "exec-revoke" {
t.Fatalf("revocation=%+v", verification.revoked)
}
if exec.Details["target_verified"] != false || exec.Details["changed"] != true ||
exec.Details["application_id"] != "77" {
t.Fatalf("execute revoke details=%+v", exec.Details)
}
if exec.TargetPeer != (domain.Peer{Type: domain.PeerTypeChannel, ID: 5005}) {
t.Fatalf("journalled target peer=%+v", exec.TargetPeer)
}
}
func TestRevokeVerificationRefusesSystemAccount(t *testing.T) {
ctx := context.Background()
svc, verification, _ := newVerificationFixture()
result, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
CommandMeta: CommandMeta{CommandID: "revoke-system", Actor: "alice", Reason: "cleanup"},
TargetType: domain.VerificationTargetBot,
TargetID: domain.BotFatherUserID,
})
if !errors.Is(err, domain.ErrVerificationTargetSystem) {
t.Fatalf("revoke of a system account err=%v, want ErrVerificationTargetSystem", err)
}
if verification.revokeCalls != 0 || result.Details["target_id"] == nil {
t.Fatalf("revokeCalls=%d details=%+v", verification.revokeCalls, result.Details)
}
}
func TestVerificationDecisionShapeIsValidatedBeforeTheJournal(t *testing.T) {
ctx := context.Background()
svc, _, repo := newVerificationFixture()
if _, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
CommandMeta: CommandMeta{CommandID: "claim-no-id", Actor: "alice", Reason: "queue"},
ApplicationID: 0,
Version: 3,
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("claim without an application err=%v", err)
}
if _, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
CommandMeta: CommandMeta{CommandID: "claim-no-version", Actor: "alice", Reason: "queue"},
ApplicationID: 77,
Version: 0,
}); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("claim without a version err=%v", err)
}
if _, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
CommandMeta: CommandMeta{CommandID: "approve-long-note", Actor: "alice", Reason: "queue"},
ApplicationID: 77,
Version: 3,
InternalNote: strings.Repeat("x", domain.MaxVerificationNoteLength+1),
}); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("approve with an oversized note err=%v", err)
}
if len(repo.items) != 0 {
t.Fatalf("journalled=%d, want malformed decisions refused before the journal", len(repo.items))
}
}
func TestVerificationMissingApplicationIsJournalledAsNotFound(t *testing.T) {
ctx := context.Background()
svc, verification, repo := newVerificationFixture()
verification.appErr = domain.ErrVerificationApplicationNotFound
result, err := svc.RejectVerification(ctx, RejectVerificationRequest{
CommandMeta: CommandMeta{CommandID: "reject-missing", Actor: "alice", Reason: "not eligible"},
ApplicationID: 77,
Version: 3,
})
if !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("reject of a missing application err=%v", err)
}
if VerificationErrorCode(err) != CodeVerificationNotFound {
t.Fatalf("code=%q", VerificationErrorCode(err))
}
if result.Status != string(domain.AdminCommandFailed) {
t.Fatalf("result=%+v, want a failed command", result)
}
if cmd, ok := repo.items["reject-missing"]; !ok || cmd.Status != domain.AdminCommandFailed {
t.Fatalf("journalled command=%+v ok=%v, want the failure recorded", cmd, ok)
}
}
func TestVerificationReadsRequireTheDependency(t *testing.T) {
ctx := context.Background()
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Now: fixedNow})
if _, err := svc.VerificationApplications(ctx, domain.VerificationApplicationFilter{}); err == nil {
t.Fatal("listing without the dependency succeeded")
}
if _, err := svc.VerificationCounts(ctx); err == nil {
t.Fatal("counting without the dependency succeeded")
}
if _, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{ApplicationID: 1, Version: 1}); err == nil {
t.Fatal("claiming without the dependency succeeded")
}
}
func TestVerificationReadsPassThrough(t *testing.T) {
ctx := context.Background()
svc, verification, _ := newVerificationFixture()
verification.counts = domain.VerificationStatusCounts{domain.VerificationStatusSubmitted: 3}
verification.events = []domain.VerificationApplicationEvent{{
ID: 9, ApplicationID: 77, Kind: domain.VerificationEventSubmitted,
ToStatus: domain.VerificationStatusSubmitted, CreatedAt: fixedNow(),
}}
items, err := svc.VerificationApplications(ctx, domain.VerificationApplicationFilter{Limit: 10})
if err != nil || len(items) != 1 || items[0].ID != 77 {
t.Fatalf("applications=%+v err=%v", items, err)
}
counts, err := svc.VerificationCounts(ctx)
if err != nil || counts[domain.VerificationStatusSubmitted] != 3 {
t.Fatalf("counts=%+v err=%v", counts, err)
}
events, err := svc.VerificationApplicationEvents(ctx, 77, 10)
if err != nil || len(events) != 1 || events[0].ID != 9 {
t.Fatalf("events=%+v err=%v", events, err)
}
app, err := svc.VerificationApplication(ctx, 77)
if err != nil || app.Version != 3 {
t.Fatalf("application=%+v err=%v", app, err)
}
if _, err := svc.VerificationApplication(ctx, 0); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("application(0) err=%v", err)
}
target, err := svc.VerificationTargetSnapshot(ctx, domain.VerificationTargetChannel, 5005)
if err != nil || target.ID != 5005 {
t.Fatalf("target=%+v err=%v", target, err)
}
}
func TestVerificationErrorCodeCoversTheDomainSentinels(t *testing.T) {
cases := map[error]string{
domain.ErrVerificationApplicationNotFound: CodeVerificationNotFound,
domain.ErrVerificationVersionConflict: CodeVerificationConflict,
domain.ErrVerificationApplicationExists: CodeVerificationTargetOccupied,
domain.ErrVerificationStatusInvalid: CodeVerificationStatusInvalid,
domain.ErrVerificationReasonRequired: CodeVerificationReasonRequired,
domain.ErrVerificationTargetAlreadyVerified: CodeVerificationTargetVerified,
domain.ErrVerificationTargetNotPublic: CodeVerificationTargetNotPublic,
domain.ErrVerificationTargetRestricted: CodeVerificationTargetRestricted,
domain.ErrVerificationTargetSystem: CodeVerificationTargetSystem,
domain.ErrVerificationNotOwner: CodeVerificationNotOwner,
domain.ErrVerificationUserTargetsDisabled: CodeVerificationUserTargetsDisabled,
domain.ErrVerificationTargetInvalid: CodeVerificationTargetInvalid,
domain.ErrVerificationApplicationInvalid: CodeVerificationInvalid,
errors.New("some transport failure nobody mapped"): "",
}
for err, want := range cases {
if got := VerificationErrorCode(err); got != want {
t.Fatalf("VerificationErrorCode(%v) = %q, want %q", err, got, want)
}
}
if got := VerificationErrorCode(nil); got != "" {
t.Fatalf("VerificationErrorCode(nil) = %q", got)
}
}
func TestVerificationTargetReasonErrorRoundTripsTheSnapshotString(t *testing.T) {
for _, sentinel := range []error{
domain.ErrVerificationTargetAlreadyVerified,
domain.ErrVerificationTargetRestricted,
domain.ErrVerificationTargetNotPublic,
domain.ErrVerificationTargetSystem,
domain.ErrVerificationUserTargetsDisabled,
domain.ErrVerificationTargetInvalid,
} {
if err := verificationTargetReasonError(sentinel.Error()); !errors.Is(err, sentinel) {
t.Fatalf("verificationTargetReasonError(%q) = %v", sentinel.Error(), err)
}
}
// An unrecognised reason must still be a target failure rather than a panic or
// a silent pass.
if err := verificationTargetReasonError("something new"); !errors.Is(err, domain.ErrVerificationTargetInvalid) {
t.Fatalf("unknown reason mapped to %v", err)
}
}
func TestVerificationClockIsNotRequiredForDetails(t *testing.T) {
// The details are pure projections of the application, so a service without a
// wall clock still produces a complete audit entry.
svc, verification, _ := newVerificationFixture()
verification.app.UpdatedAt = time.Time{}
result, err := svc.ClaimVerification(context.Background(), ClaimVerificationRequest{
CommandMeta: CommandMeta{CommandID: "claim-clockless", Actor: "alice", Reason: "queue"},
ApplicationID: 77,
Version: 3,
})
if err != nil {
t.Fatalf("claim: %v", err)
}
for _, key := range []string{"application_id", "applicant_user_id", "target_type", "target_id", "previous_status", "status", "correlation_id"} {
if _, ok := result.Details[key]; !ok {
t.Fatalf("details %+v missing %q", result.Details, key)
}
}
}

View file

@ -0,0 +1,561 @@
package adminapi
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
// Third-party bot verification over the admin API
// (core.telegram.org/api/bots/verification).
//
// These are the mirror routes of the panel's own /api/botverification endpoints:
// the panel reads straight from PostgreSQL for speed, while an integration holding
// a scoped token reads it here. Every mutation only ever travels this way, so the
// command journal, the status machine and the optimistic lock are enforced in one
// place.
//
// This is NOT the official platform verification surface in verification.go. The
// two mechanisms own separate tables, separate permissions (botverification.* vs
// verification.*) and separate routes, and neither reads the other's state: a
// third-party verifier must never be able to mint a platform checkmark.
//
// Every int64 crosses the JSON boundary as a decimal string. Bot ids, peer ids,
// custom emoji document ids and the optimistic-locking version all exceed the
// range a JSON number holds exactly, and a rounded version would decide the wrong
// revision of a row.
// handleBotVerifiers lists verifier bots.
func (s *Server) handleBotVerifiers(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
items, err := s.svc.BotVerifiers(r.Context(), queryBool(query.Get("enabled_only")), limit)
if err != nil {
writeBotVerificationError(w, err)
return
}
rows := make([]map[string]any, 0, len(items))
for _, item := range items {
rows = append(rows, botVerifierResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
}
// handleVerificationIcons lists the icon catalogue.
func (s *Server) handleVerificationIcons(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
items, err := s.svc.VerificationIcons(r.Context(), queryBool(query.Get("active_only")), limit)
if err != nil {
writeBotVerificationError(w, err)
return
}
rows := make([]map[string]any, 0, len(items))
for _, item := range items {
rows = append(rows, verificationIconResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
}
// handleCustomVerifications lists granted marks with keyset paging.
func (s *Server) handleCustomVerifications(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
peerType, ok := botVerificationPeerType(w, query.Get("peer_type"))
if !ok {
return
}
verifierBotID, ok := optionalQueryInt64(w, query, "verifier_bot_id")
if !ok {
return
}
beforeID, ok := optionalQueryInt64(w, query, "before_id")
if !ok {
return
}
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
items, err := s.svc.CustomVerifications(r.Context(), domain.CustomVerificationFilter{
VerifierBotID: verifierBotID,
PeerType: peerType,
Query: query.Get("q"),
BeforeID: beforeID,
Limit: limit,
})
if err != nil {
writeBotVerificationError(w, err)
return
}
rows := make([]map[string]any, 0, len(items))
for _, item := range items {
rows = append(rows, customVerificationResponse(item))
}
// The page bound is the use-case layer's, so has_more is derived from what came
// back rather than from the limit the caller asked for.
hasMore := limit > 0 && len(items) >= limit
nextBeforeID := ""
if hasMore && len(items) > 0 {
nextBeforeID = strconv.FormatInt(items[len(items)-1].ID, 10)
}
writeJSON(w, http.StatusOK, map[string]any{
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
})
}
// handleCustomVerificationRequests is the third-party review queue.
func (s *Server) handleCustomVerificationRequests(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
peerType, ok := botVerificationPeerType(w, query.Get("peer_type"))
if !ok {
return
}
filter := domain.CustomVerificationRequestFilter{
PeerType: peerType,
Query: query.Get("q"),
}
// status accepts a comma-separated list, so a "pending,approved" view is one
// request rather than two.
for _, raw := range strings.Split(query.Get("status"), ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
status := domain.CustomVerificationRequestStatus(raw)
if !status.Valid() {
writeCodedError(w, http.StatusBadRequest, admin.CodeCustomVerificationStatusInvalid, "invalid status "+raw)
return
}
filter.Statuses = append(filter.Statuses, status)
}
verifierBotID, ok := optionalQueryInt64(w, query, "verifier_bot_id")
if !ok {
return
}
filter.VerifierBotID = verifierBotID
beforeID, ok := optionalQueryInt64(w, query, "before_id")
if !ok {
return
}
filter.BeforeID = beforeID
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
filter.Limit = limit
items, err := s.svc.CustomVerificationRequests(r.Context(), filter)
if err != nil {
writeBotVerificationError(w, err)
return
}
rows := make([]map[string]any, 0, len(items))
for _, item := range items {
rows = append(rows, customVerificationRequestResponse(item))
}
hasMore := limit > 0 && len(items) >= limit
nextBeforeID := ""
if hasMore && len(items) > 0 {
nextBeforeID = strconv.FormatInt(items[len(items)-1].ID, 10)
}
writeJSON(w, http.StatusOK, map[string]any{
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
})
}
// handleCustomVerificationRequest is one application with the verifier behind it
// and whether the mark is on the peer right now.
func (s *Server) handleCustomVerificationRequest(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
req, err := s.svc.CustomVerificationRequest(r.Context(), id)
if err != nil {
writeBotVerificationError(w, err)
return
}
body := map[string]any{"request": customVerificationRequestResponse(req)}
// The verifier row is advisory: it may have been revoked since the application
// was filed, and that must not turn the audit record into a 500. An absent row
// is reported as a verifier with only its id, so the reviewer can see which bot
// it was.
if settings, err := s.svc.BotVerifier(r.Context(), req.VerifierBotID); err == nil {
body["verifier"] = botVerifierResponse(settings)
} else if errors.Is(err, domain.ErrVerifierNotFound) {
body["verifier"] = botVerifierResponse(domain.BotVerifierSettings{BotID: req.VerifierBotID})
} else {
body["verifier"] = botVerifierResponse(domain.BotVerifierSettings{BotID: req.VerifierBotID})
body["verifier_error"] = err.Error()
}
// mark_active tells "approved" apart from "approved and since stripped by the
// operator", which is the one thing the status alone cannot say.
if active, err := s.svc.CustomVerificationMarkActive(r.Context(), req.VerifierBotID, req.Peer); err == nil {
body["mark_active"] = active
} else {
body["mark_active"] = false
body["mark_error"] = err.Error()
}
writeJSON(w, http.StatusOK, body)
}
// handleCustomVerificationCounts is the queue summary.
func (s *Server) handleCustomVerificationCounts(w http.ResponseWriter, r *http.Request) {
counts, err := s.svc.CustomVerificationRequestCounts(r.Context())
if err != nil {
writeBotVerificationError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"counts": customVerificationCountsResponse(counts)})
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
func (s *Server) handleGrantBotVerifier(w http.ResponseWriter, r *http.Request) {
var req admin.GrantBotVerifierRequest
if !decodeJSON(w, r, &req) {
return
}
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.GrantBotVerifier(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
func (s *Server) handleSetBotVerifierEnabled(w http.ResponseWriter, r *http.Request) {
var req admin.SetBotVerifierEnabledRequest
if !decodeJSON(w, r, &req) {
return
}
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.SetBotVerifierEnabled(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
func (s *Server) handleRevokeBotVerifier(w http.ResponseWriter, r *http.Request) {
var req admin.RevokeBotVerifierRequest
if !decodeJSON(w, r, &req) {
return
}
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.RevokeBotVerifier(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
func (s *Server) handleUpsertVerificationIcon(w http.ResponseWriter, r *http.Request) {
var req admin.UpsertVerificationIconRequest
if !decodeJSON(w, r, &req) {
return
}
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.UpsertVerificationIcon(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
func (s *Server) handleSetVerificationIconActive(w http.ResponseWriter, r *http.Request) {
var req admin.SetVerificationIconActiveRequest
if !decodeJSON(w, r, &req) {
return
}
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.SetVerificationIconActive(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
func (s *Server) handleRevokeCustomVerification(w http.ResponseWriter, r *http.Request) {
var req admin.RevokeCustomVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.RevokeCustomVerification(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
func (s *Server) handleApproveBotVerification(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var req admin.ApproveBotVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
// The path is the authority on which application is decided: a body naming a
// different one would make the URL lie to the audit trail.
req.RequestID = id
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.ApproveBotVerification(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
func (s *Server) handleRejectBotVerification(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var req admin.RejectBotVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
req.RequestID = id
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.RejectBotVerification(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
func (s *Server) handleRevokeBotVerification(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var req admin.RevokeBotVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
req.RequestID = id
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.RevokeBotVerification(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
// botVerifierResponse renders one verifier bot. The keys are the panel's row
// field names, so the same shape reaches the browser whether it came from here or
// from the panel's direct read.
func botVerifierResponse(settings domain.BotVerifierSettings) map[string]any {
out := map[string]any{
"BotID": strconv.FormatInt(settings.BotID, 10),
"IconDocumentID": strconv.FormatInt(settings.IconDocumentID, 10),
"CompanyName": settings.CompanyName,
"DefaultDescription": settings.DefaultDescription,
"CanModifyCustomDescription": settings.CanModifyCustomDescription,
"Enabled": settings.Enabled,
"GrantedBy": settings.GrantedBy,
"GrantReason": settings.GrantReason,
"Version": strconv.FormatInt(settings.Version, 10),
}
if !settings.CreatedAt.IsZero() {
out["CreatedAt"] = settings.CreatedAt.UTC().Format(time.RFC3339)
}
if !settings.UpdatedAt.IsZero() {
out["UpdatedAt"] = settings.UpdatedAt.UTC().Format(time.RFC3339)
}
return out
}
func verificationIconResponse(icon domain.VerificationIcon) map[string]any {
out := map[string]any{
"ID": strconv.FormatInt(icon.ID, 10),
"DocumentID": strconv.FormatInt(icon.DocumentID, 10),
"OwnerBotID": strconv.FormatInt(icon.OwnerBotID, 10),
"Name": icon.Name,
"Active": icon.Active,
}
if !icon.CreatedAt.IsZero() {
out["CreatedAt"] = icon.CreatedAt.UTC().Format(time.RFC3339)
}
if !icon.UpdatedAt.IsZero() {
out["UpdatedAt"] = icon.UpdatedAt.UTC().Format(time.RFC3339)
}
return out
}
func customVerificationResponse(mark domain.CustomVerification) map[string]any {
out := map[string]any{
"ID": strconv.FormatInt(mark.ID, 10),
"VerifierBotID": strconv.FormatInt(mark.VerifierBotID, 10),
"PeerType": string(mark.Peer.Type),
"PeerID": strconv.FormatInt(mark.Peer.ID, 10),
"IconDocumentID": strconv.FormatInt(mark.IconDocumentID, 10),
"Description": mark.Description,
"Version": strconv.FormatInt(mark.Version, 10),
}
if !mark.CreatedAt.IsZero() {
out["CreatedAt"] = mark.CreatedAt.UTC().Format(time.RFC3339)
}
if !mark.UpdatedAt.IsZero() {
out["UpdatedAt"] = mark.UpdatedAt.UTC().Format(time.RFC3339)
}
return out
}
func customVerificationRequestResponse(req domain.CustomVerificationRequest) map[string]any {
out := map[string]any{
"ID": strconv.FormatInt(req.ID, 10),
"VerifierBotID": strconv.FormatInt(req.VerifierBotID, 10),
"ApplicantUserID": strconv.FormatInt(req.ApplicantUserID, 10),
"PeerType": string(req.Peer.Type),
"PeerID": strconv.FormatInt(req.Peer.ID, 10),
"PeerTitle": req.PeerTitle,
"PeerUsername": req.PeerUsername,
"Reason": req.Reason,
"RequestedDescription": req.RequestedDescription,
"Status": string(req.Status),
"DecidedBy": req.DecidedBy,
"DecisionReason": req.DecisionReason,
// InternalNote is operator-only. It is exposed here because every caller of
// this route already holds botverification.review, and it is the reviewer's
// own handover note; it is never part of the applicant-facing projection.
"InternalNote": req.InternalNote,
"CorrelationID": req.CorrelationID,
"Version": strconv.FormatInt(req.Version, 10),
}
if !req.CreatedAt.IsZero() {
out["CreatedAt"] = req.CreatedAt.UTC().Format(time.RFC3339)
}
if !req.UpdatedAt.IsZero() {
out["UpdatedAt"] = req.UpdatedAt.UTC().Format(time.RFC3339)
}
if !req.ApprovedAt.IsZero() {
out["ApprovedAt"] = req.ApprovedAt.UTC().Format(time.RFC3339)
}
if !req.RejectedAt.IsZero() {
out["RejectedAt"] = req.RejectedAt.UTC().Format(time.RFC3339)
}
return out
}
// customVerificationCountsResponse renders the queue summary with every modelled
// status present, so the panel never has to distinguish "zero" from "absent". The
// values are decimal strings for the same exactness reason as the ids.
func customVerificationCountsResponse(counts map[domain.CustomVerificationRequestStatus]int64) map[string]string {
out := make(map[string]string, len(customVerificationStatusOrder))
for _, status := range customVerificationStatusOrder {
out[string(status)] = strconv.FormatInt(counts[status], 10)
}
for status, count := range counts {
if _, ok := out[string(status)]; !ok {
out[string(status)] = strconv.FormatInt(count, 10)
}
}
return out
}
// customVerificationStatusOrder is the closed status set, in lifecycle order.
var customVerificationStatusOrder = []domain.CustomVerificationRequestStatus{
domain.CustomVerificationPending,
domain.CustomVerificationApproved,
domain.CustomVerificationRejected,
domain.CustomVerificationRevoked,
}
// botVerificationPeerType validates the peer filter against the peer kinds a
// third-party mark can sit on. An unmodelled value is a 400 rather than an empty
// result, so a typo is reported instead of silently returning nothing.
func botVerificationPeerType(w http.ResponseWriter, raw string) (domain.PeerType, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", true
}
peerType := domain.PeerType(raw)
if peerType != domain.PeerTypeUser && peerType != domain.PeerTypeChannel {
writeCodedError(w, http.StatusBadRequest, admin.CodeCustomVerificationTargetInvalid, "invalid peer_type")
return "", false
}
return peerType, true
}
// queryBool reads a boolean flag the way the panel writes it: an absent or empty
// value is false, and "1"/"true"/"yes" are true.
func queryBool(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// botVerificationErrorStatus maps a third-party verification failure onto its HTTP
// status.
//
// The version conflict is 409, not 400, because nothing about the request was
// wrong -- another operator simply decided first, and the panel has to answer that
// by reloading rather than by correcting input. The per-verifier bound is 409 for
// the same reason: the request was well formed and the state refused it.
func botVerificationErrorStatus(code string) int {
switch code {
case admin.CodeBotVerifierNotFound,
admin.CodeBotVerifierBotNotFound,
admin.CodeVerificationIconNotFound,
admin.CodeCustomVerificationNotFound,
admin.CodeCustomVerificationRequestNotFound:
return http.StatusNotFound
case admin.CodeCustomVerificationConflict,
admin.CodeCustomVerificationLimit,
admin.CodeCustomVerificationRequestExists:
return http.StatusConflict
case admin.CodeCustomVerificationRateLimited:
return http.StatusTooManyRequests
case admin.CodeBotVerifierForbidden,
admin.CodeBotVerifierDescriptionForbidden,
admin.CodeBotVerifierInvalid,
admin.CodeVerificationIconInactive,
admin.CodeVerificationIconInvalid,
admin.CodeCustomVerificationStatusInvalid,
admin.CodeCustomVerificationReasonRequired,
admin.CodeCustomVerificationTargetInvalid,
admin.CodeCustomVerificationTargetSystem,
admin.CodeCustomVerificationInvalid:
// BOTVERIFIER_FORBIDDEN is 400 rather than 403 on purpose: the caller is
// authorised (403 is reserved for the permission gate), it is the *subject*
// that may not verify, which the operator fixes by enabling the verifier.
return http.StatusBadRequest
default:
return http.StatusInternalServerError
}
}
func writeBotVerificationError(w http.ResponseWriter, err error) {
code := admin.BotVerificationErrorCode(err)
writeCodedError(w, botVerificationErrorStatus(code), code, err.Error())
}
// writeBotVerificationCommandResult answers a command.
//
// The body stays a CommandResult so the panel parses one shape for every operator
// action, but the status is derived from the failure: a lost optimistic-locking
// race must reach the browser as 409, because that is the one failure the panel
// resolves by reloading the row instead of by asking the operator to fix the form.
func writeBotVerificationCommandResult(w http.ResponseWriter, result admin.CommandResult, err error) {
if err == nil {
writeJSON(w, http.StatusOK, result)
return
}
code := admin.BotVerificationErrorCode(err)
status := botVerificationErrorStatus(code)
if status == http.StatusInternalServerError {
// An unmapped command failure is a bad request, as everywhere else in this
// API, rather than a server fault.
status = http.StatusBadRequest
}
if result.CommandID == "" {
result = admin.CommandResult{Status: "failed", Message: "command failed", Error: err.Error()}
}
if result.Error == "" {
result.Error = err.Error()
}
if code == admin.CodeCustomVerificationConflict {
result.Message = "another operator changed this row first; reload it and try again"
}
writeJSON(w, status, result)
}

View file

@ -0,0 +1,826 @@
package adminapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
// fakeService gains the third-party verification surface here so the shared fake
// keeps satisfying Service without touching the existing test files.
func (fakeService) GrantBotVerifier(_ context.Context, req admin.GrantBotVerifierRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetBotVerifierEnabled(_ context.Context, req admin.SetBotVerifierEnabledRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeBotVerifier(_ context.Context, req admin.RevokeBotVerifierRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) UpsertVerificationIcon(_ context.Context, req admin.UpsertVerificationIconRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetVerificationIconActive(_ context.Context, req admin.SetVerificationIconActiveRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeCustomVerification(_ context.Context, req admin.RevokeCustomVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) ApproveBotVerification(_ context.Context, req admin.ApproveBotVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RejectBotVerification(_ context.Context, req admin.RejectBotVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeBotVerification(_ context.Context, req admin.RevokeBotVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) BotVerifiers(context.Context, bool, int) ([]domain.BotVerifierSettings, error) {
return nil, nil
}
func (fakeService) BotVerifier(context.Context, int64) (domain.BotVerifierSettings, error) {
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
}
func (fakeService) VerificationIcons(context.Context, bool, int) ([]domain.VerificationIcon, error) {
return nil, nil
}
func (fakeService) CustomVerifications(context.Context, domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
return nil, nil
}
func (fakeService) CustomVerificationRequests(context.Context, domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
return nil, nil
}
func (fakeService) CustomVerificationRequest(context.Context, int64) (domain.CustomVerificationRequest, error) {
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
}
func (fakeService) CustomVerificationRequestCounts(context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
return nil, nil
}
func (fakeService) CustomVerificationMarkActive(context.Context, int64, domain.Peer) (bool, error) {
return false, nil
}
type captureBotVerificationService struct {
fakeService
verifier domain.BotVerifierSettings
hasRow bool
icons []domain.VerificationIcon
marks []domain.CustomVerification
request domain.CustomVerificationRequest
counts map[domain.CustomVerificationRequestStatus]int64
markActive bool
iconFilterActiveOnly bool
verifierFilterEnabled bool
verifierFilterLimit int
markFilter domain.CustomVerificationFilter
requestFilter domain.CustomVerificationRequestFilter
grant admin.GrantBotVerifierRequest
setEnabled admin.SetBotVerifierEnabledRequest
revokeVerifier admin.RevokeBotVerifierRequest
upsertIcon admin.UpsertVerificationIconRequest
setIconActive admin.SetVerificationIconActiveRequest
revokeMark admin.RevokeCustomVerificationRequest
approve admin.ApproveBotVerificationRequest
reject admin.RejectBotVerificationRequest
revokeRequest admin.RevokeBotVerificationRequest
commandErr error
}
func (s *captureBotVerificationService) BotVerifiers(_ context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error) {
s.verifierFilterEnabled = enabledOnly
s.verifierFilterLimit = limit
if !s.hasRow {
return nil, nil
}
return []domain.BotVerifierSettings{s.verifier}, nil
}
func (s *captureBotVerificationService) BotVerifier(_ context.Context, botID int64) (domain.BotVerifierSettings, error) {
if !s.hasRow || s.verifier.BotID != botID {
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
}
return s.verifier, nil
}
func (s *captureBotVerificationService) VerificationIcons(_ context.Context, activeOnly bool, _ int) ([]domain.VerificationIcon, error) {
s.iconFilterActiveOnly = activeOnly
return s.icons, nil
}
func (s *captureBotVerificationService) CustomVerifications(_ context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
s.markFilter = filter
return s.marks, nil
}
func (s *captureBotVerificationService) CustomVerificationRequests(_ context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
s.requestFilter = filter
return []domain.CustomVerificationRequest{s.request}, nil
}
func (s *captureBotVerificationService) CustomVerificationRequest(_ context.Context, requestID int64) (domain.CustomVerificationRequest, error) {
if s.request.ID != requestID {
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
}
return s.request, nil
}
func (s *captureBotVerificationService) CustomVerificationRequestCounts(context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
return s.counts, nil
}
func (s *captureBotVerificationService) CustomVerificationMarkActive(context.Context, int64, domain.Peer) (bool, error) {
return s.markActive, nil
}
func (s *captureBotVerificationService) commandResult(commandID string, dryRun bool) (admin.CommandResult, error) {
if s.commandErr != nil {
return admin.CommandResult{CommandID: commandID, Status: "failed", Error: s.commandErr.Error()}, s.commandErr
}
return admin.CommandResult{CommandID: commandID, Status: "completed", DryRun: dryRun}, nil
}
func (s *captureBotVerificationService) GrantBotVerifier(_ context.Context, req admin.GrantBotVerifierRequest) (admin.CommandResult, error) {
s.grant = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) SetBotVerifierEnabled(_ context.Context, req admin.SetBotVerifierEnabledRequest) (admin.CommandResult, error) {
s.setEnabled = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) RevokeBotVerifier(_ context.Context, req admin.RevokeBotVerifierRequest) (admin.CommandResult, error) {
s.revokeVerifier = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) UpsertVerificationIcon(_ context.Context, req admin.UpsertVerificationIconRequest) (admin.CommandResult, error) {
s.upsertIcon = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) SetVerificationIconActive(_ context.Context, req admin.SetVerificationIconActiveRequest) (admin.CommandResult, error) {
s.setIconActive = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) RevokeCustomVerification(_ context.Context, req admin.RevokeCustomVerificationRequest) (admin.CommandResult, error) {
s.revokeMark = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) ApproveBotVerification(_ context.Context, req admin.ApproveBotVerificationRequest) (admin.CommandResult, error) {
s.approve = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) RejectBotVerification(_ context.Context, req admin.RejectBotVerificationRequest) (admin.CommandResult, error) {
s.reject = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) RevokeBotVerification(_ context.Context, req admin.RevokeBotVerificationRequest) (admin.CommandResult, error) {
s.revokeRequest = req
return s.commandResult(req.CommandID, req.DryRun)
}
// botVerificationServer is the deployment shape the permission model exists for:
// one master token plus bounded tokens that can review, manage, or neither.
func botVerificationServer(svc Service) *Server {
return &Server{
token: "master",
scoped: []ScopedToken{
{Name: "queue-bot", Token: "scoped-review", Permissions: []string{PermissionBotVerificationReview}},
{Name: "trust-and-safety", Token: "scoped-manage", Permissions: []string{PermissionBotVerificationManage}},
{Name: "both", Token: "scoped-both", Permissions: []string{
PermissionBotVerificationReview, PermissionBotVerificationManage,
}},
// A token for the *official* review surface: it must not reach this one.
{Name: "official-review", Token: "scoped-official", Permissions: []string{PermissionVerificationReview}},
},
svc: svc,
}
}
// botVerificationRoute is one route with a body the handler accepts, so an
// authorisation test cannot pass by accident on a malformed payload.
type botVerificationRoute struct {
method string
path string
body string
}
const decisionBody = `{"command_id":"c1","actor":"ops","reason":"decided","version":2}`
var botVerificationReadRoutes = []botVerificationRoute{
{http.MethodGet, "/v1/botverification/verifiers", ""},
{http.MethodGet, "/v1/botverification/icons", ""},
{http.MethodGet, "/v1/botverification/marks", ""},
{http.MethodGet, "/v1/botverification/requests", ""},
{http.MethodGet, "/v1/botverification/requests/7", ""},
{http.MethodGet, "/v1/botverification/counts", ""},
{http.MethodPost, "/v1/botverification/requests/7/approve", decisionBody},
{http.MethodPost, "/v1/botverification/requests/7/reject", decisionBody},
{http.MethodPost, "/v1/botverification/requests/7/revoke", decisionBody},
}
var botVerificationManageRoutes = []botVerificationRoute{
{http.MethodPost, "/v1/botverification/verifiers/grant",
`{"command_id":"c1","actor":"ops","reason":"partner","bot_id":3003,"icon_document_id":900,"company_name":"Example Trust","version":4}`},
{http.MethodPost, "/v1/botverification/verifiers/set-enabled",
`{"command_id":"c1","actor":"ops","reason":"abuse","bot_id":3003,"enabled":false}`},
{http.MethodPost, "/v1/botverification/verifiers/revoke",
`{"command_id":"c1","actor":"ops","reason":"programme ended","bot_id":3003}`},
{http.MethodPost, "/v1/botverification/icons/upsert",
`{"command_id":"c1","actor":"ops","reason":"new icon","document_id":900,"name":"blue check"}`},
{http.MethodPost, "/v1/botverification/icons/set-active",
`{"command_id":"c1","actor":"ops","reason":"retired","icon_id":501,"active":false}`},
{http.MethodPost, "/v1/botverification/marks/revoke",
`{"command_id":"c1","actor":"ops","reason":"impersonation","verifier_bot_id":3003,"peer_type":"channel","peer_id":5005}`},
}
// botVerificationRoutes is every route in the section.
func botVerificationRoutes() []botVerificationRoute {
out := make([]botVerificationRoute, 0, len(botVerificationReadRoutes)+len(botVerificationManageRoutes))
out = append(out, botVerificationReadRoutes...)
return append(out, botVerificationManageRoutes...)
}
func TestBotVerificationRoutesRejectMissingAndUnknownTokens(t *testing.T) {
srv := botVerificationServer(fakeService{})
for _, item := range botVerificationRoutes() {
for _, token := range []string{"", "not-a-configured-token"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s %s token=%q status=%d, want 401", item.method, item.path, token, rec.Code)
}
}
}
}
func TestBotVerificationRoutesRefuseScopedTokenWithoutThePermission(t *testing.T) {
srv := botVerificationServer(fakeService{})
// The official-verification token is the interesting negative: the two
// mechanisms are separate, so verification.review must not open this surface.
for _, token := range []string{"scoped-official", "scoped-manage"} {
for _, item := range botVerificationReadRoutes {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
if rec.Code != http.StatusForbidden {
t.Fatalf("%s %s token=%q status=%d body=%s, want 403", item.method, item.path, token, rec.Code, rec.Body.String())
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode 403 body: %v", err)
}
if body["code"] != CodeForbidden || body["permission"] != PermissionBotVerificationReview {
t.Fatalf("403 body=%+v, want botverification.review named", body)
}
}
}
// And the review right alone does not reach the configuration half.
for _, token := range []string{"scoped-official", "scoped-review"} {
for _, item := range botVerificationManageRoutes {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
if rec.Code != http.StatusForbidden {
t.Fatalf("%s %s token=%q status=%d body=%s, want 403", item.method, item.path, token, rec.Code, rec.Body.String())
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode 403 body: %v", err)
}
if body["permission"] != PermissionBotVerificationManage {
t.Fatalf("403 body=%+v, want botverification.manage named", body)
}
}
}
}
// A token holding the third-party rights must not reach the official queue either:
// the separation is symmetric.
func TestBotVerificationTokenCannotReachTheOfficialVerificationSurface(t *testing.T) {
srv := botVerificationServer(fakeService{})
for _, path := range []string{"/v1/verification/applications", "/v1/verification/counts"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, path, "scoped-both", ""))
if rec.Code != http.StatusForbidden {
t.Fatalf("%s status=%d body=%s, want 403", path, rec.Code, rec.Body.String())
}
}
// Nor the legacy surface that predates permissions.
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/accounts/set-verified", "scoped-both",
`{"command_id":"c1","actor":"ops","reason":"x","user_id":1001,"verified":true}`))
if rec.Code != http.StatusForbidden {
t.Fatalf("legacy surface status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
}
func TestBotVerificationScopedTokensReachTheirOwnHalf(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 7, Version: 2}}
srv := botVerificationServer(svc)
for _, item := range botVerificationReadRoutes {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "scoped-review", item.body))
if rec.Code != http.StatusOK {
t.Fatalf("%s %s status=%d body=%s", item.method, item.path, rec.Code, rec.Body.String())
}
}
for _, item := range botVerificationManageRoutes {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "scoped-manage", item.body))
if rec.Code != http.StatusOK {
t.Fatalf("%s %s status=%d body=%s", item.method, item.path, rec.Code, rec.Body.String())
}
}
}
func TestMasterTokenReachesTheBotVerificationSurface(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 7, Version: 2}}
srv := botVerificationServer(svc)
for _, item := range botVerificationRoutes() {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "master", item.body))
if rec.Code != http.StatusOK {
t.Fatalf("master on %s %s status=%d body=%s", item.method, item.path, rec.Code, rec.Body.String())
}
}
}
func TestBotVerifierListRendersInt64AsDecimalStrings(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
svc := &captureBotVerificationService{
hasRow: true,
verifier: domain.BotVerifierSettings{
BotID: maxInt64,
IconDocumentID: maxInt64,
CompanyName: "Example Trust",
DefaultDescription: "verified by Example Trust",
CanModifyCustomDescription: true,
Enabled: true,
GrantedBy: "alice",
GrantReason: "partner programme",
Version: maxInt64,
CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
UpdatedAt: time.Unix(1_700_000_000, 0).UTC(),
},
}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
"/v1/botverification/verifiers?enabled_only=1&limit=25", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !svc.verifierFilterEnabled || svc.verifierFilterLimit != 25 {
t.Fatalf("enabledOnly=%v limit=%d, want the query honoured", svc.verifierFilterEnabled, svc.verifierFilterLimit)
}
var body struct {
Rows []map[string]any `json:"rows"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode verifiers: %v", err)
}
if len(body.Rows) != 1 {
t.Fatalf("rows=%+v", body.Rows)
}
for _, field := range []string{"BotID", "IconDocumentID", "Version"} {
if body.Rows[0][field] != "9223372036854775807" {
t.Fatalf("%s = %#v, want an exact decimal string", field, body.Rows[0][field])
}
}
if body.Rows[0]["CanModifyCustomDescription"] != true || body.Rows[0]["Enabled"] != true {
t.Fatalf("row=%+v, want the booleans as booleans", body.Rows[0])
}
}
func TestVerificationIconAndMarkListingsRenderInt64AsDecimalStrings(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
svc := &captureBotVerificationService{
icons: []domain.VerificationIcon{{
ID: maxInt64, DocumentID: maxInt64, OwnerBotID: maxInt64, Name: "blue check", Active: true,
CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
}},
marks: []domain.CustomVerification{{
ID: maxInt64, VerifierBotID: maxInt64,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: maxInt64},
IconDocumentID: maxInt64, Description: "verified partner", Version: maxInt64,
}},
}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
"/v1/botverification/icons?active_only=true", "scoped-review", ""))
if rec.Code != http.StatusOK || !svc.iconFilterActiveOnly {
t.Fatalf("icons status=%d activeOnly=%v body=%s", rec.Code, svc.iconFilterActiveOnly, rec.Body.String())
}
var icons struct {
Rows []map[string]any `json:"rows"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &icons); err != nil {
t.Fatalf("decode icons: %v", err)
}
for _, field := range []string{"ID", "DocumentID", "OwnerBotID"} {
if icons.Rows[0][field] != "9223372036854775807" {
t.Fatalf("icon %s = %#v", field, icons.Rows[0][field])
}
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
"/v1/botverification/marks?verifier_bot_id=9223372036854775807&peer_type=channel&q=news&limit=1&before_id=99",
"scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("marks status=%d body=%s", rec.Code, rec.Body.String())
}
if svc.markFilter.VerifierBotID != maxInt64 || svc.markFilter.PeerType != domain.PeerTypeChannel ||
svc.markFilter.Query != "news" || svc.markFilter.Limit != 1 || svc.markFilter.BeforeID != 99 {
t.Fatalf("mark filter=%+v", svc.markFilter)
}
var marks struct {
Rows []map[string]any `json:"rows"`
HasMore bool `json:"has_more"`
NextBeforeID string `json:"next_before_id"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &marks); err != nil {
t.Fatalf("decode marks: %v", err)
}
for _, field := range []string{"ID", "VerifierBotID", "PeerID", "IconDocumentID", "Version"} {
if marks.Rows[0][field] != "9223372036854775807" {
t.Fatalf("mark %s = %#v", field, marks.Rows[0][field])
}
}
// A full page reports more, and the cursor is the last id as a decimal string.
if !marks.HasMore || marks.NextBeforeID != "9223372036854775807" {
t.Fatalf("paging hasMore=%v next=%q", marks.HasMore, marks.NextBeforeID)
}
}
func TestBotVerificationQueueFilterAndUnmodelledValues(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{
ID: 88, VerifierBotID: 3003, ApplicantUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 5005},
Status: domain.CustomVerificationPending, Version: 3,
}}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
"/v1/botverification/requests?status=pending,approved&verifier_bot_id=3003&peer_type=channel&q=news&limit=25&before_id=99",
"scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if len(svc.requestFilter.Statuses) != 2 ||
svc.requestFilter.Statuses[0] != domain.CustomVerificationPending ||
svc.requestFilter.Statuses[1] != domain.CustomVerificationApproved ||
svc.requestFilter.VerifierBotID != 3003 || svc.requestFilter.PeerType != domain.PeerTypeChannel ||
svc.requestFilter.Query != "news" || svc.requestFilter.Limit != 25 || svc.requestFilter.BeforeID != 99 {
t.Fatalf("filter=%+v", svc.requestFilter)
}
// An unmodelled status or peer type is a 400 rather than an empty result, so a
// typo is reported instead of silently returning nothing.
for _, query := range []string{"?status=in_review", "?peer_type=chat", "?verifier_bot_id=abc", "?before_id=-1"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests"+query, "scoped-review", ""))
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s status=%d body=%s, want 400", query, rec.Code, rec.Body.String())
}
}
for _, query := range []string{"?peer_type=chat"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/marks"+query, "scoped-review", ""))
if rec.Code != http.StatusBadRequest {
t.Fatalf("marks %s status=%d, want 400", query, rec.Code)
}
}
}
func TestBotVerificationRequestDetailAndCounts(t *testing.T) {
svc := &captureBotVerificationService{
hasRow: true,
verifier: domain.BotVerifierSettings{
BotID: 3003, IconDocumentID: 900, CompanyName: "Example Trust", Enabled: true, Version: 4,
},
request: domain.CustomVerificationRequest{
ID: 88, VerifierBotID: 3003, ApplicantUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 5005},
PeerTitle: "Example News",
PeerUsername: "examplenews",
InternalNote: "operator only",
Status: domain.CustomVerificationApproved, Version: 5,
ApprovedAt: time.Unix(1_700_000_000, 0).UTC(),
},
markActive: true,
counts: map[domain.CustomVerificationRequestStatus]int64{domain.CustomVerificationPending: 3, domain.CustomVerificationApproved: 1},
}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests/88", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("detail status=%d body=%s", rec.Code, rec.Body.String())
}
var detail struct {
Request map[string]any `json:"request"`
Verifier map[string]any `json:"verifier"`
MarkActive bool `json:"mark_active"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &detail); err != nil {
t.Fatalf("decode detail: %v", err)
}
if detail.Request["ID"] != "88" || detail.Request["PeerID"] != "5005" || detail.Request["Version"] != "5" ||
detail.Request["InternalNote"] != "operator only" || detail.Request["ApprovedAt"] == nil {
t.Fatalf("request=%+v", detail.Request)
}
if detail.Verifier["BotID"] != "3003" || detail.Verifier["CompanyName"] != "Example Trust" || !detail.MarkActive {
t.Fatalf("verifier=%+v markActive=%v", detail.Verifier, detail.MarkActive)
}
// A verifier revoked since the application was filed must not turn the audit
// record into a 500: the row is reported with only its id.
svc.hasRow = false
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests/88", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("detail without a verifier status=%d body=%s", rec.Code, rec.Body.String())
}
if err := json.Unmarshal(rec.Body.Bytes(), &detail); err != nil {
t.Fatalf("decode detail: %v", err)
}
if detail.Verifier["BotID"] != "3003" || detail.Verifier["Enabled"] != false {
t.Fatalf("verifier=%+v, want the bot named and no status claimed", detail.Verifier)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests/89", "scoped-review", ""))
if rec.Code != http.StatusNotFound {
t.Fatalf("missing application status=%d body=%s, want 404", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/counts", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("counts status=%d body=%s", rec.Code, rec.Body.String())
}
var counts struct {
Counts map[string]string `json:"counts"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &counts); err != nil {
t.Fatalf("decode counts: %v", err)
}
// Every modelled status is present so the panel never tells "zero" from
// "absent", and the values are decimal strings.
if counts.Counts["pending"] != "3" || counts.Counts["approved"] != "1" ||
counts.Counts["rejected"] != "0" || counts.Counts["revoked"] != "0" || len(counts.Counts) != 4 {
t.Fatalf("counts=%+v", counts.Counts)
}
}
func TestBotVerificationDecisionTakesTheRequestIDFromThePath(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 88, Version: 3}}
srv := botVerificationServer(svc)
// The body names a different application on purpose: the path has to win, or
// the URL would lie to the audit trail.
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
`{"command_id":"c1","actor":"alice","reason":"verified","request_id":99,"version":3,"internal_note":"handover"}`))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if svc.approve.RequestID != 88 || svc.approve.Version != 3 ||
svc.approve.InternalNote != "handover" || svc.approve.Actor != "alice" {
t.Fatalf("forwarded approval=%+v", svc.approve)
}
}
func TestBotVerificationDryRunIsForwardedAndEchoed(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 88, Version: 3}}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/reject", "scoped-review",
`{"command_id":"dry-1","actor":"alice","reason":"not an outlet","dry_run":true,"version":3}`))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !svc.reject.DryRun || svc.reject.Reason != "not an outlet" {
t.Fatalf("forwarded rejection=%+v", svc.reject)
}
if !strings.Contains(rec.Body.String(), `"dry_run":true`) {
t.Fatalf("body=%s, want the dry run echoed", rec.Body.String())
}
// Also on the manage half: appointing a verifier is rehearsable too.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/grant", "scoped-manage",
`{"command_id":"dry-2","actor":"alice","reason":"partner","dry_run":true,
"bot_id":3003,"icon_document_id":900,"company_name":"Example Trust","version":4}`))
if rec.Code != http.StatusOK {
t.Fatalf("grant status=%d body=%s", rec.Code, rec.Body.String())
}
if !svc.grant.DryRun || svc.grant.BotID != 3003 || svc.grant.IconDocumentID != 900 || svc.grant.Version != 4 {
t.Fatalf("forwarded grant=%+v, want the exact int64s from decimal strings", svc.grant)
}
}
func TestBotVerificationVersionConflictIsAnswered409(t *testing.T) {
svc := &captureBotVerificationService{
request: domain.CustomVerificationRequest{ID: 88, Version: 5},
// The shape admin.codedError produces for a lost race.
commandErr: fmt.Errorf("%s: %w", admin.CodeCustomVerificationConflict, domain.ErrCustomVerificationVersionConflict),
}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
`{"command_id":"c1","actor":"alice","reason":"verified","version":4}`))
if rec.Code != http.StatusConflict {
t.Fatalf("status=%d body=%s, want 409 for a lost optimistic-locking race", rec.Code, rec.Body.String())
}
var result admin.CommandResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode conflict: %v", err)
}
if !strings.Contains(result.Error, admin.CodeCustomVerificationConflict) {
t.Fatalf("result=%+v, want the stable conflict code", result)
}
if !strings.Contains(result.Message, "reload") {
t.Fatalf("result message=%q, want an actionable message", result.Message)
}
// The same on the manage half, where two operators can race a verifier row.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/grant", "scoped-manage",
`{"command_id":"c2","actor":"alice","reason":"partner","bot_id":3003,"icon_document_id":900,"company_name":"x","version":3}`))
if rec.Code != http.StatusConflict {
t.Fatalf("grant conflict status=%d body=%s, want 409", rec.Code, rec.Body.String())
}
}
func TestBotVerificationErrorStatusMapping(t *testing.T) {
cases := map[string]int{
admin.CodeBotVerifierNotFound: http.StatusNotFound,
admin.CodeBotVerifierBotNotFound: http.StatusNotFound,
admin.CodeVerificationIconNotFound: http.StatusNotFound,
admin.CodeCustomVerificationNotFound: http.StatusNotFound,
admin.CodeCustomVerificationRequestNotFound: http.StatusNotFound,
admin.CodeCustomVerificationConflict: http.StatusConflict,
admin.CodeCustomVerificationLimit: http.StatusConflict,
admin.CodeCustomVerificationRequestExists: http.StatusConflict,
admin.CodeCustomVerificationRateLimited: http.StatusTooManyRequests,
admin.CodeBotVerifierForbidden: http.StatusBadRequest,
admin.CodeBotVerifierInvalid: http.StatusBadRequest,
admin.CodeVerificationIconInactive: http.StatusBadRequest,
admin.CodeVerificationIconInvalid: http.StatusBadRequest,
admin.CodeCustomVerificationStatusInvalid: http.StatusBadRequest,
admin.CodeCustomVerificationReasonRequired: http.StatusBadRequest,
admin.CodeCustomVerificationTargetInvalid: http.StatusBadRequest,
admin.CodeCustomVerificationInvalid: http.StatusBadRequest,
"": http.StatusInternalServerError,
}
for code, want := range cases {
if got := botVerificationErrorStatus(code); got != want {
t.Fatalf("botVerificationErrorStatus(%q) = %d, want %d", code, got, want)
}
}
}
func TestBotVerificationActionsForwardTheirPayloads(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
svc := &captureBotVerificationService{}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/set-enabled", "scoped-manage",
`{"command_id":"c1","actor":"ops","reason":"abuse","bot_id":9223372036854775807,"enabled":false}`))
if rec.Code != http.StatusOK || svc.setEnabled.BotID != maxInt64 || svc.setEnabled.Enabled {
t.Fatalf("set-enabled status=%d req=%+v", rec.Code, svc.setEnabled)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/revoke", "scoped-manage",
`{"command_id":"c2","actor":"ops","reason":"programme ended","bot_id":3003}`))
if rec.Code != http.StatusOK || svc.revokeVerifier.BotID != 3003 {
t.Fatalf("revoke-verifier status=%d req=%+v", rec.Code, svc.revokeVerifier)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/icons/upsert", "scoped-manage",
`{"command_id":"c3","actor":"ops","reason":"new icon","document_id":9223372036854775807,"name":"blue check","owner_bot_id":3003}`))
if rec.Code != http.StatusOK || svc.upsertIcon.DocumentID != maxInt64 ||
svc.upsertIcon.Name != "blue check" || svc.upsertIcon.OwnerBotID != 3003 {
t.Fatalf("upsert-icon status=%d req=%+v", rec.Code, svc.upsertIcon)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/icons/set-active", "scoped-manage",
`{"command_id":"c4","actor":"ops","reason":"retired","icon_id":501,"active":false}`))
if rec.Code != http.StatusOK || svc.setIconActive.IconID != 501 || svc.setIconActive.Active {
t.Fatalf("set-icon-active status=%d req=%+v", rec.Code, svc.setIconActive)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/marks/revoke", "scoped-manage",
`{"command_id":"c5","actor":"ops","reason":"impersonation","verifier_bot_id":3003,"peer_type":"channel","peer_id":9223372036854775807}`))
if rec.Code != http.StatusOK || svc.revokeMark.VerifierBotID != 3003 ||
svc.revokeMark.PeerType != domain.PeerTypeChannel || svc.revokeMark.PeerID != maxInt64 {
t.Fatalf("revoke-mark status=%d req=%+v", rec.Code, svc.revokeMark)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/revoke", "scoped-review",
`{"command_id":"c6","actor":"ops","reason":"licence withdrawn","version":9223372036854775807}`))
if rec.Code != http.StatusOK || svc.revokeRequest.RequestID != 88 || svc.revokeRequest.Version != maxInt64 {
t.Fatalf("revoke-request status=%d req=%+v", rec.Code, svc.revokeRequest)
}
}
func TestBotVerificationScopedTokenNameBecomesTheAuditActor(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 88, Version: 3}}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
`{"command_id":"c1","reason":"queue sweep","version":3}`))
if rec.Code != http.StatusOK || svc.approve.Actor != "queue-bot" {
t.Fatalf("status=%d actor=%q, want the scoped token name", rec.Code, svc.approve.Actor)
}
// A stated actor is never overwritten, which is how the panel attributes an
// action to the signed-in operator.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
`{"command_id":"c2","actor":"alice","reason":"queue sweep","version":3}`))
if rec.Code != http.StatusOK || svc.approve.Actor != "alice" {
t.Fatalf("status=%d actor=%q", rec.Code, svc.approve.Actor)
}
// The master token has no name, so the caller keeps having to say who acts.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "master",
`{"command_id":"c3","reason":"queue sweep","version":3}`))
if rec.Code != http.StatusOK || svc.approve.Actor != "" {
t.Fatalf("master token status=%d actor=%q, want no invented identity", rec.Code, svc.approve.Actor)
}
}
func TestBotVerificationCommandsRejectUnknownFields(t *testing.T) {
srv := botVerificationServer(&captureBotVerificationService{})
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/grant", "scoped-manage",
`{"command_id":"c1","actor":"ops","reason":"x","bot_id":3003,"icon_document_id":900,"company_name":"y","enabled":true}`))
// enabled is not part of the grant payload: the kill switch is its own action,
// and a silently ignored field would hide that from the operator.
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "enabled") {
t.Fatalf("status=%d body=%s, want 400 naming the unknown field", rec.Code, rec.Body.String())
}
}
func TestBotVerificationPermissionNamesAreDistinctFromTheOfficialOnes(t *testing.T) {
// The permission model's whole point here: appointing verifiers is not implied
// by reviewing the official queue, in either direction.
bounded := newPermissionSet([]string{PermissionBotVerificationReview})
if !bounded.Has(PermissionBotVerificationReview) {
t.Fatal("bounded set dropped its own permission")
}
if bounded.Has(PermissionBotVerificationManage) || bounded.Has(PermissionVerificationReview) {
t.Fatalf("botverification.review leaked into another right")
}
manage := newPermissionSet([]string{PermissionBotVerificationManage})
if manage.Has(PermissionBotVerificationReview) {
t.Fatal("botverification.manage implied the review right")
}
all := newPermissionSet([]string{PermissionAll})
if !all.Has(PermissionBotVerificationReview) || !all.Has(PermissionBotVerificationManage) {
t.Fatal("the wildcard refused a third-party permission")
}
}

196
internal/adminapi/rbac.go Normal file
View file

@ -0,0 +1,196 @@
package adminapi
import (
"context"
"crypto/subtle"
"net/http"
"strings"
)
// Admin API authorisation.
//
// Every request arrives with a bearer token, and the token decides which
// permissions the request carries:
//
// - TELESRV_ADMIN_API_TOKEN is the master token and carries every permission.
// This is what keeps the existing surface working unchanged: all the routes
// that predate permissions stay mounted through authenticated(), which is
// defined as "requires every permission", so the master token reaches them
// exactly as before.
// - A scoped token from TELESRV_ADMIN_SCOPED_TOKENS carries only the
// permissions its entry lists. A scoped token is therefore *not* a weaker
// master token: it authenticates successfully and is then refused with 403 on
// anything outside its list, including every legacy route. Widening a scoped
// token to the legacy surface would be a silent privilege escalation, so the
// wildcard has to be spelled out in configuration to get it.
//
// The two-step answer matters for diagnosis: 401 means "I do not know this
// token", 403 means "I know you and you may not do this".
// Permission names. They are the same strings the operator writes into
// TELESRV_ADMIN_UI_PERMISSIONS / TELESRV_ADMIN_SCOPED_TOKENS.
const (
// PermissionAll is the wildcard: a principal carrying it passes every check.
PermissionAll = "*"
// PermissionVerificationReview guards the whole official-verification review
// surface: the queue, one application, the counters, and the claim/approve/
// reject decisions.
PermissionVerificationReview = "verification.review"
// PermissionVerificationRevoke is required *in addition* to
// PermissionVerificationReview to clear a badge that was already granted.
// Taking a badge away is visible to every client of a public peer, so it is
// deliberately not implied by the right to review new applications.
PermissionVerificationRevoke = "verification.revoke"
// PermissionBotVerificationReview guards the third-party verification read
// surface -- verifiers, icons, granted marks, the queue and its counters -- plus
// the decisions on the applications filed with a verifier bot.
//
// This is NOT verification.review. Third-party verification is a separate
// mechanism over separate tables (verification_icons, bot_verifier_settings,
// custom_verifications, custom_verification_requests), so a token trusted to
// work one queue is not thereby trusted with the other: neither permission
// implies the other.
PermissionBotVerificationReview = "botverification.review"
// PermissionBotVerificationManage guards the configuration half: granting,
// switching and revoking verifier status, the icon catalogue, and stripping a
// granted mark.
//
// It is separate from the review right because these are the actions that
// decide how much a third-party mark is worth. Handing out the queue is
// routine; handing out the ability to appoint verifiers is not.
PermissionBotVerificationManage = "botverification.manage"
)
// CodeForbidden is the stable code for a permission failure, so the panel can
// tell an authorisation refusal apart from a domain refusal.
const CodeForbidden = "FORBIDDEN"
// ScopedToken is one bearer token restricted to a permission set. It mirrors
// config.AdminScopedToken; the adminapi package keeps its own shape so it does
// not depend on the configuration loader.
type ScopedToken struct {
// Name is the audit identity of actions performed with this token.
Name string
Token string
Permissions []string
}
// permissionSet is a resolved permission list.
type permissionSet struct {
all bool
names map[string]struct{}
}
func newPermissionSet(permissions []string) permissionSet {
set := permissionSet{names: make(map[string]struct{}, len(permissions))}
for _, permission := range permissions {
permission = strings.TrimSpace(permission)
if permission == "" {
continue
}
if permission == PermissionAll {
set.all = true
continue
}
set.names[permission] = struct{}{}
}
return set
}
// Has reports whether the set grants the permission.
func (p permissionSet) Has(permission string) bool {
if p.all {
return true
}
_, ok := p.names[permission]
return ok
}
// principal is the authenticated caller.
type principal struct {
// name is the scoped token's audit identity, or "" for the master token,
// whose actions are attributed by the actor the caller states in the body.
name string
permissions permissionSet
}
type principalKey struct{}
// principalName returns the scoped-token identity behind the request, or "" when
// the request came in on the master token.
func principalName(ctx context.Context) string {
if p, ok := ctx.Value(principalKey{}).(principal); ok {
return p.name
}
return ""
}
// principalFor resolves the bearer token to a principal.
//
// Every configured token is compared, and every comparison is constant time and
// unconditional: returning as soon as one matches would leak, through timing,
// which token position a guess collided with.
func (s *Server) principalFor(r *http.Request) (principal, bool) {
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if got == "" {
return principal{}, false
}
matched := false
resolved := principal{}
if subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) == 1 && s.token != "" {
matched = true
resolved = principal{permissions: permissionSet{all: true}}
}
for _, scoped := range s.scoped {
if subtle.ConstantTimeCompare([]byte(got), []byte(scoped.Token)) == 1 && scoped.Token != "" && !matched {
matched = true
resolved = principal{name: scoped.Name, permissions: newPermissionSet(scoped.Permissions)}
}
}
return resolved, matched
}
// authenticated guards a route that requires unrestricted rights.
//
// This is every route that predates the permission model. Keeping them here is
// the documented behaviour: the master token carries every permission, so nothing
// about the existing surface changes, while a bounded scoped token cannot use one
// of them as a side door.
func (s *Server) authenticated(next http.HandlerFunc) http.HandlerFunc {
return s.authorized(PermissionAll, next)
}
// authorized guards a route behind one permission.
func (s *Server) authorized(permission string, next http.HandlerFunc) http.HandlerFunc {
return s.authorizedAll([]string{permission}, next)
}
// authorizedAll guards a route behind every listed permission. Revocation uses it
// to require the review right and the revoke right together, so the revoke right
// alone cannot be handed out as a way into the review surface.
func (s *Server) authorizedAll(permissions []string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
caller, ok := s.principalFor(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
for _, permission := range permissions {
if !caller.permissions.Has(permission) {
writeForbidden(w, permission)
return
}
}
next(w, r.WithContext(context.WithValue(r.Context(), principalKey{}, caller)))
}
}
// writeForbidden names the missing permission, so an operator configuring a
// scoped token is told what to add instead of having to guess.
func writeForbidden(w http.ResponseWriter, permission string) {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "permission " + permission + " is required",
"code": CodeForbidden,
"permission": permission,
})
}

View file

@ -2,12 +2,13 @@ package adminapi
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@ -22,6 +23,20 @@ import (
type Config struct {
Addr string
Token string
// ScopedTokens are additional bearer tokens with a bounded permission set
// each. Token stays the unrestricted master token, so a deployment that
// configures no scoped token behaves exactly as it did before.
//
// The shape mirrors config.AdminScopedToken without importing the loader --
// only the main packages depend on internal/config -- so the caller converts:
//
// scoped := make([]adminapi.ScopedToken, 0, len(cfg.AdminScopedTokens))
// for _, item := range cfg.AdminScopedTokens {
// scoped = append(scoped, adminapi.ScopedToken{
// Name: item.Name, Token: item.Token, Permissions: item.Permissions,
// })
// }
ScopedTokens []ScopedToken
}
type Service interface {
@ -64,6 +79,47 @@ type Service interface {
DecideModerationCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
SubmitModerationAppeal(ctx context.Context, caseID, appellantUserID int64, text string) (domain.ModerationAppeal, bool, error)
ReviewModerationAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
MintCollectibleUsername(ctx context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error)
TransferCollectibleUsername(ctx context.Context, req admin.TransferCollectibleUsernameRequest) (admin.CommandResult, error)
RevokeCollectibleUsername(ctx context.Context, req admin.RevokeCollectibleUsernameRequest) (admin.CommandResult, error)
DeleteCollectibleUsername(ctx context.Context, req admin.DeleteCollectibleUsernameRequest) (admin.CommandResult, error)
CollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
RecomputeAccountRating(ctx context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error)
AdjustAccountRating(ctx context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error)
AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error)
AccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error)
AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error)
ClaimVerification(ctx context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error)
ApproveVerification(ctx context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error)
RejectVerification(ctx context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error)
RevokeVerification(ctx context.Context, req admin.RevokeVerificationRequest) (admin.CommandResult, error)
VerificationApplications(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error)
VerificationApplication(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
VerificationApplicationEvents(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error)
VerificationCounts(ctx context.Context) (domain.VerificationStatusCounts, error)
VerificationTargetSnapshot(ctx context.Context, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationTarget, error)
// Third-party bot verification. A separate mechanism from the official
// verification methods above, over separate tables and separate permissions;
// see botverification.go.
GrantBotVerifier(ctx context.Context, req admin.GrantBotVerifierRequest) (admin.CommandResult, error)
SetBotVerifierEnabled(ctx context.Context, req admin.SetBotVerifierEnabledRequest) (admin.CommandResult, error)
RevokeBotVerifier(ctx context.Context, req admin.RevokeBotVerifierRequest) (admin.CommandResult, error)
UpsertVerificationIcon(ctx context.Context, req admin.UpsertVerificationIconRequest) (admin.CommandResult, error)
SetVerificationIconActive(ctx context.Context, req admin.SetVerificationIconActiveRequest) (admin.CommandResult, error)
RevokeCustomVerification(ctx context.Context, req admin.RevokeCustomVerificationRequest) (admin.CommandResult, error)
ApproveBotVerification(ctx context.Context, req admin.ApproveBotVerificationRequest) (admin.CommandResult, error)
RejectBotVerification(ctx context.Context, req admin.RejectBotVerificationRequest) (admin.CommandResult, error)
RevokeBotVerification(ctx context.Context, req admin.RevokeBotVerificationRequest) (admin.CommandResult, error)
BotVerifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error)
BotVerifier(ctx context.Context, botID int64) (domain.BotVerifierSettings, error)
VerificationIcons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error)
CustomVerifications(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error)
CustomVerificationRequests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error)
CustomVerificationRequest(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error)
CustomVerificationRequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error)
CustomVerificationMarkActive(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error)
}
func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http.Server, error) {
@ -80,7 +136,7 @@ func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http
if log == nil {
log = zap.NewNop()
}
server := &Server{token: cfg.Token, svc: svc, log: log}
server := &Server{token: cfg.Token, scoped: cfg.ScopedTokens, svc: svc, log: log}
httpServer := &http.Server{
Addr: cfg.Addr,
Handler: server.routes(),
@ -102,9 +158,10 @@ func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http
}
type Server struct {
token string
svc Service
log *zap.Logger
token string
scoped []ScopedToken
svc Service
log *zap.Logger
}
func (s *Server) routes() http.Handler {
@ -151,20 +208,50 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/moderation/cases/{id}/decide", s.authenticated(s.handleDecideModerationCase))
mux.HandleFunc("POST /v1/moderation/cases/{id}/appeals", s.authenticated(s.handleSubmitModerationAppeal))
mux.HandleFunc("POST /v1/moderation/cases/{id}/appeals/{appeal_id}/review", s.authenticated(s.handleReviewModerationAppeal))
mux.HandleFunc("POST /v1/collectible-usernames/mint", s.authenticated(s.handleMintCollectibleUsername))
mux.HandleFunc("POST /v1/collectible-usernames/transfer", s.authenticated(s.handleTransferCollectibleUsername))
mux.HandleFunc("POST /v1/collectible-usernames/revoke", s.authenticated(s.handleRevokeCollectibleUsername))
mux.HandleFunc("POST /v1/collectible-usernames/delete", s.authenticated(s.handleDeleteCollectibleUsername))
mux.HandleFunc("GET /v1/collectible-usernames", s.authenticated(s.handleCollectibleUsernames))
mux.HandleFunc("GET /v1/collectible-usernames/{id}", s.authenticated(s.handleCollectibleUsername))
mux.HandleFunc("POST /v1/account-ratings/recompute", s.authenticated(s.handleRecomputeAccountRating))
mux.HandleFunc("POST /v1/account-ratings/adjust", s.authenticated(s.handleAdjustAccountRating))
mux.HandleFunc("GET /v1/account-ratings", s.authenticated(s.handleAccountRatings))
mux.HandleFunc("GET /v1/account-ratings/{id}", s.authenticated(s.handleAccountRating))
// Official platform verification. Unlike every route above, these carry a
// named permission, so a scoped token can be given the review surface and
// nothing else. Revocation additionally requires verification.revoke.
mux.HandleFunc("GET /v1/verification/applications", s.authorized(PermissionVerificationReview, s.handleVerificationApplications))
mux.HandleFunc("GET /v1/verification/applications/{id}", s.authorized(PermissionVerificationReview, s.handleVerificationApplication))
mux.HandleFunc("GET /v1/verification/counts", s.authorized(PermissionVerificationReview, s.handleVerificationCounts))
mux.HandleFunc("POST /v1/verification/applications/{id}/claim", s.authorized(PermissionVerificationReview, s.handleClaimVerification))
mux.HandleFunc("POST /v1/verification/applications/{id}/approve", s.authorized(PermissionVerificationReview, s.handleApproveVerification))
mux.HandleFunc("POST /v1/verification/applications/{id}/reject", s.authorized(PermissionVerificationReview, s.handleRejectVerification))
mux.HandleFunc("POST /v1/verification/revoke", s.authorizedAll(
[]string{PermissionVerificationReview, PermissionVerificationRevoke}, s.handleRevokeVerification))
// Third-party bot verification. Separate routes, separate permissions and
// separate tables from the official verification block above -- the two
// mechanisms never read each other's state. Reads and queue decisions need
// botverification.review; appointing verifiers, curating icons and stripping a
// granted mark need botverification.manage.
mux.HandleFunc("GET /v1/botverification/verifiers", s.authorized(PermissionBotVerificationReview, s.handleBotVerifiers))
mux.HandleFunc("GET /v1/botverification/icons", s.authorized(PermissionBotVerificationReview, s.handleVerificationIcons))
mux.HandleFunc("GET /v1/botverification/marks", s.authorized(PermissionBotVerificationReview, s.handleCustomVerifications))
mux.HandleFunc("GET /v1/botverification/requests", s.authorized(PermissionBotVerificationReview, s.handleCustomVerificationRequests))
mux.HandleFunc("GET /v1/botverification/requests/{id}", s.authorized(PermissionBotVerificationReview, s.handleCustomVerificationRequest))
mux.HandleFunc("GET /v1/botverification/counts", s.authorized(PermissionBotVerificationReview, s.handleCustomVerificationCounts))
mux.HandleFunc("POST /v1/botverification/requests/{id}/approve", s.authorized(PermissionBotVerificationReview, s.handleApproveBotVerification))
mux.HandleFunc("POST /v1/botverification/requests/{id}/reject", s.authorized(PermissionBotVerificationReview, s.handleRejectBotVerification))
mux.HandleFunc("POST /v1/botverification/requests/{id}/revoke", s.authorized(PermissionBotVerificationReview, s.handleRevokeBotVerification))
mux.HandleFunc("POST /v1/botverification/verifiers/grant", s.authorized(PermissionBotVerificationManage, s.handleGrantBotVerifier))
mux.HandleFunc("POST /v1/botverification/verifiers/set-enabled", s.authorized(PermissionBotVerificationManage, s.handleSetBotVerifierEnabled))
mux.HandleFunc("POST /v1/botverification/verifiers/revoke", s.authorized(PermissionBotVerificationManage, s.handleRevokeBotVerifier))
mux.HandleFunc("POST /v1/botverification/icons/upsert", s.authorized(PermissionBotVerificationManage, s.handleUpsertVerificationIcon))
mux.HandleFunc("POST /v1/botverification/icons/set-active", s.authorized(PermissionBotVerificationManage, s.handleSetVerificationIconActive))
mux.HandleFunc("POST /v1/botverification/marks/revoke", s.authorized(PermissionBotVerificationManage, s.handleRevokeCustomVerification))
return mux
}
func (s *Server) authenticated(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) != 1 {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
next(w, r)
}
}
func (s *Server) handleSetAccountFrozen(w http.ResponseWriter, r *http.Request) {
var req admin.SetAccountFrozenRequest
if !decodeJSON(w, r, &req) {
@ -938,6 +1025,372 @@ func writeModerationError(w http.ResponseWriter, err error) {
}
}
func (s *Server) handleMintCollectibleUsername(w http.ResponseWriter, r *http.Request) {
var req admin.MintCollectibleUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.MintCollectibleUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleTransferCollectibleUsername(w http.ResponseWriter, r *http.Request) {
var req admin.TransferCollectibleUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.TransferCollectibleUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleRevokeCollectibleUsername(w http.ResponseWriter, r *http.Request) {
var req admin.RevokeCollectibleUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.RevokeCollectibleUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleDeleteCollectibleUsername(w http.ResponseWriter, r *http.Request) {
var req admin.DeleteCollectibleUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.DeleteCollectibleUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleRecomputeAccountRating(w http.ResponseWriter, r *http.Request) {
var req admin.RecomputeAccountRatingRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.RecomputeAccountRating(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleAdjustAccountRating(w http.ResponseWriter, r *http.Request) {
var req admin.AdjustAccountRatingRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.AdjustAccountRating(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleCollectibleUsernames(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
filter := domain.CollectibleUsernameFilter{
Status: domain.CollectibleUsernameStatus(strings.TrimSpace(query.Get("status"))),
Query: query.Get("q"),
}
if filter.Status != "" && !filter.Status.Valid() {
writeCodedError(w, http.StatusBadRequest, admin.CodeCollectibleStateInvalid, "invalid status")
return
}
owner, ok := collectibleOwnerFilter(w, query)
if !ok {
return
}
filter.Owner = owner
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
filter.Limit = limit
beforeID, ok := optionalQueryInt64(w, query, "before_id")
if !ok {
return
}
filter.BeforeID = beforeID
items, err := s.svc.CollectibleUsernames(r.Context(), filter)
if err != nil {
writeCollectibleUsernameError(w, err)
return
}
assets := make([]map[string]any, 0, len(items))
for _, item := range items {
assets = append(assets, collectibleUsernameResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{"assets": assets})
}
func (s *Server) handleCollectibleUsername(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
asset, err := s.svc.CollectibleUsernameByID(r.Context(), id)
if err != nil {
writeCollectibleUsernameError(w, err)
return
}
limit, ok := optionalQueryInt(w, r.URL.Query(), "limit")
if !ok {
return
}
transfers, err := s.svc.CollectibleUsernameTransfers(r.Context(), asset.ID, limit)
if err != nil {
writeCollectibleUsernameError(w, err)
return
}
log := make([]map[string]any, 0, len(transfers))
for _, item := range transfers {
log = append(log, collectibleUsernameTransferResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{
"asset": collectibleUsernameResponse(asset), "transfers": log,
})
}
func (s *Server) handleAccountRatings(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
minLevel, ok := optionalQueryInt(w, query, "min_level")
if !ok {
return
}
userID, ok := optionalQueryInt64(w, query, "user_id")
if !ok {
return
}
beforeID, ok := optionalQueryInt64(w, query, "before_id")
if !ok {
return
}
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
items, err := s.svc.AccountRatings(r.Context(), domain.AccountRatingFilter{
MinLevel: minLevel, UserID: userID, BeforeID: beforeID, Limit: limit,
})
if err != nil {
writeAccountRatingError(w, err)
return
}
ratings := make([]map[string]any, 0, len(items))
for _, item := range items {
ratings = append(ratings, accountRatingResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{"ratings": ratings})
}
func (s *Server) handleAccountRating(w http.ResponseWriter, r *http.Request) {
userID, ok := moderationPathID(w, r, "id")
if !ok {
return
}
rating, err := s.svc.AccountRating(r.Context(), userID)
if err != nil {
writeAccountRatingError(w, err)
return
}
limit, ok := optionalQueryInt(w, r.URL.Query(), "limit")
if !ok {
return
}
events, err := s.svc.AccountRatingEvents(r.Context(), userID, limit)
if err != nil {
writeAccountRatingError(w, err)
return
}
ledger := make([]map[string]any, 0, len(events))
for _, item := range events {
ledger = append(ledger, accountRatingEventResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{
"rating": accountRatingResponse(rating), "events": ledger,
})
}
// collectibleOwnerFilter reads the optional owner filter. At most one of the two
// identifiers may be present, mirroring the mint/transfer request shape.
func collectibleOwnerFilter(w http.ResponseWriter, query url.Values) (domain.Peer, bool) {
userID, ok := optionalQueryInt64(w, query, "owner_user_id")
if !ok {
return domain.Peer{}, false
}
channelID, ok := optionalQueryInt64(w, query, "owner_channel_id")
if !ok {
return domain.Peer{}, false
}
switch {
case userID > 0 && channelID > 0:
writeError(w, http.StatusBadRequest, "at most one owner filter is allowed")
return domain.Peer{}, false
case userID > 0:
return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, true
case channelID > 0:
return domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, true
default:
return domain.Peer{}, true
}
}
func optionalQueryInt64(w http.ResponseWriter, query url.Values, name string) (int64, bool) {
raw := strings.TrimSpace(query.Get(name))
if raw == "" {
return 0, true
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value < 0 {
writeError(w, http.StatusBadRequest, "invalid "+name)
return 0, false
}
return value, true
}
func optionalQueryInt(w http.ResponseWriter, query url.Values, name string) (int, bool) {
value, ok := optionalQueryInt64(w, query, name)
if !ok {
return 0, false
}
if value > math.MaxInt32 {
writeError(w, http.StatusBadRequest, "invalid "+name)
return 0, false
}
return int(value), true
}
// collectibleUsernameResponse renders one asset. Every int64 crosses the JSON
// boundary as a decimal string: asset ids and nanoton amounts exceed the exact
// range of a JSON number, and a rounded id would address the wrong asset.
func collectibleUsernameResponse(asset domain.CollectibleUsername) map[string]any {
out := map[string]any{
"id": strconv.FormatInt(asset.ID, 10),
"username": asset.Username,
"status": string(asset.Status),
"owner_type": string(asset.Owner.Type),
"owner_id": strconv.FormatInt(asset.Owner.ID, 10),
"purchase_date": asset.Info().PurchaseDate,
"currency": asset.Currency,
"amount": strconv.FormatInt(asset.Amount, 10),
"crypto_currency": asset.CryptoCurrency,
"crypto_amount": strconv.FormatInt(asset.CryptoAmount, 10),
"url": asset.URL,
"original_owner_type": string(asset.OriginalOwner.Type),
"original_owner_id": strconv.FormatInt(asset.OriginalOwner.ID, 10),
"transfer_count": asset.TransferCount,
"version": strconv.FormatInt(asset.Version, 10),
}
if !asset.CreatedAt.IsZero() {
out["created_at"] = asset.CreatedAt.UTC().Format(time.RFC3339)
}
if !asset.UpdatedAt.IsZero() {
out["updated_at"] = asset.UpdatedAt.UTC().Format(time.RFC3339)
}
return out
}
func collectibleUsernameTransferResponse(item domain.CollectibleUsernameTransfer) map[string]any {
out := map[string]any{
"id": strconv.FormatInt(item.ID, 10),
"collectible_id": strconv.FormatInt(item.CollectibleID, 10),
"kind": string(item.Kind),
"from_type": string(item.From.Type),
"from_id": strconv.FormatInt(item.From.ID, 10),
"to_type": string(item.To.Type),
"to_id": strconv.FormatInt(item.To.ID, 10),
"currency": item.Currency,
"amount": strconv.FormatInt(item.Amount, 10),
"actor": item.Actor,
"reason": item.Reason,
"command_key": item.CommandKey,
}
if !item.CreatedAt.IsZero() {
out["created_at"] = item.CreatedAt.UTC().Format(time.RFC3339)
}
return out
}
// accountRatingResponse renders one composite rating. The score and every
// component stay decimal strings for the same exactness reason as the asset ids.
func accountRatingResponse(rating domain.AccountRating) map[string]any {
out := map[string]any{
"user_id": strconv.FormatInt(rating.UserID, 10),
"level": rating.Level,
"stars": strconv.FormatInt(rating.Stars, 10),
"current_level_stars": strconv.FormatInt(rating.CurrentLevelStars, 10),
"has_next_level": rating.HasNextLevel,
"stars_component": strconv.FormatInt(rating.StarsComponent, 10),
"activity_component": strconv.FormatInt(rating.ActivityComponent, 10),
"penalty_component": strconv.FormatInt(rating.PenaltyComponent, 10),
"manual_component": strconv.FormatInt(rating.ManualComponent, 10),
"pending_stars": strconv.FormatInt(rating.PendingStars, 10),
"version": strconv.FormatInt(rating.Version, 10),
}
if rating.HasNextLevel {
out["next_level_stars"] = strconv.FormatInt(rating.NextLevelStars, 10)
}
if !rating.PendingDate.IsZero() {
out["pending_date"] = rating.PendingDate.UTC().Format(time.RFC3339)
}
if !rating.ComputedAt.IsZero() {
out["computed_at"] = rating.ComputedAt.UTC().Format(time.RFC3339)
}
if !rating.UpdatedAt.IsZero() {
out["updated_at"] = rating.UpdatedAt.UTC().Format(time.RFC3339)
}
return out
}
func accountRatingEventResponse(event domain.AccountRatingEvent) map[string]any {
out := map[string]any{
"id": strconv.FormatInt(event.ID, 10),
"user_id": strconv.FormatInt(event.UserID, 10),
"kind": string(event.Kind),
"amount": strconv.FormatInt(event.Amount, 10),
"reason": event.Reason,
"actor": event.Actor,
"command_key": event.CommandKey,
}
if !event.CreatedAt.IsZero() {
out["created_at"] = event.CreatedAt.UTC().Format(time.RFC3339)
}
return out
}
// writeCollectibleUsernameError maps a collectible-username failure onto its
// stable admin code and the matching HTTP status, the way writeModerationError
// does for moderation. An unmapped failure stays a 500 with its own text rather
// than being dressed up as a client error.
func writeCollectibleUsernameError(w http.ResponseWriter, err error) {
code := admin.CollectibleUsernameErrorCode(err)
status := http.StatusInternalServerError
switch code {
case admin.CodeCollectibleNotFound:
status = http.StatusNotFound
case admin.CodeUsernameOccupied, admin.CodeCollectibleBurned,
admin.CodeCollectiblePeerLimit, admin.CodeCollectibleNotOwned:
status = http.StatusConflict
case admin.CodeUsernameInvalid, admin.CodeUsernameNotCollectible,
admin.CodeCollectibleCurrencyInvalid, admin.CodeCollectibleStateInvalid:
status = http.StatusBadRequest
}
writeCodedError(w, status, code, err.Error())
}
func writeAccountRatingError(w http.ResponseWriter, err error) {
code := admin.AccountRatingErrorCode(err)
status := http.StatusInternalServerError
switch code {
case admin.CodeRatingNotFound:
status = http.StatusNotFound
case admin.CodeRatingAdjustmentInvalid, admin.CodeRatingWeightsInvalid:
status = http.StatusBadRequest
}
writeCodedError(w, status, code, err.Error())
}
func writeCodedError(w http.ResponseWriter, status int, code, msg string) {
body := map[string]string{"error": msg}
if code != "" {
body["code"] = code
}
writeJSON(w, status, body)
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
defer r.Body.Close()
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))

View file

@ -606,3 +606,369 @@ func (fakeService) SubmitModerationAppeal(context.Context, int64, int64, string)
func (fakeService) ReviewModerationAppeal(context.Context, domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
return domain.ModerationCaseDetail{}, true, nil
}
type captureCollectibleUsernameService struct {
fakeService
mint admin.MintCollectibleUsernameRequest
transfer admin.TransferCollectibleUsernameRequest
revoke admin.RevokeCollectibleUsernameRequest
del admin.DeleteCollectibleUsernameRequest
filter domain.CollectibleUsernameFilter
assetID int64
}
func (s *captureCollectibleUsernameService) MintCollectibleUsername(_ context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error) {
s.mint = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureCollectibleUsernameService) TransferCollectibleUsername(_ context.Context, req admin.TransferCollectibleUsernameRequest) (admin.CommandResult, error) {
s.transfer = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureCollectibleUsernameService) RevokeCollectibleUsername(_ context.Context, req admin.RevokeCollectibleUsernameRequest) (admin.CommandResult, error) {
s.revoke = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureCollectibleUsernameService) DeleteCollectibleUsername(_ context.Context, req admin.DeleteCollectibleUsernameRequest) (admin.CommandResult, error) {
s.del = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureCollectibleUsernameService) CollectibleUsernames(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
s.filter = filter
return []domain.CollectibleUsername{maxInt64Collectible()}, nil
}
func (s *captureCollectibleUsernameService) CollectibleUsernameByID(_ context.Context, id int64) (domain.CollectibleUsername, error) {
s.assetID = id
asset := maxInt64Collectible()
asset.ID = id
return asset, nil
}
func (s *captureCollectibleUsernameService) CollectibleUsernameTransfers(_ context.Context, collectibleID int64, _ int) ([]domain.CollectibleUsernameTransfer, error) {
return []domain.CollectibleUsernameTransfer{{
ID: 9223372036854775807,
CollectibleID: collectibleID,
Kind: domain.CollectibleUsernameKindMint,
To: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Currency: domain.CollectibleCurrencyTON,
Amount: 9223372036854775807,
Actor: "ops",
}}, nil
}
func maxInt64Collectible() domain.CollectibleUsername {
return domain.CollectibleUsername{
ID: 9223372036854775807,
Username: "durov",
Status: domain.CollectibleUsernameStatusOwned,
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Currency: domain.CollectibleCurrencyTON,
Amount: 9223372036854775807,
CryptoCurrency: domain.CollectibleCryptoCurrencyTON,
CryptoAmount: 9223372036854775807,
Version: 9223372036854775807,
}
}
type captureAccountRatingService struct {
fakeService
recompute admin.RecomputeAccountRatingRequest
adjust admin.AdjustAccountRatingRequest
filter domain.AccountRatingFilter
}
func (s *captureAccountRatingService) RecomputeAccountRating(_ context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error) {
s.recompute = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureAccountRatingService) AdjustAccountRating(_ context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error) {
s.adjust = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureAccountRatingService) AccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
s.filter = filter
return []domain.AccountRating{maxInt64Rating()}, nil
}
func (s *captureAccountRatingService) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
rating := maxInt64Rating()
rating.UserID = userID
return rating, nil
}
func (s *captureAccountRatingService) AccountRatingEvents(_ context.Context, userID int64, _ int) ([]domain.AccountRatingEvent, error) {
return []domain.AccountRatingEvent{{
ID: 9223372036854775807, UserID: userID,
Kind: domain.AccountRatingEventManual, Amount: -9223372036854775807,
Actor: "ops", Reason: "abuse",
}}, nil
}
func maxInt64Rating() domain.AccountRating {
return domain.AccountRating{
UserID: 1001,
Level: 7,
Stars: 9223372036854775807,
CurrentLevelStars: 4900,
NextLevelStars: 6400,
HasNextLevel: true,
StarsComponent: 9223372036854775807,
ManualComponent: -1500,
Version: 9223372036854775807,
}
}
func TestAdminAPICollectibleUsernameCommandsRequireToken(t *testing.T) {
srv := &Server{token: "secret", svc: fakeService{}}
for _, path := range []string{
"/v1/collectible-usernames/mint",
"/v1/collectible-usernames/transfer",
"/v1/collectible-usernames/revoke",
"/v1/account-ratings/recompute",
"/v1/account-ratings/adjust",
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s status=%d, want 401", path, rec.Code)
}
}
for _, path := range []string{
"/v1/collectible-usernames",
"/v1/collectible-usernames/7",
"/v1/account-ratings",
"/v1/account-ratings/7",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s status=%d, want 401", path, rec.Code)
}
}
}
func TestAdminAPIMintCollectibleUsernameForwardsExactInt64AndDryRun(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
svc := &captureCollectibleUsernameService{}
srv := &Server{token: "secret", svc: svc}
req := httptest.NewRequest(http.MethodPost, "/v1/collectible-usernames/mint", strings.NewReader(`{
"command_id":"mint-1","actor":"ops","reason":"fragment import","dry_run":true,
"username":"durov","owner_user_id":"1001","currency":"TON","amount":"9223372036854775807",
"crypto_currency":"TON","crypto_amount":"250000000000",
"url":"https://fragment.example/durov","purchase_date":1700000000
}`))
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"command_id":"mint-1"`) {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"dry_run":true`) {
t.Fatalf("dry-run was not propagated: %s", rec.Body.String())
}
if svc.mint.Username != "durov" || svc.mint.OwnerUserID != 1001 || svc.mint.Amount != maxInt64 ||
svc.mint.CryptoAmount != 250000000000 || svc.mint.PurchaseDate != 1700000000 || !svc.mint.DryRun {
t.Fatalf("decoded mint request = %+v", svc.mint)
}
}
func TestAdminAPITransferAndRevokeCollectibleUsername(t *testing.T) {
svc := &captureCollectibleUsernameService{}
srv := &Server{token: "secret", svc: svc}
transfer := httptest.NewRequest(http.MethodPost, "/v1/collectible-usernames/transfer", strings.NewReader(
`{"command_id":"t-1","actor":"ops","reason":"sold","username":"durov","to_channel_id":"2002"}`))
transfer.Header.Set("Authorization", "Bearer secret")
transferRec := httptest.NewRecorder()
srv.routes().ServeHTTP(transferRec, transfer)
if transferRec.Code != http.StatusOK || svc.transfer.ToChannelID != 2002 || svc.transfer.Username != "durov" {
t.Fatalf("transfer status=%d request=%+v", transferRec.Code, svc.transfer)
}
revoke := httptest.NewRequest(http.MethodPost, "/v1/collectible-usernames/revoke", strings.NewReader(
`{"command_id":"r-1","actor":"ops","reason":"fraud","username":"durov","burn":true}`))
revoke.Header.Set("Authorization", "Bearer secret")
revokeRec := httptest.NewRecorder()
srv.routes().ServeHTTP(revokeRec, revoke)
if revokeRec.Code != http.StatusOK || !svc.revoke.Burn || svc.revoke.CommandID != "r-1" {
t.Fatalf("revoke status=%d request=%+v", revokeRec.Code, svc.revoke)
}
}
func TestAdminAPIAccountRatingCommands(t *testing.T) {
svc := &captureAccountRatingService{}
srv := &Server{token: "secret", svc: svc}
recompute := httptest.NewRequest(http.MethodPost, "/v1/account-ratings/recompute", strings.NewReader(
`{"command_id":"rc-1","actor":"ops","reason":"support ticket","dry_run":true,"user_id":"1001"}`))
recompute.Header.Set("Authorization", "Bearer secret")
recomputeRec := httptest.NewRecorder()
srv.routes().ServeHTTP(recomputeRec, recompute)
if recomputeRec.Code != http.StatusOK || svc.recompute.UserID != 1001 || !svc.recompute.DryRun {
t.Fatalf("recompute status=%d request=%+v body=%s", recomputeRec.Code, svc.recompute, recomputeRec.Body.String())
}
adjust := httptest.NewRequest(http.MethodPost, "/v1/account-ratings/adjust", strings.NewReader(
`{"command_id":"adj-1","actor":"ops","reason":"manual penalty","user_id":"1001","amount":"-2500"}`))
adjust.Header.Set("Authorization", "Bearer secret")
adjustRec := httptest.NewRecorder()
srv.routes().ServeHTTP(adjustRec, adjust)
if adjustRec.Code != http.StatusOK || svc.adjust.Amount != -2500 || svc.adjust.DryRun {
t.Fatalf("adjust status=%d request=%+v body=%s", adjustRec.Code, svc.adjust, adjustRec.Body.String())
}
}
func TestAdminAPICollectibleUsernameReadsUseDecimalStrings(t *testing.T) {
svc := &captureCollectibleUsernameService{}
srv := &Server{token: "secret", svc: svc}
list := httptest.NewRequest(http.MethodGet,
"/v1/collectible-usernames?status=owned&owner_user_id=1001&q=%40Durov&limit=25&before_id=42", nil)
list.Header.Set("Authorization", "Bearer secret")
listRec := httptest.NewRecorder()
srv.routes().ServeHTTP(listRec, list)
if listRec.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String())
}
if svc.filter.Status != domain.CollectibleUsernameStatusOwned ||
svc.filter.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: 1001}) ||
svc.filter.Query != "@Durov" || svc.filter.Limit != 25 || svc.filter.BeforeID != 42 {
t.Fatalf("collectible filter = %+v", svc.filter)
}
if !strings.Contains(listRec.Body.String(), `"id":"9223372036854775807"`) ||
!strings.Contains(listRec.Body.String(), `"amount":"9223372036854775807"`) {
t.Fatalf("list body lost int64 precision: %s", listRec.Body.String())
}
detail := httptest.NewRequest(http.MethodGet, "/v1/collectible-usernames/77", nil)
detail.Header.Set("Authorization", "Bearer secret")
detailRec := httptest.NewRecorder()
srv.routes().ServeHTTP(detailRec, detail)
if detailRec.Code != http.StatusOK || svc.assetID != 77 {
t.Fatalf("detail status=%d assetID=%d body=%s", detailRec.Code, svc.assetID, detailRec.Body.String())
}
var payload struct {
Asset map[string]any `json:"asset"`
Transfers []map[string]any `json:"transfers"`
}
if err := json.Unmarshal(detailRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode detail: %v", err)
}
if payload.Asset["id"] != "77" || len(payload.Transfers) != 1 ||
payload.Transfers[0]["amount"] != "9223372036854775807" ||
payload.Transfers[0]["collectible_id"] != "77" {
t.Fatalf("detail payload = %+v", payload)
}
}
func TestAdminAPIAccountRatingReadsUseDecimalStrings(t *testing.T) {
svc := &captureAccountRatingService{}
srv := &Server{token: "secret", svc: svc}
list := httptest.NewRequest(http.MethodGet, "/v1/account-ratings?min_level=3&user_id=1001&limit=10&before_id=99", nil)
list.Header.Set("Authorization", "Bearer secret")
listRec := httptest.NewRecorder()
srv.routes().ServeHTTP(listRec, list)
if listRec.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String())
}
if svc.filter.MinLevel != 3 || svc.filter.UserID != 1001 || svc.filter.Limit != 10 || svc.filter.BeforeID != 99 {
t.Fatalf("rating filter = %+v", svc.filter)
}
if !strings.Contains(listRec.Body.String(), `"stars":"9223372036854775807"`) {
t.Fatalf("rating list lost int64 precision: %s", listRec.Body.String())
}
detail := httptest.NewRequest(http.MethodGet, "/v1/account-ratings/1001", nil)
detail.Header.Set("Authorization", "Bearer secret")
detailRec := httptest.NewRecorder()
srv.routes().ServeHTTP(detailRec, detail)
if detailRec.Code != http.StatusOK {
t.Fatalf("detail status=%d body=%s", detailRec.Code, detailRec.Body.String())
}
var payload struct {
Rating map[string]any `json:"rating"`
Events []map[string]any `json:"events"`
}
if err := json.Unmarshal(detailRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode detail: %v", err)
}
if payload.Rating["user_id"] != "1001" || payload.Rating["stars"] != "9223372036854775807" ||
len(payload.Events) != 1 || payload.Events[0]["amount"] != "-9223372036854775807" {
t.Fatalf("rating detail payload = %+v", payload)
}
}
func TestAdminAPIMissingCollectibleAndRatingReportCodedErrors(t *testing.T) {
srv := &Server{token: "secret", svc: fakeService{}}
asset := httptest.NewRequest(http.MethodGet, "/v1/collectible-usernames/5", nil)
asset.Header.Set("Authorization", "Bearer secret")
assetRec := httptest.NewRecorder()
srv.routes().ServeHTTP(assetRec, asset)
if assetRec.Code != http.StatusNotFound ||
!strings.Contains(assetRec.Body.String(), `"code":"`+admin.CodeCollectibleNotFound+`"`) {
t.Fatalf("missing asset status=%d body=%s", assetRec.Code, assetRec.Body.String())
}
rating := httptest.NewRequest(http.MethodGet, "/v1/account-ratings/5", nil)
rating.Header.Set("Authorization", "Bearer secret")
ratingRec := httptest.NewRecorder()
srv.routes().ServeHTTP(ratingRec, rating)
if ratingRec.Code != http.StatusNotFound ||
!strings.Contains(ratingRec.Body.String(), `"code":"`+admin.CodeRatingNotFound+`"`) {
t.Fatalf("missing rating status=%d body=%s", ratingRec.Code, ratingRec.Body.String())
}
}
func (fakeService) MintCollectibleUsername(_ context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) TransferCollectibleUsername(_ context.Context, req admin.TransferCollectibleUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeCollectibleUsername(_ context.Context, req admin.RevokeCollectibleUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) DeleteCollectibleUsername(_ context.Context, req admin.DeleteCollectibleUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) CollectibleUsernames(context.Context, domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
return nil, nil
}
func (fakeService) CollectibleUsernameByID(context.Context, int64) (domain.CollectibleUsername, error) {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
func (fakeService) CollectibleUsernameTransfers(context.Context, int64, int) ([]domain.CollectibleUsernameTransfer, error) {
return nil, nil
}
func (fakeService) RecomputeAccountRating(_ context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) AdjustAccountRating(_ context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) AccountRating(context.Context, int64) (domain.AccountRating, error) {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
func (fakeService) AccountRatings(context.Context, domain.AccountRatingFilter) ([]domain.AccountRating, error) {
return nil, nil
}
func (fakeService) AccountRatingEvents(context.Context, int64, int) ([]domain.AccountRatingEvent, error) {
return nil, nil
}

View file

@ -0,0 +1,361 @@
package adminapi
import (
"net/http"
"strconv"
"strings"
"time"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
// Official platform verification review over the admin API.
//
// These are the mirror routes of the panel's own endpoints: the panel reads the
// queue straight from PostgreSQL for speed, while an integration holding a scoped
// token reads it here. Decisions only ever travel this way, so the command
// journal and the status machine are enforced in one place.
// handleVerificationApplications is the review queue.
func (s *Server) handleVerificationApplications(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
filter := domain.VerificationApplicationFilter{
TargetType: domain.VerificationTargetType(strings.TrimSpace(query.Get("target_type"))),
Reviewer: strings.TrimSpace(query.Get("reviewer")),
Query: query.Get("q"),
}
if filter.TargetType != "" && !filter.TargetType.Valid() {
writeCodedError(w, http.StatusBadRequest, admin.CodeVerificationTargetInvalid, "invalid target_type")
return
}
// status accepts a comma-separated list, so the queue view ("submitted,
// in_review") is one request rather than two.
for _, raw := range strings.Split(query.Get("status"), ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
status := domain.VerificationStatus(raw)
if !status.Valid() {
writeCodedError(w, http.StatusBadRequest, admin.CodeVerificationStatusInvalid, "invalid status "+raw)
return
}
filter.Statuses = append(filter.Statuses, status)
}
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
filter.Limit = limit
beforeID, ok := optionalQueryInt64(w, query, "before_id")
if !ok {
return
}
filter.BeforeID = beforeID
items, err := s.svc.VerificationApplications(r.Context(), filter)
if err != nil {
writeVerificationError(w, err)
return
}
applications := make([]map[string]any, 0, len(items))
for _, item := range items {
applications = append(applications, verificationApplicationResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{"applications": applications})
}
// handleVerificationApplication is one application with its history and the
// target as it looks right now.
func (s *Server) handleVerificationApplication(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
app, err := s.svc.VerificationApplication(r.Context(), id)
if err != nil {
writeVerificationError(w, err)
return
}
limit, ok := optionalQueryInt(w, r.URL.Query(), "limit")
if !ok {
return
}
events, err := s.svc.VerificationApplicationEvents(r.Context(), app.ID, limit)
if err != nil {
writeVerificationError(w, err)
return
}
history := make([]map[string]any, 0, len(events))
for _, event := range events {
history = append(history, verificationEventResponse(event))
}
body := map[string]any{
"application": verificationApplicationResponse(app),
"events": history,
}
// The snapshot is advisory: a target that vanished must not turn the audit
// record into a 500, so a snapshot failure is reported next to the record
// instead of replacing it.
if target, err := s.svc.VerificationTargetSnapshot(r.Context(), app.TargetType, app.TargetID); err == nil {
body["target"] = verificationTargetResponse(target)
} else {
body["target_error"] = err.Error()
}
writeJSON(w, http.StatusOK, body)
}
// handleVerificationCounts is the queue summary.
func (s *Server) handleVerificationCounts(w http.ResponseWriter, r *http.Request) {
counts, err := s.svc.VerificationCounts(r.Context())
if err != nil {
writeVerificationError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"counts": verificationCountsResponse(counts)})
}
func (s *Server) handleClaimVerification(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var req admin.ClaimVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
// The path is the authority on which application is decided: a body naming a
// different one would make the URL lie to the audit trail.
req.ApplicationID = id
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.ClaimVerification(r.Context(), req)
writeVerificationCommandResult(w, result, err)
}
func (s *Server) handleApproveVerification(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var req admin.ApproveVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
req.ApplicationID = id
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.ApproveVerification(r.Context(), req)
writeVerificationCommandResult(w, result, err)
}
func (s *Server) handleRejectVerification(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var req admin.RejectVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
req.ApplicationID = id
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.RejectVerification(r.Context(), req)
writeVerificationCommandResult(w, result, err)
}
func (s *Server) handleRevokeVerification(w http.ResponseWriter, r *http.Request) {
var req admin.RevokeVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.RevokeVerification(r.Context(), req)
writeVerificationCommandResult(w, result, err)
}
// applyVerificationPrincipal fills in the audit actor for a scoped token that did
// not state one.
//
// A scoped token's configured name *is* its audit identity, so an integration
// does not have to invent an actor string. The master token has no name, so a
// caller using it keeps having to state who is acting -- which is what the panel
// does with the signed-in operator.
func (s *Server) applyVerificationPrincipal(r *http.Request, meta *admin.CommandMeta) {
if strings.TrimSpace(meta.Actor) != "" {
return
}
if name := principalName(r.Context()); name != "" {
meta.Actor = name
}
}
// verificationApplicationResponse renders one application. Every int64 crosses
// the JSON boundary as a decimal string: application ids, peer ids and the
// optimistic-locking version exceed the range a JSON number holds exactly, and a
// rounded id would decide the wrong application.
func verificationApplicationResponse(app domain.VerificationApplication) map[string]any {
out := map[string]any{
"id": strconv.FormatInt(app.ID, 10),
"applicant_user_id": strconv.FormatInt(app.ApplicantUserID, 10),
"target_type": string(app.TargetType),
"target_id": strconv.FormatInt(app.TargetID, 10),
"target_title": app.TargetTitle,
"target_username": app.TargetUsername,
"category": app.Category,
"description": app.Description,
"official_website": app.OfficialWebsite,
"social_links": stringList(app.SocialLinks),
"press_links": stringList(app.PressLinks),
"additional_note": app.AdditionalNote,
"status": string(app.Status),
"reviewer_admin_id": app.ReviewerAdminID,
"decision_reason": app.DecisionReason,
// internal_note is operator-only. It is exposed here because every caller
// of this route already holds verification.review, and it is the reviewer's
// own handover note; it is never part of the applicant-facing projection.
"internal_note": app.InternalNote,
"correlation_id": app.CorrelationID,
"version": strconv.FormatInt(app.Version, 10),
}
if !app.CreatedAt.IsZero() {
out["created_at"] = app.CreatedAt.UTC().Format(time.RFC3339)
}
if !app.UpdatedAt.IsZero() {
out["updated_at"] = app.UpdatedAt.UTC().Format(time.RFC3339)
}
if !app.SubmittedAt.IsZero() {
out["submitted_at"] = app.SubmittedAt.UTC().Format(time.RFC3339)
}
if !app.ReviewedAt.IsZero() {
out["reviewed_at"] = app.ReviewedAt.UTC().Format(time.RFC3339)
}
return out
}
func verificationEventResponse(event domain.VerificationApplicationEvent) map[string]any {
out := map[string]any{
"id": strconv.FormatInt(event.ID, 10),
"application_id": strconv.FormatInt(event.ApplicationID, 10),
"kind": string(event.Kind),
"from_status": string(event.FromStatus),
"to_status": string(event.ToStatus),
"actor": event.Actor,
"reason": event.Reason,
"note": event.Note,
"correlation_id": event.CorrelationID,
}
if !event.CreatedAt.IsZero() {
out["created_at"] = event.CreatedAt.UTC().Format(time.RFC3339)
}
return out
}
func verificationTargetResponse(target domain.VerificationTarget) map[string]any {
return map[string]any{
"type": string(target.Type),
"id": strconv.FormatInt(target.ID, 10),
"title": target.Title,
"username": target.Username,
"verified": target.Verified,
"eligible": target.Eligible,
"reason": target.Reason,
}
}
// verificationCountsResponse renders the queue summary with every modelled status
// present, so the panel never has to distinguish "zero" from "absent". The values
// are decimal strings for the same exactness reason as the ids.
func verificationCountsResponse(counts domain.VerificationStatusCounts) map[string]string {
out := make(map[string]string, len(verificationStatusOrder))
for _, status := range verificationStatusOrder {
out[string(status)] = strconv.FormatInt(counts[status], 10)
}
for status, count := range counts {
if _, ok := out[string(status)]; !ok {
out[string(status)] = strconv.FormatInt(count, 10)
}
}
return out
}
// verificationStatusOrder is the closed status set, in lifecycle order.
var verificationStatusOrder = []domain.VerificationStatus{
domain.VerificationStatusDraft,
domain.VerificationStatusSubmitted,
domain.VerificationStatusInReview,
domain.VerificationStatusApproved,
domain.VerificationStatusRejected,
domain.VerificationStatusCancelled,
}
// stringList normalises a nil slice to an empty JSON array, so the panel can
// iterate without a null check.
func stringList(items []string) []string {
if items == nil {
return []string{}
}
return items
}
// verificationErrorStatus maps a verification failure onto its HTTP status.
//
// The version conflict is the interesting one: it is 409, not 400, because
// nothing about the request was wrong -- another reviewer simply decided first,
// and the panel has to answer that by reloading rather than by correcting input.
func verificationErrorStatus(code string) int {
switch code {
case admin.CodeVerificationNotFound:
return http.StatusNotFound
case admin.CodeVerificationConflict,
admin.CodeVerificationTargetOccupied,
admin.CodeVerificationTargetVerified:
return http.StatusConflict
case admin.CodeVerificationStatusInvalid,
admin.CodeVerificationReasonRequired,
admin.CodeVerificationTargetInvalid,
admin.CodeVerificationTargetNotPublic,
admin.CodeVerificationTargetRestricted,
admin.CodeVerificationTargetSystem,
admin.CodeVerificationNotOwner,
admin.CodeVerificationUserTargetsDisabled,
admin.CodeVerificationInvalid:
return http.StatusBadRequest
default:
return http.StatusInternalServerError
}
}
func writeVerificationError(w http.ResponseWriter, err error) {
code := admin.VerificationErrorCode(err)
writeCodedError(w, verificationErrorStatus(code), code, err.Error())
}
// writeVerificationCommandResult answers a decision.
//
// The body stays a CommandResult so the panel parses one shape for every
// operator action, but the status is derived from the failure: a lost
// optimistic-locking race must reach the browser as 409, because that is the one
// failure the panel resolves by reloading the application instead of by asking
// the operator to fix the form.
func writeVerificationCommandResult(w http.ResponseWriter, result admin.CommandResult, err error) {
if err == nil {
writeJSON(w, http.StatusOK, result)
return
}
code := admin.VerificationErrorCode(err)
status := verificationErrorStatus(code)
if status == http.StatusInternalServerError {
// An unmapped command failure is a bad request, as everywhere else in this
// API, rather than a server fault.
status = http.StatusBadRequest
}
if result.CommandID == "" {
result = admin.CommandResult{Status: "failed", Message: "command failed", Error: err.Error()}
}
if result.Error == "" {
result.Error = err.Error()
}
if code == admin.CodeVerificationConflict {
result.Message = "another reviewer changed this application first; reload it and decide again"
}
writeJSON(w, status, result)
}

View file

@ -0,0 +1,546 @@
package adminapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
// fakeService gains the verification surface here so the shared fake keeps
// satisfying Service without touching the existing test file.
func (fakeService) ClaimVerification(_ context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) ApproveVerification(_ context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RejectVerification(_ context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeVerification(_ context.Context, req admin.RevokeVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) VerificationApplications(context.Context, domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
return nil, nil
}
func (fakeService) VerificationApplication(context.Context, int64) (domain.VerificationApplication, error) {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
func (fakeService) VerificationApplicationEvents(context.Context, int64, int) ([]domain.VerificationApplicationEvent, error) {
return nil, nil
}
func (fakeService) VerificationCounts(context.Context) (domain.VerificationStatusCounts, error) {
return domain.VerificationStatusCounts{}, nil
}
func (fakeService) VerificationTargetSnapshot(context.Context, domain.VerificationTargetType, int64) (domain.VerificationTarget, error) {
return domain.VerificationTarget{}, nil
}
type captureVerificationService struct {
fakeService
filter domain.VerificationApplicationFilter
claim admin.ClaimVerificationRequest
approve admin.ApproveVerificationRequest
reject admin.RejectVerificationRequest
revoke admin.RevokeVerificationRequest
app domain.VerificationApplication
events []domain.VerificationApplicationEvent
counts domain.VerificationStatusCounts
target domain.VerificationTarget
decideOn error
}
func (s *captureVerificationService) VerificationApplications(_ context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
s.filter = filter
return []domain.VerificationApplication{s.app}, nil
}
func (s *captureVerificationService) VerificationApplication(_ context.Context, applicationID int64) (domain.VerificationApplication, error) {
if s.app.ID != applicationID {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
return s.app, nil
}
func (s *captureVerificationService) VerificationApplicationEvents(context.Context, int64, int) ([]domain.VerificationApplicationEvent, error) {
return s.events, nil
}
func (s *captureVerificationService) VerificationCounts(context.Context) (domain.VerificationStatusCounts, error) {
return s.counts, nil
}
func (s *captureVerificationService) VerificationTargetSnapshot(context.Context, domain.VerificationTargetType, int64) (domain.VerificationTarget, error) {
return s.target, nil
}
func (s *captureVerificationService) ClaimVerification(_ context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error) {
s.claim = req
if s.decideOn != nil {
return admin.CommandResult{CommandID: req.CommandID, Status: "failed", Error: s.decideOn.Error()}, s.decideOn
}
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureVerificationService) ApproveVerification(_ context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error) {
s.approve = req
if s.decideOn != nil {
return admin.CommandResult{CommandID: req.CommandID, Status: "failed", Error: s.decideOn.Error()}, s.decideOn
}
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureVerificationService) RejectVerification(_ context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error) {
s.reject = req
if s.decideOn != nil {
return admin.CommandResult{CommandID: req.CommandID, Status: "failed", Error: s.decideOn.Error()}, s.decideOn
}
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureVerificationService) RevokeVerification(_ context.Context, req admin.RevokeVerificationRequest) (admin.CommandResult, error) {
s.revoke = req
if s.decideOn != nil {
return admin.CommandResult{CommandID: req.CommandID, Status: "failed", Error: s.decideOn.Error()}, s.decideOn
}
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
// reviewOnlyServer is the deployment shape the permission model exists for: one
// unrestricted master token plus two bounded tokens, one able to review and one
// able to review and revoke.
func reviewOnlyServer(svc Service) *Server {
return &Server{
token: "master",
scoped: []ScopedToken{
{Name: "queue-bot", Token: "scoped-review", Permissions: []string{PermissionVerificationReview}},
{Name: "trust-and-safety", Token: "scoped-revoke", Permissions: []string{
PermissionVerificationReview, PermissionVerificationRevoke,
}},
{Name: "gift-importer", Token: "scoped-other", Permissions: []string{"gifts.import"}},
},
svc: svc,
}
}
func verificationRequest(method, path, token, body string) *http.Request {
var req *http.Request
if body == "" {
req = httptest.NewRequest(method, path, nil)
} else {
req = httptest.NewRequest(method, path, strings.NewReader(body))
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
return req
}
func TestVerificationRoutesRejectMissingAndUnknownTokens(t *testing.T) {
srv := reviewOnlyServer(fakeService{})
cases := []struct {
method string
path string
body string
}{
{http.MethodGet, "/v1/verification/applications", ""},
{http.MethodGet, "/v1/verification/applications/7", ""},
{http.MethodGet, "/v1/verification/counts", ""},
{http.MethodPost, "/v1/verification/applications/7/claim", `{}`},
{http.MethodPost, "/v1/verification/applications/7/approve", `{}`},
{http.MethodPost, "/v1/verification/applications/7/reject", `{}`},
{http.MethodPost, "/v1/verification/revoke", `{}`},
}
for _, item := range cases {
for _, token := range []string{"", "not-a-configured-token"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s %s token=%q status=%d, want 401", item.method, item.path, token, rec.Code)
}
}
}
}
func TestVerificationRoutesRefuseScopedTokenWithoutThePermission(t *testing.T) {
srv := reviewOnlyServer(fakeService{})
cases := []struct {
method string
path string
body string
}{
{http.MethodGet, "/v1/verification/applications", ""},
{http.MethodGet, "/v1/verification/counts", ""},
{http.MethodPost, "/v1/verification/applications/7/claim", `{}`},
}
for _, item := range cases {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "scoped-other", item.body))
if rec.Code != http.StatusForbidden {
t.Fatalf("%s %s status=%d body=%s, want 403", item.method, item.path, rec.Code, rec.Body.String())
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode 403 body: %v", err)
}
if body["code"] != CodeForbidden || body["permission"] != PermissionVerificationReview {
t.Fatalf("403 body=%+v, want the missing permission named", body)
}
}
}
func TestVerificationRevokeNeedsTheRevokePermissionOnTopOfReview(t *testing.T) {
svc := &captureVerificationService{}
srv := reviewOnlyServer(svc)
// A review-only token reaches the queue but not the revocation.
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/counts", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("review token on counts status=%d body=%s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/revoke", "scoped-review",
`{"command_id":"c1","actor":"ops","reason":"impersonation","target_type":"channel","target_id":5005}`))
if rec.Code != http.StatusForbidden {
t.Fatalf("review token on revoke status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode 403 body: %v", err)
}
if body["permission"] != PermissionVerificationRevoke {
t.Fatalf("403 body=%+v, want verification.revoke named", body)
}
// The token that carries both rights gets through.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/revoke", "scoped-revoke",
`{"command_id":"c2","actor":"ops","reason":"impersonation","target_type":"channel","target_id":5005}`))
if rec.Code != http.StatusOK {
t.Fatalf("revoke token status=%d body=%s", rec.Code, rec.Body.String())
}
if svc.revoke.TargetType != domain.VerificationTargetChannel || svc.revoke.TargetID != 5005 ||
svc.revoke.Reason != "impersonation" {
t.Fatalf("forwarded revocation=%+v", svc.revoke)
}
}
func TestMasterTokenKeepsEveryPermissionIncludingTheLegacySurface(t *testing.T) {
svc := &captureVerificationService{app: domain.VerificationApplication{ID: 7, Version: 2}}
srv := reviewOnlyServer(svc)
// The new permissioned routes.
for _, path := range []string{"/v1/verification/applications", "/v1/verification/counts"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, path, "master", ""))
if rec.Code != http.StatusOK {
t.Fatalf("master token on %s status=%d body=%s", path, rec.Code, rec.Body.String())
}
}
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/revoke", "master",
`{"command_id":"c1","actor":"ops","reason":"impersonation","target_type":"bot","target_id":2002}`))
if rec.Code != http.StatusOK {
t.Fatalf("master token on revoke status=%d body=%s", rec.Code, rec.Body.String())
}
// And every route that predates permissions.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/accounts/set-verified", "master",
`{"command_id":"c2","actor":"ops","reason":"official","dry_run":true,"user_id":1001,"verified":true}`))
if rec.Code != http.StatusOK {
t.Fatalf("master token on the legacy surface status=%d body=%s", rec.Code, rec.Body.String())
}
}
// A bounded token must not inherit the routes that predate the permission model:
// that would turn "give the queue bot the review right" into "give it everything".
func TestScopedTokenCannotUseTheLegacySurfaceAsASideDoor(t *testing.T) {
srv := reviewOnlyServer(fakeService{})
for _, path := range []string{"/v1/accounts/set-verified", "/v1/accounts/set-frozen", "/v1/bots/delete"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, path, "scoped-review",
`{"command_id":"c1","actor":"ops","reason":"x","user_id":1001,"verified":true}`))
if rec.Code != http.StatusForbidden {
t.Fatalf("scoped token on %s status=%d body=%s, want 403", path, rec.Code, rec.Body.String())
}
}
// A scoped token that spells out the wildcard is the operator's explicit
// choice and does reach it.
wide := &Server{
token: "master",
scoped: []ScopedToken{{Name: "everything", Token: "scoped-all", Permissions: []string{PermissionAll}}},
svc: fakeService{},
}
rec := httptest.NewRecorder()
wide.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/accounts/set-verified", "scoped-all",
`{"command_id":"c1","actor":"ops","reason":"x","dry_run":true,"user_id":1001,"verified":true}`))
if rec.Code != http.StatusOK {
t.Fatalf("wildcard scoped token status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestVerificationQueueFilterAndInt64Rendering(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
svc := &captureVerificationService{app: domain.VerificationApplication{
ID: maxInt64,
ApplicantUserID: maxInt64,
TargetType: domain.VerificationTargetChannel,
TargetID: maxInt64,
TargetTitle: "Example News",
TargetUsername: "examplenews",
Category: "media",
Status: domain.VerificationStatusSubmitted,
SocialLinks: []string{"https://example.test/social"},
Version: maxInt64,
CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
}}
srv := reviewOnlyServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(
http.MethodGet,
"/v1/verification/applications?status=submitted,in_review&target_type=channel&reviewer=alice&q=examplenews&limit=25&before_id=99",
"scoped-review", "",
))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if len(svc.filter.Statuses) != 2 ||
svc.filter.Statuses[0] != domain.VerificationStatusSubmitted ||
svc.filter.Statuses[1] != domain.VerificationStatusInReview ||
svc.filter.TargetType != domain.VerificationTargetChannel ||
svc.filter.Reviewer != "alice" || svc.filter.Query != "examplenews" ||
svc.filter.Limit != 25 || svc.filter.BeforeID != 99 {
t.Fatalf("filter=%+v", svc.filter)
}
var body struct {
Applications []map[string]any `json:"applications"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode queue: %v", err)
}
if len(body.Applications) != 1 {
t.Fatalf("applications=%+v", body.Applications)
}
for _, field := range []string{"id", "applicant_user_id", "target_id", "version"} {
if body.Applications[0][field] != "9223372036854775807" {
t.Fatalf("%s = %#v, want an exact decimal string", field, body.Applications[0][field])
}
}
}
func TestVerificationQueueRejectsUnmodelledFilters(t *testing.T) {
srv := reviewOnlyServer(fakeService{})
for _, query := range []string{"?status=pending", "?target_type=group"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/applications"+query, "scoped-review", ""))
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s status=%d body=%s, want 400", query, rec.Code, rec.Body.String())
}
}
}
func TestVerificationApplicationDetailAndCounts(t *testing.T) {
svc := &captureVerificationService{
app: domain.VerificationApplication{ID: 7, TargetType: domain.VerificationTargetBot, TargetID: 2002, Version: 4},
events: []domain.VerificationApplicationEvent{{
ID: 11, ApplicationID: 7, Kind: domain.VerificationEventSubmitted,
ToStatus: domain.VerificationStatusSubmitted, CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
}},
target: domain.VerificationTarget{Type: domain.VerificationTargetBot, ID: 2002, Verified: true, Eligible: false, Reason: "already verified"},
counts: domain.VerificationStatusCounts{domain.VerificationStatusSubmitted: 3},
}
srv := reviewOnlyServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/applications/7", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("detail status=%d body=%s", rec.Code, rec.Body.String())
}
var detail struct {
Application map[string]any `json:"application"`
Events []map[string]any `json:"events"`
Target map[string]any `json:"target"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &detail); err != nil {
t.Fatalf("decode detail: %v", err)
}
if detail.Application["id"] != "7" || len(detail.Events) != 1 || detail.Events[0]["id"] != "11" {
t.Fatalf("detail=%+v", detail)
}
if detail.Target["verified"] != true || detail.Target["eligible"] != false {
t.Fatalf("target=%+v, want the live snapshot alongside the record", detail.Target)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/applications/8", "scoped-review", ""))
if rec.Code != http.StatusNotFound {
t.Fatalf("missing application status=%d body=%s, want 404", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/counts", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("counts status=%d body=%s", rec.Code, rec.Body.String())
}
var counts struct {
Counts map[string]string `json:"counts"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &counts); err != nil {
t.Fatalf("decode counts: %v", err)
}
// Every modelled status is present so the panel never tells "zero" from
// "absent", and the values are decimal strings.
if counts.Counts["submitted"] != "3" || counts.Counts["draft"] != "0" ||
counts.Counts["cancelled"] != "0" || len(counts.Counts) != 6 {
t.Fatalf("counts=%+v", counts.Counts)
}
}
func TestVerificationDecisionTakesTheApplicationIDFromThePath(t *testing.T) {
svc := &captureVerificationService{app: domain.VerificationApplication{ID: 7, Version: 4}}
srv := reviewOnlyServer(svc)
rec := httptest.NewRecorder()
// The body names a different application on purpose: the path has to win, or
// the URL would lie to the audit trail.
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/approve", "scoped-review",
`{"command_id":"c1","actor":"alice","reason":"verified","application_id":99,"version":4,"internal_note":"handover"}`))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if svc.approve.ApplicationID != 7 || svc.approve.Version != 4 ||
svc.approve.InternalNote != "handover" || svc.approve.Actor != "alice" {
t.Fatalf("forwarded approval=%+v", svc.approve)
}
}
func TestVerificationDecisionDryRunIsForwarded(t *testing.T) {
svc := &captureVerificationService{app: domain.VerificationApplication{ID: 7, Version: 4}}
srv := reviewOnlyServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/reject", "scoped-review",
`{"command_id":"dry-1","actor":"alice","reason":"press links are self-published","dry_run":true,"version":4}`))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !svc.reject.DryRun || svc.reject.Reason != "press links are self-published" {
t.Fatalf("forwarded rejection=%+v", svc.reject)
}
if !strings.Contains(rec.Body.String(), `"dry_run":true`) {
t.Fatalf("body=%s, want the dry run echoed", rec.Body.String())
}
}
func TestVerificationVersionConflictIsAnswered409(t *testing.T) {
svc := &captureVerificationService{
app: domain.VerificationApplication{ID: 7, Version: 5},
// The shape admin.codedError produces for a lost race.
decideOn: fmt.Errorf("%s: %w", admin.CodeVerificationConflict, domain.ErrVerificationVersionConflict),
}
srv := reviewOnlyServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/approve", "scoped-review",
`{"command_id":"c1","actor":"alice","reason":"verified","version":4}`))
if rec.Code != http.StatusConflict {
t.Fatalf("status=%d body=%s, want 409 for a lost optimistic-locking race", rec.Code, rec.Body.String())
}
var result admin.CommandResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode conflict: %v", err)
}
if !strings.Contains(result.Error, admin.CodeVerificationConflict) {
t.Fatalf("result=%+v, want the stable conflict code", result)
}
if !strings.Contains(result.Message, "reload") {
t.Fatalf("result message=%q, want an actionable message", result.Message)
}
}
func TestVerificationErrorStatusMapping(t *testing.T) {
cases := map[string]int{
admin.CodeVerificationNotFound: http.StatusNotFound,
admin.CodeVerificationConflict: http.StatusConflict,
admin.CodeVerificationTargetOccupied: http.StatusConflict,
admin.CodeVerificationTargetVerified: http.StatusConflict,
admin.CodeVerificationStatusInvalid: http.StatusBadRequest,
admin.CodeVerificationReasonRequired: http.StatusBadRequest,
admin.CodeVerificationTargetInvalid: http.StatusBadRequest,
admin.CodeVerificationTargetRestricted: http.StatusBadRequest,
admin.CodeVerificationTargetSystem: http.StatusBadRequest,
admin.CodeVerificationNotOwner: http.StatusBadRequest,
admin.CodeVerificationInvalid: http.StatusBadRequest,
"": http.StatusInternalServerError,
}
for code, want := range cases {
if got := verificationErrorStatus(code); got != want {
t.Fatalf("verificationErrorStatus(%q) = %d, want %d", code, got, want)
}
}
}
func TestScopedTokenNameBecomesTheAuditActorWhenNoneIsStated(t *testing.T) {
svc := &captureVerificationService{app: domain.VerificationApplication{ID: 7, Version: 4}}
srv := reviewOnlyServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/claim", "scoped-review",
`{"command_id":"c1","reason":"queue sweep","version":4}`))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
// The scoped token's configured name is its audit identity.
if svc.claim.Actor != "queue-bot" {
t.Fatalf("actor=%q, want the scoped token name", svc.claim.Actor)
}
// A stated actor is never overwritten, which is how the panel attributes an
// action to the signed-in operator.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/claim", "scoped-review",
`{"command_id":"c2","actor":"alice","reason":"queue sweep","version":4}`))
if rec.Code != http.StatusOK || svc.claim.Actor != "alice" {
t.Fatalf("status=%d actor=%q", rec.Code, svc.claim.Actor)
}
// The master token has no name, so the caller keeps having to say who acts.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/claim", "master",
`{"command_id":"c3","reason":"queue sweep","version":4}`))
if rec.Code != http.StatusOK || svc.claim.Actor != "" {
t.Fatalf("master token status=%d actor=%q, want no invented identity", rec.Code, svc.claim.Actor)
}
}
func TestPermissionSetWildcardAndMembership(t *testing.T) {
all := newPermissionSet([]string{PermissionAll})
if !all.Has(PermissionVerificationReview) || !all.Has("anything.at.all") {
t.Fatal("wildcard set refused a permission")
}
bounded := newPermissionSet([]string{" verification.review ", ""})
if !bounded.Has(PermissionVerificationReview) {
t.Fatal("bounded set dropped a padded permission")
}
if bounded.Has(PermissionVerificationRevoke) {
t.Fatal("bounded set granted an unlisted permission")
}
if newPermissionSet(nil).Has(PermissionVerificationReview) {
t.Fatal("empty set granted a permission")
}
}

View file

@ -74,15 +74,26 @@ You can control me by sending these commands:
/cancel - cancel the current operation
/help - show this message`
// botReply 是 BotFather 的一条回复。
// botReply 是内置 service bot 的一条回复。ReplyMarkup 为可选 inline keyboard
// 快照(@verifybot 的按钮式对话使用);落库前经 domain.ValidateReplyMarkup 校验。
type botReply struct {
Text string
Entities []domain.MessageEntity
Text string
Entities []domain.MessageEntity
ReplyMarkup *domain.MessageReplyMarkup
}
// HandlesBot 报告该收件人是否为内置应答 bot(messages.BotResponder 实现)。
func (s *Service) HandlesBot(botUserID int64) bool {
return s != nil && (botUserID == domain.BotFatherUserID || botUserID == domain.StickersBotUserID || botUserID == domain.ChatBotUserID)
if s == nil {
return false
}
switch botUserID {
case domain.BotFatherUserID, domain.StickersBotUserID, domain.ChatBotUserID,
domain.VerifyBotUserID, domain.VerifierBotUserID:
return true
default:
return false
}
}
// OnPrivateMessage 处理投递给内置 bot 的私聊消息(messages.BotResponder 实现)。
@ -103,6 +114,10 @@ func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg dom
go s.respondAsStickers(userID, msg)
case domain.ChatBotUserID:
go s.respondAsChatBot(userID, msg)
case domain.VerifyBotUserID:
go s.respondAsVerify(userID, msg)
case domain.VerifierBotUserID:
go s.respondAsVerifier(userID, msg)
}
}
@ -145,12 +160,24 @@ func (s *Service) sendServiceBotReplyResult(ctx context.Context, botUserID, user
if s == nil || s.messages == nil || reply.Text == "" {
return domain.SendPrivateTextResult{}, false
}
markup := reply.ReplyMarkup
if err := domain.ValidateReplyMarkup(markup); err != nil {
// 键盘校验必须先于落库(I9):结构非法的 markup 绝不写库,但正文仍然发出
// ——用户至少收到提示文本,不会因为一颗坏按钮而完全失联。
s.log.Error("service bot: invalid reply markup",
zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
markup = nil
}
if markup.IsZero() {
markup = nil
}
res, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: botUserID,
RecipientUserID: userID,
RandomID: s.botReplyRandomID(),
Message: reply.Text,
Entities: serviceBotReplyEntities(reply.Text, reply.Entities),
ReplyMarkup: markup,
Date: int(s.now().Unix()),
RecipientBlocked: s.serviceBotRecipientBlocked(ctx, botUserID, userID),
})

View file

@ -48,6 +48,30 @@ type aiChatGenerator interface {
GenerateTextStream(ctx context.Context, req domain.AITextGenerationRequest, emit func(domain.AIComposeText) error) (domain.AIComposeText, error)
}
// verificationApplications is the applicant-side surface of official platform
// verification used by the built-in @verifybot (app/verification.Service
// satisfies it as-is).
//
// It is declared as a narrow port rather than taken as a concrete service for the
// usual reason plus one specific to this feature: every verification rule --
// ownership, public username, restrictions, already-verified, cooldown, rate
// limit, the status machine -- belongs to that service, and the bot must not be
// able to reach past it. Nothing here can write a peer's verified flag.
type verificationApplications interface {
EligibleTargets(ctx context.Context, applicantUserID int64) ([]domain.VerificationTarget, error)
StartDraft(ctx context.Context, req domain.SubmitVerificationApplicationRequest) (domain.VerificationApplication, bool, error)
SaveDraft(ctx context.Context, applicantUserID, applicationID, version int64, draft domain.VerificationDraftInput) (domain.VerificationApplication, error)
Submit(ctx context.Context, applicantUserID, applicationID, version int64) (domain.VerificationApplication, error)
Cancel(ctx context.Context, applicantUserID, applicationID, version int64, reason string) (domain.VerificationApplication, error)
Draft(ctx context.Context, applicantUserID int64) (domain.VerificationApplication, error)
ApplicantApplications(ctx context.Context, applicantUserID int64, limit int) ([]domain.VerificationApplication, error)
Application(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
}
// The third-party verification ports live in verifierbot.go
// (customVerifications, verifierBotTargets): they are the built-in @verifierbot's
// only way to reach the feature, and are kept next to the dialog that uses them.
// RouterHooks 是 rpc 层回调(router 创建后经 SetRouterHooks 延迟注入,打破
// router↔bots 的构造循环;这些能力都依赖 TL/连接层边界,不能在 app 层实现):
// - RevokeBotSessions:token revoke 后撤销 bot 的全部已登录 session(删
@ -83,6 +107,9 @@ type Service struct {
stickers stickerSetCreator
installer userStickerSetInstaller
aiChat aiChatGenerator
verification verificationApplications
customVerification customVerifications
verifierTargets verifierBotTargets
telegramLogin *telegramloginapp.Service
hooks RouterHooks
textDrafts TextDraftPusher
@ -92,6 +119,13 @@ type Service struct {
now func() time.Time
chatBotStreamThrottle time.Duration
publicBaseURL string
// dialogLimiter bounds how often one applicant can drive a service-bot dialog.
// The verification service already rate-limits application creation; this is the
// separate bound on dialog traffic itself, so a script cannot spin the state
// machine (and its writes) even without ever submitting anything.
dialogLimiter store.RateLimiter
dialogRateLimit int
dialogRateWindow time.Duration
// replySeq 是回复 randomID 在 crypto/rand 失败时的兜底单调序列。
replySeq atomic.Int64
replyLocks [replyLockStripes]sync.Mutex
@ -177,6 +211,56 @@ func WithAIChatGenerator(g aiChatGenerator) Option {
}
}
// WithVerification injects the official verification service used by the
// built-in @verifybot. Without it the bot still answers, but every command
// reports that verification is unavailable rather than half-running the dialog.
func WithVerification(v verificationApplications) Option {
return func(s *Service) {
if v != nil {
s.verification = v
}
}
}
// WithCustomVerification injects the third-party verification service used by the
// built-in @verifierbot. Without it the bot still answers, but every command
// reports that third-party verification is unavailable rather than half-running the
// dialog.
func WithCustomVerification(v customVerifications) Option {
return func(s *Service) {
if v != nil {
s.customVerification = v
}
}
}
// WithVerifierTargets injects the directory of an applicant's own peers used by
// @verifierbot's subject picker. It is optional: with nothing injected the bot
// falls back to the official verification service's EligibleTargets, which
// enumerates exactly the same peers (only its eligibility verdicts, which answer a
// different question, are ignored).
func WithVerifierTargets(t verifierBotTargets) Option {
return func(s *Service) {
if t != nil {
s.verifierTargets = t
}
}
}
// WithDialogRateLimiter bounds service-bot dialog traffic per user. A zero limit
// or a nil limiter disables the bound, which is what a deployment without Redis
// gets.
func WithDialogRateLimiter(limiter store.RateLimiter, limit int, window time.Duration) Option {
return func(s *Service) {
if limiter == nil || limit <= 0 || window <= 0 {
return
}
s.dialogLimiter = limiter
s.dialogRateLimit = limit
s.dialogRateWindow = window
}
}
// WithTelegramLogin injects the OIDC application service used by BotFather.
// BotFather never writes the login tables directly.
func WithTelegramLogin(login *telegramloginapp.Service) Option {
@ -262,6 +346,33 @@ func (s *Service) SetAIChatGenerator(g aiChatGenerator) {
}
}
// SetVerification injects the official verification service after construction.
// The bots service is built before the peer directories that service depends on,
// so in the shipped process this is the wiring order that actually exists (same
// deferred-injection pattern as SetRouterHooks).
func (s *Service) SetVerification(v verificationApplications) {
if s != nil && v != nil {
s.verification = v
}
}
// SetCustomVerification injects the third-party verification service after
// construction. The bots service is built before the stores and directories that
// service depends on, so in the shipped process this is the wiring order that
// actually exists (same deferred-injection pattern as SetVerification).
func (s *Service) SetCustomVerification(v customVerifications) {
if s != nil && v != nil {
s.customVerification = v
}
}
// SetVerifierTargets injects @verifierbot's subject directory after construction.
func (s *Service) SetVerifierTargets(t verifierBotTargets) {
if s != nil && t != nil {
s.verifierTargets = t
}
}
// NewService 创建 bots 服务。
func NewService(users store.UserStore, bots store.BotStore, messages store.MessageStore, opts ...Option) *Service {
s := &Service{

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,984 @@
package bots
import (
"context"
"errors"
"sort"
"strconv"
"strings"
"testing"
"time"
verificationapp "telesrv/internal/app/verification"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// ---------------------------------------------------------------------------
// Fake verification service
// ---------------------------------------------------------------------------
// fakeVerification is an in-memory stand-in for app/verification.Service. It
// keeps the properties the bot dialog actually leans on: one draft per applicant,
// StartDraft resuming instead of duplicating, optimistic-locking versions, and
// the domain validation of the payload.
type fakeVerification struct {
targets []domain.VerificationTarget
apps map[int64]domain.VerificationApplication
nextID int64
starts int
submits int
targetsErr error
startErr error
}
func newFakeVerification(targets ...domain.VerificationTarget) *fakeVerification {
return &fakeVerification{
targets: targets,
apps: make(map[int64]domain.VerificationApplication),
nextID: 100,
}
}
func (f *fakeVerification) EligibleTargets(_ context.Context, applicantUserID int64) ([]domain.VerificationTarget, error) {
if f.targetsErr != nil {
return nil, f.targetsErr
}
if applicantUserID <= 0 {
return nil, domain.ErrVerificationApplicationInvalid
}
return append([]domain.VerificationTarget(nil), f.targets...), nil
}
func (f *fakeVerification) draftFor(applicantUserID int64) (domain.VerificationApplication, bool) {
for _, app := range f.apps {
if app.ApplicantUserID == applicantUserID && app.Status == domain.VerificationStatusDraft {
return app, true
}
}
return domain.VerificationApplication{}, false
}
func (f *fakeVerification) StartDraft(_ context.Context, req domain.SubmitVerificationApplicationRequest) (domain.VerificationApplication, bool, error) {
f.starts++
if app, found := f.draftFor(req.ApplicantUserID); found {
return app, false, nil
}
if f.startErr != nil {
return domain.VerificationApplication{}, false, f.startErr
}
var target domain.VerificationTarget
for _, candidate := range f.targets {
if candidate.Type == req.TargetType && candidate.ID == req.TargetID {
target = candidate
}
}
if target.ID == 0 {
return domain.VerificationApplication{}, false, domain.ErrVerificationTargetInvalid
}
if !target.Eligible {
return domain.VerificationApplication{}, false, domain.ErrVerificationTargetAlreadyVerified
}
f.nextID++
app := domain.VerificationApplication{
ID: f.nextID,
ApplicantUserID: req.ApplicantUserID,
TargetType: target.Type,
TargetID: target.ID,
TargetTitle: target.Title,
TargetUsername: target.Username,
Status: domain.VerificationStatusDraft,
CreatedAt: time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC),
Version: 1,
}
f.apps[app.ID] = app
return app, true, nil
}
func (f *fakeVerification) SaveDraft(_ context.Context, applicantUserID, applicationID, version int64, draft domain.VerificationDraftInput) (domain.VerificationApplication, error) {
app, found := f.apps[applicationID]
if !found || app.ApplicantUserID != applicantUserID {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if app.Version != version {
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
}
if app.Status != domain.VerificationStatusDraft {
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
}
if err := draft.ValidateDraft(); err != nil {
return domain.VerificationApplication{}, err
}
draft = draft.Normalize()
app.Category = draft.Category
app.Description = draft.Description
app.OfficialWebsite = draft.OfficialWebsite
app.SocialLinks = draft.SocialLinks
app.PressLinks = draft.PressLinks
app.AdditionalNote = draft.AdditionalNote
app.Version++
f.apps[applicationID] = app
return app, nil
}
func (f *fakeVerification) Submit(_ context.Context, applicantUserID, applicationID, version int64) (domain.VerificationApplication, error) {
app, found := f.apps[applicationID]
if !found || app.ApplicantUserID != applicantUserID {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if app.Version != version {
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
}
if !domain.CanTransitionVerificationStatus(app.Status, domain.VerificationStatusSubmitted) {
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
}
if err := (domain.VerificationDraftInput{
Category: app.Category,
Description: app.Description,
OfficialWebsite: app.OfficialWebsite,
SocialLinks: app.SocialLinks,
PressLinks: app.PressLinks,
AdditionalNote: app.AdditionalNote,
}).ValidateForSubmission(); err != nil {
return domain.VerificationApplication{}, err
}
f.submits++
app.Status = domain.VerificationStatusSubmitted
app.SubmittedAt = time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
app.Version++
f.apps[applicationID] = app
return app, nil
}
func (f *fakeVerification) Cancel(_ context.Context, applicantUserID, applicationID, version int64, reason string) (domain.VerificationApplication, error) {
app, found := f.apps[applicationID]
if !found || app.ApplicantUserID != applicantUserID {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if app.Version != version {
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
}
if !domain.CanTransitionVerificationStatus(app.Status, domain.VerificationStatusCancelled) {
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
}
app.Status = domain.VerificationStatusCancelled
app.DecisionReason = reason
app.Version++
f.apps[applicationID] = app
return app, nil
}
func (f *fakeVerification) Draft(_ context.Context, applicantUserID int64) (domain.VerificationApplication, error) {
if app, found := f.draftFor(applicantUserID); found {
return app, nil
}
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
func (f *fakeVerification) ApplicantApplications(_ context.Context, applicantUserID int64, limit int) ([]domain.VerificationApplication, error) {
out := make([]domain.VerificationApplication, 0, len(f.apps))
for _, app := range f.apps {
if app.ApplicantUserID == applicantUserID {
out = append(out, app)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (f *fakeVerification) Application(_ context.Context, applicationID int64) (domain.VerificationApplication, error) {
if app, found := f.apps[applicationID]; found {
return app, nil
}
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
var _ verificationApplications = (*fakeVerification)(nil)
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
func verifyChannelTarget() domain.VerificationTarget {
return domain.VerificationTarget{
Type: domain.VerificationTargetChannel, ID: 7001,
Title: "Example News", Username: "examplenews", AccessHash: 42, Eligible: true,
}
}
func verifyBotTarget() domain.VerificationTarget {
return domain.VerificationTarget{
Type: domain.VerificationTargetBot, ID: 8002,
Title: "Example Bot", Username: "examplebot", Eligible: true,
}
}
func newVerifyBotTestService(t *testing.T, verification verificationApplications, opts ...Option) (*Service, *memory.UserStore, *memory.MessageStore) {
t.Helper()
users := memory.NewUserStore()
bots := memory.NewBotStore(users)
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
all := append([]Option{WithVerification(verification)}, opts...)
return NewService(users, bots, messages, all...), users, messages
}
// verifyBotReplies returns every @verifybot message in the user's box, oldest
// first.
func verifyBotReplies(t *testing.T, messages *memory.MessageStore, userID int64) []domain.Message {
t.Helper()
list, err := messages.ListByUser(context.Background(), userID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.VerifyBotUserID},
Limit: 200,
})
if err != nil {
t.Fatalf("list @verifybot history: %v", err)
}
out := make([]domain.Message, 0, len(list.Messages))
for _, msg := range list.Messages {
if msg.From.ID == domain.VerifyBotUserID {
out = append(out, msg)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func latestVerifyReply(t *testing.T, messages *memory.MessageStore, userID int64) domain.Message {
t.Helper()
replies := verifyBotReplies(t, messages, userID)
if len(replies) == 0 {
t.Fatal("no @verifybot reply")
}
latest := replies[len(replies)-1]
// Every keyboard the bot renders must be a valid, persistable markup: the send
// path validates before storing, so an invalid one would silently vanish.
if err := domain.ValidateReplyMarkup(latest.ReplyMarkup); err != nil {
t.Fatalf("reply markup invalid: %v (%+v)", err, latest.ReplyMarkup)
}
for _, row := range verifyInlineRows(latest) {
for _, button := range row {
if len(button.Data) > domain.MaxCallbackDataLen {
t.Fatalf("callback data %q is %d bytes, limit is %d", button.Data, len(button.Data), domain.MaxCallbackDataLen)
}
}
}
return latest
}
func verifyInlineRows(msg domain.Message) [][]domain.MarkupButton {
if msg.ReplyMarkup == nil {
return nil
}
return msg.ReplyMarkup.Inline
}
// sendToVerifyBot drives the responder synchronously, bypassing the
// OnPrivateMessage goroutine dispatch for determinism (the same shortcut the
// BotFather and @Stickers tests take).
func sendToVerifyBot(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64, text string) domain.Message {
t.Helper()
svc.respondAsVerify(userID, domain.Message{
From: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.VerifyBotUserID},
Body: text,
})
return latestVerifyReply(t, messages, userID)
}
func verifyButtonData(msg domain.Message, label string) ([]byte, bool) {
for _, row := range verifyInlineRows(msg) {
for _, button := range row {
if button.Type == domain.MarkupButtonCallback && strings.Contains(button.Text, label) {
return append([]byte(nil), button.Data...), true
}
}
}
return nil, false
}
// pressVerifyCallbackData drives the internal callback path with raw data, the
// way rpc.Router does once it has validated the click.
func pressVerifyCallbackData(t *testing.T, svc *Service, userID int64, msg domain.Message, data []byte) domain.BotCallbackAnswer {
t.Helper()
if len(data) > domain.MaxCallbackDataLen {
t.Fatalf("callback data too long: %d bytes", len(data))
}
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
ID: 1,
BotUserID: domain.VerifyBotUserID,
UserID: userID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
MessageID: msg.ID,
Data: data,
})
if err != nil {
t.Fatalf("callback query: %v", err)
}
if !handled {
t.Fatal("callback query reported unhandled for @verifybot")
}
return answer
}
func pressVerifyButton(t *testing.T, svc *Service, userID int64, msg domain.Message, label string) domain.BotCallbackAnswer {
t.Helper()
data, found := verifyButtonData(msg, label)
if !found {
t.Fatalf("button %q is not in the keyboard of message %d: %+v", label, msg.ID, msg.ReplyMarkup)
}
return pressVerifyCallbackData(t, svc, userID, msg, data)
}
const (
verifyTestDescription = "Example News is the daily newsroom of the Example Foundation, publishing since 2015."
verifyTestWebsite = "https://news.example.com"
verifyTestPressLinks = "https://press.example.org/story-one\nhttps://media.example.net/story-two"
)
// runVerifyApplication walks the whole dialog up to (but not including) Submit and
// returns the summary message.
func runVerifyApplication(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64) domain.Message {
t.Helper()
intro := sendToVerifyBot(t, svc, messages, userID, "/start")
pressVerifyButton(t, svc, userID, intro, verifyApplyButtonText)
picker := latestVerifyReply(t, messages, userID)
pressVerifyButton(t, svc, userID, picker, "@examplenews")
categories := latestVerifyReply(t, messages, userID)
pressVerifyButton(t, svc, userID, categories, "Media outlet")
if got := latestVerifyReply(t, messages, userID); !strings.Contains(got.Body, "describe the subject") {
t.Fatalf("after category, reply = %q", got.Body)
}
sendToVerifyBot(t, svc, messages, userID, verifyTestDescription)
social := sendToVerifyBot(t, svc, messages, userID, verifyTestWebsite)
if !strings.Contains(social.Body, "social media") {
t.Fatalf("after website, reply = %q", social.Body)
}
pressVerifyButton(t, svc, userID, social, verifySkipButtonText)
if got := latestVerifyReply(t, messages, userID); !strings.Contains(got.Body, "press coverage") {
t.Fatalf("after skipping social links, reply = %q", got.Body)
}
note := sendToVerifyBot(t, svc, messages, userID, verifyTestPressLinks)
pressVerifyButton(t, svc, userID, note, verifySkipButtonText)
return latestVerifyReply(t, messages, userID)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func TestVerifyBotStartExplainsAndOffersApplyButton(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7100")
if !svc.HandlesBot(domain.VerifyBotUserID) {
t.Fatal("service should handle @verifybot")
}
reply := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
for _, want := range []string{"official", "public @username", "/new", "/help"} {
if !strings.Contains(reply.Body, want) {
t.Fatalf("/start reply missing %q: %q", want, reply.Body)
}
}
data, found := verifyButtonData(reply, verifyApplyButtonText)
if !found {
t.Fatalf("/start reply has no apply button: %+v", reply.ReplyMarkup)
}
if !strings.HasPrefix(string(data), verifyCallbackDataPrefix) {
t.Fatalf("callback data %q is not a @verifybot token", data)
}
if fake.starts != 0 {
t.Fatalf("StartDraft called %d times on /start, want 0", fake.starts)
}
}
func TestVerifyBotFullApplicationFlowFilesExactlyOneApplication(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget(), verifyBotTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7101")
summary := runVerifyApplication(t, svc, messages, owner.ID)
for _, want := range []string{"Example News", "Media outlet", verifyTestWebsite, "press.example.org/story-one", verifySubmitButtonText} {
if !strings.Contains(summary.Body, want) {
t.Fatalf("summary missing %q: %q", want, summary.Body)
}
}
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
filed := latestVerifyReply(t, messages, owner.ID)
if !strings.Contains(filed.Body, "#101") || !strings.Contains(filed.Body, "/status") {
t.Fatalf("submitted reply = %q", filed.Body)
}
if fake.submits != 1 || len(fake.apps) != 1 {
t.Fatalf("submits=%d applications=%d, want exactly one of each", fake.submits, len(fake.apps))
}
app := fake.apps[101]
if app.Status != domain.VerificationStatusSubmitted {
t.Fatalf("application status = %q", app.Status)
}
if app.Category != "media" || app.OfficialWebsite != verifyTestWebsite || len(app.PressLinks) != 2 {
t.Fatalf("stored application = %+v", app)
}
if app.TargetID != 7001 || app.TargetType != domain.VerificationTargetChannel {
t.Fatalf("stored target = %s/%d", app.TargetType, app.TargetID)
}
}
// The target buttons must not leak the peer they stand for: the whole point of the
// token table is that a click cannot name a peer at all.
func TestVerifyBotCallbackDataCarriesNoTargetIdentity(t *testing.T) {
target := verifyChannelTarget()
fake := newFakeVerification(target)
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7102")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
picker := latestVerifyReply(t, messages, owner.ID)
buttons := 0
for _, row := range verifyInlineRows(picker) {
for _, button := range row {
buttons++
data := string(button.Data)
// Structural assertion rather than a substring hunt: the data is the
// prefix plus an opaque hex token and nothing else, so it is incapable of
// encoding a peer id, an access hash, a username or a peer type.
token, ok := strings.CutPrefix(data, verifyCallbackDataPrefix)
if !ok || len(token) != 2*verifyOptionTokenBytes {
t.Fatalf("callback data %q is not <prefix><token>", data)
}
for _, c := range token {
if !strings.ContainsRune("0123456789abcdef", c) {
t.Fatalf("callback data %q carries non-token bytes", data)
}
}
for _, forbidden := range []string{target.Username, string(target.Type)} {
if strings.Contains(data, forbidden) {
t.Fatalf("callback data %q leaks %q", data, forbidden)
}
}
}
}
if buttons == 0 {
t.Fatal("target picker has no buttons")
}
// The token is minted per render, so the same target never has a stable,
// guessable identifier on the wire.
firstData, _ := verifyButtonData(picker, "@"+target.Username)
sendToVerifyBot(t, svc, messages, owner.ID, "/new")
secondData, found := verifyButtonData(latestVerifyReply(t, messages, owner.ID), "@"+target.Username)
if !found {
t.Fatal("re-rendered picker has no target button")
}
if string(firstData) == string(secondData) {
t.Fatalf("token %q is stable across renders", firstData)
}
}
func TestVerifyBotRepeatedButtonPressIsIdempotent(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7103")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
picker := latestVerifyReply(t, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
first := latestVerifyReply(t, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
second := latestVerifyReply(t, messages, owner.ID)
if first.Body != second.Body {
t.Fatalf("repeat target press changed the answer:\nfirst = %q\nsecond = %q", first.Body, second.Body)
}
if len(fake.apps) != 1 {
t.Fatalf("applications = %d after pressing the same target twice, want 1", len(fake.apps))
}
}
// The same must hold for the terminal action: a double-tapped Submit files one
// application and repeats the same confirmation.
func TestVerifyBotRepeatedSubmitFilesOneApplication(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7121")
summary := runVerifyApplication(t, svc, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
firstFiled := latestVerifyReply(t, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
secondFiled := latestVerifyReply(t, messages, owner.ID)
if firstFiled.Body != secondFiled.Body {
t.Fatalf("repeat submit changed the answer:\nfirst = %q\nsecond = %q", firstFiled.Body, secondFiled.Body)
}
if fake.submits != 1 || len(fake.apps) != 1 {
t.Fatalf("submits=%d applications=%d after double submit, want 1/1", fake.submits, len(fake.apps))
}
}
func TestVerifyBotForgedCallbackTokenIsRefused(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7104")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
before := len(verifyBotReplies(t, messages, owner.ID))
// A token that was never minted for this user, and a plausible-looking
// hand-written one: both resolve only through the user's own state, so both are
// refused without any side effect.
for _, data := range [][]byte{
[]byte(verifyCallbackDataPrefix + "deadbeefcafe"),
[]byte("tgt:channel:7001"),
[]byte(verifyCallbackDataPrefix),
} {
answer := pressVerifyCallbackData(t, svc, owner.ID, intro, data)
if !answer.Alert || !strings.Contains(answer.Message, "no longer active") {
t.Fatalf("forged data %q answered %+v, want an explaining alert", data, answer)
}
}
if got := len(verifyBotReplies(t, messages, owner.ID)); got != before {
t.Fatalf("forged callbacks produced %d new messages", got-before)
}
if fake.starts != 0 || len(fake.apps) != 0 {
t.Fatalf("forged callbacks touched the service: starts=%d apps=%d", fake.starts, len(fake.apps))
}
}
// A token minted for one applicant must be meaningless for another: resolution
// goes through the clicking user's own chat state only.
func TestVerifyBotTokenFromAnotherUserIsRefused(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
victim := newOwner(t, users, "+7105")
attacker := newOwner(t, users, "+7106")
intro := sendToVerifyBot(t, svc, messages, victim.ID, "/start")
pressVerifyButton(t, svc, victim.ID, intro, verifyApplyButtonText)
picker := latestVerifyReply(t, messages, victim.ID)
stolen, found := verifyButtonData(picker, "@examplenews")
if !found {
t.Fatal("victim picker has no target button")
}
sendToVerifyBot(t, svc, messages, attacker.ID, "/start")
attackerIntro := latestVerifyReply(t, messages, attacker.ID)
answer := pressVerifyCallbackData(t, svc, attacker.ID, attackerIntro, stolen)
if !answer.Alert {
t.Fatalf("stolen token answered %+v, want an alert", answer)
}
for _, app := range fake.apps {
if app.ApplicantUserID == attacker.ID {
t.Fatalf("stolen token created an application for the attacker: %+v", app)
}
}
}
func TestVerifyBotPressLinkMinimumIsEnforced(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7107")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
social := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestWebsite)
pressVerifyButton(t, svc, owner.ID, social, verifySkipButtonText)
tooFew := sendToVerifyBot(t, svc, messages, owner.ID, "https://press.example.org/story-one")
if !strings.Contains(tooFew.Body, strconv.Itoa(domain.MinVerificationPressLinks)) {
t.Fatalf("single press link accepted or unexplained: %q", tooFew.Body)
}
if len(fake.apps[101].PressLinks) != 0 {
t.Fatalf("press links stored despite refusal: %+v", fake.apps[101].PressLinks)
}
accepted := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestPressLinks)
if !strings.Contains(accepted.Body, "reviewers should know") {
t.Fatalf("two press links did not advance the dialog: %q", accepted.Body)
}
if len(fake.apps[101].PressLinks) != 2 {
t.Fatalf("press links = %+v, want two stored", fake.apps[101].PressLinks)
}
}
func TestVerifyBotRejectsInvalidLinksWithAReason(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7108")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
// Not a URL, a non-web scheme, and an address the domain refuses as
// non-public (which is also what keeps a submitted link from becoming an SSRF
// probe).
for _, bad := range []string{"my site", "ftp://example.com", "http://127.0.0.1/admin", "https://localhost/x"} {
reply := sendToVerifyBot(t, svc, messages, owner.ID, bad)
if !strings.Contains(reply.Body, "http:// or https://") {
t.Fatalf("website %q answered %q, want the link rules", bad, reply.Body)
}
if fake.apps[101].OfficialWebsite != "" {
t.Fatalf("website %q was stored", bad)
}
}
// A short description is refused with the actual bar, not a generic error.
shortDesc := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestWebsite)
if !strings.Contains(shortDesc.Body, "social media") {
t.Fatalf("valid website not accepted: %q", shortDesc.Body)
}
}
func TestVerifyBotDescriptionMinimumIsExplained(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7109")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
reply := sendToVerifyBot(t, svc, messages, owner.ID, "a newsroom")
if !strings.Contains(reply.Body, strconv.Itoa(domain.MinVerificationDescriptionLength)) {
t.Fatalf("short description answered %q, want the minimum length", reply.Body)
}
if fake.apps[101].Description != "" {
t.Fatalf("short description was stored: %q", fake.apps[101].Description)
}
}
func TestVerifyBotGlobalCommandsWorkMidStep(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7110")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
// /help in the middle of the description step answers help and keeps the step.
help := sendToVerifyBot(t, svc, messages, owner.ID, "/help")
if help.Body != verifyBotHelpText {
t.Fatalf("/help mid-step = %q", help.Body)
}
status := sendToVerifyBot(t, svc, messages, owner.ID, "/status")
if !strings.Contains(status.Body, "#101") {
t.Fatalf("/status mid-step = %q", status.Body)
}
resumed := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
if !strings.Contains(resumed.Body, "official website") {
t.Fatalf("description not accepted after global commands: %q", resumed.Body)
}
if fake.apps[101].Description != verifyTestDescription {
t.Fatalf("description = %q, want the step to have survived", fake.apps[101].Description)
}
// An unknown command is never swallowed as a field value.
unknown := sendToVerifyBot(t, svc, messages, owner.ID, "/nope")
if !strings.Contains(unknown.Body, "do not know that command") {
t.Fatalf("unknown command = %q", unknown.Body)
}
if fake.apps[101].OfficialWebsite != "" {
t.Fatalf("unknown command stored as a website: %q", fake.apps[101].OfficialWebsite)
}
}
func TestVerifyBotStatusListsApplicationsWithoutInternalNotes(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7111")
if empty := sendToVerifyBot(t, svc, messages, owner.ID, "/status"); empty.Body != verifyNoApplicationsText {
t.Fatalf("/status without applications = %q", empty.Body)
}
fake.apps[500] = domain.VerificationApplication{
ID: 500, ApplicantUserID: owner.ID,
TargetType: domain.VerificationTargetChannel, TargetID: 7001,
TargetTitle: "Example News", TargetUsername: "examplenews",
Status: domain.VerificationStatusRejected,
DecisionReason: "the linked coverage does not mention the channel",
InternalNote: "applicant argued with the reviewer",
ReviewedAt: time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC),
Version: 4,
}
fake.apps[501] = domain.VerificationApplication{
ID: 501, ApplicantUserID: owner.ID,
TargetType: domain.VerificationTargetBot, TargetID: 8002, TargetUsername: "examplebot",
Status: domain.VerificationStatusSubmitted,
SubmittedAt: time.Date(2026, 7, 25, 9, 0, 0, 0, time.UTC),
Version: 2,
}
reply := sendToVerifyBot(t, svc, messages, owner.ID, "/status")
for _, want := range []string{"#500", "#501", "@examplebot", "does not mention the channel", "2026-07-20"} {
if !strings.Contains(reply.Body, want) {
t.Fatalf("/status missing %q: %q", want, reply.Body)
}
}
if strings.Contains(reply.Body, "argued with the reviewer") {
t.Fatalf("/status leaked the internal note: %q", reply.Body)
}
}
func TestVerifyBotCancelWithdrawsTheOpenApplication(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7112")
if nothing := sendToVerifyBot(t, svc, messages, owner.ID, "/cancel"); nothing.Body != verifyNothingToCancelText {
t.Fatalf("/cancel with nothing open = %q", nothing.Body)
}
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
cancelled := sendToVerifyBot(t, svc, messages, owner.ID, "/cancel")
if !strings.Contains(cancelled.Body, "#101") || !strings.Contains(cancelled.Body, "withdrawn") {
t.Fatalf("/cancel = %q", cancelled.Body)
}
if fake.apps[101].Status != domain.VerificationStatusCancelled {
t.Fatalf("application status = %q after /cancel", fake.apps[101].Status)
}
// The dialog is gone with it, so a stale button cannot revive it.
idle := sendToVerifyBot(t, svc, messages, owner.ID, "still here?")
if idle.Body != verifyBotIdleText {
t.Fatalf("after /cancel, plain text = %q", idle.Body)
}
}
func TestVerifyBotCancelButtonWithdrawsFromInsideTheForm(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7113")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
categories := latestVerifyReply(t, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, categories, verifyCancelButtonText)
if reply := latestVerifyReply(t, messages, owner.ID); !strings.Contains(reply.Body, "withdrawn") {
t.Fatalf("cancel button = %q", reply.Body)
}
if fake.apps[101].Status != domain.VerificationStatusCancelled {
t.Fatalf("application status = %q after the cancel button", fake.apps[101].Status)
}
}
func TestVerifyBotHelpAndIdleText(t *testing.T) {
fake := newFakeVerification()
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7114")
help := sendToVerifyBot(t, svc, messages, owner.ID, "/help")
for _, want := range []string{"/new", "/status", "/cancel", "/help"} {
if !strings.Contains(help.Body, want) {
t.Fatalf("/help missing %q: %q", want, help.Body)
}
}
assertReplyEntityText(t, help, domain.MessageEntityBotCommand, "/new")
// Nothing to verify: the requirement is stated instead of an empty picker.
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyNoTargetsText {
t.Fatalf("/new with no candidates = %q", reply.Body)
}
}
func TestVerifyBotShowsIneligibleTargetsWithTheirReason(t *testing.T) {
verified := verifyChannelTarget()
verified.Eligible = false
verified.Verified = true
verified.Reason = domain.ErrVerificationTargetAlreadyVerified.Error()
fake := newFakeVerification(verified)
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7115")
picker := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
if !strings.Contains(picker.Body, "cannot be filed") && !strings.Contains(picker.Body, verifyNoEligibleText) {
t.Fatalf("picker with only ineligible candidates = %q", picker.Body)
}
answer := pressVerifyButton(t, svc, owner.ID, picker, "unavailable")
if !answer.Alert || !strings.Contains(answer.Message, "already verified") {
t.Fatalf("ineligible button answered %+v, want the reason", answer)
}
if fake.starts != 0 || len(fake.apps) != 0 {
t.Fatalf("ineligible button reached the service: starts=%d apps=%d", fake.starts, len(fake.apps))
}
}
func TestVerifyBotNewResumesTheOpenDraft(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7116")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
resumed := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
if !strings.Contains(resumed.Body, "#101") || !strings.Contains(resumed.Body, "official website") {
t.Fatalf("/new mid-draft = %q, want a resume at the website step", resumed.Body)
}
if len(fake.apps) != 1 {
t.Fatalf("applications = %d after /new mid-draft, want 1", len(fake.apps))
}
}
func TestVerifyBotWithoutServiceReportsUnavailable(t *testing.T) {
svc, users, messages := newVerifyBotTestService(t, nil)
owner := newOwner(t, users, "+7117")
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyUnavailableText {
t.Fatalf("/new without a verification service = %q", reply.Body)
}
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/help"); reply.Body != verifyBotHelpText {
t.Fatalf("/help without a verification service = %q", reply.Body)
}
}
func TestVerifyBotCallbackForForeignBotIsNotClaimed(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, _, _ := newVerifyBotTestService(t, fake)
if _, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
BotUserID: 555111, UserID: 900, Data: []byte("vb:whatever"),
}); handled || err != nil {
t.Fatalf("foreign bot callback handled=%v err=%v, want (false, nil)", handled, err)
}
// A built-in bot with no keyboards is claimed but answered empty, so the click
// cannot hang for the whole callback timeout.
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
BotUserID: domain.BotFatherUserID, UserID: 900, Data: []byte("x"),
})
if !handled || err != nil || answer.Message != "" {
t.Fatalf("BotFather callback = (%+v, %v, %v)", answer, handled, err)
}
}
func TestVerifyBotSendVerificationNoticeNeverLeaksInternalNote(t *testing.T) {
fake := newFakeVerification()
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7118")
ctx := context.Background()
app := domain.VerificationApplication{
ID: 4242, ApplicantUserID: owner.ID,
TargetType: domain.VerificationTargetChannel, TargetID: 7001,
TargetTitle: "Example News", TargetUsername: "examplenews",
DecisionReason: "the coverage you linked does not mention the channel",
InternalNote: "reviewer note: applicant is a repeat filer, escalate next time",
}
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindApproved); err != nil {
t.Fatalf("approved notice: %v", err)
}
approved := latestVerifyReply(t, messages, owner.ID)
for _, want := range []string{"#4242", "Example News", "@examplenews", "approved"} {
if !strings.Contains(approved.Body, want) {
t.Fatalf("approved notice missing %q: %q", want, approved.Body)
}
}
if strings.Contains(approved.Body, "repeat filer") {
t.Fatalf("approved notice leaked the internal note: %q", approved.Body)
}
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindRejected); err != nil {
t.Fatalf("rejected notice: %v", err)
}
rejected := latestVerifyReply(t, messages, owner.ID)
if !strings.Contains(rejected.Body, "#4242") || !strings.Contains(rejected.Body, "does not mention the channel") {
t.Fatalf("rejected notice = %q", rejected.Body)
}
if strings.Contains(rejected.Body, "repeat filer") || strings.Contains(rejected.Body, "escalate") {
t.Fatalf("rejected notice leaked the internal note: %q", rejected.Body)
}
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindRevoked); err != nil {
t.Fatalf("revoked notice: %v", err)
}
revoked := latestVerifyReply(t, messages, owner.ID)
if !strings.Contains(revoked.Body, "revoked") || strings.Contains(revoked.Body, "repeat filer") {
t.Fatalf("revoked notice = %q", revoked.Body)
}
// An unknown kind is reported rather than delivered as an empty message: the
// outbox row must stay pending instead of being marked delivered.
before := len(verifyBotReplies(t, messages, owner.ID))
if err := svc.SendVerificationNotice(ctx, owner.ID, app, "teleported"); err == nil {
t.Fatal("unknown notice kind reported success")
}
if got := len(verifyBotReplies(t, messages, owner.ID)); got != before {
t.Fatalf("unknown notice kind sent %d messages", got-before)
}
if err := svc.SendVerificationNotice(ctx, 0, app, verificationapp.NoticeKindApproved); err == nil {
t.Fatal("empty recipient reported success")
}
}
func TestVerifyBotSubmitBouncesAnIncompleteApplication(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7119")
summary := runVerifyApplication(t, svc, messages, owner.ID)
// Simulate a payload that lost a required field between rendering the summary
// and the press: Submit must send the applicant back, not file a broken record.
app := fake.apps[101]
app.PressLinks = nil
app.Version++
fake.apps[101] = app
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
bounced := latestVerifyReply(t, messages, owner.ID)
if !strings.Contains(bounced.Body, "press coverage") {
t.Fatalf("incomplete submit = %q, want the press step", bounced.Body)
}
if fake.submits != 0 {
t.Fatalf("submits = %d for an incomplete application", fake.submits)
}
}
func TestVerifyBotPolicyRefusalsAreExplained(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
fake.startErr = domain.ErrVerificationRateLimited
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7120")
picker := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
if reply := latestVerifyReply(t, messages, owner.ID); !strings.Contains(reply.Body, "limit on open applications") {
t.Fatalf("rate-limited StartDraft = %q", reply.Body)
}
fake.targetsErr = verificationapp.ErrDisabled
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyUnavailableText {
t.Fatalf("disabled verification = %q", reply.Body)
}
if !errors.Is(fake.targetsErr, verificationapp.ErrDisabled) {
t.Fatal("test setup lost the sentinel")
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -55,9 +55,9 @@ const tdesktopClient = "tdesktop"
//
// WebK directly calls Array.some on fragment_prefixes while rendering user profiles,
// so this compatibility key must always remain an array, even when it is empty.
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stargifts_pinned_to_top_limit":6,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stargifts_pinned_to_top_limit":6,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"bot_verification_description_length_limit":70,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
const defaultAppConfigHash = 24 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
const defaultAppConfigHash = 25 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
// Service 提供客户端启动配置与国家区号目录。
//

View file

@ -45,39 +45,40 @@ func TestAppConfigPremiumKeys(t *testing.T) {
t.Fatalf("fragment_prefixes = %#v, want [\"888\"]", decoded["fragment_prefixes"])
}
wantNumbers := map[string]float64{
"reactions_user_max_default": 1,
"reactions_user_max_premium": 3,
"boosts_channel_level_max": 100,
"stargifts_pinned_to_top_limit": 6,
"about_length_limit_default": 70,
"about_length_limit_premium": 140,
"dialogs_pinned_limit_default": 5,
"dialogs_pinned_limit_premium": 10,
"dialogs_folder_pinned_limit_default": 100,
"dialogs_folder_pinned_limit_premium": 200,
"saved_dialogs_pinned_limit_default": 5,
"saved_dialogs_pinned_limit_premium": 100,
"caption_length_limit_default": 1024,
"caption_length_limit_premium": 4096,
"channels_limit_default": 500,
"channels_limit_premium": 1000,
"dialog_filters_limit_default": 10,
"dialog_filters_limit_premium": 20,
"chatlist_update_period": 3600,
"chatlist_invites_limit_default": 3,
"chatlist_invites_limit_premium": 20,
"chatlists_joined_limit_default": 2,
"chatlists_joined_limit_premium": 20,
"upload_max_fileparts_default": 4000,
"upload_max_fileparts_premium": 8000,
"aicompose_tone_examples_num": 3,
"aicompose_tone_title_length_max": 12,
"aicompose_tone_prompt_length_max": 1024,
"aicompose_tone_saved_limit_default": 5,
"aicompose_tone_saved_limit_premium": 20,
"stories_stealth_future_period": 1500,
"stories_stealth_past_period": 300,
"stories_stealth_cooldown_period": 10800,
"reactions_user_max_default": 1,
"reactions_user_max_premium": 3,
"boosts_channel_level_max": 100,
"stargifts_pinned_to_top_limit": 6,
"about_length_limit_default": 70,
"about_length_limit_premium": 140,
"bot_verification_description_length_limit": 70,
"dialogs_pinned_limit_default": 5,
"dialogs_pinned_limit_premium": 10,
"dialogs_folder_pinned_limit_default": 100,
"dialogs_folder_pinned_limit_premium": 200,
"saved_dialogs_pinned_limit_default": 5,
"saved_dialogs_pinned_limit_premium": 100,
"caption_length_limit_default": 1024,
"caption_length_limit_premium": 4096,
"channels_limit_default": 500,
"channels_limit_premium": 1000,
"dialog_filters_limit_default": 10,
"dialog_filters_limit_premium": 20,
"chatlist_update_period": 3600,
"chatlist_invites_limit_default": 3,
"chatlist_invites_limit_premium": 20,
"chatlists_joined_limit_default": 2,
"chatlists_joined_limit_premium": 20,
"upload_max_fileparts_default": 4000,
"upload_max_fileparts_premium": 8000,
"aicompose_tone_examples_num": 3,
"aicompose_tone_title_length_max": 12,
"aicompose_tone_prompt_length_max": 1024,
"aicompose_tone_saved_limit_default": 5,
"aicompose_tone_saved_limit_premium": 20,
"stories_stealth_future_period": 1500,
"stories_stealth_past_period": 300,
"stories_stealth_cooldown_period": 10800,
}
for key, want := range wantNumbers {
got, ok := decoded[key].(float64)

View file

@ -0,0 +1,453 @@
// Package rating implements the composite account rating use cases: reading the
// stored projection, recomputing it from the raw contribution signals, and
// applying operator adjustments through the contribution ledger.
//
// This is an admin-only local model, not Telegram's Stars Rating protocol
// surface. The service gathers signals, applies the configured weights and
// pending-delay policy, and persists the result under optimistic concurrency.
package rating
import (
"context"
"errors"
"fmt"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/store"
)
const (
// defaultPendingDelay parks a rating increase for a day, matching the
// shipped TELESRV_RATING_PENDING_DELAY default.
defaultPendingDelay = 24 * time.Hour
// defaultStaleAfter is the recompute horizon used when none is configured.
defaultStaleAfter = 6 * time.Hour
// defaultListLimit / maxListLimit bound one leaderboard page.
defaultListLimit = 50
maxListLimit = 200
// defaultEventLimit / maxEventLimit bound one ledger page.
defaultEventLimit = 50
maxEventLimit = 200
// defaultRecomputeBatch / maxRecomputeBatch bound one worker cycle.
defaultRecomputeBatch = 500
maxRecomputeBatch = 10000
)
// ErrDisabled reports that the local composite rating feature is switched off.
// Reads degrade to an empty admin projection; writes are refused so an operator
// never believes an adjustment was recorded when it was not.
var ErrDisabled = errors.New("account rating is disabled")
// Service is the composite account rating use-case layer.
type Service struct {
store store.AccountRatingStore
weights domain.AccountRatingWeights
pendingDelay time.Duration
staleAfter time.Duration
enabled bool
now func() time.Time
log *zap.Logger
}
// Option adjusts optional service dependencies.
type Option func(*Service)
// WithStore injects the rating read model and ledger store.
func WithStore(st store.AccountRatingStore) Option {
return func(s *Service) { s.store = st }
}
// WithWeights installs the composite formula. An invalid set is rejected in
// favour of the shipped defaults, so a misconfigured deployment produces a
// conservative rating instead of an inconsistent one.
func WithWeights(weights domain.AccountRatingWeights) Option {
return func(s *Service) {
if err := weights.Validate(); err != nil {
return
}
s.weights = weights
}
}
// WithPendingDelay configures how long a rating increase stays parked as
// pending. Zero applies every change immediately.
func WithPendingDelay(delay time.Duration) Option {
return func(s *Service) {
if delay >= 0 {
s.pendingDelay = delay
}
}
}
// WithStaleAfter configures the projection age after which the background
// worker recomputes a user.
func WithStaleAfter(staleAfter time.Duration) Option {
return func(s *Service) {
if staleAfter > 0 {
s.staleAfter = staleAfter
}
}
}
// WithEnabled toggles the feature.
func WithEnabled(enabled bool) Option {
return func(s *Service) { s.enabled = enabled }
}
// WithClock injects the clock (tests).
func WithClock(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
s.now = now
}
}
}
// WithLogger injects the service logger.
func WithLogger(log *zap.Logger) Option {
return func(s *Service) {
if log != nil {
s.log = log
}
}
}
// NewService creates the rating service. It is enabled by default so that the
// only switch is the configuration flag, and it stays safe without a store:
// reads answer empty and writes report a configuration error.
func NewService(opts ...Option) *Service {
s := &Service{
weights: domain.DefaultAccountRatingWeights(),
pendingDelay: defaultPendingDelay,
staleAfter: defaultStaleAfter,
enabled: true,
now: time.Now,
log: zap.NewNop(),
}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
if s.now == nil {
s.now = time.Now
}
if s.log == nil {
s.log = zap.NewNop()
}
if s.pendingDelay < 0 {
s.pendingDelay = 0
}
if s.staleAfter <= 0 {
s.staleAfter = defaultStaleAfter
}
if err := s.weights.Validate(); err != nil {
s.weights = domain.DefaultAccountRatingWeights()
}
return s
}
// Enabled reports whether the feature is switched on.
func (s *Service) Enabled() bool { return s != nil && s.enabled }
// Ready reports whether the feature is on and backed by a store.
func (s *Service) Ready() bool { return s.Enabled() && s.store != nil }
// Weights returns the configured composite formula, so the admin panel can
// explain a level with the same numbers that produced it.
func (s *Service) Weights() domain.AccountRatingWeights {
if s == nil {
return domain.DefaultAccountRatingWeights()
}
return s.weights
}
func (s *Service) ratingStore() (store.AccountRatingStore, error) {
if s == nil || s.store == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
return s.store, nil
}
// Rating returns the stored projection.
//
// domain.ErrAccountRatingNotFound is propagated rather than flattened to a zero
// value so the admin API can distinguish "not computed" from a computed zero.
// A missing store reports a configuration error an operator can diagnose.
func (s *Service) Rating(ctx context.Context, userID int64) (domain.AccountRating, error) {
if s == nil || !s.enabled || userID <= 0 {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
st, err := s.ratingStore()
if err != nil {
return domain.AccountRating{}, err
}
return st.AccountRating(ctx, userID)
}
// RatingBatch resolves several users in one round trip. Users without a stored
// projection are absent from the map, so a disabled feature and an unconfigured
// store both read as "nobody has a rating" -- the batch shape already encodes
// absence and needs no error to express it.
func (s *Service) RatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
if s == nil || !s.enabled || s.store == nil {
return map[int64]domain.AccountRating{}, nil
}
unique := make([]int64, 0, len(userIDs))
seen := make(map[int64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID <= 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
unique = append(unique, userID)
}
if len(unique) == 0 {
return map[int64]domain.AccountRating{}, nil
}
batch, err := s.store.AccountRatingBatch(ctx, unique)
if err != nil {
return nil, err
}
if batch == nil {
return map[int64]domain.AccountRating{}, nil
}
return batch, nil
}
// Recompute gathers the contribution signals, applies the configured weights
// and the pending-delay policy relative to the stored value, and persists the
// result.
//
// The save is guarded by the stored version. A concurrent writer (another
// recompute, an adjustment, the worker) only invalidates the base the pending
// policy was resolved against, so exactly one retry against the freshly
// returned row is both sufficient and terminating.
func (s *Service) Recompute(ctx context.Context, userID int64) (domain.AccountRating, error) {
if s == nil || !s.enabled {
return domain.AccountRating{}, ErrDisabled
}
st, err := s.ratingStore()
if err != nil {
return domain.AccountRating{}, err
}
if userID <= 0 {
return domain.AccountRating{}, domain.ErrAccountRatingAdjustmentInvalid
}
// The service accounts are infrastructure, not participants. Refusing here as
// well as in the seeding query means an operator cannot create a rating for one
// by hand either -- the platform account is not flagged is_bot, so nothing else
// would stop it.
if !domain.RatableAccount(userID, false) {
return domain.AccountRating{}, domain.ErrAccountRatingAdjustmentInvalid
}
signals, err := st.AccountRatingSignals(ctx, userID)
if err != nil {
return domain.AccountRating{}, err
}
signals.UserID = userID
prev, err := s.previous(ctx, st, userID)
if err != nil {
return domain.AccountRating{}, err
}
now := s.now().UTC()
computed := domain.ComputeAccountRating(signals, s.weights, now)
stored, changed, err := st.SaveAccountRating(ctx, domain.ResolveAccountRatingPending(prev, computed, s.pendingDelay, now))
if err != nil {
return domain.AccountRating{}, err
}
if changed {
return stored, nil
}
// One retry: `stored` is the row that won the race, so resolving the pending
// policy against it produces the correct next version.
stored, changed, err = st.SaveAccountRating(ctx, domain.ResolveAccountRatingPending(stored, computed, s.pendingDelay, now))
if err != nil {
return domain.AccountRating{}, err
}
if !changed {
return stored, fmt.Errorf("recompute account rating %d: concurrent version conflict", userID)
}
return stored, nil
}
// Adjust records an operator adjustment in the contribution ledger and
// immediately recomputes the projection, so the manual component is visible
// without waiting for the background worker. Replaying the same CommandKey
// records nothing and reports applied=false; the current rating is still
// returned so a retried admin command stays idempotent.
func (s *Service) Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error) {
if s == nil || !s.enabled {
return domain.AccountRating{}, false, ErrDisabled
}
st, err := s.ratingStore()
if err != nil {
return domain.AccountRating{}, false, err
}
if err := req.Validate(); err != nil {
return domain.AccountRating{}, false, err
}
_, applied, err := st.AdjustAccountRating(ctx, req)
if err != nil {
return domain.AccountRating{}, false, err
}
rating, err := s.Recompute(ctx, req.UserID)
if err != nil {
return domain.AccountRating{}, applied, err
}
return rating, applied, nil
}
// List is the admin leaderboard query with a bounded page size.
func (s *Service) List(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
if s == nil || !s.enabled {
return nil, nil
}
st, err := s.ratingStore()
if err != nil {
return nil, err
}
if filter.MinLevel < 0 {
filter.MinLevel = 0
}
if filter.MinLevel > domain.MaxAccountRatingLevel {
filter.MinLevel = domain.MaxAccountRatingLevel
}
filter.Limit = clampLimit(filter.Limit, defaultListLimit, maxListLimit)
return st.ListAccountRatings(ctx, filter)
}
// Events returns one user's contribution ledger, newest first.
func (s *Service) Events(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
if s == nil || !s.enabled {
return nil, nil
}
st, err := s.ratingStore()
if err != nil {
return nil, err
}
if userID <= 0 {
return nil, domain.ErrAccountRatingAdjustmentInvalid
}
return st.AccountRatingEvents(ctx, userID, clampLimit(limit, defaultEventLimit, maxEventLimit))
}
// RunRecomputeCycle advances the read model by one bounded batch and returns how
// many users it wrote. A single user's failure is logged and skipped: one poisoned
// row must not stall the whole cycle.
//
// The cycle does two things, and the order matters. It first refreshes projections
// that have gone stale, because those are rows somebody is already looking at.
// Whatever batch budget is left it spends seeding accounts that have no projection
// at all -- without that pass the read model can never populate itself, since
// StaleAccountRatings walks account_rating and cannot return a user who is not in
// it. Staleness keeps existing ratings honest; seeding is what makes them exist at
// all, which is what makes the admin leaderboard populate without an operator
// opening every account first.
func (s *Service) RunRecomputeCycle(ctx context.Context, limit int) (int, error) {
if s == nil || !s.enabled {
return 0, nil
}
st, err := s.ratingStore()
if err != nil {
return 0, err
}
limit = clampLimit(limit, defaultRecomputeBatch, maxRecomputeBatch)
olderThan := s.now().UTC().Add(-s.staleAfter).Unix()
userIDs, err := st.StaleAccountRatings(ctx, olderThan, limit)
if err != nil {
return 0, err
}
processed, err := s.recomputeEach(ctx, userIDs, "recompute account rating failed")
if err != nil {
return processed, err
}
// The bound belongs to the cycle, not to each pass, so a backlog of stale rows
// can never turn one cycle into an unbounded amount of work.
remaining := limit - len(userIDs)
if remaining <= 0 {
return processed, nil
}
unrated, err := st.UnratedAccounts(ctx, remaining)
if err != nil {
// Seeding extends the cycle rather than being its purpose: a store that
// cannot enumerate accounts must not turn a successful stale pass into a
// failed cycle.
s.log.Warn("list unrated accounts failed", zap.Error(err))
return processed, nil
}
seeded, err := s.recomputeEach(ctx, unrated, "seed account rating failed")
return processed + seeded, err
}
// recomputeEach recomputes a list of users, skipping the ones that fail, and
// giving up early only when the context is done.
func (s *Service) recomputeEach(ctx context.Context, userIDs []int64, failureMessage string) (int, error) {
processed := 0
for _, userID := range userIDs {
if err := ctx.Err(); err != nil {
return processed, err
}
if userID <= 0 {
continue
}
if _, err := s.Recompute(ctx, userID); err != nil {
s.log.Warn(failureMessage,
zap.Int64("user_id", userID),
zap.Error(err))
continue
}
processed++
}
return processed, nil
}
// EnsureRating returns the stored local-admin projection, computing and storing
// it first when an administrative caller needs an immediate value.
//
// The background cycle reaches every account eventually; callers that require a
// local rating immediately use this bounded materialization path instead.
func (s *Service) EnsureRating(ctx context.Context, userID int64) (domain.AccountRating, error) {
if s == nil || !s.enabled || userID <= 0 {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
rating, err := s.Rating(ctx, userID)
if err == nil {
return rating, nil
}
if !errors.Is(err, domain.ErrAccountRatingNotFound) {
return domain.AccountRating{}, err
}
return s.Recompute(ctx, userID)
}
// previous reads the stored projection the pending policy is resolved against.
// A never-computed user yields the zero value, which domain.ResolveAccountRating
// Pending treats as "apply immediately" -- a first rating is never parked.
func (s *Service) previous(ctx context.Context, st store.AccountRatingStore, userID int64) (domain.AccountRating, error) {
prev, err := st.AccountRating(ctx, userID)
if err != nil {
if errors.Is(err, domain.ErrAccountRatingNotFound) {
return domain.AccountRating{}, nil
}
return domain.AccountRating{}, err
}
return prev, nil
}
func clampLimit(limit, fallback, maximum int) int {
if limit <= 0 {
return fallback
}
if limit > maximum {
return maximum
}
return limit
}

View file

@ -0,0 +1,719 @@
package rating
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
)
var testNow = time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
// fakeRatingStore is an in-memory AccountRatingStore with the same optimistic
// concurrency contract as PostgreSQL: a save whose version does not follow the
// stored one reports changed=false and returns the row that won.
type fakeRatingStore struct {
signals map[int64]domain.AccountRatingSignals
ratings map[int64]domain.AccountRating
manual map[int64]int64
events map[int64][]domain.AccountRatingEvent
keys map[string]domain.AccountRatingEvent
stale []int64
staleOlderThan int64
staleLimit int
unrated []int64
unratedLimit int
unratedCalls int
unratedErr error
saves []domain.AccountRating
forceConflicts int
signalsErr error
}
func newFakeRatingStore() *fakeRatingStore {
return &fakeRatingStore{
signals: map[int64]domain.AccountRatingSignals{},
ratings: map[int64]domain.AccountRating{},
manual: map[int64]int64{},
events: map[int64][]domain.AccountRatingEvent{},
keys: map[string]domain.AccountRatingEvent{},
}
}
func (f *fakeRatingStore) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
rating, ok := f.ratings[userID]
if !ok {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
return rating, nil
}
func (f *fakeRatingStore) AccountRatingBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
out := make(map[int64]domain.AccountRating, len(userIDs))
for _, userID := range userIDs {
if rating, ok := f.ratings[userID]; ok {
out[userID] = rating
}
}
return out, nil
}
func (f *fakeRatingStore) SaveAccountRating(_ context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
f.saves = append(f.saves, rating)
current := f.ratings[rating.UserID]
if f.forceConflicts > 0 {
f.forceConflicts--
return current, false, nil
}
if rating.Version != current.Version+1 {
return current, false, nil
}
f.ratings[rating.UserID] = rating
return rating, true, nil
}
func (f *fakeRatingStore) AccountRatingSignals(_ context.Context, userID int64) (domain.AccountRatingSignals, error) {
if f.signalsErr != nil {
return domain.AccountRatingSignals{}, f.signalsErr
}
signals := f.signals[userID]
signals.UserID = userID
signals.Manual = f.manual[userID]
return signals, nil
}
func (f *fakeRatingStore) AdjustAccountRating(_ context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
if req.CommandKey != "" {
if event, ok := f.keys[req.CommandKey]; ok {
return event, false, nil
}
}
event := domain.AccountRatingEvent{
ID: int64(len(f.events[req.UserID]) + 1), UserID: req.UserID, Kind: domain.AccountRatingEventManual,
Amount: req.Amount, Reason: req.Reason, Actor: req.Actor, CommandKey: req.CommandKey, CreatedAt: testNow,
}
f.events[req.UserID] = append(f.events[req.UserID], event)
f.manual[req.UserID] += req.Amount
if req.CommandKey != "" {
f.keys[req.CommandKey] = event
}
return event, true, nil
}
func (f *fakeRatingStore) ListAccountRatings(_ 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 {
out = append(out, rating)
}
if len(out) >= filter.Limit {
break
}
}
return out, nil
}
func (f *fakeRatingStore) AccountRatingEvents(_ context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
events := f.events[userID]
if len(events) > limit {
events = events[:limit]
}
return append([]domain.AccountRatingEvent(nil), events...), nil
}
func (f *fakeRatingStore) StaleAccountRatings(_ context.Context, olderThanUnix int64, limit int) ([]int64, error) {
f.staleOlderThan = olderThanUnix
f.staleLimit = limit
if len(f.stale) > limit {
return append([]int64(nil), f.stale[:limit]...), nil
}
return append([]int64(nil), f.stale...), nil
}
func (f *fakeRatingStore) UnratedAccounts(_ context.Context, limit int) ([]int64, error) {
f.unratedCalls++
f.unratedLimit = limit
if f.unratedErr != nil {
return nil, f.unratedErr
}
out := make([]int64, 0, len(f.unrated))
for _, userID := range f.unrated {
if _, rated := f.ratings[userID]; rated {
continue
}
out = append(out, userID)
if len(out) == limit {
break
}
}
return out, nil
}
func newTestService(st *fakeRatingStore, opts ...Option) *Service {
base := []Option{WithStore(st), WithClock(func() time.Time { return testNow })}
return NewService(append(base, opts...)...)
}
func TestRecomputeAppliesConfiguredWeights(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{
StarsReceived: 1000, StarsSpent: 400, MessagesSent: 30, AccountAgeDays: 10,
GiftsReceived: 2, ModerationCases: 1,
}
service := newTestService(st)
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
weights := domain.DefaultAccountRatingWeights()
want := domain.ComputeAccountRating(domain.AccountRatingSignals{
UserID: 7, StarsReceived: 1000, StarsSpent: 400, MessagesSent: 30, AccountAgeDays: 10,
GiftsReceived: 2, ModerationCases: 1,
}, weights, testNow)
if rating.Stars != want.Stars || rating.Level != want.Level ||
rating.StarsComponent != want.StarsComponent || rating.ActivityComponent != want.ActivityComponent ||
rating.PenaltyComponent != want.PenaltyComponent {
t.Fatalf("rating = %#v, want the domain formula result %#v", rating, want)
}
if rating.Version != 1 {
t.Fatalf("first stored version = %d, want 1", rating.Version)
}
if !rating.ComputedAt.Equal(testNow) {
t.Fatalf("ComputedAt = %v, want the injected clock %v", rating.ComputedAt, testNow)
}
}
func TestRecomputePendingPolicy(t *testing.T) {
t.Run("increase is parked", func(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Level: 1, Version: 4}
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
service := newTestService(st, WithPendingDelay(24*time.Hour))
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
if rating.Stars != 100 {
t.Fatalf("visible stars = %d, want the previous 100 while the increase is pending", rating.Stars)
}
if rating.PendingStars != 400 {
t.Fatalf("pending stars = %d, want 400", rating.PendingStars)
}
if want := testNow.Add(24 * time.Hour); !rating.PendingDate.Equal(want) {
t.Fatalf("pending date = %v, want %v", rating.PendingDate, want)
}
if rating.Version != 5 {
t.Fatalf("version = %d, want 5", rating.Version)
}
})
t.Run("decrease applies immediately", func(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 500, Level: 2, Version: 1}
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500, Scam: true}
service := newTestService(st, WithPendingDelay(24*time.Hour))
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
if rating.Stars != 0 || rating.PendingStars != 0 {
t.Fatalf("rating = %d stars / %d pending, want a penalty applied at once", rating.Stars, rating.PendingStars)
}
if rating.PenaltyComponent != domain.DefaultAccountRatingWeights().ScamPenalty {
t.Fatalf("penalty = %d, want the scam penalty", rating.PenaltyComponent)
}
})
t.Run("expired parking is folded into the visible rating", func(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{
UserID: 7, Stars: 100, Level: 1, Version: 2,
PendingStars: 400, PendingDate: testNow.Add(-time.Hour),
}
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
service := newTestService(st, WithPendingDelay(24*time.Hour))
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
if rating.Stars != 500 || rating.PendingStars != 0 || !rating.PendingDate.IsZero() {
t.Fatalf("rating = %#v, want the parked delta applied and cleared", rating)
}
})
t.Run("zero delay never parks", func(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Version: 1}
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
service := newTestService(st, WithPendingDelay(0))
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
if rating.Stars != 500 || rating.PendingStars != 0 {
t.Fatalf("rating = %d stars / %d pending, want an immediate apply", rating.Stars, rating.PendingStars)
}
})
}
func TestRecomputeRetriesOnceOnVersionConflict(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Version: 3}
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 200}
st.forceConflicts = 1
service := newTestService(st, WithPendingDelay(0))
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
if len(st.saves) != 2 {
t.Fatalf("saves = %d, want exactly one retry", len(st.saves))
}
if rating.Version != 4 || rating.Stars != 200 {
t.Fatalf("rating = %#v, want version 4 with 200 stars", rating)
}
}
func TestRecomputeFailsAfterPersistentConflict(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 200}
st.forceConflicts = 2
service := newTestService(st)
if _, err := service.Recompute(context.Background(), 7); err == nil {
t.Fatal("Recompute reported success while every save lost the version race")
}
if len(st.saves) != 2 {
t.Fatalf("saves = %d, want the bounded single retry", len(st.saves))
}
}
func TestAdjustRecordsLedgerAndRecomputes(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 100}
service := newTestService(st, WithPendingDelay(0))
rating, applied, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{
UserID: 7, Amount: 300, Reason: "support compensation", Actor: "admin", CommandKey: "cmd-1",
})
if err != nil || !applied {
t.Fatalf("Adjust = %v, %v", applied, err)
}
if rating.ManualComponent != 300 || rating.Stars != 400 {
t.Fatalf("rating = %#v, want the manual component folded in", rating)
}
if len(st.events[7]) != 1 {
t.Fatalf("ledger rows = %d, want 1", len(st.events[7]))
}
}
func TestAdjustReplayByCommandKeyIsIdempotent(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 100}
service := newTestService(st, WithPendingDelay(0))
req := domain.AdjustAccountRatingRequest{UserID: 7, Amount: 300, Actor: "admin", CommandKey: "cmd-1"}
first, applied, err := service.Adjust(context.Background(), req)
if err != nil || !applied {
t.Fatalf("first Adjust = %v, %v", applied, err)
}
second, applied, err := service.Adjust(context.Background(), req)
if err != nil {
t.Fatalf("replayed Adjust: %v", err)
}
if applied {
t.Fatal("replayed Adjust reported applied=true")
}
if len(st.events[7]) != 1 || st.manual[7] != 300 {
t.Fatalf("ledger = %d rows / manual %d, want the replay recorded nothing", len(st.events[7]), st.manual[7])
}
if second.Stars != first.Stars || second.ManualComponent != first.ManualComponent {
t.Fatalf("replayed rating = %#v, want the same score as %#v", second, first)
}
}
func TestAdjustValidatesRequest(t *testing.T) {
st := newFakeRatingStore()
service := newTestService(st)
tests := []domain.AdjustAccountRatingRequest{
{UserID: 0, Amount: 10},
{UserID: 7, Amount: 0},
{UserID: 7, Amount: 10, Reason: string(make([]byte, domain.MaxAccountRatingReasonLength+1))},
}
for _, req := range tests {
if _, _, err := service.Adjust(context.Background(), req); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
t.Fatalf("Adjust(%#v) error = %v, want ErrAccountRatingAdjustmentInvalid", req, err)
}
}
if len(st.events) != 0 || len(st.saves) != 0 {
t.Fatal("store was touched by an invalid adjustment")
}
}
func TestRunRecomputeCycleProcessesTheBatch(t *testing.T) {
st := newFakeRatingStore()
st.stale = []int64{1, 2, 3}
for _, userID := range st.stale {
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 100 * userID}
}
service := newTestService(st, WithStaleAfter(6*time.Hour))
processed, err := service.RunRecomputeCycle(context.Background(), 10)
if err != nil {
t.Fatalf("RunRecomputeCycle: %v", err)
}
if processed != 3 {
t.Fatalf("processed = %d, want 3", processed)
}
if st.staleLimit != 10 {
t.Fatalf("stale limit = %d, want the requested 10", st.staleLimit)
}
if want := testNow.Add(-6 * time.Hour).Unix(); st.staleOlderThan != want {
t.Fatalf("stale horizon = %d, want %d", st.staleOlderThan, want)
}
for _, userID := range st.stale {
if _, ok := st.ratings[userID]; !ok {
t.Fatalf("user %d was not recomputed", userID)
}
}
}
func TestRunRecomputeCycleSkipsFailingUsers(t *testing.T) {
st := newFakeRatingStore()
st.stale = []int64{1, 0, 2}
st.forceConflicts = 2 // both saves of the first user lose the race
service := newTestService(st)
processed, err := service.RunRecomputeCycle(context.Background(), 0)
if err != nil {
t.Fatalf("RunRecomputeCycle: %v", err)
}
if processed != 1 {
t.Fatalf("processed = %d, want the surviving user only", processed)
}
if st.staleLimit != defaultRecomputeBatch {
t.Fatalf("stale limit = %d, want the default batch", st.staleLimit)
}
}
func TestReadPathsDegradeWhenDisabled(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 500, Level: 2, Version: 1}
service := newTestService(st, WithEnabled(false))
if service.Enabled() || service.Ready() {
t.Fatal("disabled service reported enabled/ready")
}
// The userFull projection omits both TL flags on this error, which is exactly
// the pre-rating wire shape.
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("Rating error = %v, want ErrAccountRatingNotFound", err)
}
batch, err := service.RatingBatch(context.Background(), []int64{7})
if err != nil || len(batch) != 0 {
t.Fatalf("RatingBatch = %#v, %v; want empty", batch, err)
}
if _, err := service.Recompute(context.Background(), 7); !errors.Is(err, ErrDisabled) {
t.Fatalf("Recompute error = %v, want ErrDisabled", err)
}
if _, _, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{UserID: 7, Amount: 5}); !errors.Is(err, ErrDisabled) {
t.Fatalf("Adjust error = %v, want ErrDisabled", err)
}
processed, err := service.RunRecomputeCycle(context.Background(), 10)
if err != nil || processed != 0 {
t.Fatalf("RunRecomputeCycle = %d, %v; want a no-op", processed, err)
}
}
func TestUnconfiguredStoreReportsConfiguration(t *testing.T) {
service := NewService()
if service.Ready() {
t.Fatal("Ready = true without a store")
}
if _, err := service.Rating(context.Background(), 7); err == nil {
t.Fatal("Rating accepted a missing store")
}
if _, err := service.Recompute(context.Background(), 7); err == nil {
t.Fatal("Recompute accepted a missing store")
}
if _, _, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{UserID: 7, Amount: 5}); err == nil {
t.Fatal("Adjust accepted a missing store")
}
if _, err := service.RunRecomputeCycle(context.Background(), 10); err == nil {
t.Fatal("RunRecomputeCycle accepted a missing store")
}
}
func TestNilServiceIsSafe(t *testing.T) {
var service *Service
if service.Enabled() || service.Ready() {
t.Fatal("nil service reported enabled/ready")
}
if got := service.Weights(); got != domain.DefaultAccountRatingWeights() {
t.Fatalf("nil service weights = %#v, want the defaults", got)
}
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("nil service Rating error = %v, want ErrAccountRatingNotFound", err)
}
if batch, err := service.RatingBatch(context.Background(), []int64{7}); err != nil || len(batch) != 0 {
t.Fatalf("nil service RatingBatch = %#v, %v; want empty", batch, err)
}
if _, err := service.Recompute(context.Background(), 7); !errors.Is(err, ErrDisabled) {
t.Fatalf("nil service Recompute error = %v, want ErrDisabled", err)
}
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
t.Fatalf("nil service RunRecomputeCycle = %d, %v", processed, err)
}
}
func TestInvalidWeightsFallBackToDefaults(t *testing.T) {
st := newFakeRatingStore()
service := newTestService(st, WithWeights(domain.AccountRatingWeights{StarsReceivedPermille: -1}))
if got := service.Weights(); got != domain.DefaultAccountRatingWeights() {
t.Fatalf("weights = %#v, want the defaults after rejecting a negative set", got)
}
}
func TestListAndEventsBoundThePage(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Level: 3, Version: 1}
for i := range maxEventLimit + 10 {
st.events[7] = append(st.events[7], domain.AccountRatingEvent{ID: int64(i + 1), UserID: 7, Amount: 1})
}
service := newTestService(st)
list, err := service.List(context.Background(), domain.AccountRatingFilter{MinLevel: -5, Limit: 0})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 {
t.Fatalf("List = %d rows, want 1", len(list))
}
events, err := service.Events(context.Background(), 7, 100000)
if err != nil {
t.Fatalf("Events: %v", err)
}
if len(events) != maxEventLimit {
t.Fatalf("Events = %d rows, want the %d cap", len(events), maxEventLimit)
}
if _, err := service.Events(context.Background(), 0, 10); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
t.Fatalf("Events accepted a zero user id")
}
}
func TestRecomputeWorkerRunsAndStops(t *testing.T) {
st := newFakeRatingStore()
st.stale = []int64{1}
st.signals[1] = domain.AccountRatingSignals{StarsReceived: 100}
service := newTestService(st)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
NewRecomputeWorker(service, nil, time.Hour, 10).Run(ctx)
}()
// The first cycle runs before the ticker, so cancelling immediately still
// leaves exactly one recompute behind.
<-time.After(20 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("worker did not stop on context cancellation")
}
if _, ok := st.ratings[1]; !ok {
t.Fatal("worker did not recompute the stale user")
}
}
func TestRecomputeWorkerExitsWhenNotReady(t *testing.T) {
worker := NewRecomputeWorker(newTestService(newFakeRatingStore(), WithEnabled(false)), nil, time.Millisecond, 0)
done := make(chan struct{})
go func() {
defer close(done)
worker.Run(context.Background())
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("disabled worker kept running")
}
if worker.batch != defaultRecomputeBatch {
t.Fatalf("batch = %d, want the default fallback", worker.batch)
}
}
// TestRunRecomputeCycleSeedsAccountsWithNoProjection is the report "the ratings tab
// is empty and no client shows a rating". StaleAccountRatings reads account_rating,
// so it can only ever refresh rows that already exist; without a seeding pass the
// very first row for a user has to come from an operator recomputing that user by
// hand, and the read model stays permanently empty.
func TestRunRecomputeCycleSeedsAccountsWithNoProjection(t *testing.T) {
st := newFakeRatingStore()
st.unrated = []int64{11, 12, 13}
for _, userID := range st.unrated {
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 100 * userID}
}
service := newTestService(st)
processed, err := service.RunRecomputeCycle(context.Background(), 10)
if err != nil {
t.Fatalf("RunRecomputeCycle: %v", err)
}
if processed != 3 {
t.Fatalf("processed = %d, want the three seeded accounts", processed)
}
for _, userID := range st.unrated {
if _, ok := st.ratings[userID]; !ok {
t.Fatalf("account %d was not seeded", userID)
}
}
// A second cycle has nothing left to seed, so seeding converges instead of
// rewriting the same rows every interval.
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
t.Fatalf("second cycle = %d,%v, want 0,nil", processed, err)
}
}
// The batch bound belongs to the cycle, not to each pass: a backlog of stale rows
// must not let one cycle do an unbounded amount of work.
func TestRunRecomputeCycleSharesTheBatchBudget(t *testing.T) {
st := newFakeRatingStore()
st.stale = []int64{1, 2}
st.unrated = []int64{11, 12, 13, 14}
service := newTestService(st)
processed, err := service.RunRecomputeCycle(context.Background(), 3)
if err != nil {
t.Fatalf("RunRecomputeCycle: %v", err)
}
if processed != 3 {
t.Fatalf("processed = %d, want the batch bound of 3", processed)
}
if st.unratedLimit != 1 {
t.Fatalf("seeding limit = %d, want the 1 left after two stale rows", st.unratedLimit)
}
// A cycle whose stale pass already fills the batch does not query for seeds at
// all: refreshing rows somebody is looking at comes first.
full := newFakeRatingStore()
full.stale = []int64{1, 2, 3}
full.unrated = []int64{11}
if _, err := newTestService(full).RunRecomputeCycle(context.Background(), 3); err != nil {
t.Fatalf("RunRecomputeCycle: %v", err)
}
if full.unratedCalls != 0 {
t.Fatalf("seeding was queried %d times, want none when the batch is already full", full.unratedCalls)
}
}
// Seeding extends the cycle; it is not its purpose. A store that cannot enumerate
// accounts must not turn a successful stale pass into a failed cycle.
func TestRunRecomputeCycleSurvivesSeedingFailure(t *testing.T) {
st := newFakeRatingStore()
st.stale = []int64{1}
st.unratedErr = errors.New("no users table")
service := newTestService(st)
processed, err := service.RunRecomputeCycle(context.Background(), 10)
if err != nil {
t.Fatalf("RunRecomputeCycle = %v, want the stale pass to stand", err)
}
if processed != 1 {
t.Fatalf("processed = %d, want the one stale row", processed)
}
}
// TestEnsureRatingMaterializesOnce covers an administrative immediate-read path:
// when the worker has not reached an account yet, the first read materializes the
// local projection and the second read must not write again.
func TestEnsureRatingMaterializesOnce(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 900}
service := newTestService(st)
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("Rating before materialising = %v, want ErrAccountRatingNotFound", err)
}
rating, err := service.EnsureRating(context.Background(), 7)
if err != nil {
t.Fatalf("EnsureRating: %v", err)
}
if rating.UserID != 7 || rating.Stars == 0 {
t.Fatalf("materialised rating = %+v, want a computed rating for user 7", rating)
}
writes := len(st.saves)
again, err := service.EnsureRating(context.Background(), 7)
if err != nil {
t.Fatalf("second EnsureRating: %v", err)
}
if again.Version != rating.Version {
t.Fatalf("second EnsureRating rewrote the row: version %d then %d", rating.Version, again.Version)
}
if len(st.saves) != writes {
t.Fatalf("second EnsureRating issued %d extra saves, want none", len(st.saves)-writes)
}
}
// A disabled feature materialises nothing. Telegram wire fields remain unset
// independently of this local feature flag.
func TestEnsureRatingDisabledStaysEmpty(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 900}
service := newTestService(st, WithEnabled(false))
if _, err := service.EnsureRating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("EnsureRating while disabled = %v, want ErrAccountRatingNotFound", err)
}
if len(st.saves) != 0 {
t.Fatalf("EnsureRating while disabled wrote %d rows, want none", len(st.saves))
}
}
// TestRecomputeRefusesServiceAccounts pins that the platform account and the
// built-in bots carry no rating. The platform account is not flagged is_bot, so the
// bot exclusion in the seeding query does not cover it -- which is how it acquired a
// rating in the first place -- and an operator must not be able to create one by
// hand either.
func TestRecomputeRefusesServiceAccounts(t *testing.T) {
for _, userID := range domain.SystemUserIDs() {
st := newFakeRatingStore()
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 5000}
st.unrated = []int64{userID}
service := newTestService(st)
if _, err := service.Recompute(context.Background(), userID); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
t.Fatalf("Recompute(%d) = %v, want ErrAccountRatingAdjustmentInvalid", userID, err)
}
if _, err := service.EnsureRating(context.Background(), userID); err == nil {
t.Fatalf("EnsureRating(%d) succeeded, want a refusal", userID)
}
if len(st.ratings) != 0 {
t.Fatalf("service account %d ended up with a projection: %#v", userID, st.ratings)
}
// A seeding pass that is somehow handed one skips it rather than failing the
// whole cycle.
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
t.Fatalf("cycle over service account %d = %d,%v, want 0,nil", userID, processed, err)
}
}
// An ordinary account is unaffected.
st := newFakeRatingStore()
st.signals[42] = domain.AccountRatingSignals{StarsReceived: 5000}
if _, err := newTestService(st).Recompute(context.Background(), 42); err != nil {
t.Fatalf("Recompute of an ordinary account: %v", err)
}
}

View file

@ -0,0 +1,91 @@
package rating
import (
"context"
"time"
"go.uber.org/zap"
)
const (
// defaultRecomputeInterval matches the shipped
// TELESRV_RATING_RECOMPUTE_INTERVAL default.
defaultRecomputeInterval = 15 * time.Minute
)
// RecomputeWorker keeps the rating read model fresh.
//
// The projection is derived from signals that change outside the rating write
// path (Stars flow, message activity, moderation decisions, account age), so no
// single writer can keep it current. This worker walks the stale projections in
// bounded batches; it never recomputes the whole table in one pass, and a
// cancelled context stops it between users rather than mid-write.
type RecomputeWorker struct {
service *Service
logger *zap.Logger
interval time.Duration
batch int
}
// NewRecomputeWorker creates the periodic recompute worker. Non-positive
// interval/batch fall back to the shipped defaults, matching the retention
// worker's contract.
func NewRecomputeWorker(service *Service, logger *zap.Logger, interval time.Duration, batch int) *RecomputeWorker {
if logger == nil {
logger = zap.NewNop()
}
if interval <= 0 {
interval = defaultRecomputeInterval
}
if batch <= 0 {
batch = defaultRecomputeBatch
}
return &RecomputeWorker{service: service, logger: logger, interval: interval, batch: batch}
}
// Run recomputes one batch immediately and then on every tick until ctx is
// done. A disabled or store-less service exits immediately with one explicit
// log line instead of ticking forever over a no-op.
func (w *RecomputeWorker) Run(ctx context.Context) {
if w == nil {
return
}
if !w.service.Ready() {
w.logger.Info("account rating recompute worker disabled",
zap.Bool("enabled", w.service.Enabled()))
return
}
w.runOnce(ctx)
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.runOnce(ctx)
}
}
}
func (w *RecomputeWorker) runOnce(ctx context.Context) {
if w == nil || w.service == nil {
return
}
processed, err := w.service.RunRecomputeCycle(ctx, w.batch)
if err != nil {
if ctx.Err() != nil {
return
}
w.logger.Warn("account rating recompute cycle failed",
zap.Int("processed", processed),
zap.Int("batch", w.batch),
zap.Error(err))
return
}
if processed > 0 {
w.logger.Info("account rating recompute cycle completed",
zap.Int("processed", processed),
zap.Int("batch", w.batch))
}
}

View file

@ -0,0 +1,552 @@
// Package usernames implements the collectible (Fragment-style) username
// registry use cases: reading a peer's username vector, toggling and reordering
// the collectible rows a client owns, and the operator lifecycle that mints,
// transfers, revokes and burns the assets behind those rows.
//
// The service owns normalisation and validation. Every entry point normalises
// the name through domain.NormalizeUsername and runs the domain Validate()
// checks before the store is touched, so an RPC handler, the admin API and a
// unit test all reject the same shapes with the same errors.
package usernames
import (
"context"
"errors"
"fmt"
"strings"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/links"
"telesrv/internal/store"
)
const (
// defaultListLimit is the admin listing page size used when the caller does
// not bound the query itself.
defaultListLimit = 50
// maxListLimit bounds one listing page regardless of the requested limit.
maxListLimit = 200
// defaultTransferLimit / maxTransferLimit bound the provenance log page.
defaultTransferLimit = 50
maxTransferLimit = 200
// usernamePlaceholder is the substitution supported by the operator URL
// template, e.g. https://example.org/nft/{username}.
usernamePlaceholder = "{username}"
// defaultCollectibleURLPath is the public-link route used when no operator
// template is configured.
defaultCollectibleURLPath = "nft/username"
)
// ErrPeerInvalid rejects a registry mutation for a peer that cannot hold
// usernames. Only users and channels have a username registry; anything else is
// a caller bug rather than a client-visible protocol state.
var ErrPeerInvalid = errors.New("username peer invalid")
// PeerUsernameNotifier is the domain-only edge hook invoked after a username
// registry mutation. The RPC router implements it: it invalidates the cached
// peer projections and pushes the username change to online clients, exactly
// like the account.updateUsername path does for the editable slot. Keeping it an
// injected port means this package never depends on the protocol edge.
type PeerUsernameNotifier interface {
NotifyPeerUsernamesChanged(ctx context.Context, peer domain.Peer) error
}
// Service is the collectible username use-case layer.
type Service struct {
registry store.UsernameRegistryStore
collectibles store.CollectibleUsernameStore
notifier PeerUsernameNotifier
// urlTemplate is the operator-provided collectible landing URL template;
// publicBaseURL is the fallback root the default route is built from.
urlTemplate string
publicBaseURL string
now func() time.Time
log *zap.Logger
}
// Option adjusts optional service dependencies.
type Option func(*Service)
// WithRegistryStore injects the peer username registry reader/writer.
func WithRegistryStore(registry store.UsernameRegistryStore) Option {
return func(s *Service) { s.registry = registry }
}
// WithCollectibleStore injects the collectible asset lifecycle store.
func WithCollectibleStore(collectibles store.CollectibleUsernameStore) Option {
return func(s *Service) { s.collectibles = collectibles }
}
// WithNotifier injects the edge invalidation/update hook.
func WithNotifier(notifier PeerUsernameNotifier) Option {
return func(s *Service) { s.notifier = notifier }
}
// WithURLTemplate configures the collectible asset landing URL template. An
// empty template keeps the public-link default route.
func WithURLTemplate(template string) Option {
return func(s *Service) { s.urlTemplate = strings.TrimSpace(template) }
}
// WithPublicBaseURL configures the public-link root the default collectible URL
// route is derived from.
func WithPublicBaseURL(baseURL string) Option {
return func(s *Service) { s.publicBaseURL = strings.TrimSpace(baseURL) }
}
// WithClock injects the clock (tests).
func WithClock(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
s.now = now
}
}
}
// WithLogger injects the service logger.
func WithLogger(log *zap.Logger) Option {
return func(s *Service) {
if log != nil {
s.log = log
}
}
}
// NewService creates the collectible username service. Every dependency is
// optional: a service without stores answers with a configuration error instead
// of panicking, which keeps partial deployments diagnosable.
func NewService(opts ...Option) *Service {
s := &Service{now: time.Now, log: zap.NewNop()}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
if s.now == nil {
s.now = time.Now
}
if s.log == nil {
s.log = zap.NewNop()
}
return s
}
// SetPeerUsernameNotifier injects the edge hook after construction. The RPC
// router is built after the app services, so the notification port is bound
// here rather than through NewService.
func (s *Service) SetPeerUsernameNotifier(notifier PeerUsernameNotifier) {
if s == nil {
return
}
s.notifier = notifier
}
// Configured reports whether both registries are installed.
func (s *Service) Configured() bool {
return s != nil && s.registry != nil && s.collectibles != nil
}
func (s *Service) registryStore() (store.UsernameRegistryStore, error) {
if s == nil || s.registry == nil {
return nil, fmt.Errorf("username registry store is not configured")
}
return s.registry, nil
}
func (s *Service) collectibleStore() (store.CollectibleUsernameStore, error) {
if s == nil || s.collectibles == nil {
return nil, fmt.Errorf("collectible username store is not configured")
}
return s.collectibles, nil
}
// PeerUsernames returns the peer's username vector in projection order.
func (s *Service) PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error) {
registry, err := s.registryStore()
if err != nil {
return nil, err
}
if !validPeer(peer) {
return nil, nil
}
list, err := registry.PeerUsernames(ctx, peer)
if err != nil {
return nil, err
}
return domain.SortUsernames(list), nil
}
// UsernamesBatch resolves several peers in one round trip. Peers holding no
// usernames are absent from the result.
func (s *Service) UsernamesBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
registry, err := s.registryStore()
if err != nil {
return nil, err
}
unique := make([]domain.Peer, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, peer := range peers {
if !validPeer(peer) {
continue
}
if _, ok := seen[peer]; ok {
continue
}
seen[peer] = struct{}{}
unique = append(unique, peer)
}
if len(unique) == 0 {
return map[domain.Peer][]domain.Username{}, nil
}
batch, err := registry.PeerUsernamesBatch(ctx, unique)
if err != nil {
return nil, err
}
out := make(map[domain.Peer][]domain.Username, len(batch))
for peer, list := range batch {
if len(list) == 0 {
continue
}
out[peer] = domain.SortUsernames(list)
}
return out, nil
}
// ToggleUsername activates or deactivates one collectible row. The editable
// slot is never touched: it is owned by account/channels.updateUsername.
func (s *Service) ToggleUsername(ctx context.Context, peer domain.Peer, username string, active bool) (bool, error) {
registry, err := s.registryStore()
if err != nil {
return false, err
}
if !validPeer(peer) {
return false, ErrPeerInvalid
}
username = domain.NormalizeUsername(username)
if username == "" {
return false, domain.ErrUsernameInvalid
}
current, err := registry.PeerUsernames(ctx, peer)
if err != nil {
return false, err
}
if err := domain.ValidateUsernameToggle(current, username, active); err != nil {
return false, err
}
changed, err := registry.SetUsernameActive(ctx, peer, username, active)
if err != nil {
return false, err
}
if changed {
s.notifyPeers(ctx, peer)
}
return changed, nil
}
// ReorderUsernames rewrites the collectible order. order must be a permutation
// of the peer's collectible usernames; the editable slot always projects first.
func (s *Service) ReorderUsernames(ctx context.Context, peer domain.Peer, order []string) (bool, error) {
registry, err := s.registryStore()
if err != nil {
return false, err
}
if !validPeer(peer) {
return false, ErrPeerInvalid
}
normalized := make([]string, 0, len(order))
for _, name := range order {
normalized = append(normalized, domain.NormalizeUsername(name))
}
current, err := registry.PeerUsernames(ctx, peer)
if err != nil {
return false, err
}
if err := domain.ValidateUsernameReorder(current, normalized); err != nil {
return false, err
}
changed, err := registry.ReorderUsernames(ctx, peer, normalized)
if err != nil {
return false, err
}
if changed {
s.notifyPeers(ctx, peer)
}
return changed, nil
}
// DeactivateAllUsernames clears the active flag on every collectible row.
func (s *Service) DeactivateAllUsernames(ctx context.Context, peer domain.Peer) (bool, error) {
registry, err := s.registryStore()
if err != nil {
return false, err
}
if !validPeer(peer) {
return false, ErrPeerInvalid
}
changed, err := registry.DeactivateAllUsernames(ctx, peer)
if err != nil {
return false, err
}
if changed {
s.notifyPeers(ctx, peer)
}
return changed, nil
}
// CollectibleInfo returns the fragment.collectibleInfo projection for a name.
func (s *Service) CollectibleInfo(ctx context.Context, username string) (domain.CollectibleInfo, error) {
asset, err := s.Collectible(ctx, username)
if err != nil {
return domain.CollectibleInfo{}, err
}
return asset.Info(), nil
}
// Collectible looks up the asset behind a collectible username.
func (s *Service) Collectible(ctx context.Context, username string) (domain.CollectibleUsername, error) {
collectibles, err := s.collectibleStore()
if err != nil {
return domain.CollectibleUsername{}, err
}
username = domain.NormalizeUsername(username)
if !domain.ValidCollectibleUsername(username) {
return domain.CollectibleUsername{}, domain.ErrUsernameInvalid
}
return collectibles.CollectibleUsername(ctx, username)
}
// Mint creates a collectible asset, optionally assigning it in the same
// command. An empty URL is rendered from the configured template and an unset
// purchase date is stamped with the service clock, so the stored provenance is
// always complete and reproducible.
func (s *Service) Mint(ctx context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
collectibles, err := s.collectibleStore()
if err != nil {
return domain.CollectibleUsername{}, false, err
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if strings.TrimSpace(req.URL) == "" {
req.URL = s.CollectibleURL(req.Username)
}
if req.PurchaseDate.IsZero() {
req.PurchaseDate = s.now().UTC()
}
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
asset, created, err := collectibles.MintCollectibleUsername(ctx, req)
if err != nil {
return domain.CollectibleUsername{}, false, err
}
if created {
s.notifyPeers(ctx, req.Owner, asset.Owner)
}
return asset, created, nil
}
// Transfer moves the asset to req.To, either out of the vault or from the
// current holder. Both the previous and the new holder are invalidated.
func (s *Service) Transfer(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
collectibles, err := s.collectibleStore()
if err != nil {
return domain.CollectibleUsername{}, false, err
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
asset, changed, err := collectibles.TransferCollectibleUsername(ctx, req)
if err != nil {
return domain.CollectibleUsername{}, false, err
}
if changed {
s.notifyPeers(ctx, previousOwner, req.To, asset.Owner)
}
return asset, changed, nil
}
// Revoke returns the asset to the vault, or burns it when req.Burn is set.
func (s *Service) Revoke(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
collectibles, err := s.collectibleStore()
if err != nil {
return domain.CollectibleUsername{}, false, err
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
asset, changed, err := collectibles.RevokeCollectibleUsername(ctx, req)
if err != nil {
return domain.CollectibleUsername{}, false, err
}
if changed {
s.notifyPeers(ctx, previousOwner, asset.Owner)
}
return asset, changed, nil
}
// Delete removes an asset outright, releasing its name and discarding its
// provenance. Revoke with Burn retires an asset but keeps the history; this is
// the operator's escape hatch for an asset issued by mistake.
//
// The previous owner is notified exactly like a revoke: the peer's projection
// still carries the username until it is invalidated.
func (s *Service) Delete(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
collectibles, err := s.collectibleStore()
if err != nil {
return false, err
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return false, err
}
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
deleted, err := collectibles.DeleteCollectibleUsername(ctx, req)
if err != nil {
return false, err
}
if deleted {
s.notifyPeers(ctx, previousOwner, domain.Peer{})
}
return deleted, nil
}
// List is the admin listing query. The limit is always bounded, so an
// unfiltered operator request can never ask the store for an unbounded scan.
func (s *Service) List(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
collectibles, err := s.collectibleStore()
if err != nil {
return nil, err
}
if filter.Status != "" && !filter.Status.Valid() {
return nil, domain.ErrCollectibleUsernameStateInvalid
}
if filter.Owner.Type != "" && !validPeer(filter.Owner) {
return nil, domain.ErrCollectibleUsernameStateInvalid
}
filter.Query = domain.NormalizeUsername(filter.Query)
filter.Limit = clampLimit(filter.Limit, defaultListLimit, maxListLimit)
return collectibles.ListCollectibleUsernames(ctx, filter)
}
// Transfers returns the provenance log of one asset, newest first.
func (s *Service) Transfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
collectibles, err := s.collectibleStore()
if err != nil {
return nil, err
}
if collectibleID <= 0 {
return nil, domain.ErrCollectibleUsernameNotFound
}
return collectibles.CollectibleUsernameTransfers(ctx, collectibleID, clampLimit(limit, defaultTransferLimit, maxTransferLimit))
}
// CollectibleURL renders the asset landing URL for a name. The operator
// template wins; {username} is substituted when present and appended as a path
// segment when it is not. Without a template the public-link default route is
// used, and without any configured root the URL stays empty rather than
// pointing at an unrelated host.
func (s *Service) CollectibleURL(username string) string {
if s == nil {
return ""
}
username = domain.NormalizeUsername(username)
if username == "" {
return ""
}
template := strings.TrimSpace(s.urlTemplate)
if template != "" {
if strings.Contains(template, usernamePlaceholder) {
return strings.ReplaceAll(template, usernamePlaceholder, username)
}
return strings.TrimRight(template, "/") + "/" + username
}
if strings.TrimSpace(s.publicBaseURL) == "" {
return ""
}
return links.Build(s.publicBaseURL, defaultCollectibleURLPath+"/"+username, nil)
}
// currentOwner reads the holder before a lifecycle mutation so the previous
// peer's projection is invalidated too. It is best effort: a missing or
// unreadable asset only means there is no extra peer to notify, and the
// mutation itself remains the authority.
func (s *Service) currentOwner(ctx context.Context, collectibles store.CollectibleUsernameStore, username string) domain.Peer {
asset, err := collectibles.CollectibleUsername(ctx, username)
if err != nil {
if !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
s.log.Debug("read collectible username owner before mutation",
zap.String("username", username),
zap.Error(err))
}
return domain.Peer{}
}
if !asset.Owned() {
return domain.Peer{}
}
return asset.Owner
}
// notifyPeers invalidates projections and pushes updates for every distinct
// affected peer. Notification is best effort: the registry mutation already
// committed, and a failed push converges through the client's next
// authoritative peer read.
func (s *Service) notifyPeers(ctx context.Context, peers ...domain.Peer) {
if s == nil || s.notifier == nil {
return
}
seen := make(map[domain.Peer]struct{}, len(peers))
for _, peer := range peers {
if !validPeer(peer) {
continue
}
if _, ok := seen[peer]; ok {
continue
}
seen[peer] = struct{}{}
if err := s.notifier.NotifyPeerUsernamesChanged(ctx, peer); err != nil {
s.log.Warn("notify collectible username change failed",
zap.String("peer_type", string(peer.Type)),
zap.Int64("peer_id", peer.ID),
zap.Error(err))
}
}
}
func validPeer(peer domain.Peer) bool {
switch peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel:
return peer.ID > 0
default:
return false
}
}
func clampLimit(limit, fallback, maximum int) int {
if limit <= 0 {
return fallback
}
if limit > maximum {
return maximum
}
return limit
}

View file

@ -0,0 +1,734 @@
package usernames
import (
"context"
"errors"
"strings"
"testing"
"time"
"telesrv/internal/domain"
)
var (
testUser = domain.Peer{Type: domain.PeerTypeUser, ID: 42}
testChannel = domain.Peer{Type: domain.PeerTypeChannel, ID: 77}
testClock = time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC)
)
type toggleCall struct {
peer domain.Peer
username string
active bool
}
// fakeRegistry is an in-memory domain.Username registry recording exactly what
// the service asked it to do, so the tests can assert normalisation reached the
// store and validation did not.
type fakeRegistry struct {
lists map[domain.Peer][]domain.Username
toggles []toggleCall
orders [][]string
clears []domain.Peer
changed bool
batchErr error
}
func newFakeRegistry() *fakeRegistry {
return &fakeRegistry{lists: map[domain.Peer][]domain.Username{}, changed: true}
}
func (f *fakeRegistry) PeerUsernames(_ context.Context, peer domain.Peer) ([]domain.Username, error) {
if f.batchErr != nil {
return nil, f.batchErr
}
return append([]domain.Username(nil), f.lists[peer]...), nil
}
func (f *fakeRegistry) PeerUsernamesBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
if f.batchErr != nil {
return nil, f.batchErr
}
out := make(map[domain.Peer][]domain.Username, len(peers))
for _, peer := range peers {
if list, ok := f.lists[peer]; ok {
out[peer] = append([]domain.Username(nil), list...)
}
}
return out, nil
}
func (f *fakeRegistry) SetUsernameActive(_ context.Context, peer domain.Peer, username string, active bool) (bool, error) {
f.toggles = append(f.toggles, toggleCall{peer: peer, username: username, active: active})
return f.changed, nil
}
func (f *fakeRegistry) ReorderUsernames(_ context.Context, _ domain.Peer, order []string) (bool, error) {
f.orders = append(f.orders, append([]string(nil), order...))
return f.changed, nil
}
func (f *fakeRegistry) DeactivateAllUsernames(_ context.Context, peer domain.Peer) (bool, error) {
f.clears = append(f.clears, peer)
return f.changed, nil
}
// fakeCollectibles records the lifecycle commands and serves stored assets.
type fakeCollectibles struct {
assets map[string]domain.CollectibleUsername
mints []domain.MintCollectibleUsernameRequest
transfers []domain.TransferCollectibleUsernameRequest
revokes []domain.RevokeCollectibleUsernameRequest
deletes []domain.DeleteCollectibleUsernameRequest
filters []domain.CollectibleUsernameFilter
logLimits []int
created bool
changed bool
}
func newFakeCollectibles() *fakeCollectibles {
return &fakeCollectibles{assets: map[string]domain.CollectibleUsername{}, created: true, changed: true}
}
func (f *fakeCollectibles) MintCollectibleUsername(_ context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
f.mints = append(f.mints, req)
asset := domain.CollectibleUsername{
ID: int64(len(f.mints)), Username: req.Username, Status: domain.CollectibleUsernameStatusVault,
PurchaseDate: req.PurchaseDate, Currency: req.Currency, Amount: req.Amount, URL: req.URL,
}
if req.Owner.Type != "" {
asset.Status = domain.CollectibleUsernameStatusOwned
asset.Owner = req.Owner
asset.OriginalOwner = req.Owner
}
f.assets[strings.ToLower(req.Username)] = asset
return asset, f.created, nil
}
func (f *fakeCollectibles) TransferCollectibleUsername(_ context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
f.transfers = append(f.transfers, req)
asset := f.assets[strings.ToLower(req.Username)]
asset.Username = req.Username
asset.Status = domain.CollectibleUsernameStatusOwned
asset.Owner = req.To
asset.TransferCount++
f.assets[strings.ToLower(req.Username)] = asset
return asset, f.changed, nil
}
func (f *fakeCollectibles) RevokeCollectibleUsername(_ context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
f.revokes = append(f.revokes, req)
asset := f.assets[strings.ToLower(req.Username)]
asset.Username = req.Username
asset.Owner = domain.Peer{}
asset.Status = domain.CollectibleUsernameStatusVault
if req.Burn {
asset.Status = domain.CollectibleUsernameStatusBurned
}
f.assets[strings.ToLower(req.Username)] = asset
return asset, f.changed, nil
}
func (f *fakeCollectibles) DeleteCollectibleUsername(_ context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
f.deletes = append(f.deletes, req)
key := strings.ToLower(req.Username)
if _, ok := f.assets[key]; !ok {
return false, nil
}
delete(f.assets, key)
return true, nil
}
func (f *fakeCollectibles) CollectibleUsername(_ 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 *fakeCollectibles) CollectibleUsernameByID(_ context.Context, id int64) (domain.CollectibleUsername, error) {
for _, asset := range f.assets {
if asset.ID == id {
return asset, nil
}
}
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
func (f *fakeCollectibles) ListCollectibleUsernames(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
f.filters = append(f.filters, filter)
return nil, nil
}
func (f *fakeCollectibles) CollectibleUsernameTransfers(_ context.Context, _ int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
f.logLimits = append(f.logLimits, limit)
return nil, nil
}
// recordingNotifier captures the peers whose projections were invalidated.
type recordingNotifier struct {
peers []domain.Peer
err error
}
func (n *recordingNotifier) NotifyPeerUsernamesChanged(_ context.Context, peer domain.Peer) error {
n.peers = append(n.peers, peer)
return n.err
}
func newTestService(t *testing.T, registry *fakeRegistry, collectibles *fakeCollectibles, opts ...Option) (*Service, *recordingNotifier) {
t.Helper()
notifier := &recordingNotifier{}
base := []Option{
WithRegistryStore(registry),
WithCollectibleStore(collectibles),
WithNotifier(notifier),
WithClock(func() time.Time { return testClock }),
}
return NewService(append(base, opts...)...), notifier
}
func TestPeerUsernamesProjectsStoredOrder(t *testing.T) {
// Legacy numbering: the editable slot and the first collectible both carry
// sort_order 0, and the editable slot wins that tie, so a peer that never
// reordered anything projects its own username first.
registry := newFakeRegistry()
registry.lists[testUser] = []domain.Username{
{Username: "zeta", Active: true, SortOrder: 1, CollectibleID: 2},
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1},
{Username: "editable", Active: true, Editable: true, SortOrder: 0},
}
service, _ := newTestService(t, registry, newFakeCollectibles())
if got := projectedNames(t, service); got != "editable,alpha,zeta" {
t.Fatalf("projection order = %v, want editable,alpha,zeta", got)
}
// After a reorder that made a collectible primary, stored order decides and
// the editable slot is no longer first: clients show usernames[0] as primary.
registry.lists[testUser] = []domain.Username{
{Username: "zeta", Active: true, SortOrder: 2, CollectibleID: 2},
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1},
{Username: "editable", Active: true, Editable: true, SortOrder: 1},
}
if got := projectedNames(t, service); got != "alpha,editable,zeta" {
t.Fatalf("reordered projection = %v, want alpha,editable,zeta", got)
}
}
func projectedNames(t *testing.T, service *Service) string {
t.Helper()
list, err := service.PeerUsernames(context.Background(), testUser)
if err != nil {
t.Fatalf("PeerUsernames: %v", err)
}
got := make([]string, 0, len(list))
for _, item := range list {
got = append(got, item.Username)
}
return strings.Join(got, ",")
}
func TestUsernamesBatchSkipsInvalidAndEmptyPeers(t *testing.T) {
registry := newFakeRegistry()
registry.lists[testUser] = []domain.Username{{Username: "alpha", Active: true, CollectibleID: 1}}
registry.lists[testChannel] = nil
service, _ := newTestService(t, registry, newFakeCollectibles())
batch, err := service.UsernamesBatch(context.Background(), []domain.Peer{testUser, testUser, testChannel, {}, {Type: domain.PeerTypeUser}})
if err != nil {
t.Fatalf("UsernamesBatch: %v", err)
}
if len(batch) != 1 || len(batch[testUser]) != 1 {
t.Fatalf("batch = %#v, want only the peer holding usernames", batch)
}
}
func TestToggleUsernameNormalizesBeforeStore(t *testing.T) {
registry := newFakeRegistry()
registry.lists[testUser] = []domain.Username{
{Username: "editable", Active: true, Editable: true},
{Username: "Nft_One", Active: false, CollectibleID: 1},
}
service, notifier := newTestService(t, registry, newFakeCollectibles())
changed, err := service.ToggleUsername(context.Background(), testUser, " @Nft_One ", true)
if err != nil || !changed {
t.Fatalf("ToggleUsername = %v, %v", changed, err)
}
if len(registry.toggles) != 1 || registry.toggles[0].username != "Nft_One" || !registry.toggles[0].active {
t.Fatalf("store toggles = %#v, want normalized Nft_One", registry.toggles)
}
if len(notifier.peers) != 1 || notifier.peers[0] != testUser {
t.Fatalf("notified peers = %#v, want the toggled peer", notifier.peers)
}
}
func TestToggleUsernameValidatesBeforeStore(t *testing.T) {
tests := []struct {
name string
list []domain.Username
username string
active bool
wantErr error
}{
{
name: "editable slot is not collectible",
list: []domain.Username{{Username: "editable", Active: true, Editable: true}},
username: "editable",
wantErr: domain.ErrUsernameNotCollectible,
},
{
name: "unknown username",
list: []domain.Username{{Username: "alpha", Active: true, CollectibleID: 1}},
username: "missing",
wantErr: domain.ErrUsernameNotOccupied,
},
{
name: "empty username",
list: []domain.Username{{Username: "alpha", Active: true, CollectibleID: 1}},
username: "@",
wantErr: domain.ErrUsernameInvalid,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
registry := newFakeRegistry()
registry.lists[testUser] = test.list
service, notifier := newTestService(t, registry, newFakeCollectibles())
_, err := service.ToggleUsername(context.Background(), testUser, test.username, test.active)
if !errors.Is(err, test.wantErr) {
t.Fatalf("ToggleUsername error = %v, want %v", err, test.wantErr)
}
if len(registry.toggles) != 0 {
t.Fatalf("store was called with invalid input: %#v", registry.toggles)
}
if len(notifier.peers) != 0 {
t.Fatalf("notifier ran for a rejected toggle: %#v", notifier.peers)
}
})
}
}
func TestReorderUsernamesNormalizesPermutation(t *testing.T) {
registry := newFakeRegistry()
registry.lists[testUser] = []domain.Username{
{Username: "editable", Active: true, Editable: true},
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1},
{Username: "zeta", Active: true, SortOrder: 1, CollectibleID: 2},
}
service, notifier := newTestService(t, registry, newFakeCollectibles())
changed, err := service.ReorderUsernames(context.Background(), testUser, []string{"@zeta", " alpha ", "editable"})
if err != nil || !changed {
t.Fatalf("ReorderUsernames = %v, %v", changed, err)
}
if len(registry.orders) != 1 || strings.Join(registry.orders[0], ",") != "zeta,alpha,editable" {
t.Fatalf("store order = %#v, want normalized zeta,alpha,editable", registry.orders)
}
if len(notifier.peers) != 1 {
t.Fatalf("notified peers = %#v, want one", notifier.peers)
}
}
func TestReorderUsernamesRejectsIncompletePermutation(t *testing.T) {
registry := newFakeRegistry()
registry.lists[testUser] = []domain.Username{
{Username: "alpha", Active: true, CollectibleID: 1},
{Username: "zeta", Active: true, CollectibleID: 2},
}
service, _ := newTestService(t, registry, newFakeCollectibles())
if _, err := service.ReorderUsernames(context.Background(), testUser, []string{"alpha"}); !errors.Is(err, domain.ErrUsernameOrderInvalid) {
t.Fatalf("ReorderUsernames error = %v, want ErrUsernameOrderInvalid", err)
}
if len(registry.orders) != 0 {
t.Fatalf("store was called with a non-permutation: %#v", registry.orders)
}
}
// TestReorderUsernamesAcceptsTheEditableSlot is the report "channels.reorderUsernames
// answers USERNAME_INVALID": Telegram Desktop sends the whole visible list, and
// core.telegram.org/api/fragment requires exactly that ("all currently active
// usernames must be specified"), so the editable slot is a legitimate member of
// the order -- including as its first entry, and including when it is the only
// username the peer has.
func TestReorderUsernamesAcceptsTheEditableSlot(t *testing.T) {
registry := newFakeRegistry()
registry.lists[testChannel] = []domain.Username{
{Username: "chan_slot", Active: true, Editable: true},
}
service, _ := newTestService(t, registry, newFakeCollectibles())
if _, err := service.ReorderUsernames(context.Background(), testChannel, []string{"chan_slot"}); err != nil {
t.Fatalf("editable-only reorder: %v", err)
}
if len(registry.orders) != 1 || strings.Join(registry.orders[0], ",") != "chan_slot" {
t.Fatalf("store order = %#v, want chan_slot", registry.orders)
}
// An inactive collectible does not have to be listed, and listing an unknown
// name is still rejected.
registry.lists[testChannel] = []domain.Username{
{Username: "chan_slot", Active: true, Editable: true},
{Username: "hidden", Active: false, CollectibleID: 7},
}
if _, err := service.ReorderUsernames(context.Background(), testChannel, []string{"chan_slot"}); err != nil {
t.Fatalf("reorder omitting an inactive collectible: %v", err)
}
if _, err := service.ReorderUsernames(context.Background(), testChannel, []string{"chan_slot", "nothere"}); !errors.Is(err, domain.ErrUsernameOrderInvalid) {
t.Fatalf("reorder with an unknown name = %v, want ErrUsernameOrderInvalid", err)
}
}
func TestDeactivateAllUsernamesNotifiesPeer(t *testing.T) {
registry := newFakeRegistry()
service, notifier := newTestService(t, registry, newFakeCollectibles())
changed, err := service.DeactivateAllUsernames(context.Background(), testChannel)
if err != nil || !changed {
t.Fatalf("DeactivateAllUsernames = %v, %v", changed, err)
}
if len(registry.clears) != 1 || registry.clears[0] != testChannel {
t.Fatalf("store clears = %#v", registry.clears)
}
if len(notifier.peers) != 1 || notifier.peers[0] != testChannel {
t.Fatalf("notified peers = %#v", notifier.peers)
}
}
func TestMintRendersCollectibleURL(t *testing.T) {
tests := []struct {
name string
opts []Option
url string
wantURL string
username string
}{
{
name: "public-link default route",
opts: []Option{WithPublicBaseURL("https://example.test")},
username: "alpha",
wantURL: "https://example.test/nft/username/alpha",
},
{
name: "template placeholder",
opts: []Option{WithURLTemplate("https://frag.example/u/{username}?ref=1"), WithPublicBaseURL("https://example.test")},
username: "alpha",
wantURL: "https://frag.example/u/alpha?ref=1",
},
{
name: "template without placeholder appends the name",
opts: []Option{WithURLTemplate("https://frag.example/u/")},
username: "alpha",
wantURL: "https://frag.example/u/alpha",
},
{
name: "explicit request URL wins",
opts: []Option{WithURLTemplate("https://frag.example/u/{username}")},
username: "alpha",
url: "https://operator.example/custom",
wantURL: "https://operator.example/custom",
},
{
name: "no template and no base URL keeps the URL empty",
username: "alpha",
wantURL: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
collectibles := newFakeCollectibles()
service, _ := newTestService(t, newFakeRegistry(), collectibles, test.opts...)
asset, created, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{
Username: "@" + test.username, Currency: domain.CollectibleCurrencyStars, Amount: 1000, URL: test.url,
})
if err != nil || !created {
t.Fatalf("Mint = %v, %v", created, err)
}
if asset.URL != test.wantURL {
t.Fatalf("asset URL = %q, want %q", asset.URL, test.wantURL)
}
if len(collectibles.mints) != 1 {
t.Fatalf("mints = %d, want 1", len(collectibles.mints))
}
if got := collectibles.mints[0].Username; got != test.username {
t.Fatalf("stored username = %q, want normalized %q", got, test.username)
}
if !collectibles.mints[0].PurchaseDate.Equal(testClock) {
t.Fatalf("purchase date = %v, want the service clock %v", collectibles.mints[0].PurchaseDate, testClock)
}
})
}
}
func TestMintValidatesBeforeStore(t *testing.T) {
tests := []struct {
name string
req domain.MintCollectibleUsernameRequest
wantErr error
}{
{
name: "username too short",
req: domain.MintCollectibleUsernameRequest{Username: "ab", Currency: domain.CollectibleCurrencyStars},
wantErr: domain.ErrUsernameInvalid,
},
{
name: "unsupported currency",
req: domain.MintCollectibleUsernameRequest{Username: "alpha", Currency: "EUR"},
wantErr: domain.ErrCollectibleCurrencyInvalid,
},
{
name: "crypto amount without currency",
req: domain.MintCollectibleUsernameRequest{Username: "alpha", Currency: domain.CollectibleCurrencyStars, CryptoAmount: 5},
wantErr: domain.ErrCollectibleCurrencyInvalid,
},
{
name: "owner peer is not a username holder",
req: domain.MintCollectibleUsernameRequest{
Username: "alpha", Currency: domain.CollectibleCurrencyStars,
Owner: domain.Peer{Type: domain.PeerTypeCommunity, ID: 5},
},
wantErr: domain.ErrCollectibleUsernameStateInvalid,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
collectibles := newFakeCollectibles()
service, notifier := newTestService(t, newFakeRegistry(), collectibles)
if _, _, err := service.Mint(context.Background(), test.req); !errors.Is(err, test.wantErr) {
t.Fatalf("Mint error = %v, want %v", err, test.wantErr)
}
if len(collectibles.mints) != 0 {
t.Fatalf("store was called with invalid input: %#v", collectibles.mints)
}
if len(notifier.peers) != 0 {
t.Fatalf("notifier ran for a rejected mint: %#v", notifier.peers)
}
})
}
}
func TestMintNotifiesOwnerOnly(t *testing.T) {
collectibles := newFakeCollectibles()
service, notifier := newTestService(t, newFakeRegistry(), collectibles, WithPublicBaseURL("https://example.test"))
if _, _, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{
Username: "vaulted", Currency: domain.CollectibleCurrencyStars,
}); err != nil {
t.Fatalf("Mint vault: %v", err)
}
if len(notifier.peers) != 0 {
t.Fatalf("vault mint notified %#v, want nothing", notifier.peers)
}
if _, _, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{
Username: "assigned", Currency: domain.CollectibleCurrencyStars, Owner: testUser,
}); err != nil {
t.Fatalf("Mint assigned: %v", err)
}
if len(notifier.peers) != 1 || notifier.peers[0] != testUser {
t.Fatalf("notified peers = %#v, want the assigned owner", notifier.peers)
}
}
func TestTransferNotifiesPreviousAndNewOwner(t *testing.T) {
collectibles := newFakeCollectibles()
collectibles.assets["alpha"] = domain.CollectibleUsername{
ID: 1, Username: "alpha", Status: domain.CollectibleUsernameStatusOwned, Owner: testUser,
}
service, notifier := newTestService(t, newFakeRegistry(), collectibles)
_, changed, err := service.Transfer(context.Background(), domain.TransferCollectibleUsernameRequest{
Username: "@Alpha", To: testChannel, Actor: "admin", CommandKey: "cmd-1",
})
if err != nil || !changed {
t.Fatalf("Transfer = %v, %v", changed, err)
}
if len(collectibles.transfers) != 1 || collectibles.transfers[0].Username != "Alpha" {
t.Fatalf("stored transfer = %#v, want normalized username", collectibles.transfers)
}
if len(notifier.peers) != 2 {
t.Fatalf("notified peers = %#v, want previous and new owner", notifier.peers)
}
seen := map[domain.Peer]bool{notifier.peers[0]: true, notifier.peers[1]: true}
if !seen[testUser] || !seen[testChannel] {
t.Fatalf("notified peers = %#v, want %v and %v", notifier.peers, testUser, testChannel)
}
}
func TestRevokeNotifiesPreviousOwner(t *testing.T) {
collectibles := newFakeCollectibles()
collectibles.assets["alpha"] = domain.CollectibleUsername{
ID: 1, Username: "alpha", Status: domain.CollectibleUsernameStatusOwned, Owner: testUser,
}
service, notifier := newTestService(t, newFakeRegistry(), collectibles)
asset, changed, err := service.Revoke(context.Background(), domain.RevokeCollectibleUsernameRequest{
Username: "alpha", Burn: true, Actor: "admin",
})
if err != nil || !changed {
t.Fatalf("Revoke = %v, %v", changed, err)
}
if asset.Status != domain.CollectibleUsernameStatusBurned {
t.Fatalf("asset status = %q, want burned", asset.Status)
}
if len(notifier.peers) != 1 || notifier.peers[0] != testUser {
t.Fatalf("notified peers = %#v, want the previous owner", notifier.peers)
}
}
func TestCollectibleInfoProjectsPurchaseRecord(t *testing.T) {
collectibles := newFakeCollectibles()
collectibles.assets["alpha"] = domain.CollectibleUsername{
ID: 1, Username: "alpha", Status: domain.CollectibleUsernameStatusOwned, Owner: testUser,
PurchaseDate: testClock, Currency: domain.CollectibleCurrencyStars, Amount: 2500,
URL: "https://example.test/nft/username/alpha",
}
service, _ := newTestService(t, newFakeRegistry(), collectibles)
info, err := service.CollectibleInfo(context.Background(), "@ALPHA")
if err != nil {
t.Fatalf("CollectibleInfo: %v", err)
}
if info.PurchaseDate != int(testClock.Unix()) || info.Amount != 2500 || info.Currency != domain.CollectibleCurrencyStars {
t.Fatalf("collectible info = %#v", info)
}
if _, err := service.CollectibleInfo(context.Background(), "ab"); !errors.Is(err, domain.ErrUsernameInvalid) {
t.Fatalf("CollectibleInfo short name error = %v, want ErrUsernameInvalid", err)
}
}
func TestListAndTransfersBoundThePage(t *testing.T) {
collectibles := newFakeCollectibles()
service, _ := newTestService(t, newFakeRegistry(), collectibles)
if _, err := service.List(context.Background(), domain.CollectibleUsernameFilter{Query: " @Alpha ", Limit: 0}); err != nil {
t.Fatalf("List: %v", err)
}
if _, err := service.List(context.Background(), domain.CollectibleUsernameFilter{Limit: 100000}); err != nil {
t.Fatalf("List: %v", err)
}
if len(collectibles.filters) != 2 ||
collectibles.filters[0].Limit != defaultListLimit || collectibles.filters[0].Query != "Alpha" ||
collectibles.filters[1].Limit != maxListLimit {
t.Fatalf("filters = %#v", collectibles.filters)
}
if _, err := service.List(context.Background(), domain.CollectibleUsernameFilter{Status: "sold"}); !errors.Is(err, domain.ErrCollectibleUsernameStateInvalid) {
t.Fatalf("List accepted an unmodelled status")
}
if _, err := service.Transfers(context.Background(), 7, 0); err != nil {
t.Fatalf("Transfers: %v", err)
}
if len(collectibles.logLimits) != 1 || collectibles.logLimits[0] != defaultTransferLimit {
t.Fatalf("transfer log limits = %#v", collectibles.logLimits)
}
if _, err := service.Transfers(context.Background(), 0, 10); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("Transfers accepted a zero collectible id")
}
}
func TestServiceWithoutStoresReportsConfiguration(t *testing.T) {
service := NewService()
if service.Configured() {
t.Fatal("Configured = true without stores")
}
if _, err := service.PeerUsernames(context.Background(), testUser); err == nil {
t.Fatal("PeerUsernames accepted a missing registry store")
}
if _, err := service.ToggleUsername(context.Background(), testUser, "alpha", true); err == nil {
t.Fatal("ToggleUsername accepted a missing registry store")
}
if _, _, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{Username: "alpha"}); err == nil {
t.Fatal("Mint accepted a missing collectible store")
}
}
func TestNilServiceIsSafe(t *testing.T) {
var service *Service
service.SetPeerUsernameNotifier(&recordingNotifier{})
if service.Configured() {
t.Fatal("nil service reported configured")
}
if url := service.CollectibleURL("alpha"); url != "" {
t.Fatalf("nil service URL = %q", url)
}
if _, err := service.PeerUsernames(context.Background(), testUser); err == nil {
t.Fatal("nil service PeerUsernames returned no error")
}
if _, _, err := service.Transfer(context.Background(), domain.TransferCollectibleUsernameRequest{Username: "alpha", To: testUser}); err == nil {
t.Fatal("nil service Transfer returned no error")
}
}
func TestNotifierFailureDoesNotFailTheMutation(t *testing.T) {
registry := newFakeRegistry()
registry.lists[testUser] = []domain.Username{
{Username: "editable", Active: true, Editable: true},
{Username: "alpha", Active: true, CollectibleID: 1},
}
notifier := &recordingNotifier{err: errors.New("push failed")}
service := NewService(
WithRegistryStore(registry),
WithCollectibleStore(newFakeCollectibles()),
WithNotifier(notifier),
)
changed, err := service.ToggleUsername(context.Background(), testUser, "alpha", false)
if err != nil || !changed {
t.Fatalf("ToggleUsername = %v, %v; committed mutation must survive a failed push", changed, err)
}
}
// TestServiceDeleteNotifiesPreviousOwner covers the hard delete: the request is
// normalised and validated before the store is touched, and the peer that held
// the asset is invalidated so its projection stops advertising the username.
func TestServiceDeleteNotifiesPreviousOwner(t *testing.T) {
ctx := context.Background()
registry := newFakeRegistry()
collectibles := newFakeCollectibles()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 501}
collectibles.assets["gone"] = domain.CollectibleUsername{
ID: 9, Username: "Gone", Status: domain.CollectibleUsernameStatusOwned, Owner: holder,
}
svc, notifier := newTestService(t, registry, collectibles)
deleted, err := svc.Delete(ctx, domain.DeleteCollectibleUsernameRequest{
Username: " @Gone ", Actor: "admin", Reason: "issued by mistake",
})
if err != nil || !deleted {
t.Fatalf("delete: deleted=%v err=%v", deleted, err)
}
if len(collectibles.deletes) != 1 || collectibles.deletes[0].Username != "Gone" {
t.Fatalf("store received %+v, want the normalised name", collectibles.deletes)
}
if len(notifier.peers) != 1 || notifier.peers[0] != holder {
t.Fatalf("notified peers = %#v, want the previous owner %+v", notifier.peers, holder)
}
// An invalid name never reaches the store.
before := len(collectibles.deletes)
if _, err := svc.Delete(ctx, domain.DeleteCollectibleUsernameRequest{Username: "no"}); err == nil {
t.Fatalf("delete of a too-short name = nil error, want rejection")
}
if len(collectibles.deletes) != before {
t.Fatalf("store was called with an invalid request: %+v", collectibles.deletes)
}
// Nothing live left is not an error, and nothing is notified.
notifier.peers = nil
deleted, err = svc.Delete(ctx, domain.DeleteCollectibleUsernameRequest{
Username: "absentname", Actor: "admin", Reason: "again",
})
if err != nil || deleted {
t.Fatalf("delete of unknown name = %v err=%v, want (false, nil)", deleted, err)
}
if len(notifier.peers) != 0 {
t.Fatalf("no-op delete notified %+v", notifier.peers)
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,88 @@
package verification
import (
"context"
"time"
"go.uber.org/zap"
)
// defaultNotifyInterval matches the shipped
// TELESRV_VERIFICATION_NOTIFY_INTERVAL default.
const defaultNotifyInterval = 15 * time.Second
// NotificationWorker drains the applicant-notification outbox.
//
// A decision is committed together with its outbox row, never with a message
// send: @verifybot may be blocked, the applicant may be deleted, and the panel
// must not wait on either. Delivery is therefore a separate, retrying cycle over
// durable rows, and this worker is only its cadence.
type NotificationWorker struct {
service *Service
logger *zap.Logger
interval time.Duration
batch int
}
// NewNotificationWorker creates the periodic delivery worker. Non-positive
// interval/batch fall back to the shipped defaults, matching the rating
// recompute worker's contract.
func NewNotificationWorker(service *Service, logger *zap.Logger, interval time.Duration, batch int) *NotificationWorker {
if logger == nil {
logger = zap.NewNop()
}
if interval <= 0 {
interval = defaultNotifyInterval
}
if batch <= 0 {
batch = defaultNotifyBatch
}
return &NotificationWorker{service: service, logger: logger, interval: interval, batch: batch}
}
// Run delivers one batch immediately and then on every tick until ctx is done. A
// disabled or store-less service exits immediately with one explicit log line
// instead of ticking forever over a no-op.
func (w *NotificationWorker) Run(ctx context.Context) {
if w == nil {
return
}
if !w.service.Ready() {
w.logger.Info("verification notification worker disabled",
zap.Bool("enabled", w.service.Enabled()))
return
}
w.runOnce(ctx)
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.runOnce(ctx)
}
}
}
func (w *NotificationWorker) runOnce(ctx context.Context) {
if w == nil || w.service == nil {
return
}
delivered, err := w.service.RunNotificationCycle(ctx, w.batch)
if err != nil {
if ctx.Err() != nil {
return
}
w.logger.Warn("verification notification cycle failed",
zap.Int("delivered", delivered),
zap.Int("batch", w.batch),
zap.Error(err))
return
}
if delivered > 0 {
w.logger.Info("verification notification cycle completed",
zap.Int("delivered", delivered),
zap.Int("batch", w.batch))
}
}

View file

@ -13,6 +13,7 @@ import (
"golang.org/x/text/language"
"telesrv/internal/domain"
"telesrv/internal/links"
)
@ -130,6 +131,19 @@ type Config struct {
AdminUIPassword string
AdminUIToken string
AdminSessionKey string
// AdminUIPermissions is the permission set granted to a panel session that
// authenticated with TELESRV_ADMIN_UI_PASSWORD / _TOKEN. The single entry "*"
// means "every permission" and is the shipped default, so enabling RBAC never
// silently locks an operator out of a panel that worked before.
AdminUIPermissions []string
// AdminScopedTokens are additional adminapi bearer tokens with a bounded
// permission set each. They exist so an integration can be given exactly the
// rights it needs instead of the unrestricted TELESRV_ADMIN_API_TOKEN. Parsed
// from
// "name:token:perm1,perm2" entries separated by ';'; a malformed entry, a
// duplicate name or a duplicate token fails startup rather than silently
// granting or dropping rights.
AdminScopedTokens []AdminScopedToken
// PostgresDSN 是业务数据(auth_key / user / authorization 等)持久化的 PostgreSQL 连接串。
// 依赖由 deploy/docker-compose.yml 启动;职责划分见 docs/persistence-layer.md。
@ -374,6 +388,106 @@ type Config struct {
StarGiftCraftDelay time.Duration
StarGiftCraftChancePermille int
// RatingEnabled controls the local admin-only composite account rating.
// Disabled keeps every local projection empty and refuses rating writes; no
// client-facing Telegram field changes in either mode.
RatingEnabled bool
// RatingPendingDelay is how long a rating increase stays parked as a pending
// local score before it becomes the visible admin level. A decrease is
// always applied immediately: a penalty must not sit behind a delay.
// 0 applies every change immediately.
RatingPendingDelay time.Duration
// RatingRecomputeInterval / RatingRecomputeBatch drive the background
// recompute worker. The rating derives from signals owned by other
// subsystems, so freshness is a worker property, not a write-path one.
RatingRecomputeInterval time.Duration
RatingRecomputeBatch int
// RatingStaleAfter is the projection age after which the worker recomputes a
// user.
RatingStaleAfter time.Duration
// Rating weights are the integer composite formula. Defaults mirror
// domain.DefaultAccountRatingWeights() exactly, so the shipped behaviour is
// identical whether or not these keys are set. Every weight is a magnitude:
// the penalties are subtracted by the domain formula, so all values are
// non-negative and a negative value fails startup.
RatingWeightStarsReceivedPermille int64
RatingWeightStarsSpentPermille int64
RatingWeightMessageSent int64
RatingWeightAccountAgeDay int64
RatingWeightGiftReceived int64
RatingWeightModerationCase int64
RatingWeightScamPenalty int64
RatingWeightFakePenalty int64
// RatingActivityCap bounds the activity component so activity alone cannot
// outweigh Stars and moderation; 0 leaves it uncapped.
RatingActivityCap int64
// VerificationEnabled controls official platform verification: the @verifybot
// application flow and the panel's review queue. Disabled refuses every
// verification use case explicitly; already-verified peers keep their badge,
// because the flag lives on the peer record and is not derived from this
// feature being on.
VerificationEnabled bool
// VerificationAllowUserTargets opts plain user accounts in as verification
// subjects. Off by default: the official process verifies a public presence
// (bot, public channel, public supergroup), and a private account has nothing
// to check.
VerificationAllowUserTargets bool
// VerificationRejectCooldown is how long an applicant must wait before filing
// the same target again after a rejection. Measured from the decision, so a
// slow review never shortens it; 0 disables the cooldown.
VerificationRejectCooldown time.Duration
// VerificationApplyRateLimit / VerificationApplyRateWindow bound how many
// applications one applicant may create per window. 0 for either disables the
// budget.
VerificationApplyRateLimit int
VerificationApplyRateWindow time.Duration
// VerificationBotRateLimit / VerificationBotRateWindow bound the @verifybot
// dialog itself (per-applicant command rate), independently of how many
// applications are actually created.
VerificationBotRateLimit int
VerificationBotRateWindow time.Duration
// VerificationNotifyInterval / VerificationNotifyBatch drive the applicant
// notification worker. A decision commits with its outbox row, never with a
// message send, so delivery cadence is a worker property.
VerificationNotifyInterval time.Duration
VerificationNotifyBatch int
// VerificationMaxActivePerUser bounds how many applications one applicant may
// keep open at once; 0 disables the cap.
VerificationMaxActivePerUser int
// BotVerificationEnabled controls THIRD-PARTY bot verification
// (core.telegram.org/api/bots/verification): a verifier bot marking peers with
// its own icon and description, projected onto
// user/channel.bot_verification_icon and botInfo.verifier_settings. It is a
// different mechanism from VerificationEnabled above -- that one is the
// operator-granted platform checkmark, and the two never read each other's
// state.
//
// Disabled refuses every third-party mutation (grants, revocations,
// applications, catalogue edits) while the marks already granted keep
// projecting: blanking one verifier's badges is what its per-verifier kill
// switch is for.
BotVerificationEnabled bool
// BotVerificationMaxPerVerifier bounds how many peers one verifier bot may
// mark. Verifier status is granted per deployment rather than earned per peer,
// so an unbounded verifier would be an unbounded badge printer. 0 disables the
// service-level bound and leaves only the storage bound
// (domain.MaxCustomVerificationsPerVerifier), which is also the maximum this
// key accepts.
BotVerificationMaxPerVerifier int
// BotVerificationRequestRateLimit / BotVerificationRequestRateWindow bound how
// many verification applications one applicant may file per window, across all
// verifier bots. 0 for either disables the budget.
BotVerificationRequestRateLimit int
BotVerificationRequestRateWindow time.Duration
// CollectibleUsernameURLTemplate is the landing URL recorded on a minted
// collectible username when the mint request carries no explicit URL.
// Empty derives <TELESRV_PUBLIC_BASE_URL>/nft/username/<username>; a template
// may carry the {username} placeholder, and without it the name is appended
// as the last path segment. No external marketplace is contacted.
CollectibleUsernameURLTemplate string
// GroupCallCheckTTL 是群通话参与者保活水位的过期阈值(客户端 Connecting 态
// 4s 一跳;M1 起 SFU liveness reporter 同样刷新该水位)。
GroupCallCheckTTL time.Duration
@ -423,6 +537,15 @@ type Config struct {
SFUAdvertiseIP string
}
// AdminScopedToken is one adminapi bearer token restricted to a permission set.
// Name is the audit identity written next to actions performed with the token;
// Permissions is the closed list of rights it carries ("*" means all).
type AdminScopedToken struct {
Name string
Token string
Permissions []string
}
type AIProviderConfig struct {
Name string
Kind string
@ -473,6 +596,13 @@ func Load() (Config, error) {
if err != nil {
return Config{}, fmt.Errorf("TELESRV_DEFAULT_COUNTRY_CODE: %w", err)
}
// The composite rating weight defaults are the domain formula's own defaults;
// see RatingWeight* below.
defaultRatingWeights := domain.DefaultAccountRatingWeights()
adminScopedTokens, err := parseAdminScopedTokens(envAllowEmptyOr("TELESRV_ADMIN_SCOPED_TOKENS", ""))
if err != nil {
return Config{}, err
}
cfg := Config{
ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"),
@ -537,6 +667,8 @@ func Load() (Config, error) {
TelegramLoginRetention: envDurationOr("TELESRV_TELEGRAM_LOGIN_RETENTION", 7*24*time.Hour),
TelegramLoginSweepInterval: envDurationOr("TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL", 5*time.Minute),
TelegramLoginSweepBatch: envIntOr("TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH", 500),
AdminUIPermissions: envListOr("TELESRV_ADMIN_UI_PERMISSIONS", []string{adminPermissionAll}),
AdminScopedTokens: adminScopedTokens,
AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"),
AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""),
AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""),
@ -664,6 +796,47 @@ func Load() (Config, error) {
StarGiftCraftDelay: envDurationOr("TELESRV_STARGIFT_CRAFT_DELAY", 0),
StarGiftCraftChancePermille: envIntOr("TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE", 250),
RatingEnabled: envBoolOr("TELESRV_RATING_ENABLED", true),
RatingPendingDelay: envDurationOr("TELESRV_RATING_PENDING_DELAY", 24*time.Hour),
RatingRecomputeInterval: envDurationOr("TELESRV_RATING_RECOMPUTE_INTERVAL", 15*time.Minute),
RatingRecomputeBatch: envIntOr("TELESRV_RATING_RECOMPUTE_BATCH", 500),
RatingStaleAfter: envDurationOr("TELESRV_RATING_STALE_AFTER", 6*time.Hour),
// Weight defaults are read from the domain formula itself so the shipped
// behaviour cannot drift from domain.DefaultAccountRatingWeights().
RatingWeightStarsReceivedPermille: envInt64Or("TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE", defaultRatingWeights.StarsReceivedPermille),
RatingWeightStarsSpentPermille: envInt64Or("TELESRV_RATING_WEIGHT_STARS_SPENT_PERMILLE", defaultRatingWeights.StarsSpentPermille),
RatingWeightMessageSent: envInt64Or("TELESRV_RATING_WEIGHT_MESSAGE_SENT", defaultRatingWeights.PerMessageSent),
RatingWeightAccountAgeDay: envInt64Or("TELESRV_RATING_WEIGHT_ACCOUNT_AGE_DAY", defaultRatingWeights.PerAccountAgeDay),
RatingWeightGiftReceived: envInt64Or("TELESRV_RATING_WEIGHT_GIFT_RECEIVED", defaultRatingWeights.PerGiftReceived),
RatingWeightModerationCase: envInt64Or("TELESRV_RATING_WEIGHT_MODERATION_CASE", defaultRatingWeights.PerModerationCase),
RatingWeightScamPenalty: envInt64Or("TELESRV_RATING_WEIGHT_SCAM_PENALTY", defaultRatingWeights.ScamPenalty),
RatingWeightFakePenalty: envInt64Or("TELESRV_RATING_WEIGHT_FAKE_PENALTY", defaultRatingWeights.FakePenalty),
RatingActivityCap: envInt64Or("TELESRV_RATING_ACTIVITY_CAP", defaultRatingWeights.ActivityCap),
CollectibleUsernameURLTemplate: strings.TrimSpace(envAllowEmptyOr("TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE", "")),
// Official verification defaults ship the feature on with the official bar
// in place: user accounts are not accepted, a rejection costs a month, and
// an applicant can neither flood the queue nor keep an unbounded number of
// applications open.
VerificationEnabled: envBoolOr("TELESRV_VERIFICATION_ENABLED", true),
VerificationAllowUserTargets: envBoolOr("TELESRV_VERIFICATION_ALLOW_USER_TARGETS", false),
VerificationRejectCooldown: envDurationOr("TELESRV_VERIFICATION_REJECT_COOLDOWN", 720*time.Hour),
VerificationApplyRateLimit: envIntOr("TELESRV_VERIFICATION_APPLY_RATE_LIMIT", 3),
VerificationApplyRateWindow: envDurationOr("TELESRV_VERIFICATION_APPLY_RATE_WINDOW", 24*time.Hour),
VerificationBotRateLimit: envIntOr("TELESRV_VERIFICATION_BOT_RATE_LIMIT", 30),
VerificationBotRateWindow: envDurationOr("TELESRV_VERIFICATION_BOT_RATE_WINDOW", time.Minute),
VerificationNotifyInterval: envDurationOr("TELESRV_VERIFICATION_NOTIFY_INTERVAL", 15*time.Second),
VerificationNotifyBatch: envIntOr("TELESRV_VERIFICATION_NOTIFY_BATCH", 50),
VerificationMaxActivePerUser: envIntOr("TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER", 3),
BotVerificationEnabled: envBoolOr("TELESRV_BOT_VERIFICATION_ENABLED", true),
BotVerificationMaxPerVerifier: envIntOr("TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER", domain.MaxCustomVerificationsPerVerifier),
// The applicant budget is deliberately looser than the official one
// (TELESRV_VERIFICATION_APPLY_RATE_LIMIT=3): a deployment can run many
// verifier bots, and filing with a second company is not a retry of the first.
BotVerificationRequestRateLimit: envIntOr("TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT", 5),
BotVerificationRequestRateWindow: envDurationOr("TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW", 24*time.Hour),
GroupCallCheckTTL: envDurationOr("TELESRV_GROUPCALL_CHECK_TTL", 45*time.Second),
GroupCallSweepInterval: envDurationOr("TELESRV_GROUPCALL_SWEEP_INTERVAL", 10*time.Second),
GroupCallMaxParticipants: envIntOr("TELESRV_GROUPCALL_MAX_PARTICIPANTS", 32),
@ -697,6 +870,18 @@ func Load() (Config, error) {
if err := validateStarGiftConfig(cfg); err != nil {
return Config{}, err
}
if err := validateAccountRatingConfig(cfg); err != nil {
return Config{}, err
}
if err := validateCollectibleUsernameConfig(cfg); err != nil {
return Config{}, err
}
if err := validateVerificationConfig(cfg); err != nil {
return Config{}, err
}
if err := validateAdminRBACConfig(cfg); err != nil {
return Config{}, err
}
if err := validateTelegramLoginConfig(cfg); err != nil {
return Config{}, err
}
@ -785,6 +970,247 @@ func validateStarGiftConfig(cfg Config) error {
return nil
}
// AccountRatingWeights renders the configured composite rating formula. It is
// the single conversion point between env keys and the domain formula, so the
// app service and the admin explanation always use the same numbers.
func (c Config) AccountRatingWeights() domain.AccountRatingWeights {
return domain.AccountRatingWeights{
StarsReceivedPermille: c.RatingWeightStarsReceivedPermille,
StarsSpentPermille: c.RatingWeightStarsSpentPermille,
PerMessageSent: c.RatingWeightMessageSent,
PerAccountAgeDay: c.RatingWeightAccountAgeDay,
PerGiftReceived: c.RatingWeightGiftReceived,
PerModerationCase: c.RatingWeightModerationCase,
ScamPenalty: c.RatingWeightScamPenalty,
FakePenalty: c.RatingWeightFakePenalty,
ActivityCap: c.RatingActivityCap,
}
}
// validateAccountRatingConfig rejects a formula or worker cadence that cannot
// produce a reproducible rating. Weights are validated even when the feature is
// disabled: enabling it later must not be the moment a typo is discovered.
func validateAccountRatingConfig(cfg Config) error {
if err := cfg.AccountRatingWeights().Validate(); err != nil {
return fmt.Errorf("TELESRV_RATING_WEIGHT_* and TELESRV_RATING_ACTIVITY_CAP must be non-negative: %w", err)
}
if cfg.RatingPendingDelay < 0 {
return fmt.Errorf("TELESRV_RATING_PENDING_DELAY must be non-negative")
}
const maxRatingPendingDelay = 30 * 24 * time.Hour
if cfg.RatingPendingDelay > maxRatingPendingDelay {
return fmt.Errorf("TELESRV_RATING_PENDING_DELAY must not exceed 720h")
}
if cfg.RatingRecomputeInterval <= 0 {
return fmt.Errorf("TELESRV_RATING_RECOMPUTE_INTERVAL must be positive")
}
if cfg.RatingStaleAfter <= 0 {
return fmt.Errorf("TELESRV_RATING_STALE_AFTER must be positive")
}
if cfg.RatingRecomputeBatch <= 0 || cfg.RatingRecomputeBatch > 10000 {
return fmt.Errorf("TELESRV_RATING_RECOMPUTE_BATCH must be 1..10000")
}
return nil
}
// adminPermissionAll is the wildcard permission: a session or token carrying it
// may perform every admin action.
const adminPermissionAll = "*"
// validateVerificationConfig rejects a verification policy that cannot be
// enforced, for both mechanisms: the operator-granted platform badge and the
// third-party bot verification marks. It runs even when either feature is
// disabled, so enabling it later is not the moment a typo is discovered.
func validateVerificationConfig(cfg Config) error {
if cfg.VerificationRejectCooldown < 0 {
return fmt.Errorf("TELESRV_VERIFICATION_REJECT_COOLDOWN must be non-negative")
}
const maxVerificationRejectCooldown = 365 * 24 * time.Hour
if cfg.VerificationRejectCooldown > maxVerificationRejectCooldown {
return fmt.Errorf("TELESRV_VERIFICATION_REJECT_COOLDOWN must not exceed 8760h")
}
if cfg.VerificationApplyRateLimit < 0 || cfg.VerificationBotRateLimit < 0 {
return fmt.Errorf("TELESRV_VERIFICATION_APPLY_RATE_LIMIT and TELESRV_VERIFICATION_BOT_RATE_LIMIT must be non-negative")
}
if cfg.VerificationApplyRateWindow < 0 || cfg.VerificationBotRateWindow < 0 {
return fmt.Errorf("TELESRV_VERIFICATION_APPLY_RATE_WINDOW and TELESRV_VERIFICATION_BOT_RATE_WINDOW must be non-negative")
}
// A positive limit with a zero window is not "unlimited", it is a limiter that
// can never refill: reject it instead of shipping a permanent lockout.
if cfg.VerificationApplyRateLimit > 0 && cfg.VerificationApplyRateWindow <= 0 {
return fmt.Errorf("TELESRV_VERIFICATION_APPLY_RATE_WINDOW must be positive when TELESRV_VERIFICATION_APPLY_RATE_LIMIT is set")
}
if cfg.VerificationBotRateLimit > 0 && cfg.VerificationBotRateWindow <= 0 {
return fmt.Errorf("TELESRV_VERIFICATION_BOT_RATE_WINDOW must be positive when TELESRV_VERIFICATION_BOT_RATE_LIMIT is set")
}
if cfg.VerificationNotifyInterval <= 0 {
return fmt.Errorf("TELESRV_VERIFICATION_NOTIFY_INTERVAL must be positive")
}
if cfg.VerificationNotifyBatch <= 0 || cfg.VerificationNotifyBatch > 500 {
return fmt.Errorf("TELESRV_VERIFICATION_NOTIFY_BATCH must be 1..500")
}
if cfg.VerificationMaxActivePerUser < 0 || cfg.VerificationMaxActivePerUser > 50 {
return fmt.Errorf("TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER must be 0..50")
}
// Third-party bot verification. The ceiling is the storage bound: a value above
// it would be silently unreachable, and a configuration key that cannot do what
// it says is worse than no key.
if cfg.BotVerificationMaxPerVerifier < 0 {
return fmt.Errorf("TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER must be non-negative")
}
if cfg.BotVerificationMaxPerVerifier > domain.MaxCustomVerificationsPerVerifier {
return fmt.Errorf("TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER must not exceed %d", domain.MaxCustomVerificationsPerVerifier)
}
if cfg.BotVerificationRequestRateLimit < 0 {
return fmt.Errorf("TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT must be non-negative")
}
if cfg.BotVerificationRequestRateWindow < 0 {
return fmt.Errorf("TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW must be non-negative")
}
// Same trap as the official budget above: a positive limit with a zero window is
// not "unlimited", it is a limiter that can never refill.
if cfg.BotVerificationRequestRateLimit > 0 && cfg.BotVerificationRequestRateWindow <= 0 {
return fmt.Errorf("TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW must be positive when TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT is set")
}
return nil
}
// validateAdminRBACConfig checks the panel/adminapi permission configuration.
// An unparsable permission name is refused rather than ignored: a silently
// dropped permission is either a lockout or an unintended grant.
func validateAdminRBACConfig(cfg Config) error {
if len(cfg.AdminUIPermissions) == 0 {
return fmt.Errorf("TELESRV_ADMIN_UI_PERMISSIONS must not be empty; use * to grant every permission")
}
for _, permission := range cfg.AdminUIPermissions {
if !validAdminPermission(permission) {
return fmt.Errorf("TELESRV_ADMIN_UI_PERMISSIONS contains invalid permission %q", permission)
}
}
names := make(map[string]struct{}, len(cfg.AdminScopedTokens))
tokens := make(map[string]struct{}, len(cfg.AdminScopedTokens))
for _, scoped := range cfg.AdminScopedTokens {
if _, dup := names[strings.ToLower(scoped.Name)]; dup {
return fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS has duplicate name %q", scoped.Name)
}
names[strings.ToLower(scoped.Name)] = struct{}{}
if _, dup := tokens[scoped.Token]; dup {
return fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS reuses one token for several names")
}
tokens[scoped.Token] = struct{}{}
if scoped.Token == cfg.AdminAPIToken && strings.TrimSpace(cfg.AdminAPIToken) != "" {
return fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q reuses TELESRV_ADMIN_API_TOKEN, which would silently widen it to every permission", scoped.Name)
}
if len(scoped.Permissions) == 0 {
return fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q has no permissions", scoped.Name)
}
for _, permission := range scoped.Permissions {
if !validAdminPermission(permission) {
return fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q has invalid permission %q", scoped.Name, permission)
}
}
}
return nil
}
// parseAdminScopedTokens reads "name:token:perm1,perm2" entries separated by ';'.
//
// The shape is strict on purpose: the value carries credentials, and a
// half-understood entry must fail startup rather than produce a token whose
// rights nobody can predict. The permission list is the last field, so a token
// itself may not contain ':' -- which is also why it is validated here rather
// than being re-split later by a consumer.
func parseAdminScopedTokens(raw string) ([]AdminScopedToken, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
out := make([]AdminScopedToken, 0, 4)
for _, entry := range strings.Split(raw, ";") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
parts := strings.Split(entry, ":")
if len(parts) != 3 {
return nil, fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q must be name:token:perm1,perm2", entry)
}
name := strings.TrimSpace(parts[0])
token := strings.TrimSpace(parts[1])
if name == "" || token == "" {
return nil, fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q must carry a non-empty name and token", entry)
}
if strings.ContainsAny(token, " \t\r\n") {
return nil, fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q has whitespace inside its token", name)
}
permissions := make([]string, 0, 4)
for _, permission := range strings.Split(parts[2], ",") {
permission = strings.TrimSpace(permission)
if permission == "" {
continue
}
permissions = append(permissions, permission)
}
if len(permissions) == 0 {
return nil, fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q must list at least one permission", name)
}
out = append(out, AdminScopedToken{Name: name, Token: token, Permissions: permissions})
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// validAdminPermission accepts the wildcard and dotted/namespaced permission
// names such as "users.read" or "verification:decide".
func validAdminPermission(permission string) bool {
if permission == adminPermissionAll {
return true
}
if permission == "" || len(permission) > 64 {
return false
}
for i := 0; i < len(permission); i++ {
c := permission[i]
switch {
case c >= 'a' && c <= 'z':
case c >= 'A' && c <= 'Z':
case c >= '0' && c <= '9':
case c == '_' || c == '-':
case (c == '.' || c == ':') && i != 0 && i != len(permission)-1:
case c == '*' && i == len(permission)-1 && i > 0 && (permission[i-1] == '.' || permission[i-1] == ':'):
// A trailing "namespace.*" grants a whole namespace.
default:
return false
}
}
return true
}
// validateCollectibleUsernameConfig checks the optional mint URL template. An
// empty template is the documented default (the public-link route is derived
// from TELESRV_PUBLIC_BASE_URL); a configured one must be a client-openable
// absolute http(s) URL that still fits the registry's url column.
func validateCollectibleUsernameConfig(cfg Config) error {
template := strings.TrimSpace(cfg.CollectibleUsernameURLTemplate)
if template == "" {
return nil
}
if len(template)+domain.MaxCollectibleUsernameLength > domain.MaxCollectibleUsernameURLLength {
return fmt.Errorf("TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE is too long to hold a rendered username")
}
// Render the placeholder before parsing so a templated path segment is
// validated in its final shape.
rendered := strings.ReplaceAll(template, "{username}", "username")
parsed, err := url.Parse(rendered)
if err != nil || parsed.Host == "" || parsed.User != nil ||
(parsed.Scheme != "http" && parsed.Scheme != "https") {
return fmt.Errorf("TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE must be an absolute http(s) URL without userinfo")
}
return nil
}
const mtProtoRPCResultMinBytes = int64((1 << 24) - (2 << 10))
func validateRPCResultCacheConfig(cfg Config) error {

View file

@ -5,6 +5,8 @@ import (
"path/filepath"
"testing"
"time"
"telesrv/internal/domain"
)
func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
@ -720,6 +722,359 @@ func TestValidateStarGiftConfigRejectsNegativeInternalTONGrant(t *testing.T) {
}
}
func TestLoadAccountRatingDefaults(t *testing.T) {
disableDefaultConfigFile(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.RatingEnabled {
t.Fatal("RatingEnabled = false, want the feature on by default")
}
if cfg.RatingPendingDelay != 24*time.Hour || cfg.RatingRecomputeInterval != 15*time.Minute ||
cfg.RatingRecomputeBatch != 500 || cfg.RatingStaleAfter != 6*time.Hour {
t.Fatalf("rating worker defaults = %v/%v/%d/%v, want 24h/15m/500/6h",
cfg.RatingPendingDelay, cfg.RatingRecomputeInterval, cfg.RatingRecomputeBatch, cfg.RatingStaleAfter)
}
if got, want := cfg.AccountRatingWeights(), domain.DefaultAccountRatingWeights(); got != want {
t.Fatalf("rating weights = %#v, want the domain defaults %#v", got, want)
}
if cfg.CollectibleUsernameURLTemplate != "" {
t.Fatalf("CollectibleUsernameURLTemplate = %q, want empty (derived from the public base URL)",
cfg.CollectibleUsernameURLTemplate)
}
}
func TestLoadAccountRatingOverrides(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_RATING_ENABLED", "false")
t.Setenv("TELESRV_RATING_PENDING_DELAY", "1h")
t.Setenv("TELESRV_RATING_RECOMPUTE_INTERVAL", "90s")
t.Setenv("TELESRV_RATING_RECOMPUTE_BATCH", "42")
t.Setenv("TELESRV_RATING_STALE_AFTER", "30m")
t.Setenv("TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE", "500")
t.Setenv("TELESRV_RATING_WEIGHT_MESSAGE_SENT", "0")
t.Setenv("TELESRV_RATING_ACTIVITY_CAP", "0")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.RatingEnabled {
t.Fatal("RatingEnabled = true, want the explicit override")
}
weights := cfg.AccountRatingWeights()
if weights.StarsReceivedPermille != 500 || weights.PerMessageSent != 0 || weights.ActivityCap != 0 {
t.Fatalf("weights = %#v, want the overridden values", weights)
}
if weights.StarsSpentPermille != domain.DefaultAccountRatingWeights().StarsSpentPermille {
t.Fatalf("unset weight = %d, want the domain default", weights.StarsSpentPermille)
}
if cfg.RatingPendingDelay != time.Hour || cfg.RatingRecomputeInterval != 90*time.Second ||
cfg.RatingRecomputeBatch != 42 || cfg.RatingStaleAfter != 30*time.Minute {
t.Fatalf("rating worker overrides = %v/%v/%d/%v",
cfg.RatingPendingDelay, cfg.RatingRecomputeInterval, cfg.RatingRecomputeBatch, cfg.RatingStaleAfter)
}
}
func TestLoadRejectsInvalidAccountRatingConfig(t *testing.T) {
tests := []struct {
name string
key string
value string
}{
{name: "negative stars weight", key: "TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE", value: "-1"},
{name: "negative moderation weight", key: "TELESRV_RATING_WEIGHT_MODERATION_CASE", value: "-150"},
{name: "negative scam penalty", key: "TELESRV_RATING_WEIGHT_SCAM_PENALTY", value: "-1"},
{name: "negative activity cap", key: "TELESRV_RATING_ACTIVITY_CAP", value: "-5000"},
{name: "negative pending delay", key: "TELESRV_RATING_PENDING_DELAY", value: "-1h"},
{name: "zero recompute interval", key: "TELESRV_RATING_RECOMPUTE_INTERVAL", value: "0s"},
{name: "zero stale horizon", key: "TELESRV_RATING_STALE_AFTER", value: "0s"},
{name: "zero recompute batch", key: "TELESRV_RATING_RECOMPUTE_BATCH", value: "0"},
{name: "oversized recompute batch", key: "TELESRV_RATING_RECOMPUTE_BATCH", value: "20000"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv(test.key, test.value)
if _, err := Load(); err == nil {
t.Fatalf("Load accepted invalid %s=%s", test.key, test.value)
}
})
}
}
func TestLoadCollectibleUsernameURLTemplate(t *testing.T) {
t.Run("absolute template accepted", func(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE", " https://frag.example/u/{username} ")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.CollectibleUsernameURLTemplate != "https://frag.example/u/{username}" {
t.Fatalf("template = %q, want the trimmed value", cfg.CollectibleUsernameURLTemplate)
}
})
for _, invalid := range []string{"/nft/{username}", "ftp://frag.example/{username}", "https://user:pass@frag.example/{username}"} {
t.Run("rejects "+invalid, func(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE", invalid)
if _, err := Load(); err == nil {
t.Fatalf("Load accepted template %q", invalid)
}
})
}
}
func TestLoadVerificationDefaults(t *testing.T) {
disableDefaultConfigFile(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.VerificationEnabled {
t.Fatal("VerificationEnabled = false, want the feature shipped on")
}
if cfg.VerificationAllowUserTargets {
t.Fatal("VerificationAllowUserTargets = true, want user targets opt-in")
}
if cfg.VerificationRejectCooldown != 720*time.Hour {
t.Fatalf("VerificationRejectCooldown = %v, want 720h", cfg.VerificationRejectCooldown)
}
if cfg.VerificationApplyRateLimit != 3 || cfg.VerificationApplyRateWindow != 24*time.Hour {
t.Fatalf("apply rate = %d/%v, want 3/24h", cfg.VerificationApplyRateLimit, cfg.VerificationApplyRateWindow)
}
if cfg.VerificationBotRateLimit != 30 || cfg.VerificationBotRateWindow != time.Minute {
t.Fatalf("bot rate = %d/%v, want 30/1m", cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow)
}
if cfg.VerificationNotifyInterval != 15*time.Second || cfg.VerificationNotifyBatch != 50 {
t.Fatalf("notify = %v/%d, want 15s/50", cfg.VerificationNotifyInterval, cfg.VerificationNotifyBatch)
}
if cfg.VerificationMaxActivePerUser != 3 {
t.Fatalf("VerificationMaxActivePerUser = %d, want 3", cfg.VerificationMaxActivePerUser)
}
}
func TestLoadVerificationOverrides(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_VERIFICATION_ENABLED", "false")
t.Setenv("TELESRV_VERIFICATION_ALLOW_USER_TARGETS", "true")
t.Setenv("TELESRV_VERIFICATION_REJECT_COOLDOWN", "48h")
t.Setenv("TELESRV_VERIFICATION_APPLY_RATE_LIMIT", "7")
t.Setenv("TELESRV_VERIFICATION_APPLY_RATE_WINDOW", "12h")
t.Setenv("TELESRV_VERIFICATION_BOT_RATE_LIMIT", "0")
t.Setenv("TELESRV_VERIFICATION_BOT_RATE_WINDOW", "0s")
t.Setenv("TELESRV_VERIFICATION_NOTIFY_INTERVAL", "5s")
t.Setenv("TELESRV_VERIFICATION_NOTIFY_BATCH", "200")
t.Setenv("TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER", "0")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.VerificationEnabled || !cfg.VerificationAllowUserTargets {
t.Fatalf("enabled=%v allowUserTargets=%v", cfg.VerificationEnabled, cfg.VerificationAllowUserTargets)
}
if cfg.VerificationRejectCooldown != 48*time.Hour {
t.Fatalf("cooldown = %v", cfg.VerificationRejectCooldown)
}
if cfg.VerificationApplyRateLimit != 7 || cfg.VerificationApplyRateWindow != 12*time.Hour {
t.Fatalf("apply rate = %d/%v", cfg.VerificationApplyRateLimit, cfg.VerificationApplyRateWindow)
}
// A zero limit disables the budget, so a zero window is accepted with it.
if cfg.VerificationBotRateLimit != 0 || cfg.VerificationBotRateWindow != 0 {
t.Fatalf("bot rate = %d/%v", cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow)
}
if cfg.VerificationNotifyInterval != 5*time.Second || cfg.VerificationNotifyBatch != 200 {
t.Fatalf("notify = %v/%d", cfg.VerificationNotifyInterval, cfg.VerificationNotifyBatch)
}
if cfg.VerificationMaxActivePerUser != 0 {
t.Fatalf("maxActive = %d, want the cap disabled", cfg.VerificationMaxActivePerUser)
}
}
func TestLoadRejectsInvalidVerificationConfig(t *testing.T) {
for _, test := range []struct {
key string
value string
}{
{"TELESRV_VERIFICATION_REJECT_COOLDOWN", "-1h"},
{"TELESRV_VERIFICATION_REJECT_COOLDOWN", "9000h"},
{"TELESRV_VERIFICATION_APPLY_RATE_LIMIT", "-1"},
{"TELESRV_VERIFICATION_APPLY_RATE_WINDOW", "0s"},
{"TELESRV_VERIFICATION_APPLY_RATE_WINDOW", "-5m"},
{"TELESRV_VERIFICATION_BOT_RATE_LIMIT", "-2"},
{"TELESRV_VERIFICATION_BOT_RATE_WINDOW", "0s"},
{"TELESRV_VERIFICATION_NOTIFY_INTERVAL", "0s"},
{"TELESRV_VERIFICATION_NOTIFY_BATCH", "0"},
{"TELESRV_VERIFICATION_NOTIFY_BATCH", "501"},
{"TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER", "-1"},
{"TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER", "51"},
} {
t.Run(test.key+"="+test.value, func(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv(test.key, test.value)
if _, err := Load(); err == nil {
t.Fatalf("Load accepted invalid %s=%s", test.key, test.value)
}
})
}
}
func TestLoadBotVerificationDefaults(t *testing.T) {
disableDefaultConfigFile(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.BotVerificationEnabled {
t.Fatal("BotVerificationEnabled = false, want the feature shipped on")
}
// The default is the storage bound itself, so the shipped behaviour is the same
// whether or not the key is set.
if cfg.BotVerificationMaxPerVerifier != domain.MaxCustomVerificationsPerVerifier {
t.Fatalf("BotVerificationMaxPerVerifier = %d, want %d", cfg.BotVerificationMaxPerVerifier, domain.MaxCustomVerificationsPerVerifier)
}
if cfg.BotVerificationRequestRateLimit != 5 || cfg.BotVerificationRequestRateWindow != 24*time.Hour {
t.Fatalf("request rate = %d/%v, want 5/24h", cfg.BotVerificationRequestRateLimit, cfg.BotVerificationRequestRateWindow)
}
}
func TestLoadBotVerificationOverrides(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_BOT_VERIFICATION_ENABLED", "false")
t.Setenv("TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER", "0")
t.Setenv("TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT", "0")
t.Setenv("TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW", "0s")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.BotVerificationEnabled {
t.Fatal("BotVerificationEnabled = true, want the override honoured")
}
if cfg.BotVerificationMaxPerVerifier != 0 {
t.Fatalf("BotVerificationMaxPerVerifier = %d, want the service bound disabled", cfg.BotVerificationMaxPerVerifier)
}
// A zero limit disables the budget, so a zero window is accepted with it.
if cfg.BotVerificationRequestRateLimit != 0 || cfg.BotVerificationRequestRateWindow != 0 {
t.Fatalf("request rate = %d/%v, want the budget disabled", cfg.BotVerificationRequestRateLimit, cfg.BotVerificationRequestRateWindow)
}
}
func TestLoadRejectsInvalidBotVerificationConfig(t *testing.T) {
for _, test := range []struct {
key string
value string
}{
{"TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER", "-1"},
{"TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER", "10001"},
{"TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT", "-1"},
{"TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW", "-5m"},
// A positive limit with no window is a limiter that never refills.
{"TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW", "0s"},
} {
t.Run(test.key+"="+test.value, func(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv(test.key, test.value)
if _, err := Load(); err == nil {
t.Fatalf("Load accepted invalid %s=%s", test.key, test.value)
}
})
}
}
// TestLoadValidatesBotVerificationWhileDisabled pins that the policy is checked
// even with the feature off, so switching it on later is not the moment a typo is
// discovered.
func TestLoadValidatesBotVerificationWhileDisabled(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_BOT_VERIFICATION_ENABLED", "false")
t.Setenv("TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER", "-3")
if _, err := Load(); err == nil {
t.Fatal("Load accepted a negative per-verifier bound while the feature was disabled")
}
}
func TestLoadAdminRBACDefaults(t *testing.T) {
disableDefaultConfigFile(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.AdminUIPermissions) != 1 || cfg.AdminUIPermissions[0] != "*" {
t.Fatalf("AdminUIPermissions = %v, want the wildcard default", cfg.AdminUIPermissions)
}
if len(cfg.AdminScopedTokens) != 0 {
t.Fatalf("AdminScopedTokens = %+v, want none by default", cfg.AdminScopedTokens)
}
}
func TestLoadAdminScopedTokens(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_ADMIN_UI_PERMISSIONS", "users.read, verification:decide")
t.Setenv("TELESRV_ADMIN_SCOPED_TOKENS", "ops:tok-ops-1:users.read,users.write; audit:tok-audit-2:verification.*")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.AdminUIPermissions) != 2 ||
cfg.AdminUIPermissions[0] != "users.read" || cfg.AdminUIPermissions[1] != "verification:decide" {
t.Fatalf("AdminUIPermissions = %v", cfg.AdminUIPermissions)
}
if len(cfg.AdminScopedTokens) != 2 {
t.Fatalf("AdminScopedTokens = %+v, want 2 entries", cfg.AdminScopedTokens)
}
first := cfg.AdminScopedTokens[0]
if first.Name != "ops" || first.Token != "tok-ops-1" ||
len(first.Permissions) != 2 || first.Permissions[0] != "users.read" || first.Permissions[1] != "users.write" {
t.Fatalf("first scoped token = %+v", first)
}
second := cfg.AdminScopedTokens[1]
if second.Name != "audit" || second.Token != "tok-audit-2" ||
len(second.Permissions) != 1 || second.Permissions[0] != "verification.*" {
t.Fatalf("second scoped token = %+v", second)
}
}
func TestLoadRejectsInvalidAdminRBACConfig(t *testing.T) {
for name, env := range map[string]map[string]string{
"missing permissions field": {"TELESRV_ADMIN_SCOPED_TOKENS": "ops:tok-ops-1"},
"too many fields": {"TELESRV_ADMIN_SCOPED_TOKENS": "ops:tok:extra:users.read"},
"empty name": {"TELESRV_ADMIN_SCOPED_TOKENS": ":tok-ops-1:users.read"},
"empty token": {"TELESRV_ADMIN_SCOPED_TOKENS": "ops::users.read"},
"no permissions listed": {"TELESRV_ADMIN_SCOPED_TOKENS": "ops:tok-ops-1:"},
"invalid permission": {"TELESRV_ADMIN_SCOPED_TOKENS": "ops:tok-ops-1:users read"},
"duplicate name": {"TELESRV_ADMIN_SCOPED_TOKENS": "ops:tok-a:users.read;OPS:tok-b:users.read"},
"duplicate token": {"TELESRV_ADMIN_SCOPED_TOKENS": "ops:tok-a:users.read;audit:tok-a:users.read"},
"reuses the admin api token": {
"TELESRV_ADMIN_API_TOKEN": "tok-a",
"TELESRV_ADMIN_SCOPED_TOKENS": "ops:tok-a:users.read",
},
"invalid ui permission": {"TELESRV_ADMIN_UI_PERMISSIONS": "users/read"},
} {
t.Run(name, func(t *testing.T) {
disableDefaultConfigFile(t)
for key, value := range env {
t.Setenv(key, value)
}
if _, err := Load(); err == nil {
t.Fatalf("Load accepted %v", env)
}
})
}
}
func writeConfigFile(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {

View file

@ -0,0 +1,398 @@
package domain
import (
"errors"
"time"
)
// Composite account rating.
//
// This is a server-local moderation/operations score for the admin panel. It is
// deliberately not Telegram's Stars Rating: the official field describes Stars
// transaction volume, whereas this model combines Stars, account activity and
// moderation penalties. Projecting it into userFull.stars_rating would give
// official clients a materially false meaning, so the RPC edge keeps those
// fields unset.
const (
// MaxAccountRatingLevel bounds the local admin level.
MaxAccountRatingLevel = 50
// accountRatingLevelUnit is the score required for level 1. Thresholds grow
// quadratically from it: level n needs accountRatingLevelUnit * n^2.
accountRatingLevelUnit = 100
// MaxAccountRatingReasonLength matches the event ledger CHECK on reason.
MaxAccountRatingReasonLength = 512
// MaxAccountRatingActorLength matches the event ledger CHECK on actor.
MaxAccountRatingActorLength = 128
// MaxAccountRatingCommandKeyLength matches the idempotency CHECK.
MaxAccountRatingCommandKeyLength = 128
)
var (
// ErrAccountRatingNotFound reports a user with no rating row yet.
ErrAccountRatingNotFound = errors.New("account rating not found")
// ErrAccountRatingWeightsInvalid rejects a non-sensical weight set.
ErrAccountRatingWeightsInvalid = errors.New("account rating weights invalid")
// ErrAccountRatingAdjustmentInvalid rejects a malformed manual adjustment.
ErrAccountRatingAdjustmentInvalid = errors.New("account rating adjustment invalid")
)
// AccountRatingEventKind is the contribution source of a ledger row. Only
// 'manual' rows survive a full recompute; the rest are audit trail.
type AccountRatingEventKind string
const (
AccountRatingEventStars AccountRatingEventKind = "stars"
AccountRatingEventActivity AccountRatingEventKind = "activity"
AccountRatingEventModeration AccountRatingEventKind = "moderation"
AccountRatingEventManual AccountRatingEventKind = "manual"
AccountRatingEventRecompute AccountRatingEventKind = "recompute"
)
// Valid reports whether the kind is modelled.
func (k AccountRatingEventKind) Valid() bool {
switch k {
case AccountRatingEventStars, AccountRatingEventActivity, AccountRatingEventModeration,
AccountRatingEventManual, AccountRatingEventRecompute:
return true
default:
return false
}
}
// AccountRating is the stored read model for one user.
type AccountRating struct {
UserID int64
Level int
Stars int64
CurrentLevelStars int64
// NextLevelStars is meaningful only when HasNextLevel is true.
NextLevelStars int64
HasNextLevel bool
// Components are the explainable breakdown. PenaltyComponent is a
// non-negative magnitude that is subtracted.
StarsComponent int64
ActivityComponent int64
PenaltyComponent int64
ManualComponent int64
// PendingStars is a score delta not yet applied to the visible level, with
// PendingDate reporting when it becomes effective.
PendingStars int64
PendingDate time.Time
ComputedAt time.Time
UpdatedAt time.Time
Version int64
}
// AccountRatingLevel is the local admin-facing level snapshot.
type AccountRatingLevel struct {
Level int
CurrentLevelStars int64
Stars int64
NextLevelStars int64
HasNextLevelStars bool
}
// RatableAccount reports whether an account may carry a composite rating.
//
// The rating measures what an account did with Stars -- gifts bought, paid
// messages sent, activity, moderation history. Two kinds of account have no
// meaningful answer there and are excluded everywhere the rating is computed,
// seeded or projected:
//
// - Bots. A bot does not buy gifts or send paid messages on its own behalf, so
// its score would only ever be the flat account-age term.
// - The built-in service accounts (the platform account, BotFather, @Stickers,
// @ChatBot, the verification bots). They are infrastructure rather than
// participants: a leaderboard entry for the platform account is noise, and a
// level badge on it would claim something about transaction volume that means
// nothing.
//
// Note that the platform account is not flagged is_bot, so the bot check alone
// does not cover it -- which is exactly how it ended up in the seeding pass.
func RatableAccount(userID int64, bot bool) bool {
return userID > 0 && !bot && !IsSystemUserID(userID)
}
// LevelSnapshot returns the current local admin-facing level.
func (r AccountRating) LevelSnapshot() AccountRatingLevel {
return AccountRatingLevel{
Level: r.Level,
CurrentLevelStars: r.CurrentLevelStars,
Stars: r.Stars,
NextLevelStars: r.NextLevelStars,
HasNextLevelStars: r.HasNextLevel,
}
}
// PendingLevel returns the local level after the pending score is applied and
// reports whether a pending record exists at all.
func (r AccountRating) PendingLevel() (AccountRatingLevel, bool) {
if r.PendingStars == 0 || r.PendingDate.IsZero() {
return AccountRatingLevel{}, false
}
total := r.Stars + r.PendingStars
if total < 0 {
total = 0
}
level, current, next, hasNext := AccountRatingLevelForStars(total)
return AccountRatingLevel{
Level: level,
CurrentLevelStars: current,
Stars: total,
NextLevelStars: next,
HasNextLevelStars: hasNext,
}, true
}
// AccountRatingWeights is the composite formula. All weights are integers so the
// score is exactly reproducible across a recompute and across store backends.
type AccountRatingWeights struct {
// StarsReceivedPermille weighs Stars credited to the account (gifts,
// reactions, paid messages received), in permille of the raw amount.
StarsReceivedPermille int64
// StarsSpentPermille weighs Stars the account spent. Spending is a weaker
// signal than receiving, so the default is lower.
StarsSpentPermille int64
// PerMessageSent rewards sustained use.
PerMessageSent int64
// PerAccountAgeDay rewards account longevity.
PerAccountAgeDay int64
// PerGiftReceived rewards collectible gifts held.
PerGiftReceived int64
// PerModerationCase is the penalty for each upheld moderation case.
PerModerationCase int64
// ScamPenalty and FakePenalty are flat penalties for the peer flags.
ScamPenalty int64
FakePenalty int64
// ActivityCap bounds the activity component so activity alone cannot
// outweigh everything else. Zero means uncapped.
ActivityCap int64
}
// DefaultAccountRatingWeights returns the shipped local policy. Stars dominate,
// activity contributes a bounded floor, and moderation subtracts.
func DefaultAccountRatingWeights() AccountRatingWeights {
return AccountRatingWeights{
StarsReceivedPermille: 1000,
StarsSpentPermille: 250,
PerMessageSent: 1,
PerAccountAgeDay: 2,
PerGiftReceived: 25,
PerModerationCase: 150,
ScamPenalty: 5000,
FakePenalty: 5000,
ActivityCap: 5000,
}
}
// Validate rejects negative weights and an impossible cap.
func (w AccountRatingWeights) Validate() error {
values := []int64{
w.StarsReceivedPermille, w.StarsSpentPermille, w.PerMessageSent,
w.PerAccountAgeDay, w.PerGiftReceived, w.PerModerationCase,
w.ScamPenalty, w.FakePenalty, w.ActivityCap,
}
for _, v := range values {
if v < 0 {
return ErrAccountRatingWeightsInvalid
}
}
return nil
}
// AccountRatingSignals is the raw snapshot gathered from the contributing
// sources for one user. It is deliberately a plain value: the same snapshot must
// produce the same score in a unit test and in production.
type AccountRatingSignals struct {
UserID int64
StarsReceived int64
StarsSpent int64
MessagesSent int64
AccountAgeDays int64
GiftsReceived int64
ModerationCases int64
Scam bool
Fake bool
// Manual is the sum of admin adjustments, carried across recomputes.
Manual int64
}
// ComputeAccountRating turns a signal snapshot into the read model. The score is
// clamped at zero: penalties can erase this local score but never invert it.
func ComputeAccountRating(signals AccountRatingSignals, weights AccountRatingWeights, now time.Time) AccountRating {
if err := weights.Validate(); err != nil {
weights = DefaultAccountRatingWeights()
}
starsComponent := permille(max64(signals.StarsReceived, 0), weights.StarsReceivedPermille) +
permille(max64(signals.StarsSpent, 0), weights.StarsSpentPermille)
activityComponent := max64(signals.MessagesSent, 0)*weights.PerMessageSent +
max64(signals.AccountAgeDays, 0)*weights.PerAccountAgeDay +
max64(signals.GiftsReceived, 0)*weights.PerGiftReceived
if weights.ActivityCap > 0 && activityComponent > weights.ActivityCap {
activityComponent = weights.ActivityCap
}
penalty := max64(signals.ModerationCases, 0) * weights.PerModerationCase
if signals.Scam {
penalty += weights.ScamPenalty
}
if signals.Fake {
penalty += weights.FakePenalty
}
total := starsComponent + activityComponent + signals.Manual - penalty
if total < 0 {
total = 0
}
level, current, next, hasNext := AccountRatingLevelForStars(total)
return AccountRating{
UserID: signals.UserID,
Level: level,
Stars: total,
CurrentLevelStars: current,
NextLevelStars: next,
HasNextLevel: hasNext,
StarsComponent: starsComponent,
ActivityComponent: activityComponent,
PenaltyComponent: penalty,
ManualComponent: signals.Manual,
ComputedAt: now,
UpdatedAt: now,
Version: 1,
}
}
// AccountRatingLevelThreshold returns the score needed to reach the given level.
// Level 0 needs nothing; growth is quadratic so early levels arrive quickly and
// later ones stay meaningful.
func AccountRatingLevelThreshold(level int) int64 {
if level <= 0 {
return 0
}
if level > MaxAccountRatingLevel {
level = MaxAccountRatingLevel
}
n := int64(level)
return accountRatingLevelUnit * n * n
}
// AccountRatingLevelForStars maps a score onto the level and the surrounding
// thresholds. hasNext is false at MaxAccountRatingLevel.
func AccountRatingLevelForStars(stars int64) (level int, currentLevelStars int64, nextLevelStars int64, hasNext bool) {
if stars < 0 {
stars = 0
}
level = 0
for candidate := 1; candidate <= MaxAccountRatingLevel; candidate++ {
if stars < AccountRatingLevelThreshold(candidate) {
break
}
level = candidate
}
currentLevelStars = AccountRatingLevelThreshold(level)
if level >= MaxAccountRatingLevel {
return level, currentLevelStars, 0, false
}
return level, currentLevelStars, AccountRatingLevelThreshold(level + 1), true
}
// ResolveAccountRatingPending decides whether a freshly computed score becomes
// visible immediately or is parked as pending.
//
// A score that dropped is applied at once -- a penalty must not sit behind a
// delay. A score that grew is parked until delay has elapsed; once the parked
// window has passed the pending delta is folded into the visible admin rating.
func ResolveAccountRatingPending(prev, computed AccountRating, delay time.Duration, now time.Time) AccountRating {
out := computed
out.Version = prev.Version + 1
if out.Version <= 0 {
out.Version = 1
}
if delay <= 0 || prev.UserID == 0 {
return out
}
if computed.Stars <= prev.Stars {
return out
}
// A previously parked delta whose date has arrived is applied now.
if prev.PendingStars != 0 && !prev.PendingDate.IsZero() && !now.Before(prev.PendingDate) {
return out
}
pendingSince := prev.PendingDate
if prev.PendingStars == 0 || pendingSince.IsZero() {
pendingSince = now.Add(delay)
}
visible := prev
visible.StarsComponent = computed.StarsComponent
visible.ActivityComponent = computed.ActivityComponent
visible.PenaltyComponent = computed.PenaltyComponent
visible.ManualComponent = computed.ManualComponent
visible.PendingStars = computed.Stars - prev.Stars
visible.PendingDate = pendingSince
visible.ComputedAt = now
visible.UpdatedAt = now
visible.Version = out.Version
return visible
}
// AccountRatingEvent is one contribution ledger row.
type AccountRatingEvent struct {
ID int64
UserID int64
Kind AccountRatingEventKind
Amount int64
Reason string
Actor string
CommandKey string
CreatedAt time.Time
}
// AdjustAccountRatingRequest is an operator adjustment to the manual component.
type AdjustAccountRatingRequest struct {
UserID int64
Amount int64
Reason string
Actor string
CommandKey string
}
// Validate rejects a no-op or oversized adjustment.
func (r AdjustAccountRatingRequest) Validate() error {
if r.UserID <= 0 || r.Amount == 0 {
return ErrAccountRatingAdjustmentInvalid
}
if len(r.Reason) > MaxAccountRatingReasonLength {
return ErrAccountRatingAdjustmentInvalid
}
if len(r.Actor) > MaxAccountRatingActorLength {
return ErrAccountRatingAdjustmentInvalid
}
if len(r.CommandKey) > MaxAccountRatingCommandKeyLength {
return ErrAccountRatingAdjustmentInvalid
}
return nil
}
// AccountRatingFilter bounds an admin listing query.
type AccountRatingFilter struct {
MinLevel int
UserID int64
BeforeID int64
Limit int
}
func permille(value, weight int64) int64 {
if value <= 0 || weight <= 0 {
return 0
}
return value * weight / 1000
}
func max64(a, b int64) int64 {
if a > b {
return a
}
return b
}

View file

@ -0,0 +1,369 @@
package domain
import (
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
)
// Third-party bot verification (core.telegram.org/api/bots/verification).
//
// A verifier bot marks a peer with its OWN icon and description. Clients render
// that icon before the name and the description in the profile, which is
// deliberately different from the official checkmark: only the operator grants the
// platform flag (see verification.go), and the two mechanisms never read each
// other's state.
//
// Layer 228 shapes this feature projects onto:
//
// botVerification#f93cd45c bot_id:long icon:long description:string
// botVerifierSettings#b0cd6617 flags:# can_modify_custom_description:flags.1?true
// icon:long company:string custom_description:flags.0?string
// bots.setCustomVerification#8b89dfbd flags:# enabled:flags.1?true
// bot:flags.0?InputUser peer:InputPeer custom_description:flags.2?string = Bool
// user#b1b8cc83 bot_verification_icon:flags2.14?long
// channel#d49f34c6 bot_verification_icon:flags2.13?long
// userFull#6cbe645 bot_verification:flags2.12?BotVerification
// channelFull#a04e8d3a bot_verification:flags2.17?BotVerification
// chatInvite#5c9d3702 bot_verification:flags.13?BotVerification
// botInfo#4d8a0299 verifier_settings:flags.9?BotVerifierSettings
//
// The icon is a custom emoji document id. Clients resolve it through
// messages.getCustomEmojiDocuments, so an id that names no fetchable document
// renders as nothing at all -- which is why the catalogue is validated against
// real documents rather than accepting arbitrary numbers.
const (
// MaxVerifierCompanyLength bounds botVerifierSettings.company.
MaxVerifierCompanyLength = 128
// MaxCustomVerificationDescriptionLength is the app-configured limit for text
// supplied by a verifier (bot_verification_description_length_limit).
MaxCustomVerificationDescriptionLength = 70
// MaxBotVerificationDescriptionLength bounds the final wire description. It
// may exceed the custom-input limit because the server-generated fallback
// includes the organization name.
MaxBotVerificationDescriptionLength = 1024
// MaxVerificationIconNameLength bounds an operator-facing catalogue label.
MaxVerificationIconNameLength = 128
// MaxCustomVerificationReasonLength bounds an applicant's stated reason.
MaxCustomVerificationReasonLength = 4096
// MaxCustomVerificationNoteLength bounds an operator-only note.
MaxCustomVerificationNoteLength = 8192
// MaxVerifierGrantReasonLength bounds the reason a bot was made a verifier.
MaxVerifierGrantReasonLength = 1024
// MaxCustomVerificationsPerVerifier bounds how many peers one verifier may
// mark. Verifier status is granted per deployment, not earned per peer, so an
// unbounded verifier would be an unbounded badge printer.
MaxCustomVerificationsPerVerifier = 10000
)
var (
// ErrVerifierNotFound reports a bot without verifier status.
ErrVerifierNotFound = errors.New("bot verifier settings not found")
// ErrVerifierForbidden reports a bot that may not verify: no verifier row, or a
// row the operator disabled. It maps to BOT_VERIFIER_FORBIDDEN.
ErrVerifierForbidden = errors.New("bot is not allowed to verify peers")
// ErrVerifierSettingsInvalid rejects a malformed verifier configuration.
ErrVerifierSettingsInvalid = errors.New("bot verifier settings invalid")
// ErrVerifierDescriptionForbidden reports a per-peer description supplied by a
// verifier whose can_modify_custom_description is false.
ErrVerifierDescriptionForbidden = errors.New("verifier may not set a custom description")
// ErrVerificationIconNotFound reports an icon missing from the catalogue.
ErrVerificationIconNotFound = errors.New("verification icon not found")
// ErrVerificationIconInactive rejects an icon the operator retired.
ErrVerificationIconInactive = errors.New("verification icon inactive")
// ErrVerificationIconInvalid rejects a malformed icon record.
ErrVerificationIconInvalid = errors.New("verification icon invalid")
// ErrCustomVerificationNotFound reports a peer this verifier has not marked.
ErrCustomVerificationNotFound = errors.New("custom verification not found")
// ErrCustomVerificationLimit reports the per-verifier bound.
ErrCustomVerificationLimit = errors.New("custom verification limit reached")
// ErrCustomVerificationTargetInvalid rejects an unverifiable peer.
ErrCustomVerificationTargetInvalid = errors.New("custom verification target invalid")
// ErrCustomVerificationRequestNotFound reports a missing application.
ErrCustomVerificationRequestNotFound = errors.New("custom verification request not found")
// ErrCustomVerificationRequestExists reports a pending application already
// occupying the (verifier, peer) pair.
ErrCustomVerificationRequestExists = errors.New("custom verification request already pending")
// ErrCustomVerificationRequestInvalid rejects a malformed application.
ErrCustomVerificationRequestInvalid = errors.New("custom verification request invalid")
// ErrCustomVerificationVersionConflict reports a lost optimistic-locking race.
ErrCustomVerificationVersionConflict = errors.New("custom verification changed concurrently")
)
// VerificationIcon is one catalogue entry: a custom emoji document usable as a
// verifier's mark.
type VerificationIcon struct {
ID int64
DocumentID int64
// OwnerBotID is zero for a shared entry and a bot id when the operator
// reserved the icon for one verifier.
OwnerBotID int64
Name string
Active bool
CreatedAt time.Time
UpdatedAt time.Time
}
// Validate checks the catalogue entry shape.
func (i VerificationIcon) Validate() error {
if i.DocumentID <= 0 {
return ErrVerificationIconInvalid
}
name := strings.TrimSpace(i.Name)
if name == "" || utf8.RuneCountInString(name) > MaxVerificationIconNameLength {
return ErrVerificationIconInvalid
}
if i.OwnerBotID < 0 {
return ErrVerificationIconInvalid
}
return nil
}
// UsableBy reports whether a verifier may mark peers with this icon.
func (i VerificationIcon) UsableBy(botID int64) bool {
return i.Active && (i.OwnerBotID == 0 || i.OwnerBotID == botID)
}
// BotVerifierSettings is a bot's verifier status, projected onto
// botVerifierSettings#b0cd6617 and consulted by bots.setCustomVerification.
type BotVerifierSettings struct {
BotID int64
IconDocumentID int64
CompanyName string
DefaultDescription string
// CanModifyCustomDescription mirrors flags.1 of the TL constructor: when false
// the verifier may only apply DefaultDescription.
CanModifyCustomDescription bool
// Enabled is the operator's kill switch. A disabled verifier keeps its row and
// its granted marks but can no longer mark anything new, and the settings stop
// being projected into botInfo.
Enabled bool
GrantedBy string
GrantReason string
CreatedAt time.Time
UpdatedAt time.Time
Version int64
}
// Validate checks the verifier configuration.
func (s BotVerifierSettings) Validate() error {
if s.BotID <= 0 || s.IconDocumentID <= 0 {
return ErrVerifierSettingsInvalid
}
company := strings.TrimSpace(s.CompanyName)
if company == "" || utf8.RuneCountInString(company) > MaxVerifierCompanyLength {
return ErrVerifierSettingsInvalid
}
if utf8.RuneCountInString(s.DefaultDescription) > MaxCustomVerificationDescriptionLength {
return ErrVerifierSettingsInvalid
}
if len(s.GrantedBy) > 128 || utf8.RuneCountInString(s.GrantReason) > MaxVerifierGrantReasonLength {
return ErrVerifierSettingsInvalid
}
return nil
}
// DescriptionFor resolves the description a mark carries: the verifier-supplied
// one when allowed, the configured default, or the protocol-defined generated
// fallback.
func (s BotVerifierSettings) DescriptionFor(custom string) (string, error) {
custom = strings.TrimSpace(custom)
if custom == "" {
if fallback := strings.TrimSpace(s.DefaultDescription); fallback != "" {
return fallback, nil
}
return fmt.Sprintf(`Was verified by organization "%s"`, strings.TrimSpace(s.CompanyName)), nil
}
if !s.CanModifyCustomDescription {
return "", ErrVerifierDescriptionForbidden
}
if utf8.RuneCountInString(custom) > MaxCustomVerificationDescriptionLength {
return "", ErrCustomVerificationRequestInvalid
}
return custom, nil
}
// CustomVerification is one granted third-party mark.
type CustomVerification struct {
ID int64
VerifierBotID int64
Peer Peer
// IconDocumentID is denormalised at grant time so the mark keeps rendering the
// icon it was granted with even after the verifier changes its own.
IconDocumentID int64
Description string
GrantedByUserID int64
CreatedAt time.Time
UpdatedAt time.Time
Version int64
}
// BotVerification is the TL projection (botVerification#f93cd45c).
type BotVerification struct {
BotID int64
Icon int64
Description string
}
// Projection returns the botVerification payload for full peer objects.
func (v CustomVerification) Projection() BotVerification {
return BotVerification{
BotID: v.VerifierBotID,
Icon: v.IconDocumentID,
Description: v.Description,
}
}
// Validate checks the mark shape.
func (v CustomVerification) Validate() error {
if v.VerifierBotID <= 0 || v.IconDocumentID <= 0 {
return ErrCustomVerificationTargetInvalid
}
if !validCustomVerificationPeer(v.Peer) {
return ErrCustomVerificationTargetInvalid
}
if utf8.RuneCountInString(v.Description) > MaxBotVerificationDescriptionLength {
return ErrCustomVerificationRequestInvalid
}
return nil
}
func validCustomVerificationPeer(peer Peer) bool {
switch peer.Type {
case PeerTypeUser, PeerTypeChannel:
return peer.ID > 0
default:
return false
}
}
// CustomVerificationRequestStatus is the application lifecycle in front of a mark.
type CustomVerificationRequestStatus string
const (
CustomVerificationPending CustomVerificationRequestStatus = "pending"
CustomVerificationApproved CustomVerificationRequestStatus = "approved"
CustomVerificationRejected CustomVerificationRequestStatus = "rejected"
CustomVerificationRevoked CustomVerificationRequestStatus = "revoked"
)
// Valid reports whether the status is modelled.
func (s CustomVerificationRequestStatus) Valid() bool {
switch s {
case CustomVerificationPending, CustomVerificationApproved,
CustomVerificationRejected, CustomVerificationRevoked:
return true
default:
return false
}
}
// CanTransitionCustomVerificationStatus is the status machine. A revoked mark is
// reached only from approved, which keeps "revoked" meaning "was verified once".
func CanTransitionCustomVerificationStatus(from, to CustomVerificationRequestStatus) bool {
if !from.Valid() || !to.Valid() || from == to {
return false
}
switch from {
case CustomVerificationPending:
return to == CustomVerificationApproved || to == CustomVerificationRejected
case CustomVerificationApproved:
return to == CustomVerificationRevoked
default:
return false
}
}
// CustomVerificationRequest is an application filed with a verifier bot.
type CustomVerificationRequest struct {
ID int64
VerifierBotID int64
ApplicantUserID int64
Peer Peer
PeerTitle string
PeerUsername string
Reason string
RequestedDescription string
Status CustomVerificationRequestStatus
DecidedBy string
DecisionReason string
InternalNote string
CorrelationID string
CreatedAt time.Time
UpdatedAt time.Time
ApprovedAt time.Time
RejectedAt time.Time
Version int64
}
// Validate checks the application shape.
func (r CustomVerificationRequest) Validate() error {
if r.VerifierBotID <= 0 || r.ApplicantUserID <= 0 || !validCustomVerificationPeer(r.Peer) {
return ErrCustomVerificationRequestInvalid
}
if !r.Status.Valid() {
return ErrCustomVerificationRequestInvalid
}
if utf8.RuneCountInString(r.Reason) > MaxCustomVerificationReasonLength ||
utf8.RuneCountInString(r.RequestedDescription) > MaxCustomVerificationDescriptionLength ||
utf8.RuneCountInString(r.InternalNote) > MaxCustomVerificationNoteLength {
return ErrCustomVerificationRequestInvalid
}
if r.Status == CustomVerificationRejected && strings.TrimSpace(r.DecisionReason) == "" {
return ErrVerificationReasonRequired
}
return nil
}
// SetCustomVerificationRequest is the bots.setCustomVerification payload after the
// RPC edge has resolved the caller, the verifier bot and the target peer.
type SetCustomVerificationRequest struct {
VerifierBotID int64
Peer Peer
// Enabled false revokes the mark this verifier granted; the request then
// carries no description.
Enabled bool
// CustomDescription is the verifier-supplied per-peer text. It is only honoured
// when the verifier's settings allow it.
CustomDescription string
// CallerUserID is the account that invoked the RPC: the bot itself or its owner.
CallerUserID int64
}
// Validate checks the request shape without consulting stored state.
func (r SetCustomVerificationRequest) Validate() error {
if r.VerifierBotID <= 0 || !validCustomVerificationPeer(r.Peer) {
return ErrCustomVerificationTargetInvalid
}
if utf8.RuneCountInString(r.CustomDescription) > MaxCustomVerificationDescriptionLength {
return ErrCustomVerificationRequestInvalid
}
if !r.Enabled && strings.TrimSpace(r.CustomDescription) != "" {
// Revocation with a description is a caller bug worth reporting rather than
// silently ignoring: it usually means enabled was left unset by mistake.
return ErrCustomVerificationRequestInvalid
}
return nil
}
// CustomVerificationFilter bounds an admin listing query.
type CustomVerificationFilter struct {
VerifierBotID int64
PeerType PeerType
PeerID int64
Query string
BeforeID int64
Limit int
}
// CustomVerificationRequestFilter bounds a review-queue query.
type CustomVerificationRequestFilter struct {
Statuses []CustomVerificationRequestStatus
VerifierBotID int64
PeerType PeerType
Query string
BeforeID int64
Limit int
}

View file

@ -0,0 +1,635 @@
package domain
import (
"errors"
"sort"
"strings"
"time"
)
// Collectible (Fragment-style) usernames.
//
// A peer holds exactly one editable username -- the slot the client owns through
// account.updateUsername / channels.updateUsername -- plus any number of
// collectible usernames up to MaxPeerCollectibleUsernames. Both kinds live in
// the same global registry, so occupancy checks and username resolution have a
// single source of truth.
//
// The editable slot keeps the service-level 5..32 length rule. Collectible names
// are minted by the operator and may be shorter: short names are precisely what
// a collectible market distributes. They still have to be syntactically
// resolvable by clients, so the character rules are identical.
const (
MinCollectibleUsernameLength = 4
MaxCollectibleUsernameLength = 32
// MaxPeerCollectibleUsernames bounds the collectible rows a single peer can
// hold. The TL usernames vector is rendered in full by clients, so the bound
// keeps both the projection cost and the rendered list finite.
MaxPeerCollectibleUsernames = 20
// MaxUsernameSortOrder matches the registry CHECK and bounds reorder input.
MaxUsernameSortOrder = 1024
// MaxCollectibleUsernameURLLength matches the registry CHECK on url.
MaxCollectibleUsernameURLLength = 512
// MaxCollectibleUsernameReasonLength matches the provenance CHECK on reason.
MaxCollectibleUsernameReasonLength = 512
// MaxCollectibleUsernameActorLength matches the provenance CHECK on actor.
MaxCollectibleUsernameActorLength = 128
// MaxCollectibleUsernameCommandKeyLength matches the idempotency CHECK.
MaxCollectibleUsernameCommandKeyLength = 128
)
var (
// ErrCollectibleUsernameNotFound is returned when no asset backs the name.
ErrCollectibleUsernameNotFound = errors.New("collectible username not found")
// ErrCollectibleUsernameNotOwned rejects operations that need a live owner.
ErrCollectibleUsernameNotOwned = errors.New("collectible username not owned")
// ErrCollectibleUsernameBurned rejects any mutation of a burned asset.
ErrCollectibleUsernameBurned = errors.New("collectible username burned")
// ErrCollectibleUsernameLimit reports the per-peer collectible bound.
ErrCollectibleUsernameLimit = errors.New("collectible username limit reached")
// ErrCollectibleUsernameStateInvalid rejects an impossible asset shape.
ErrCollectibleUsernameStateInvalid = errors.New("collectible username state invalid")
// ErrUsernameNotCollectible rejects collectible-only operations on the
// editable slot, which the client owns and the operator must not move.
ErrUsernameNotCollectible = errors.New("username not collectible")
// ErrUsernameNotEditable rejects editable-only operations on a collectible.
ErrUsernameNotEditable = errors.New("username not editable")
// ErrUsernameOrderInvalid rejects a reorder that is not a permutation of the
// peer's current collectible usernames.
ErrUsernameOrderInvalid = errors.New("username order invalid")
// ErrCollectibleCurrencyInvalid rejects an unsupported purchase currency.
ErrCollectibleCurrencyInvalid = errors.New("collectible currency invalid")
)
// Purchase currencies recorded on a collectible asset. XTR is Stars, TON is the
// local (non on-chain) TON ledger, USD is a bookkeeping-only fiat record for
// assets the operator imported rather than sold.
const (
CollectibleCurrencyStars = "XTR"
CollectibleCurrencyTON = "TON"
CollectibleCurrencyUSD = "USD"
)
// CollectibleCryptoCurrencyTON is the only crypto currency the projection
// reports, matching the TON ledger the star gift lifecycle already uses.
const CollectibleCryptoCurrencyTON = "TON"
// CollectibleUsernameStatus is the asset lifecycle.
//
// vault -- minted, held by the operator, attached to no peer.
// owned -- attached to a peer and present in that peer's username registry.
// burned -- permanently retired; the name is released back to the pool.
type CollectibleUsernameStatus string
const (
CollectibleUsernameStatusVault CollectibleUsernameStatus = "vault"
CollectibleUsernameStatusOwned CollectibleUsernameStatus = "owned"
CollectibleUsernameStatusBurned CollectibleUsernameStatus = "burned"
)
// Valid reports whether the status is one of the three modelled states.
func (s CollectibleUsernameStatus) Valid() bool {
switch s {
case CollectibleUsernameStatusVault, CollectibleUsernameStatusOwned, CollectibleUsernameStatusBurned:
return true
default:
return false
}
}
// CollectibleUsernameTransferKind is the provenance entry kind.
type CollectibleUsernameTransferKind string
const (
// CollectibleUsernameKindMint records the asset entering the vault.
CollectibleUsernameKindMint CollectibleUsernameTransferKind = "mint"
// CollectibleUsernameKindTransfer records an ownership change, including the
// first assignment out of the vault.
CollectibleUsernameKindTransfer CollectibleUsernameTransferKind = "transfer"
// CollectibleUsernameKindRevoke records an owner losing the asset back to the
// vault without the name being released.
CollectibleUsernameKindRevoke CollectibleUsernameTransferKind = "revoke"
// CollectibleUsernameKindBurn records permanent retirement.
CollectibleUsernameKindBurn CollectibleUsernameTransferKind = "burn"
)
// Valid reports whether the kind is modelled.
func (k CollectibleUsernameTransferKind) Valid() bool {
switch k {
case CollectibleUsernameKindMint, CollectibleUsernameKindTransfer, CollectibleUsernameKindRevoke, CollectibleUsernameKindBurn:
return true
default:
return false
}
}
// Username is one registry row of a peer's username list. It projects directly
// onto username#b4073647 (editable/active flags plus the display form).
type Username struct {
Username string
Active bool
Editable bool
// SortOrder orders collectible rows. The editable row always sorts first
// regardless of its stored value, matching client expectations that the
// editable username heads the list.
SortOrder int
// CollectibleID is zero for the editable slot and non-zero for an asset.
CollectibleID int64
}
// Collectible reports whether this row is backed by a collectible asset.
func (u Username) Collectible() bool { return u.CollectibleID != 0 }
// CollectibleUsername is the asset behind a collectible username: who holds it,
// what was paid, and where the purchase can be verified.
type CollectibleUsername struct {
ID int64
Username string
Status CollectibleUsernameStatus
// Owner is the zero Peer unless Status is owned.
Owner Peer
// PurchaseDate, Currency, Amount, CryptoCurrency, CryptoAmount and URL are
// the fragment.collectibleInfo payload.
PurchaseDate time.Time
Currency string
Amount int64
CryptoCurrency string
CryptoAmount int64
URL string
// OriginalOwner is the first holder and survives transfers and burns.
OriginalOwner Peer
TransferCount int
Version int64
CreatedAt time.Time
UpdatedAt time.Time
}
// CollectibleInfo is the fragment.collectibleInfo projection. PurchaseDate is a
// unix timestamp because the TL field is an int date.
type CollectibleInfo struct {
PurchaseDate int
Currency string
Amount int64
CryptoCurrency string
CryptoAmount int64
URL string
}
// Info returns the TL-shaped purchase record.
func (c CollectibleUsername) Info() CollectibleInfo {
date := 0
if !c.PurchaseDate.IsZero() {
date = int(c.PurchaseDate.Unix())
}
return CollectibleInfo{
PurchaseDate: date,
Currency: c.Currency,
Amount: c.Amount,
CryptoCurrency: c.CryptoCurrency,
CryptoAmount: c.CryptoAmount,
URL: c.URL,
}
}
// Owned reports whether the asset is currently attached to a peer.
func (c CollectibleUsername) Owned() bool {
return c.Status == CollectibleUsernameStatusOwned && c.Owner.Type != "" && c.Owner.ID > 0
}
// Validate enforces the invariants the registry CHECK constraints encode, so an
// in-memory store and PostgreSQL reject the same shapes.
func (c CollectibleUsername) Validate() error {
if !ValidCollectibleUsername(c.Username) {
return ErrUsernameInvalid
}
if !c.Status.Valid() {
return ErrCollectibleUsernameStateInvalid
}
switch c.Status {
case CollectibleUsernameStatusOwned:
if !validCollectibleOwner(c.Owner) {
return ErrCollectibleUsernameStateInvalid
}
default:
if c.Owner.Type != "" || c.Owner.ID != 0 {
return ErrCollectibleUsernameStateInvalid
}
}
if err := ValidateCollectibleAmounts(c.Currency, c.Amount, c.CryptoCurrency, c.CryptoAmount); err != nil {
return err
}
if len(c.URL) > MaxCollectibleUsernameURLLength {
return ErrCollectibleUsernameStateInvalid
}
if c.TransferCount < 0 {
return ErrCollectibleUsernameStateInvalid
}
if c.OriginalOwner.Type != "" && !validCollectibleOwner(c.OriginalOwner) {
return ErrCollectibleUsernameStateInvalid
}
return nil
}
func validCollectibleOwner(peer Peer) bool {
switch peer.Type {
case PeerTypeUser, PeerTypeChannel:
return peer.ID > 0
default:
return false
}
}
// ValidateCollectibleAmounts enforces the currency/amount pairing shared by the
// registry CHECK constraints: a crypto currency requires a positive amount and
// an empty one forbids it.
func ValidateCollectibleAmounts(currency string, amount int64, cryptoCurrency string, cryptoAmount int64) error {
switch currency {
case CollectibleCurrencyStars, CollectibleCurrencyTON, CollectibleCurrencyUSD:
default:
return ErrCollectibleCurrencyInvalid
}
if amount < 0 {
return ErrCollectibleCurrencyInvalid
}
switch cryptoCurrency {
case "":
if cryptoAmount != 0 {
return ErrCollectibleCurrencyInvalid
}
case CollectibleCryptoCurrencyTON:
if cryptoAmount <= 0 {
return ErrCollectibleCurrencyInvalid
}
default:
return ErrCollectibleCurrencyInvalid
}
return nil
}
// CollectibleUsernameTransfer is one provenance row.
type CollectibleUsernameTransfer struct {
ID int64
CollectibleID int64
Kind CollectibleUsernameTransferKind
From Peer
To Peer
Currency string
Amount int64
Actor string
Reason string
CommandKey string
CreatedAt time.Time
}
// NormalizeUsername trims whitespace and a leading '@'. It is the shared entry
// normalisation for every username surface: RPC, admin API and store.
func NormalizeUsername(username string) string {
username = strings.TrimSpace(username)
username = strings.TrimPrefix(username, "@")
return strings.TrimSpace(username)
}
// ValidCollectibleUsername reports whether the name is syntactically usable as a
// collectible username. Character rules match the editable slot exactly; only
// the minimum length differs.
func ValidCollectibleUsername(username string) bool {
return validUsernameChars(username, MinCollectibleUsernameLength, MaxCollectibleUsernameLength)
}
func validUsernameChars(username string, minLen, maxLen int) bool {
if len(username) < minLen || len(username) > maxLen {
return false
}
for i := 0; i < len(username); i++ {
c := username[i]
switch {
case c >= 'a' && c <= 'z':
case c >= 'A' && c <= 'Z':
case c >= '0' && c <= '9':
if i == 0 {
return false
}
case c == '_':
if i == 0 {
return false
}
default:
return false
}
}
return true
}
// SortUsernames returns the projection order clients expect: stored order first,
// then the editable slot ahead of a collectible that shares its position, then
// the name as a stable tiebreak. The input is not mutated.
//
// Order beats editability because core.telegram.org/api/fragment makes the first
// entry of the vector the peer's primary username, and reorderUsernames is
// allowed to move the editable slot out of that position. Editability is only the
// tiebreak, which is what keeps a peer that never reordered anything projecting
// exactly as before: every such row carries sort_order 0, including the editable
// one, so the tiebreak alone decides and the editable slot stays first.
func SortUsernames(list []Username) []Username {
out := append([]Username(nil), list...)
sort.SliceStable(out, func(i, j int) bool {
if out[i].SortOrder != out[j].SortOrder {
return out[i].SortOrder < out[j].SortOrder
}
if out[i].Editable != out[j].Editable {
return out[i].Editable
}
return strings.ToLower(out[i].Username) < strings.ToLower(out[j].Username)
})
return out
}
// ActiveUsername returns the name clients treat as the peer's primary username:
// the first active entry of the projected vector, which is the active editable
// slot until a reorder moves a collectible ahead of it.
func ActiveUsername(list []Username) string {
for _, item := range SortUsernames(list) {
if item.Active && item.Username != "" {
return item.Username
}
}
return ""
}
// ValidateUsernameReorder checks that order is a valid new ordering of the peer's
// usernames.
//
// The contract is the one clients implement, not a collectible-only one:
// core.telegram.org/api/fragment says "all currently active usernames must be
// specified", and the editable slot is an active username. Telegram Desktop
// therefore sends the whole visible list -- editable slot included -- and a
// server that rejects the editable name answers USERNAME_INVALID to a correct
// client, which is what made a channel's username editor unusable.
//
// So: every name in order must belong to the peer, without duplicates, and every
// *active* username of the peer must appear. Inactive names are optional, because
// they are not part of what the client is showing; when a client does include
// them they are positioned like any other name.
func ValidateUsernameReorder(current []Username, order []string) error {
// The bound is the collectible bound plus the one editable slot, because the
// editable name is a legitimate member of the vector.
if len(order) > MaxPeerCollectibleUsernames+1 {
return ErrUsernameOrderInvalid
}
owned := make(map[string]struct{}, len(current))
active := make(map[string]struct{}, len(current))
for _, item := range current {
key := strings.ToLower(item.Username)
if key == "" {
continue
}
owned[key] = struct{}{}
if item.Active {
active[key] = struct{}{}
}
}
seen := make(map[string]struct{}, len(order))
for _, name := range order {
key := strings.ToLower(NormalizeUsername(name))
if key == "" {
return ErrUsernameOrderInvalid
}
if _, ok := owned[key]; !ok {
return ErrUsernameOrderInvalid
}
if _, dup := seen[key]; dup {
return ErrUsernameOrderInvalid
}
seen[key] = struct{}{}
}
for key := range active {
if _, ok := seen[key]; !ok {
return ErrUsernameOrderInvalid
}
}
return nil
}
// SameUsernameOrder reports whether two lists project the same visible sequence
// of names. It is what a reorder reports as "changed", rather than whether the
// stored sort_order integers moved: legacy rows numbered the editable slot and
// the first collectible both 0, so the very first reorder renumbers rows without
// moving anything a client can see, and every peer of the peer would be notified
// for nothing.
func SameUsernameOrder(a, b []Username) bool {
left, right := SortUsernames(a), SortUsernames(b)
if len(left) != len(right) {
return false
}
for i := range left {
if !strings.EqualFold(left[i].Username, right[i].Username) {
return false
}
}
return true
}
// ApplyUsernameReorder returns the list with sort orders rewritten to follow
// order, the editable slot included: a client is allowed to move its own username
// below a collectible one, and the first entry of the vector is what clients show
// as the peer's primary username.
//
// Names absent from order -- only ever inactive ones, since ValidateUsernameReorder
// requires every active name -- keep their relative position and are placed after
// everything the client ordered, so an inactive name can never displace a visible
// one.
func ApplyUsernameReorder(current []Username, order []string) ([]Username, error) {
if err := ValidateUsernameReorder(current, order); err != nil {
return nil, err
}
position := make(map[string]int, len(order))
for i, name := range order {
position[strings.ToLower(NormalizeUsername(name))] = i
}
out := append([]Username(nil), current...)
// Unordered rows follow the ordered block in their previous projection order.
trailing := len(order)
for _, item := range SortUsernames(current) {
key := strings.ToLower(item.Username)
if key == "" {
continue
}
if _, ok := position[key]; ok {
continue
}
position[key] = trailing
trailing++
}
if trailing > MaxUsernameSortOrder {
return nil, ErrUsernameOrderInvalid
}
for i := range out {
key := strings.ToLower(out[i].Username)
if key == "" {
continue
}
out[i].SortOrder = position[key]
}
return SortUsernames(out), nil
}
// ValidateUsernameToggle rejects toggling the editable slot through the
// collectible-only method. Every collectible may be inactive at the same time:
// ownership and resolvability are separate facts, and inactive assets remain
// visible to their owner without resolving publicly.
func ValidateUsernameToggle(current []Username, username string, active bool) error {
key := strings.ToLower(NormalizeUsername(username))
if key == "" {
return ErrUsernameInvalid
}
var target *Username
for i := range current {
if strings.ToLower(current[i].Username) == key {
target = &current[i]
break
}
}
if target == nil {
return ErrUsernameNotOccupied
}
if !target.Collectible() {
return ErrUsernameNotCollectible
}
return nil
}
// MintCollectibleUsernameRequest describes an operator minting a new asset,
// optionally assigning it to a holder in the same command.
type MintCollectibleUsernameRequest struct {
Username string
// Owner is optional: the zero value mints into the vault.
Owner Peer
PurchaseDate time.Time
Currency string
Amount int64
CryptoCurrency string
CryptoAmount int64
URL string
Actor string
Reason string
CommandKey string
}
// Validate normalises nothing and only checks; callers normalise first.
func (r MintCollectibleUsernameRequest) Validate() error {
if !ValidCollectibleUsername(r.Username) {
return ErrUsernameInvalid
}
if r.Owner.Type != "" && !validCollectibleOwner(r.Owner) {
return ErrCollectibleUsernameStateInvalid
}
if err := ValidateCollectibleAmounts(r.Currency, r.Amount, r.CryptoCurrency, r.CryptoAmount); err != nil {
return err
}
if len(r.URL) > MaxCollectibleUsernameURLLength {
return ErrCollectibleUsernameStateInvalid
}
if len(r.Reason) > MaxCollectibleUsernameReasonLength {
return ErrCollectibleUsernameStateInvalid
}
if len(r.Actor) > MaxCollectibleUsernameActorLength {
return ErrCollectibleUsernameStateInvalid
}
if len(r.CommandKey) > MaxCollectibleUsernameCommandKeyLength {
return ErrCollectibleUsernameStateInvalid
}
return nil
}
// TransferCollectibleUsernameRequest moves an asset between peers, or out of the
// vault when the asset is unowned.
type TransferCollectibleUsernameRequest struct {
Username string
To Peer
Actor string
Reason string
CommandKey string
}
// Validate checks the request shape.
func (r TransferCollectibleUsernameRequest) Validate() error {
if !ValidCollectibleUsername(r.Username) {
return ErrUsernameInvalid
}
if !validCollectibleOwner(r.To) {
return ErrCollectibleUsernameStateInvalid
}
if len(r.Reason) > MaxCollectibleUsernameReasonLength {
return ErrCollectibleUsernameStateInvalid
}
if len(r.Actor) > MaxCollectibleUsernameActorLength {
return ErrCollectibleUsernameStateInvalid
}
if len(r.CommandKey) > MaxCollectibleUsernameCommandKeyLength {
return ErrCollectibleUsernameStateInvalid
}
return nil
}
// RevokeCollectibleUsernameRequest returns an asset to the vault, or burns it.
type RevokeCollectibleUsernameRequest struct {
Username string
// Burn retires the asset permanently instead of returning it to the vault.
Burn bool
Actor string
Reason string
CommandKey string
}
// Validate checks the request shape.
func (r RevokeCollectibleUsernameRequest) Validate() error {
if !ValidCollectibleUsername(r.Username) {
return ErrUsernameInvalid
}
if len(r.Reason) > MaxCollectibleUsernameReasonLength {
return ErrCollectibleUsernameStateInvalid
}
if len(r.Actor) > MaxCollectibleUsernameActorLength {
return ErrCollectibleUsernameStateInvalid
}
if len(r.CommandKey) > MaxCollectibleUsernameCommandKeyLength {
return ErrCollectibleUsernameStateInvalid
}
return nil
}
// DeleteCollectibleUsernameRequest removes an asset outright, releasing its name
// and discarding its provenance. Revoke+Burn retires an asset but keeps the
// history; this is the escape hatch for a name that was issued by mistake.
type DeleteCollectibleUsernameRequest struct {
Username string
Actor string
Reason string
CommandKey string
}
// Validate checks the request shape.
func (r DeleteCollectibleUsernameRequest) Validate() error {
if !ValidCollectibleUsername(r.Username) {
return ErrUsernameInvalid
}
if len(r.Reason) > MaxCollectibleUsernameReasonLength {
return ErrCollectibleUsernameStateInvalid
}
if len(r.Actor) > MaxCollectibleUsernameActorLength {
return ErrCollectibleUsernameStateInvalid
}
if len(r.CommandKey) > MaxCollectibleUsernameCommandKeyLength {
return ErrCollectibleUsernameStateInvalid
}
return nil
}
// CollectibleUsernameFilter bounds an admin listing query.
type CollectibleUsernameFilter struct {
Status CollectibleUsernameStatus
Owner Peer
Query string
BeforeID int64
Limit int
}

View file

@ -21,6 +21,28 @@ const (
ChatBotUserID int64 = 1250000007
// ChatBotAccessHash 固定不变;与 postgres 种子行双写,必须保持一致。
ChatBotAccessHash int64 = 6332902371644871201
// VerifyBotUserID is the built-in @verifybot: it collects official platform
// verification applications and reports decisions back to the applicant. The
// id is reserved and stable, so a restart never re-creates the account under a
// different identity.
VerifyBotUserID int64 = 1250000011
// VerifyBotAccessHash is fixed and double-written with the seed row in
// migration 0153; the two must never drift.
VerifyBotAccessHash int64 = 7802113947355620887
// VerifierBotUserID is the built-in @verifierbot: the first THIRD-PARTY
// verifier of a deployment (core.telegram.org/api/bots/verification). It
// collects applications for its own icon+description mark and reports the
// operator's decision back to the applicant. The id is reserved and stable, so
// a restart never re-creates the account under a different identity.
//
// It is not a second route to the platform checkmark: that badge is granted by
// the operator alone and collected by VerifyBotUserID above.
VerifierBotUserID int64 = 1250000013
// VerifierBotAccessHash is fixed and double-written with the seed row in
// migration 0156; the two must never drift.
VerifierBotAccessHash int64 = 6913402578811563729
)
// OfficialSystemUser 返回第一阶段内置的官方系统账号。
@ -75,6 +97,38 @@ func ChatBotUser() User {
}
}
// VerifyBotUser returns the built-in @verifybot account. It is verified itself,
// so the applicant sees the same badge on the account that grants it.
func VerifyBotUser() User {
return User{
ID: VerifyBotUserID,
AccessHash: VerifyBotAccessHash,
FirstName: "Verify Bot",
Username: "verifybot",
Verified: true,
Bot: true,
BotInfoVersion: 1,
}
}
// VerifierBotUser returns the built-in @verifierbot account.
//
// Verified is false on purpose: the official checkmark is the platform's own
// mechanism, and a third-party verifier wearing it would blur exactly the
// distinction this bot has to explain to every applicant. What makes the account a
// verifier is the operator-granted BotVerifierSettings row, not this seed.
func VerifierBotUser() User {
return User{
ID: VerifierBotUserID,
AccessHash: VerifierBotAccessHash,
FirstName: "Verifier Bot",
Username: "verifierbot",
Verified: false,
Bot: true,
BotInfoVersion: 1,
}
}
// SystemUserByID 返回内置系统账号;非系统账号返回 ok=false。
// 所有对 777000 的硬编码注入点统一经此函数,新增内置账号只改这里。
func SystemUserByID(id int64) (User, bool) {
@ -87,6 +141,10 @@ func SystemUserByID(id int64) (User, bool) {
return StickersBotUser(), true
case ChatBotUserID:
return ChatBotUser(), true
case VerifyBotUserID:
return VerifyBotUser(), true
case VerifierBotUserID:
return VerifierBotUser(), true
}
return User{}, false
}
@ -96,9 +154,25 @@ func IsSystemUserID(id int64) bool {
return ok
}
// SystemUserIDs returns every built-in account id, in a stable order.
//
// It is the one list to extend when a service account is added, so a caller that
// has to enumerate them -- a SQL predicate excluding infrastructure, say -- cannot
// silently miss one the way an inline literal would.
func SystemUserIDs() []int64 {
return []int64{
OfficialSystemUserID,
BotFatherUserID,
StickersBotUserID,
ChatBotUserID,
VerifyBotUserID,
VerifierBotUserID,
}
}
func SystemUserByPhone(phone string) (User, bool) {
phone = NormalizePhone(phone)
for _, id := range []int64{OfficialSystemUserID, BotFatherUserID, StickersBotUserID, ChatBotUserID} {
for _, id := range SystemUserIDs() {
u, ok := SystemUserByID(id)
if !ok || u.Phone == "" {
continue

View file

@ -0,0 +1,651 @@
package domain
import (
"errors"
"net"
"net/url"
"strings"
"time"
"unicode/utf8"
)
// Official platform verification.
//
// This is the badge official clients render from user#b1b8cc83 verified:flags.17
// and channel#d49f34c6 verified:flags.7 (layer 228). It is deliberately NOT the
// third-party mechanism built on botVerification#f93cd45c /
// bots.setCustomVerification#8b89dfbd / bot_verification_icon, where an outside
// organisation attaches its own icon. The two must never be conflated: an
// application reviewed here flips the platform flag on the peer record and
// nothing else.
//
// Applications are filed through the built-in @verifybot and decided in the
// admin panel. The application record is the durable audit subject: it is never
// deleted, only moved through its status machine.
// Verification target kinds. A verification subject is always a public presence:
// a bot, a public channel or a public supergroup. Ordinary user accounts are
// excluded unless the operator opts in via configuration.
type VerificationTargetType string
const (
VerificationTargetBot VerificationTargetType = "bot"
VerificationTargetChannel VerificationTargetType = "channel"
VerificationTargetSupergroup VerificationTargetType = "supergroup"
VerificationTargetUser VerificationTargetType = "user"
)
// Valid reports whether the target type is modelled.
func (t VerificationTargetType) Valid() bool {
switch t {
case VerificationTargetBot, VerificationTargetChannel, VerificationTargetSupergroup, VerificationTargetUser:
return true
default:
return false
}
}
// PeerType maps the target kind onto the peer namespace that holds it: bots and
// users live in the user namespace, channels and supergroups in the channel one.
func (t VerificationTargetType) PeerType() PeerType {
switch t {
case VerificationTargetChannel, VerificationTargetSupergroup:
return PeerTypeChannel
default:
return PeerTypeUser
}
}
// VerificationStatus is the application lifecycle.
//
// draft -- being filled in through the bot dialog, not yet visible to review
// submitted -- awaiting a reviewer
// in_review -- claimed by a reviewer
// approved -- decided in favour; the target carries the platform flag
// rejected -- decided against, with a mandatory reason
// cancelled -- withdrawn by the applicant
type VerificationStatus string
const (
VerificationStatusDraft VerificationStatus = "draft"
VerificationStatusSubmitted VerificationStatus = "submitted"
VerificationStatusInReview VerificationStatus = "in_review"
VerificationStatusApproved VerificationStatus = "approved"
VerificationStatusRejected VerificationStatus = "rejected"
VerificationStatusCancelled VerificationStatus = "cancelled"
)
// Valid reports whether the status is modelled.
func (s VerificationStatus) Valid() bool {
switch s {
case VerificationStatusDraft, VerificationStatusSubmitted, VerificationStatusInReview,
VerificationStatusApproved, VerificationStatusRejected, VerificationStatusCancelled:
return true
default:
return false
}
}
// Active reports whether the application still occupies its target. Exactly one
// active application per target is allowed, which the store enforces with a
// partial unique index over these statuses.
func (s VerificationStatus) Active() bool {
switch s {
case VerificationStatusDraft, VerificationStatusSubmitted, VerificationStatusInReview:
return true
default:
return false
}
}
// Decided reports whether a reviewer has closed the application.
func (s VerificationStatus) Decided() bool {
return s == VerificationStatusApproved || s == VerificationStatusRejected
}
// CanTransitionVerificationStatus is the single source of truth for the status
// machine. Both the bot dialog and the review API validate against it, so an
// operator cannot drive an application into a state the applicant path forbids.
func CanTransitionVerificationStatus(from, to VerificationStatus) bool {
if !from.Valid() || !to.Valid() || from == to {
return false
}
switch from {
case VerificationStatusDraft:
return to == VerificationStatusSubmitted || to == VerificationStatusCancelled
case VerificationStatusSubmitted:
return to == VerificationStatusInReview || to == VerificationStatusApproved ||
to == VerificationStatusRejected || to == VerificationStatusCancelled
case VerificationStatusInReview:
return to == VerificationStatusApproved || to == VerificationStatusRejected ||
to == VerificationStatusSubmitted || to == VerificationStatusCancelled
default:
// Decided and cancelled applications are terminal: history is kept, a new
// attempt is a new application.
return false
}
}
// Field bounds. They are enforced in the domain so the bot dialog, the admin API
// and both store backends reject the same inputs, and they mirror the CHECK
// constraints on verification_applications.
const (
MaxVerificationCategoryLength = 64
MaxVerificationDescriptionLength = 1024
MinVerificationDescriptionLength = 40
MaxVerificationCommentLength = 1024
MaxVerificationURLLength = 512
MaxVerificationSocialLinks = 10
MaxVerificationPressLinks = 10
// MinVerificationPressLinks is the official bar: independent coverage is what
// distinguishes a verifiable public presence from a self-declared one.
MinVerificationPressLinks = 2
MaxVerificationReasonLength = 1024
MaxVerificationNoteLength = 2048
MaxVerificationTitleLength = 256
MaxVerificationReviewerLength = 128
MaxVerificationCorrelationLen = 128
MaxVerificationEventPayloadSize = 8192
)
// VerificationCategories is the closed set of project categories offered by the
// bot. A closed set keeps the review queue groupable and keeps free text out of
// a field the panel filters on.
var VerificationCategories = []string{
"media",
"government",
"company",
"brand",
"sport",
"culture",
"education",
"nonprofit",
"public_figure",
"service",
"other",
}
// ValidVerificationCategory reports whether the category is offered.
func ValidVerificationCategory(category string) bool {
for _, item := range VerificationCategories {
if item == category {
return true
}
}
return false
}
var (
// ErrVerificationApplicationNotFound reports a missing application.
ErrVerificationApplicationNotFound = errors.New("verification application not found")
// ErrVerificationApplicationExists reports an active application already
// occupying the target.
ErrVerificationApplicationExists = errors.New("verification application already active for target")
// ErrVerificationApplicationInvalid rejects a malformed application.
ErrVerificationApplicationInvalid = errors.New("verification application invalid")
// ErrVerificationStatusInvalid rejects an impossible status transition.
ErrVerificationStatusInvalid = errors.New("verification status transition invalid")
// ErrVerificationVersionConflict reports a lost optimistic-locking race, which
// is what two reviewers deciding at once must produce for the loser.
ErrVerificationVersionConflict = errors.New("verification application changed concurrently")
// ErrVerificationTargetInvalid rejects a target that cannot be verified.
ErrVerificationTargetInvalid = errors.New("verification target invalid")
// ErrVerificationTargetNotPublic rejects a target without a public username.
ErrVerificationTargetNotPublic = errors.New("verification target has no public username")
// ErrVerificationTargetAlreadyVerified rejects a target that already carries
// the platform flag.
ErrVerificationTargetAlreadyVerified = errors.New("verification target already verified")
// ErrVerificationTargetRestricted rejects a scam/fake/frozen/deleted target.
ErrVerificationTargetRestricted = errors.New("verification target restricted")
// ErrVerificationTargetSystem rejects a built-in system entity.
ErrVerificationTargetSystem = errors.New("verification target is a system entity")
// ErrVerificationNotOwner reports that the applicant does not control the
// target.
ErrVerificationNotOwner = errors.New("applicant does not control verification target")
// ErrVerificationReasonRequired reports a rejection or revocation without a
// reason, which the audit trail must never contain.
ErrVerificationReasonRequired = errors.New("verification decision reason required")
// ErrVerificationCooldown reports a re-application filed before the
// configured cooldown after a rejection has elapsed.
ErrVerificationCooldown = errors.New("verification re-application cooldown active")
// ErrVerificationRateLimited reports too many applications in the window.
ErrVerificationRateLimited = errors.New("verification rate limit exceeded")
// ErrVerificationURLInvalid rejects a link that is not a plain http(s) URL to
// a public host.
ErrVerificationURLInvalid = errors.New("verification link invalid")
// ErrVerificationUserTargetsDisabled reports that plain user accounts are not
// accepted by this deployment.
ErrVerificationUserTargetsDisabled = errors.New("verification of user accounts is disabled")
)
// VerificationApplication is the persistent application record.
//
// The target is addressed by its stable peer id; TargetTitle and TargetUsername
// are a snapshot for the review UI and audit trail, because a username can move
// between peers and a title can change after submission.
type VerificationApplication struct {
ID int64
ApplicantUserID int64
TargetType VerificationTargetType
TargetID int64
TargetTitle string
TargetUsername string
TargetAccessHash int64
Category string
Description string
OfficialWebsite string
SocialLinks []string
PressLinks []string
AdditionalNote string
Status VerificationStatus
CreatedAt time.Time
UpdatedAt time.Time
SubmittedAt time.Time
ReviewedAt time.Time
ReviewerAdminID string
DecisionReason string
InternalNote string
CorrelationID string
// Version is the optimistic-locking token. Every mutation submits the version
// it read and the store refuses a stale one, which is how two reviewers
// deciding the same application at the same time produce exactly one decision.
Version int64
}
// Target returns the peer the application is about.
func (a VerificationApplication) Target() Peer {
return Peer{Type: a.TargetType.PeerType(), ID: a.TargetID}
}
// Editable reports whether the applicant may still change the payload.
func (a VerificationApplication) Editable() bool {
return a.Status == VerificationStatusDraft
}
// VerificationApplicationEventKind is one entry of the immutable per-application
// history rendered in the panel.
type VerificationApplicationEventKind string
const (
VerificationEventCreated VerificationApplicationEventKind = "created"
VerificationEventUpdated VerificationApplicationEventKind = "updated"
VerificationEventSubmitted VerificationApplicationEventKind = "submitted"
VerificationEventClaimed VerificationApplicationEventKind = "claimed"
VerificationEventApproved VerificationApplicationEventKind = "approved"
VerificationEventRejected VerificationApplicationEventKind = "rejected"
VerificationEventCancelled VerificationApplicationEventKind = "cancelled"
VerificationEventRevoked VerificationApplicationEventKind = "revoked"
VerificationEventNotified VerificationApplicationEventKind = "notified"
)
// Valid reports whether the event kind is modelled.
func (k VerificationApplicationEventKind) Valid() bool {
switch k {
case VerificationEventCreated, VerificationEventUpdated, VerificationEventSubmitted,
VerificationEventClaimed, VerificationEventApproved, VerificationEventRejected,
VerificationEventCancelled, VerificationEventRevoked, VerificationEventNotified:
return true
default:
return false
}
}
// VerificationApplicationEvent is an append-only history row. Actor is the admin
// identity for review actions and empty for applicant-driven ones, which are
// attributed by ApplicantUserID on the application itself.
type VerificationApplicationEvent struct {
ID int64
ApplicationID int64
Kind VerificationApplicationEventKind
FromStatus VerificationStatus
ToStatus VerificationStatus
Actor string
Reason string
Note string
CorrelationID string
CreatedAt time.Time
}
// VerificationTarget is a candidate the applicant controls, as offered by the
// bot's target picker.
type VerificationTarget struct {
Type VerificationTargetType
ID int64
Title string
Username string
AccessHash int64
Verified bool
// Eligible is false when the target cannot be filed right now; Reason carries
// the domain error for the bot to explain why.
Eligible bool
Reason string
}
// VerificationDraftInput is the applicant-supplied payload collected by the bot.
type VerificationDraftInput struct {
Category string
Description string
OfficialWebsite string
SocialLinks []string
PressLinks []string
AdditionalNote string
}
// Normalize trims the payload and drops empty links. It is applied before
// validation so a trailing newline from a chat message is not an error.
func (in VerificationDraftInput) Normalize() VerificationDraftInput {
out := VerificationDraftInput{
Category: strings.TrimSpace(in.Category),
Description: strings.TrimSpace(in.Description),
OfficialWebsite: strings.TrimSpace(in.OfficialWebsite),
AdditionalNote: strings.TrimSpace(in.AdditionalNote),
}
out.SocialLinks = normalizeVerificationLinks(in.SocialLinks)
out.PressLinks = normalizeVerificationLinks(in.PressLinks)
return out
}
func normalizeVerificationLinks(links []string) []string {
out := make([]string, 0, len(links))
seen := make(map[string]struct{}, len(links))
for _, link := range links {
link = strings.TrimSpace(link)
if link == "" {
continue
}
key := strings.ToLower(link)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, link)
}
return out
}
// ValidateDraft checks a partially filled draft: every present field must be
// well formed, but nothing is required yet. The bot uses it per step.
func (in VerificationDraftInput) ValidateDraft() error {
in = in.Normalize()
if in.Category != "" && !ValidVerificationCategory(in.Category) {
return ErrVerificationApplicationInvalid
}
if utf8.RuneCountInString(in.Description) > MaxVerificationDescriptionLength {
return ErrVerificationApplicationInvalid
}
if utf8.RuneCountInString(in.AdditionalNote) > MaxVerificationCommentLength {
return ErrVerificationApplicationInvalid
}
if in.OfficialWebsite != "" {
if err := ValidateVerificationURL(in.OfficialWebsite); err != nil {
return err
}
}
if len(in.SocialLinks) > MaxVerificationSocialLinks || len(in.PressLinks) > MaxVerificationPressLinks {
return ErrVerificationApplicationInvalid
}
for _, link := range append(append([]string(nil), in.SocialLinks...), in.PressLinks...) {
if err := ValidateVerificationURL(link); err != nil {
return err
}
}
return nil
}
// ValidateForSubmission checks a complete application. Everything the official
// process requires must be present: category, a real description, a website, and
// at least MinVerificationPressLinks independent press links.
func (in VerificationDraftInput) ValidateForSubmission() error {
in = in.Normalize()
if err := in.ValidateDraft(); err != nil {
return err
}
if !ValidVerificationCategory(in.Category) {
return ErrVerificationApplicationInvalid
}
if utf8.RuneCountInString(in.Description) < MinVerificationDescriptionLength {
return ErrVerificationApplicationInvalid
}
if in.OfficialWebsite == "" {
return ErrVerificationApplicationInvalid
}
if len(in.PressLinks) < MinVerificationPressLinks {
return ErrVerificationApplicationInvalid
}
return nil
}
// ValidateVerificationURL accepts only a plain http(s) URL naming a public host.
//
// The server never fetches these links, and this check is what keeps that
// decision safe to revisit: credentials, non-web schemes, loopback, link-local,
// private and other reserved address space are all refused, so a link can never
// become an SSRF probe against the deployment's own network, and the admin panel
// only ever renders an absolute external URL.
func ValidateVerificationURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > MaxVerificationURLLength {
return ErrVerificationURLInvalid
}
if strings.ContainsAny(raw, " \t\r\n<>\"'\\") {
return ErrVerificationURLInvalid
}
parsed, err := url.Parse(raw)
if err != nil {
return ErrVerificationURLInvalid
}
switch strings.ToLower(parsed.Scheme) {
case "http", "https":
default:
return ErrVerificationURLInvalid
}
if parsed.User != nil {
// user:password@host would be rendered as a credential-bearing link.
return ErrVerificationURLInvalid
}
host := parsed.Hostname()
if host == "" {
return ErrVerificationURLInvalid
}
if err := validateVerificationHost(host); err != nil {
return err
}
if port := parsed.Port(); port != "" {
switch port {
case "80", "443":
default:
// A non-standard port on a "public site" link is a smell and would be
// the first thing an SSRF attempt reaches for.
return ErrVerificationURLInvalid
}
}
return nil
}
func validateVerificationHost(host string) error {
lower := strings.ToLower(host)
if lower == "localhost" || strings.HasSuffix(lower, ".localhost") ||
strings.HasSuffix(lower, ".local") || strings.HasSuffix(lower, ".internal") ||
strings.HasSuffix(lower, ".onion") {
return ErrVerificationURLInvalid
}
if ip := net.ParseIP(host); ip != nil {
if !publicVerificationIP(ip) {
return ErrVerificationURLInvalid
}
return nil
}
// A registered name must look like a real domain: at least one dot, no
// underscores, no trailing dot-only labels.
if !strings.Contains(lower, ".") {
return ErrVerificationURLInvalid
}
for _, label := range strings.Split(strings.TrimSuffix(lower, "."), ".") {
if label == "" || len(label) > 63 {
return ErrVerificationURLInvalid
}
for i := 0; i < len(label); i++ {
c := label[i]
switch {
case c >= 'a' && c <= 'z':
case c >= '0' && c <= '9':
case c == '-' && i != 0 && i != len(label)-1:
default:
return ErrVerificationURLInvalid
}
}
}
return nil
}
// publicVerificationIP reports whether an IP literal is globally routable.
func publicVerificationIP(ip net.IP) bool {
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast() || ip.IsPrivate() {
return false
}
if v4 := ip.To4(); v4 != nil {
switch {
case v4[0] == 100 && v4[1]&0xc0 == 64: // 100.64.0.0/10 CGNAT
return false
case v4[0] == 192 && v4[1] == 0 && v4[2] == 0: // 192.0.0.0/24
return false
case v4[0] == 192 && v4[1] == 0 && v4[2] == 2: // TEST-NET-1
return false
case v4[0] == 198 && v4[1] == 51 && v4[2] == 100: // TEST-NET-2
return false
case v4[0] == 203 && v4[1] == 0 && v4[2] == 113: // TEST-NET-3
return false
case v4[0] == 198 && v4[1]&0xfe == 18: // 198.18.0.0/15 benchmarking
return false
case v4[0] >= 240: // 240.0.0.0/4 reserved, 255.255.255.255 broadcast
return false
}
return true
}
// IPv6: refuse unique-local, documentation, and v4-mapped forms that would
// smuggle a private v4 address past the checks above.
if len(ip) == net.IPv6len {
if ip[0]&0xfe == 0xfc { // fc00::/7
return false
}
if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x0d && ip[3] == 0xb8 { // 2001:db8::/32
return false
}
}
return true
}
// SubmitVerificationApplicationRequest is the applicant-side submission.
type SubmitVerificationApplicationRequest struct {
ApplicantUserID int64
TargetType VerificationTargetType
TargetID int64
TargetTitle string
TargetUsername string
Draft VerificationDraftInput
CorrelationID string
}
// Validate checks the request shape without touching the target state, which is
// the service's job.
func (r SubmitVerificationApplicationRequest) Validate() error {
if r.ApplicantUserID <= 0 || r.TargetID <= 0 || !r.TargetType.Valid() {
return ErrVerificationApplicationInvalid
}
if utf8.RuneCountInString(r.TargetTitle) > MaxVerificationTitleLength {
return ErrVerificationApplicationInvalid
}
if len(r.CorrelationID) > MaxVerificationCorrelationLen {
return ErrVerificationApplicationInvalid
}
return r.Draft.ValidateForSubmission()
}
// VerificationDecision is a reviewer's action on one application.
type VerificationDecision struct {
ApplicationID int64
// Version is the value the reviewer read; a mismatch is a concurrent decision.
Version int64
Reviewer string
Reason string
InternalNote string
CorrelationID string
}
// Validate checks a decision that does not require a reason.
func (d VerificationDecision) Validate() error {
if d.ApplicationID <= 0 || d.Version <= 0 {
return ErrVerificationApplicationInvalid
}
if strings.TrimSpace(d.Reviewer) == "" || len(d.Reviewer) > MaxVerificationReviewerLength {
return ErrVerificationApplicationInvalid
}
if utf8.RuneCountInString(d.Reason) > MaxVerificationReasonLength ||
utf8.RuneCountInString(d.InternalNote) > MaxVerificationNoteLength {
return ErrVerificationApplicationInvalid
}
if len(d.CorrelationID) > MaxVerificationCorrelationLen {
return ErrVerificationApplicationInvalid
}
return nil
}
// ValidateWithReason checks a decision that must carry one: rejection and
// revocation are never recorded without a stated reason.
func (d VerificationDecision) ValidateWithReason() error {
if err := d.Validate(); err != nil {
return err
}
if strings.TrimSpace(d.Reason) == "" {
return ErrVerificationReasonRequired
}
return nil
}
// VerificationApplicationFilter bounds a review-queue query.
type VerificationApplicationFilter struct {
Statuses []VerificationStatus
TargetType VerificationTargetType
Reviewer string
// Query matches an application id, a peer id or a username.
Query string
CreatedAt time.Time
Until time.Time
BeforeID int64
Limit int
}
// VerificationStatusCounts is the queue summary shown above the list.
type VerificationStatusCounts map[VerificationStatus]int64
// VerificationRevocation removes the platform flag from a previously approved
// target. It is modelled separately from a rejection: the application stays
// approved as history, and the revocation is its own audit event.
type VerificationRevocation struct {
TargetType VerificationTargetType
TargetID int64
Reviewer string
Reason string
InternalNote string
CorrelationID string
}
// Validate checks the revocation shape; a reason is mandatory.
func (r VerificationRevocation) Validate() error {
if r.TargetID <= 0 || !r.TargetType.Valid() {
return ErrVerificationTargetInvalid
}
if strings.TrimSpace(r.Reviewer) == "" || len(r.Reviewer) > MaxVerificationReviewerLength {
return ErrVerificationApplicationInvalid
}
if strings.TrimSpace(r.Reason) == "" {
return ErrVerificationReasonRequired
}
if utf8.RuneCountInString(r.Reason) > MaxVerificationReasonLength ||
utf8.RuneCountInString(r.InternalNote) > MaxVerificationNoteLength {
return ErrVerificationApplicationInvalid
}
return nil
}

View file

@ -64,6 +64,12 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
return r.onAccountUpdateUsername(ctx, layerRequest.
Username)
})
registerRPC[*tg.AccountReorderUsernamesRequest](d, tlprofile.SemanticMethodAccountReorderUsernames, func(ctx context.Context, layerRequest *tg.AccountReorderUsernamesRequest) (any, error) {
return r.onAccountReorderUsernames(ctx, layerRequest)
})
registerRPC[*tg.AccountToggleUsernameRequest](d, tlprofile.SemanticMethodAccountToggleUsername, func(ctx context.Context, layerRequest *tg.AccountToggleUsernameRequest) (any, error) {
return r.onAccountToggleUsername(ctx, layerRequest)
})
registerRPC[*tg.AccountUpdateBirthdayRequest](d, tlprofile.SemanticMethodAccountUpdateBirthday, func(ctx context.Context, layerRequest *tg.AccountUpdateBirthdayRequest) (any, error) {
return r.onAccountUpdateBirthday(ctx, layerRequest)
})
@ -1555,6 +1561,54 @@ func (r *Router) onAccountUpdateUsername(ctx context.Context, username string) (
return r.tgSelfUser(u), nil
}
// onAccountReorderUsernames rewrites the caller's active username order
// (account.reorderUsernames). The editable slot is included when active, just as
// it is in the vector sent by official clients.
//
// Without a username registry the account owns a single editable username, which
// has no order to change: USERNAME_NOT_MODIFIED is the accurate answer and keeps
// clients from believing a reorder took effect.
func (r *Router) onAccountReorderUsernames(ctx context.Context, req *tg.AccountReorderUsernamesRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if req == nil {
return false, usernameInvalidErr()
}
if len(req.Order) > domain.MaxPeerCollectibleUsernames+1 {
return false, limitInvalidErr()
}
if r.deps.Usernames == nil {
return false, usernameNotModifiedErr()
}
if err := r.reorderRegistryUsernames(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}, req.Order); err != nil {
return false, err
}
return true, nil
}
// onAccountToggleUsername activates/deactivates one of the caller's own
// collectible usernames (account.toggleUsername). Deactivating the editable slot
// through this method is rejected by the domain rules with USERNAME_INVALID:
// clearing the editable username is account.updateUsername's job.
func (r *Router) onAccountToggleUsername(ctx context.Context, req *tg.AccountToggleUsernameRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if req == nil {
return false, usernameInvalidErr()
}
if r.deps.Usernames == nil {
return false, usernameNotModifiedErr()
}
if err := r.toggleRegistryUsername(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}, req.Username, req.Active); err != nil {
return false, err
}
return true, nil
}
// onAccountUpdateBirthday 持久化资料页生日(account.updateBirthday)。birthday 缺省即清除;
// 月/日/年非法返回 BIRTHDAY_INVALID。生日落在 userFull(按隐私 PrivacyKeyBirthday 对外裁剪)。
// 写入后推 updateUser 信号给本人其它在线 session,促使已加载 full profile 的客户端重拉。
@ -1869,14 +1923,24 @@ func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) {
if u.ID == 0 {
return
}
// updateUserName carries the vector clients persist, so it has to be the full
// registry list when one exists. One peer, one registry read; the overlay
// degrades to tgUsernames(u.Username) whenever the registry is unavailable.
self := r.tgSelfUser(u)
users := []tg.UserClass{self}
r.applyUsernamesToPeerObjects(ctx, users, nil)
usernames := tgUsernames(u.Username)
if vector, ok := self.GetUsernames(); ok && len(vector) > 0 {
usernames = vector
}
r.pushUserUpdates(ctx, u.ID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUserName{
UserID: u.ID,
FirstName: u.FirstName,
LastName: u.LastName,
Usernames: tgUsernames(u.Username),
Usernames: usernames,
}},
Users: []tg.UserClass{r.tgSelfUser(u)},
Users: users,
Date: int(r.clock.Now().Unix()),
})
}

View file

@ -0,0 +1,616 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
)
// This file is the load-bearing test of the third-party verification feature.
//
// An official client renders the badge off one specific bit of one specific flags
// word. A projection that sets the neighbouring bit encodes a different, valid
// field -- the response still decodes, no error is raised anywhere, and the badge
// simply never appears. So the assertions below are not "the Go field is populated"
// but "the encoded flags word differs from the unmarked encoding in exactly the bit
// layer 228 assigns":
//
// user#b1b8cc83 bot_verification_icon:flags2.14?long
// channel#d49f34c6 bot_verification_icon:flags2.13?long
// userFull#6cbe645 bot_verification:flags2.12?BotVerification
// channelFull#a04e8d3a bot_verification:flags2.17?BotVerification
// chatInvite#5c9d3702 bot_verification:flags.13?BotVerification
// botInfo#4d8a0299 verifier_settings:flags.9?BotVerifierSettings
//
// Each case encodes the same object twice through the real handler -- once before
// the mark exists and once after -- and XORs the two flags words. That catches an
// off-by-one bit, and also catches a projection that quietly disturbs an unrelated
// flag while adding the badge.
// tlRoundTrip serialises through the wire and decodes back, so every assertion
// reads the encoded form rather than the in-memory struct.
func tlRoundTrip(t *testing.T, in bin.Encoder, out bin.Decoder) {
t.Helper()
buf := &bin.Buffer{}
if err := in.Encode(buf); err != nil {
t.Fatalf("encode %T: %v", in, err)
}
if err := out.Decode(buf); err != nil {
t.Fatalf("decode %T: %v", out, err)
}
if buf.Len() != 0 {
t.Fatalf("decode %T left %d trailing bytes", out, buf.Len())
}
}
// assertFlagBitDelta pins that adding the mark flipped exactly one bit of the flags
// word, at the index layer 228 assigns.
func assertFlagBitDelta(t *testing.T, label string, before, after bin.Fields, wantBit int) {
t.Helper()
want := uint32(1) << uint(wantBit)
delta := uint32(before) ^ uint32(after)
if delta != want {
t.Fatalf("%s flags delta = %032b, want exactly bit %d (%032b); before %032b after %032b",
label, delta, wantBit, want, uint32(before), uint32(after))
}
if uint32(after)&want == 0 {
t.Fatalf("%s did not set bit %d: flags = %032b", label, wantBit, uint32(after))
}
}
// TestBotVerificationConstructorIDs pins the two constructor ids the feature
// serialises. A drift here silently reshapes every payload below.
func TestBotVerificationConstructorIDs(t *testing.T) {
if tg.BotVerificationTypeID != 0xf93cd45c {
t.Fatalf("botVerification constructor id = %#x, want 0xf93cd45c", tg.BotVerificationTypeID)
}
if tg.BotVerifierSettingsTypeID != 0xb0cd6617 {
t.Fatalf("botVerifierSettings constructor id = %#x, want 0xb0cd6617", tg.BotVerifierSettingsTypeID)
}
// The carriers, so a layer bump that reshuffles them is caught here too.
if tg.UserTypeID != 0xb1b8cc83 || tg.ChannelTypeID != 0xd49f34c6 {
t.Fatalf("peer constructor ids = user %#x / channel %#x", tg.UserTypeID, tg.ChannelTypeID)
}
if tg.UserFullTypeID != 0x6cbe645 || tg.ChannelFullTypeID != 0xa04e8d3a {
t.Fatalf("full constructor ids = userFull %#x / channelFull %#x", tg.UserFullTypeID, tg.ChannelFullTypeID)
}
if tg.ChatInviteTypeID != 0x5c9d3702 || tg.BotInfoTypeID != 0x4d8a0299 {
t.Fatalf("constructor ids = chatInvite %#x / botInfo %#x", tg.ChatInviteTypeID, tg.BotInfoTypeID)
}
if tg.BotsSetCustomVerificationRequestTypeID != 0x8b89dfbd {
t.Fatalf("bots.setCustomVerification id = %#x, want 0x8b89dfbd", tg.BotsSetCustomVerificationRequestTypeID)
}
}
// TestBotVerificationTLProjectionShapes pins the two payload builders: the icon is a
// custom emoji document id, so a mark without one is omitted rather than encoded as
// a badge the client draws as nothing.
func TestBotVerificationTLProjectionShapes(t *testing.T) {
value, ok := tgBotVerification(domain.BotVerification{BotID: 42, Icon: 777, Description: "Verified by Acme"})
if !ok {
t.Fatal("complete mark was rejected")
}
decoded := &tg.BotVerification{}
tlRoundTrip(t, &value, decoded)
if decoded.BotID != 42 || decoded.Icon != 777 || decoded.Description != "Verified by Acme" {
t.Fatalf("botVerification = %+v", decoded)
}
for _, bad := range []domain.BotVerification{
{BotID: 0, Icon: 777},
{BotID: 42, Icon: 0},
} {
if _, ok := tgBotVerification(bad); ok {
t.Fatalf("unrenderable mark %+v was projected", bad)
}
}
settings, ok := tgBotVerifierSettings(domain.BotVerifierSettings{
BotID: 42, IconDocumentID: 777, CompanyName: "Acme Trust",
DefaultDescription: "Verified by Acme Trust", CanModifyCustomDescription: true, Enabled: true,
})
if !ok {
t.Fatal("valid verifier settings were rejected")
}
decodedSettings := &tg.BotVerifierSettings{}
tlRoundTrip(t, &settings, decodedSettings)
if decodedSettings.Icon != 777 || decodedSettings.Company != "Acme Trust" {
t.Fatalf("botVerifierSettings = %+v", decodedSettings)
}
if !decodedSettings.GetCanModifyCustomDescription() || !decodedSettings.Flags.Has(1) {
t.Fatalf("can_modify_custom_description not on flags.1: %032b", uint32(decodedSettings.Flags))
}
if desc, ok := decodedSettings.GetCustomDescription(); !ok || desc != "Verified by Acme Trust" ||
!decodedSettings.Flags.Has(0) {
t.Fatalf("custom_description not on flags.0: %q ok=%v flags %032b", desc, ok, uint32(decodedSettings.Flags))
}
// A verifier with no default description leaves flags.0 clear.
bare, ok := tgBotVerifierSettings(domain.BotVerifierSettings{
BotID: 42, IconDocumentID: 777, CompanyName: "Acme Trust", Enabled: true,
})
if !ok {
t.Fatal("verifier settings without a default description were rejected")
}
decodedBare := &tg.BotVerifierSettings{}
tlRoundTrip(t, &bare, decodedBare)
if decodedBare.Flags.Has(0) || decodedBare.Flags.Has(1) {
t.Fatalf("bare verifier settings set optional flags: %032b", uint32(decodedBare.Flags))
}
// A configuration that does not validate is omitted entirely.
if _, ok := tgBotVerifierSettings(domain.BotVerifierSettings{BotID: 42, IconDocumentID: 777, Enabled: true}); ok {
t.Fatal("verifier settings without a company were projected")
}
}
// getUsersUser runs users.getUsers and returns the wire form of one projected user.
func getUsersUser(t *testing.T, r *Router, viewerUserID int64, target domain.User) *tg.User {
t.Helper()
out, err := r.onUsersGetUsers(WithUserID(context.Background(), viewerUserID),
[]tg.InputUserClass{&tg.InputUser{UserID: target.ID, AccessHash: target.AccessHash}})
if err != nil {
t.Fatalf("get users: %v", err)
}
if len(out) != 1 {
t.Fatalf("get users returned %d users, want 1", len(out))
}
decoded := &tg.User{}
tlRoundTrip(t, out[0].(*tg.User), decoded)
return decoded
}
// getFullUserProjection runs users.getFullUser and returns the wire forms of the
// userFull and (when present) its bot_info.
func getFullUserProjection(t *testing.T, r *Router, viewerUserID int64, target domain.User) (*tg.UserFull, *tg.BotInfo) {
t.Helper()
res, err := r.onUsersGetFullUser(WithUserID(context.Background(), viewerUserID),
&tg.InputUser{UserID: target.ID, AccessHash: target.AccessHash})
if err != nil {
t.Fatalf("get full user: %v", err)
}
full := res.FullUser
decoded := &tg.UserFull{}
tlRoundTrip(t, &full, decoded)
info, ok := decoded.GetBotInfo()
if !ok {
return decoded, nil
}
return decoded, &info
}
// TestUserAndUserFullCarryBotVerificationOnLayer228Bits is the user half: the icon
// on user#b1b8cc83 flags2.14 and the full payload on userFull#6cbe645 flags2.12.
func TestUserAndUserFullCarryBotVerificationOnLayer228Bits(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 8800001, true)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
plainUser := getUsersUser(t, f.router, f.owner.ID, f.target)
if _, ok := plainUser.GetBotVerificationIcon(); ok {
t.Fatalf("unmarked user carries an icon: %+v", plainUser)
}
plainFull, _ := getFullUserProjection(t, f.router, f.owner.ID, f.target)
if _, ok := plainFull.GetBotVerification(); ok {
t.Fatalf("unmarked userFull carries a mark: %+v", plainFull)
}
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "Official reseller")); err != nil || !ok {
t.Fatalf("grant = %v,%v, want true,nil", ok, err)
}
markedUser := getUsersUser(t, f.router, f.owner.ID, f.target)
icon, ok := markedUser.GetBotVerificationIcon()
if !ok || icon != 8800001 {
t.Fatalf("user bot_verification_icon = %d, ok=%v, want 8800001", icon, ok)
}
assertFlagBitDelta(t, "user", plainUser.Flags2, markedUser.Flags2, 14)
if uint32(plainUser.Flags) != uint32(markedUser.Flags) {
t.Fatalf("user flags word changed: before %032b after %032b", uint32(plainUser.Flags), uint32(markedUser.Flags))
}
// The operator-granted checkmark is a different mechanism and must stay clear.
if markedUser.Verified || markedUser.Flags.Has(17) {
t.Fatalf("third-party mark leaked into official verified:flags.17: %+v", markedUser)
}
markedFull, _ := getFullUserProjection(t, f.router, f.owner.ID, f.target)
mark, ok := markedFull.GetBotVerification()
if !ok {
t.Fatalf("userFull bot_verification unset: %+v", markedFull)
}
if mark.BotID != f.bot.ID || mark.Icon != 8800001 || mark.Description != "Official reseller" {
t.Fatalf("userFull bot_verification = %+v, want verifier %d icon 8800001", mark, f.bot.ID)
}
assertFlagBitDelta(t, "userFull", plainFull.Flags2, markedFull.Flags2, 12)
if uint32(plainFull.Flags) != uint32(markedFull.Flags) {
t.Fatalf("userFull flags word changed: before %032b after %032b", uint32(plainFull.Flags), uint32(markedFull.Flags))
}
// Revoking clears the bit again on both surfaces: the payload is an overlay, so
// it must not survive inside the userFull projection cache.
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), false, "")); err != nil || !ok {
t.Fatalf("revoke = %v,%v, want true,nil", ok, err)
}
if revokedUser := getUsersUser(t, f.router, f.owner.ID, f.target); revokedUser.Flags2.Has(14) {
t.Fatalf("revoked user still carries flags2.14: %032b", uint32(revokedUser.Flags2))
}
revokedFull, _ := getFullUserProjection(t, f.router, f.owner.ID, f.target)
if revokedFull.Flags2.Has(12) {
t.Fatalf("revoked userFull still carries flags2.12: %032b", uint32(revokedFull.Flags2))
}
}
// TestBotInfoCarriesVerifierSettingsOnFlags9 pins botInfo#4d8a0299
// verifier_settings:flags.9 inside the verifier bot's own userFull, and the operator
// kill switch: a disabled verifier stops advertising itself.
func TestBotInfoCarriesVerifierSettingsOnFlags9(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
_, plainInfo := getFullUserProjection(t, f.router, f.owner.ID, f.bot)
if plainInfo == nil {
t.Fatal("bot userFull carries no bot_info at all")
}
if _, ok := plainInfo.GetVerifierSettings(); ok {
t.Fatalf("ordinary bot advertises verifier settings: %+v", plainInfo)
}
f.enableVerifier(f.bot.ID, 8800002, true)
f.router.invalidateRPCProjectionForUser(f.bot.ID)
_, markedInfo := getFullUserProjection(t, f.router, f.owner.ID, f.bot)
if markedInfo == nil {
t.Fatal("verifier bot userFull carries no bot_info")
}
settings, ok := markedInfo.GetVerifierSettings()
if !ok {
t.Fatalf("verifier bot bot_info has no verifier_settings: %+v", markedInfo)
}
if settings.Icon != 8800002 || settings.Company != "Acme Trust" || !settings.GetCanModifyCustomDescription() {
t.Fatalf("verifier_settings = %+v", settings)
}
assertFlagBitDelta(t, "botInfo", plainInfo.Flags, markedInfo.Flags, 9)
// Operator kill switch: the row stays, the advertisement stops.
disabled := f.verify.settings[f.bot.ID]
disabled.Enabled = false
f.verify.settings[f.bot.ID] = disabled
f.router.invalidateRPCProjectionForUser(f.bot.ID)
_, offInfo := getFullUserProjection(t, f.router, f.owner.ID, f.bot)
if offInfo == nil || offInfo.Flags.Has(9) {
t.Fatalf("disabled verifier still advertises verifier_settings: %+v", offInfo)
}
}
// botVerificationGroup creates a megagroup owned by the fixture owner and returns its
// projected channel object.
func (f botVerificationFixture) botVerificationGroup(t *testing.T, title string) *tg.Channel {
t.Helper()
created, err := f.router.onMessagesCreateChat(WithUserID(context.Background(), f.owner.ID),
&tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{inputUser(f.stranger)},
Title: title,
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
return created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
}
func getChannelsChannel(t *testing.T, r *Router, viewerUserID int64, channel *tg.Channel) *tg.Channel {
t.Helper()
res, err := r.onChannelsGetChannels(WithUserID(context.Background(), viewerUserID),
[]tg.InputChannelClass{&tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}})
if err != nil {
t.Fatalf("get channels: %v", err)
}
chats := res.(*tg.MessagesChats).Chats
if len(chats) != 1 {
t.Fatalf("get channels returned %d chats, want 1", len(chats))
}
decoded := &tg.Channel{}
tlRoundTrip(t, chats[0].(*tg.Channel), decoded)
return decoded
}
func getFullChannelProjection(t *testing.T, r *Router, viewerUserID int64, channel *tg.Channel) *tg.ChannelFull {
t.Helper()
res, err := r.onChannelsGetFullChannel(WithUserID(context.Background(), viewerUserID),
&tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get full channel: %v", err)
}
decoded := &tg.ChannelFull{}
tlRoundTrip(t, res.FullChat.(*tg.ChannelFull), decoded)
return decoded
}
// TestChannelAndChannelFullCarryBotVerificationOnLayer228Bits is the channel half:
// channel#d49f34c6 flags2.13 and channelFull#a04e8d3a flags2.17. Note the two bits
// differ from the user ones, which is exactly the mistake this test exists to catch.
func TestChannelAndChannelFullCarryBotVerificationOnLayer228Bits(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 8800003, true)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
group := f.botVerificationGroup(t, "Verified Group")
plainChannel := getChannelsChannel(t, f.router, f.owner.ID, group)
if _, ok := plainChannel.GetBotVerificationIcon(); ok {
t.Fatalf("unmarked channel carries an icon: %+v", plainChannel)
}
plainFull := getFullChannelProjection(t, f.router, f.owner.ID, group)
if _, ok := plainFull.GetBotVerification(); ok {
t.Fatalf("unmarked channelFull carries a mark: %+v", plainFull)
}
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx, setCustomVerificationRequest(
&tg.InputPeerChannel{ChannelID: group.ID, AccessHash: group.AccessHash},
inputUser(f.bot), true, "Community partner")); err != nil || !ok {
t.Fatalf("grant on channel = %v,%v, want true,nil", ok, err)
}
markedChannel := getChannelsChannel(t, f.router, f.owner.ID, group)
icon, ok := markedChannel.GetBotVerificationIcon()
if !ok || icon != 8800003 {
t.Fatalf("channel bot_verification_icon = %d, ok=%v, want 8800003", icon, ok)
}
assertFlagBitDelta(t, "channel", plainChannel.Flags2, markedChannel.Flags2, 13)
if uint32(plainChannel.Flags) != uint32(markedChannel.Flags) {
t.Fatalf("channel flags word changed: before %032b after %032b",
uint32(plainChannel.Flags), uint32(markedChannel.Flags))
}
if markedChannel.Verified || markedChannel.Flags.Has(7) {
t.Fatalf("third-party mark leaked into official verified:flags.7: %+v", markedChannel)
}
markedFull := getFullChannelProjection(t, f.router, f.owner.ID, group)
mark, ok := markedFull.GetBotVerification()
if !ok {
t.Fatalf("channelFull bot_verification unset: %+v", markedFull)
}
if mark.BotID != f.bot.ID || mark.Icon != 8800003 || mark.Description != "Community partner" {
t.Fatalf("channelFull bot_verification = %+v, want verifier %d icon 8800003", mark, f.bot.ID)
}
assertFlagBitDelta(t, "channelFull", plainFull.Flags2, markedFull.Flags2, 17)
if uint32(plainFull.Flags) != uint32(markedFull.Flags) {
t.Fatalf("channelFull flags word changed: before %032b after %032b",
uint32(plainFull.Flags), uint32(markedFull.Flags))
}
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx, setCustomVerificationRequest(
&tg.InputPeerChannel{ChannelID: group.ID, AccessHash: group.AccessHash},
inputUser(f.bot), false, "")); err != nil || !ok {
t.Fatalf("revoke on channel = %v,%v, want true,nil", ok, err)
}
if revoked := getChannelsChannel(t, f.router, f.owner.ID, group); revoked.Flags2.Has(13) {
t.Fatalf("revoked channel still carries flags2.13: %032b", uint32(revoked.Flags2))
}
if revokedFull := getFullChannelProjection(t, f.router, f.owner.ID, group); revokedFull.Flags2.Has(17) {
t.Fatalf("revoked channelFull still carries flags2.17: %032b", uint32(revokedFull.Flags2))
}
}
// TestChannelFullBotInfoCarriesVerifierSettingsInOneBatch covers the batched botInfo
// path (channelFull.bot_info): the verifier settings for the whole bot list must cost
// one query, not one per bot.
func TestChannelFullBotInfoCarriesVerifierSettingsInOneBatch(t *testing.T) {
ctx := context.Background()
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 8800004, true)
group := f.botVerificationGroup(t, "Bot Group")
if _, err := f.bots.SetJoinGroups(ctx, f.bot.ID, true); err != nil {
t.Fatalf("enable join groups: %v", err)
}
if _, err := f.router.onChannelsInviteToChannel(WithUserID(ctx, f.owner.ID), &tg.ChannelsInviteToChannelRequest{
Channel: &tg.InputChannel{ChannelID: group.ID, AccessHash: group.AccessHash},
Users: []tg.InputUserClass{inputUser(f.bot)},
}); err != nil {
t.Fatalf("invite verifier bot: %v", err)
}
f.verify.settingsBatchCalls = 0
f.verify.settingsCalls = 0
full := getFullChannelProjection(t, f.router, f.owner.ID, group)
if len(full.BotInfo) != 1 || full.BotInfo[0].UserID != f.bot.ID {
t.Fatalf("channelFull bot_info = %+v, want the verifier bot", full.BotInfo)
}
settings, ok := full.BotInfo[0].GetVerifierSettings()
if !ok || !full.BotInfo[0].Flags.Has(9) {
t.Fatalf("channelFull bot_info verifier_settings unset: %+v", full.BotInfo[0])
}
if settings.Icon != 8800004 || settings.Company != "Acme Trust" {
t.Fatalf("channelFull verifier_settings = %+v", settings)
}
if f.verify.settingsBatchCalls != 1 || f.verify.settingsCalls != 0 {
t.Fatalf("verifier settings reads = batch %d / single %d, want batch 1 / single 0",
f.verify.settingsBatchCalls, f.verify.settingsCalls)
}
}
// TestChatInviteCarriesBotVerificationOnFlags13 pins chatInvite#5c9d3702
// bot_verification:flags.13 on the preview a non-member sees.
func TestChatInviteCarriesBotVerificationOnFlags13(t *testing.T) {
const channelID = int64(4242)
channel := domain.Channel{
ID: channelID, AccessHash: 42, Title: "Partner", Username: "partner",
Broadcast: true, ParticipantsCount: 11,
}
verify := newFakeBotVerifications()
r := New(Config{}, Deps{
Channels: &inviteBadgeChannels{result: domain.CheckChannelInviteResult{
Channel: channel,
Invite: domain.ChannelInvite{Hash: "hash", ChannelID: channelID},
}},
BotVerifications: verify,
}, zap.NewNop(), clock.System)
ctx := WithUserID(context.Background(), 1001)
preview := func() *tg.ChatInvite {
t.Helper()
res, err := r.onMessagesCheckChatInvite(ctx, "hash")
if err != nil {
t.Fatalf("check chat invite: %v", err)
}
decoded := &tg.ChatInvite{}
tlRoundTrip(t, res.(*tg.ChatInvite), decoded)
return decoded
}
plain := preview()
if _, ok := plain.GetBotVerification(); ok {
t.Fatalf("unmarked invite carries a mark: %+v", plain)
}
verify.marks[domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}] = domain.CustomVerification{
VerifierBotID: 777000123, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
IconDocumentID: 8800005, Description: "Verified by Acme Trust",
}
marked := preview()
mark, ok := marked.GetBotVerification()
if !ok {
t.Fatalf("invite bot_verification unset: %+v", marked)
}
if mark.BotID != 777000123 || mark.Icon != 8800005 || mark.Description != "Verified by Acme Trust" {
t.Fatalf("invite bot_verification = %+v", mark)
}
assertFlagBitDelta(t, "chatInvite", plain.Flags, marked.Flags, 13)
// The official badge and the moderation warnings are untouched.
if marked.GetVerified() || marked.GetScam() || marked.GetFake() {
t.Fatalf("third-party mark leaked into the moderation flags: %+v", marked)
}
if !marked.Channel || !marked.Broadcast || !marked.Public ||
marked.Title != "Partner" || marked.ParticipantsCount != 11 {
t.Fatalf("invite lost unrelated fields: %+v", marked)
}
}
// TestBotVerificationDegradesWithoutService is the whole-feature degradation test:
// with no verification service wired, not one of the six flags may be set, and every
// response must stay exactly what it was before the feature existed.
func TestBotVerificationDegradesWithoutService(t *testing.T) {
ctx := context.Background()
f := newBotVerificationFixture(t, nil)
group := f.botVerificationGroup(t, "Plain Group")
if _, err := f.bots.SetJoinGroups(ctx, f.bot.ID, true); err != nil {
t.Fatalf("enable join groups: %v", err)
}
if _, err := f.router.onChannelsInviteToChannel(WithUserID(ctx, f.owner.ID), &tg.ChannelsInviteToChannelRequest{
Channel: &tg.InputChannel{ChannelID: group.ID, AccessHash: group.AccessHash},
Users: []tg.InputUserClass{inputUser(f.bot)},
}); err != nil {
t.Fatalf("invite bot: %v", err)
}
if user := getUsersUser(t, f.router, f.owner.ID, f.target); user.Flags2.Has(14) {
t.Fatalf("user set flags2.14 without a service: %032b", uint32(user.Flags2))
}
userFull, botInfo := getFullUserProjection(t, f.router, f.owner.ID, f.bot)
if userFull.Flags2.Has(12) {
t.Fatalf("userFull set flags2.12 without a service: %032b", uint32(userFull.Flags2))
}
if botInfo == nil {
t.Fatal("bot userFull carries no bot_info")
}
if botInfo.Flags.Has(9) {
t.Fatalf("botInfo set flags.9 without a service: %032b", uint32(botInfo.Flags))
}
if channel := getChannelsChannel(t, f.router, f.owner.ID, group); channel.Flags2.Has(13) {
t.Fatalf("channel set flags2.13 without a service: %032b", uint32(channel.Flags2))
}
full := getFullChannelProjection(t, f.router, f.owner.ID, group)
if full.Flags2.Has(17) {
t.Fatalf("channelFull set flags2.17 without a service: %032b", uint32(full.Flags2))
}
if len(full.BotInfo) != 1 || full.BotInfo[0].Flags.Has(9) {
t.Fatalf("channelFull bot_info set flags.9 without a service: %+v", full.BotInfo)
}
invite := New(Config{}, Deps{Channels: &inviteBadgeChannels{result: domain.CheckChannelInviteResult{
Channel: domain.Channel{ID: 4343, AccessHash: 43, Title: "Plain", Broadcast: true},
Invite: domain.ChannelInvite{Hash: "hash", ChannelID: 4343},
}}}, zaptest.NewLogger(t), clock.System)
res, err := invite.onMessagesCheckChatInvite(WithUserID(ctx, 1001), "hash")
if err != nil {
t.Fatalf("check chat invite: %v", err)
}
decodedInvite := &tg.ChatInvite{}
tlRoundTrip(t, res.(*tg.ChatInvite), decodedInvite)
if decodedInvite.Flags.Has(13) {
t.Fatalf("chatInvite set flags.13 without a service: %032b", uint32(decodedInvite.Flags))
}
}
// TestBotVerificationDegradesWhenServiceFails is the other half of the degradation
// contract: a failing read model must be indistinguishable from an unmarked peer, so
// a storage blip cannot turn a peer response into an error.
func TestBotVerificationDegradesWhenServiceFails(t *testing.T) {
verify := newFakeBotVerifications()
verify.err = context.DeadlineExceeded
f := newBotVerificationFixture(t, verify)
if user := getUsersUser(t, f.router, f.owner.ID, f.target); user.Flags2.Has(14) {
t.Fatalf("failing service still set flags2.14: %032b", uint32(user.Flags2))
}
full, botInfo := getFullUserProjection(t, f.router, f.owner.ID, f.bot)
if full.Flags2.Has(12) {
t.Fatalf("failing service still set flags2.12: %032b", uint32(full.Flags2))
}
if botInfo == nil || botInfo.Flags.Has(9) {
t.Fatalf("failing service still set botInfo flags.9: %+v", botInfo)
}
}
// TestUsersGetUsersResolvesBotVerificationInOneBatch pins the absence of an N+1: the
// icon overlay runs once per response over the whole user set, never once per user.
func TestUsersGetUsersResolvesBotVerificationInOneBatch(t *testing.T) {
verify := newFakeBotVerifications()
f := newBotVerificationFixture(t, verify)
verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}] = domain.CustomVerification{
VerifierBotID: f.bot.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID},
IconDocumentID: 8800006, Description: "Verified by Acme Trust",
}
verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: f.stranger.ID}] = domain.CustomVerification{
VerifierBotID: f.bot.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.stranger.ID},
IconDocumentID: 8800007, Description: "Verified by Acme Trust",
}
verify.peerCalls = 0
verify.batchCalls = 0
out, err := f.router.onUsersGetUsers(WithUserID(context.Background(), f.owner.ID), []tg.InputUserClass{
&tg.InputUserSelf{},
&tg.InputUser{UserID: f.target.ID, AccessHash: f.target.AccessHash},
&tg.InputUser{UserID: f.stranger.ID, AccessHash: f.stranger.AccessHash},
})
if err != nil {
t.Fatalf("get users: %v", err)
}
if len(out) != 3 {
t.Fatalf("users = %d, want 3", len(out))
}
icons := map[int64]int64{}
for _, item := range out {
decoded := &tg.User{}
tlRoundTrip(t, item.(*tg.User), decoded)
if icon, ok := decoded.GetBotVerificationIcon(); ok {
if !decoded.Flags2.Has(14) {
t.Fatalf("icon set without flags2.14 on user %d: %032b", decoded.ID, uint32(decoded.Flags2))
}
icons[decoded.ID] = icon
}
}
if icons[f.target.ID] != 8800006 || icons[f.stranger.ID] != 8800007 {
t.Fatalf("projected icons = %v", icons)
}
if _, marked := icons[f.owner.ID]; marked {
t.Fatalf("unmarked self got an icon: %v", icons)
}
// Three users, one batch read: no N+1.
if verify.batchCalls != 1 || verify.peerCalls != 0 {
t.Fatalf("verification reads = batch %d / peer %d, want batch 1 / peer 0",
verify.batchCalls, verify.peerCalls)
}
}

View file

@ -0,0 +1,107 @@
package rpc
import (
"context"
"fmt"
"telesrv/internal/domain"
)
// NotifyPeerBotVerification is the protocol-edge hook invoked after a third-party
// verification mark has already committed -- from bots.setCustomVerification, or
// from an operator action on the same rows. It makes the new
// user#b1b8cc83 bot_verification_icon:flags2.14 /
// channel#d49f34c6 bot_verification_icon:flags2.13 (and the matching
// userFull/channelFull payload) observable without waiting for a cache TTL:
//
// - the cached peer projections for the target are dropped, so the next
// users.getFullUser / channels.getFullChannel rebuilds from the committed row;
// - online clients that already know the peer are pushed the ordinary, non-PTS
// refresh update (updateUser / updateChannel) together with the re-projected
// peer object, which is what makes the badge appear live in an official client.
//
// It is deliberately the same mechanism as NotifyPeerVerified, which is the same
// mechanism the scam/fake moderation flags use: the third-party mark is one more
// fact on the peer's base record, and inventing a verification-specific update
// would only add a second path that can drift. Nothing new is added to TL.
//
// The official verified flag is untouched here. That badge is granted by the
// operator alone (see verification_notify.go) and the two mechanisms never read or
// write each other's state.
//
// Offline sessions are not pushed to and do not need to be: the peer's base read
// model version is bumped by the users/channels triggers, so
// updates.getDifference and any later getUsers / getFullUser answer already carries
// the new mark.
//
// A push failure never invalidates the committed change, so callers log and swallow
// the returned error; this method therefore reports problems instead of panicking,
// and is safe on a nil receiver.
func (r *Router) NotifyPeerBotVerification(ctx context.Context, peer domain.Peer) error {
if r == nil {
return nil
}
if peer.ID <= 0 {
return fmt.Errorf("notify peer bot verification: invalid peer id %d", peer.ID)
}
switch peer.Type {
case domain.PeerTypeUser:
return r.notifyUserBotVerification(ctx, peer.ID)
case domain.PeerTypeChannel:
return r.notifyChannelBotVerification(ctx, peer.ID)
default:
return fmt.Errorf("notify peer bot verification: unsupported peer type %q for peer %d", peer.Type, peer.ID)
}
}
// notifyUserBotVerification covers ordinary accounts and bots alike: a marked bot is
// a user#b1b8cc83 with bot_verification_icon:flags2.14, so it takes the same
// audience-wide updateUser fan-out the moderation flags use (the peer itself plus
// every online account that already sees it).
func (r *Router) notifyUserBotVerification(ctx context.Context, userID int64) error {
// Invalidate first and unconditionally: a committed mark whose projection still
// says "unmarked" would keep serving the stale badge state even if the push below
// cannot run.
r.invalidateRPCProjectionForUser(userID)
if r.deps.Users == nil {
return nil
}
user, found, err := r.verificationUser(ctx, userID)
if err != nil {
return fmt.Errorf("notify peer bot verification: load user %d: %w", userID, err)
}
if !found || user.ID == 0 {
return fmt.Errorf("notify peer bot verification: user %d not found", userID)
}
// NotifyUserModerationFlagsChanged re-projects the peer per recipient, so the
// snapshot handed in only carries identity; the pushed tg.User is always built
// from a fresh read and therefore picks the icon up from the batch overlay.
return r.NotifyUserModerationFlagsChanged(ctx, user)
}
// notifyChannelBotVerification reuses the channel state-mutation path, which
// invalidates the channel projections (plus a linked monoforum's) and pushes
// updateChannel with the refreshed chat object to the channel's members.
func (r *Router) notifyChannelBotVerification(ctx context.Context, channelID int64) error {
r.invalidateRPCProjectionForChannel(channelID)
// The bot list cached for channelFull carries botInfo.verifier_settings, so a
// verifier's own status change has to drop it too; the channel projection cache
// is cleared by the same call.
r.invalidateChannelFullBotInfoCacheForChannel(channelID)
if r.deps.Channels == nil {
return nil
}
directory, ok := r.deps.Channels.(verificationChannelDirectory)
if !ok {
return fmt.Errorf("notify peer bot verification: channel service does not expose GetChannelByID")
}
channel, err := directory.GetChannelByID(ctx, channelID)
if err != nil {
return fmt.Errorf("notify peer bot verification: load channel %d: %w", channelID, err)
}
if channel.ID == 0 {
return fmt.Errorf("notify peer bot verification: channel %d not found", channelID)
}
// Same hook the admin panel uses for any other channel base fact.
return r.NotifyChannelChanged(ctx, channel)
}

View file

@ -0,0 +1,335 @@
package rpc
import (
"context"
"errors"
"strings"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
)
// TestNotifyPeerBotVerificationUserInvalidatesAndPushesUpdateUser is the bot/user
// half: a newly marked peer must reach every online account that already sees it,
// through the same non-PTS updateUser shape the scam/fake flags use, and the pushed
// tg.User must already carry bot_verification_icon:flags2.14.
func TestNotifyPeerBotVerificationUserInvalidatesAndPushesUpdateUser(t *testing.T) {
const (
shopID = int64(2200000022)
onlineViewerID = int64(1001)
offlineViewerID = int64(3003)
icon = int64(9900001)
)
users := &verifiedNotifyUsers{
user: domain.User{ID: shopID, FirstName: "Shop", AccessHash: 22},
found: true,
audience: []int64{shopID, onlineViewerID, offlineViewerID},
}
verify := newFakeBotVerifications()
verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: shopID}] = domain.CustomVerification{
VerifierBotID: 777000123, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: shopID},
IconDocumentID: icon, Description: "Verified by Acme Trust",
}
sessions := &captureSessions{onlineUserIDs: []int64{shopID, onlineViewerID}}
r := New(Config{}, Deps{Users: users, Sessions: sessions, BotVerifications: verify}, zap.NewNop(), clock.System)
seedUserFullProjection(t, r, onlineViewerID, shopID)
seedUserFullProjection(t, r, shopID, shopID)
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
Type: domain.PeerTypeUser, ID: shopID,
}); err != nil {
t.Fatalf("notify peer bot verification: %v", err)
}
if _, ok := r.userFullProjectionCache.Lookup(onlineViewerID, shopID); ok {
t.Fatal("viewer userFull projection survived the mark change")
}
if _, ok := r.userFullProjectionCache.Lookup(shopID, shopID); ok {
t.Fatal("target userFull projection survived the mark change")
}
if users.adminCalls != 1 {
t.Fatalf("authoritative account reads = %d, want 1", users.adminCalls)
}
// One read for the whole audience: the mark is a peer-wide fact, so the
// per-recipient push builder must not query it again for every recipient.
if verify.peerCalls != 1 || verify.batchCalls != 0 {
t.Fatalf("verification reads = peer %d / batch %d, want peer 1 / batch 0",
verify.peerCalls, verify.batchCalls)
}
// Offline audience members are skipped; they converge through the bumped peer
// read model on their next getDifference / getUsers.
pushed := sessions.pushedUserIDs()
if len(pushed) != 2 || pushed[0] != shopID || pushed[1] != onlineViewerID {
t.Fatalf("pushed user ids = %v", pushed)
}
updates, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("updates = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
}
refresh, ok := updates.Updates[0].(*tg.UpdateUser)
if !ok || refresh.UserID != shopID {
t.Fatalf("refresh = %T %+v", updates.Updates[0], updates.Updates[0])
}
if len(updates.Users) != 1 {
t.Fatalf("users = %+v", updates.Users)
}
pushedUser := &tg.User{}
tlRoundTrip(t, updates.Users[0].(*tg.User), pushedUser)
got, ok := pushedUser.GetBotVerificationIcon()
if !ok || got != icon {
t.Fatalf("pushed user bot_verification_icon = %d ok=%v, want %d on flags2.14", got, ok, icon)
}
if !pushedUser.Flags2.Has(14) {
t.Fatalf("pushed user flags2 = %032b, want bit 14", uint32(pushedUser.Flags2))
}
// The official checkmark is a separate mechanism and must not be implied.
if pushedUser.Verified || pushedUser.Scam || pushedUser.Fake {
t.Fatalf("mark push leaked moderation flags: %+v", pushedUser)
}
}
// TestNotifyPeerBotVerificationChannelInvalidatesAndPushesUpdateChannel is the
// channel half: the mark rides the existing channel state-mutation fan-out, and the
// pushed tg.Channel carries bot_verification_icon:flags2.13.
func TestNotifyPeerBotVerificationChannelInvalidatesAndPushesUpdateChannel(t *testing.T) {
const (
channelID = int64(4404)
ownerID = int64(3003)
memberID = int64(3004)
icon = int64(9900002)
)
channels := &verifiedNotifyChannels{channel: domain.Channel{
ID: channelID, AccessHash: 44, CreatorUserID: ownerID,
Title: "Partner", Username: "partner", Broadcast: true,
}}
verify := newFakeBotVerifications()
verify.marks[domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}] = domain.CustomVerification{
VerifierBotID: 777000123, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
IconDocumentID: icon, Description: "Verified by Acme Trust",
}
sessions := &captureSessions{
onlineUserIDs: []int64{ownerID, memberID},
channelMembers: map[int64][]int64{channelID: {ownerID, memberID}},
}
r := New(Config{}, Deps{Channels: channels, Sessions: sessions, BotVerifications: verify}, zap.NewNop(), clock.System)
seedChannelFullProjection(t, r, ownerID, channelID)
seedChannelFullProjection(t, r, memberID, channelID)
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
Type: domain.PeerTypeChannel, ID: channelID,
}); err != nil {
t.Fatalf("notify peer bot verification: %v", err)
}
if _, ok := r.channelFullProjectionCache.Lookup(ownerID, channelID); ok {
t.Fatal("owner channelFull projection survived the mark change")
}
if _, ok := r.channelFullProjectionCache.Lookup(memberID, channelID); ok {
t.Fatal("member channelFull projection survived the mark change")
}
if channels.calls != 1 {
t.Fatalf("base channel row reads = %d, want 1", channels.calls)
}
// Same contract on the channel fan-out: one read for every recipient plus the
// returned updates.
if verify.peerCalls != 1 || verify.batchCalls != 0 {
t.Fatalf("verification reads = peer %d / batch %d, want peer 1 / batch 0",
verify.peerCalls, verify.batchCalls)
}
if pushed := sessions.pushedUserIDs(); len(pushed) != 2 {
t.Fatalf("pushed user ids = %v", pushed)
}
updates, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("updates = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
}
refresh, ok := updates.Updates[0].(*tg.UpdateChannel)
if !ok || refresh.ChannelID != channelID {
t.Fatalf("refresh = %T %+v", updates.Updates[0], updates.Updates[0])
}
if len(updates.Chats) != 1 {
t.Fatalf("chats = %+v", updates.Chats)
}
pushedChannel := &tg.Channel{}
tlRoundTrip(t, updates.Chats[0].(*tg.Channel), pushedChannel)
got, ok := pushedChannel.GetBotVerificationIcon()
if !ok || got != icon {
t.Fatalf("pushed channel bot_verification_icon = %d ok=%v, want %d on flags2.13", got, ok, icon)
}
if !pushedChannel.Flags2.Has(13) {
t.Fatalf("pushed channel flags2 = %032b, want bit 13", uint32(pushedChannel.Flags2))
}
if pushedChannel.Verified || pushedChannel.Scam || pushedChannel.Fake {
t.Fatalf("mark push leaked moderation flags: %+v", pushedChannel)
}
}
// TestNotifyPeerBotVerificationDropsChannelBotInfoCache guards the one cache the
// user path does not have: channelFull.bot_info bakes botInfo.verifier_settings, so a
// verifier's own status change has to drop it or the profile keeps advertising the
// old state for the cache TTL.
func TestNotifyPeerBotVerificationDropsChannelBotInfoCache(t *testing.T) {
const channelID = int64(4405)
channels := &verifiedNotifyChannels{channel: domain.Channel{
ID: channelID, AccessHash: 45, CreatorUserID: 3003, Title: "Partner", Broadcast: true,
}}
r := New(Config{}, Deps{Channels: channels, Sessions: &captureSessions{}}, zap.NewNop(), clock.System)
epoch := r.channelFullBotCache.LoadEpoch()
r.channelFullBotCache.StoreIfEpoch(3003, channelID, channelFullBotInfoResult{
userIDs: []int64{777000123},
botInfos: []tg.BotInfo{{}},
}, epoch)
if _, ok := r.channelFullBotCache.Lookup(3003, channelID); !ok {
t.Fatal("seed channelFull bot info cache")
}
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
Type: domain.PeerTypeChannel, ID: channelID,
}); err != nil {
t.Fatalf("notify peer bot verification: %v", err)
}
if _, ok := r.channelFullBotCache.Lookup(3003, channelID); ok {
t.Fatal("channelFull bot info cache survived the mark change")
}
}
// TestNotifyPeerBotVerificationNilRouterIsSafe pins the nil-receiver contract: the
// hook runs after the change already committed, so it may never panic.
func TestNotifyPeerBotVerificationNilRouterIsSafe(t *testing.T) {
var r *Router
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
Type: domain.PeerTypeUser, ID: 1001,
}); err != nil {
t.Fatalf("nil router notify = %v", err)
}
}
// TestNotifyPeerBotVerificationWithoutServicesStillInvalidates covers degraded
// wiring: with no user/channel service the hook cannot push, but the stale
// projection must still be dropped and no error reported.
func TestNotifyPeerBotVerificationWithoutServicesStillInvalidates(t *testing.T) {
r := New(Config{}, Deps{}, zap.NewNop(), clock.System)
seedUserFullProjection(t, r, 1001, 2002)
seedChannelFullProjection(t, r, 1001, 4004)
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
Type: domain.PeerTypeUser, ID: 2002,
}); err != nil {
t.Fatalf("notify user without users service = %v", err)
}
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
Type: domain.PeerTypeChannel, ID: 4004,
}); err != nil {
t.Fatalf("notify channel without channels service = %v", err)
}
if _, ok := r.userFullProjectionCache.Lookup(1001, 2002); ok {
t.Fatal("userFull projection survived without a users service")
}
if _, ok := r.channelFullProjectionCache.Lookup(1001, 4004); ok {
t.Fatal("channelFull projection survived without a channels service")
}
}
// TestNotifyPeerBotVerificationRejectsUnknownPeers pins the "explain, never panic"
// contract for every peer the hook cannot act on.
func TestNotifyPeerBotVerificationRejectsUnknownPeers(t *testing.T) {
users := &verifiedNotifyUsers{}
channels := &verifiedNotifyChannels{}
sessions := &captureSessions{}
r := New(Config{}, Deps{Users: users, Channels: channels, Sessions: sessions}, zap.NewNop(), clock.System)
ctx := context.Background()
for _, tc := range []struct {
name string
peer domain.Peer
want string
}{
{"zero id", domain.Peer{Type: domain.PeerTypeUser}, "invalid peer id"},
{"negative id", domain.Peer{Type: domain.PeerTypeChannel, ID: -1}, "invalid peer id"},
{"community peer", domain.Peer{Type: domain.PeerTypeCommunity, ID: 5005}, "unsupported peer type"},
{"empty type", domain.Peer{ID: 5005}, "unsupported peer type"},
{"missing user", domain.Peer{Type: domain.PeerTypeUser, ID: 2002}, "user 2002 not found"},
{"missing channel", domain.Peer{Type: domain.PeerTypeChannel, ID: 4004}, "channel 4004 not found"},
} {
err := r.NotifyPeerBotVerification(ctx, tc.peer)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s: err = %v, want mention of %q", tc.name, err, tc.want)
}
if err != nil && !strings.Contains(err.Error(), "notify peer bot verification") {
t.Fatalf("%s: err = %v, want the bot-verification hook named", tc.name, err)
}
}
if pushed := sessions.pushedUserIDs(); len(pushed) != 0 {
t.Fatalf("unresolved peers pushed updates to %v", pushed)
}
}
// TestNotifyPeerBotVerificationReportsLookupFailures keeps a directory error distinct
// from "peer not found", and keeps a channels adapter without the base-row reader
// from failing silently.
func TestNotifyPeerBotVerificationReportsLookupFailures(t *testing.T) {
ctx := context.Background()
loadErr := errors.New("boom")
failing := New(Config{}, Deps{
Channels: &verifiedNotifyChannels{err: loadErr},
Sessions: &captureSessions{},
}, zap.NewNop(), clock.System)
if err := failing.NotifyPeerBotVerification(ctx, domain.Peer{
Type: domain.PeerTypeChannel, ID: 4004,
}); !errors.Is(err, loadErr) {
t.Fatalf("channel load error = %v", err)
}
unwired := New(Config{}, Deps{
Channels: channelsWithoutDirectory{},
Sessions: &captureSessions{},
}, zap.NewNop(), clock.System)
err := unwired.NotifyPeerBotVerification(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: 4004})
if err == nil || !strings.Contains(err.Error(), "GetChannelByID") {
t.Fatalf("missing channel directory error = %v", err)
}
}
// TestSetCustomVerificationNotifiesAudience closes the loop: a successful
// bots.setCustomVerification must itself drop the projections and push the refresh,
// so the badge appears in a running client without a second RPC.
func TestSetCustomVerificationNotifiesAudience(t *testing.T) {
fake := newFakeBotVerifications()
f := newBotVerificationFixture(t, fake)
// Production wiring: the service holds the router as its PeerNotifier, so the
// push happens once, in the service, for every driver of a mark change.
fake.notifier = f.router
f.enableVerifier(f.bot.ID, 9900003, true)
sessions := &captureSessions{onlineUserIDs: []int64{f.owner.ID, f.target.ID}}
f.router.deps.Sessions = sessions
seedUserFullProjection(t, f.router, f.owner.ID, f.target.ID)
ok, err := f.router.onBotsSetCustomVerification(WithUserID(context.Background(), f.owner.ID),
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if err != nil || !ok {
t.Fatalf("grant = %v,%v, want true,nil", ok, err)
}
if _, cached := f.router.userFullProjectionCache.Lookup(f.owner.ID, f.target.ID); cached {
t.Fatal("userFull projection survived the grant")
}
if pushed := sessions.pushedUserIDs(); len(pushed) == 0 {
t.Fatal("grant pushed no refresh update")
}
updates, isUpdates := sessions.lastUserPush().(*tg.Updates)
if !isUpdates || len(updates.Users) == 0 {
t.Fatalf("push = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
}
pushedUser := &tg.User{}
tlRoundTrip(t, updates.Users[0].(*tg.User), pushedUser)
if icon, set := pushedUser.GetBotVerificationIcon(); !set || icon != 9900003 {
t.Fatalf("pushed user icon = %d set=%v, want 9900003 on flags2.14", icon, set)
}
}

View file

@ -0,0 +1,314 @@
package rpc
import (
"context"
"strings"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
// Third-party bot verification on the protocol edge
// (core.telegram.org/api/bots/verification).
//
// Layer 228 spreads one fact -- "verifier bot B marked peer P with icon I and
// description D" -- over six unrelated constructors, and an official client only
// renders the badge when the exact bit is set:
//
// user#b1b8cc83 bot_verification_icon:flags2.14?long
// channel#d49f34c6 bot_verification_icon:flags2.13?long
// userFull#6cbe645 bot_verification:flags2.12?BotVerification
// channelFull#a04e8d3a bot_verification:flags2.17?BotVerification
// chatInvite#5c9d3702 bot_verification:flags.13?BotVerification
// botInfo#4d8a0299 verifier_settings:flags.9?BotVerifierSettings
//
// Every projection here goes through the generated Set* helpers rather than a raw
// field assignment, because the flags word is what the client reads: a struct field
// set without its bit encodes as an absent field and the badge silently disappears.
//
// Deps.BotVerifications is the single source. Nothing in this file recomputes the
// mark, and nothing derives it from the official verified flag (user flags.17 /
// channel flags.7) -- that is a separate operator-granted mechanism, and mixing the
// two would let a third-party verifier mint platform checkmarks.
// tgBotVerification projects domain.BotVerification onto botVerification#f93cd45c.
//
// The bool reports whether the payload is renderable at all: the icon is a custom
// emoji document id resolved through messages.getCustomEmojiDocuments, so a zero
// icon (or an unattributed mark) would encode a badge the client draws as nothing.
// Such a mark is omitted instead of shipped half-formed.
func tgBotVerification(in domain.BotVerification) (tg.BotVerification, bool) {
if in.BotID <= 0 || in.Icon <= 0 {
return tg.BotVerification{}, false
}
return tg.BotVerification{
BotID: in.BotID,
Icon: in.Icon,
Description: in.Description,
}, true
}
// tgBotVerifierSettings projects domain.BotVerifierSettings onto
// botVerifierSettings#b0cd6617.
//
// custom_description:flags.0 carries the operator default description (the text the
// verifier applies when it supplies none per peer);
// can_modify_custom_description:flags.1 is the permission that lets the verifier
// override it. A configuration that does not validate is omitted entirely rather
// than encoded with an empty company, which clients render as a blank badge sheet.
func tgBotVerifierSettings(in domain.BotVerifierSettings) (tg.BotVerifierSettings, bool) {
if err := in.Validate(); err != nil {
return tg.BotVerifierSettings{}, false
}
out := tg.BotVerifierSettings{
Icon: in.IconDocumentID,
Company: strings.TrimSpace(in.CompanyName),
}
if in.CanModifyCustomDescription {
out.SetCanModifyCustomDescription(true)
}
if desc := strings.TrimSpace(in.DefaultDescription); desc != "" {
out.SetCustomDescription(desc)
}
return out, true
}
// applyBotVerificationIconsToPeerObjects overlays user#b1b8cc83
// bot_verification_icon:flags2.14 and channel#d49f34c6
// bot_verification_icon:flags2.13 onto already-projected peer objects.
//
// It mirrors applyUsernamesToPeerObjects exactly, including why it is an overlay:
// tgUser/tgChannel run inside per-id loops (users.getUsers, dialog lists), so a
// per-object read there would be the N+1 this pass exists to avoid. Running at the
// response boundary keeps every one of the ~90 plain projection call sites
// untouched and still reaches them all.
//
// A nil service or any read error is a silent no-op: the encoded peer then stays
// byte-identical to the pre-feature shape.
func (r *Router) applyBotVerificationIconsToPeerObjects(ctx context.Context, users []tg.UserClass, chats []tg.ChatClass) {
if r.deps.BotVerifications == nil || len(users)+len(chats) == 0 {
return
}
peers := make([]domain.Peer, 0, len(users)+len(chats))
seen := make(map[domain.Peer]struct{}, len(users)+len(chats))
addPeer := func(peer domain.Peer) {
if peer.ID == 0 {
return
}
if _, ok := seen[peer]; ok {
return
}
seen[peer] = struct{}{}
peers = append(peers, peer)
}
for _, item := range users {
if u, ok := item.(*tg.User); ok && u != nil {
addPeer(domain.Peer{Type: domain.PeerTypeUser, ID: u.ID})
}
}
for _, item := range chats {
if ch, ok := item.(*tg.Channel); ok && ch != nil {
addPeer(domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID})
}
}
if len(peers) == 0 {
return
}
byPeer := r.botVerificationMap(ctx, peers)
if len(byPeer) == 0 {
return
}
for _, item := range users {
u, ok := item.(*tg.User)
if !ok || u == nil {
continue
}
mark, ok := byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}]
if !ok || mark.IconDocumentID <= 0 {
continue
}
u.SetBotVerificationIcon(mark.IconDocumentID)
}
for _, item := range chats {
ch, ok := item.(*tg.Channel)
if !ok || ch == nil {
continue
}
mark, ok := byPeer[domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}]
if !ok || mark.IconDocumentID <= 0 {
continue
}
ch.SetBotVerificationIcon(mark.IconDocumentID)
}
}
// botVerificationMap loads the marks for the given peers. One peer goes through
// PeerVerification so a single-object projection does not pay for a batch round
// trip; anything larger goes through PeerVerificationBatch, so a list response
// costs one query regardless of length. Any error yields an empty map, which every
// caller treats as "no badge".
func (r *Router) botVerificationMap(ctx context.Context, peers []domain.Peer) map[domain.Peer]domain.CustomVerification {
if r.deps.BotVerifications == nil || len(peers) == 0 {
return nil
}
if len(peers) == 1 {
mark, err := r.deps.BotVerifications.PeerVerification(ctx, peers[0])
if err != nil || mark.IconDocumentID <= 0 {
return nil
}
return map[domain.Peer]domain.CustomVerification{peers[0]: mark}
}
byPeer, err := r.deps.BotVerifications.PeerVerificationBatch(ctx, peers)
if err != nil {
return nil
}
return byPeer
}
// peerBotVerificationIcon resolves just the icon for one peer, for the update
// fan-outs. They build one peer object per recipient from the same peer-wide fact,
// so they resolve it once with this and then stamp it with the helpers below rather
// than reading inside their per-recipient builder. Zero means "no mark", which
// leaves the flag unset.
func (r *Router) peerBotVerificationIcon(ctx context.Context, peer domain.Peer) int64 {
if r.deps.BotVerifications == nil || peer.ID <= 0 {
return 0
}
mark, err := r.deps.BotVerifications.PeerVerification(ctx, peer)
if err != nil {
return 0
}
return mark.IconDocumentID
}
// applyBotVerificationIconToUsers stamps an already-resolved icon onto the matching
// user object of one push (user#b1b8cc83 bot_verification_icon:flags2.14).
func applyBotVerificationIconToUsers(users []tg.UserClass, userID, icon int64) {
if icon <= 0 || userID <= 0 {
return
}
for _, item := range users {
if u, ok := item.(*tg.User); ok && u != nil && u.ID == userID {
u.SetBotVerificationIcon(icon)
}
}
}
// applyBotVerificationIconToChannelChats is the channel counterpart
// (channel#d49f34c6 bot_verification_icon:flags2.13).
func applyBotVerificationIconToChannelChats(chats []tg.ChatClass, channelID, icon int64) {
if icon <= 0 || channelID <= 0 {
return
}
for _, item := range chats {
if ch, ok := item.(*tg.Channel); ok && ch != nil && ch.ID == channelID {
ch.SetBotVerificationIcon(icon)
}
}
}
// peerBotVerification resolves the single-peer projection payload.
func (r *Router) peerBotVerification(ctx context.Context, peer domain.Peer) (tg.BotVerification, bool) {
if r.deps.BotVerifications == nil || peer.ID <= 0 {
return tg.BotVerification{}, false
}
mark, err := r.deps.BotVerifications.PeerVerification(ctx, peer)
if err != nil {
// Includes domain.ErrCustomVerificationNotFound: an unmarked peer simply has
// no badge to show.
return tg.BotVerification{}, false
}
return tgBotVerification(mark.Projection())
}
// applyBotVerificationToUserFull sets userFull#6cbe645
// bot_verification:flags2.12.
//
// It runs as a post-cache overlay on both users.getFullUser paths (cache hit and
// fresh build) and clears the bit first, so a revoked mark disappears on the next
// response instead of surviving inside the per-(viewer,target) projection cache
// TTL. The payload is viewer-independent by construction: a badge granted by a
// verifier is a property of the peer, not of who is looking.
func (r *Router) applyBotVerificationToUserFull(ctx context.Context, userID int64, full *tg.UserFull) {
if r.deps.BotVerifications == nil || full == nil || userID <= 0 {
return
}
full.Flags2.Unset(12)
full.BotVerification = tg.BotVerification{}
if value, ok := r.peerBotVerification(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}); ok {
full.SetBotVerification(value)
}
}
// applyBotVerificationToChannelFull sets channelFull#a04e8d3a
// bot_verification:flags2.17, on the same post-cache overlay contract as
// applyBotVerificationToUserFull.
func (r *Router) applyBotVerificationToChannelFull(ctx context.Context, channelID int64, full *tg.ChannelFull) {
if r.deps.BotVerifications == nil || full == nil || channelID <= 0 {
return
}
full.Flags2.Unset(17)
full.BotVerification = tg.BotVerification{}
if value, ok := r.peerBotVerification(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}); ok {
full.SetBotVerification(value)
}
}
// applyBotVerificationToChatInvite sets chatInvite#5c9d3702
// bot_verification:flags.13.
//
// A non-member sees only this preview, so the badge has to be visible here for the
// same reason verified/scam/fake are (applyChatInviteModerationFlags): a mark that
// appears only after joining is exactly backwards.
func (r *Router) applyBotVerificationToChatInvite(ctx context.Context, invite *tg.ChatInvite, channelID int64) {
if r.deps.BotVerifications == nil || invite == nil || channelID <= 0 {
return
}
if value, ok := r.peerBotVerification(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}); ok {
invite.SetBotVerification(value)
}
}
// applyVerifierSettingsToBotInfo sets botInfo#4d8a0299
// verifier_settings:flags.9 -- the block a client shows inside a verifier bot's
// own profile, and what tells it the bot may verify others.
//
// The operator kill switch is honoured here: a disabled verifier keeps its row and
// the marks it already granted, but stops advertising itself as a verifier, so the
// client stops offering the verification UI.
func applyVerifierSettingsToBotInfo(info *tg.BotInfo, botUserID int64, settings domain.BotVerifierSettings) {
if info == nil || botUserID <= 0 || !settings.Enabled || settings.BotID != botUserID {
return
}
if value, ok := tgBotVerifierSettings(settings); ok {
info.SetVerifierSettings(value)
}
}
// applyVerifierSettingsToOneBotInfo is the single-bot path (userFull.bot_info).
func (r *Router) applyVerifierSettingsToOneBotInfo(ctx context.Context, info *tg.BotInfo, botUserID int64) {
if r.deps.BotVerifications == nil || info == nil || botUserID <= 0 {
return
}
settings, err := r.deps.BotVerifications.VerifierSettings(ctx, botUserID)
if err != nil {
// Includes domain.ErrVerifierNotFound: an ordinary bot is not a verifier.
return
}
applyVerifierSettingsToBotInfo(info, botUserID, settings)
}
// verifierSettingsBatch resolves verifier status for several bots at once, next to
// the profile batch resolver tgBotInfos already uses, so channelFull.bot_info costs
// one settings query for the whole bot list.
func (r *Router) verifierSettingsBatch(ctx context.Context, botUserIDs []int64) map[int64]domain.BotVerifierSettings {
if r.deps.BotVerifications == nil || len(botUserIDs) == 0 {
return nil
}
settings, err := r.deps.BotVerifications.VerifierSettingsBatch(ctx, botUserIDs)
if err != nil {
return nil
}
return settings
}

View file

@ -0,0 +1,537 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
botsapp "telesrv/internal/app/bots"
appchannels "telesrv/internal/app/channels"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// fakeBotVerifications is an in-memory BotVerificationService. It enforces the same
// domain rules the real service must (verifier status, the operator kill switch,
// domain.BotVerifierSettings.DescriptionFor, the per-verifier bound and
// idempotent mutations), so the RPC tests exercise the real error mapping
// instead of hand-rolled sentinels.
type fakeBotVerifications struct {
marks map[domain.Peer]domain.CustomVerification
settings map[int64]domain.BotVerifierSettings
// limit, when positive, bounds how many peers one verifier may mark.
limit int
// err, when set, fails every read. Used for the degradation tests.
err error
// Call counters let the tests assert the batch fan-out (no N+1).
peerCalls int
batchCalls int
settingsCalls int
settingsBatchCalls int
setCalls int
// notifier mirrors production wiring: the application service owns the badge
// push for all three drivers (this RPC, the bot dialog, the admin panel), so the
// fake pushes here exactly where the real service does.
notifier interface {
NotifyPeerBotVerification(ctx context.Context, peer domain.Peer) error
}
}
func newFakeBotVerifications() *fakeBotVerifications {
return &fakeBotVerifications{
marks: make(map[domain.Peer]domain.CustomVerification),
settings: make(map[int64]domain.BotVerifierSettings),
}
}
func (f *fakeBotVerifications) PeerVerification(_ context.Context, peer domain.Peer) (domain.CustomVerification, error) {
f.peerCalls++
if f.err != nil {
return domain.CustomVerification{}, f.err
}
mark, ok := f.marks[peer]
if !ok {
return domain.CustomVerification{}, domain.ErrCustomVerificationNotFound
}
return mark, nil
}
func (f *fakeBotVerifications) PeerVerificationBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer]domain.CustomVerification, error) {
f.batchCalls++
if f.err != nil {
return nil, f.err
}
out := make(map[domain.Peer]domain.CustomVerification, len(peers))
for _, peer := range peers {
if mark, ok := f.marks[peer]; ok {
out[peer] = mark
}
}
return out, nil
}
func (f *fakeBotVerifications) VerifierSettings(_ context.Context, botID int64) (domain.BotVerifierSettings, error) {
f.settingsCalls++
if f.err != nil {
return domain.BotVerifierSettings{}, f.err
}
settings, ok := f.settings[botID]
if !ok {
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
}
return settings, nil
}
func (f *fakeBotVerifications) VerifierSettingsBatch(_ context.Context, botIDs []int64) (map[int64]domain.BotVerifierSettings, error) {
f.settingsBatchCalls++
if f.err != nil {
return nil, f.err
}
out := make(map[int64]domain.BotVerifierSettings, len(botIDs))
for _, id := range botIDs {
if settings, ok := f.settings[id]; ok {
out[id] = settings
}
}
return out, nil
}
func (f *fakeBotVerifications) SetCustomVerification(_ context.Context, req domain.SetCustomVerificationRequest) (bool, error) {
f.setCalls++
if f.err != nil {
return false, f.err
}
if err := req.Validate(); err != nil {
return false, err
}
settings, ok := f.settings[req.VerifierBotID]
if !ok {
return false, domain.ErrVerifierNotFound
}
if !settings.Enabled {
return false, domain.ErrVerifierForbidden
}
existing, marked := f.marks[req.Peer]
if !req.Enabled {
// A revoke only touches this verifier's own mark, and a repeated revoke is a
// no-op rather than an error.
if !marked || existing.VerifierBotID != req.VerifierBotID {
return false, nil
}
delete(f.marks, req.Peer)
f.notify(req.Peer)
return true, nil
}
description, err := settings.DescriptionFor(req.CustomDescription)
if err != nil {
return false, err
}
if marked && existing.VerifierBotID == req.VerifierBotID &&
existing.IconDocumentID == settings.IconDocumentID && existing.Description == description {
return false, nil
}
if f.limit > 0 && !marked && f.countFor(req.VerifierBotID) >= f.limit {
return false, domain.ErrCustomVerificationLimit
}
f.marks[req.Peer] = domain.CustomVerification{
VerifierBotID: req.VerifierBotID,
Peer: req.Peer,
IconDocumentID: settings.IconDocumentID,
Description: description,
GrantedByUserID: req.CallerUserID,
}
f.notify(req.Peer)
return true, nil
}
// notify reproduces the service's post-commit push.
func (f *fakeBotVerifications) notify(peer domain.Peer) {
if f.notifier == nil {
return
}
_ = f.notifier.NotifyPeerBotVerification(context.Background(), peer)
}
func (f *fakeBotVerifications) countFor(verifierBotID int64) int {
n := 0
for _, mark := range f.marks {
if mark.VerifierBotID == verifierBotID {
n++
}
}
return n
}
var _ BotVerificationService = (*fakeBotVerifications)(nil)
// botVerificationFixture wires the real users/bots/channels services next to the
// fake verification service, so the ownership checks bots.setCustomVerification
// depends on are the production ones.
type botVerificationFixture struct {
router *Router
verify *fakeBotVerifications
bots *botsapp.Service
owner domain.User
stranger domain.User
target domain.User
bot domain.User
foreign domain.User
}
func newBotVerificationFixture(t *testing.T, verify BotVerificationService) botVerificationFixture {
t.Helper()
ctx := context.Background()
userStore := memory.NewUserStore()
botStore := memory.NewBotStore(userStore)
dialogs := memory.NewDialogStore()
messageStore := memory.NewMessageStore(dialogs)
bots := botsapp.NewService(userStore, botStore, messageStore)
channelStore := memory.NewChannelStore()
channels := appchannels.NewService(channelStore, appchannels.WithBotProfileResolver(bots))
owner, err := userStore.Create(ctx, domain.User{AccessHash: 9101, Phone: "15550009101", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
stranger, err := userStore.Create(ctx, domain.User{AccessHash: 9102, Phone: "15550009102", FirstName: "Stranger"})
if err != nil {
t.Fatalf("create stranger: %v", err)
}
target, err := userStore.Create(ctx, domain.User{AccessHash: 9103, Phone: "15550009103", FirstName: "Target", Username: "target_shop"})
if err != nil {
t.Fatalf("create target: %v", err)
}
bot, _, err := bots.CreateBot(ctx, owner.ID, "Verifier Bot", "verifier_shape_bot")
if err != nil {
t.Fatalf("create verifier bot: %v", err)
}
foreign, _, err := bots.CreateBot(ctx, stranger.ID, "Foreign Bot", "foreign_shape_bot")
if err != nil {
t.Fatalf("create foreign bot: %v", err)
}
fake, _ := verify.(*fakeBotVerifications)
deps := Deps{
Users: appusers.NewService(userStore),
Bots: bots,
Channels: channels,
}
if verify != nil {
deps.BotVerifications = verify
}
return botVerificationFixture{
router: New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, deps, zaptest.NewLogger(t), clock.System),
verify: fake,
bots: bots,
owner: owner,
stranger: stranger,
target: target,
bot: bot,
foreign: foreign,
}
}
// enableVerifier grants the fake verifier status the way the operator would.
func (f botVerificationFixture) enableVerifier(botID, icon int64, canModifyDescription bool) {
f.verify.settings[botID] = domain.BotVerifierSettings{
BotID: botID,
IconDocumentID: icon,
CompanyName: "Acme Trust",
DefaultDescription: "Verified by Acme Trust",
CanModifyCustomDescription: canModifyDescription,
Enabled: true,
}
}
func setCustomVerificationRequest(peer tg.InputPeerClass, bot tg.InputUserClass, enabled bool, description string) *tg.BotsSetCustomVerificationRequest {
req := &tg.BotsSetCustomVerificationRequest{Peer: peer}
if bot != nil {
req.SetBot(bot)
}
req.SetEnabled(enabled)
if description != "" {
req.SetCustomDescription(description)
}
return req
}
// TestBotsSetCustomVerificationOwnerGrantsAndRevokes is the main happy path: the
// owner of a verifier bot marks a peer, the repeat call remains successful, and
// the revoke removes exactly that mark.
//
// The Bool result is asserted rather than checking only err == nil: official
// clients require BoolTrue even when the requested state was already applied.
func TestBotsSetCustomVerificationOwnerGrantsAndRevokes(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550001, true)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if err != nil || !ok {
t.Fatalf("owner grant = %v,%v, want true,nil", ok, err)
}
mark, exists := f.verify.marks[peer]
if !exists {
t.Fatalf("grant stored nothing: %+v", f.verify.marks)
}
if mark.VerifierBotID != f.bot.ID || mark.IconDocumentID != 5550001 {
t.Fatalf("stored mark = %+v, want verifier %d icon 5550001", mark, f.bot.ID)
}
if mark.Description != "Verified by Acme Trust" {
t.Fatalf("stored description = %q, want the verifier default", mark.Description)
}
if mark.GrantedByUserID != f.owner.ID {
t.Fatalf("granted by = %d, want calling owner %d", mark.GrantedByUserID, f.owner.ID)
}
// The method reports successful processing, including an idempotent replay.
// Official clients treat BoolFalse as failure and do not infer "unchanged".
repeat, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if err != nil || !repeat {
t.Fatalf("repeated grant = %v,%v, want true,nil", repeat, err)
}
revoked, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), false, ""))
if err != nil || !revoked {
t.Fatalf("revoke = %v,%v, want true,nil", revoked, err)
}
if _, exists := f.verify.marks[peer]; exists {
t.Fatalf("revoke left the mark behind: %+v", f.verify.marks)
}
repeatRevoke, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), false, ""))
if err != nil || !repeatRevoke {
t.Fatalf("repeated revoke = %v,%v, want true,nil", repeatRevoke, err)
}
}
// TestBotsSetCustomVerificationBotCallerActsAsItself covers the other caller the TL
// constructor allows: a bot invoking without bot:flags.0, which must resolve to the
// calling bot's own id.
func TestBotsSetCustomVerificationBotCallerActsAsItself(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550002, true)
botCtx := WithUserID(context.Background(), f.bot.ID)
ok, err := f.router.onBotsSetCustomVerification(botCtx,
setCustomVerificationRequest(inputPeerUser(f.target), nil, true, ""))
if err != nil || !ok {
t.Fatalf("bot self grant = %v,%v, want true,nil", ok, err)
}
mark := f.verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}]
if mark.VerifierBotID != f.bot.ID {
t.Fatalf("verifier id = %d, want the calling bot %d", mark.VerifierBotID, f.bot.ID)
}
if mark.GrantedByUserID != f.bot.ID {
t.Fatalf("granted by = %d, want the calling bot %d", mark.GrantedByUserID, f.bot.ID)
}
}
// TestBotsSetCustomVerificationRejectsForeignBot pins the ownership boundary: naming
// somebody else's bot is BOT_INVALID, and nothing reaches the service.
func TestBotsSetCustomVerificationRejectsForeignBot(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.foreign.ID, 5550003, true)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.foreign), true, ""))
if ok || !tgerr.Is(err, "BOT_INVALID") {
t.Fatalf("foreign bot = %v,%v, want false,BOT_INVALID", ok, err)
}
if f.verify.setCalls != 0 {
t.Fatalf("foreign bot reached the service %d times", f.verify.setCalls)
}
if len(f.verify.marks) != 0 {
t.Fatalf("foreign bot minted a mark: %+v", f.verify.marks)
}
}
// TestBotsSetCustomVerificationRejectsNonVerifier covers a bot the operator never
// granted verifier status, and a verifier the operator switched off: both are
// BOT_VERIFIER_FORBIDDEN, because in both cases the bot may not verify anything.
func TestBotsSetCustomVerificationRejectsNonVerifier(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
ownerCtx := WithUserID(context.Background(), f.owner.ID)
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("non-verifier = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
disabled := f.verify.settings
f.enableVerifier(f.bot.ID, 5550004, true)
settings := disabled[f.bot.ID]
settings.Enabled = false
disabled[f.bot.ID] = settings
ok, err = f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("disabled verifier = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
if len(f.verify.marks) != 0 {
t.Fatalf("forbidden verifier minted a mark: %+v", f.verify.marks)
}
}
// TestBotsSetCustomVerificationRejectsInvalidPeer covers both invalid-target paths:
// an unresolvable inputPeer at the edge, and a peer the domain refuses to verify.
// Both must read PEER_ID_INVALID to a client -- the verifier is fine, the target is
// not.
func TestBotsSetCustomVerificationRejectsInvalidPeer(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550005, true)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(&tg.InputPeerEmpty{}, inputUser(f.bot), true, ""))
if ok || !tgerr.Is(err, "PEER_ID_INVALID") {
t.Fatalf("empty peer = %v,%v, want false,PEER_ID_INVALID", ok, err)
}
if f.verify.setCalls != 0 {
t.Fatalf("empty peer reached the service %d times", f.verify.setCalls)
}
// A stored-state rejection maps to the same code, so a client cannot tell the
// two apart and cannot probe which peers exist.
f.verify.err = domain.ErrCustomVerificationTargetInvalid
ok, err = f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if ok || !tgerr.Is(err, "PEER_ID_INVALID") {
t.Fatalf("domain-rejected peer = %v,%v, want false,PEER_ID_INVALID", ok, err)
}
}
// TestBotsSetCustomVerificationRejectsForbiddenDescription pins the
// can_modify_custom_description permission: a verifier that may only apply the
// operator default is refused when it supplies its own text, and the refusal is a
// documented BOT_VERIFIER_FORBIDDEN error.
func TestBotsSetCustomVerificationRejectsForbiddenDescription(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550006, false)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "Hand-written blurb"))
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("forbidden description = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
if code, _ := tgerr.AsType(err, "BOT_VERIFIER_FORBIDDEN"); code == nil || code.Code != 403 {
t.Fatalf("forbidden description error = %+v, want code 403", code)
}
if len(f.verify.marks) != 0 {
t.Fatalf("rejected description still minted a mark: %+v", f.verify.marks)
}
// Without the description the very same call goes through with the default text.
ok, err = f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if err != nil || !ok {
t.Fatalf("default description = %v,%v, want true,nil", ok, err)
}
if got := f.verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}].Description; got != "Verified by Acme Trust" {
t.Fatalf("stored description = %q, want the operator default", got)
}
// A verifier that IS allowed to override stores its own text.
f.enableVerifier(f.bot.ID, 5550006, true)
ok, err = f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "Official reseller"))
if err != nil || !ok {
t.Fatalf("custom description = %v,%v, want true,nil", ok, err)
}
if got := f.verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}].Description; got != "Official reseller" {
t.Fatalf("stored description = %q, want the verifier text", got)
}
}
// TestBotsSetCustomVerificationRejectsRevokeWithDescription pins the edge-side shape
// validation: a revoke that still carries a description is a caller bug (usually a
// forgotten enabled flag) and is reported rather than silently reinterpreted.
func TestBotsSetCustomVerificationRejectsRevokeWithDescription(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550007, true)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), false, "why"))
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("revoke with description = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
if f.verify.setCalls != 0 {
t.Fatalf("malformed request reached the service %d times", f.verify.setCalls)
}
}
// TestBotsSetCustomVerificationReportsLimit keeps the local per-verifier bound
// behind the only applicable documented method error.
func TestBotsSetCustomVerificationReportsLimit(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550008, true)
f.verify.limit = 1
ownerCtx := WithUserID(context.Background(), f.owner.ID)
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "")); err != nil || !ok {
t.Fatalf("first grant = %v,%v, want true,nil", ok, err)
}
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.stranger), inputUser(f.bot), true, ""))
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("over-limit grant = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
}
func TestBotsSetCustomVerificationRejectsNilRequest(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
ok, err := f.router.onBotsSetCustomVerification(
WithUserID(context.Background(), f.owner.ID),
nil,
)
if ok || !tgerr.Is(err, "PEER_ID_INVALID") {
t.Fatalf("nil request = %v,%v, want false,PEER_ID_INVALID", ok, err)
}
}
// TestBotsSetCustomVerificationWithoutServiceStaysForbidden is the degradation half
// of the RPC: with no verification service wired no bot can be a verifier, so the
// answer must be exactly what it was before the feature existed.
func TestBotsSetCustomVerificationWithoutServiceStaysForbidden(t *testing.T) {
f := newBotVerificationFixture(t, nil)
if f.router.deps.BotVerifications != nil {
t.Fatal("fixture wired a verification service, want nil")
}
ownerCtx := WithUserID(context.Background(), f.owner.ID)
botCtx := WithUserID(context.Background(), f.bot.ID)
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "")); ok ||
!tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("owner call without service = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
if ok, err := f.router.onBotsSetCustomVerification(botCtx,
setCustomVerificationRequest(inputPeerUser(f.target), nil, true, "")); ok ||
!tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("bot call without service = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
// The ownership and peer checks still run first, so their codes are unchanged too.
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.foreign), true, "")); ok ||
!tgerr.Is(err, "BOT_INVALID") {
t.Fatalf("foreign bot without service = %v,%v, want false,BOT_INVALID", ok, err)
}
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(&tg.InputPeerEmpty{}, inputUser(f.bot), true, "")); ok ||
!tgerr.Is(err, "PEER_ID_INVALID") {
t.Fatalf("empty peer without service = %v,%v, want false,PEER_ID_INVALID", ok, err)
}
}

View file

@ -61,6 +61,25 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
}
botUserID := callback.BotUserID
// 内置(进程内)service bot 分支:@verifybot 这类 bot 没有 MTProto session、也没有
// Bot API 消费者,走下面的「推 updateBotCallbackQuery + 挂起 25s」必然超时回
// BOT_RESPONSE_TIMEOUT。因此在 registerContext / 推送之前同步问 responder:点击本身
// 已由 resolveBotCallbackQuery 校验过(消息在请求者自己的盒里、对端正是该 bot、data
// 确实出现在该消息的 inline keyboard 中),此处只是把答案交给拥有该 bot 的实现。
// 不注册 query id:外部 setBotCallbackAnswer 因此无法伪造/覆盖内置 bot 的应答。
if r.deps.ServiceBotCallbacks != nil && r.deps.ServiceBotCallbacks.HandlesBot(botUserID) {
ans, handled, err := r.deps.ServiceBotCallbacks.OnCallbackQuery(ctx, callback)
if err != nil {
r.log.Warn("service bot callback query",
zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
return nil, internalErr()
}
if !handled {
return nil, dataInvalidErr()
}
return tgBotCallbackAnswer(ans), nil
}
queryID, pending, err := r.callbacks.registerContext(ctx, r.clock.Now(), botUserID, userID, botCallbackTimeout)
if err != nil {
r.log.Warn("register shared bot callback query", zap.Int64("bot_user_id", botUserID), zap.Error(err))

View file

@ -28,6 +28,31 @@ func rightsNotModifiedErr() error { return tgerr.New(400, "RIGHTS_NOT_MOD
func botVerifierForbiddenErr() error { return tgerr.New(403, "BOT_VERIFIER_FORBIDDEN") }
func userPermissionDeniedErr() error { return tgerr.New(403, "USER_PERMISSION_DENIED") }
// setCustomVerificationErr maps the third-party verification domain errors onto TL.
//
// The public method documents only BOT_INVALID, BOT_VERIFIER_FORBIDDEN and
// PEER_ID_INVALID. Domain detail must not leak invented error names that official
// clients do not handle.
func setCustomVerificationErr(err error) error {
switch {
case errors.Is(err, domain.ErrVerifierForbidden),
errors.Is(err, domain.ErrVerifierNotFound),
errors.Is(err, domain.ErrVerificationIconNotFound),
errors.Is(err, domain.ErrVerificationIconInactive),
errors.Is(err, domain.ErrVerificationIconInvalid),
errors.Is(err, domain.ErrVerifierDescriptionForbidden),
errors.Is(err, domain.ErrCustomVerificationRequestInvalid),
errors.Is(err, domain.ErrCustomVerificationLimit):
return botVerifierForbiddenErr()
case errors.Is(err, domain.ErrCustomVerificationTargetInvalid):
return peerIDInvalidErr()
case errors.Is(err, domain.ErrBotNotFound), errors.Is(err, domain.ErrVerifierSettingsInvalid):
return botInvalidErr()
default:
return internalErr()
}
}
func setBotCommandsErr(err error) error {
if errors.Is(err, domain.ErrBotCommandInvalid) {
return botCommandInvalidErr()

View file

@ -77,15 +77,33 @@ func (r *Router) onBotsSetBotGroupDefaultAdminRights(ctx context.Context, _ tg.C
return false, rightsNotModifiedErr()
}
// onBotsReorderUsernames reorders a bot's collectible usernames. A bot is a user
// peer in the registry, so the only difference from account.reorderUsernames is
// the ownership gate: resolveOwnedBotUser already rejects a caller who does not
// own the bot.
//
// With no registry wired the historical USERNAME_NOT_MODIFIED answer is kept --
// a bot with a single editable username genuinely has nothing to reorder.
func (r *Router) onBotsReorderUsernames(ctx context.Context, req *tg.BotsReorderUsernamesRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if _, err := r.resolveOwnedBotUser(ctx, userID, req.Bot); err != nil {
if req == nil {
return false, botInvalidErr()
}
bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot)
if err != nil {
return false, err
}
return false, usernameNotModifiedErr()
if r.deps.Usernames == nil {
return false, usernameNotModifiedErr()
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: bot.ID}
if err := r.reorderRegistryUsernames(ctx, peer, req.Order); err != nil {
return false, err
}
return true, nil
}
func (r *Router) onBotsToggleUsername(ctx context.Context, req *tg.BotsToggleUsernameRequest) (bool, error) {
@ -93,10 +111,21 @@ func (r *Router) onBotsToggleUsername(ctx context.Context, req *tg.BotsToggleUse
if err != nil {
return false, internalErr()
}
if _, err := r.resolveOwnedBotUser(ctx, userID, req.Bot); err != nil {
if req == nil {
return false, botInvalidErr()
}
bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot)
if err != nil {
return false, err
}
return false, usernameNotModifiedErr()
if r.deps.Usernames == nil {
return false, usernameNotModifiedErr()
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: bot.ID}
if err := r.toggleRegistryUsername(ctx, peer, req.Username, req.Active); err != nil {
return false, err
}
return true, nil
}
func (r *Router) onBotsCanSendMessage(ctx context.Context, bot tg.InputUserClass) (bool, error) {
@ -481,22 +510,72 @@ func (r *Router) onBotsUpdateStarRefProgram(ctx context.Context, req *tg.BotsUpd
return nil, botInvalidErr()
}
// onBotsSetCustomVerification answers bots.setCustomVerification#8b89dfbd: a
// verifier bot adding or removing its own third-party mark on a peer
// (core.telegram.org/api/bots/verification).
//
// The TL constructor allows exactly two callers, and the schema encodes which:
// bot:flags.0?InputUser "must not be set if invoked by a bot, must be set to the ID
// of an owned bot if invoked by a user". Both branches are resolved to the same
// verifier bot id before anything is written, so a user can only ever act through a
// bot they own and a bot can only ever act as itself.
//
// Bool reports successful handling. Idempotent retries still return true; both
// official clients and the Bot API interpret boolFalse as failure.
func (r *Router) onBotsSetCustomVerification(ctx context.Context, req *tg.BotsSetCustomVerificationRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if req == nil {
return false, peerIDInvalidErr()
}
var verifierBotID int64
if bot, ok := req.GetBot(); ok {
if _, err := r.resolveOwnedBotUser(ctx, userID, bot); err != nil {
owned, err := r.resolveOwnedBotUser(ctx, userID, bot)
if err != nil {
return false, err
}
} else if _, err := r.callerBotID(ctx); err != nil {
verifierBotID = owned.ID
} else {
botID, err := r.callerBotID(ctx)
if err != nil {
return false, err
}
verifierBotID = botID
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
return false, err
if r.deps.BotVerifications == nil {
// Unwired deployment: no bot can be a verifier, which is exactly what
// BOT_VERIFIER_FORBIDDEN says.
return false, botVerifierForbiddenErr()
}
return false, botVerifierForbiddenErr()
setRequest := domain.SetCustomVerificationRequest{
VerifierBotID: verifierBotID,
Peer: peer,
Enabled: req.GetEnabled(),
CallerUserID: userID,
}
if description, ok := req.GetCustomDescription(); ok {
setRequest.CustomDescription = description
}
// Shape-only validation at the edge (peer kind, description length, revoke that
// still carries a description) so a malformed request gets a deterministic TL
// error regardless of how strict the wired service is.
if err := setRequest.Validate(); err != nil {
return false, setCustomVerificationErr(err)
}
_, err = r.deps.BotVerifications.SetCustomVerification(ctx, setRequest)
if err != nil {
return false, setCustomVerificationErr(err)
}
// The push belongs to the service, not here: it owns the same invalidation for
// all three drivers (this RPC, the bot dialog and the admin panel), so pushing
// again would fan out to the whole audience twice per change.
return true, nil
}
func (r *Router) onBotsGetBotRecommendations(ctx context.Context, _ tg.InputUserClass) (tg.UsersUsersClass, error) {

View file

@ -116,7 +116,7 @@ func (r *Router) onChannelsGetChannels(ctx context.Context, ids []tg.InputChanne
chats = append(chats, tgChannelChatForView(userID, view))
}
}
r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats)
r.applyPeerReadModels(ctx, userID, nil, chats)
return &tg.MessagesChats{Chats: chats}, nil
}
@ -172,11 +172,12 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
r.applyStarGiftsCountToChannelFull(ctx, ref.ID, &full)
r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, ref.ID, &full)
r.applyNotifySettingsToChannelFull(ctx, userID, ref.ID, &full)
r.applyBotVerificationToChannelFull(ctx, ref.ID, &full)
r.applyAndroidChannelReactionEditorCompat(ctx, &full, cached.canChangeInfo)
chats := append([]tg.ChatClass(nil), cached.chats...)
chats = r.appendLinkedDiscussionChat(ctx, userID, ref.ID, chats)
r.trackChannelInterest(ctx, userID, ref.ID)
r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats)
r.applyPeerReadModels(ctx, userID, nil, chats)
return &tg.MessagesChatFull{
FullChat: &full,
Chats: chats,
@ -220,8 +221,11 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
}
r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, view.Channel.ID, full)
r.applyNotifySettingsToChannelFull(ctx, userID, view.Channel.ID, full)
// After StoreIfEpoch on purpose: the mark stays out of the per-(viewer,channel)
// projection cache so a revoked badge cannot outlive the change by a cache TTL.
r.applyBotVerificationToChannelFull(ctx, view.Channel.ID, full)
r.applyAndroidChannelReactionEditorCompat(ctx, full, canChangeInfo)
r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats)
r.applyPeerReadModels(ctx, userID, nil, chats)
return &tg.MessagesChatFull{
FullChat: full,
Chats: chats,

View file

@ -59,9 +59,11 @@ func (r *Router) onMessagesCheckChatInvite(ctx context.Context, hash string) (tg
return nil, channelInviteErr(err)
}
if res.Already {
// chatInviteAlready#5a686d7c wraps a full Chat, so the badge flags already
// travel through tgChannelChat; nothing to re-apply here.
return &tg.ChatInviteAlready{Chat: tgChannelChat(userID, res.Channel, &res.Self)}, nil
}
return &tg.ChatInvite{
invite := &tg.ChatInvite{
Channel: true,
Broadcast: res.Channel.Broadcast,
Megagroup: res.Channel.Megagroup,
@ -71,7 +73,37 @@ func (r *Router) onMessagesCheckChatInvite(ctx context.Context, hash string) (tg
About: res.Channel.About,
Photo: &tg.PhotoEmpty{},
ParticipantsCount: res.Channel.ParticipantsCount,
}, nil
}
// chatInvite#5c9d3702 carries verified:flags.7 / scam:flags.8 / fake:flags.9.
// A non-member sees only this preview, so the peer's moderation and official
// verification state must already be visible here: without it the badge (or the
// scam/fake warning) appears only after joining, which is exactly backwards.
// Set*, not raw field assignment, so Flags stays consistent before Encode.
applyChatInviteModerationFlags(invite, res.Channel)
// bot_verification:flags.13 extends the same reasoning one field further: the
// third-party mark is part of what identifies the peer, so the preview a
// non-member sees has to carry it as well.
r.applyBotVerificationToChatInvite(ctx, invite, res.Channel.ID)
return invite, nil
}
// applyChatInviteModerationFlags mirrors the persistent channel record's official
// verification and moderation flags onto an invite preview. Absent flags are left
// unset rather than explicitly cleared, so the encoded chatInvite matches what an
// official server sends for an unflagged peer.
func applyChatInviteModerationFlags(invite *tg.ChatInvite, ch domain.Channel) {
if invite == nil {
return
}
if ch.Verified {
invite.SetVerified(true)
}
if ch.Scam {
invite.SetScam(true)
}
if ch.Fake {
invite.SetFake(true)
}
}
func (r *Router) onMessagesImportChatInvite(ctx context.Context, hash string) (tg.MessagesChatInviteJoinResultClass, error) {

View file

@ -0,0 +1,146 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
)
// inviteBadgeChannels answers messages.checkChatInvite from a canned persistent
// channel record, so the test observes exactly the flags the projection derives.
type inviteBadgeChannels struct {
ChannelsService
result domain.CheckChannelInviteResult
}
func (s *inviteBadgeChannels) CheckInvite(_ context.Context, _ int64, _ string, _ int) (domain.CheckChannelInviteResult, error) {
return s.result, nil
}
func checkChatInvitePreview(t *testing.T, ch domain.Channel) *tg.ChatInvite {
t.Helper()
r := New(Config{}, Deps{Channels: &inviteBadgeChannels{
result: domain.CheckChannelInviteResult{
Channel: ch,
Invite: domain.ChannelInvite{Hash: "hash", ChannelID: ch.ID},
},
}}, zap.NewNop(), clock.System)
res, err := r.onMessagesCheckChatInvite(WithUserID(context.Background(), 1001), "hash")
if err != nil {
t.Fatalf("check chat invite: %v", err)
}
invite, ok := res.(*tg.ChatInvite)
if !ok {
t.Fatalf("invite = %T %+v", res, res)
}
// Round-trip through the wire so the assertions read the encoded flags word
// rather than only the Go struct fields: a raw field assignment would pass the
// struct check and still ship flags.7/8/9 unset.
buf := &bin.Buffer{}
if err := invite.Encode(buf); err != nil {
t.Fatalf("encode chatInvite: %v", err)
}
decoded := &tg.ChatInvite{}
if err := decoded.Decode(buf); err != nil {
t.Fatalf("decode chatInvite: %v", err)
}
return decoded
}
// TestCheckChatInvitePreviewCarriesLayer228BadgeFlags pins chatInvite#5c9d3702
// verified:flags.7 / scam:flags.8 / fake:flags.9 onto the invite preview, so an
// official client shows the badge before the user joins rather than after.
func TestCheckChatInvitePreviewCarriesLayer228BadgeFlags(t *testing.T) {
if tg.ChatInviteTypeID != 0x5c9d3702 {
t.Fatalf("chatInvite constructor id = %#x", tg.ChatInviteTypeID)
}
verified := checkChatInvitePreview(t, domain.Channel{
ID: 4004, AccessHash: 44, Title: "Official", Username: "official",
Broadcast: true, ParticipantsCount: 7, Verified: true,
})
if !verified.GetVerified() || !verified.Verified {
t.Fatalf("verified invite = %+v", verified)
}
if verified.GetScam() || verified.GetFake() {
t.Fatalf("verified invite leaked moderation flags: %+v", verified)
}
if !verified.Channel || !verified.Broadcast || !verified.Public ||
verified.Title != "Official" || verified.ParticipantsCount != 7 {
t.Fatalf("verified invite lost unrelated fields: %+v", verified)
}
flagged := checkChatInvitePreview(t, domain.Channel{
ID: 4005, AccessHash: 45, Title: "Flagged", Megagroup: true,
Scam: true, Fake: true,
})
if !flagged.GetScam() || !flagged.GetFake() {
t.Fatalf("flagged invite = %+v", flagged)
}
if flagged.GetVerified() {
t.Fatalf("flagged invite claims verification: %+v", flagged)
}
if !flagged.Megagroup || flagged.Public {
t.Fatalf("flagged invite lost unrelated fields: %+v", flagged)
}
}
// TestCheckChatInvitePreviewLeavesUnflaggedChannelClean is the negative half: an
// ordinary channel must not carry flags.7/8/9 at all, so the encoded preview stays
// byte-identical to what an official server sends for an unflagged peer.
func TestCheckChatInvitePreviewLeavesUnflaggedChannelClean(t *testing.T) {
plain := checkChatInvitePreview(t, domain.Channel{
ID: 4006, AccessHash: 46, Title: "Plain", Broadcast: true,
})
if plain.GetVerified() || plain.Verified {
t.Fatalf("unverified channel exposed verified: %+v", plain)
}
if plain.GetScam() || plain.GetFake() || plain.Scam || plain.Fake {
t.Fatalf("unflagged channel exposed moderation flags: %+v", plain)
}
if plain.Flags.Has(7) || plain.Flags.Has(8) || plain.Flags.Has(9) {
t.Fatalf("unflagged channel set badge bits in flags word: %d", plain.Flags)
}
}
// TestCheckChatInviteAlreadyKeepsChannelBadge guards the other branch: a member
// gets chatInviteAlready#5a686d7c, whose Chat already carries
// channel#d49f34c6 verified:flags.7 through tgChannelChat, so the flags must not be
// applied twice or lost.
func TestCheckChatInviteAlreadyKeepsChannelBadge(t *testing.T) {
const channelID = int64(4007)
r := New(Config{}, Deps{Channels: &inviteBadgeChannels{
result: domain.CheckChannelInviteResult{
Channel: domain.Channel{
ID: channelID, AccessHash: 47, Title: "Official", Username: "official",
Broadcast: true, Verified: true,
},
Invite: domain.ChannelInvite{Hash: "hash", ChannelID: channelID},
Already: true,
Self: domain.ChannelMember{
ChannelID: channelID, UserID: 1001, Status: domain.ChannelMemberActive,
},
},
}}, zap.NewNop(), clock.System)
res, err := r.onMessagesCheckChatInvite(WithUserID(context.Background(), 1001), "hash")
if err != nil {
t.Fatalf("check chat invite: %v", err)
}
already, ok := res.(*tg.ChatInviteAlready)
if !ok {
t.Fatalf("invite = %T %+v", res, res)
}
channel, ok := already.Chat.(*tg.Channel)
if !ok || channel.ID != channelID {
t.Fatalf("chat = %T %+v", already.Chat, already.Chat)
}
if !channel.Verified || channel.Scam || channel.Fake {
t.Fatalf("already-member channel badge = %+v", channel)
}
}

View file

@ -199,7 +199,7 @@ func (r *Router) onChannelsGetParticipants(ctx context.Context, req *tg.Channels
users = append(users, r.tgUsersForIDs(ctx, userID, missing)...)
}
}
r.applyStoryMaxIDsToPeerObjects(ctx, userID, users, nil)
r.applyPeerReadModels(ctx, userID, users, nil)
r.log.Debug("channels.getParticipants result",
zap.Int64("channel_id", ref.ID),
zap.String("filter", string(filter.Kind)),
@ -237,7 +237,7 @@ func (r *Router) onChannelsGetParticipant(ctx context.Context, req *tg.ChannelsG
}
participant := tgChannelParticipant(userID, member)
users := r.tgUsersForIDs(ctx, userID, channelParticipantUserRefs(participant))
r.applyStoryMaxIDsToPeerObjects(ctx, userID, users, nil)
r.applyPeerReadModels(ctx, userID, users, nil)
return &tg.ChannelsChannelParticipant{
Participant: participant,
Users: users,
@ -597,7 +597,7 @@ func (r *Router) onChannelsGetAdminLog(ctx context.Context, req *tg.ChannelsGetA
events := tgChannelAdminLogEvents(userID, res.Events)
chats := []tg.ChatClass{tgChannelChatMin(userID, res.Channel)}
users := r.channelAdminLogUsers(ctx, userID, res.Events)
r.applyStoryMaxIDsToPeerObjects(ctx, userID, users, chats)
r.applyPeerReadModels(ctx, userID, users, chats)
return &tg.ChannelsAdminLogResults{
Events: events,
Chats: chats,

View file

@ -102,28 +102,66 @@ func (r *Router) onChannelsSetEmojiStickers(ctx context.Context, req *tg.Channel
return true, nil
}
// onChannelsReorderUsernames rewrites the channel's collectible username order.
// Permissions are the ordinary change_info gate every other channels.* setting
// uses (channelChangeInfoView).
//
// With no username registry wired the handler keeps its historical accept-and-
// ignore answer: the channel then owns exactly one editable username, whose order
// is not expressible, so reporting success is both true and what shipped clients
// already saw.
func (r *Router) onChannelsReorderUsernames(ctx context.Context, req *tg.ChannelsReorderUsernamesRequest) (bool, error) {
if req == nil {
return false, channelInvalidErr(domain.ErrChannelInvalid)
}
if len(req.Order) > maxChannelUsernameOrder {
return false, limitInvalidErr()
}
if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil {
_, view, err := r.channelChangeInfoView(ctx, req.Channel)
if err != nil {
return false, err
}
if r.deps.Usernames == nil {
return true, nil
}
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: view.Channel.ID}
if err := r.reorderRegistryUsernames(ctx, peer, req.Order); err != nil {
return false, err
}
return true, nil
}
func (r *Router) onChannelsToggleUsername(ctx context.Context, req *tg.ChannelsToggleUsernameRequest) (bool, error) {
if req == nil {
return false, channelInvalidErr(domain.ErrChannelInvalid)
}
if req.Username != "" && !validChannelManagementUsername(req.Username) {
return false, usernameInvalidErr()
}
if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil {
_, view, err := r.channelChangeInfoView(ctx, req.Channel)
if err != nil {
return false, err
}
if r.deps.Usernames == nil {
return true, nil
}
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: view.Channel.ID}
if err := r.toggleRegistryUsername(ctx, peer, req.Username, req.Active); err != nil {
return false, err
}
return true, nil
}
func (r *Router) onChannelsDeactivateAllUsernames(ctx context.Context, input tg.InputChannelClass) (bool, error) {
if _, _, err := r.channelChangeInfoView(ctx, input); err != nil {
_, view, err := r.channelChangeInfoView(ctx, input)
if err != nil {
return false, err
}
if r.deps.Usernames == nil {
return true, nil
}
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: view.Channel.ID}
if err := r.deactivateAllRegistryUsernames(ctx, peer); err != nil {
return false, err
}
return true, nil

View file

@ -47,8 +47,13 @@ func (r *Router) channelStateMutationUpdates(ctx context.Context, userID int64,
r.invalidateRPCProjectionForChannel(channel.LinkedMonoforumID)
}
mono, includeMono := r.linkedMonoforumForChannelState(ctx, userID, channel)
r.pushChannelStateToMembersWithLinkedMonoforum(ctx, userID, channel, mono, includeMono)
return r.channelStateUpdatesWithLinkedMonoforum(userID, channel, mono, includeMono)
// One read for the whole fan-out plus the returned updates: the third-party
// verification mark is a peer-wide fact, and the per-recipient builder must stay
// read-free (see channelStateUpdatesWithLinkedMonoforum).
icon := r.peerBotVerificationIcon(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID})
usernames := r.channelStateUsernameRegistry(ctx, channel, mono, includeMono)
r.pushChannelStateToMembersWithLinkedMonoforum(ctx, userID, channel, mono, includeMono, icon, usernames)
return r.channelStateUpdatesWithLinkedMonoforum(userID, channel, mono, includeMono, icon, usernames)
}
func (r *Router) channelPaidMessagesPriceUpdates(ctx context.Context, userID int64, res domain.ChannelPaidMessagesPriceResult) tg.UpdatesClass {

View file

@ -445,18 +445,30 @@ func uniqueRecipientIDs(ids []int64) []int64 {
}
func (r *Router) pushChannelStateToMembers(ctx context.Context, originUserID int64, channel domain.Channel) {
r.pushChannelStateToMembersWithLinkedMonoforum(ctx, originUserID, channel, domain.Channel{}, false)
// The third-party verification icon is resolved once here, outside the
// per-recipient builder: see channelStateUpdatesWithLinkedMonoforum.
icon := r.peerBotVerificationIcon(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID})
usernames := r.channelStateUsernameRegistry(ctx, channel, domain.Channel{}, false)
r.pushChannelStateToMembersWithLinkedMonoforum(ctx, originUserID, channel, domain.Channel{}, false, icon, usernames)
}
func (r *Router) pushChannelStateToMembersWithLinkedMonoforum(ctx context.Context, originUserID int64, channel domain.Channel, mono domain.Channel, includeMono bool) {
func (r *Router) pushChannelStateToMembersWithLinkedMonoforum(ctx context.Context, originUserID int64, channel domain.Channel, mono domain.Channel, includeMono bool, botVerificationIcon int64, usernames map[domain.Peer][]domain.Username) {
if r.deps.Channels == nil || channel.ID == 0 {
return
}
r.pushChannelUpdates(ctx, originUserID, channel.ID, []int64{originUserID}, func(viewerUserID int64) *tg.Updates {
return r.channelStateUpdatesWithLinkedMonoforum(viewerUserID, channel, mono, includeMono)
return r.channelStateUpdatesWithLinkedMonoforum(viewerUserID, channel, mono, includeMono, botVerificationIcon, usernames)
})
}
func (r *Router) channelStateUsernameRegistry(ctx context.Context, channel domain.Channel, mono domain.Channel, includeMono bool) map[domain.Peer][]domain.Username {
peers := []domain.Peer{{Type: domain.PeerTypeChannel, ID: channel.ID}}
if includeMono && mono.ID != 0 && mono.ID != channel.ID {
peers = append(peers, domain.Peer{Type: domain.PeerTypeChannel, ID: mono.ID})
}
return r.usernameRegistryMap(ctx, peers)
}
func (r *Router) tgUsersForIDs(ctx context.Context, currentUserID int64, ids []int64) []tg.UserClass {
if r.deps.Users == nil || len(ids) == 0 {
return nil

View file

@ -145,7 +145,11 @@ func appendChannelStateUpdates(dst *tg.Updates, extra *tg.Updates) {
}
}
func (r *Router) channelStateUpdatesWithLinkedMonoforum(viewerUserID int64, channel domain.Channel, mono domain.Channel, includeMono bool) *tg.Updates {
// channelStateUpdatesWithLinkedMonoforum builds one recipient's channel-state
// update. Peer-wide overlays are passed in already resolved rather than read
// here: this builder runs once per online recipient, so reads inside it would
// turn one state change into one query per member.
func (r *Router) channelStateUpdatesWithLinkedMonoforum(viewerUserID int64, channel domain.Channel, mono domain.Channel, includeMono bool, botVerificationIcon int64, usernames map[domain.Peer][]domain.Username) *tg.Updates {
updates := r.channelStateUpdates(viewerUserID, channel)
// 母广播频道有/曾有关联 monoforum 时,按完整(非 min)形态下发。关闭 Direct Messages 时
// linked_monoforum_id 在投影里被隐藏,只有完整频道对象才能覆盖客户端缓存里旧的 linked_monoforum_id,
@ -158,6 +162,12 @@ func (r *Router) channelStateUpdatesWithLinkedMonoforum(viewerUserID int64, chan
if includeMono {
updates.Chats = appendUniqueTGChats(updates.Chats, tgChannelChat(viewerUserID, mono, nil))
}
// The third-party mark travels with the state mutation for the same reason
// verified:flags.7 does -- it is part of how the peer identifies itself -- but it
// lives in a read model rather than on domain.Channel, so it is stamped on here
// instead of being projected from the row.
applyBotVerificationIconToChannelChats(updates.Chats, channel.ID, botVerificationIcon)
applyUsernamesFromRegistry(nil, updates.Chats, usernames)
return updates
}

View file

@ -0,0 +1,709 @@
package rpc
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// fakeUsernameRegistry is an in-memory UsernameRegistryService. It enforces the
// same domain rules the real registry does, so the RPC tests exercise the actual
// error mapping rather than hand-rolled sentinels.
type fakeUsernameRegistry struct {
byPeer map[domain.Peer][]domain.Username
collectibles map[string]domain.CollectibleUsername
// err, when set, fails every read. Used for the degradation tests.
err error
// batchCalls / peerCalls count read fan-out so the tests can assert no N+1.
batchCalls int
peerCalls int
}
func newFakeUsernameRegistry() *fakeUsernameRegistry {
return &fakeUsernameRegistry{
byPeer: make(map[domain.Peer][]domain.Username),
collectibles: make(map[string]domain.CollectibleUsername),
}
}
func (f *fakeUsernameRegistry) PeerUsernames(_ context.Context, peer domain.Peer) ([]domain.Username, error) {
f.peerCalls++
if f.err != nil {
return nil, f.err
}
return f.byPeer[peer], nil
}
func (f *fakeUsernameRegistry) UsernamesBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
f.batchCalls++
if f.err != nil {
return nil, f.err
}
out := make(map[domain.Peer][]domain.Username, len(peers))
for _, peer := range peers {
if list, ok := f.byPeer[peer]; ok {
out[peer] = list
}
}
return out, nil
}
func (f *fakeUsernameRegistry) ToggleUsername(_ context.Context, peer domain.Peer, username string, active bool) (bool, error) {
if f.err != nil {
return false, f.err
}
current := f.byPeer[peer]
if err := domain.ValidateUsernameToggle(current, username, active); err != nil {
return false, err
}
changed := false
next := append([]domain.Username(nil), current...)
for i := range next {
if next[i].Username != domain.NormalizeUsername(username) {
continue
}
if next[i].Active != active {
next[i].Active = active
changed = true
}
}
f.byPeer[peer] = next
return changed, nil
}
func (f *fakeUsernameRegistry) ReorderUsernames(_ context.Context, peer domain.Peer, order []string) (bool, error) {
if f.err != nil {
return false, f.err
}
current := f.byPeer[peer]
next, err := domain.ApplyUsernameReorder(current, order)
if err != nil {
return false, err
}
// Same contract as both real backends: the renumbering is always persisted,
// and "changed" is only about what a client can see.
changed := !domain.SameUsernameOrder(current, next)
f.byPeer[peer] = next
return changed, nil
}
func (f *fakeUsernameRegistry) DeactivateAllUsernames(_ context.Context, peer domain.Peer) (bool, error) {
if f.err != nil {
return false, f.err
}
next := append([]domain.Username(nil), f.byPeer[peer]...)
changed := false
for i := range next {
if next[i].Collectible() && next[i].Active {
next[i].Active = false
changed = true
}
}
f.byPeer[peer] = next
return changed, nil
}
func (f *fakeUsernameRegistry) Collectible(_ context.Context, username string) (domain.CollectibleUsername, error) {
if f.err != nil {
return domain.CollectibleUsername{}, f.err
}
// Usernames are case-insensitive in the registry, as they are in the store.
asset, ok := f.collectibles[strings.ToLower(domain.NormalizeUsername(username))]
if !ok {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
return asset, nil
}
var _ UsernameRegistryService = (*fakeUsernameRegistry)(nil)
func TestTgUsernamesFromRegistryFollowsStoredOrder(t *testing.T) {
// Legacy numbering, which is what every peer that never reordered carries:
// the editable slot and the first collectible share sort_order 0 and the
// editable slot wins the tie, so it still projects first.
assertUsernameVector(t,
[]domain.Username{
{Username: "second", Active: false, SortOrder: 1, CollectibleID: 22},
{Username: "editable_slot", Active: true, Editable: true, SortOrder: 0},
{Username: "first", Active: true, SortOrder: 0, CollectibleID: 11},
},
[]tg.Username{
{Editable: true, Active: true, Username: "editable_slot"},
{Editable: false, Active: true, Username: "first"},
{Editable: false, Active: false, Username: "second"},
})
// After a reorder made a collectible primary, stored order wins: clients read
// usernames[0] as the peer's primary username
// (core.telegram.org/api/fragment), so the editable slot must be able to
// leave that position.
assertUsernameVector(t,
[]domain.Username{
{Username: "second", Active: false, SortOrder: 2, CollectibleID: 22},
{Username: "editable_slot", Active: true, Editable: true, SortOrder: 1},
{Username: "first", Active: true, SortOrder: 0, CollectibleID: 11},
},
[]tg.Username{
{Editable: false, Active: true, Username: "first"},
{Editable: true, Active: true, Username: "editable_slot"},
{Editable: false, Active: false, Username: "second"},
})
}
func assertUsernameVector(t *testing.T, list []domain.Username, want []tg.Username) {
t.Helper()
got := tgUsernamesFromRegistry(list, "editable_slot")
if len(got) != len(want) {
t.Fatalf("usernames = %+v, want %+v", got, want)
}
for i := range want {
if got[i].Username != want[i].Username || got[i].Editable != want[i].Editable || got[i].Active != want[i].Active {
t.Fatalf("usernames[%d] = %+v, want %+v", i, got[i], want[i])
}
}
}
func TestTgUsernamesFromRegistryDegradesToScalar(t *testing.T) {
// Empty registry contribution must be byte-identical to the legacy vector.
if got, want := tgUsernamesFromRegistry(nil, "legacy"), tgUsernames("legacy"); len(got) != 1 || got[0] != want[0] {
t.Fatalf("empty registry = %+v, want %+v", got, want)
}
if got := tgUsernamesFromRegistry(nil, ""); got != nil {
t.Fatalf("empty registry with empty scalar = %+v, want nil", got)
}
// A registry list that normalizes away entirely also degrades rather than
// emitting an empty vector.
if got, want := tgUsernamesFromRegistry([]domain.Username{{Username: " "}}, "legacy"), tgUsernames("legacy"); len(got) != 1 || got[0] != want[0] {
t.Fatalf("blank registry rows = %+v, want %+v", got, want)
}
}
type usernameProjectionFixture struct {
router *Router
registry *fakeUsernameRegistry
owner domain.User
friend domain.User
}
func newUsernameProjectionFixture(t *testing.T, registry UsernameRegistryService) usernameProjectionFixture {
t.Helper()
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002001", FirstName: "Owner", Username: "owner_slot"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
friend, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002002", FirstName: "Friend", Username: "friend_slot"})
if err != nil {
t.Fatalf("create friend: %v", err)
}
fake, _ := registry.(*fakeUsernameRegistry)
return usernameProjectionFixture{
router: New(Config{}, Deps{
Users: appusers.NewService(userStore),
Usernames: registry,
}, zaptest.NewLogger(t), clock.System),
registry: fake,
owner: owner,
friend: friend,
}
}
func usernameStrings(list []tg.Username) []string {
out := make([]string, 0, len(list))
for _, item := range list {
out = append(out, item.Username)
}
return out
}
func TestUsersGetUsersProjectsCollectibleUsernamesInOneBatch(t *testing.T) {
registry := newFakeUsernameRegistry()
f := newUsernameProjectionFixture(t, registry)
registry.byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: f.owner.ID}] = []domain.Username{
{Username: "owner_slot", Editable: true, Active: true},
{Username: "nft", Active: true, SortOrder: 0, CollectibleID: 7},
}
registry.byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: f.friend.ID}] = []domain.Username{
{Username: "friend_slot", Editable: true, Active: true},
{Username: "gem", Active: false, SortOrder: 0, CollectibleID: 8},
}
ctx := WithUserID(context.Background(), f.owner.ID)
out, err := f.router.onUsersGetUsers(ctx, []tg.InputUserClass{
&tg.InputUserSelf{},
&tg.InputUser{UserID: f.friend.ID, AccessHash: f.friend.AccessHash},
})
if err != nil {
t.Fatalf("get users: %v", err)
}
if len(out) != 2 {
t.Fatalf("users = %d, want 2", len(out))
}
self := out[0].(*tg.User)
if _, ok := self.GetUsername(); ok {
t.Fatalf("self scalar username is set together with collectible vector")
}
vector, ok := self.GetUsernames()
if !ok {
t.Fatalf("self usernames unset, want registry vector")
}
if got := usernameStrings(vector); len(got) != 2 || got[0] != "owner_slot" || got[1] != "nft" {
t.Fatalf("self usernames = %v, want [owner_slot nft]", got)
}
if !vector[0].Editable || !vector[0].Active {
t.Fatalf("editable slot flags = %+v, want editable+active", vector[0])
}
if vector[1].Editable || !vector[1].Active {
t.Fatalf("collectible flags = %+v, want non-editable+active", vector[1])
}
friend := out[1].(*tg.User)
friendVector, _ := friend.GetUsernames()
if got := usernameStrings(friendVector); len(got) != 2 || got[1] != "gem" {
t.Fatalf("friend usernames = %v, want [friend_slot gem]", got)
}
if friendVector[1].Active {
t.Fatalf("inactive collectible projected active: %+v", friendVector[1])
}
// Two users, one batch read: no N+1.
if registry.batchCalls != 1 || registry.peerCalls != 0 {
t.Fatalf("registry reads = batch %d / peer %d, want batch 1 / peer 0", registry.batchCalls, registry.peerCalls)
}
}
func TestUsersGetUsersDegradesWithoutRegistry(t *testing.T) {
f := newUsernameProjectionFixture(t, nil)
ctx := WithUserID(context.Background(), f.owner.ID)
out, err := f.router.onUsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUserSelf{}})
if err != nil {
t.Fatalf("get users: %v", err)
}
self := out[0].(*tg.User)
// Without a collectible registry the official legacy shape is scalar-only.
self.SetFlags()
vector, ok := self.GetUsernames()
if ok || len(vector) != 0 {
t.Fatalf("usernames = %+v (set %v), want vector absent", vector, ok)
}
if scalar, ok := self.GetUsername(); !ok || scalar != "owner_slot" {
t.Fatalf("scalar username = %q (set %v), want owner_slot", scalar, ok)
}
}
func TestUsersGetUsersDegradesWhenRegistryFails(t *testing.T) {
registry := newFakeUsernameRegistry()
registry.err = errors.New("registry unavailable")
f := newUsernameProjectionFixture(t, registry)
ctx := WithUserID(context.Background(), f.owner.ID)
out, err := f.router.onUsersGetUsers(ctx, []tg.InputUserClass{
&tg.InputUserSelf{},
&tg.InputUser{UserID: f.friend.ID, AccessHash: f.friend.AccessHash},
})
if err != nil {
t.Fatalf("get users must not fail when the registry does: %v", err)
}
if len(out) != 2 {
t.Fatalf("users = %d, want 2", len(out))
}
for i, want := range []string{"owner_slot", "friend_slot"} {
user := out[i].(*tg.User)
user.SetFlags()
vector, ok := user.GetUsernames()
if ok || len(vector) != 0 {
t.Fatalf("users[%d] usernames = %+v, want vector absent", i, vector)
}
if scalar, ok := user.GetUsername(); !ok || scalar != want {
t.Fatalf("users[%d] scalar username = %q (set %v), want %q", i, scalar, ok, want)
}
}
}
func TestFragmentGetCollectibleInfoRPC(t *testing.T) {
registry := newFakeUsernameRegistry()
f := newUsernameProjectionFixture(t, registry)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: f.owner.ID}
registry.collectibles["nfts"] = domain.CollectibleUsername{
ID: 7,
Username: "nfts",
Status: domain.CollectibleUsernameStatusOwned,
Owner: peer,
PurchaseDate: time.Unix(1700000000, 0),
Currency: domain.CollectibleCurrencyUSD,
Amount: 550000,
CryptoCurrency: domain.CollectibleCryptoCurrencyTON,
CryptoAmount: 1200000000,
URL: "https://fragment.example/username/nfts",
}
registry.byPeer[peer] = []domain.Username{{Username: "nfts", Active: false, CollectibleID: 7}}
ctx := WithUserID(context.Background(), f.owner.ID)
info, err := f.router.onFragmentGetCollectibleInfo(ctx, &tg.FragmentGetCollectibleInfoRequest{
Collectible: &tg.InputCollectibleUsername{Username: "@NFTS"},
})
if err != nil {
t.Fatalf("get collectible info: %v", err)
}
if info.PurchaseDate != 1700000000 || info.Currency != domain.CollectibleCurrencyUSD || info.Amount != 550000 {
t.Fatalf("collectible info = %+v, want the stored purchase record", info)
}
if info.CryptoCurrency != domain.CollectibleCryptoCurrencyTON || info.CryptoAmount != 1200000000 {
t.Fatalf("collectible crypto = %s/%d, want TON/1200000000", info.CryptoCurrency, info.CryptoAmount)
}
if info.URL != "https://fragment.example/username/nfts" {
t.Fatalf("collectible url = %q", info.URL)
}
// Inactive collectibles are private to their current owner.
friendCtx := WithUserID(context.Background(), f.friend.ID)
if _, err := f.router.onFragmentGetCollectibleInfo(friendCtx, &tg.FragmentGetCollectibleInfoRequest{
Collectible: &tg.InputCollectibleUsername{Username: "nfts"},
}); !tgerr.Is(err, "COLLECTIBLE_NOT_FOUND") {
t.Fatalf("inactive collectible seen by another user: %v, want COLLECTIBLE_NOT_FOUND", err)
}
registry.byPeer[peer][0].Active = true
if _, err := f.router.onFragmentGetCollectibleInfo(friendCtx, &tg.FragmentGetCollectibleInfoRequest{
Collectible: &tg.InputCollectibleUsername{Username: "nfts"},
}); err != nil {
t.Fatalf("active collectible hidden from another user: %v", err)
}
// A name with no collectible asset behind it is not occupied as a collectible.
if _, err := f.router.onFragmentGetCollectibleInfo(ctx, &tg.FragmentGetCollectibleInfoRequest{
Collectible: &tg.InputCollectibleUsername{Username: "owner_slot"},
}); !tgerr.Is(err, "COLLECTIBLE_NOT_FOUND") {
t.Fatalf("non-collectible username err = %v, want COLLECTIBLE_NOT_FOUND", err)
}
// Syntactically impossible name is rejected before the registry is consulted.
if _, err := f.router.onFragmentGetCollectibleInfo(ctx, &tg.FragmentGetCollectibleInfoRequest{
Collectible: &tg.InputCollectibleUsername{Username: "a"},
}); !tgerr.Is(err, "COLLECTIBLE_INVALID") {
t.Fatalf("malformed username err = %v, want COLLECTIBLE_INVALID", err)
}
// Collectible phones do not exist in this server.
if _, err := f.router.onFragmentGetCollectibleInfo(ctx, &tg.FragmentGetCollectibleInfoRequest{
Collectible: &tg.InputCollectiblePhone{Phone: "15550002001"},
}); !tgerr.Is(err, "COLLECTIBLE_NOT_FOUND") {
t.Fatalf("collectible phone err = %v, want COLLECTIBLE_NOT_FOUND", err)
}
}
func TestFragmentGetCollectibleInfoWithoutRegistry(t *testing.T) {
f := newUsernameProjectionFixture(t, nil)
ctx := WithUserID(context.Background(), f.owner.ID)
if _, err := f.router.onFragmentGetCollectibleInfo(ctx, &tg.FragmentGetCollectibleInfoRequest{
Collectible: &tg.InputCollectibleUsername{Username: "owner_slot"},
}); !tgerr.Is(err, "COLLECTIBLE_NOT_FOUND") {
t.Fatalf("no registry err = %v, want COLLECTIBLE_NOT_FOUND", err)
}
}
func TestAccountToggleAndReorderUsernamesRPC(t *testing.T) {
registry := newFakeUsernameRegistry()
f := newUsernameProjectionFixture(t, registry)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: f.owner.ID}
registry.byPeer[peer] = []domain.Username{
{Username: "owner_slot", Editable: true, Active: true},
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1},
{Username: "bravo", Active: true, SortOrder: 1, CollectibleID: 2},
}
ctx := WithUserID(context.Background(), f.owner.ID)
if ok, err := f.router.onAccountToggleUsername(ctx, &tg.AccountToggleUsernameRequest{Username: "alpha", Active: false}); !ok || err != nil {
t.Fatalf("toggle collectible = %v,%v, want true,nil", ok, err)
}
if list := registry.byPeer[peer]; list[1].Active {
t.Fatalf("alpha still active after toggle: %+v", list)
}
// Toggling the same value again changes nothing.
if ok, err := f.router.onAccountToggleUsername(ctx, &tg.AccountToggleUsernameRequest{Username: "alpha", Active: false}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") {
t.Fatalf("idempotent toggle = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err)
}
// The editable slot is not a collectible: account.updateUsername owns it.
if ok, err := f.router.onAccountToggleUsername(ctx, &tg.AccountToggleUsernameRequest{Username: "owner_slot", Active: false}); ok || !tgerr.Is(err, "USERNAME_INVALID") {
t.Fatalf("toggle editable slot = %v,%v, want false,USERNAME_INVALID", ok, err)
}
// An unknown name is not occupied.
if ok, err := f.router.onAccountToggleUsername(ctx, &tg.AccountToggleUsernameRequest{Username: "ghost", Active: true}); ok || !tgerr.Is(err, "USERNAME_NOT_OCCUPIED") {
t.Fatalf("toggle unknown = %v,%v, want false,USERNAME_NOT_OCCUPIED", ok, err)
}
// alpha is inactive at this point, so the order does not have to mention it.
if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{Order: []string{"owner_slot", "bravo"}}); !ok || err != nil {
t.Fatalf("reorder = %v,%v, want true,nil", ok, err)
}
list := domain.SortUsernames(registry.byPeer[peer])
if len(list) != 3 || !list[0].Editable || list[1].Username != "bravo" || list[2].Username != "alpha" {
t.Fatalf("order after reorder = %+v, want editable, bravo, alpha", list)
}
// The editable slot may lead the order or follow a collectible.
if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{Order: []string{"bravo", "owner_slot"}}); !ok || err != nil {
t.Fatalf("reorder with a collectible first = %v,%v, want true,nil", ok, err)
}
if list := domain.SortUsernames(registry.byPeer[peer]); list[0].Username != "bravo" || !list[1].Editable {
t.Fatalf("order after promoting a collectible = %+v, want bravo, editable, alpha", list)
}
// An order that omits an active username is still rejected.
if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{Order: []string{"bravo"}}); ok || !tgerr.Is(err, "ORDER_INVALID") {
t.Fatalf("partial reorder = %v,%v, want false,ORDER_INVALID", ok, err)
}
// So is one naming something the peer does not own.
if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{Order: []string{"owner_slot", "bravo", "ghost"}}); ok || !tgerr.Is(err, "ORDER_INVALID") {
t.Fatalf("reorder with an unknown name = %v,%v, want false,ORDER_INVALID", ok, err)
}
if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{
Order: make([]string, domain.MaxPeerCollectibleUsernames+2),
}); ok || !tgerr.Is(err, "LIMIT_INVALID") {
t.Fatalf("oversized reorder = %v,%v, want false,LIMIT_INVALID", ok, err)
}
}
func TestAccountUsernameManagementWithoutRegistry(t *testing.T) {
f := newUsernameProjectionFixture(t, nil)
ctx := WithUserID(context.Background(), f.owner.ID)
if ok, err := f.router.onAccountToggleUsername(ctx, &tg.AccountToggleUsernameRequest{Username: "owner_slot", Active: true}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") {
t.Fatalf("toggle without registry = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err)
}
if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{Order: []string{"owner_slot"}}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") {
t.Fatalf("reorder without registry = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err)
}
}
// TestCollectibleUsernameRPCsAreRegistered proves the three previously missing
// methods reach a handler through the real dispatcher rather than the unregistered
// fallback: an ordinary client calls them by wire constructor, not by Go method.
func TestCollectibleUsernameRPCsAreRegistered(t *testing.T) {
f := newUsernameProjectionFixture(t, nil)
ctx := WithUserID(context.Background(), f.owner.ID)
cases := []struct {
name string
req bin.Encoder
want string
}{
{name: "account.reorderUsernames", req: &tg.AccountReorderUsernamesRequest{Order: []string{"owner_slot"}}, want: "USERNAME_NOT_MODIFIED"},
{name: "account.toggleUsername", req: &tg.AccountToggleUsernameRequest{Username: "owner_slot", Active: true}, want: "USERNAME_NOT_MODIFIED"},
{name: "fragment.getCollectibleInfo", req: &tg.FragmentGetCollectibleInfoRequest{Collectible: &tg.InputCollectibleUsername{Username: "owner_slot"}}, want: "COLLECTIBLE_NOT_FOUND"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
var in bin.Buffer
if err := tt.req.Encode(&in); err != nil {
t.Fatalf("encode request: %v", err)
}
if _, err := f.router.Dispatch(ctx, [8]byte{}, 0, &in); !tgerr.Is(err, tt.want) {
t.Fatalf("dispatch err = %v, want %s", err, tt.want)
}
})
}
}
func newCollectibleChannelFixture(t *testing.T, registry UsernameRegistryService) (*Router, domain.User, *tg.Channel) {
t.Helper()
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550003001", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Usernames: registry,
}, zaptest.NewLogger(t), clock.System)
ownerCtx := WithUserID(ctx, owner.ID)
created, err := r.onChannelsCreateChannel(ownerCtx, &tg.ChannelsCreateChannelRequest{
Broadcast: true,
Title: "Collectible Channel",
About: "about",
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
if _, err := r.onChannelsUpdateUsername(ownerCtx, &tg.ChannelsUpdateUsernameRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Username: "chan_slot",
}); err != nil {
t.Fatalf("update channel username: %v", err)
}
return r, owner, channel
}
func TestChannelsUsernameManagementUsesRegistry(t *testing.T) {
registry := newFakeUsernameRegistry()
r, owner, channel := newCollectibleChannelFixture(t, registry)
ctx := WithUserID(context.Background(), owner.ID)
input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}
registry.byPeer[peer] = []domain.Username{
{Username: "chan_slot", Editable: true, Active: true},
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 31},
{Username: "bravo", Active: true, SortOrder: 1, CollectibleID: 32},
}
if ok, err := r.onChannelsToggleUsername(ctx, &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "alpha", Active: false}); !ok || err != nil {
t.Fatalf("toggle channel collectible = %v,%v, want true,nil", ok, err)
}
if ok, err := r.onChannelsToggleUsername(ctx, &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "alpha", Active: false}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") {
t.Fatalf("idempotent channel toggle = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err)
}
if ok, err := r.onChannelsToggleUsername(ctx, &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "chan_slot", Active: false}); ok || !tgerr.Is(err, "USERNAME_INVALID") {
t.Fatalf("toggle channel editable slot = %v,%v, want false,USERNAME_INVALID", ok, err)
}
// alpha was just deactivated, so the order carries only the active names.
if ok, err := r.onChannelsReorderUsernames(ctx, &tg.ChannelsReorderUsernamesRequest{Channel: input, Order: []string{"chan_slot", "bravo"}}); !ok || err != nil {
t.Fatalf("reorder channel usernames = %v,%v, want true,nil", ok, err)
}
if list := domain.SortUsernames(registry.byPeer[peer]); list[1].Username != "bravo" {
t.Fatalf("channel order = %+v, want bravo first collectible", list)
}
if ok, err := r.onChannelsDeactivateAllUsernames(ctx, input); !ok || err != nil {
t.Fatalf("deactivate all = %v,%v, want true,nil", ok, err)
}
for _, item := range registry.byPeer[peer] {
if item.Collectible() && item.Active {
t.Fatalf("collectible still active after deactivateAll: %+v", item)
}
}
// Deactivate-all is idempotent, while reorder follows the documented
// USERNAME_NOT_MODIFIED result for an unchanged order.
if ok, err := r.onChannelsDeactivateAllUsernames(ctx, input); !ok || err != nil {
t.Fatalf("repeat deactivate all = %v,%v, want true,nil", ok, err)
}
if ok, err := r.onChannelsReorderUsernames(ctx, &tg.ChannelsReorderUsernamesRequest{Channel: input, Order: []string{"chan_slot"}}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") {
t.Fatalf("repeat reorder after deactivateAll = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err)
}
// A non-admin must not reach the registry at all.
other := WithUserID(context.Background(), owner.ID+9999)
if _, err := r.onChannelsToggleUsername(other, &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "alpha", Active: true}); err == nil {
t.Fatalf("toggle as stranger err = nil, want permission failure")
}
}
func TestChannelsUsernameManagementWithoutRegistryKeepsLegacyAnswers(t *testing.T) {
r, owner, channel := newCollectibleChannelFixture(t, nil)
ctx := WithUserID(context.Background(), owner.ID)
input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
if ok, err := r.onChannelsToggleUsername(ctx, &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "chan_slot", Active: true}); !ok || err != nil {
t.Fatalf("toggle without registry = %v,%v, want true,nil", ok, err)
}
if ok, err := r.onChannelsReorderUsernames(ctx, &tg.ChannelsReorderUsernamesRequest{Channel: input, Order: []string{"chan_slot"}}); !ok || err != nil {
t.Fatalf("reorder without registry = %v,%v, want true,nil", ok, err)
}
if ok, err := r.onChannelsDeactivateAllUsernames(ctx, input); !ok || err != nil {
t.Fatalf("deactivate without registry = %v,%v, want true,nil", ok, err)
}
}
// TestChannelsDeactivateAllUsernamesIsIdempotent covers the report "I created a
// channel without a username and later could not set one": Telegram Desktop
// drives its channel-username editor by calling channels.deactivateAllUsernames
// first and only continuing when it answers true. A channel that owns no
// collectible username at all -- every freshly created channel -- has nothing to
// deactivate, and answering USERNAME_NOT_MODIFIED there aborted the whole flow
// client-side. Deactivating an empty set is success.
func TestChannelsDeactivateAllUsernamesIsIdempotent(t *testing.T) {
registry := newFakeUsernameRegistry()
r, owner, channel := newCollectibleChannelFixture(t, registry)
ctx := WithUserID(context.Background(), owner.ID)
input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}
// A channel with only its editable slot: no collectible username exists.
registry.byPeer[peer] = []domain.Username{{Username: "chan_slot", Editable: true, Active: true}}
for attempt := 0; attempt < 2; attempt++ {
if ok, err := r.onChannelsDeactivateAllUsernames(ctx, input); !ok || err != nil {
t.Fatalf("deactivate all with nothing collectible (attempt %d) = %v,%v, want true,nil", attempt, ok, err)
}
}
// The whole visible list is just the editable slot, which is exactly what
// Telegram Desktop sends here.
if ok, err := r.onChannelsReorderUsernames(ctx, &tg.ChannelsReorderUsernamesRequest{Channel: input, Order: []string{"chan_slot"}}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") {
t.Fatalf("reorder with nothing collectible = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err)
}
// The editable slot is untouched by either call: only collectibles are hidden.
if len(registry.byPeer[peer]) != 1 || !registry.byPeer[peer][0].Active {
t.Fatalf("editable slot after bulk calls = %+v, want it left active", registry.byPeer[peer])
}
// A channel with no rows at all behaves the same way.
delete(registry.byPeer, peer)
if ok, err := r.onChannelsDeactivateAllUsernames(ctx, input); !ok || err != nil {
t.Fatalf("deactivate all on an empty registry = %v,%v, want true,nil", ok, err)
}
// Permission is still checked before the registry is consulted.
stranger := WithUserID(context.Background(), owner.ID+4242)
if ok, err := r.onChannelsDeactivateAllUsernames(stranger, input); ok || err == nil {
t.Fatalf("deactivate all as stranger = %v,%v, want false and a permission failure", ok, err)
}
}
func TestChannelsGetChannelsProjectsCollectibleUsernames(t *testing.T) {
registry := newFakeUsernameRegistry()
r, owner, channel := newCollectibleChannelFixture(t, registry)
ctx := WithUserID(context.Background(), owner.ID)
registry.byPeer[domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}] = []domain.Username{
{Username: "chan_slot", Editable: true, Active: true},
{Username: "chan_nft", Active: true, SortOrder: 0, CollectibleID: 44},
}
chats, err := r.onChannelsGetChannels(ctx, []tg.InputChannelClass{
&tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
})
if err != nil {
t.Fatalf("get channels: %v", err)
}
out := chats.(*tg.MessagesChats).Chats
if len(out) != 1 {
t.Fatalf("chats = %d, want 1", len(out))
}
vector, ok := out[0].(*tg.Channel).GetUsernames()
if !ok {
t.Fatalf("channel usernames unset, want registry vector")
}
if got := usernameStrings(vector); len(got) != 2 || got[0] != "chan_slot" || got[1] != "chan_nft" {
t.Fatalf("channel usernames = %v, want [chan_slot chan_nft]", got)
}
if scalar, ok := out[0].(*tg.Channel).GetUsername(); ok || scalar != "" {
t.Fatalf("scalar channel username = %q (set %v), want absent with collectible vector", scalar, ok)
}
}
func TestChannelsGetChannelsDegradesWithoutRegistry(t *testing.T) {
r, owner, channel := newCollectibleChannelFixture(t, nil)
ctx := WithUserID(context.Background(), owner.ID)
chats, err := r.onChannelsGetChannels(ctx, []tg.InputChannelClass{
&tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
})
if err != nil {
t.Fatalf("get channels: %v", err)
}
vector, ok := chats.(*tg.MessagesChats).Chats[0].(*tg.Channel).GetUsernames()
if ok || len(vector) != 0 {
t.Fatalf("legacy channel usernames = %+v (set %v), want vector absent", vector, ok)
}
if scalar, ok := chats.(*tg.MessagesChats).Chats[0].(*tg.Channel).GetUsername(); !ok || scalar != "chan_slot" {
t.Fatalf("legacy scalar username = %q (set %v), want chan_slot", scalar, ok)
}
}

View file

@ -637,7 +637,7 @@ func (r *Router) onContactsImportContacts(ctx context.Context, input []tg.InputP
for _, contact := range res.Contacts {
out.Users = append(out.Users, r.tgUser(contact.User))
}
r.applyStoryMaxIDsToPeerObjects(ctx, userID, out.Users, nil)
r.applyPeerReadModels(ctx, userID, out.Users, nil)
out.RetryContacts = append(out.RetryContacts, res.RetryContacts...)
for _, contact := range res.Contacts {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: contact.User.ID}
@ -1110,7 +1110,7 @@ func (r *Router) contactPeerSettingsUpdates(ctx context.Context, userID int64, p
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
r.applyStoryMaxIDsToPeerObjects(ctx, userID, out.Users, nil)
r.applyPeerReadModels(ctx, userID, out.Users, nil)
return out
}

View file

@ -672,8 +672,8 @@ func TestUsernameRPCLifecycle(t *testing.T) {
t.Fatalf("update username: %v", err)
}
self, ok := user.(*tg.User)
if !ok || self.Username != "owner_name" || len(self.Usernames) != 1 || !self.Usernames[0].Active {
t.Fatalf("updated user = %T %+v, want self with active username", user, user)
if !ok || self.Username != "owner_name" || len(self.Usernames) != 0 {
t.Fatalf("updated user = %T %+v, want self with scalar username only", user, user)
}
resolved, err := r.onContactsResolveUsername(reqCtx, &tg.ContactsResolveUsernameRequest{Username: "@OWNER_NAME"})

View file

@ -495,7 +495,6 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
}
if ch.Username != "" {
out.SetUsername(ch.Username)
out.SetUsernames(tgUsernames(ch.Username))
}
if color := tgPeerColor(ch.Color); color != nil {
out.SetColor(color)

View file

@ -27,7 +27,6 @@ func tgSelfUser(u domain.User) *tg.User {
Contact: u.Contact,
MutualContact: u.Mutual,
CloseFriend: u.CloseFriend,
Usernames: tgUsernames(u.Username),
}
applyTgUserBotFields(out, u)
applyTgUserPremiumFields(out, u)
@ -60,7 +59,6 @@ func tgUser(u domain.User) *tg.User {
Contact: u.Contact,
MutualContact: u.Mutual,
CloseFriend: u.CloseFriend,
Usernames: tgUsernames(u.Username),
}
applyTgUserBotFields(out, u)
applyTgUserPremiumFields(out, u)
@ -240,6 +238,9 @@ func tgUserStatus(status domain.UserStatus) tg.UserStatusClass {
return &tg.UserStatusRecently{}
}
// tgUsernames builds the vector carried by updateUserName. Ordinary user/channel
// constructors keep using their scalar username until at least one collectible
// is associated with the peer.
func tgUsernames(username string) []tg.Username {
if username == "" {
return nil
@ -247,6 +248,45 @@ func tgUsernames(username string) []tg.Username {
return []tg.Username{{Editable: true, Active: true, Username: username}}
}
// tgUsernamesFromRegistry is the single builder of the full username#b4073647
// vector in stored order (see domain.SortUsernames), with editable/active taken
// from the registry row rather than assumed.
//
// It degrades to tgUsernames(fallback) whenever the registry contributed nothing
// usable, which is what keeps a missing/failing registry service byte-identical
// to the pre-collectible wire shape.
func tgUsernamesFromRegistry(list []domain.Username, fallback string) []tg.Username {
if len(list) == 0 {
return tgUsernames(fallback)
}
sorted := domain.SortUsernames(list)
out := make([]tg.Username, 0, len(sorted))
for _, item := range sorted {
name := domain.NormalizeUsername(item.Username)
if name == "" {
continue
}
out = append(out, tg.Username{
Editable: item.Editable,
Active: item.Active,
Username: name,
})
}
if len(out) == 0 {
return tgUsernames(fallback)
}
return out
}
func hasCollectibleUsername(list []domain.Username) bool {
for _, item := range list {
if item.Collectible() {
return true
}
}
return false
}
func tgContacts(list domain.ContactList) tg.ContactsContactsClass {
out := &tg.ContactsContacts{
Contacts: make([]tg.Contact, 0, len(list.Contacts)),

View file

@ -327,6 +327,27 @@ type BotsService interface {
PutWebViewCustomMethodQuery(ctx context.Context, botUserID, userID int64, method, paramsJSON string) (domain.BotWebViewCustomMethodQuery, error)
}
// ServiceBotCallbacks answers inline-button clicks for the built-in bots that run
// inside this process (@verifybot and friends); app/bots implements it.
//
// It exists because the ordinary callback path cannot serve them: an internal bot
// has no MTProto session to receive updateBotCallbackQuery and no Bot API consumer
// to drain the update queue, so pushing the query at it and waiting could only
// ever end in BOT_RESPONSE_TIMEOUT after the full 25-second window. A bot claimed
// here is answered synchronously instead, by the responder that owns it.
//
// OnCallbackQuery reports handled=false when the bot is not one of the responder's
// own, which the edge treats as invalid callback data. The answer is final:
// nothing is registered in the shared callback registry for it, so no external
// setBotCallbackAnswer can overwrite or spoof it.
//
// A nil Deps.ServiceBotCallbacks keeps the edge behaviour exactly as it was:
// every callback is pushed to the bot's session and waited on.
type ServiceBotCallbacks interface {
HandlesBot(botUserID int64) bool
OnCallbackQuery(ctx context.Context, query domain.BotCallbackQuery) (domain.BotCallbackAnswer, bool, error)
}
// UserIdentityService 是 UsersService 的资料扩展能力,用于 username/phone 解析。
type UserIdentityService interface {
CheckUsername(ctx context.Context, userID int64, username string) (bool, error)
@ -931,6 +952,67 @@ type PremiumPromoService interface {
PremiumPromo(ctx context.Context) (domain.PremiumPromoCatalog, bool, error)
}
// UsernameRegistryService is the collectible (Fragment-style) username registry
// boundary. It owns the full per-peer username list -- the editable slot the
// client owns through account/channels.updateUsername plus every collectible
// asset attached to the peer -- and the purchase record behind a collectible.
//
// The registry is deliberately optional. Every RPC surface that consults it must
// degrade to the legacy single-editable-username behaviour when the field is nil
// or a call fails, because the scalar users.username / channels.username column
// remains the editable-name persistence slot.
type UsernameRegistryService interface {
// PeerUsernames returns one peer's full username list. Order is irrelevant:
// callers project through domain.SortUsernames.
PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error)
// UsernamesBatch is the N+1-free variant used by list projections. Peers with
// no registry row may be omitted from the result map.
UsernamesBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error)
// ToggleUsername activates/deactivates one collectible username. The bool
// reports whether anything changed; false maps to USERNAME_NOT_MODIFIED.
ToggleUsername(ctx context.Context, peer domain.Peer, username string, active bool) (bool, error)
// ReorderUsernames rewrites the active username display order, including the
// editable slot when present (domain.ValidateUsernameReorder).
ReorderUsernames(ctx context.Context, peer domain.Peer, order []string) (bool, error)
// DeactivateAllUsernames deactivates every collectible username of the peer.
DeactivateAllUsernames(ctx context.Context, peer domain.Peer) (bool, error)
// Collectible returns the asset and owner needed by the RPC edge to enforce
// fragment visibility before projecting fragment.collectibleInfo.
Collectible(ctx context.Context, username string) (domain.CollectibleUsername, error)
}
// BotVerificationService is the third-party bot verification boundary
// (core.telegram.org/api/bots/verification): a verifier bot marking peers with its
// own icon and description, which official clients render as a badge distinct from
// the operator-granted checkmark.
//
// It is the single source for every surface that projects the feature --
// user.bot_verification_icon, channel.bot_verification_icon,
// userFull.bot_verification, channelFull.bot_verification,
// chatInvite.bot_verification and botInfo.verifier_settings -- so no two responses
// can disagree about which mark a peer carries.
//
// Optional like UsernameRegistryService: a nil field, or
// any read error, must leave every flag unset and bots.setCustomVerification
// answering BOT_VERIFIER_FORBIDDEN, which is exactly the pre-feature wire shape.
type BotVerificationService interface {
// PeerVerification returns the peer's single mark, or
// domain.ErrCustomVerificationNotFound.
PeerVerification(ctx context.Context, peer domain.Peer) (domain.CustomVerification, error)
// PeerVerificationBatch is the N+1-free variant used by the response-boundary
// overlay. Peers without a mark may be omitted from the result map.
PeerVerificationBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer]domain.CustomVerification, error)
// VerifierSettings reads one bot's verifier status, or
// domain.ErrVerifierNotFound.
VerifierSettings(ctx context.Context, botID int64) (domain.BotVerifierSettings, error)
// VerifierSettingsBatch resolves several bots at once for the botInfo
// projection; bots without verifier status may be omitted.
VerifierSettingsBatch(ctx context.Context, botIDs []int64) (map[int64]domain.BotVerifierSettings, error)
// SetCustomVerification applies bots.setCustomVerification. changed controls
// update fan-out only; the RPC returns Bool true for an idempotent success.
SetCustomVerification(ctx context.Context, req domain.SetCustomVerificationRequest) (changed bool, err error)
}
// Deps 按业务域注入服务接口。各域的 handler 注册见对应文件(auth.go / users.go / updates.go)。
type Deps struct {
Auth AuthService
@ -949,6 +1031,8 @@ type Deps struct {
EphemeralPush store.EphemeralPushBroker
Moderation ModerationService
Users UsersService
Usernames UsernameRegistryService
BotVerifications BotVerificationService
TelegramLogin TelegramLoginService
Updates UpdatesService
BootstrapUpdates store.BootstrapUpdateJobStore
@ -965,6 +1049,7 @@ type Deps struct {
Files FilesService
PremiumPromo PremiumPromoService
Bots BotsService
ServiceBotCallbacks ServiceBotCallbacks
Polls PollsService
Phone PhoneService
GroupCalls GroupCallsService

350
internal/rpc/fragment.go Normal file
View file

@ -0,0 +1,350 @@
package rpc
import (
"context"
"errors"
"strings"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/domain"
)
// Collectible (Fragment-style) usernames on the protocol edge.
//
// This file owns three things:
//
// - fragment.getCollectibleInfo, the purchase-record lookup a client opens from
// the "this username was bought on Fragment" badge;
// - the projection overlay that turns the legacy single-username vector into
// the full username#b4073647 list (editable slot + collectibles);
// - the shared toggle/reorder/deactivate plumbing behind
// account.*, channels.* and bots.* username management.
//
// Every entry point degrades: with Deps.Usernames nil (or on any registry error)
// the wire shape is exactly what it was before collectibles existed.
// registerFragment 注册 fragment.* RPC handler。
func (r *Router) registerFragment(d *tlprofile.Dispatcher) {
registerRPC[*tg.FragmentGetCollectibleInfoRequest](d, tlprofile.SemanticMethodFragmentGetCollectibleInfo, func(ctx context.Context, layerRequest *tg.FragmentGetCollectibleInfoRequest) (any, error) {
return r.onFragmentGetCollectibleInfo(ctx, layerRequest)
})
}
// onFragmentGetCollectibleInfo answers fragment.getCollectibleInfo.
//
// Only inputCollectibleUsername is answerable here: this server has no
// collectible-phone registry. The method exposes only assets visible to the
// caller: their own inactive collectible, a collectible attached to a channel
// they own, or anybody's active collectible.
func (r *Router) onFragmentGetCollectibleInfo(ctx context.Context, req *tg.FragmentGetCollectibleInfoRequest) (*tg.FragmentCollectibleInfo, error) {
if req == nil {
return nil, collectibleInvalidErr()
}
// An authenticated caller is required, matching every other profile lookup.
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
switch collectible := req.Collectible.(type) {
case *tg.InputCollectibleUsername:
if collectible == nil {
return nil, collectibleInvalidErr()
}
return r.collectibleUsernameInfo(ctx, userID, collectible.Username)
case *tg.InputCollectiblePhone:
if collectible == nil || strings.TrimSpace(collectible.Phone) == "" {
return nil, collectibleInvalidErr()
}
return nil, collectibleNotFoundErr()
default:
return nil, collectibleInvalidErr()
}
}
func (r *Router) collectibleUsernameInfo(ctx context.Context, userID int64, username string) (*tg.FragmentCollectibleInfo, error) {
name := domain.NormalizeUsername(username)
// Syntax first: a name that cannot be a collectible is COLLECTIBLE_INVALID, and
// rejecting it here keeps malformed input off the registry.
if !domain.ValidCollectibleUsername(name) {
return nil, collectibleInvalidErr()
}
if r.deps.Usernames == nil {
return nil, collectibleNotFoundErr()
}
asset, err := r.deps.Usernames.Collectible(ctx, name)
if err != nil {
return nil, collectibleInfoErr(err)
}
if !asset.Owned() {
return nil, collectibleNotFoundErr()
}
visible, err := r.collectibleVisibleTo(ctx, userID, asset)
if err != nil {
return nil, internalErr()
}
if !visible {
// Do not reveal that an inactive collectible is associated with another
// peer. The official surface intentionally collapses that state to not found.
return nil, collectibleNotFoundErr()
}
info := asset.Info()
out := &tg.FragmentCollectibleInfo{
PurchaseDate: info.PurchaseDate,
Currency: info.Currency,
Amount: info.Amount,
CryptoCurrency: info.CryptoCurrency,
CryptoAmount: info.CryptoAmount,
URL: info.URL,
}
return out, nil
}
func (r *Router) collectibleVisibleTo(ctx context.Context, userID int64, asset domain.CollectibleUsername) (bool, error) {
if asset.Owner.Type == domain.PeerTypeUser && asset.Owner.ID == userID {
return true, nil
}
list, err := r.deps.Usernames.PeerUsernames(ctx, asset.Owner)
if err != nil {
return false, err
}
for _, item := range list {
if item.CollectibleID == asset.ID && item.Active {
return true, nil
}
}
if asset.Owner.Type != domain.PeerTypeChannel || r.deps.Channels == nil {
return false, nil
}
view, err := r.deps.Channels.ResolveChannel(ctx, userID, asset.Owner.ID)
if err != nil {
// Inaccessible channels are indistinguishable from an inactive asset owned
// by somebody else.
return false, nil
}
return view.Self.Role == domain.ChannelRoleCreator, nil
}
func collectibleInvalidErr() error { return tgerr400("COLLECTIBLE_INVALID") }
func collectibleNotFoundErr() error { return tgerr400("COLLECTIBLE_NOT_FOUND") }
// collectibleInfoErr maps registry lookup failures onto TL.
func collectibleInfoErr(err error) error {
switch {
case errors.Is(err, domain.ErrCollectibleUsernameNotFound),
errors.Is(err, domain.ErrUsernameNotOccupied),
errors.Is(err, domain.ErrCollectibleUsernameBurned),
errors.Is(err, domain.ErrCollectibleUsernameNotOwned):
return collectibleNotFoundErr()
case errors.Is(err, domain.ErrUsernameNotCollectible),
errors.Is(err, domain.ErrUsernameInvalid):
return collectibleInvalidErr()
default:
return internalErr()
}
}
// collectibleUsernameErr maps registry mutation failures onto TL.
//
// domain.ErrUsernameNotCollectible and domain.ErrUsernameOrderInvalid both mean
// "the client asked for something the collectible slots cannot express" -- moving
// or deactivating the editable slot, or an order that is not a permutation of the
// peer's collectibles -- so both are USERNAME_INVALID.
func collectibleUsernameErr(err error) error {
switch {
case errors.Is(err, domain.ErrUsernameNotCollectible),
errors.Is(err, domain.ErrUsernameOrderInvalid),
errors.Is(err, domain.ErrUsernameNotEditable),
errors.Is(err, domain.ErrUsernameInvalid):
return usernameInvalidErr()
case errors.Is(err, domain.ErrUsernameNotOccupied),
errors.Is(err, domain.ErrCollectibleUsernameNotFound),
errors.Is(err, domain.ErrCollectibleUsernameNotOwned),
errors.Is(err, domain.ErrCollectibleUsernameBurned):
return usernameNotOccupiedErr()
case errors.Is(err, domain.ErrCollectibleUsernameLimit):
return limitInvalidErr()
default:
return internalErr()
}
}
// toggleRegistryUsername is the shared body of account/channels/bots
// .toggleUsername. Callers do the permission check first.
func (r *Router) toggleRegistryUsername(ctx context.Context, peer domain.Peer, username string, active bool) error {
name := domain.NormalizeUsername(username)
if !domain.ValidCollectibleUsername(name) {
return usernameInvalidErr()
}
changed, err := r.deps.Usernames.ToggleUsername(ctx, peer, name, active)
if err != nil {
return collectibleUsernameErr(err)
}
if !changed {
return usernameNotModifiedErr()
}
r.invalidateRegistryProjection(peer)
return nil
}
// reorderRegistryUsernames is the shared body of account/channels/bots
// .reorderUsernames.
func (r *Router) reorderRegistryUsernames(ctx context.Context, peer domain.Peer, order []string) error {
normalized := make([]string, 0, len(order))
for _, name := range order {
normalized = append(normalized, domain.NormalizeUsername(name))
}
changed, err := r.deps.Usernames.ReorderUsernames(ctx, peer, normalized)
if err != nil {
if errors.Is(err, domain.ErrUsernameOrderInvalid) {
return tgerr400("ORDER_INVALID")
}
return collectibleUsernameErr(err)
}
if !changed {
return usernameNotModifiedErr()
}
r.invalidateRegistryProjection(peer)
return nil
}
// deactivateAllRegistryUsernames is the shared body of
// channels.deactivateAllUsernames: it hides every collectible username of a peer.
//
// Deactivating an empty set is success, not USERNAME_NOT_MODIFIED: Telegram
// Desktop calls channels.deactivateAllUsernames as a step of its "set the
// username" flow, so a peer that has no collectible usernames yet -- the common
// case for a freshly created channel -- would abort that flow on a 400. The
// no-op stub this replaced also answered true, and clients depend on it.
func (r *Router) deactivateAllRegistryUsernames(ctx context.Context, peer domain.Peer) error {
changed, err := r.deps.Usernames.DeactivateAllUsernames(ctx, peer)
if err != nil {
return collectibleUsernameErr(err)
}
if !changed {
return nil
}
r.invalidateRegistryProjection(peer)
return nil
}
// invalidateRegistryProjection drops the cached user/channel projections that
// embed the username vector, so the next getFullUser / getFullChannel rebuilds it.
func (r *Router) invalidateRegistryProjection(peer domain.Peer) {
switch peer.Type {
case domain.PeerTypeUser:
r.invalidateRPCProjectionForUser(peer.ID)
case domain.PeerTypeChannel:
r.invalidateRPCProjectionForChannel(peer.ID)
}
}
// applyUsernamesToPeerObjects overlays the registry onto already-projected user
// and channel objects. Per the Fragment contract, the scalar username is cleared
// and the vector is set only when a collectible is associated with the peer.
//
// It mirrors applyStoryMaxIDsToPeerObjects: one batched read-model call per
// response instead of a per-peer query, and a silent no-op whenever the read
// model is unavailable. Overlaying after projection is what keeps the ~90
// pure tgUser/tgChannel call sites untouched -- they keep emitting the legacy
// scalar, and this pass upgrades it wherever a Router-level entry point runs.
func (r *Router) applyUsernamesToPeerObjects(ctx context.Context, users []tg.UserClass, chats []tg.ChatClass) {
if r.deps.Usernames == nil || len(users)+len(chats) == 0 {
return
}
peers := make([]domain.Peer, 0, len(users)+len(chats))
seen := make(map[domain.Peer]struct{}, len(users)+len(chats))
addPeer := func(peer domain.Peer) {
if peer.ID == 0 {
return
}
if _, ok := seen[peer]; ok {
return
}
seen[peer] = struct{}{}
peers = append(peers, peer)
}
for _, item := range users {
if u, ok := item.(*tg.User); ok && u != nil {
addPeer(domain.Peer{Type: domain.PeerTypeUser, ID: u.ID})
}
}
for _, item := range chats {
if ch, ok := item.(*tg.Channel); ok && ch != nil {
addPeer(domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID})
}
}
if len(peers) == 0 {
return
}
byPeer := r.usernameRegistryMap(ctx, peers)
if len(byPeer) == 0 {
return
}
applyUsernamesFromRegistry(users, chats, byPeer)
}
// applyUsernamesFromRegistry applies a previously loaded registry snapshot.
// Notification fan-out uses this form so one peer-wide read does not become one
// database query per online viewer.
func applyUsernamesFromRegistry(users []tg.UserClass, chats []tg.ChatClass, byPeer map[domain.Peer][]domain.Username) {
if len(byPeer) == 0 {
return
}
for _, item := range users {
u, ok := item.(*tg.User)
if !ok || u == nil {
continue
}
list, ok := byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}]
if !ok || !hasCollectibleUsername(list) {
continue
}
if vector := tgUsernamesFromRegistry(list, u.Username); len(vector) > 0 {
u.Flags.Unset(3)
u.Username = ""
u.SetUsernames(vector)
}
}
for _, item := range chats {
ch, ok := item.(*tg.Channel)
if !ok || ch == nil {
continue
}
list, ok := byPeer[domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}]
if !ok || !hasCollectibleUsername(list) {
continue
}
// ch.Username is the flagged scalar; GetUsername reports the empty string
// when unset, which is exactly the fallback tgUsernamesFromRegistry wants.
scalar, _ := ch.GetUsername()
if vector := tgUsernamesFromRegistry(list, scalar); len(vector) > 0 {
ch.Flags.Unset(6)
ch.Username = ""
ch.SetUsernames(vector)
}
}
}
// usernameRegistryMap loads the registry for the given peers. A single peer goes
// through PeerUsernames so a one-object projection does not pay for a batch
// round trip; anything larger goes through UsernamesBatch (no N+1). Any error
// yields an empty map, which the caller treats as "keep the legacy scalar".
func (r *Router) usernameRegistryMap(ctx context.Context, peers []domain.Peer) map[domain.Peer][]domain.Username {
if r.deps.Usernames == nil || len(peers) == 0 {
return nil
}
if len(peers) == 1 {
list, err := r.deps.Usernames.PeerUsernames(ctx, peers[0])
if err != nil || len(list) == 0 {
return nil
}
return map[domain.Peer][]domain.Username{peers[0]: list}
}
byPeer, err := r.deps.Usernames.UsernamesBatch(ctx, peers)
if err != nil {
return nil
}
return byPeer
}

View file

@ -422,7 +422,7 @@ func (r *Router) onPhotosGetUserPhotos(ctx context.Context, req *tg.PhotosGetUse
selfUser = r.tgSelfUser(target)
}
users := []tg.UserClass{selfUser}
r.applyStoryMaxIDsToPeerObjects(ctx, currentUserID, users, nil)
r.applyPeerReadModels(ctx, currentUserID, users, nil)
countOffset := offset
if countOffset < 0 {
countOffset = 0

View file

@ -108,6 +108,8 @@ func (r *Router) NotifyUserModerationFlagsChanged(ctx context.Context, u domain.
pushCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
botVerificationIcon := r.peerBotVerificationIcon(pushCtx, domain.Peer{Type: domain.PeerTypeUser, ID: u.ID})
usernames := r.usernameRegistryMap(pushCtx, []domain.Peer{{Type: domain.PeerTypeUser, ID: u.ID}})
seen := make(map[int64]struct{}, len(audience))
for _, viewerUserID := range audience {
if viewerUserID == 0 {
@ -128,9 +130,18 @@ func (r *Router) NotifyUserModerationFlagsChanged(ctx context.Context, u domain.
zap.Error(err))
continue
}
projected := tgUsersForViewer(viewerUserID, users)
// The pushed peer object has to match what users.getUsers would answer, or the
// client refreshes the peer straight back into the stale shape. The third-party
// verification icon (user#b1b8cc83 bot_verification_icon:flags2.14) lives in a
// read model rather than on domain.User, so it is stamped on here -- from the
// single read taken before the loop, since it is the same peer for every
// recipient. Zero leaves flags2.14 unset, which is the pre-feature shape.
applyBotVerificationIconToUsers(projected, u.ID, botVerificationIcon)
applyUsernamesFromRegistry(projected, nil, usernames)
r.pushUserUpdates(pushCtx, viewerUserID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: u.ID}},
Users: tgUsersForViewer(viewerUserID, users),
Users: projected,
Date: int(r.clock.Now().Unix()),
Seq: 0,
})

View file

@ -567,6 +567,11 @@ func (r *Router) withUserSearchPresence(res domain.UserSearchResult) domain.User
return res
}
// tgUser / tgSelfUser deliberately do NOT overlay the username registry or the
// bot verification icon: they are called inside per-id loops (users.getUsers), and a
// per-object read there would be exactly the N+1 the batch overlay exists to avoid.
// Single-object handlers pick both up from applyPeerReadModels at the response
// boundary.
func (r *Router) tgUser(u domain.User) *tg.User {
return r.withBotProfileFlags(context.Background(), tgUser(r.withUserPresence(u)))
}
@ -578,6 +583,8 @@ func (r *Router) tgSelfUser(u domain.User) *tg.User {
func (r *Router) tgUsers(users []domain.User) []tg.UserClass {
out := tgUsers(r.withUsersPresence(users))
r.withBotProfileFlagsForUsers(context.Background(), out)
r.applyUsernamesToPeerObjects(context.Background(), out, nil)
r.applyBotVerificationIconsToPeerObjects(context.Background(), out, nil)
return out
}
@ -586,6 +593,8 @@ func (r *Router) tgUsers(users []domain.User) []tg.UserClass {
func (r *Router) tgUsersForViewer(viewerUserID int64, users []domain.User) []tg.UserClass {
out := tgUsersForViewer(viewerUserID, r.withUsersPresence(users))
r.withBotProfileFlagsForUsers(context.Background(), out)
r.applyUsernamesToPeerObjects(context.Background(), out, nil)
r.applyBotVerificationIconsToPeerObjects(context.Background(), out, nil)
return out
}

View file

@ -289,6 +289,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
r.registerPremium(d)
r.registerAiCompose(d)
r.registerBots(d)
r.registerFragment(d)
r.registerEphemeral(d)
r.dispatcher = d

View file

@ -295,7 +295,7 @@ func (r *Router) tgStatsPublicForwards(ctx context.Context, viewerUserID int64,
if list.NextOffset != "" {
out.SetNextOffset(list.NextOffset)
}
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out
}

View file

@ -13,7 +13,7 @@ func (r *Router) tgStoriesAllStories(ctx context.Context, viewerUserID int64, li
list = r.withStoryListPeerObjects(ctx, viewerUserID, list)
out := tgStoriesAllStories(viewerUserID, list)
if stories, ok := out.(*tg.StoriesAllStories); ok {
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, stories.Users, stories.Chats)
r.applyPeerReadModels(ctx, viewerUserID, stories.Users, stories.Chats)
}
return out
}
@ -21,14 +21,14 @@ func (r *Router) tgStoriesAllStories(ctx context.Context, viewerUserID int64, li
func (r *Router) tgStoriesStories(ctx context.Context, viewerUserID int64, list domain.StoryList) *tg.StoriesStories {
list = r.withStoryListPeerObjects(ctx, viewerUserID, list)
out := tgStoriesStories(viewerUserID, list)
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out
}
func (r *Router) tgStoriesPeerStories(ctx context.Context, viewerUserID int64, peerStories domain.PeerStories) *tg.StoriesPeerStories {
peerStories = r.withPeerStoriesPeerObjects(ctx, viewerUserID, peerStories)
out := tgStoriesPeerStories(viewerUserID, peerStories)
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out
}
@ -40,7 +40,7 @@ func (r *Router) tgStoryViewsList(ctx context.Context, viewerUserID int64, list
if len(peerChannels) > 0 {
out.Chats = appendUniqueTGChats(out.Chats, tgChannels(viewerUserID, peerChannels)...)
}
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out
}
@ -52,16 +52,16 @@ func (r *Router) tgStoryReactionsList(ctx context.Context, viewerUserID int64, l
if len(peerChannels) > 0 {
out.Chats = appendUniqueTGChats(out.Chats, tgChannels(viewerUserID, peerChannels)...)
}
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out
}
func (r *Router) applyStoryMaxIDsToChats(ctx context.Context, viewerUserID int64, out tg.MessagesChatsClass) tg.MessagesChatsClass {
switch v := out.(type) {
case *tg.MessagesChats:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, nil, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, nil, v.Chats)
case *tg.MessagesChatsSlice:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, nil, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, nil, v.Chats)
}
return out
}
@ -71,9 +71,9 @@ func (r *Router) tgMessagesDialogs(ctx context.Context, viewerUserID int64, list
out := tgMessagesDialogs(viewerUserID, list)
switch v := out.(type) {
case *tg.MessagesDialogs:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, v.Users, v.Chats)
case *tg.MessagesDialogsSlice:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, v.Users, v.Chats)
}
return out
}
@ -81,33 +81,33 @@ func (r *Router) tgMessagesDialogs(ctx context.Context, viewerUserID int64, list
func (r *Router) tgPeerDialogs(ctx context.Context, viewerUserID int64, list domain.DialogList, st domain.UpdateState) *tg.MessagesPeerDialogs {
list = r.withDialogNotifySettings(ctx, viewerUserID, list)
out := tgPeerDialogs(viewerUserID, list, st)
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out
}
func (r *Router) tgContacts(ctx context.Context, viewerUserID int64, list domain.ContactList) tg.ContactsContactsClass {
out := tgContacts(list)
if contacts, ok := out.(*tg.ContactsContacts); ok {
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, contacts.Users, nil)
r.applyPeerReadModels(ctx, viewerUserID, contacts.Users, nil)
}
return out
}
func (r *Router) tgContactsFound(ctx context.Context, viewerUserID int64, res domain.UserSearchResult) *tg.ContactsFound {
out := tgContactsFound(viewerUserID, res)
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out
}
func (r *Router) tgResolvedUserPeerWithStories(ctx context.Context, viewerUserID int64, u domain.User) *tg.ContactsResolvedPeer {
out := r.tgResolvedUserPeer(viewerUserID, u)
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, nil)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, nil)
return out
}
func (r *Router) tgResolvedChannelPeerWithStories(ctx context.Context, viewerUserID int64, view domain.ChannelView) *tg.ContactsResolvedPeer {
out := tgResolvedChannelPeer(viewerUserID, view)
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, nil, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, nil, out.Chats)
return out
}
@ -147,27 +147,27 @@ func (r *Router) tgMessagesDiscussionMessage(ctx context.Context, viewerUserID i
// 用带 presence + self 标志的投影覆盖裸 tgUsers,防止 viewer 自己以 self=false
// 进入 Users(Android putUsers 会覆盖 currentUser)。
out.Users = r.tgUsersForViewer(viewerUserID, discussion.Users)
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out
}
func (r *Router) applyStoryMaxIDsToForumTopics(ctx context.Context, viewerUserID int64, out *tg.MessagesForumTopics) *tg.MessagesForumTopics {
if out != nil {
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
}
return out
}
func (r *Router) applyStoryMaxIDsToMessageViews(ctx context.Context, viewerUserID int64, out *tg.MessagesMessageViews) *tg.MessagesMessageViews {
if out != nil {
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
}
return out
}
func (r *Router) applyStoryMaxIDsToMessageReactionsList(ctx context.Context, viewerUserID int64, out *tg.MessagesMessageReactionsList) *tg.MessagesMessageReactionsList {
if out != nil {
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
}
return out
}
@ -181,11 +181,11 @@ func (r *Router) tgGlobalSearchMessages(ctx context.Context, viewerUserID int64,
func (r *Router) applyStoryMaxIDsToMessages(ctx context.Context, viewerUserID int64, out tg.MessagesMessagesClass) {
switch v := out.(type) {
case *tg.MessagesMessages:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, v.Users, v.Chats)
case *tg.MessagesMessagesSlice:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, v.Users, v.Chats)
case *tg.MessagesChannelMessages:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, v.Users, v.Chats)
}
}
@ -206,9 +206,9 @@ func (r *Router) tgUpdatesDifference(ctx context.Context, viewerUserID int64, di
out := tgUpdatesDifference(viewerUserID, diff)
switch v := out.(type) {
case *tg.UpdatesDifference:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, v.Users, v.Chats)
case *tg.UpdatesDifferenceSlice:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, v.Users, v.Chats)
}
return out
}
@ -224,7 +224,7 @@ func (r *Router) withStoryUpdatePeerObjects(ctx context.Context, viewerUserID in
if len(channels) > 0 {
updates.Chats = appendUniqueTGChats(updates.Chats, tgChannels(viewerUserID, channels)...)
}
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, updates.Users, updates.Chats)
r.applyPeerReadModels(ctx, viewerUserID, updates.Users, updates.Chats)
return updates
}
@ -271,7 +271,7 @@ func (r *Router) appendStoryPrivacyUsers(ctx context.Context, viewerUserID int64
return
}
updates.Users = appendUniqueTGUsers(updates.Users, r.tgUsersForViewer(viewerUserID, users)...)
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, updates.Users, updates.Chats)
r.applyPeerReadModels(ctx, viewerUserID, updates.Users, updates.Chats)
}
func storyViewUserIDs(views []domain.StoryView) []int64 {
@ -497,6 +497,19 @@ func appendUniqueTGChats(base []tg.ChatClass, extra ...tg.ChatClass) []tg.ChatCl
return out
}
// applyPeerReadModels is the response-boundary overlay pass for peer objects that
// have already been projected. Every read model it drives is batched over the
// whole user/chat set, so a handler pays one call per read model per response
// rather than one per peer.
//
// It is the single hook every handler uses; adding a read model here reaches all
// of them at once.
func (r *Router) applyPeerReadModels(ctx context.Context, viewerUserID int64, users []tg.UserClass, chats []tg.ChatClass) {
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, users, chats)
r.applyUsernamesToPeerObjects(ctx, users, chats)
r.applyBotVerificationIconsToPeerObjects(ctx, users, chats)
}
func (r *Router) applyStoryMaxIDsToPeerObjects(ctx context.Context, viewerUserID int64, users []tg.UserClass, chats []tg.ChatClass) {
if r.deps.Stories == nil || viewerUserID == 0 || len(users)+len(chats) == 0 {
return

View file

@ -0,0 +1,64 @@
package rpc
import (
"context"
"fmt"
"telesrv/internal/domain"
)
// NotifyPeerUsernamesChanged is the domain-only edge hook invoked after a
// collectible username registry mutation commits. It invalidates the cached
// peer projection and pushes the ordinary non-PTS updateUser/updateChannel
// refresh to online viewers. The shared projection paths preload the registry
// once, so fan-out cannot turn the change into an N+1 query.
func (r *Router) NotifyPeerUsernamesChanged(ctx context.Context, peer domain.Peer) error {
if r == nil {
return nil
}
if peer.ID <= 0 {
return fmt.Errorf("notify peer usernames changed: invalid peer id %d", peer.ID)
}
switch peer.Type {
case domain.PeerTypeUser:
return r.notifyUserUsernamesChanged(ctx, peer.ID)
case domain.PeerTypeChannel:
return r.notifyChannelUsernamesChanged(ctx, peer.ID)
default:
return fmt.Errorf("notify peer usernames changed: unsupported peer type %q for peer %d", peer.Type, peer.ID)
}
}
func (r *Router) notifyUserUsernamesChanged(ctx context.Context, userID int64) error {
r.invalidateRPCProjectionForUser(userID)
if r.deps.Users == nil {
return nil
}
user, found, err := r.verificationUser(ctx, userID)
if err != nil {
return fmt.Errorf("notify peer usernames changed: load user %d: %w", userID, err)
}
if !found || user.ID == 0 {
return fmt.Errorf("notify peer usernames changed: user %d not found", userID)
}
return r.NotifyUserModerationFlagsChanged(ctx, user)
}
func (r *Router) notifyChannelUsernamesChanged(ctx context.Context, channelID int64) error {
r.invalidateRPCProjectionForChannel(channelID)
if r.deps.Channels == nil {
return nil
}
directory, ok := r.deps.Channels.(verificationChannelDirectory)
if !ok {
return fmt.Errorf("notify peer usernames changed: channel service does not expose GetChannelByID")
}
channel, err := directory.GetChannelByID(ctx, channelID)
if err != nil {
return fmt.Errorf("notify peer usernames changed: load channel %d: %w", channelID, err)
}
if channel.ID == 0 {
return fmt.Errorf("notify peer usernames changed: channel %d not found", channelID)
}
return r.NotifyChannelChanged(ctx, channel)
}

View file

@ -0,0 +1,94 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
usernamesapp "telesrv/internal/app/usernames"
"telesrv/internal/domain"
)
var _ usernamesapp.PeerUsernameNotifier = (*Router)(nil)
func TestNotifyPeerUsernamesChangedUserPushesPreloadedVector(t *testing.T) {
const (
targetID = int64(2002)
viewerID = int64(1001)
)
users := &verifiedNotifyUsers{
user: domain.User{ID: targetID, FirstName: "Owner", Username: "owner_slot"},
found: true,
audience: []int64{targetID, viewerID},
}
registry := newFakeUsernameRegistry()
registry.byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: targetID}] = []domain.Username{
{Username: "owner_slot", Editable: true, Active: true},
{Username: "nft", Active: true, CollectibleID: 7},
}
sessions := &captureSessions{onlineUserIDs: []int64{targetID, viewerID}}
r := New(Config{}, Deps{Users: users, Usernames: registry, Sessions: sessions}, zap.NewNop(), clock.System)
if err := r.NotifyPeerUsernamesChanged(context.Background(), domain.Peer{
Type: domain.PeerTypeUser, ID: targetID,
}); err != nil {
t.Fatalf("notify user usernames: %v", err)
}
if registry.peerCalls != 1 || registry.batchCalls != 0 {
t.Fatalf("registry reads = peer %d / batch %d, want one peer-wide read", registry.peerCalls, registry.batchCalls)
}
updates := sessions.lastUserPush().(*tg.Updates)
user := updates.Users[0].(*tg.User)
if scalar, ok := user.GetUsername(); ok || scalar != "" {
t.Fatalf("pushed scalar username = %q (set %v), want absent", scalar, ok)
}
vector, ok := user.GetUsernames()
if !ok || len(vector) != 2 || vector[1].Username != "nft" {
t.Fatalf("pushed username vector = %+v (set %v)", vector, ok)
}
}
func TestNotifyPeerUsernamesChangedChannelPushesPreloadedVector(t *testing.T) {
const (
channelID = int64(4004)
ownerID = int64(3003)
memberID = int64(3004)
)
channels := &verifiedNotifyChannels{channel: domain.Channel{
ID: channelID, AccessHash: 44, CreatorUserID: ownerID,
Title: "Collectible", Username: "channel_slot", Broadcast: true,
}}
registry := newFakeUsernameRegistry()
registry.byPeer[domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}] = []domain.Username{
{Username: "channel_slot", Editable: true, Active: true},
{Username: "collectible", Active: true, CollectibleID: 9},
}
sessions := &captureSessions{
onlineUserIDs: []int64{ownerID, memberID},
channelMembers: map[int64][]int64{channelID: {ownerID, memberID}},
}
r := New(Config{}, Deps{
Channels: channels, Usernames: registry, Sessions: sessions,
}, zap.NewNop(), clock.System)
if err := r.NotifyPeerUsernamesChanged(context.Background(), domain.Peer{
Type: domain.PeerTypeChannel, ID: channelID,
}); err != nil {
t.Fatalf("notify channel usernames: %v", err)
}
if registry.peerCalls != 1 || registry.batchCalls != 0 {
t.Fatalf("registry reads = peer %d / batch %d, want one peer-wide read", registry.peerCalls, registry.batchCalls)
}
updates := sessions.lastUserPush().(*tg.Updates)
channel := updates.Chats[0].(*tg.Channel)
if scalar, ok := channel.GetUsername(); ok || scalar != "" {
t.Fatalf("pushed scalar username = %q (set %v), want absent", scalar, ok)
}
vector, ok := channel.GetUsernames()
if !ok || len(vector) != 2 || vector[1].Username != "collectible" {
t.Fatalf("pushed username vector = %+v (set %v)", vector, ok)
}
}

View file

@ -125,7 +125,7 @@ func (r *Router) onUsersGetUsers(ctx context.Context, ids []tg.InputUserClass) (
}
out = append(out, r.tgUser(u))
}
r.applyStoryMaxIDsToPeerObjects(ctx, currentUserID, out, nil)
r.applyPeerReadModels(ctx, currentUserID, out, nil)
return out, nil
}
@ -159,7 +159,7 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
return nil, err
}
applyPrivateContactRestrictionToUser(user, contactRestriction)
r.applyStoryMaxIDsToPeerObjects(ctx, currentUserID, []tg.UserClass{user}, nil)
r.applyPeerReadModels(ctx, currentUserID, []tg.UserClass{user}, nil)
loadEpoch := r.userFullProjectionCache.LoadEpoch()
if full, ok := r.userFullProjectionCache.Lookup(currentUserID, u.ID); ok {
if !applyContactNoteToUserFull(u, &full) {
@ -170,6 +170,7 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
}
r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full)
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
r.applyBotVerificationToUserFull(ctx, u.ID, &full)
applyPrivateContactRestrictionToUserFull(&full, contactRestriction)
chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full)
return &tg.UsersUserFull{
@ -191,6 +192,10 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
}
r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full)
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
// Deliberately after StoreIfEpoch, like applyPersonalChannelToUserFull: the mark
// is not baked into the per-(viewer,target) cache, so a revoked badge is gone on
// the next response instead of lingering for the cache TTL.
r.applyBotVerificationToUserFull(ctx, u.ID, &full)
applyPrivateContactRestrictionToUserFull(&full, contactRestriction)
chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full)
return &tg.UsersUserFull{
@ -722,14 +727,16 @@ func (r *Router) fillUserFullPhotos(ctx context.Context, viewerUserID, ownerUser
// TDesktop 的 botInfo.inited 永不置位,每次开聊/输 "/" 都会重拉 getFullUser;
// user_id 必填且必须等于该 bot 的 id,不匹配会被客户端整体静默忽略。
func (r *Router) tgBotInfo(ctx context.Context, u domain.User) tg.BotInfo {
if r.deps.Bots == nil {
return tgBotInfoFromProfile(u.ID, domain.BotProfile{}, false)
info := tgBotInfoFromProfile(u.ID, domain.BotProfile{}, false)
if r.deps.Bots != nil {
if profile, found, err := r.deps.Bots.BotInfo(ctx, u.ID); err == nil && found {
info = tgBotInfoFromProfile(u.ID, profile, true)
}
}
profile, found, err := r.deps.Bots.BotInfo(ctx, u.ID)
if err != nil || !found {
return tgBotInfoFromProfile(u.ID, domain.BotProfile{}, false)
}
return tgBotInfoFromProfile(u.ID, profile, true)
// botInfo#4d8a0299 verifier_settings:flags.9 -- present only for a bot that is
// itself an enabled verifier, and independent of the bot profile above.
r.applyVerifierSettingsToOneBotInfo(ctx, &info, u.ID)
return info
}
type botProfileBatchResolver interface {
@ -755,10 +762,18 @@ func (r *Router) tgBotInfos(ctx context.Context, userIDs []int64) []tg.BotInfo {
}
}
}
// One verifier-settings query for the whole bot list, next to the profile batch
// above: channelFull.bot_info must not turn into an N+1 just to carry
// verifier_settings:flags.9.
verifiers := r.verifierSettingsBatch(ctx, ids)
out := make([]tg.BotInfo, 0, len(userIDs))
for _, id := range userIDs {
profile, found := profiles[id]
out = append(out, tgBotInfoFromProfile(id, profile, found))
info := tgBotInfoFromProfile(id, profile, found)
if settings, ok := verifiers[id]; ok {
applyVerifierSettingsToBotInfo(&info, id, settings)
}
out = append(out, info)
}
return out
}

View file

@ -0,0 +1,117 @@
package rpc
import (
"context"
"fmt"
"telesrv/internal/domain"
)
// verificationUserDirectory is the optional viewer-independent account reader.
// users.Service satisfies it; it is preferred over UsersService.ByID because
// "does this peer exist" must not depend on a viewer projection.
type verificationUserDirectory interface {
AdminUser(ctx context.Context, userID int64) (domain.User, bool, error)
}
// verificationChannelDirectory is the optional non-personalized channel base-row
// reader. channels.Service satisfies it (see the same assertion in communities.go);
// the badge hook has no viewer of its own, so it cannot use GetChannel.
type verificationChannelDirectory interface {
GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error)
}
// NotifyPeerVerified is the protocol-edge hook the official verification service
// invokes after an approve/reject/revoke decision has already committed. It makes
// the new user#b1b8cc83 verified:flags.17 / channel#d49f34c6 verified:flags.7 bit
// observable without waiting for a cache TTL:
//
// - the cached peer projections for the target are dropped, so the next
// users.getFullUser / channels.getFullChannel rebuilds from the committed row;
// - online clients that already know the peer are pushed the ordinary, non-PTS
// refresh update (updateUser / updateChannel) together with the re-projected
// peer object, which is what flips the badge in an official client live.
//
// It deliberately reuses the paths the scam/fake moderation flags already use
// rather than inventing a verification-specific update: the badge is one more
// boolean on the same peer record, and a second mechanism could only drift.
//
// Offline sessions are not pushed to and do not need to be: verified is part of
// the peer's base read model, whose version is bumped by the users/channels
// triggers in 0001_init, so updates.getDifference and any later getFullUser /
// getUsers answer already carries the new flag.
//
// A push failure never invalidates the committed decision, so the caller logs and
// swallows the returned error; this method therefore reports problems instead of
// panicking, and is safe on a nil receiver.
func (r *Router) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error {
if r == nil {
return nil
}
if peer.ID <= 0 {
return fmt.Errorf("notify peer verified: invalid peer id %d", peer.ID)
}
switch peer.Type {
case domain.PeerTypeUser:
return r.notifyUserVerified(ctx, peer.ID)
case domain.PeerTypeChannel:
return r.notifyChannelVerified(ctx, peer.ID)
default:
return fmt.Errorf("notify peer verified: unsupported peer type %q for peer %d", peer.Type, peer.ID)
}
}
// notifyUserVerified covers ordinary accounts and bots alike: a verified bot is a
// user#b1b8cc83 with verified:flags.17, so it takes the same audience-wide
// updateUser fan-out the moderation flags use (owner plus every online account
// that already sees the peer).
func (r *Router) notifyUserVerified(ctx context.Context, userID int64) error {
// Invalidate first and unconditionally: a decided application whose projection
// still says "not verified" would keep serving the stale badge state even if the
// push below cannot run.
r.invalidateRPCProjectionForUser(userID)
if r.deps.Users == nil {
return nil
}
user, found, err := r.verificationUser(ctx, userID)
if err != nil {
return fmt.Errorf("notify peer verified: load user %d: %w", userID, err)
}
if !found || user.ID == 0 {
return fmt.Errorf("notify peer verified: user %d not found", userID)
}
// NotifyUserModerationFlagsChanged re-projects the peer per recipient, so the
// snapshot handed in only carries identity; the pushed tg.User is always built
// from a fresh read.
return r.NotifyUserModerationFlagsChanged(ctx, user)
}
func (r *Router) verificationUser(ctx context.Context, userID int64) (domain.User, bool, error) {
if directory, ok := r.deps.Users.(verificationUserDirectory); ok {
return directory.AdminUser(ctx, userID)
}
return r.deps.Users.ByID(ctx, userID, userID)
}
// notifyChannelVerified reuses the channel state-mutation path, which invalidates
// the channel projections (plus a linked monoforum's) and pushes updateChannel
// with the refreshed chat object to the channel's members.
func (r *Router) notifyChannelVerified(ctx context.Context, channelID int64) error {
r.invalidateRPCProjectionForChannel(channelID)
if r.deps.Channels == nil {
return nil
}
directory, ok := r.deps.Channels.(verificationChannelDirectory)
if !ok {
return fmt.Errorf("notify peer verified: channel service does not expose GetChannelByID")
}
channel, err := directory.GetChannelByID(ctx, channelID)
if err != nil {
return fmt.Errorf("notify peer verified: load channel %d: %w", channelID, err)
}
if channel.ID == 0 {
return fmt.Errorf("notify peer verified: channel %d not found", channelID)
}
// Same hook the admin panel uses for any other channel base fact.
return r.NotifyChannelChanged(ctx, channel)
}

View file

@ -0,0 +1,319 @@
package rpc
import (
"context"
"errors"
"strings"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
verificationapp "telesrv/internal/app/verification"
"telesrv/internal/domain"
)
// The process wires the badge hook through a dynamic type assertion on
// *rpc.Router, so a signature drift would silently fall back to the
// invalidation-only notifier instead of breaking the build. Assert the port here
// so it breaks the build instead.
var _ verificationapp.PeerNotifier = (*Router)(nil)
// verifiedNotifyUsers is the authoritative account reader plus the moderation
// audience port, i.e. exactly the two capabilities the badge push relies on.
type verifiedNotifyUsers struct {
UsersService
user domain.User
found bool
adminCalls int
audience []int64
viewers []int64
}
func (s *verifiedNotifyUsers) AdminUser(_ context.Context, userID int64) (domain.User, bool, error) {
s.adminCalls++
if !s.found || s.user.ID != userID {
return domain.User{}, false, nil
}
return s.user, true, nil
}
func (s *verifiedNotifyUsers) ByIDs(_ context.Context, viewerUserID int64, ids []int64) ([]domain.User, error) {
s.viewers = append(s.viewers, viewerUserID)
if !s.found || len(ids) == 0 {
return nil, nil
}
return []domain.User{s.user}, nil
}
func (s *verifiedNotifyUsers) ModerationFlagAudience(_ context.Context, _ int64, _ int) ([]int64, error) {
return append([]int64(nil), s.audience...), nil
}
// verifiedNotifyChannels exposes the viewer-independent base row the hook needs
// plus the membership filter the channel fan-out uses.
type verifiedNotifyChannels struct {
ChannelsService
channel domain.Channel
err error
calls int
}
func (s *verifiedNotifyChannels) GetChannelByID(_ context.Context, channelID int64) (domain.Channel, error) {
s.calls++
if s.err != nil {
return domain.Channel{}, s.err
}
if s.channel.ID != channelID {
return domain.Channel{}, nil
}
return s.channel, nil
}
func (s *verifiedNotifyChannels) FilterActiveMemberIDs(_ context.Context, _ int64, userIDs []int64) ([]int64, error) {
return append([]int64(nil), userIDs...), nil
}
// channelsWithoutDirectory models a channels adapter that does not expose the
// non-personalized base-row reader.
type channelsWithoutDirectory struct{ ChannelsService }
func seedUserFullProjection(t *testing.T, r *Router, viewerUserID, targetUserID int64) {
t.Helper()
epoch := r.userFullProjectionCache.LoadEpoch()
r.userFullProjectionCache.StoreIfEpoch(viewerUserID, targetUserID, tg.UserFull{ID: targetUserID}, epoch)
if _, ok := r.userFullProjectionCache.Lookup(viewerUserID, targetUserID); !ok {
t.Fatalf("seed userFull projection for viewer %d target %d", viewerUserID, targetUserID)
}
}
func seedChannelFullProjection(t *testing.T, r *Router, viewerUserID, channelID int64) {
t.Helper()
epoch := r.channelFullProjectionCache.LoadEpoch()
r.channelFullProjectionCache.StoreIfEpoch(viewerUserID, channelID, channelFullProjection{
accessHash: 1,
full: tg.ChannelFull{ID: channelID},
}, epoch)
if _, ok := r.channelFullProjectionCache.Lookup(viewerUserID, channelID); !ok {
t.Fatalf("seed channelFull projection for viewer %d channel %d", viewerUserID, channelID)
}
}
// TestNotifyPeerVerifiedUserInvalidatesAndPushesUpdateUser is the bot/user half of
// the main scenario: an approved bot must reach every online account that already
// sees it, through the same non-PTS updateUser shape the scam/fake flags use.
func TestNotifyPeerVerifiedUserInvalidatesAndPushesUpdateUser(t *testing.T) {
const (
botID = int64(1250000012)
onlineViewerID = int64(1001)
offlineViewerID = int64(3003)
)
users := &verifiedNotifyUsers{
user: domain.User{ID: botID, FirstName: "Shop", Bot: true, Verified: true},
found: true,
audience: []int64{botID, onlineViewerID, offlineViewerID},
}
sessions := &captureSessions{onlineUserIDs: []int64{botID, onlineViewerID}}
r := New(Config{}, Deps{Users: users, Sessions: sessions}, zap.NewNop(), clock.System)
seedUserFullProjection(t, r, onlineViewerID, botID)
seedUserFullProjection(t, r, botID, botID)
if err := r.NotifyPeerVerified(context.Background(), domain.Peer{
Type: domain.PeerTypeUser, ID: botID,
}); err != nil {
t.Fatalf("notify peer verified: %v", err)
}
if _, ok := r.userFullProjectionCache.Lookup(onlineViewerID, botID); ok {
t.Fatal("viewer userFull projection survived the badge change")
}
if _, ok := r.userFullProjectionCache.Lookup(botID, botID); ok {
t.Fatal("target userFull projection survived the badge change")
}
if users.adminCalls != 1 {
t.Fatalf("authoritative account reads = %d", users.adminCalls)
}
// Offline audience members are skipped; they converge through the bumped
// user_base read model on their next getDifference / getFullUser.
pushed := sessions.pushedUserIDs()
if len(pushed) != 2 || pushed[0] != botID || pushed[1] != onlineViewerID {
t.Fatalf("pushed user ids = %v", pushed)
}
if len(users.viewers) != 2 || users.viewers[0] != botID || users.viewers[1] != onlineViewerID {
t.Fatalf("re-projected viewers = %v", users.viewers)
}
updates, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("updates = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
}
refresh, ok := updates.Updates[0].(*tg.UpdateUser)
if !ok || refresh.UserID != botID {
t.Fatalf("refresh = %T %+v", updates.Updates[0], updates.Updates[0])
}
if len(updates.Users) != 1 {
t.Fatalf("users = %+v", updates.Users)
}
user, ok := updates.Users[0].(*tg.User)
if !ok || user.ID != botID || !user.Verified {
t.Fatalf("user = %T %+v", updates.Users[0], updates.Users[0])
}
if user.Scam || user.Fake {
t.Fatalf("badge push leaked moderation flags: %+v", user)
}
}
// TestNotifyPeerVerifiedChannelInvalidatesAndPushesUpdateChannel is the channel
// half: the badge rides the existing channel state-mutation fan-out.
func TestNotifyPeerVerifiedChannelInvalidatesAndPushesUpdateChannel(t *testing.T) {
const (
channelID = int64(4004)
ownerID = int64(3003)
memberID = int64(3004)
)
channels := &verifiedNotifyChannels{channel: domain.Channel{
ID: channelID, AccessHash: 44, CreatorUserID: ownerID,
Title: "Official", Username: "official", Broadcast: true, Verified: true,
}}
sessions := &captureSessions{
onlineUserIDs: []int64{ownerID, memberID},
channelMembers: map[int64][]int64{channelID: {ownerID, memberID}},
}
r := New(Config{}, Deps{Channels: channels, Sessions: sessions}, zap.NewNop(), clock.System)
seedChannelFullProjection(t, r, ownerID, channelID)
seedChannelFullProjection(t, r, memberID, channelID)
if err := r.NotifyPeerVerified(context.Background(), domain.Peer{
Type: domain.PeerTypeChannel, ID: channelID,
}); err != nil {
t.Fatalf("notify peer verified: %v", err)
}
if _, ok := r.channelFullProjectionCache.Lookup(ownerID, channelID); ok {
t.Fatal("owner channelFull projection survived the badge change")
}
if _, ok := r.channelFullProjectionCache.Lookup(memberID, channelID); ok {
t.Fatal("member channelFull projection survived the badge change")
}
if channels.calls != 1 {
t.Fatalf("base channel row reads = %d", channels.calls)
}
if pushed := sessions.pushedUserIDs(); len(pushed) != 2 {
t.Fatalf("pushed user ids = %v", pushed)
}
updates, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("updates = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
}
refresh, ok := updates.Updates[0].(*tg.UpdateChannel)
if !ok || refresh.ChannelID != channelID {
t.Fatalf("refresh = %T %+v", updates.Updates[0], updates.Updates[0])
}
if len(updates.Chats) != 1 {
t.Fatalf("chats = %+v", updates.Chats)
}
channel, ok := updates.Chats[0].(*tg.Channel)
if !ok || channel.ID != channelID || !channel.Verified {
t.Fatalf("channel = %T %+v", updates.Chats[0], updates.Chats[0])
}
if channel.Scam || channel.Fake {
t.Fatalf("badge push leaked moderation flags: %+v", channel)
}
}
// TestNotifyPeerVerifiedNilRouterIsSafe pins the nil-receiver contract: the hook is
// invoked after the decision already committed, so it may never panic.
func TestNotifyPeerVerifiedNilRouterIsSafe(t *testing.T) {
var r *Router
if err := r.NotifyPeerVerified(context.Background(), domain.Peer{
Type: domain.PeerTypeUser, ID: 1001,
}); err != nil {
t.Fatalf("nil router notify = %v", err)
}
}
// TestNotifyPeerVerifiedWithoutServicesStillInvalidates covers the degraded wiring:
// with no user/channel service the hook cannot push, but the stale projection must
// still be dropped and no error reported.
func TestNotifyPeerVerifiedWithoutServicesStillInvalidates(t *testing.T) {
r := New(Config{}, Deps{}, zap.NewNop(), clock.System)
seedUserFullProjection(t, r, 1001, 2002)
seedChannelFullProjection(t, r, 1001, 4004)
if err := r.NotifyPeerVerified(context.Background(), domain.Peer{
Type: domain.PeerTypeUser, ID: 2002,
}); err != nil {
t.Fatalf("notify user without users service = %v", err)
}
if err := r.NotifyPeerVerified(context.Background(), domain.Peer{
Type: domain.PeerTypeChannel, ID: 4004,
}); err != nil {
t.Fatalf("notify channel without channels service = %v", err)
}
if _, ok := r.userFullProjectionCache.Lookup(1001, 2002); ok {
t.Fatal("userFull projection survived without a users service")
}
if _, ok := r.channelFullProjectionCache.Lookup(1001, 4004); ok {
t.Fatal("channelFull projection survived without a channels service")
}
}
// TestNotifyPeerVerifiedRejectsUnknownPeers pins the "explain, never panic"
// contract for every peer the hook cannot act on.
func TestNotifyPeerVerifiedRejectsUnknownPeers(t *testing.T) {
users := &verifiedNotifyUsers{}
channels := &verifiedNotifyChannels{}
sessions := &captureSessions{}
r := New(Config{}, Deps{Users: users, Channels: channels, Sessions: sessions}, zap.NewNop(), clock.System)
ctx := context.Background()
for _, tc := range []struct {
name string
peer domain.Peer
want string
}{
{"zero id", domain.Peer{Type: domain.PeerTypeUser}, "invalid peer id"},
{"negative id", domain.Peer{Type: domain.PeerTypeChannel, ID: -1}, "invalid peer id"},
{"community peer", domain.Peer{Type: domain.PeerTypeCommunity, ID: 5005}, "unsupported peer type"},
{"empty type", domain.Peer{ID: 5005}, "unsupported peer type"},
{"missing user", domain.Peer{Type: domain.PeerTypeUser, ID: 2002}, "user 2002 not found"},
{"missing channel", domain.Peer{Type: domain.PeerTypeChannel, ID: 4004}, "channel 4004 not found"},
} {
err := r.NotifyPeerVerified(ctx, tc.peer)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s: err = %v, want mention of %q", tc.name, err, tc.want)
}
}
if pushed := sessions.pushedUserIDs(); len(pushed) != 0 {
t.Fatalf("unresolved peers pushed updates to %v", pushed)
}
}
// TestNotifyPeerVerifiedReportsLookupFailures keeps a directory error distinct from
// "peer not found", and keeps a channels adapter without the base-row reader from
// failing silently.
func TestNotifyPeerVerifiedReportsLookupFailures(t *testing.T) {
ctx := context.Background()
loadErr := errors.New("boom")
failing := New(Config{}, Deps{
Channels: &verifiedNotifyChannels{err: loadErr},
Sessions: &captureSessions{},
}, zap.NewNop(), clock.System)
err := failing.NotifyPeerVerified(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: 4004})
if !errors.Is(err, loadErr) {
t.Fatalf("channel load error = %v", err)
}
unwired := New(Config{}, Deps{
Channels: channelsWithoutDirectory{},
Sessions: &captureSessions{},
}, zap.NewNop(), clock.System)
err = unwired.NotifyPeerVerified(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: 4004})
if err == nil || !strings.Contains(err.Error(), "GetChannelByID") {
t.Fatalf("missing channel directory error = %v", err)
}
}

View file

@ -0,0 +1,49 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// AccountRatingStore owns the composite rating read model and its contribution
// ledger.
//
// The read model is derived: SaveAccountRating writes a recomputed projection,
// AccountRatingSignals gathers the raw inputs from the contributing tables, and
// the ledger keeps manual adjustments that must survive a recompute.
type AccountRatingStore interface {
// AccountRating returns the stored projection. Missing rows return
// domain.ErrAccountRatingNotFound so callers can distinguish "never computed"
// from "computed as zero".
AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error)
// AccountRatingBatch resolves several users in one round trip. Users without
// a row are absent from the map.
AccountRatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error)
// SaveAccountRating upserts the projection using optimistic concurrency on
// the stored version; a stale version reports changed=false.
SaveAccountRating(ctx context.Context, rating domain.AccountRating) (stored domain.AccountRating, changed bool, err error)
// AccountRatingSignals gathers the raw contribution snapshot for one user,
// including the manual total carried from the ledger.
AccountRatingSignals(ctx context.Context, userID int64) (domain.AccountRatingSignals, error)
// AdjustAccountRating appends a manual adjustment. Replaying the same
// CommandKey returns the recorded event and applied=false.
AdjustAccountRating(ctx context.Context, req domain.AdjustAccountRatingRequest) (event domain.AccountRatingEvent, applied bool, err error)
// ListAccountRatings is the admin leaderboard query with keyset paging.
ListAccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error)
// AccountRatingEvents returns the ledger for one user, newest first.
AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error)
// StaleAccountRatings returns user ids whose projection is older than the
// given horizon, for the background recompute worker.
StaleAccountRatings(ctx context.Context, olderThanUnix int64, limit int) ([]int64, error)
// UnratedAccounts returns user ids that have no projection at all, oldest
// account first, for the same worker.
//
// Without this the read model can never populate itself: StaleAccountRatings
// walks account_rating, so it can only refresh rows that already exist, and the
// very first row for a user would have to come from an operator recomputing
// that user by hand. Seeding is what makes the local admin leaderboard useful.
//
// Deleted accounts and bots are excluded: neither has a rating to show.
UnratedAccounts(ctx context.Context, limit int) ([]int64, error)
}

View file

@ -0,0 +1,97 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// BotVerificationStore owns third-party verification: the icon catalogue, verifier
// status, the granted marks and the application queue in front of them.
//
// Reads on the projection path (PeerVerification / PeerVerificationBatch) are on
// every peer serialisation, so they must be cheap. The wire model carries one
// BotVerification, and the store therefore permits exactly one mark per peer.
type BotVerificationStore interface {
// --- icon catalogue ---
// UpsertVerificationIcon adds or updates a catalogue entry by document id.
UpsertVerificationIcon(ctx context.Context, icon domain.VerificationIcon) (domain.VerificationIcon, error)
// SetVerificationIconActive retires or restores an entry. Marks already granted
// with it keep rendering: the icon id is denormalised onto the mark.
SetVerificationIconActive(ctx context.Context, iconID int64, active bool) (domain.VerificationIcon, error)
// VerificationIcon reads one entry by id.
VerificationIcon(ctx context.Context, iconID int64) (domain.VerificationIcon, error)
// VerificationIconByDocument reads one entry by its custom emoji document id.
VerificationIconByDocument(ctx context.Context, documentID int64) (domain.VerificationIcon, error)
// ListVerificationIcons lists the catalogue, newest first.
ListVerificationIcons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error)
// --- verifier status ---
// UpsertBotVerifierSettings grants or updates verifier status. Optimistic
// locking on the stored version keeps two operators from clobbering each other.
UpsertBotVerifierSettings(ctx context.Context, settings domain.BotVerifierSettings) (domain.BotVerifierSettings, error)
// SetBotVerifierEnabled flips the operator kill switch. Existing marks stay,
// but the verifier can grant nothing new and its settings stop being projected.
SetBotVerifierEnabled(ctx context.Context, botID int64, enabled bool) (domain.BotVerifierSettings, error)
// DeleteBotVerifierSettings removes verifier status; its marks cascade away with
// it, because a mark whose verifier no longer exists has nothing to render.
DeleteBotVerifierSettings(ctx context.Context, botID int64) (bool, error)
// BotVerifierSettings reads one verifier's status, enabled or not.
BotVerifierSettings(ctx context.Context, botID int64) (domain.BotVerifierSettings, error)
// BotVerifierSettingsBatch resolves several bots in one round trip for the
// botInfo projection; bots without verifier status are absent from the map.
BotVerifierSettingsBatch(ctx context.Context, botIDs []int64) (map[int64]domain.BotVerifierSettings, error)
// ListBotVerifiers lists verifier bots for the admin panel.
ListBotVerifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error)
// --- granted marks ---
// GrantCustomVerification creates or updates the peer's mark. A different
// verifier replaces the current mark rather than leaving hidden fallback state.
// The icon is taken from the verifier's settings at grant time and the caller
// has already resolved the description through
// domain.BotVerifierSettings.DescriptionFor.
GrantCustomVerification(ctx context.Context, mark domain.CustomVerification) (domain.CustomVerification, bool, error)
// RevokeCustomVerification removes this verifier's mark from the peer and
// reports whether anything was removed, so a repeated revoke is a no-op.
RevokeCustomVerification(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error)
// CustomVerification reads one verifier's mark on a peer.
CustomVerification(ctx context.Context, verifierBotID int64, peer domain.Peer) (domain.CustomVerification, error)
// PeerVerification returns the peer's single mark. Missing marks report
// domain.ErrCustomVerificationNotFound.
PeerVerification(ctx context.Context, peer domain.Peer) (domain.CustomVerification, error)
// PeerVerificationBatch resolves the projection for many peers at once. This is
// the call on the hot serialisation path, so peers without a mark are simply
// absent instead of erroring.
PeerVerificationBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer]domain.CustomVerification, error)
// CountCustomVerifications reports how many peers a verifier has marked, for the
// per-verifier bound.
CountCustomVerifications(ctx context.Context, verifierBotID int64) (int, error)
// ListCustomVerifications is the admin listing query with keyset paging.
ListCustomVerifications(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error)
// --- application queue ---
// CreateCustomVerificationRequest files an application. A pending application on
// the same (verifier, peer) reports domain.ErrCustomVerificationRequestExists.
CreateCustomVerificationRequest(ctx context.Context, req domain.CustomVerificationRequest) (domain.CustomVerificationRequest, error)
// DecideCustomVerificationRequest moves an application through its status
// machine. approve=true grants the mark in the same transaction through the
// supplied callback, so an approved application can never exist without its
// mark; revoke removes it the same way.
DecideCustomVerificationRequest(ctx context.Context, requestID int64, version int64, status domain.CustomVerificationRequestStatus, decidedBy, reason, note string, apply func(ctx context.Context, req domain.CustomVerificationRequest) error) (domain.CustomVerificationRequest, bool, error)
// CustomVerificationRequest reads one application.
CustomVerificationRequest(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error)
// PendingCustomVerificationRequest returns the live application for a
// (verifier, peer) pair, if any.
PendingCustomVerificationRequest(ctx context.Context, verifierBotID int64, peer domain.Peer) (domain.CustomVerificationRequest, error)
// ListCustomVerificationRequests is the review-queue query with keyset paging.
ListCustomVerificationRequests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error)
// CustomVerificationRequestsForApplicant returns an applicant's own history for
// the verifier bot's /status command.
CustomVerificationRequestsForApplicant(ctx context.Context, applicantUserID int64, limit int) ([]domain.CustomVerificationRequest, error)
// CustomVerificationRequestCounts is the queue summary by status.
CustomVerificationRequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error)
}

View file

@ -0,0 +1,60 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// UsernameRegistryStore reads a peer's full username list. The list is the
// projection source for the TL usernames vector, so every caller sees the same
// order the client will render: editable slot first, then collectibles.
type UsernameRegistryStore interface {
// PeerUsernames returns the peer's registry rows in projection order.
PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error)
// PeerUsernamesBatch resolves several peers in one round trip; peers absent
// from the result simply hold no usernames.
PeerUsernamesBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error)
// SetUsernameActive toggles one collectible row. It never touches the
// editable slot and returns domain.ErrUsernameNotCollectible if asked to.
SetUsernameActive(ctx context.Context, peer domain.Peer, username string, active bool) (bool, error)
// ReorderUsernames rewrites collectible sort order. order must be a
// permutation of the peer's collectible usernames.
ReorderUsernames(ctx context.Context, peer domain.Peer, order []string) (bool, error)
// DeactivateAllUsernames clears the active flag on every collectible row and
// reports whether anything changed. The editable slot is left alone.
DeactivateAllUsernames(ctx context.Context, peer domain.Peer) (bool, error)
}
// CollectibleUsernameStore owns the collectible asset lifecycle: minting into the
// operator vault, assigning to a holder, returning to the vault and burning.
//
// Every mutation is expected to be atomic with the corresponding username
// registry row, so an asset can never be owned without being resolvable and can
// never be resolvable without being owned.
type CollectibleUsernameStore interface {
// MintCollectibleUsername creates the asset. A request carrying an owner
// assigns it in the same transaction. Replaying the same CommandKey returns
// the original asset and reports created=false.
MintCollectibleUsername(ctx context.Context, req domain.MintCollectibleUsernameRequest) (asset domain.CollectibleUsername, created bool, err error)
// TransferCollectibleUsername moves the asset to req.To, out of the vault or
// from the current holder. Replay by CommandKey is a no-op returning the
// current state with changed=false.
TransferCollectibleUsername(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (asset domain.CollectibleUsername, changed bool, err error)
// RevokeCollectibleUsername returns the asset to the vault, or burns it when
// req.Burn is set. Burning releases the name back to the free pool.
RevokeCollectibleUsername(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (asset domain.CollectibleUsername, changed bool, err error)
// DeleteCollectibleUsername removes the live asset for a name completely --
// registry row, asset and provenance -- and frees the name for any use. A
// replay finds nothing live left and reports deleted=false without an error,
// because the record a command key would key on is gone.
DeleteCollectibleUsername(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (deleted bool, err error)
// CollectibleUsername looks the asset up by name.
CollectibleUsername(ctx context.Context, username string) (domain.CollectibleUsername, error)
// CollectibleUsernameByID looks the asset up by identity.
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
// ListCollectibleUsernames is the admin listing query with keyset paging.
ListCollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
// CollectibleUsernameTransfers returns the provenance log, newest first.
CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
}

View file

@ -0,0 +1,392 @@
package memory
import (
"context"
"sort"
"sync"
"time"
"telesrv/internal/domain"
)
// Default page sizes for the rating reads, so an unset limit resolves to a
// finite page the way the PostgreSQL LIMIT does.
const (
defaultAccountRatingListLimit = 50
defaultAccountRatingEventLimit = 50
defaultAccountRatingStaleLimit = 50
)
// AccountRatingStore is the in-memory implementation of store.AccountRatingStore.
// It reproduces the invariants migration 0151 encodes:
//
// - account_rating is keyed by user_id, so one projection row per user.
// - the version CHECK plus optimistic concurrency: a write is applied only when
// it carries the successor of the stored version, which is exactly what
// domain.ResolveAccountRatingPending produces.
// - the pending pair CHECK: a pending delta and its date exist together or not
// at all.
// - the component CHECKs: stars/activity/penalty components and level are
// non-negative, and next_level_stars is either absent or above
// current_level_stars.
// - account_rating_events_command_idx: a replayed command key never appends a
// second adjustment.
type AccountRatingStore struct {
mu sync.Mutex
nextID int64
// ratings is the account_rating read model.
ratings map[int64]domain.AccountRating
// events is the append-only contribution ledger in insertion order.
events []domain.AccountRatingEvent
// commands maps an adjustment command key onto the ledger row it created.
commands map[string]int64
// signals holds the raw contribution snapshot per user.
//
// PostgreSQL aggregates it from stars_transactions, message counts, saved
// gifts and moderation cases. In memory those live in unrelated store types
// (StarsStore, MessageStore, StarGiftStore, ModerationReportStore) that this
// store has no handle on, and wiring them in would make the rating depend on
// which stores a test happens to construct. The snapshot is therefore
// injected -- deterministic, and identical for a unit test and for the
// recompute worker, which is what domain.AccountRatingSignals promises. Only
// the manual total is derived here, from the ledger, because the ledger is
// this store's own data.
signals map[int64]domain.AccountRatingSignals
// accounts is the account universe UnratedAccounts seeds from, in declaration
// order.
//
// PostgreSQL reads it from the users table. This store has no users table and
// inventing one from whichever ids happen to appear in the ledger would be
// circular -- an account with no rating and no adjustment is exactly the case
// seeding exists for. So the universe is declared, like signals above.
accounts []int64
}
// NewAccountRatingStore creates an empty rating store.
func NewAccountRatingStore() *AccountRatingStore {
return &AccountRatingStore{
nextID: 1,
ratings: make(map[int64]domain.AccountRating),
commands: make(map[string]int64),
signals: make(map[int64]domain.AccountRatingSignals),
}
}
// SeedAccountRatingSignals installs raw contribution snapshots for tests in other
// packages; see the signals field for why they are injected rather than derived.
func (s *AccountRatingStore) SeedAccountRatingSignals(signals ...domain.AccountRatingSignals) {
for _, item := range signals {
s.setAccountRatingSignals(item)
}
}
// setAccountRatingSignals is the same hook for this package's tests.
func (s *AccountRatingStore) setAccountRatingSignals(signals domain.AccountRatingSignals) {
if signals.UserID <= 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.signals[signals.UserID] = signals
}
// AccountRating returns the stored projection, or domain.ErrAccountRatingNotFound
// when the user was never computed.
func (s *AccountRatingStore) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
s.mu.Lock()
defer s.mu.Unlock()
rating, ok := s.ratings[userID]
if !ok {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
return rating, nil
}
// AccountRatingBatch resolves several users at once; users without a row are
// absent from the map.
func (s *AccountRatingStore) AccountRatingBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
out := make(map[int64]domain.AccountRating, len(userIDs))
if len(userIDs) == 0 {
return out, nil
}
s.mu.Lock()
defer s.mu.Unlock()
for _, userID := range userIDs {
if userID <= 0 {
continue
}
if rating, ok := s.ratings[userID]; ok {
out[userID] = rating
}
}
return out, nil
}
// SaveAccountRating upserts the projection under optimistic concurrency: the
// incoming Version must be the successor of the stored one, which is what
// domain.ResolveAccountRatingPending computes. A stale or missing version leaves
// the stored row untouched and reports changed=false, so a caller that lost a
// race can re-read and retry.
func (s *AccountRatingStore) SaveAccountRating(_ context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
if rating.UserID <= 0 {
// account_rating.user_id references users(id): there is no row to write.
return domain.AccountRating{}, false, domain.ErrAccountRatingNotFound
}
s.mu.Lock()
defer s.mu.Unlock()
stored, exists := s.ratings[rating.UserID]
if rating.Version != stored.Version+1 {
if !exists {
return domain.AccountRating{}, false, nil
}
return stored, false, nil
}
next := normalizeAccountRating(rating)
s.ratings[next.UserID] = next
return next, true, nil
}
// AccountRatingSignals returns the injected raw snapshot with the manual total
// taken from the ledger, mirroring the PostgreSQL aggregate. A user with no
// contributions reports zeros rather than an error, because the aggregate has no
// "missing row" state.
func (s *AccountRatingStore) AccountRatingSignals(_ context.Context, userID int64) (domain.AccountRatingSignals, error) {
if userID <= 0 {
return domain.AccountRatingSignals{}, domain.ErrAccountRatingNotFound
}
s.mu.Lock()
defer s.mu.Unlock()
signals := s.signals[userID]
signals.UserID = userID
signals.Manual += s.manualTotalLocked(userID)
return signals, nil
}
// AdjustAccountRating appends a manual adjustment. A replayed command key returns
// the recorded event with applied=false and appends nothing.
func (s *AccountRatingStore) AdjustAccountRating(_ context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
if err := req.Validate(); err != nil {
return domain.AccountRatingEvent{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if req.CommandKey != "" {
if id, ok := s.commands[req.CommandKey]; ok {
for _, event := range s.events {
if event.ID == id {
return event, false, nil
}
}
}
}
event := domain.AccountRatingEvent{
ID: s.nextID,
UserID: req.UserID,
Kind: domain.AccountRatingEventManual,
Amount: req.Amount,
Reason: req.Reason,
Actor: req.Actor,
CommandKey: req.CommandKey,
CreatedAt: time.Now().UTC(),
}
s.nextID++
s.events = append(s.events, event)
if event.CommandKey != "" {
s.commands[event.CommandKey] = event.ID
}
return event, true, nil
}
// ListAccountRatings is the admin leaderboard: level desc, stars desc, user id
// asc, matching account_rating_leaderboard_idx. BeforeID is the keyset cursor and
// names the last row of the previous page.
func (s *AccountRatingStore) ListAccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
limit := filter.Limit
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
s.mu.Lock()
defer s.mu.Unlock()
cursor, hasCursor := s.ratings[filter.BeforeID]
out := make([]domain.AccountRating, 0, len(s.ratings))
for _, rating := range s.ratings {
if filter.MinLevel > 0 && rating.Level < filter.MinLevel {
continue
}
if filter.UserID > 0 && rating.UserID != filter.UserID {
continue
}
switch {
case filter.BeforeID <= 0:
case hasCursor:
// Keyset paging over the leaderboard order.
if !accountRatingLess(cursor, rating) {
continue
}
default:
// The cursor row is gone; fall back to the id tiebreak alone so paging
// still terminates.
if rating.UserID <= filter.BeforeID {
continue
}
}
out = append(out, rating)
}
sort.Slice(out, func(i, j int) bool { return accountRatingLess(out[i], out[j]) })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// AccountRatingEvents returns the ledger for one user, newest first.
func (s *AccountRatingStore) AccountRatingEvents(_ context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
if limit <= 0 {
limit = defaultAccountRatingEventLimit
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.AccountRatingEvent, 0, limit)
for i := len(s.events) - 1; i >= 0 && len(out) < limit; i-- {
if s.events[i].UserID != userID {
continue
}
out = append(out, s.events[i])
}
return out, nil
}
// StaleAccountRatings returns the users whose projection predates the horizon,
// oldest first, which is the order account_rating_stale_idx serves.
func (s *AccountRatingStore) StaleAccountRatings(_ context.Context, olderThanUnix int64, limit int) ([]int64, error) {
if limit <= 0 {
limit = defaultAccountRatingStaleLimit
}
horizon := time.Unix(olderThanUnix, 0).UTC()
s.mu.Lock()
defer s.mu.Unlock()
stale := make([]domain.AccountRating, 0, len(s.ratings))
for _, rating := range s.ratings {
if rating.ComputedAt.Before(horizon) {
stale = append(stale, rating)
}
}
sort.Slice(stale, func(i, j int) bool {
if !stale[i].ComputedAt.Equal(stale[j].ComputedAt) {
return stale[i].ComputedAt.Before(stale[j].ComputedAt)
}
return stale[i].UserID < stale[j].UserID
})
if len(stale) > limit {
stale = stale[:limit]
}
out := make([]int64, 0, len(stale))
for _, rating := range stale {
out = append(out, rating.UserID)
}
return out, nil
}
// SeedAccounts declares the account universe UnratedAccounts walks. Repeating an
// id is a no-op, so a test can declare accounts as it creates them.
func (s *AccountRatingStore) SeedAccounts(userIDs ...int64) {
s.mu.Lock()
defer s.mu.Unlock()
known := make(map[int64]struct{}, len(s.accounts))
for _, id := range s.accounts {
known[id] = struct{}{}
}
for _, id := range userIDs {
if id <= 0 {
continue
}
if _, ok := known[id]; ok {
continue
}
known[id] = struct{}{}
s.accounts = append(s.accounts, id)
}
}
// UnratedAccounts returns declared accounts that have no projection yet, in
// declaration order -- the memory stand-in for PostgreSQL's oldest-account-first
// walk. A store nobody seeded reports no candidates rather than erroring: the
// worker treats that as "nothing to seed", which is the truth.
func (s *AccountRatingStore) UnratedAccounts(_ context.Context, limit int) ([]int64, error) {
if limit <= 0 {
limit = defaultAccountRatingStaleLimit
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]int64, 0, limit)
for _, id := range s.accounts {
if _, rated := s.ratings[id]; rated {
continue
}
out = append(out, id)
if len(out) == limit {
break
}
}
return out, nil
}
// manualTotalLocked sums the manual ledger rows, the only kind that survives a
// recompute.
func (s *AccountRatingStore) manualTotalLocked(userID int64) int64 {
var total int64
for _, event := range s.events {
if event.UserID == userID && event.Kind == domain.AccountRatingEventManual {
total += event.Amount
}
}
return total
}
// normalizeAccountRating makes the rows the table's CHECK constraints forbid
// unrepresentable. PostgreSQL raises an opaque constraint error there rather than
// a domain error, so the memory store folds the impossible shapes onto the
// closest representable one instead of inventing an error the RPC layer would
// then have to handle only in tests.
func normalizeAccountRating(rating domain.AccountRating) domain.AccountRating {
if rating.Level < 0 {
rating.Level = 0
}
if rating.Level > domain.MaxAccountRatingLevel {
rating.Level = domain.MaxAccountRatingLevel
}
if rating.CurrentLevelStars < 0 {
rating.CurrentLevelStars = 0
}
if rating.StarsComponent < 0 {
rating.StarsComponent = 0
}
if rating.ActivityComponent < 0 {
rating.ActivityComponent = 0
}
if rating.PenaltyComponent < 0 {
rating.PenaltyComponent = 0
}
if !rating.HasNextLevel || rating.NextLevelStars <= rating.CurrentLevelStars {
rating.HasNextLevel = false
rating.NextLevelStars = 0
}
// The pending delta and its date only exist together.
if rating.PendingStars == 0 || rating.PendingDate.IsZero() {
rating.PendingStars = 0
rating.PendingDate = time.Time{}
}
return rating
}
// accountRatingLess is the leaderboard order: highest level first, then the
// larger score, then the lower user id as a stable tiebreak.
func accountRatingLess(a, b domain.AccountRating) bool {
if a.Level != b.Level {
return a.Level > b.Level
}
if a.Stars != b.Stars {
return a.Stars > b.Stars
}
return a.UserID < b.UserID
}

View file

@ -0,0 +1,425 @@
package memory
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
var _ store.AccountRatingStore = (*AccountRatingStore)(nil)
// accountRatingFixture builds a projection the table's CHECK constraints accept.
func accountRatingFixture(userID, stars, version int64, computedAt time.Time) domain.AccountRating {
level, current, next, hasNext := domain.AccountRatingLevelForStars(stars)
return domain.AccountRating{
UserID: userID,
Level: level,
Stars: stars,
CurrentLevelStars: current,
NextLevelStars: next,
HasNextLevel: hasNext,
StarsComponent: stars,
ComputedAt: computedAt,
UpdatedAt: computedAt,
Version: version,
}
}
func TestSaveAccountRating(t *testing.T) {
ctx := context.Background()
now := time.Unix(1700000000, 0).UTC()
tests := []struct {
name string
seed []domain.AccountRating
input domain.AccountRating
wantErr error
wantChanged bool
check func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating)
}{
{
name: "insert",
input: accountRatingFixture(11, 450, 1, now),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.Level != 2 || stored.Stars != 450 || stored.Version != 1 ||
stored.CurrentLevelStars != 400 || stored.NextLevelStars != 900 || !stored.HasNextLevel {
t.Fatalf("stored=%+v", stored)
}
read, err := s.AccountRating(ctx, 11)
if err != nil || read != stored {
t.Fatalf("read=%+v err=%v", read, err)
}
},
},
{
name: "successor version applied",
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
input: accountRatingFixture(11, 1000, 2, now.Add(time.Minute)),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.Version != 2 || stored.Stars != 1000 || stored.Level != 3 {
t.Fatalf("stored=%+v", stored)
}
},
},
{
name: "replayed version is stale",
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
input: accountRatingFixture(11, 9999, 1, now.Add(time.Minute)),
wantChanged: false,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
// The loser of the race gets the current row back, untouched.
if stored.Stars != 450 || stored.Version != 1 {
t.Fatalf("stored=%+v", stored)
}
read, err := s.AccountRating(ctx, 11)
if err != nil || read.Stars != 450 || read.Version != 1 {
t.Fatalf("read=%+v err=%v", read, err)
}
},
},
{
name: "version from the future is rejected",
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
input: accountRatingFixture(11, 9999, 7, now.Add(time.Minute)),
wantChanged: false,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.Stars != 450 || stored.Version != 1 {
t.Fatalf("stored=%+v", stored)
}
},
},
{
name: "insert must carry version one",
input: accountRatingFixture(11, 450, 3, now),
wantChanged: false,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored != (domain.AccountRating{}) {
t.Fatalf("stored=%+v", stored)
}
if _, err := s.AccountRating(ctx, 11); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("row was written: %v", err)
}
},
},
{
name: "no user",
input: accountRatingFixture(0, 450, 1, now),
wantErr: domain.ErrAccountRatingNotFound,
},
{
name: "pending delta without a date is dropped",
input: func() domain.AccountRating {
rating := accountRatingFixture(11, 450, 1, now)
rating.PendingStars = 120
return rating
}(),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.PendingStars != 0 || !stored.PendingDate.IsZero() {
t.Fatalf("stored=%+v", stored)
}
if _, ok := stored.PendingLevel(); ok {
t.Fatalf("pending projection survived: %+v", stored)
}
},
},
{
name: "pending pair is kept",
input: func() domain.AccountRating {
rating := accountRatingFixture(11, 450, 1, now)
rating.PendingStars = 500
rating.PendingDate = now.Add(time.Hour)
return rating
}(),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
pending, ok := stored.PendingLevel()
if !ok || pending.Stars != 950 || pending.Level != 3 {
t.Fatalf("pending=%+v ok=%v", pending, ok)
}
},
},
{
name: "impossible components are folded",
input: func() domain.AccountRating {
rating := accountRatingFixture(11, 450, 1, now)
rating.Level = -3
rating.StarsComponent = -10
rating.ActivityComponent = -1
rating.PenaltyComponent = -7
rating.CurrentLevelStars = -5
rating.NextLevelStars = -9
rating.HasNextLevel = true
return rating
}(),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.Level != 0 || stored.StarsComponent != 0 || stored.ActivityComponent != 0 ||
stored.PenaltyComponent != 0 || stored.CurrentLevelStars != 0 {
t.Fatalf("stored=%+v", stored)
}
if stored.HasNextLevel || stored.NextLevelStars != 0 {
t.Fatalf("next level survived: %+v", stored)
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := NewAccountRatingStore()
for _, seed := range tc.seed {
if _, changed, err := s.SaveAccountRating(ctx, seed); err != nil || !changed {
t.Fatalf("seed changed=%v err=%v", changed, err)
}
}
stored, changed, err := s.SaveAccountRating(ctx, tc.input)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err=%v want %v", err, tc.wantErr)
}
if changed != tc.wantChanged {
t.Fatalf("changed=%v want %v", changed, tc.wantChanged)
}
if tc.wantErr != nil {
return
}
if tc.check != nil {
tc.check(t, s, stored)
}
})
}
}
func TestAccountRatingReadsAndLeaderboard(t *testing.T) {
ctx := context.Background()
now := time.Unix(1700000000, 0).UTC()
s := NewAccountRatingStore()
for _, rating := range []domain.AccountRating{
accountRatingFixture(11, 2500, 1, now),
accountRatingFixture(12, 450, 1, now),
accountRatingFixture(13, 2500, 1, now),
accountRatingFixture(14, 0, 1, now),
} {
if _, changed, err := s.SaveAccountRating(ctx, rating); err != nil || !changed {
t.Fatalf("seed %d changed=%v err=%v", rating.UserID, changed, err)
}
}
batch, err := s.AccountRatingBatch(ctx, []int64{11, 13, 99, 0, 11})
if err != nil || len(batch) != 2 {
t.Fatalf("batch=%+v err=%v", batch, err)
}
if batch[11].Stars != 2500 || batch[13].Stars != 2500 {
t.Fatalf("batch=%+v", batch)
}
if empty, err := s.AccountRatingBatch(ctx, nil); err != nil || len(empty) != 0 {
t.Fatalf("empty batch=%+v err=%v", empty, err)
}
// level desc, stars desc, user id asc.
board, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{})
if err != nil || len(board) != 4 {
t.Fatalf("board=%+v err=%v", board, err)
}
want := []int64{11, 13, 12, 14}
for i, userID := range want {
if board[i].UserID != userID {
t.Fatalf("board order=%+v want %v", board, want)
}
}
page, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{Limit: 2})
if err != nil || len(page) != 2 || page[1].UserID != 13 {
t.Fatalf("page=%+v err=%v", page, err)
}
next, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{
BeforeID: page[len(page)-1].UserID, Limit: 2,
})
if err != nil || len(next) != 2 || next[0].UserID != 12 || next[1].UserID != 14 {
t.Fatalf("next=%+v err=%v", next, err)
}
filtered, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: 5})
if err != nil || len(filtered) != 2 {
t.Fatalf("filtered=%+v err=%v", filtered, err)
}
single, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{UserID: 12})
if err != nil || len(single) != 1 || single[0].UserID != 12 {
t.Fatalf("single=%+v err=%v", single, err)
}
if _, err := s.AccountRating(ctx, 99); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("unknown user err=%v", err)
}
}
func TestAdjustAccountRating(t *testing.T) {
ctx := context.Background()
s := NewAccountRatingStore()
req := domain.AdjustAccountRatingRequest{
UserID: 11, Amount: 750, Reason: "contest prize", Actor: "admin", CommandKey: "cmd-adjust",
}
event, applied, err := s.AdjustAccountRating(ctx, req)
if err != nil || !applied {
t.Fatalf("adjust applied=%v err=%v", applied, err)
}
if event.ID == 0 || event.Kind != domain.AccountRatingEventManual || event.Amount != 750 ||
event.Reason != "contest prize" || event.Actor != "admin" || event.CreatedAt.IsZero() {
t.Fatalf("event=%+v", event)
}
// Replaying the command key returns the recorded row and appends nothing.
replay, applied, err := s.AdjustAccountRating(ctx, req)
if err != nil || applied {
t.Fatalf("replay applied=%v err=%v", applied, err)
}
if replay != event {
t.Fatalf("replay=%+v want %+v", replay, event)
}
ledger, err := s.AccountRatingEvents(ctx, 11, 10)
if err != nil || len(ledger) != 1 {
t.Fatalf("ledger=%+v err=%v", ledger, err)
}
second := req
second.Amount = -200
second.CommandKey = "cmd-adjust-2"
if _, applied, err := s.AdjustAccountRating(ctx, second); err != nil || !applied {
t.Fatalf("second adjust applied=%v err=%v", applied, err)
}
// Newest first, and other users are not mixed in.
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: 12, Amount: 5, CommandKey: "cmd-other",
}); err != nil || !applied {
t.Fatalf("other user applied=%v err=%v", applied, err)
}
ledger, err = s.AccountRatingEvents(ctx, 11, 10)
if err != nil || len(ledger) != 2 || ledger[0].Amount != -200 || ledger[1].Amount != 750 {
t.Fatalf("ledger=%+v err=%v", ledger, err)
}
if capped, err := s.AccountRatingEvents(ctx, 11, 1); err != nil || len(capped) != 1 ||
capped[0].Amount != -200 {
t.Fatalf("capped=%+v err=%v", capped, err)
}
// An unkeyed adjustment is always appended.
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: 11, Amount: 10,
}); err != nil || !applied {
t.Fatalf("unkeyed applied=%v err=%v", applied, err)
}
for _, invalid := range []domain.AdjustAccountRatingRequest{
{UserID: 11, Amount: 0, CommandKey: "cmd-zero"},
{UserID: 0, Amount: 5, CommandKey: "cmd-nouser"},
} {
if _, applied, err := s.AdjustAccountRating(ctx, invalid); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) || applied {
t.Fatalf("invalid adjust applied=%v err=%v", applied, err)
}
}
// The manual total is carried out of the ledger into the signal snapshot.
signals, err := s.AccountRatingSignals(ctx, 11)
if err != nil || signals.UserID != 11 || signals.Manual != 560 {
t.Fatalf("signals=%+v err=%v", signals, err)
}
}
func TestAccountRatingSignals(t *testing.T) {
ctx := context.Background()
s := NewAccountRatingStore()
// A user with no contributions reports zeros rather than an error.
signals, err := s.AccountRatingSignals(ctx, 11)
if err != nil || signals != (domain.AccountRatingSignals{UserID: 11}) {
t.Fatalf("signals=%+v err=%v", signals, err)
}
if _, err := s.AccountRatingSignals(ctx, 0); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("missing user err=%v", err)
}
s.setAccountRatingSignals(domain.AccountRatingSignals{
UserID: 11, StarsReceived: 4000, StarsSpent: 2000, MessagesSent: 300,
AccountAgeDays: 100, GiftsReceived: 4, ModerationCases: 1,
})
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: 11, Amount: 250, CommandKey: "cmd-bonus",
}); err != nil || !applied {
t.Fatalf("adjust applied=%v err=%v", applied, err)
}
signals, err = s.AccountRatingSignals(ctx, 11)
if err != nil || signals.StarsReceived != 4000 || signals.MessagesSent != 300 ||
signals.ModerationCases != 1 || signals.Manual != 250 {
t.Fatalf("signals=%+v err=%v", signals, err)
}
// The snapshot feeds the domain formula, and the result round-trips.
now := time.Unix(1700000000, 0).UTC()
computed := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
stored, changed, err := s.SaveAccountRating(ctx, computed)
if err != nil || !changed {
t.Fatalf("save changed=%v err=%v", changed, err)
}
if stored.Stars != computed.Stars || stored.Level != computed.Level ||
stored.ManualComponent != 250 {
t.Fatalf("stored=%+v computed=%+v", stored, computed)
}
// A recompute uses the pending resolution the domain owns.
s.setAccountRatingSignals(domain.AccountRatingSignals{UserID: 11, StarsReceived: 40000})
signals, err = s.AccountRatingSignals(ctx, 11)
if err != nil {
t.Fatal(err)
}
recomputed := domain.ResolveAccountRatingPending(stored,
domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now.Add(time.Hour)),
24*time.Hour, now.Add(time.Hour))
saved, changed, err := s.SaveAccountRating(ctx, recomputed)
if err != nil || !changed || saved.Version != stored.Version+1 {
t.Fatalf("saved=%+v changed=%v err=%v", saved, changed, err)
}
if saved.PendingStars <= 0 || saved.PendingDate.IsZero() {
t.Fatalf("pending was not parked: %+v", saved)
}
}
func TestStaleAccountRatings(t *testing.T) {
ctx := context.Background()
now := time.Unix(1700000000, 0).UTC()
s := NewAccountRatingStore()
for _, rating := range []domain.AccountRating{
accountRatingFixture(11, 100, 1, now.Add(-3*time.Hour)),
accountRatingFixture(12, 100, 1, now.Add(-2*time.Hour)),
accountRatingFixture(13, 100, 1, now.Add(-time.Hour)),
accountRatingFixture(14, 100, 1, now),
} {
if _, changed, err := s.SaveAccountRating(ctx, rating); err != nil || !changed {
t.Fatalf("seed %d changed=%v err=%v", rating.UserID, changed, err)
}
}
stale, err := s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 10)
if err != nil || len(stale) != 2 || stale[0] != 11 || stale[1] != 12 {
t.Fatalf("stale=%v err=%v", stale, err)
}
if limited, err := s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 1); err != nil ||
len(limited) != 1 || limited[0] != 11 {
t.Fatalf("limited=%v err=%v", limited, err)
}
if none, err := s.StaleAccountRatings(ctx, now.Add(-4*time.Hour).Unix(), 10); err != nil || len(none) != 0 {
t.Fatalf("none=%v err=%v", none, err)
}
// A recompute refreshes computed_at and takes the row out of the horizon.
refreshed := accountRatingFixture(11, 100, 2, now)
if _, changed, err := s.SaveAccountRating(ctx, refreshed); err != nil || !changed {
t.Fatalf("refresh changed=%v err=%v", changed, err)
}
stale, err = s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 10)
if err != nil || len(stale) != 1 || stale[0] != 12 {
t.Fatalf("stale=%v err=%v", stale, err)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,851 @@
package memory
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"telesrv/internal/domain"
)
func botVerificationUserPeer(id int64) domain.Peer {
return domain.Peer{Type: domain.PeerTypeUser, ID: id}
}
func botVerificationChannelPeer(id int64) domain.Peer {
return domain.Peer{Type: domain.PeerTypeChannel, ID: id}
}
// botVerificationTestVerifier grants verifier status the way the admin edge does.
func botVerificationTestVerifier(t *testing.T, s *BotVerificationStore, botID, iconDocumentID int64) domain.BotVerifierSettings {
t.Helper()
settings, err := s.UpsertBotVerifierSettings(context.Background(), domain.BotVerifierSettings{
BotID: botID,
IconDocumentID: iconDocumentID,
CompanyName: fmt.Sprintf("Verifier %d", botID),
DefaultDescription: "verified by the test verifier",
CanModifyCustomDescription: true,
Enabled: true,
GrantedBy: "operator",
GrantReason: "test fixture",
})
if err != nil {
t.Fatalf("grant verifier %d: %v", botID, err)
}
return settings
}
// botVerificationTestRequest is an application that clears domain validation.
func botVerificationTestRequest(verifier, applicant int64, peer domain.Peer, username string) domain.CustomVerificationRequest {
return domain.CustomVerificationRequest{
VerifierBotID: verifier,
ApplicantUserID: applicant,
Peer: peer,
PeerTitle: "Target " + username,
PeerUsername: username,
Reason: "we run the official account for this brand",
RequestedDescription: "official brand account",
CorrelationID: fmt.Sprintf("corr-%d", peer.ID),
}
}
// TestBotVerificationIconCatalogueMemory covers the catalogue: an entry is keyed
// by document id, retiring one keeps it readable, and the listing pages newest
// first with the activeOnly filter honoured.
func TestBotVerificationIconCatalogueMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
first, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{
DocumentID: 5001, Name: " Blue check ", Active: true,
})
if err != nil {
t.Fatalf("upsert icon: %v", err)
}
if first.ID == 0 || first.Name != "Blue check" || !first.Active || first.OwnerBotID != 0 {
t.Fatalf("stored icon = %+v", first)
}
if first.CreatedAt.IsZero() || first.UpdatedAt.Before(first.CreatedAt) {
t.Fatalf("icon timestamps = %v / %v", first.CreatedAt, first.UpdatedAt)
}
second, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{
DocumentID: 5002, OwnerBotID: 777, Name: "Reserved", Active: true,
})
if err != nil {
t.Fatalf("upsert second icon: %v", err)
}
// document_id is the identity of an entry: the second upsert edits in place.
edited, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{
DocumentID: 5001, OwnerBotID: 42, Name: "Blue check v2", Active: true,
})
if err != nil {
t.Fatalf("re-upsert icon: %v", err)
}
if edited.ID != first.ID || edited.Name != "Blue check v2" || edited.OwnerBotID != 42 {
t.Fatalf("edited icon = %+v, want id %d", edited, first.ID)
}
if !edited.CreatedAt.Equal(first.CreatedAt) || !edited.UpdatedAt.After(first.UpdatedAt) {
t.Fatalf("edited timestamps = %v / %v", edited.CreatedAt, edited.UpdatedAt)
}
if _, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{DocumentID: 0, Name: "bad"}); !errors.Is(err, domain.ErrVerificationIconInvalid) {
t.Fatalf("upsert without document err = %v, want ErrVerificationIconInvalid", err)
}
if _, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{DocumentID: 9, Name: " "}); !errors.Is(err, domain.ErrVerificationIconInvalid) {
t.Fatalf("upsert without name err = %v, want ErrVerificationIconInvalid", err)
}
retired, err := s.SetVerificationIconActive(ctx, second.ID, false)
if err != nil {
t.Fatalf("retire icon: %v", err)
}
if retired.Active || retired.ID != second.ID {
t.Fatalf("retired icon = %+v", retired)
}
if _, err := s.SetVerificationIconActive(ctx, second.ID+1000, false); !errors.Is(err, domain.ErrVerificationIconNotFound) {
t.Fatalf("retire unknown err = %v, want ErrVerificationIconNotFound", err)
}
byDocument, err := s.VerificationIconByDocument(ctx, 5002)
if err != nil {
t.Fatalf("read icon by document: %v", err)
}
if byDocument.ID != second.ID || byDocument.Active {
t.Fatalf("icon by document = %+v", byDocument)
}
if _, err := s.VerificationIconByDocument(ctx, 999999); !errors.Is(err, domain.ErrVerificationIconNotFound) {
t.Fatalf("unknown document err = %v, want ErrVerificationIconNotFound", err)
}
byID, err := s.VerificationIcon(ctx, first.ID)
if err != nil || byID.DocumentID != 5001 {
t.Fatalf("read icon by id = %+v err=%v", byID, err)
}
if _, err := s.VerificationIcon(ctx, 0); !errors.Is(err, domain.ErrVerificationIconNotFound) {
t.Fatalf("icon id 0 err = %v, want ErrVerificationIconNotFound", err)
}
all, err := s.ListVerificationIcons(ctx, false, 0)
if err != nil {
t.Fatalf("list icons: %v", err)
}
if len(all) != 2 || all[0].ID != second.ID || all[1].ID != first.ID {
t.Fatalf("catalogue order = %+v, want newest first", all)
}
active, err := s.ListVerificationIcons(ctx, true, 0)
if err != nil {
t.Fatalf("list active icons: %v", err)
}
if len(active) != 1 || active[0].ID != first.ID {
t.Fatalf("active catalogue = %+v", active)
}
if page, err := s.ListVerificationIcons(ctx, false, 1); err != nil || len(page) != 1 {
t.Fatalf("icon page = %+v err=%v", page, err)
}
}
// TestBotVerifierSettingsLifecycleMemory covers verifier status: optimistic
// locking on version, the idempotent kill switch and the cascade that takes the
// marks with the verifier row.
func TestBotVerifierSettingsLifecycleMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
if _, err := s.BotVerifierSettings(ctx, 4242); !errors.Is(err, domain.ErrVerifierNotFound) {
t.Fatalf("unknown verifier err = %v, want ErrVerifierNotFound", err)
}
if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242, IconDocumentID: 7, CompanyName: "Acme", Version: 3,
}); !errors.Is(err, domain.ErrVerifierNotFound) {
t.Fatalf("versioned upsert of missing row err = %v, want ErrVerifierNotFound", err)
}
if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242, IconDocumentID: 0, CompanyName: "Acme",
}); !errors.Is(err, domain.ErrVerifierSettingsInvalid) {
t.Fatalf("iconless upsert err = %v, want ErrVerifierSettingsInvalid", err)
}
created := botVerificationTestVerifier(t, s, 4242, 5001)
if created.Version != 1 || !created.Enabled || created.CompanyName != "Verifier 4242" {
t.Fatalf("created verifier = %+v", created)
}
// Version 0 means "there is no row yet", so it loses against the stored row.
if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242, IconDocumentID: 5001, CompanyName: "Acme",
}); !errors.Is(err, domain.ErrCustomVerificationVersionConflict) {
t.Fatalf("re-create err = %v, want ErrCustomVerificationVersionConflict", err)
}
if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242, IconDocumentID: 5001, CompanyName: "Acme", Version: 99,
}); !errors.Is(err, domain.ErrCustomVerificationVersionConflict) {
t.Fatalf("stale upsert err = %v, want ErrCustomVerificationVersionConflict", err)
}
edited, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242,
IconDocumentID: 5002,
CompanyName: "Acme Media",
DefaultDescription: "checked by Acme",
Enabled: true,
Version: created.Version,
})
if err != nil {
t.Fatalf("edit verifier: %v", err)
}
if edited.Version != 2 || edited.IconDocumentID != 5002 || edited.CanModifyCustomDescription {
t.Fatalf("edited verifier = %+v", edited)
}
if !edited.CreatedAt.Equal(created.CreatedAt) || !edited.UpdatedAt.After(created.UpdatedAt) {
t.Fatalf("edited timestamps = %v / %v", edited.CreatedAt, edited.UpdatedAt)
}
disabled, err := s.SetBotVerifierEnabled(ctx, 4242, false)
if err != nil {
t.Fatalf("disable verifier: %v", err)
}
if disabled.Enabled || disabled.Version != edited.Version+1 {
t.Fatalf("disabled verifier = %+v", disabled)
}
again, err := s.SetBotVerifierEnabled(ctx, 4242, false)
if err != nil {
t.Fatalf("re-disable verifier: %v", err)
}
if again.Version != disabled.Version {
t.Fatalf("re-disable bumped version to %d", again.Version)
}
if _, err := s.SetBotVerifierEnabled(ctx, 777777, false); !errors.Is(err, domain.ErrVerifierNotFound) {
t.Fatalf("disable unknown err = %v, want ErrVerifierNotFound", err)
}
// A disabled verifier is still readable: the admin panel renders the switch.
stored, err := s.BotVerifierSettings(ctx, 4242)
if err != nil || stored.Enabled {
t.Fatalf("read disabled verifier = %+v err=%v", stored, err)
}
other := botVerificationTestVerifier(t, s, 1042, 5001)
batch, err := s.BotVerifierSettingsBatch(ctx, []int64{4242, 1042, 999999, 0})
if err != nil {
t.Fatalf("batch verifiers: %v", err)
}
if len(batch) != 2 || batch[4242].Enabled || !batch[1042].Enabled {
t.Fatalf("verifier batch = %+v", batch)
}
if _, absent := batch[999999]; absent {
t.Fatal("batch invented a verifier")
}
listed, err := s.ListBotVerifiers(ctx, false, 0)
if err != nil {
t.Fatalf("list verifiers: %v", err)
}
if len(listed) != 2 || listed[0].BotID != other.BotID || listed[1].BotID != 4242 {
t.Fatalf("verifier list = %+v, want bot id order", listed)
}
enabledOnly, err := s.ListBotVerifiers(ctx, true, 0)
if err != nil {
t.Fatalf("list enabled verifiers: %v", err)
}
if len(enabledOnly) != 1 || enabledOnly[0].BotID != other.BotID {
t.Fatalf("enabled verifier list = %+v", enabledOnly)
}
// Marks cascade with the verifier row; applications do not.
peer := botVerificationChannelPeer(9001)
if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: other.BotID, Peer: peer, Description: "cascade me",
}); err != nil {
t.Fatalf("grant before delete: %v", err)
}
req, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(other.BotID, 31337, peer, "CascadeChannel"))
if err != nil {
t.Fatalf("create request before delete: %v", err)
}
removed, err := s.DeleteBotVerifierSettings(ctx, other.BotID)
if err != nil || !removed {
t.Fatalf("delete verifier: removed=%v err=%v", removed, err)
}
if removed, err := s.DeleteBotVerifierSettings(ctx, other.BotID); err != nil || removed {
t.Fatalf("repeated delete: removed=%v err=%v", removed, err)
}
if _, err := s.CustomVerification(ctx, other.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("mark after cascade err = %v, want ErrCustomVerificationNotFound", err)
}
if count, err := s.CountCustomVerifications(ctx, other.BotID); err != nil || count != 0 {
t.Fatalf("mark count after cascade = %d err=%v", count, err)
}
if kept, err := s.CustomVerificationRequest(ctx, req.ID); err != nil || kept.ID != req.ID {
t.Fatalf("application after cascade = %+v err=%v", kept, err)
}
}
// TestCustomVerificationGrantAndProjectionMemory is the projection contract:
// exactly one mark exists per peer, a later verifier replaces the former one,
// a disabled verifier projects nothing while its row survives, and a repeated
// grant updates the mark in place.
func TestCustomVerificationGrantAndProjectionMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
alpha := botVerificationTestVerifier(t, s, 101, 5001)
beta := botVerificationTestVerifier(t, s, 102, 5002)
peer := botVerificationUserPeer(7001)
if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: 999, Peer: peer,
}); !errors.Is(err, domain.ErrVerifierNotFound) {
t.Fatalf("grant by non-verifier err = %v, want ErrVerifierNotFound", err)
}
if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: alpha.BotID, Peer: domain.Peer{Type: domain.PeerTypeCommunity, ID: 5},
}); !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) {
t.Fatalf("grant on community err = %v, want ErrCustomVerificationTargetInvalid", err)
}
// The icon is denormalised from the verifier when the caller leaves it unset.
alphaMark, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: alpha.BotID, Peer: peer, Description: "checked by alpha",
GrantedByUserID: 555,
})
if err != nil || !created {
t.Fatalf("grant alpha mark: created=%v err=%v", created, err)
}
if alphaMark.IconDocumentID != alpha.IconDocumentID || alphaMark.Version != 1 {
t.Fatalf("alpha mark = %+v", alphaMark)
}
if alphaMark.CreatedAt.IsZero() || !alphaMark.UpdatedAt.Equal(alphaMark.CreatedAt) {
t.Fatalf("alpha mark timestamps = %v / %v", alphaMark.CreatedAt, alphaMark.UpdatedAt)
}
if got, err := s.PeerVerification(ctx, peer); err != nil || got.ID != alphaMark.ID {
t.Fatalf("projection with one mark = %+v err=%v", got, err)
}
betaMark, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: beta.BotID, Peer: peer, Description: "checked by beta",
})
if err != nil || !created {
t.Fatalf("grant beta mark: created=%v err=%v", created, err)
}
if betaMark.ID != alphaMark.ID || betaMark.Version != alphaMark.Version+1 {
t.Fatalf("replacement mark = %+v, want id %d v%d", betaMark, alphaMark.ID, alphaMark.Version+1)
}
if _, err := s.CustomVerification(ctx, alpha.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("replaced alpha mark err = %v, want ErrCustomVerificationNotFound", err)
}
if count, err := s.CountCustomVerifications(ctx, alpha.BotID); err != nil || count != 0 {
t.Fatalf("alpha mark count after replacement = %d err=%v", count, err)
}
// One peer has one wire-visible mark, irrespective of how often it is read.
for i := 0; i < 3; i++ {
got, err := s.PeerVerification(ctx, peer)
if err != nil {
t.Fatalf("projection after replacement: %v", err)
}
if got.ID != betaMark.ID || got.VerifierBotID != beta.BotID {
t.Fatalf("projection = %+v, want replacement mark %d", got, betaMark.ID)
}
if got.Projection().Icon != beta.IconDocumentID {
t.Fatalf("projected icon = %d, want %d", got.Projection().Icon, beta.IconDocumentID)
}
}
// Kill switch: disabling the current verifier hides the badge. The replaced
// alpha mark must not silently reappear.
if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, false); err != nil {
t.Fatalf("disable beta: %v", err)
}
if _, err := s.PeerVerification(ctx, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("projection after disabling beta err=%v, want ErrCustomVerificationNotFound", err)
}
if stored, err := s.CustomVerification(ctx, beta.BotID, peer); err != nil || stored.ID != betaMark.ID {
t.Fatalf("disabled verifier lost its mark: %+v err=%v", stored, err)
}
if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, true); err != nil {
t.Fatalf("re-enable beta: %v", err)
}
if got, err := s.PeerVerification(ctx, peer); err != nil || got.ID != betaMark.ID {
t.Fatalf("projection after re-enabling beta = %+v err=%v", got, err)
}
// Granting through alpha replaces beta's mark on the same peer.
regranted, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: alpha.BotID, Peer: peer, IconDocumentID: 5099,
Description: "checked by alpha, again",
})
if err != nil || !created {
t.Fatalf("re-grant alpha mark: created=%v err=%v", created, err)
}
if regranted.ID != betaMark.ID || regranted.Version != betaMark.Version+1 {
t.Fatalf("re-granted mark = %+v, want id %d v%d", regranted, betaMark.ID, betaMark.Version+1)
}
if regranted.IconDocumentID != 5099 || regranted.Description != "checked by alpha, again" {
t.Fatalf("re-granted payload = %+v", regranted)
}
if regranted.CreatedAt.Before(betaMark.CreatedAt) || !regranted.UpdatedAt.After(betaMark.UpdatedAt) {
t.Fatalf("re-granted timestamps = %v / %v", regranted.CreatedAt, regranted.UpdatedAt)
}
if count, err := s.CountCustomVerifications(ctx, alpha.BotID); err != nil || count != 1 {
t.Fatalf("alpha mark count = %d err=%v", count, err)
}
if got, err := s.PeerVerification(ctx, peer); err != nil || got.VerifierBotID != alpha.BotID {
t.Fatalf("projection after re-grant = %+v err=%v", got, err)
}
// The batch form resolves several peers at once, with the same rules.
second := botVerificationChannelPeer(7002)
third := botVerificationUserPeer(7003)
unmarked := botVerificationChannelPeer(7004)
secondMark, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: alpha.BotID, Peer: second, Description: "second",
})
if err != nil {
t.Fatalf("grant second: %v", err)
}
thirdMark, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: beta.BotID, Peer: third, Description: "third",
})
if err != nil {
t.Fatalf("grant third: %v", err)
}
batch, err := s.PeerVerificationBatch(ctx,
[]domain.Peer{peer, second, third, unmarked, peer, {Type: domain.PeerTypeUser, ID: 0}})
if err != nil {
t.Fatalf("batch projection: %v", err)
}
if len(batch) != 3 {
t.Fatalf("batch projection = %+v, want 3 peers", batch)
}
if batch[peer].ID != regranted.ID || batch[second].ID != secondMark.ID ||
batch[third].ID != thirdMark.ID {
t.Fatalf("batch projection picked %+v", batch)
}
if _, present := batch[unmarked]; present {
t.Fatal("batch projected an unmarked peer")
}
if empty, err := s.PeerVerificationBatch(ctx, nil); err != nil || len(empty) != 0 {
t.Fatalf("empty batch = %+v err=%v", empty, err)
}
// Disabling a verifier drops its peers from the batch too.
if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, false); err != nil {
t.Fatalf("disable beta again: %v", err)
}
batch, err = s.PeerVerificationBatch(ctx, []domain.Peer{peer, second, third})
if err != nil {
t.Fatalf("batch projection after disable: %v", err)
}
if len(batch) != 2 || batch[peer].ID != regranted.ID || batch[second].ID != secondMark.ID {
t.Fatalf("batch after disable = %+v", batch)
}
if _, present := batch[third]; present {
t.Fatal("disabled verifier still projects in the batch")
}
if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, true); err != nil {
t.Fatalf("re-enable beta again: %v", err)
}
// Listing and paging over the marks.
all, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{})
if err != nil || len(all) != 3 {
t.Fatalf("list marks = %+v err=%v", all, err)
}
if all[0].ID != thirdMark.ID {
t.Fatalf("mark list order = %+v, want newest first", all)
}
page, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{Limit: 2})
if err != nil || len(page) != 2 {
t.Fatalf("mark page = %+v err=%v", page, err)
}
next, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
Limit: 2, BeforeID: page[len(page)-1].ID,
})
if err != nil || len(next) != 1 || next[0].ID >= page[len(page)-1].ID {
t.Fatalf("mark keyset page = %+v err=%v", next, err)
}
mine, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
VerifierBotID: alpha.BotID,
})
if err != nil || len(mine) != 2 {
t.Fatalf("verifier-filtered marks = %+v err=%v", mine, err)
}
channels, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
PeerType: domain.PeerTypeChannel,
})
if err != nil || len(channels) != 1 || channels[0].Peer != second {
t.Fatalf("channel-filtered marks = %+v err=%v", channels, err)
}
byPeer, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
PeerType: peer.Type, PeerID: peer.ID,
})
if err != nil || len(byPeer) != 1 {
t.Fatalf("peer-filtered marks = %+v err=%v", byPeer, err)
}
byQuery, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
Query: fmt.Sprintf("%d", second.ID),
})
if err != nil || len(byQuery) != 1 || byQuery[0].ID != secondMark.ID {
t.Fatalf("numeric mark query = %+v err=%v", byQuery, err)
}
byText, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{Query: "AGAIN"})
if err != nil || len(byText) != 1 || byText[0].ID != regranted.ID {
t.Fatalf("text mark query = %+v err=%v", byText, err)
}
if _, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
PeerType: domain.PeerTypeFolder,
}); !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) {
t.Fatalf("bad peer-type filter err = %v, want ErrCustomVerificationTargetInvalid", err)
}
// A replaced verifier cannot revoke the current mark.
revoked, err := s.RevokeCustomVerification(ctx, beta.BotID, peer)
if err != nil || revoked {
t.Fatalf("revoke replaced beta mark: revoked=%v err=%v", revoked, err)
}
if revoked, err := s.RevokeCustomVerification(ctx, beta.BotID, peer); err != nil || revoked {
t.Fatalf("repeated revoke: revoked=%v err=%v", revoked, err)
}
if got, err := s.PeerVerification(ctx, peer); err != nil || got.ID != regranted.ID {
t.Fatalf("projection after rejected revoke = %+v err=%v", got, err)
}
if revoked, err := s.RevokeCustomVerification(ctx, alpha.BotID, peer); err != nil || !revoked {
t.Fatalf("revoke alpha mark: revoked=%v err=%v", revoked, err)
}
if _, err := s.RevokeCustomVerification(ctx, 0, peer); !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) {
t.Fatalf("revoke without verifier err = %v, want ErrCustomVerificationTargetInvalid", err)
}
}
// TestCustomVerificationLimitMemory pins the per-verifier bound: a new mark is
// refused at the limit while an existing one can still be re-described.
func TestCustomVerificationLimitMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
verifier := botVerificationTestVerifier(t, s, 303, 5001)
for i := 1; i <= domain.MaxCustomVerificationsPerVerifier; i++ {
if _, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: verifier.BotID, Peer: botVerificationChannelPeer(int64(i)),
}); err != nil || !created {
t.Fatalf("grant %d: created=%v err=%v", i, created, err)
}
}
if count, err := s.CountCustomVerifications(ctx, verifier.BotID); err != nil ||
count != domain.MaxCustomVerificationsPerVerifier {
t.Fatalf("mark count = %d err=%v", count, err)
}
if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: verifier.BotID, Peer: botVerificationUserPeer(424242),
}); !errors.Is(err, domain.ErrCustomVerificationLimit) {
t.Fatalf("grant past the limit err = %v, want ErrCustomVerificationLimit", err)
}
// The bound is on creating marks, not on editing them.
if _, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: verifier.BotID, Peer: botVerificationChannelPeer(7),
Description: "still editable at the limit",
}); err != nil || created {
t.Fatalf("re-grant at the limit: created=%v err=%v", created, err)
}
// Freeing one slot lets the next grant through.
if revoked, err := s.RevokeCustomVerification(ctx, verifier.BotID,
botVerificationChannelPeer(1)); err != nil || !revoked {
t.Fatalf("free a slot: revoked=%v err=%v", revoked, err)
}
if _, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: verifier.BotID, Peer: botVerificationUserPeer(424242),
}); err != nil || !created {
t.Fatalf("grant into the freed slot: created=%v err=%v", created, err)
}
}
// TestCustomVerificationRequestQueueMemory covers the application queue: one
// pending application per (verifier, peer), the decision status machine, and the
// transaction that keeps an approved application and its mark together.
func TestCustomVerificationRequestQueueMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
verifier := botVerificationTestVerifier(t, s, 501, 5001)
peer := botVerificationChannelPeer(8001)
applicant := int64(6001)
grant := func(ctx context.Context, req domain.CustomVerificationRequest) error {
_, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: req.VerifierBotID,
Peer: req.Peer,
Description: req.RequestedDescription,
GrantedByUserID: req.ApplicantUserID,
})
return err
}
revoke := func(ctx context.Context, req domain.CustomVerificationRequest) error {
_, err := s.RevokeCustomVerification(ctx, req.VerifierBotID, req.Peer)
return err
}
filed, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(verifier.BotID, applicant, peer, "AcmeNews"))
if err != nil {
t.Fatalf("file application: %v", err)
}
if filed.Status != domain.CustomVerificationPending || filed.Version != 1 {
t.Fatalf("filed application = %s v%d", filed.Status, filed.Version)
}
if !filed.ApprovedAt.IsZero() || !filed.RejectedAt.IsZero() || filed.DecidedBy != "" {
t.Fatalf("filed application carries a decision: %+v", filed)
}
// custom_verification_requests_pending_idx: one live application per pair.
if _, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(verifier.BotID, applicant, peer, "AcmeNews")); !errors.Is(err, domain.ErrCustomVerificationRequestExists) {
t.Fatalf("duplicate pending err = %v, want ErrCustomVerificationRequestExists", err)
}
if _, err := s.CreateCustomVerificationRequest(ctx, domain.CustomVerificationRequest{
VerifierBotID: verifier.BotID, ApplicantUserID: applicant, Peer: peer,
Status: domain.CustomVerificationApproved,
}); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("pre-decided application err = %v, want ErrCustomVerificationRequestInvalid", err)
}
// The SQL byte bound reserves the worst-case UTF-8 width for the domain's
// rune limit, so valid multi-byte text must behave the same in both stores.
wideStore := NewBotVerificationStore()
wideVerifier := botVerificationTestVerifier(t, wideStore, 909, 5001)
wide := botVerificationTestRequest(wideVerifier.BotID, applicant, botVerificationUserPeer(8009), "Wide")
wide.Reason = strings.Repeat("é", domain.MaxCustomVerificationReasonLength-1)
if _, err := wideStore.CreateCustomVerificationRequest(ctx, wide); err != nil {
t.Fatalf("valid multi-byte reason: %v", err)
}
pending, err := s.PendingCustomVerificationRequest(ctx, verifier.BotID, peer)
if err != nil || pending.ID != filed.ID {
t.Fatalf("pending application = %+v err=%v", pending, err)
}
// A failing callback rolls the whole decision back, including what the
// callback itself wrote before it failed.
boom := errors.New("apply exploded")
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version,
domain.CustomVerificationApproved, "operator", "looks good", "",
func(ctx context.Context, req domain.CustomVerificationRequest) error {
if err := grant(ctx, req); err != nil {
return err
}
return boom
}); !errors.Is(err, boom) {
t.Fatalf("failing apply err = %v, want the callback error", err)
}
rolledBack, err := s.CustomVerificationRequest(ctx, filed.ID)
if err != nil {
t.Fatalf("read after rollback: %v", err)
}
if rolledBack.Status != domain.CustomVerificationPending || rolledBack.Version != filed.Version {
t.Fatalf("application after rollback = %s v%d, want pending v%d",
rolledBack.Status, rolledBack.Version, filed.Version)
}
if !rolledBack.ApprovedAt.IsZero() || rolledBack.DecidedBy != "" {
t.Fatalf("application after rollback carries a decision: %+v", rolledBack)
}
if _, err := s.CustomVerification(ctx, verifier.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("mark after rollback err = %v, want ErrCustomVerificationNotFound", err)
}
if _, err := s.PeerVerification(ctx, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("projection after rollback err = %v, want ErrCustomVerificationNotFound", err)
}
// Approving requires a callback: there is no "approved, mark later".
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version,
domain.CustomVerificationApproved, "operator", "", "", nil); err == nil {
t.Fatal("approve without a callback succeeded")
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version+7,
domain.CustomVerificationApproved, "operator", "", "", grant); !errors.Is(err, domain.ErrCustomVerificationVersionConflict) {
t.Fatalf("stale decision err = %v, want ErrCustomVerificationVersionConflict", err)
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version,
domain.CustomVerificationPending, "operator", "", "", nil); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("decision back to pending err = %v, want ErrCustomVerificationRequestInvalid", err)
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID+9000, filed.Version,
domain.CustomVerificationApproved, "operator", "", "", grant); !errors.Is(err, domain.ErrCustomVerificationRequestNotFound) {
t.Fatalf("decision on unknown application err = %v, want ErrCustomVerificationRequestNotFound", err)
}
approved, changed, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version,
domain.CustomVerificationApproved, "operator", "brand confirmed", "ticket 12", grant)
if err != nil || !changed {
t.Fatalf("approve: changed=%v err=%v", changed, err)
}
if approved.Status != domain.CustomVerificationApproved || approved.Version != filed.Version+1 {
t.Fatalf("approved application = %s v%d", approved.Status, approved.Version)
}
if approved.ApprovedAt.IsZero() || !approved.RejectedAt.IsZero() {
t.Fatalf("approved stamps = %v / %v", approved.ApprovedAt, approved.RejectedAt)
}
if approved.DecidedBy != "operator" || approved.DecisionReason != "brand confirmed" ||
approved.InternalNote != "ticket 12" {
t.Fatalf("approved decision metadata = %+v", approved)
}
mark, err := s.PeerVerification(ctx, peer)
if err != nil {
t.Fatalf("projection after approve: %v", err)
}
if mark.VerifierBotID != verifier.BotID || mark.Description != filed.RequestedDescription ||
mark.IconDocumentID != verifier.IconDocumentID {
t.Fatalf("mark after approve = %+v", mark)
}
if _, err := s.PendingCustomVerificationRequest(ctx, verifier.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationRequestNotFound) {
t.Fatalf("pending after approve err = %v, want ErrCustomVerificationRequestNotFound", err)
}
// Re-issuing the decision that already holds moves nothing and does not apply
// the callback a second time.
repeat, changed, err := s.DecideCustomVerificationRequest(ctx, approved.ID, approved.Version,
domain.CustomVerificationApproved, "someone else", "again", "", func(context.Context, domain.CustomVerificationRequest) error {
t.Fatal("apply ran for a decision that already held")
return nil
})
if err != nil || changed {
t.Fatalf("repeated approve: changed=%v err=%v", changed, err)
}
if repeat.Version != approved.Version || repeat.DecidedBy != "operator" {
t.Fatalf("repeated approve mutated the row: %+v", repeat)
}
// approved -> rejected is not in the status machine; approved -> revoked is.
if _, _, err := s.DecideCustomVerificationRequest(ctx, approved.ID, approved.Version,
domain.CustomVerificationRejected, "operator", "changed my mind", "", nil); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("approved -> rejected err = %v, want ErrCustomVerificationRequestInvalid", err)
}
revoked, changed, err := s.DecideCustomVerificationRequest(ctx, approved.ID, approved.Version,
domain.CustomVerificationRevoked, "operator", "brand asked us to", "", revoke)
if err != nil || !changed {
t.Fatalf("revoke: changed=%v err=%v", changed, err)
}
if revoked.Status != domain.CustomVerificationRevoked || revoked.Version != approved.Version+1 {
t.Fatalf("revoked application = %s v%d", revoked.Status, revoked.Version)
}
// The stamps are paired with the status, so leaving approved clears approved_at.
if !revoked.ApprovedAt.IsZero() || !revoked.RejectedAt.IsZero() {
t.Fatalf("revoked stamps = %v / %v", revoked.ApprovedAt, revoked.RejectedAt)
}
if _, err := s.PeerVerification(ctx, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("projection after revoke err = %v, want ErrCustomVerificationNotFound", err)
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, revoked.ID, revoked.Version,
domain.CustomVerificationApproved, "operator", "", "", grant); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("revoked -> approved err = %v, want ErrCustomVerificationRequestInvalid", err)
}
// A rejection needs a reason, and the domain is what says so.
other := botVerificationUserPeer(8002)
second, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(verifier.BotID, applicant, other, "AcmeCEO"))
if err != nil {
t.Fatalf("file second application: %v", err)
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, second.ID, second.Version,
domain.CustomVerificationRejected, "operator", " ", "", nil); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("reject without a reason err = %v, want ErrVerificationReasonRequired", err)
}
stillPending, err := s.CustomVerificationRequest(ctx, second.ID)
if err != nil || stillPending.Status != domain.CustomVerificationPending ||
stillPending.Version != second.Version {
t.Fatalf("application after refused rejection = %+v err=%v", stillPending, err)
}
rejected, changed, err := s.DecideCustomVerificationRequest(ctx, second.ID, second.Version,
domain.CustomVerificationRejected, "operator", "not a public figure", "", nil)
if err != nil || !changed {
t.Fatalf("reject: changed=%v err=%v", changed, err)
}
if rejected.Status != domain.CustomVerificationRejected || rejected.RejectedAt.IsZero() ||
!rejected.ApprovedAt.IsZero() {
t.Fatalf("rejected application = %+v", rejected)
}
if _, err := s.CustomVerification(ctx, verifier.BotID, other); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("rejection granted a mark: %v", err)
}
// A decided pair is free again: history keeps the rejection.
reapplied, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(verifier.BotID, applicant, other, "AcmeCEO"))
if err != nil {
t.Fatalf("re-apply after rejection: %v", err)
}
counts, err := s.CustomVerificationRequestCounts(ctx)
if err != nil {
t.Fatalf("queue counts: %v", err)
}
if counts[domain.CustomVerificationPending] != 1 ||
counts[domain.CustomVerificationRejected] != 1 ||
counts[domain.CustomVerificationRevoked] != 1 {
t.Fatalf("queue counts = %+v", counts)
}
if _, present := counts[domain.CustomVerificationApproved]; present {
t.Fatalf("queue counts invented an approved application: %+v", counts)
}
listed, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{})
if err != nil || len(listed) != 3 {
t.Fatalf("queue list = %+v err=%v", listed, err)
}
if listed[0].ID != reapplied.ID {
t.Fatalf("queue order = %+v, want newest first", listed)
}
pendingOnly, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Statuses: []domain.CustomVerificationRequestStatus{domain.CustomVerificationPending},
})
if err != nil || len(pendingOnly) != 1 || pendingOnly[0].ID != reapplied.ID {
t.Fatalf("pending queue = %+v err=%v", pendingOnly, err)
}
page, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{Limit: 2})
if err != nil || len(page) != 2 {
t.Fatalf("queue page = %+v err=%v", page, err)
}
next, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Limit: 2, BeforeID: page[len(page)-1].ID,
})
if err != nil || len(next) != 1 || next[0].ID >= page[len(page)-1].ID {
t.Fatalf("queue keyset page = %+v err=%v", next, err)
}
byUsername, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Query: "@acmec",
})
if err != nil || len(byUsername) != 2 {
t.Fatalf("username query = %+v err=%v", byUsername, err)
}
byPeerID, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Query: fmt.Sprintf("%d", peer.ID),
})
if err != nil || len(byPeerID) != 1 || byPeerID[0].ID != revoked.ID {
t.Fatalf("numeric query = %+v err=%v", byPeerID, err)
}
channelsOnly, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
PeerType: domain.PeerTypeChannel, VerifierBotID: verifier.BotID,
})
if err != nil || len(channelsOnly) != 1 || channelsOnly[0].ID != revoked.ID {
t.Fatalf("channel queue = %+v err=%v", channelsOnly, err)
}
if _, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Statuses: []domain.CustomVerificationRequestStatus{"nonsense"},
}); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("bad status filter err = %v, want ErrCustomVerificationRequestInvalid", err)
}
history, err := s.CustomVerificationRequestsForApplicant(ctx, applicant, 0)
if err != nil || len(history) != 3 || history[0].ID != reapplied.ID {
t.Fatalf("applicant history = %+v err=%v", history, err)
}
if empty, err := s.CustomVerificationRequestsForApplicant(ctx, applicant+1, 0); err != nil ||
len(empty) != 0 {
t.Fatalf("other applicant history = %+v err=%v", empty, err)
}
if _, err := s.CustomVerificationRequestsForApplicant(ctx, 0, 0); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("history for applicant 0 err = %v, want ErrCustomVerificationRequestInvalid", err)
}
if _, err := s.CustomVerificationRequest(ctx, reapplied.ID+9000); !errors.Is(err, domain.ErrCustomVerificationRequestNotFound) {
t.Fatalf("unknown application err = %v, want ErrCustomVerificationRequestNotFound", err)
}
}

View file

@ -0,0 +1,687 @@
package memory
import (
"context"
"sort"
"strings"
"sync"
"time"
"telesrv/internal/domain"
)
// Default page sizes applied when an admin filter leaves the limit unset. The
// PostgreSQL queries page with LIMIT, so an unset limit has to resolve to a
// finite page in both backends.
const (
defaultCollectibleUsernameListLimit = 50
defaultCollectibleUsernameTransferLimit = 50
)
// CollectibleUsernameStore is the in-memory implementation of both
// store.UsernameRegistryStore and store.CollectibleUsernameStore. RPC unit tests
// run against it, so it reproduces every invariant migration 0150 encodes as an
// index or CHECK constraint and returns the same domain errors PostgreSQL maps
// its violations onto:
//
// - peer_usernames_peer_editable_idx: exactly one editable row per peer.
// - peer_usernames_collectible_not_editable_check: a row backed by an asset is
// never editable, so client-driven username edits cannot move an asset.
// - peer_usernames.username_lower UNIQUE: global, case-insensitive name
// uniqueness across users and channels, keyed by lower(username).
// - collectible_usernames.username_lower UNIQUE: a name is minted at most
// once. A burn therefore releases the registry row -- the name can be
// occupied again as a peer username -- while the asset row keeps the name
// for provenance and blocks a second mint.
// - the status/owner CHECK pair: owner is populated exactly for status 'owned'.
// - collectible_username_transfers_command_idx: command keys are globally
// unique, which is what makes mint/transfer/revoke replay-safe.
type CollectibleUsernameStore struct {
mu sync.Mutex
nextAssetID int64
nextTransferID int64
// assets is collectible_usernames keyed by identity.
assets map[int64]domain.CollectibleUsername
// assetsByName resolves a name onto the asset it currently stands for. After
// migration 0152 uniqueness covers live rows only, so one name can accumulate
// several burned rows plus at most one live row; this index points at the live
// row when there is one and at the newest burned row otherwise, mirroring the
// SQL lookup order.
assetsByName map[string]int64
// registry is peer_usernames keyed by username_lower, which is exactly how
// the table enforces global uniqueness.
registry map[string]collectibleRegistryRow
// transfers is the append-only provenance log per asset.
transfers map[int64][]domain.CollectibleUsernameTransfer
// commands maps a provenance command key onto the asset it touched.
commands map[string]int64
}
// collectibleRegistryRow is one peer_usernames row: the owning peer plus the
// projected username shape.
type collectibleRegistryRow struct {
peer domain.Peer
row domain.Username
}
// NewCollectibleUsernameStore creates an empty registry. Asset ids start at 1 so
// a zero CollectibleID keeps meaning "editable slot", matching the nullable
// collectible_id column.
func NewCollectibleUsernameStore() *CollectibleUsernameStore {
return &CollectibleUsernameStore{
nextAssetID: 1,
nextTransferID: 1,
assets: make(map[int64]domain.CollectibleUsername),
assetsByName: make(map[string]int64),
registry: make(map[string]collectibleRegistryRow),
transfers: make(map[int64][]domain.CollectibleUsernameTransfer),
commands: make(map[string]int64),
}
}
// SetEditableUsername writes the peer's editable slot, mirroring the
// replace-then-insert the PostgreSQL user and channel stores run inside
// account.updateUsername / channels.updateUsername. The memory backend keeps
// usernames on the user and channel rows, so tests need this hook to give a peer
// the editable registry row the projection expects. An empty username clears the
// slot.
func (s *CollectibleUsernameStore) SetEditableUsername(_ context.Context, peer domain.Peer, username string) (bool, error) {
if !validCollectibleUsernamePeer(peer) {
return false, domain.ErrUsernameInvalid
}
username = domain.NormalizeUsername(username)
s.mu.Lock()
defer s.mu.Unlock()
if username == "" {
return s.clearEditableLocked(peer), nil
}
// peer_usernames has no length CHECK; the 5..32 editable rule lives in the
// service layer. Only the character rules are a registry concern.
if !domain.ValidCollectibleUsername(username) {
return false, domain.ErrUsernameInvalid
}
key := strings.ToLower(username)
if existing, ok := s.registry[key]; ok {
if existing.peer == peer && existing.row.Editable {
if existing.row.Username == username {
return false, nil
}
existing.row.Username = username
s.registry[key] = existing
return true, nil
}
return false, domain.ErrUsernameOccupied
}
// A live asset owns its name even while it sits in the vault: only a burn
// puts the name back into the free pool.
if id, ok := s.assetsByName[key]; ok && s.assets[id].Status != domain.CollectibleUsernameStatusBurned {
return false, domain.ErrUsernameOccupied
}
s.clearEditableLocked(peer)
s.registry[key] = collectibleRegistryRow{
peer: peer,
row: domain.Username{
Username: username,
Active: true,
Editable: true,
},
}
return true, nil
}
// PeerUsernames returns the peer's registry rows in projection order.
func (s *CollectibleUsernameStore) PeerUsernames(_ context.Context, peer domain.Peer) ([]domain.Username, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.peerUsernamesLocked(peer), nil
}
// PeerUsernamesBatch resolves several peers at once; peers holding no username
// are absent from the result.
func (s *CollectibleUsernameStore) PeerUsernamesBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
out := make(map[domain.Peer][]domain.Username, len(peers))
if len(peers) == 0 {
return out, nil
}
s.mu.Lock()
defer s.mu.Unlock()
for _, peer := range peers {
if !validCollectibleUsernamePeer(peer) {
continue
}
if _, done := out[peer]; done {
continue
}
rows := s.peerUsernamesLocked(peer)
if len(rows) == 0 {
continue
}
out[peer] = rows
}
return out, nil
}
// SetUsernameActive toggles one collectible row. The domain validator owns the
// rules: the editable slot is off limits and a peer that holds usernames must
// keep at least one active.
func (s *CollectibleUsernameStore) SetUsernameActive(_ context.Context, peer domain.Peer, username string, active bool) (bool, error) {
if !validCollectibleUsernamePeer(peer) {
return false, domain.ErrUsernameInvalid
}
username = domain.NormalizeUsername(username)
s.mu.Lock()
defer s.mu.Unlock()
current := s.peerUsernamesLocked(peer)
if err := domain.ValidateUsernameToggle(current, username, active); err != nil {
return false, err
}
key := strings.ToLower(username)
entry := s.registry[key]
if entry.row.Active == active {
return false, nil
}
entry.row.Active = active
s.registry[key] = entry
return true, nil
}
// ReorderUsernames rewrites the peer's username sort order, editable slot
// included. Validation and the resulting order both come from the domain helper,
// so the two backends cannot drift.
func (s *CollectibleUsernameStore) ReorderUsernames(_ context.Context, peer domain.Peer, order []string) (bool, error) {
if !validCollectibleUsernamePeer(peer) {
return false, domain.ErrUsernameInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
current := s.peerUsernamesLocked(peer)
reordered, err := domain.ApplyUsernameReorder(current, order)
if err != nil {
return false, err
}
// Renumbering always happens; "changed" is about what a client can see.
changed := !domain.SameUsernameOrder(current, reordered)
for _, row := range reordered {
key := strings.ToLower(row.Username)
if key == "" {
continue
}
entry := s.registry[key]
if entry.row.SortOrder == row.SortOrder {
continue
}
entry.row.SortOrder = row.SortOrder
s.registry[key] = entry
}
return changed, nil
}
// DeactivateAllUsernames clears the active flag on every collectible row and
// leaves the editable slot alone.
func (s *CollectibleUsernameStore) DeactivateAllUsernames(_ context.Context, peer domain.Peer) (bool, error) {
if !validCollectibleUsernamePeer(peer) {
return false, domain.ErrUsernameInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
changed := false
for key, entry := range s.registry {
if entry.peer != peer || !entry.row.Collectible() || !entry.row.Active {
continue
}
entry.row.Active = false
s.registry[key] = entry
changed = true
}
return changed, nil
}
// MintCollectibleUsername creates the asset, optionally assigning it in the same
// call. A replayed command key returns the recorded asset with created=false.
func (s *CollectibleUsernameStore) MintCollectibleUsername(_ context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
req.Username = domain.NormalizeUsername(req.Username)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if asset, ok := s.replayLocked(req.CommandKey); ok {
return asset, false, nil
}
key := strings.ToLower(req.Username)
// Only a live asset occupies a name. A name whose history is entirely burned
// is free to be issued again, and the new asset takes over the index entry
// while the burned rows stay as provenance.
if id, ok := s.assetsByName[key]; ok && s.assets[id].Status != domain.CollectibleUsernameStatusBurned {
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
}
if _, ok := s.registry[key]; ok {
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
}
now := time.Now().UTC()
purchaseDate := req.PurchaseDate
if purchaseDate.IsZero() {
purchaseDate = now
}
asset := domain.CollectibleUsername{
ID: s.nextAssetID,
Username: req.Username,
Status: domain.CollectibleUsernameStatusVault,
PurchaseDate: purchaseDate,
Currency: req.Currency,
Amount: req.Amount,
CryptoCurrency: req.CryptoCurrency,
CryptoAmount: req.CryptoAmount,
URL: req.URL,
Version: 1,
CreatedAt: now,
UpdatedAt: now,
}
if req.Owner.Type != "" {
asset.Status = domain.CollectibleUsernameStatusOwned
asset.Owner = req.Owner
// The first holder is the original owner and survives every later move.
asset.OriginalOwner = req.Owner
}
if err := asset.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
if asset.Owned() && s.countCollectiblesLocked(req.Owner) >= domain.MaxPeerCollectibleUsernames {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameLimit
}
s.nextAssetID++
s.assets[asset.ID] = asset
s.assetsByName[key] = asset.ID
if asset.Owned() {
s.attachLocked(asset, req.Owner)
}
s.recordTransferLocked(domain.CollectibleUsernameTransfer{
CollectibleID: asset.ID,
Kind: domain.CollectibleUsernameKindMint,
To: req.Owner,
Currency: req.Currency,
Amount: req.Amount,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: req.CommandKey,
CreatedAt: now,
})
return asset, true, nil
}
// TransferCollectibleUsername moves the asset out of the vault or between
// holders. Handing the asset to the peer that already holds it is a no-op.
func (s *CollectibleUsernameStore) TransferCollectibleUsername(_ context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
req.Username = domain.NormalizeUsername(req.Username)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if asset, ok := s.replayLocked(req.CommandKey); ok {
return asset, false, nil
}
asset, err := s.assetByNameLocked(req.Username)
if err != nil {
return domain.CollectibleUsername{}, false, err
}
if asset.Status == domain.CollectibleUsernameStatusBurned {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
}
if asset.Owned() && asset.Owner == req.To {
return asset, false, nil
}
key := strings.ToLower(asset.Username)
// Defensive: the registry row for this name must be the asset's own. A live
// asset occupies its name globally, so the only way another row can hold it
// is an inconsistently seeded store.
if existing, ok := s.registry[key]; ok && existing.row.CollectibleID != asset.ID {
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
}
if s.countCollectiblesLocked(req.To) >= domain.MaxPeerCollectibleUsernames {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameLimit
}
from := asset.Owner
now := time.Now().UTC()
asset.Status = domain.CollectibleUsernameStatusOwned
asset.Owner = req.To
if asset.OriginalOwner.Type == "" {
asset.OriginalOwner = req.To
}
asset.TransferCount++
asset.Version++
asset.UpdatedAt = now
if err := asset.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.assets[asset.ID] = asset
s.detachLocked(asset.ID)
s.attachLocked(asset, req.To)
s.recordTransferLocked(domain.CollectibleUsernameTransfer{
CollectibleID: asset.ID,
Kind: domain.CollectibleUsernameKindTransfer,
From: from,
To: req.To,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: req.CommandKey,
CreatedAt: now,
})
return asset, true, nil
}
// RevokeCollectibleUsername returns the asset to the vault, or burns it.
//
// A revoke keeps the name owned by the asset -- nobody else can take it -- while
// a burn drops the registry row and releases the name back to the free pool. The
// burned asset row itself survives with its name so provenance stays readable
// and the name never mints twice.
func (s *CollectibleUsernameStore) RevokeCollectibleUsername(_ context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
req.Username = domain.NormalizeUsername(req.Username)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if asset, ok := s.replayLocked(req.CommandKey); ok {
return asset, false, nil
}
asset, err := s.assetByNameLocked(req.Username)
if err != nil {
return domain.CollectibleUsername{}, false, err
}
if asset.Status == domain.CollectibleUsernameStatusBurned {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
}
if !req.Burn && !asset.Owned() {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameNotOwned
}
from := asset.Owner
now := time.Now().UTC()
kind := domain.CollectibleUsernameKindRevoke
asset.Status = domain.CollectibleUsernameStatusVault
if req.Burn {
kind = domain.CollectibleUsernameKindBurn
asset.Status = domain.CollectibleUsernameStatusBurned
}
asset.Owner = domain.Peer{}
asset.Version++
asset.UpdatedAt = now
if err := asset.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.assets[asset.ID] = asset
s.detachLocked(asset.ID)
s.recordTransferLocked(domain.CollectibleUsernameTransfer{
CollectibleID: asset.ID,
Kind: kind,
From: from,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: req.CommandKey,
CreatedAt: now,
})
return asset, true, nil
}
// CollectibleUsername looks the asset up by name, case-insensitively.
// DeleteCollectibleUsername removes the live asset for a name completely --
// registry row, asset and provenance -- and frees the name for any use. Revoke
// with Burn retires an asset but keeps its history; this is the escape hatch for
// an asset issued by mistake.
//
// A command key cannot make this idempotent: the record it would resolve to is
// gone. A repeated call therefore reports deleted=false once no live asset is
// left, which is also what a delete of a burned-only name reports.
func (s *CollectibleUsernameStore) DeleteCollectibleUsername(_ context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
if s == nil {
return false, nil
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return false, err
}
s.mu.Lock()
defer s.mu.Unlock()
key := strings.ToLower(req.Username)
id, ok := s.assetsByName[key]
if !ok {
return false, nil
}
asset, ok := s.assets[id]
if !ok || asset.Status == domain.CollectibleUsernameStatusBurned {
return false, nil
}
s.detachLocked(id)
delete(s.assets, id)
delete(s.transfers, id)
for commandKey, target := range s.commands {
if target == id {
delete(s.commands, commandKey)
}
}
s.rebindAssetNameLocked(key)
return true, nil
}
// rebindAssetNameLocked re-points the name index after a row disappears: the
// newest remaining row wins, and the entry is dropped when none is left.
func (s *CollectibleUsernameStore) rebindAssetNameLocked(key string) {
best := int64(0)
for id, asset := range s.assets {
if strings.ToLower(asset.Username) != key {
continue
}
if best == 0 || id > best {
best = id
}
}
if best == 0 {
delete(s.assetsByName, key)
return
}
s.assetsByName[key] = best
}
func (s *CollectibleUsernameStore) CollectibleUsername(_ context.Context, username string) (domain.CollectibleUsername, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.assetByNameLocked(domain.NormalizeUsername(username))
}
// CollectibleUsernameByID looks the asset up by identity.
func (s *CollectibleUsernameStore) CollectibleUsernameByID(_ context.Context, id int64) (domain.CollectibleUsername, error) {
s.mu.Lock()
defer s.mu.Unlock()
asset, ok := s.assets[id]
if !ok {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
return asset, nil
}
// ListCollectibleUsernames is the admin listing query: newest first, paged by a
// BeforeID keyset, matching collectible_usernames_status_idx ordering.
func (s *CollectibleUsernameStore) ListCollectibleUsernames(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
if filter.Status != "" && !filter.Status.Valid() {
return nil, domain.ErrCollectibleUsernameStateInvalid
}
limit := filter.Limit
if limit <= 0 {
limit = defaultCollectibleUsernameListLimit
}
query := strings.ToLower(domain.NormalizeUsername(filter.Query))
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.CollectibleUsername, 0, len(s.assets))
for _, asset := range s.assets {
if filter.Status != "" && asset.Status != filter.Status {
continue
}
if filter.Owner.Type != "" && asset.Owner != filter.Owner {
continue
}
if query != "" && !strings.Contains(strings.ToLower(asset.Username), query) {
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 len(out) > limit {
out = out[:limit]
}
return out, nil
}
// CollectibleUsernameTransfers returns the provenance log newest first.
func (s *CollectibleUsernameStore) CollectibleUsernameTransfers(_ context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
if limit <= 0 {
limit = defaultCollectibleUsernameTransferLimit
}
s.mu.Lock()
defer s.mu.Unlock()
stored := s.transfers[collectibleID]
out := make([]domain.CollectibleUsernameTransfer, 0, len(stored))
for i := len(stored) - 1; i >= 0 && len(out) < limit; i-- {
out = append(out, stored[i])
}
return out, nil
}
// peerUsernamesLocked collects the peer's rows in projection order. The rows are
// values, so the returned slice cannot be used to mutate stored state.
func (s *CollectibleUsernameStore) peerUsernamesLocked(peer domain.Peer) []domain.Username {
rows := make([]domain.Username, 0, 4)
for _, entry := range s.registry {
if entry.peer != peer {
continue
}
rows = append(rows, entry.row)
}
return domain.SortUsernames(rows)
}
// countCollectiblesLocked counts the peer's collectible registry rows, which is
// what MaxPeerCollectibleUsernames bounds.
func (s *CollectibleUsernameStore) countCollectiblesLocked(peer domain.Peer) int {
count := 0
for _, entry := range s.registry {
if entry.peer == peer && entry.row.Collectible() {
count++
}
}
return count
}
// nextSortOrderLocked appends the new collectible after the peer's existing ones,
// clamped by the registry sort_order CHECK.
func (s *CollectibleUsernameStore) nextSortOrderLocked(peer domain.Peer) int {
next := 0
for _, entry := range s.registry {
if entry.peer != peer || !entry.row.Collectible() {
continue
}
if entry.row.SortOrder >= next {
next = entry.row.SortOrder + 1
}
}
if next > domain.MaxUsernameSortOrder {
next = domain.MaxUsernameSortOrder
}
return next
}
// attachLocked projects an owned asset into the registry. Callers check
// occupancy and the per-peer bound first, exactly like the PostgreSQL path does
// before it hits the unique index.
func (s *CollectibleUsernameStore) attachLocked(asset domain.CollectibleUsername, peer domain.Peer) {
s.registry[strings.ToLower(asset.Username)] = collectibleRegistryRow{
peer: peer,
row: domain.Username{
Username: asset.Username,
Active: true,
Editable: false,
SortOrder: s.nextSortOrderLocked(peer),
CollectibleID: asset.ID,
},
}
}
// detachLocked removes the registry row backed by the asset, leaving the peer's
// editable slot and its other collectibles untouched.
func (s *CollectibleUsernameStore) detachLocked(collectibleID int64) {
for key, entry := range s.registry {
if entry.row.CollectibleID == collectibleID {
delete(s.registry, key)
return
}
}
}
// clearEditableLocked drops the peer's editable row, keeping the one-editable-row
// index true by construction.
func (s *CollectibleUsernameStore) clearEditableLocked(peer domain.Peer) bool {
for key, entry := range s.registry {
if entry.peer == peer && entry.row.Editable {
delete(s.registry, key)
return true
}
}
return false
}
func (s *CollectibleUsernameStore) assetByNameLocked(username string) (domain.CollectibleUsername, error) {
if username == "" {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
id, ok := s.assetsByName[strings.ToLower(username)]
if !ok {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
asset, ok := s.assets[id]
if !ok {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
return asset, nil
}
// replayLocked resolves a command key onto the asset the recorded command
// touched. The provenance command index is global, so a replayed key is a no-op
// for every kind, just like the INSERT ... ON CONFLICT DO NOTHING path.
func (s *CollectibleUsernameStore) replayLocked(commandKey string) (domain.CollectibleUsername, bool) {
if commandKey == "" {
return domain.CollectibleUsername{}, false
}
id, ok := s.commands[commandKey]
if !ok {
return domain.CollectibleUsername{}, false
}
asset, ok := s.assets[id]
return asset, ok
}
func (s *CollectibleUsernameStore) recordTransferLocked(entry domain.CollectibleUsernameTransfer) {
entry.ID = s.nextTransferID
s.nextTransferID++
s.transfers[entry.CollectibleID] = append(s.transfers[entry.CollectibleID], entry)
if entry.CommandKey != "" {
s.commands[entry.CommandKey] = entry.CollectibleID
}
}
// validCollectibleUsernamePeer mirrors the peer_type CHECK: only real user and
// channel peers can hold a username.
func validCollectibleUsernamePeer(peer domain.Peer) bool {
switch peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel:
return peer.ID > 0
default:
return false
}
}

View file

@ -0,0 +1,845 @@
package memory
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
var (
_ store.UsernameRegistryStore = (*CollectibleUsernameStore)(nil)
_ store.CollectibleUsernameStore = (*CollectibleUsernameStore)(nil)
)
func collectibleMintRequest(username string, owner domain.Peer, commandKey string) domain.MintCollectibleUsernameRequest {
return domain.MintCollectibleUsernameRequest{
Username: username,
Owner: owner,
PurchaseDate: time.Unix(1700000000, 0).UTC(),
Currency: domain.CollectibleCurrencyStars,
Amount: 2500,
Actor: "admin",
Reason: "unit test",
CommandKey: commandKey,
}
}
func mustMintCollectible(t *testing.T, s *CollectibleUsernameStore, username string, owner domain.Peer) domain.CollectibleUsername {
t.Helper()
asset, created, err := s.MintCollectibleUsername(context.Background(),
collectibleMintRequest(username, owner, "mint-"+username))
if err != nil || !created {
t.Fatalf("mint %s: created=%v err=%v", username, created, err)
}
return asset
}
func mustSetEditable(t *testing.T, s *CollectibleUsernameStore, peer domain.Peer, username string) {
t.Helper()
changed, err := s.SetEditableUsername(context.Background(), peer, username)
if err != nil || !changed {
t.Fatalf("set editable %s: changed=%v err=%v", username, changed, err)
}
}
// usernameRow finds a row by name the way the registry keys it: case-insensitively.
func usernameRow(t *testing.T, rows []domain.Username, username string) domain.Username {
t.Helper()
want := domain.NormalizeUsername(username)
for _, row := range rows {
if strings.EqualFold(row.Username, want) {
return row
}
}
t.Fatalf("username %q missing from %+v", username, rows)
return domain.Username{}
}
func TestCollectibleUsernameMint(t *testing.T) {
ctx := context.Background()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
channel := domain.Peer{Type: domain.PeerTypeChannel, ID: 2002}
tests := []struct {
name string
seed func(t *testing.T, s *CollectibleUsernameStore)
req domain.MintCollectibleUsernameRequest
wantErr error
wantCreated bool
check func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername)
}{
{
name: "into vault",
req: collectibleMintRequest("vaultname", domain.Peer{}, "cmd-vault"),
wantCreated: true,
check: func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername) {
if asset.Status != domain.CollectibleUsernameStatusVault || asset.Owned() {
t.Fatalf("asset=%+v", asset)
}
if asset.Owner != (domain.Peer{}) || asset.OriginalOwner != (domain.Peer{}) {
t.Fatalf("vault asset carries an owner: %+v", asset)
}
if asset.Version != 1 || asset.TransferCount != 0 {
t.Fatalf("asset=%+v", asset)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 0 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
},
},
{
name: "with owner",
req: collectibleMintRequest("OwnedName", holder, "cmd-owned"),
wantCreated: true,
check: func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername) {
if asset.Status != domain.CollectibleUsernameStatusOwned || !asset.Owned() {
t.Fatalf("asset=%+v", asset)
}
if asset.Owner != holder || asset.OriginalOwner != holder {
t.Fatalf("asset=%+v", asset)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 1 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
row := rows[0]
if row.Username != "OwnedName" || !row.Active || row.Editable ||
row.CollectibleID != asset.ID {
t.Fatalf("row=%+v", row)
}
},
},
{
name: "channel owner",
req: collectibleMintRequest("chanpost", channel, "cmd-chan"),
wantCreated: true,
check: func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername) {
rows, err := s.PeerUsernames(ctx, channel)
if err != nil || len(rows) != 1 || rows[0].CollectibleID != asset.ID {
t.Fatalf("rows=%+v err=%v", rows, err)
}
},
},
{
name: "command key replay",
seed: func(t *testing.T, s *CollectibleUsernameStore) {
if _, created, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("replayed", holder, "cmd-replay")); err != nil || !created {
t.Fatalf("seed mint created=%v err=%v", created, err)
}
},
req: collectibleMintRequest("replayed", holder, "cmd-replay"),
wantCreated: false,
check: func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername) {
if asset.Username != "replayed" || asset.ID != 1 {
t.Fatalf("replay returned %+v", asset)
}
log, err := s.CollectibleUsernameTransfers(ctx, asset.ID, 10)
if err != nil || len(log) != 1 || log[0].Kind != domain.CollectibleUsernameKindMint {
t.Fatalf("replay appended provenance: %+v err=%v", log, err)
}
},
},
{
name: "name held by another asset",
seed: func(t *testing.T, s *CollectibleUsernameStore) {
mustMintCollectible(t, s, "TakenName", holder)
},
req: collectibleMintRequest("takenname", channel, "cmd-dup"),
wantErr: domain.ErrUsernameOccupied,
},
{
name: "name held by an editable slot",
seed: func(t *testing.T, s *CollectibleUsernameStore) {
mustSetEditable(t, s, holder, "EditableOne")
},
req: collectibleMintRequest("editableone", channel, "cmd-editable"),
wantErr: domain.ErrUsernameOccupied,
},
{
name: "syntactically invalid",
req: collectibleMintRequest("ab", holder, "cmd-short"),
wantErr: domain.ErrUsernameInvalid,
},
{
name: "crypto pair without amount",
req: func() domain.MintCollectibleUsernameRequest {
req := collectibleMintRequest("cryptoname", holder, "cmd-crypto")
req.CryptoCurrency = domain.CollectibleCryptoCurrencyTON
return req
}(),
wantErr: domain.ErrCollectibleCurrencyInvalid,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := NewCollectibleUsernameStore()
if tc.seed != nil {
tc.seed(t, s)
}
asset, created, err := s.MintCollectibleUsername(ctx, tc.req)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err=%v want %v", err, tc.wantErr)
}
if created != tc.wantCreated {
t.Fatalf("created=%v want %v", created, tc.wantCreated)
}
if tc.wantErr != nil {
return
}
if tc.check != nil {
tc.check(t, s, asset)
}
})
}
}
func TestCollectibleUsernamePeerLimit(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
for i := 0; i < domain.MaxPeerCollectibleUsernames; i++ {
mustMintCollectible(t, s, fmt.Sprintf("holder%04d", i), holder)
}
if _, _, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("overflow", holder, "cmd-overflow")); !errors.Is(err, domain.ErrCollectibleUsernameLimit) {
t.Fatalf("mint over the limit err=%v", err)
}
// The rejected mint left no asset behind, so the name is still free.
if _, err := s.CollectibleUsername(ctx, "overflow"); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("rejected mint stored an asset: %v", err)
}
spare := mustMintCollectible(t, s, "sparename", other)
if _, _, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: spare.Username, To: holder, Actor: "admin", CommandKey: "cmd-limit-transfer",
}); !errors.Is(err, domain.ErrCollectibleUsernameLimit) {
t.Fatalf("transfer over the limit err=%v", err)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != domain.MaxPeerCollectibleUsernames {
t.Fatalf("rows=%d err=%v", len(rows), err)
}
// The failed transfer did not move the asset either.
stored, err := s.CollectibleUsername(ctx, "sparename")
if err != nil || stored.Owner != other {
t.Fatalf("stored=%+v err=%v", stored, err)
}
}
func TestCollectibleUsernameTransfer(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
mustSetEditable(t, s, holder, "holderslot")
owned := mustMintCollectible(t, s, "alphaone", holder)
vaulted := mustMintCollectible(t, s, "vaultone", domain.Peer{})
// Out of the vault: the asset gains a holder and its first original owner.
moved, changed, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "VaultOne", To: holder, Actor: "admin", CommandKey: "cmd-vault-out",
})
if err != nil || !changed {
t.Fatalf("transfer out of vault changed=%v err=%v", changed, err)
}
if moved.ID != vaulted.ID || moved.Owner != holder || moved.OriginalOwner != holder ||
moved.TransferCount != 1 || moved.Version != vaulted.Version+1 {
t.Fatalf("moved=%+v", moved)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 3 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
// The editable slot stays first, untouched and editable.
if rows[0].Username != "holderslot" || !rows[0].Editable || !rows[0].Active ||
rows[0].CollectibleID != 0 {
t.Fatalf("editable row=%+v", rows[0])
}
if rows[1].Username != "alphaone" || rows[2].Username != "vaultone" {
t.Fatalf("collectible order=%+v", rows)
}
// Replaying the command key is a no-op.
replay, changed, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "vaultone", To: other, Actor: "admin", CommandKey: "cmd-vault-out",
})
if err != nil || changed || replay.Owner != holder {
t.Fatalf("replay=%+v changed=%v err=%v", replay, changed, err)
}
// Handing an asset to its current holder changes nothing.
same, changed, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "alphaone", To: holder, Actor: "admin", CommandKey: "cmd-noop",
})
if err != nil || changed || same.Version != owned.Version {
t.Fatalf("no-op transfer=%+v changed=%v err=%v", same, changed, err)
}
// Between peers: the previous holder loses the registry row, the editable
// slot survives, and the original owner is preserved.
handed, changed, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "alphaone", To: other, Actor: "admin", CommandKey: "cmd-handover",
})
if err != nil || !changed {
t.Fatalf("handover changed=%v err=%v", changed, err)
}
if handed.Owner != other || handed.OriginalOwner != holder || handed.TransferCount != 1 {
t.Fatalf("handed=%+v", handed)
}
rows, err = s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 2 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
if usernameRow(t, rows, "holderslot").Editable != true {
t.Fatalf("editable slot lost: %+v", rows)
}
for _, row := range rows {
if row.Username == "alphaone" {
t.Fatalf("old owner kept the registry row: %+v", rows)
}
}
batch, err := s.PeerUsernamesBatch(ctx, []domain.Peer{holder, other, {Type: domain.PeerTypeUser, ID: 9}})
if err != nil || len(batch) != 2 {
t.Fatalf("batch=%+v err=%v", batch, err)
}
if len(batch[other]) != 1 || batch[other][0].Username != "alphaone" ||
batch[other][0].SortOrder != 0 {
t.Fatalf("new owner rows=%+v", batch[other])
}
if _, _, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "missingone", To: other, Actor: "admin",
}); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("transfer of unknown asset err=%v", err)
}
log, err := s.CollectibleUsernameTransfers(ctx, owned.ID, 10)
if err != nil || len(log) != 2 {
t.Fatalf("provenance=%+v err=%v", log, err)
}
if log[0].Kind != domain.CollectibleUsernameKindTransfer || log[0].From != holder || log[0].To != other {
t.Fatalf("newest provenance=%+v", log[0])
}
if log[1].Kind != domain.CollectibleUsernameKindMint || log[1].To != holder {
t.Fatalf("oldest provenance=%+v", log[1])
}
}
func TestCollectibleUsernameRevokeAndBurn(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
revoked := mustMintCollectible(t, s, "revokeme", holder)
burned := mustMintCollectible(t, s, "burnme", holder)
asset, changed, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "RevokeMe", Actor: "admin", Reason: "abuse", CommandKey: "cmd-revoke",
})
if err != nil || !changed {
t.Fatalf("revoke changed=%v err=%v", changed, err)
}
if asset.Status != domain.CollectibleUsernameStatusVault || asset.Owned() ||
asset.Owner != (domain.Peer{}) || asset.OriginalOwner != holder ||
asset.Version != revoked.Version+1 {
t.Fatalf("revoked asset=%+v", asset)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 1 || rows[0].Username != "burnme" {
t.Fatalf("rows=%+v err=%v", rows, err)
}
// Back in the vault the asset still owns its name: nobody else can take it.
if _, err := s.SetEditableUsername(ctx, other, "revokeme"); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("revoked name became claimable: %v", err)
}
if _, _, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("revokeme", other, "cmd-remint")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("revoked name was re-minted: %v", err)
}
// Replay and a second revoke of a vault asset.
if replay, changed, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "revokeme", Actor: "admin", CommandKey: "cmd-revoke",
}); err != nil || changed || replay.Version != asset.Version {
t.Fatalf("replay=%+v changed=%v err=%v", replay, changed, err)
}
if _, _, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "revokeme", Actor: "admin", CommandKey: "cmd-revoke-again",
}); !errors.Is(err, domain.ErrCollectibleUsernameNotOwned) {
t.Fatalf("revoke of an unowned asset err=%v", err)
}
dead, changed, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "burnme", Burn: true, Actor: "admin", CommandKey: "cmd-burn",
})
if err != nil || !changed {
t.Fatalf("burn changed=%v err=%v", changed, err)
}
if dead.Status != domain.CollectibleUsernameStatusBurned || dead.Owner != (domain.Peer{}) ||
dead.OriginalOwner != holder || dead.Version != burned.Version+1 {
t.Fatalf("burned asset=%+v", dead)
}
rows, err = s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 0 {
t.Fatalf("burn left registry rows: %+v err=%v", rows, err)
}
// The burn released the name: another peer may occupy it again.
if changed, err := s.SetEditableUsername(ctx, other, "BurnMe"); err != nil || !changed {
t.Fatalf("claim freed name changed=%v err=%v", changed, err)
}
claimed, err := s.PeerUsernames(ctx, other)
if err != nil || len(claimed) != 1 || claimed[0].Username != "BurnMe" || !claimed[0].Editable {
t.Fatalf("claimed=%+v err=%v", claimed, err)
}
// The burned asset row survives for provenance, so the name never mints twice.
if _, _, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("burnme", holder, "cmd-burn-remint")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("burned name was re-minted: %v", err)
}
for _, err := range []error{
mustErr(s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "burnme", To: other, Actor: "admin", CommandKey: "cmd-burn-transfer",
})),
mustErr(s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "burnme", Actor: "admin", CommandKey: "cmd-burn-revoke",
})),
} {
if !errors.Is(err, domain.ErrCollectibleUsernameBurned) {
t.Fatalf("mutation of a burned asset err=%v", err)
}
}
log, err := s.CollectibleUsernameTransfers(ctx, dead.ID, 10)
if err != nil || len(log) != 2 || log[0].Kind != domain.CollectibleUsernameKindBurn ||
log[0].From != holder {
t.Fatalf("burn provenance=%+v err=%v", log, err)
}
listed, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
Status: domain.CollectibleUsernameStatusBurned,
})
if err != nil || len(listed) != 1 || listed[0].ID != dead.ID {
t.Fatalf("listed=%+v err=%v", listed, err)
}
byID, err := s.CollectibleUsernameByID(ctx, dead.ID)
if err != nil || byID.Username != "burnme" {
t.Fatalf("byID=%+v err=%v", byID, err)
}
}
func mustErr[T any](_ T, _ bool, err error) error { return err }
func TestCollectibleUsernameToggle(t *testing.T) {
ctx := context.Background()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
lonely := domain.Peer{Type: domain.PeerTypeUser, ID: 1003}
tests := []struct {
name string
peer domain.Peer
username string
active bool
wantErr error
wantChanged bool
wantActive bool
}{
{name: "deactivate collectible", peer: holder, username: "alphaone", active: false, wantChanged: true},
{name: "activating an already active row", peer: holder, username: "ALPHAONE", active: true, wantChanged: false, wantActive: true},
{name: "editable slot is off limits", peer: holder, username: "holderslot", active: false, wantErr: domain.ErrUsernameNotCollectible, wantActive: true},
{name: "unknown username", peer: holder, username: "nothere", active: false, wantErr: domain.ErrUsernameNotOccupied},
{name: "last active collectible may be deactivated", peer: lonely, username: "lonelyone", active: false, wantChanged: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := NewCollectibleUsernameStore()
mustSetEditable(t, s, holder, "holderslot")
mustMintCollectible(t, s, "alphaone", holder)
mustMintCollectible(t, s, "betatwo", holder)
mustMintCollectible(t, s, "lonelyone", lonely)
changed, err := s.SetUsernameActive(ctx, tc.peer, tc.username, tc.active)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err=%v want %v", err, tc.wantErr)
}
if changed != tc.wantChanged {
t.Fatalf("changed=%v want %v", changed, tc.wantChanged)
}
if tc.username == "nothere" {
return
}
rows, err := s.PeerUsernames(ctx, tc.peer)
if err != nil {
t.Fatal(err)
}
row := usernameRow(t, rows, tc.username)
if row.Active != tc.wantActive {
t.Fatalf("row=%+v want active=%v", row, tc.wantActive)
}
})
}
t.Run("deactivate all keeps the editable slot", func(t *testing.T) {
s := NewCollectibleUsernameStore()
mustSetEditable(t, s, holder, "holderslot")
mustMintCollectible(t, s, "alphaone", holder)
mustMintCollectible(t, s, "betatwo", holder)
changed, err := s.DeactivateAllUsernames(ctx, holder)
if err != nil || !changed {
t.Fatalf("deactivate all changed=%v err=%v", changed, err)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 3 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
if !rows[0].Editable || !rows[0].Active {
t.Fatalf("editable row=%+v", rows[0])
}
if rows[1].Active || rows[2].Active {
t.Fatalf("collectibles still active: %+v", rows)
}
if changed, err := s.DeactivateAllUsernames(ctx, holder); err != nil || changed {
t.Fatalf("second deactivate changed=%v err=%v", changed, err)
}
})
}
func TestCollectibleUsernameReorder(t *testing.T) {
ctx := context.Background()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
tests := []struct {
name string
order []string
wantErr error
wantChanged bool
wantOrder []string
}{
{
// Clients send the whole active list, editable slot included.
name: "valid permutation",
order: []string{"holderslot", "gammathree", "@AlphaOne", "betatwo"},
wantChanged: true,
wantOrder: []string{"holderslot", "gammathree", "alphaone", "betatwo"},
},
{
name: "identity permutation",
order: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
wantChanged: false,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
// The editable slot is reorderable: a collectible may be made primary.
name: "collectible ahead of the editable slot",
order: []string{"gammathree", "holderslot", "alphaone", "betatwo"},
wantChanged: true,
wantOrder: []string{"gammathree", "holderslot", "alphaone", "betatwo"},
},
{
name: "partial order",
order: []string{"holderslot", "alphaone"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
name: "active editable slot omitted",
order: []string{"alphaone", "betatwo", "gammathree"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
name: "duplicate entry",
order: []string{"holderslot", "alphaone", "alphaone", "betatwo"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
name: "unknown username",
order: []string{"holderslot", "alphaone", "betatwo", "nothere"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
name: "garbage input",
order: []string{"holderslot", "", "@", "alphaone"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := NewCollectibleUsernameStore()
mustSetEditable(t, s, holder, "holderslot")
mustMintCollectible(t, s, "alphaone", holder)
mustMintCollectible(t, s, "betatwo", holder)
mustMintCollectible(t, s, "gammathree", holder)
changed, err := s.ReorderUsernames(ctx, holder, tc.order)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err=%v want %v", err, tc.wantErr)
}
if changed != tc.wantChanged {
t.Fatalf("changed=%v want %v", changed, tc.wantChanged)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil {
t.Fatal(err)
}
want := tc.wantOrder
if len(rows) != len(want) {
t.Fatalf("rows=%+v want %v", rows, want)
}
for i, name := range want {
if rows[i].Username != name {
t.Fatalf("rows=%+v want %v", rows, want)
}
}
})
}
// A peer whose only username is the editable slot: sending just that name is
// the identity order, and sending nothing at all is the no-op every client
// gets when it reconciles an empty collectible list.
t.Run("editable slot only", func(t *testing.T) {
s := NewCollectibleUsernameStore()
mustSetEditable(t, s, holder, "holderslot")
if changed, err := s.ReorderUsernames(ctx, holder, []string{"holderslot"}); err != nil || changed {
t.Fatalf("editable-only order: changed=%v err=%v", changed, err)
}
})
t.Run("no usernames at all", func(t *testing.T) {
s := NewCollectibleUsernameStore()
if changed, err := s.ReorderUsernames(ctx, holder, nil); err != nil || changed {
t.Fatalf("changed=%v err=%v", changed, err)
}
})
}
func TestCollectibleUsernameRegistryUniqueness(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
mustSetEditable(t, s, holder, "holderslot")
// One editable row per peer: setting a new one replaces the old.
mustSetEditable(t, s, holder, "secondslot")
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 1 || rows[0].Username != "secondslot" {
t.Fatalf("rows=%+v err=%v", rows, err)
}
// The released name is free again, case-insensitively.
if changed, err := s.SetEditableUsername(ctx, other, "HOLDERSLOT"); err != nil || !changed {
t.Fatalf("changed=%v err=%v", changed, err)
}
if _, err := s.SetEditableUsername(ctx, holder, "holderslot"); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("occupied name reused: %v", err)
}
if changed, err := s.SetEditableUsername(ctx, other, ""); err != nil || !changed {
t.Fatalf("clear editable changed=%v err=%v", changed, err)
}
if rows, err := s.PeerUsernames(ctx, other); err != nil || len(rows) != 0 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
// A vault asset owns its name even without a registry row, so the editable
// slot cannot take it and the asset stays handable.
vaulted := mustMintCollectible(t, s, "vaultname", domain.Peer{})
if _, err := s.SetEditableUsername(ctx, other, "VaultName"); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("vault name became claimable: %v", err)
}
if moved, _, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "vaultname", To: other, Actor: "admin", CommandKey: "cmd-vault-out",
}); err != nil || moved.ID != vaulted.ID || moved.Owner != other {
t.Fatalf("moved=%+v err=%v", moved, err)
}
// Returned slices are copies: mutating them cannot change stored state.
rows, err = s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 1 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
rows[0].Username = "mutated"
rows[0].Active = false
again, err := s.PeerUsernames(ctx, holder)
if err != nil || again[0].Username != "secondslot" || !again[0].Active {
t.Fatalf("stored state mutated through the returned slice: %+v err=%v", again, err)
}
}
func TestCollectibleUsernameListingAndPaging(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
first := mustMintCollectible(t, s, "alphaone", holder)
second := mustMintCollectible(t, s, "alphatwo", other)
third := mustMintCollectible(t, s, "betathree", domain.Peer{})
all, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{})
if err != nil || len(all) != 3 || all[0].ID != third.ID || all[2].ID != first.ID {
t.Fatalf("all=%+v err=%v", all, err)
}
page, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{Limit: 2})
if err != nil || len(page) != 2 || page[0].ID != third.ID {
t.Fatalf("page=%+v err=%v", page, err)
}
next, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
BeforeID: page[len(page)-1].ID, Limit: 2,
})
if err != nil || len(next) != 1 || next[0].ID != first.ID {
t.Fatalf("next=%+v err=%v", next, err)
}
owned, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{Owner: other})
if err != nil || len(owned) != 1 || owned[0].ID != second.ID {
t.Fatalf("owned=%+v err=%v", owned, err)
}
matched, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{Query: "ALPHA"})
if err != nil || len(matched) != 2 {
t.Fatalf("matched=%+v err=%v", matched, err)
}
vault, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
Status: domain.CollectibleUsernameStatusVault,
})
if err != nil || len(vault) != 1 || vault[0].ID != third.ID {
t.Fatalf("vault=%+v err=%v", vault, err)
}
if _, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
Status: domain.CollectibleUsernameStatus("gone"),
}); !errors.Is(err, domain.ErrCollectibleUsernameStateInvalid) {
t.Fatalf("invalid status filter err=%v", err)
}
if _, err := s.CollectibleUsername(ctx, "@AlphaOne"); err != nil {
t.Fatalf("lookup by display form: %v", err)
}
if _, err := s.CollectibleUsername(ctx, ""); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("empty lookup err=%v", err)
}
if _, err := s.CollectibleUsernameByID(ctx, 4242); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("unknown id err=%v", err)
}
}
// TestCollectibleUsernameReissueAfterBurn covers migration 0152: burning retires
// the asset but releases the name, so the same name can be issued again while the
// burned rows stay as provenance.
func TestCollectibleUsernameReissueAfterBurn(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 4001}
first := mustMintCollectible(t, s, "Nfts", holder)
if _, _, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "nfts", Burn: true, Actor: "admin", Reason: "retire", CommandKey: "burn-1",
}); err != nil {
t.Fatalf("burn: %v", err)
}
second, created, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("NFTS", holder, "mint-again-1"))
if err != nil || !created {
t.Fatalf("reissue after burn: created=%v err=%v", created, err)
}
if second.ID == first.ID {
t.Fatalf("reissue reused asset id %d", second.ID)
}
if second.Status != domain.CollectibleUsernameStatusOwned {
t.Fatalf("reissued status = %q, want owned", second.Status)
}
// The name now resolves to the live asset, not to either burned row.
live, err := s.CollectibleUsername(ctx, "nfts")
if err != nil {
t.Fatalf("lookup after reissue: %v", err)
}
if live.ID != second.ID {
t.Fatalf("lookup id = %d, want the live asset %d", live.ID, second.ID)
}
// The burned row is still readable by identity: it is the provenance record.
burned, err := s.CollectibleUsernameByID(ctx, first.ID)
if err != nil || burned.Status != domain.CollectibleUsernameStatusBurned {
t.Fatalf("burned row = %+v err=%v", burned, err)
}
// A second burn releases the name again, so the cycle repeats.
if _, _, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "nfts", Burn: true, Actor: "admin", Reason: "retire", CommandKey: "burn-2",
}); err != nil {
t.Fatalf("second burn: %v", err)
}
if _, created, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("nfts", domain.Peer{}, "mint-again-2")); err != nil || !created {
t.Fatalf("second reissue: created=%v err=%v", created, err)
}
// A live asset still blocks a mint, burned history or not.
if _, _, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("nfts", domain.Peer{}, "mint-again-3")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("mint over live asset err = %v, want ErrUsernameOccupied", err)
}
}
func TestCollectibleUsernameDelete(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 4101}
mustSetEditable(t, s, holder, "holder_main")
asset := mustMintCollectible(t, s, "Gone", holder)
deleted, err := s.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: "@gone", Actor: "admin", Reason: "issued by mistake", CommandKey: "del-1",
})
if err != nil || !deleted {
t.Fatalf("delete: deleted=%v err=%v", deleted, err)
}
if _, err := s.CollectibleUsernameByID(ctx, asset.ID); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("asset after delete err = %v, want not found", err)
}
if _, err := s.CollectibleUsername(ctx, "gone"); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("lookup after delete err = %v, want not found", err)
}
log, err := s.CollectibleUsernameTransfers(ctx, asset.ID, 10)
if err != nil || len(log) != 0 {
t.Fatalf("provenance after delete = %d rows err=%v, want none", len(log), err)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil {
t.Fatalf("peer usernames: %v", err)
}
if len(rows) != 1 || !rows[0].Editable {
t.Fatalf("owner rows after delete = %+v, want only the editable slot", rows)
}
// The name is completely free afterwards, for a collectible or an editable slot.
if _, created, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("gone", domain.Peer{}, "mint-after-delete")); err != nil || !created {
t.Fatalf("mint after delete: created=%v err=%v", created, err)
}
// A repeat is a no-op rather than an error: the record a command key would
// resolve to is gone, so idempotency degrades to "nothing live left".
if _, _, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "gone", Burn: true, Actor: "admin", Reason: "retire", CommandKey: "burn-after-delete",
}); err != nil {
t.Fatalf("burn reissued asset: %v", err)
}
deleted, err = s.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: "gone", Actor: "admin", Reason: "again", CommandKey: "del-2",
})
if err != nil || deleted {
t.Fatalf("delete of burned-only name = %v err=%v, want (false, nil)", deleted, err)
}
deleted, err = s.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: "never_issued", Actor: "admin", Reason: "again", CommandKey: "del-3",
})
if err != nil || deleted {
t.Fatalf("delete of unknown name = %v err=%v, want (false, nil)", deleted, err)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,760 @@
package memory
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"telesrv/internal/domain"
)
// verificationTestDraft is a payload that clears domain.ValidateForSubmission:
// a category, a long enough description, a website and two independent press
// links.
func verificationTestDraft() domain.VerificationDraftInput {
return domain.VerificationDraftInput{
Category: "media",
Description: strings.Repeat("independent newsroom covering the region ", 2),
OfficialWebsite: "https://example.com",
SocialLinks: []string{"https://t.me/example"},
PressLinks: []string{
"https://press.example.com/story",
"https://press.example.org/profile",
},
AdditionalNote: "filed through the bot dialog",
}
}
func verificationTestRequest(applicant int64, targetType domain.VerificationTargetType, targetID int64, username string) domain.SubmitVerificationApplicationRequest {
return domain.SubmitVerificationApplicationRequest{
ApplicantUserID: applicant,
TargetType: targetType,
TargetID: targetID,
TargetTitle: "Target " + username,
TargetUsername: username,
Draft: verificationTestDraft(),
CorrelationID: fmt.Sprintf("corr-%d", targetID),
}
}
// submittedVerificationApplication drives the applicant path up to the review
// queue, which is the state every reviewer test starts from.
func submittedVerificationApplication(t *testing.T, s *VerificationStore, applicant int64, targetType domain.VerificationTargetType, targetID int64, username string) domain.VerificationApplication {
t.Helper()
ctx := context.Background()
app, created, err := s.CreateVerificationDraft(ctx, verificationTestRequest(applicant, targetType, targetID, username))
if err != nil || !created {
t.Fatalf("create draft: created=%v err=%v", created, err)
}
app, err = s.SubmitVerificationApplication(ctx, app.ID, app.Version)
if err != nil {
t.Fatalf("submit: %v", err)
}
return app
}
// TestMemoryVerificationDraftLifecycle covers the applicant path: a draft is
// opened once and resumed on the next /start, the payload is stored verbatim, and
// submission stamps the queue entry.
func TestMemoryVerificationDraftLifecycle(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
req := verificationTestRequest(1001, domain.VerificationTargetBot, 5001, "AlphaBot")
req.Draft = domain.VerificationDraftInput{Category: "media"}
app, created, err := s.CreateVerificationDraft(ctx, req)
if err != nil || !created {
t.Fatalf("create draft: created=%v err=%v", created, err)
}
if app.Status != domain.VerificationStatusDraft || app.Version != 1 {
t.Fatalf("draft state = %s v%d, want draft v1", app.Status, app.Version)
}
if app.TargetUsername != "AlphaBot" || app.CorrelationID != "corr-5001" {
t.Fatalf("draft snapshot = %q / %q", app.TargetUsername, app.CorrelationID)
}
if !app.SubmittedAt.IsZero() || !app.ReviewedAt.IsZero() || app.ReviewerAdminID != "" {
t.Fatal("fresh draft carries review metadata")
}
// The bot dialog is one conversation: a second /start resumes the draft.
resumed, created, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1001, domain.VerificationTargetBot, 5001, "AlphaBot"))
if err != nil || created {
t.Fatalf("resume draft: created=%v err=%v", created, err)
}
if resumed.ID != app.ID || resumed.Version != app.Version {
t.Fatalf("resumed draft = %d v%d, want %d v%d", resumed.ID, resumed.Version, app.ID, app.Version)
}
if _, err := s.SubmitVerificationApplication(ctx, app.ID, app.Version); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("submit incomplete draft err = %v, want ErrVerificationApplicationInvalid", err)
}
saved, err := s.SaveVerificationDraft(ctx, app.ID, app.Version, verificationTestDraft())
if err != nil {
t.Fatalf("save draft: %v", err)
}
if saved.Version != app.Version+1 || len(saved.PressLinks) != 2 || saved.OfficialWebsite != "https://example.com" {
t.Fatalf("saved draft = v%d links=%v site=%q", saved.Version, saved.PressLinks, saved.OfficialWebsite)
}
if _, err := s.SaveVerificationDraft(ctx, app.ID, app.Version, verificationTestDraft()); !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("stale save err = %v, want ErrVerificationVersionConflict", err)
}
if _, err := s.SaveVerificationDraft(ctx, app.ID, saved.Version, domain.VerificationDraftInput{OfficialWebsite: "http://127.0.0.1/x"}); !errors.Is(err, domain.ErrVerificationURLInvalid) {
t.Fatalf("private-host save err = %v, want ErrVerificationURLInvalid", err)
}
submitted, err := s.SubmitVerificationApplication(ctx, saved.ID, saved.Version)
if err != nil {
t.Fatalf("submit: %v", err)
}
if submitted.Status != domain.VerificationStatusSubmitted || submitted.SubmittedAt.IsZero() {
t.Fatalf("submitted = %s at %v", submitted.Status, submitted.SubmittedAt)
}
if !submitted.ReviewedAt.IsZero() || submitted.ReviewerAdminID != "" {
t.Fatal("submitted application carries a reviewer")
}
if _, err := s.VerificationDraftForApplicant(ctx, 1001); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("draft after submit err = %v, want ErrVerificationApplicationNotFound", err)
}
active, err := s.ActiveVerificationApplicationForTarget(ctx, domain.VerificationTargetBot, 5001)
if err != nil || active.ID != app.ID {
t.Fatalf("active for target = %d err=%v", active.ID, err)
}
// Returned slices are copies: mutating them must not reach the store.
submitted.PressLinks[0] = "https://evil.example.com"
reread, err := s.VerificationApplication(ctx, app.ID)
if err != nil || reread.PressLinks[0] != "https://press.example.com/story" {
t.Fatalf("stored press links mutated through the caller: %v err=%v", reread.PressLinks, err)
}
}
// TestMemoryVerificationActiveTargetUniqueness is the partial unique index: one
// live application per target, and a decided one no longer blocks a fresh
// attempt.
func TestMemoryVerificationActiveTargetUniqueness(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
first := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 6001, "beta")
if _, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1002, domain.VerificationTargetChannel, 6001, "beta")); !errors.Is(err, domain.ErrVerificationApplicationExists) {
t.Fatalf("second application err = %v, want ErrVerificationApplicationExists", err)
}
// A different target is free for the same target id in another namespace.
namespaced, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1002, domain.VerificationTargetBot, 6001, "betabot"))
if err != nil {
t.Fatalf("other namespace draft: %v", err)
}
// One draft per applicant: naming another target resumes the same
// conversation instead of opening a second draft.
resumed, created, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1002, domain.VerificationTargetBot, 6002, "betabot2"))
if err != nil || created || resumed.ID != namespaced.ID {
t.Fatalf("cross-target draft = %d created=%v err=%v, want draft %d", resumed.ID, created, err, namespaced.ID)
}
cancelled, err := s.CancelVerificationApplication(ctx, first.ID, first.Version, "changed my mind")
if err != nil {
t.Fatalf("cancel: %v", err)
}
if cancelled.Status != domain.VerificationStatusCancelled || cancelled.SubmittedAt.IsZero() {
t.Fatalf("cancelled = %s at %v", cancelled.Status, cancelled.SubmittedAt)
}
if cancelled.DecisionReason != "" {
t.Fatalf("cancel wrote the applicant reason into decision_reason: %q", cancelled.DecisionReason)
}
if _, err := s.CancelVerificationApplication(ctx, cancelled.ID, cancelled.Version, "again"); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("cancel of cancelled err = %v, want ErrVerificationStatusInvalid", err)
}
if _, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1003, domain.VerificationTargetChannel, 6001, "beta")); err != nil {
t.Fatalf("draft after cancellation: %v", err)
}
}
// TestMemoryVerificationClaimAndApprove is the reviewer path, including the
// invariant that matters most: the peer flag is written by the callback and the
// application is only approved if that callback succeeded.
func TestMemoryVerificationClaimAndApprove(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 7001, "gamma")
claimed, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
})
if err != nil {
t.Fatalf("claim: %v", err)
}
if claimed.Status != domain.VerificationStatusInReview || claimed.ReviewerAdminID != "admin-a" {
t.Fatalf("claimed = %s by %q", claimed.Status, claimed.ReviewerAdminID)
}
if !claimed.ReviewedAt.IsZero() {
t.Fatal("claim stamped reviewed_at, which the schema pairs with a decision")
}
if _, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-b",
}); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("re-claim err = %v, want ErrVerificationStatusInvalid", err)
}
// A callback failure must roll the whole decision back.
failing := errors.New("peer store unavailable")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a",
}, true, func(context.Context, domain.VerificationApplication) error {
return failing
}); !errors.Is(err, failing) {
t.Fatalf("failing approve err = %v, want %v", err, failing)
}
rolled, err := s.VerificationApplication(ctx, app.ID)
if err != nil {
t.Fatalf("read after failed approve: %v", err)
}
if rolled.Status != domain.VerificationStatusInReview || rolled.Version != claimed.Version {
t.Fatalf("failed approve left %s v%d, want in_review v%d", rolled.Status, rolled.Version, claimed.Version)
}
if !rolled.ReviewedAt.IsZero() || rolled.DecisionReason != "" {
t.Fatal("failed approve wrote decision metadata")
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 0 {
t.Fatalf("outbox after failed approve = %d rows err=%v", len(pending), err)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 10)
if err != nil {
t.Fatalf("events: %v", err)
}
for _, event := range events {
if event.Kind == domain.VerificationEventApproved {
t.Fatal("failed approve appended an approved event")
}
}
verified := make(map[domain.Peer]bool)
applyVerified := func(_ context.Context, decided domain.VerificationApplication) error {
if decided.Status != domain.VerificationStatusApproved {
return fmt.Errorf("callback saw %s, want approved", decided.Status)
}
verified[decided.Target()] = true
return nil
}
approved, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a",
InternalNote: "checked the press coverage", CorrelationID: "cmd-1",
}, true, applyVerified)
if err != nil || !changed {
t.Fatalf("approve: changed=%v err=%v", changed, err)
}
if approved.Status != domain.VerificationStatusApproved || approved.ReviewedAt.IsZero() ||
approved.ReviewerAdminID != "admin-a" || approved.Version != claimed.Version+1 {
t.Fatalf("approved = %s v%d by %q at %v", approved.Status, approved.Version,
approved.ReviewerAdminID, approved.ReviewedAt)
}
if !verified[approved.Target()] {
t.Fatal("approved application whose target is not verified")
}
// Re-issuing the decision must not notify twice.
repeat, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-b",
}, true, func(context.Context, domain.VerificationApplication) error {
t.Fatal("idempotent approve invoked the callback")
return nil
})
if err != nil || changed {
t.Fatalf("repeat approve: changed=%v err=%v", changed, err)
}
if repeat.Version != approved.Version || repeat.ReviewerAdminID != "admin-a" {
t.Fatalf("repeat approve mutated the record: v%d by %q", repeat.Version, repeat.ReviewerAdminID)
}
pending, err = s.PendingVerificationNotifications(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 1 || pending[0].Kind != "approved" || pending[0].RecipientUserID != 1001 {
t.Fatalf("outbox = %+v, want one approved row for the applicant", pending)
}
if pending[0].Application.ID != app.ID || pending[0].Application.TargetUsername != "gamma" {
t.Fatalf("outbox row carries no application context: %+v", pending[0].Application)
}
// A decided application is terminal.
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-a", Reason: "changed our mind",
}, false, nil); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("reject after approve err = %v, want ErrVerificationStatusInvalid", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-a",
}, true, nil); err == nil {
t.Fatal("approve without a callback succeeded")
}
}
// TestMemoryVerificationRejectRequiresReason keeps the audit trail honest: a
// rejection the applicant is told about always states why.
func TestMemoryVerificationRejectRequiresReason(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 8001, "delta")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
}, false, nil); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("reject without reason err = %v, want ErrVerificationReasonRequired", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: " ", Reason: "not eligible",
}, false, nil); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("reject without reviewer err = %v, want ErrVerificationApplicationInvalid", err)
}
rejected, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
Reason: "press coverage is not independent", InternalNote: "second attempt this month",
}, false, nil)
if err != nil || !changed {
t.Fatalf("reject: changed=%v err=%v", changed, err)
}
if rejected.Status != domain.VerificationStatusRejected || rejected.DecisionReason == "" {
t.Fatalf("rejected = %s reason=%q", rejected.Status, rejected.DecisionReason)
}
cooldown, err := s.LastVerificationRejection(ctx, 1001, domain.VerificationTargetChannel, 8001)
if err != nil || cooldown.ID != app.ID {
t.Fatalf("cooldown lookup = %d err=%v", cooldown.ID, err)
}
if _, err := s.LastVerificationRejection(ctx, 1002, domain.VerificationTargetChannel, 8001); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("cooldown for another applicant err = %v, want ErrVerificationApplicationNotFound", err)
}
// The rejected application is history, so the target is free again, and the
// newest rejection is the one the cooldown is measured from.
second := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 8001, "delta")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: second.ID, Version: second.Version, Reviewer: "admin-b", Reason: "still no",
}, false, nil); err != nil {
t.Fatalf("second reject: %v", err)
}
cooldown, err = s.LastVerificationRejection(ctx, 1001, domain.VerificationTargetChannel, 8001)
if err != nil || cooldown.ID != second.ID {
t.Fatalf("newest rejection = %d err=%v, want %d", cooldown.ID, err, second.ID)
}
}
// TestMemoryVerificationConcurrentDecision is the two-reviewers case: both read
// the same version, exactly one decision lands and the loser is told.
func TestMemoryVerificationConcurrentDecision(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9001, "epsilon")
calls := 0
applyVerified := func(context.Context, domain.VerificationApplication) error {
calls++
return nil
}
if _, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
}, true, applyVerified); err != nil || !changed {
t.Fatalf("first approve: changed=%v err=%v", changed, err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-b",
}, true, applyVerified); !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("second approve err = %v, want ErrVerificationVersionConflict", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-b", Reason: "no",
}, false, nil); !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("losing reject err = %v, want ErrVerificationVersionConflict", err)
}
if calls != 1 {
t.Fatalf("applyVerified called %d times, want exactly 1", calls)
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 1 {
t.Fatalf("outbox = %d rows err=%v, want exactly one notification", len(pending), err)
}
final, err := s.VerificationApplication(ctx, app.ID)
if err != nil || final.ReviewerAdminID != "admin-a" {
t.Fatalf("final decision by %q err=%v, want admin-a", final.ReviewerAdminID, err)
}
}
// TestMemoryVerificationRevoke covers taking the badge back: the flag is cleared
// through the callback, the application stays approved as history and the
// revocation notifies exactly once.
func TestMemoryVerificationRevoke(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 9101, "zeta")
approved, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
}, true, func(context.Context, domain.VerificationApplication) error { return nil })
if err != nil {
t.Fatalf("approve: %v", err)
}
req := domain.VerificationRevocation{
TargetType: domain.VerificationTargetChannel, TargetID: 9101,
Reviewer: "admin-b", Reason: "impersonation report upheld",
}
if _, _, err := s.RevokeVerification(ctx, domain.VerificationRevocation{
TargetType: domain.VerificationTargetChannel, TargetID: 9101, Reviewer: "admin-b",
}, func(context.Context, domain.Peer) error { return nil }); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("revoke without reason err = %v, want ErrVerificationReasonRequired", err)
}
failing := errors.New("peer store unavailable")
if _, _, err := s.RevokeVerification(ctx, req, func(context.Context, domain.Peer) error {
return failing
}); !errors.Is(err, failing) {
t.Fatalf("failing revoke err = %v, want %v", err, failing)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 20)
if err != nil {
t.Fatalf("events: %v", err)
}
for _, event := range events {
if event.Kind == domain.VerificationEventRevoked {
t.Fatal("failed revoke appended a revoked event")
}
}
var cleared []domain.Peer
revoked, changed, err := s.RevokeVerification(ctx, req, func(_ context.Context, target domain.Peer) error {
cleared = append(cleared, target)
return nil
})
if err != nil || !changed {
t.Fatalf("revoke: changed=%v err=%v", changed, err)
}
if len(cleared) != 1 || cleared[0] != (domain.Peer{Type: domain.PeerTypeChannel, ID: 9101}) {
t.Fatalf("cleared = %v, want the channel peer", cleared)
}
if revoked.ID != approved.ID || revoked.Status != domain.VerificationStatusApproved {
t.Fatalf("revoked application = %d %s, want %d approved", revoked.ID, revoked.Status, approved.ID)
}
// A second revocation is a no-op: one outbox row, one history entry.
repeat, changed, err := s.RevokeVerification(ctx, req, func(context.Context, domain.Peer) error {
t.Fatal("idempotent revoke invoked the callback")
return nil
})
if err != nil || changed {
t.Fatalf("repeat revoke: changed=%v err=%v", changed, err)
}
if repeat.ID != approved.ID {
t.Fatalf("repeat revoke returned %d, want %d", repeat.ID, approved.ID)
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
kinds := make([]string, 0, len(pending))
for _, item := range pending {
kinds = append(kinds, item.Kind)
}
if len(kinds) != 2 || kinds[0] != "approved" || kinds[1] != "revoked" {
t.Fatalf("outbox kinds = %v, want [approved revoked] in that order", kinds)
}
// A target nobody ever applied for is still cleared: a standing flag is worse
// than a missing audit row.
orphan, changed, err := s.RevokeVerification(ctx, domain.VerificationRevocation{
TargetType: domain.VerificationTargetBot, TargetID: 9999,
Reviewer: "admin-b", Reason: "manual flag from an older deployment",
}, func(context.Context, domain.Peer) error { return nil })
if err != nil || !changed || orphan.ID != 0 {
t.Fatalf("orphan revoke: app=%d changed=%v err=%v", orphan.ID, changed, err)
}
}
// TestMemoryVerificationHistoryOrder pins the append-only timeline: newest first,
// with the from/to statuses of every transition.
func TestMemoryVerificationHistoryOrder(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9201, "eta")
claimed, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
})
if err != nil {
t.Fatalf("claim: %v", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a", CorrelationID: "cmd-9",
}, true, func(context.Context, domain.VerificationApplication) error { return nil }); err != nil {
t.Fatalf("approve: %v", err)
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 1 {
t.Fatalf("pending = %d err=%v", len(pending), err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, pending[0].ID); err != nil {
t.Fatalf("deliver: %v", err)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 20)
if err != nil {
t.Fatalf("events: %v", err)
}
wantKinds := []domain.VerificationApplicationEventKind{
domain.VerificationEventNotified,
domain.VerificationEventApproved,
domain.VerificationEventClaimed,
domain.VerificationEventSubmitted,
domain.VerificationEventCreated,
}
if len(events) != len(wantKinds) {
t.Fatalf("history = %d rows, want %d", len(events), len(wantKinds))
}
for i, kind := range wantKinds {
if events[i].Kind != kind {
t.Fatalf("history[%d] = %s, want %s", i, events[i].Kind, kind)
}
if i > 0 && events[i].ID >= events[i-1].ID {
t.Fatalf("history is not newest-first at %d: %d >= %d", i, events[i].ID, events[i-1].ID)
}
}
approvedEvent := events[1]
if approvedEvent.FromStatus != domain.VerificationStatusInReview ||
approvedEvent.ToStatus != domain.VerificationStatusApproved ||
approvedEvent.Actor != "admin-a" || approvedEvent.CorrelationID != "cmd-9" {
t.Fatalf("approved event = %+v", approvedEvent)
}
if events[3].FromStatus != domain.VerificationStatusDraft ||
events[3].ToStatus != domain.VerificationStatusSubmitted {
t.Fatalf("submitted event = %+v", events[3])
}
}
// TestMemoryVerificationOutboxDelivery walks one notification from pending to
// delivered, including the failure trace a poisoned row leaves behind.
func TestMemoryVerificationOutboxDelivery(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9301, "theta")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a", Reason: "no press",
}, false, nil); err != nil {
t.Fatalf("reject: %v", err)
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 1 || pending[0].Attempts != 0 {
t.Fatalf("pending = %+v err=%v", pending, err)
}
id := pending[0].ID
if err := s.MarkVerificationNotificationFailed(ctx, id, "bot blocked by user"); err != nil {
t.Fatalf("fail: %v", err)
}
pending, err = s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 1 || pending[0].Attempts != 1 {
t.Fatalf("after failure = %+v err=%v, want still pending with 1 attempt", pending, err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, id); err != nil {
t.Fatalf("deliver: %v", err)
}
pending, err = s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 0 {
t.Fatalf("after delivery = %d rows err=%v, want none", len(pending), err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, id); err != nil {
t.Fatalf("repeat deliver: %v", err)
}
if err := s.MarkVerificationNotificationFailed(ctx, id, "late error"); err != nil {
t.Fatalf("fail after delivery: %v", err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, id+1000); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("deliver unknown id err = %v, want ErrVerificationApplicationNotFound", err)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 20)
if err != nil {
t.Fatalf("events: %v", err)
}
if events[0].Kind != domain.VerificationEventNotified || events[0].Reason != "rejected" {
t.Fatalf("notified event = %+v, want the rejected notification", events[0])
}
}
// TestMemoryVerificationQueueQueries covers the review-queue projection: status
// and target filters, reviewer scoping, the three search shapes and keyset paging.
func TestMemoryVerificationQueueQueries(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
first := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9401, "AlphaBot")
claimed, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: first.ID, Version: first.Version, Reviewer: "admin-a",
})
if err != nil {
t.Fatalf("claim: %v", err)
}
second := submittedVerificationApplication(t, s, 1002, domain.VerificationTargetChannel, 9402, "BetaChannel")
third, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1003, domain.VerificationTargetSupergroup, 9403, "gammagroup"))
if err != nil {
t.Fatalf("third draft: %v", err)
}
ids := func(apps []domain.VerificationApplication) []int64 {
out := make([]int64, 0, len(apps))
for _, app := range apps {
out = append(out, app.ID)
}
return out
}
equal := func(got []int64, want ...int64) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
all, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{})
if err != nil {
t.Fatalf("list: %v", err)
}
if !equal(ids(all), third.ID, second.ID, first.ID) {
t.Fatalf("queue order = %v, want newest first", ids(all))
}
got, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Statuses: []domain.VerificationStatus{domain.VerificationStatusSubmitted, domain.VerificationStatusInReview},
})
if err != nil || !equal(ids(got), second.ID, first.ID) {
t.Fatalf("status filter = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
TargetType: domain.VerificationTargetChannel,
})
if err != nil || !equal(ids(got), second.ID) {
t.Fatalf("target type filter = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Reviewer: "admin-a"})
if err != nil || !equal(ids(got), first.ID) {
t.Fatalf("reviewer filter = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Reviewer: "admin-z"})
if err != nil || len(got) != 0 {
t.Fatalf("unknown reviewer = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
CreatedAt: second.CreatedAt,
})
if err != nil || !equal(ids(got), third.ID, second.ID) {
t.Fatalf("since filter = %v err=%v", ids(got), err)
}
// BeforeID without a cursor timestamp still bounds the page.
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
BeforeID: second.ID,
})
if err != nil || !equal(ids(got), first.ID) {
t.Fatalf("id-only cursor = %v err=%v", ids(got), err)
}
// Search: application id, peer id, username prefix (with and without @).
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Query: fmt.Sprint(first.ID),
})
if err != nil || !equal(ids(got), first.ID) {
t.Fatalf("id search = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "9402"})
if err != nil || !equal(ids(got), second.ID) {
t.Fatalf("peer id search = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "@beta"})
if err != nil || !equal(ids(got), second.ID) {
t.Fatalf("username search = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "ALPHA"})
if err != nil || !equal(ids(got), first.ID) {
t.Fatalf("case-insensitive username search = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "nobody"})
if err != nil || len(got) != 0 {
t.Fatalf("miss search = %v err=%v", ids(got), err)
}
// Keyset paging over (created_at DESC, id DESC).
page, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Limit: 2})
if err != nil || !equal(ids(page), third.ID, second.ID) {
t.Fatalf("first page = %v err=%v", ids(page), err)
}
last := page[len(page)-1]
next, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Limit: 2, Until: last.CreatedAt, BeforeID: last.ID,
})
if err != nil || !equal(ids(next), first.ID) {
t.Fatalf("second page = %v err=%v", ids(next), err)
}
last = next[len(next)-1]
tail, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Limit: 2, Until: last.CreatedAt, BeforeID: last.ID,
})
if err != nil || len(tail) != 0 {
t.Fatalf("third page = %v err=%v, want empty", ids(tail), err)
}
counts, err := s.VerificationStatusCounts(ctx)
if err != nil {
t.Fatalf("counts: %v", err)
}
if counts[domain.VerificationStatusInReview] != 1 ||
counts[domain.VerificationStatusSubmitted] != 1 ||
counts[domain.VerificationStatusDraft] != 1 ||
counts[domain.VerificationStatusApproved] != 0 {
t.Fatalf("counts = %v", counts)
}
mine, err := s.VerificationApplicationsForApplicant(ctx, 1001, 10)
if err != nil || !equal(ids(mine), first.ID) {
t.Fatalf("applicant history = %v err=%v", ids(mine), err)
}
_ = claimed
if _, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Statuses: []domain.VerificationStatus{"bogus"},
}); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("bogus status filter err = %v, want ErrVerificationApplicationInvalid", err)
}
if _, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
TargetType: "bogus",
}); !errors.Is(err, domain.ErrVerificationTargetInvalid) {
t.Fatalf("bogus target filter err = %v, want ErrVerificationTargetInvalid", err)
}
}
// TestMemoryVerificationMissingApplication pins the not-found surface every
// mutation shares.
func TestMemoryVerificationMissingApplication(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
if _, err := s.VerificationApplication(ctx, 42); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("read err = %v", err)
}
if _, err := s.SubmitVerificationApplication(ctx, 42, 1); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("submit err = %v", err)
}
if _, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: 42, Version: 1, Reviewer: "admin-a",
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("claim err = %v", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: 42, Version: 1, Reviewer: "admin-a",
}, true, func(context.Context, domain.VerificationApplication) error {
return nil
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("decide err = %v", err)
}
}

View file

@ -170,7 +170,7 @@ func (s *AccountLifecycleStore) ExecuteAccountDeletion(ctx context.Context, user
if err := purgeDeletedAccountPrivateState(ctx, tx, userID, now); err != nil {
return domain.AccountDeletionResult{}, err
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, ""); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, "", ""); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("release deleted account username: %w", err)
}
reason = strings.TrimSpace(reason)

View file

@ -0,0 +1,557 @@
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// AccountRatingStore is the PostgreSQL implementation of the composite account
// rating read model and its contribution ledger.
//
// account_rating is derived state: it is always rebuildable from the contributing
// tables plus the 'manual' rows of account_rating_events, which is exactly what
// AccountRatingSignals gathers. Writes use optimistic concurrency on the stored
// version so a background recompute and an admin adjustment cannot silently
// overwrite each other.
type AccountRatingStore struct {
db sqlcgen.DBTX
}
// NewAccountRatingStore builds the store on a pgx pool or transaction.
func NewAccountRatingStore(db sqlcgen.DBTX) *AccountRatingStore {
return &AccountRatingStore{db: db}
}
var _ store.AccountRatingStore = (*AccountRatingStore)(nil)
const (
defaultAccountRatingListLimit = 50
maxAccountRatingListLimit = 200
)
const accountRatingColumns = `user_id, level, stars, current_level_stars, next_level_stars,
stars_component, activity_component, penalty_component, manual_component,
pending_stars, pending_date, computed_at, updated_at, version`
// accountRatingColumnsQualified is the same projection for queries that join, so
// the shared column names stay unambiguous.
const accountRatingColumnsQualified = `r.user_id, r.level, r.stars, r.current_level_stars, r.next_level_stars,
r.stars_component, r.activity_component, r.penalty_component, r.manual_component,
r.pending_stars, r.pending_date, r.computed_at, r.updated_at, r.version`
// AccountRating returns the stored projection, distinguishing "never computed"
// from "computed as zero".
func (s *AccountRatingStore) AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error) {
if s == nil || s.db == nil {
return domain.AccountRating{}, fmt.Errorf("account rating store is not configured")
}
if userID <= 0 {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
rating, err := scanAccountRating(s.db.QueryRow(ctx, `
SELECT `+accountRatingColumns+` FROM account_rating WHERE user_id = $1`, userID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
if err != nil {
return domain.AccountRating{}, fmt.Errorf("get account rating: %w", err)
}
return rating, nil
}
// AccountRatingBatch resolves several users in one round trip.
func (s *AccountRatingStore) AccountRatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
out := make(map[int64]domain.AccountRating, len(userIDs))
filtered := make([]int64, 0, len(userIDs))
for _, userID := range userIDs {
if userID > 0 {
filtered = append(filtered, userID)
}
}
if len(filtered) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT `+accountRatingColumns+` FROM account_rating WHERE user_id = ANY($1::bigint[])`, filtered)
if err != nil {
return nil, fmt.Errorf("list account ratings batch: %w", err)
}
defer rows.Close()
for rows.Next() {
rating, err := scanAccountRating(rows)
if err != nil {
return nil, fmt.Errorf("scan account rating batch: %w", err)
}
out[rating.UserID] = rating
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account ratings batch: %w", err)
}
return out, nil
}
// SaveAccountRating upserts the projection under optimistic concurrency: the
// caller submits the version it intends to write (prev.Version + 1, which is what
// domain.ResolveAccountRatingPending produces), and the update only lands when the
// stored row is still one version behind. A stale write reports changed=false and
// returns the row that won, so the caller can recompute instead of retrying blind.
func (s *AccountRatingStore) SaveAccountRating(ctx context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
if s == nil || s.db == nil {
return domain.AccountRating{}, false, fmt.Errorf("account rating store is not configured")
}
if rating.UserID <= 0 || rating.Level < 0 || rating.Level > domain.MaxAccountRatingLevel ||
rating.CurrentLevelStars < 0 || rating.StarsComponent < 0 ||
rating.ActivityComponent < 0 || rating.PenaltyComponent < 0 {
return domain.AccountRating{}, false, domain.ErrAccountRatingAdjustmentInvalid
}
if rating.Version <= 0 {
rating.Version = 1
}
now := time.Now().UTC()
if rating.ComputedAt.IsZero() {
rating.ComputedAt = now
}
if rating.UpdatedAt.IsZero() {
rating.UpdatedAt = now
}
// The schema pairs pending_stars with pending_date; a half-filled pending
// record is normalised away rather than rejected by the CHECK at runtime.
if rating.PendingStars == 0 || rating.PendingDate.IsZero() {
rating.PendingStars = 0
rating.PendingDate = time.Time{}
}
var nextLevelStars any
if rating.HasNextLevel && rating.NextLevelStars > rating.CurrentLevelStars {
nextLevelStars = rating.NextLevelStars
}
var pendingDate any
if rating.PendingStars != 0 {
pendingDate = rating.PendingDate.UTC()
}
stored, err := scanAccountRating(s.db.QueryRow(ctx, `
INSERT INTO account_rating (
user_id, level, stars, current_level_stars, next_level_stars,
stars_component, activity_component, penalty_component, manual_component,
pending_stars, pending_date, computed_at, updated_at, version
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
ON CONFLICT (user_id) DO UPDATE SET
level = EXCLUDED.level,
stars = EXCLUDED.stars,
current_level_stars = EXCLUDED.current_level_stars,
next_level_stars = EXCLUDED.next_level_stars,
stars_component = EXCLUDED.stars_component,
activity_component = EXCLUDED.activity_component,
penalty_component = EXCLUDED.penalty_component,
manual_component = EXCLUDED.manual_component,
pending_stars = EXCLUDED.pending_stars,
pending_date = EXCLUDED.pending_date,
computed_at = EXCLUDED.computed_at,
updated_at = EXCLUDED.updated_at,
version = EXCLUDED.version
WHERE account_rating.version = EXCLUDED.version - 1
RETURNING `+accountRatingColumns,
rating.UserID, rating.Level, rating.Stars, rating.CurrentLevelStars, nextLevelStars,
rating.StarsComponent, rating.ActivityComponent, rating.PenaltyComponent, rating.ManualComponent,
rating.PendingStars, pendingDate, rating.ComputedAt.UTC(), rating.UpdatedAt.UTC(), rating.Version,
))
if errors.Is(err, pgx.ErrNoRows) {
// The guard rejected the write; report the row that is actually stored.
current, getErr := s.AccountRating(ctx, rating.UserID)
if getErr != nil {
return domain.AccountRating{}, false, getErr
}
return current, false, nil
}
if err != nil {
return domain.AccountRating{}, false, fmt.Errorf("save account rating: %w", err)
}
return stored, true, nil
}
// AccountRatingSignals gathers the raw contribution snapshot for one user.
//
// Sources, and why each one:
//
// stars received / spent stars_transactions, split by the sign of amount: that
// ledger is the single authoritative record of Stars
// movement for a user, and stars_transactions_user_id_idx
// (user_id, id DESC) bounds the scan to the user's rows.
// gifts received peer_star_gifts for the user peer, restricted to
// lifecycle_status = 'active' -- the weight rewards gifts
// actually held, not ones converted, burned or exported
// away. peer_star_gifts_owner_profile_order_idx is the
// partial index on exactly that predicate and leads with
// (owner_peer_type, owner_peer_id).
// moderation cases moderation_cases against this user peer, restricted to
// the statuses that follow a *violation* decision:
// 'action_pending' (violation decided, actions running),
// 'action_failed' (violation decided, action delivery
// broke) and 'resolved' (violation decided and actions
// applied). 'dismissed' covers both no_violation and a
// granted appeal, and 'open'/'in_review'/'appeal_review'
// are undecided, so none of them penalise the account.
// scam / fake users.scam / users.fake, the peer flags 0136 added.
// account age users.created_at, floored to whole days.
// messages sent message_boxes, counted through
// message_boxes_private_sender_live_idx
// (message_sender_id, private_message_id) WHERE NOT
// deleted. This is the cheapest trustworthy source: the
// index leads with the sender and is coverable, so the
// count needs no heap access. Every private message
// materialises one box per participant, hence
// count(DISTINCT private_message_id) rather than
// count(*). Channel posts are deliberately excluded --
// channel_messages has no sender-leading index, so
// attributing them would cost a full table scan.
// manual sum of account_rating_events.amount where
// kind = 'manual', which is the part of the score that
// must survive a full recompute.
func (s *AccountRatingStore) AccountRatingSignals(ctx context.Context, userID int64) (domain.AccountRatingSignals, error) {
if s == nil || s.db == nil {
return domain.AccountRatingSignals{}, fmt.Errorf("account rating store is not configured")
}
if userID <= 0 {
return domain.AccountRatingSignals{}, domain.ErrUserNotFound
}
signals := domain.AccountRatingSignals{UserID: userID}
err := s.db.QueryRow(ctx, `
SELECT
COALESCE((SELECT sum(amount) FROM stars_transactions WHERE user_id = u.id AND amount > 0), 0),
COALESCE((SELECT -sum(amount) FROM stars_transactions WHERE user_id = u.id AND amount < 0), 0),
COALESCE((
SELECT count(DISTINCT private_message_id) FROM message_boxes
WHERE message_sender_id = u.id AND NOT deleted
), 0),
GREATEST(0, FLOOR(EXTRACT(EPOCH FROM ($2::timestamptz - u.created_at)) / 86400))::bigint,
COALESCE((
SELECT count(*) FROM peer_star_gifts
WHERE owner_peer_type = 'user' AND owner_peer_id = u.id AND lifecycle_status = 'active'
), 0),
COALESCE((
SELECT count(*) FROM moderation_cases
WHERE target_peer_type = 'user' AND target_peer_id = u.id
AND status IN ('action_pending', 'action_failed', 'resolved')
), 0),
u.scam,
u.fake,
COALESCE((
SELECT sum(amount) FROM account_rating_events
WHERE user_id = u.id AND kind = 'manual'
), 0)
FROM users u
WHERE u.id = $1`, userID, time.Now().UTC()).Scan(
&signals.StarsReceived, &signals.StarsSpent, &signals.MessagesSent,
&signals.AccountAgeDays, &signals.GiftsReceived, &signals.ModerationCases,
&signals.Scam, &signals.Fake, &signals.Manual,
)
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountRatingSignals{}, domain.ErrUserNotFound
}
if err != nil {
return domain.AccountRatingSignals{}, fmt.Errorf("gather account rating signals: %w", err)
}
return signals, nil
}
// AdjustAccountRating appends a manual adjustment to the ledger. It does not
// recompute the projection: the caller pairs it with SaveAccountRating so the new
// manual total is folded in through the same formula as every other signal.
// Replaying the same CommandKey returns the recorded event and applied=false.
func (s *AccountRatingStore) AdjustAccountRating(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
if s == nil || s.db == nil {
return domain.AccountRatingEvent{}, false, fmt.Errorf("account rating store is not configured")
}
req.Reason = strings.TrimSpace(req.Reason)
req.Actor = strings.TrimSpace(req.Actor)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return domain.AccountRatingEvent{}, false, err
}
var event domain.AccountRatingEvent
applied := false
err := withTx(ctx, s.db, "adjust account rating", func(tx pgx.Tx) error {
if existing, found, err := accountRatingEventByCommandKey(ctx, tx, req.CommandKey); err != nil {
return err
} else if found {
event = existing
return nil
}
event = domain.AccountRatingEvent{
UserID: req.UserID,
Kind: domain.AccountRatingEventManual,
Amount: req.Amount,
Reason: req.Reason,
Actor: req.Actor,
CommandKey: req.CommandKey,
CreatedAt: time.Now().UTC(),
}
// DO NOTHING on the partial command_key index closes the window between the
// replay lookup and the insert: a concurrent retry of the same command
// records nothing and falls back to reading the row that won.
err := tx.QueryRow(ctx, `
INSERT INTO account_rating_events (user_id, kind, amount, reason, actor, command_key, created_at)
VALUES ($1,'manual',$2,$3,$4,NULLIF($5,''),$6)
ON CONFLICT (command_key) WHERE command_key IS NOT NULL DO NOTHING
RETURNING id`, event.UserID, event.Amount, event.Reason, event.Actor, event.CommandKey, event.CreatedAt).
Scan(&event.ID)
if errors.Is(err, pgx.ErrNoRows) {
existing, found, lookupErr := accountRatingEventByCommandKey(ctx, tx, req.CommandKey)
if lookupErr != nil {
return lookupErr
}
if !found {
return fmt.Errorf("insert account rating adjustment: conflicting command %q vanished", req.CommandKey)
}
event = existing
return nil
}
if err != nil {
return fmt.Errorf("insert account rating adjustment: %w", err)
}
applied = true
return nil
})
if err != nil {
return domain.AccountRatingEvent{}, false, err
}
return event, applied, nil
}
// ListAccountRatings is the admin leaderboard query. The order matches
// account_rating_leaderboard_idx (level DESC, stars DESC, user_id) and BeforeID is
// a keyset cursor: the cursor row's own (level, stars) are read back so paging
// stays consistent across the compound order instead of only over user ids.
func (s *AccountRatingStore) ListAccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
if filter.MinLevel < 0 || filter.MinLevel > domain.MaxAccountRatingLevel {
return nil, domain.ErrAccountRatingAdjustmentInvalid
}
limit := filter.Limit
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
if limit > maxAccountRatingListLimit {
limit = maxAccountRatingListLimit
}
rows, err := s.db.Query(ctx, `
WITH cursor_row AS (
SELECT level AS c_level, stars AS c_stars, user_id AS c_user_id
FROM account_rating WHERE $3 <> 0 AND user_id = $3
)
SELECT `+accountRatingColumnsQualified+`
FROM account_rating r
LEFT JOIN cursor_row c ON true
WHERE r.level >= $1
AND ($2 = 0 OR r.user_id = $2)
AND (
c.c_user_id IS NULL
OR r.level < c.c_level
OR (r.level = c.c_level AND r.stars < c.c_stars)
OR (r.level = c.c_level AND r.stars = c.c_stars AND r.user_id > c.c_user_id)
)
ORDER BY r.level DESC, r.stars DESC, r.user_id
LIMIT $4`, filter.MinLevel, filter.UserID, filter.BeforeID, limit)
if err != nil {
return nil, fmt.Errorf("list account ratings: %w", err)
}
defer rows.Close()
out := make([]domain.AccountRating, 0, limit)
for rows.Next() {
rating, err := scanAccountRating(rows)
if err != nil {
return nil, fmt.Errorf("scan account rating: %w", err)
}
out = append(out, rating)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account ratings: %w", err)
}
return out, nil
}
// AccountRatingEvents returns the ledger for one user, newest first, over
// account_rating_events_user_idx (user_id, id DESC).
func (s *AccountRatingStore) AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
if userID <= 0 {
return nil, domain.ErrAccountRatingNotFound
}
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
if limit > maxAccountRatingListLimit {
limit = maxAccountRatingListLimit
}
rows, err := s.db.Query(ctx, `
SELECT id, user_id, kind, amount, reason, actor, COALESCE(command_key, ''), created_at
FROM account_rating_events
WHERE user_id = $1
ORDER BY id DESC
LIMIT $2`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list account rating events: %w", err)
}
defer rows.Close()
out := make([]domain.AccountRatingEvent, 0, limit)
for rows.Next() {
event, err := scanAccountRatingEvent(rows)
if err != nil {
return nil, fmt.Errorf("scan account rating event: %w", err)
}
out = append(out, event)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account rating events: %w", err)
}
return out, nil
}
// StaleAccountRatings returns the users whose projection is older than the
// horizon, ordered so the walk follows account_rating_stale_idx
// (computed_at, user_id) exactly.
func (s *AccountRatingStore) StaleAccountRatings(ctx context.Context, olderThanUnix int64, limit int) ([]int64, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
if olderThanUnix <= 0 {
return nil, nil
}
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
if limit > maxAccountRatingListLimit {
limit = maxAccountRatingListLimit
}
rows, err := s.db.Query(ctx, `
SELECT user_id FROM account_rating
WHERE computed_at < to_timestamp($1)
ORDER BY computed_at, user_id
LIMIT $2`, olderThanUnix, limit)
if err != nil {
return nil, fmt.Errorf("list stale account ratings: %w", err)
}
defer rows.Close()
out := make([]int64, 0, limit)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, fmt.Errorf("scan stale account rating: %w", err)
}
out = append(out, userID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate stale account ratings: %w", err)
}
return out, nil
}
// UnratedAccounts returns accounts that have no projection yet, oldest account
// first so the walk is stable and every account is eventually reached.
//
// Three kinds of account are skipped, per domain.RatableAccount: bots, which do
// not transact on their own behalf; the built-in service accounts, which are
// infrastructure -- and note that the platform account is not flagged is_bot, so
// excluding bots alone would still have seeded it; and deleted accounts, which are
// tombstones whose every profile field has already been cleared.
func (s *AccountRatingStore) UnratedAccounts(ctx context.Context, limit int) ([]int64, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
if limit > maxAccountRatingListLimit {
limit = maxAccountRatingListLimit
}
rows, err := s.db.Query(ctx, `
SELECT u.id FROM users u
WHERE NOT u.is_bot
AND u.deleted_at IS NULL
AND u.id <> ALL($2::bigint[])
AND NOT EXISTS (SELECT 1 FROM account_rating r WHERE r.user_id = u.id)
ORDER BY u.created_at, u.id
LIMIT $1`, limit, domain.SystemUserIDs())
if err != nil {
return nil, fmt.Errorf("list unrated accounts: %w", err)
}
defer rows.Close()
out := make([]int64, 0, limit)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, fmt.Errorf("scan unrated account: %w", err)
}
out = append(out, userID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate unrated accounts: %w", err)
}
return out, nil
}
func accountRatingEventByCommandKey(ctx context.Context, db sqlcgen.DBTX, commandKey string) (domain.AccountRatingEvent, bool, error) {
if commandKey == "" {
return domain.AccountRatingEvent{}, false, nil
}
event, err := scanAccountRatingEvent(db.QueryRow(ctx, `
SELECT id, user_id, kind, amount, reason, actor, COALESCE(command_key, ''), created_at
FROM account_rating_events WHERE command_key = $1`, commandKey))
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountRatingEvent{}, false, nil
}
if err != nil {
return domain.AccountRatingEvent{}, false, fmt.Errorf("lookup account rating command: %w", err)
}
return event, true, nil
}
func scanAccountRating(row pgx.Row) (domain.AccountRating, error) {
var rating domain.AccountRating
var nextLevelStars pgtype.Int8
var pendingDate pgtype.Timestamptz
if err := row.Scan(&rating.UserID, &rating.Level, &rating.Stars, &rating.CurrentLevelStars,
&nextLevelStars, &rating.StarsComponent, &rating.ActivityComponent,
&rating.PenaltyComponent, &rating.ManualComponent, &rating.PendingStars,
&pendingDate, &rating.ComputedAt, &rating.UpdatedAt, &rating.Version); err != nil {
return domain.AccountRating{}, err
}
if nextLevelStars.Valid {
rating.NextLevelStars = nextLevelStars.Int64
rating.HasNextLevel = true
}
if pendingDate.Valid {
rating.PendingDate = pendingDate.Time.UTC()
}
rating.ComputedAt = rating.ComputedAt.UTC()
rating.UpdatedAt = rating.UpdatedAt.UTC()
return rating, nil
}
func scanAccountRatingEvent(row pgx.Row) (domain.AccountRatingEvent, error) {
var event domain.AccountRatingEvent
var kind string
if err := row.Scan(&event.ID, &event.UserID, &kind, &event.Amount, &event.Reason,
&event.Actor, &event.CommandKey, &event.CreatedAt); err != nil {
return domain.AccountRatingEvent{}, err
}
event.Kind = domain.AccountRatingEventKind(kind)
event.CreatedAt = event.CreatedAt.UTC()
return event, nil
}

View file

@ -0,0 +1,462 @@
package postgres
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
// ratingTestUser inserts a user row with an explicit creation date so the account
// age signal is deterministic.
func ratingTestUser(t *testing.T, pool *pgxpool.Pool, seed int64, createdAt time.Time) int64 {
t.Helper()
ctx := context.Background()
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO users (access_hash, phone, first_name, created_at, updated_at)
VALUES ($1, $2, 'rating test', $3, $3)
RETURNING id`, seed, fmt.Sprintf("%d", seed), createdAt).Scan(&id); err != nil {
t.Fatalf("insert rating test user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
})
return id
}
// ratingTestCatalogRevision publishes a throwaway star gift so peer_star_gifts
// rows can satisfy their catalog revision foreign key.
func ratingTestCatalogRevision(t *testing.T, pool *pgxpool.Pool) (revisionID, giftID int64) {
t.Helper()
ctx := context.Background()
suffix := randomSuffix(t)
docID := time.Now().UnixNano() & 0x7fffffffffffffff
entry, err := NewStarGiftStore(pool).CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Stars: 50, ConvertStars: 50, Enabled: true,
Document: domain.Document{
ID: docID, AccessHash: docID + 1, MimeType: "application/x-tgsticker", Size: 4, DCID: 2,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
},
Blob: domain.FileBlob{
LocationKey: "doc:" + fmt.Sprint(docID), Backend: domain.MediaBackendLocalFS,
ObjectKey: "rating-star-gift", Size: 4, SHA256: make([]byte, 32),
MimeType: "application/x-tgsticker",
},
Animation: domain.StarGiftAnimation{
JSON: []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`),
SHA256: make([]byte, 32), SourceFormat: domain.StarGiftAnimationTGS, Width: 512, Height: 512,
},
Actor: "test", CommandID: "rating-star-gift-" + suffix,
})
if err != nil {
t.Fatalf("create catalog revision: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT id FROM star_gift_catalog_revisions WHERE gift_id = $1 ORDER BY revision DESC LIMIT 1`,
entry.Gift.ID).Scan(&revisionID); err != nil {
t.Fatalf("read catalog revision id: %v", err)
}
t.Cleanup(func() {
cleanupCtx := context.Background()
_, _ = pool.Exec(cleanupCtx, `DELETE FROM star_gift_catalog WHERE gift_id = $1`, entry.Gift.ID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM star_gift_catalog_revisions WHERE gift_id = $1`, entry.Gift.ID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM file_blobs WHERE location_key = $1`, "doc:"+fmt.Sprint(docID))
_, _ = pool.Exec(cleanupCtx, `DELETE FROM documents WHERE id = $1`, docID)
})
return revisionID, entry.Gift.ID
}
// TestAccountRatingSaveVersionConflict covers the optimistic write: a first save
// creates the row, a stale version is refused without an error, and the next
// version wins.
func TestAccountRatingSaveVersionConflict(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
userID := ratingTestUser(t, pool, 3_100_000_000+seed, time.Now().UTC().AddDate(0, 0, -30))
if _, err := store.AccountRating(ctx, userID); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("missing rating err = %v, want ErrAccountRatingNotFound", err)
}
now := time.Now().UTC().Truncate(time.Millisecond)
signals := domain.AccountRatingSignals{UserID: userID, StarsReceived: 900, MessagesSent: 10, AccountAgeDays: 30}
computed := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
stored, changed, err := store.SaveAccountRating(ctx, computed)
if err != nil || !changed {
t.Fatalf("first save changed=%v err=%v", changed, err)
}
if stored.Version != 1 || stored.Stars != computed.Stars || stored.Level != computed.Level ||
stored.HasNextLevel != computed.HasNextLevel || stored.NextLevelStars != computed.NextLevelStars {
t.Fatalf("stored = %+v want %+v", stored, computed)
}
read, err := store.AccountRating(ctx, userID)
if err != nil || read != stored {
t.Fatalf("read = %+v stored = %+v err=%v", read, stored, err)
}
// A second writer that still believes version 0 is stale: no error, no write.
stale := computed
stale.Stars = 999_999
conflicted, changed, err := store.SaveAccountRating(ctx, stale)
if err != nil || changed {
t.Fatalf("stale save changed=%v err=%v", changed, err)
}
if conflicted.Stars != stored.Stars || conflicted.Version != 1 {
t.Fatalf("conflicted = %+v, stored row must win", conflicted)
}
// The recompute that carries the right version applies, including a pending
// delta that must round-trip through the paired pending_stars/pending_date.
next := domain.ResolveAccountRatingPending(stored,
domain.ComputeAccountRating(domain.AccountRatingSignals{UserID: userID, StarsReceived: 40_000}, domain.DefaultAccountRatingWeights(), now),
time.Hour, now)
if next.PendingStars == 0 || next.PendingDate.IsZero() {
t.Fatalf("expected a parked pending delta, got %+v", next)
}
applied, changed, err := store.SaveAccountRating(ctx, next)
if err != nil || !changed {
t.Fatalf("versioned save changed=%v err=%v", changed, err)
}
if applied.Version != 2 || applied.PendingStars != next.PendingStars ||
!applied.PendingDate.Equal(next.PendingDate.UTC()) {
t.Fatalf("applied = %+v want pending %d at %v", applied, next.PendingStars, next.PendingDate)
}
pending, ok := applied.PendingLevel()
if !ok || pending.Stars <= applied.Stars {
t.Fatalf("pending projection = %+v ok=%v", pending, ok)
}
batch, err := store.AccountRatingBatch(ctx, []int64{userID, userID + 1})
if err != nil || len(batch) != 1 || batch[userID].Version != 2 {
t.Fatalf("batch = %+v err=%v", batch, err)
}
list, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{UserID: userID, Limit: 10})
if err != nil || len(list) != 1 || list[0].UserID != userID {
t.Fatalf("list = %+v err=%v", list, err)
}
stale2, err := store.StaleAccountRatings(ctx, now.Add(time.Minute).Unix(), 100)
if err != nil {
t.Fatalf("stale: %v", err)
}
if !containsInt64(stale2, userID) {
t.Fatalf("stale ratings %v must contain %d", stale2, userID)
}
fresh, err := store.StaleAccountRatings(ctx, now.Add(-time.Hour).Unix(), 100)
if err != nil {
t.Fatalf("stale fresh: %v", err)
}
if containsInt64(fresh, userID) {
t.Fatalf("rating computed at %v must not be stale before it", applied.ComputedAt)
}
}
// TestAccountRatingAdjustmentIdempotency covers the manual ledger: a replayed
// command key records nothing new and the manual total feeds the recompute.
func TestAccountRatingAdjustmentIdempotency(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
userID := ratingTestUser(t, pool, 3_200_000_000+seed, time.Now().UTC().AddDate(0, 0, -10))
key := fmt.Sprintf("adjust-%d", seed)
req := domain.AdjustAccountRatingRequest{
UserID: userID, Amount: 750, Reason: "community award", Actor: "ops", CommandKey: key,
}
event, applied, err := store.AdjustAccountRating(ctx, req)
if err != nil || !applied {
t.Fatalf("adjust applied=%v err=%v", applied, err)
}
if event.ID == 0 || event.Kind != domain.AccountRatingEventManual || event.Amount != 750 ||
event.CommandKey != key {
t.Fatalf("event = %+v", event)
}
replay, applied, err := store.AdjustAccountRating(ctx, req)
if err != nil || applied || replay.ID != event.ID {
t.Fatalf("replay applied=%v event=%+v err=%v", applied, replay, err)
}
// A second, distinct adjustment accumulates rather than replacing.
if _, applied, err := store.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: userID, Amount: -250, Reason: "partial revoke", Actor: "ops",
CommandKey: key + "-b",
}); err != nil || !applied {
t.Fatalf("second adjust applied=%v err=%v", applied, err)
}
events, err := store.AccountRatingEvents(ctx, userID, 10)
if err != nil || len(events) != 2 || events[0].Amount != -250 || events[1].Amount != 750 {
t.Fatalf("events = %+v err=%v", events, err)
}
if _, _, err := store.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: userID, Amount: 0, CommandKey: key + "-c",
}); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
t.Fatalf("zero adjustment err = %v, want ErrAccountRatingAdjustmentInvalid", err)
}
signals, err := store.AccountRatingSignals(ctx, userID)
if err != nil {
t.Fatalf("signals: %v", err)
}
if signals.Manual != 500 {
t.Fatalf("manual signal = %d, want 500", signals.Manual)
}
}
// TestAccountRatingSignalsSources pins where each contribution comes from: the
// Stars ledger sign split, saved gifts, upheld moderation cases, the peer flags,
// account age and the private-message count.
func TestAccountRatingSignalsSources(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
createdAt := time.Now().UTC().AddDate(0, 0, -45)
userID := ratingTestUser(t, pool, 3_300_000_000+seed, createdAt)
if _, err := pool.Exec(ctx, `
INSERT INTO stars_transactions (user_id, amount, reason, date)
VALUES ($1, 1500, 'gift', 0), ($1, 500, 'reaction', 0), ($1, -400, 'purchase', 0)`, userID); err != nil {
t.Fatalf("seed stars: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM stars_transactions WHERE user_id = $1`, userID)
})
revisionID, giftID := ratingTestCatalogRevision(t, pool)
if _, err := pool.Exec(ctx, `
INSERT INTO peer_star_gifts (owner_peer_type, owner_peer_id, msg_id, gift_id, gift_date, catalog_revision_id, lifecycle_status, converted)
VALUES ('user', $1, 1, $2, 0, $3, 'active', false),
('user', $1, 2, $2, 0, $3, 'active', false),
('user', $1, 3, $2, 0, $3, 'converted', true)`, userID, giftID, revisionID); err != nil {
t.Fatalf("seed gifts: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(),
`DELETE FROM peer_star_gifts WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, userID)
})
// Only statuses that follow a violation decision count; 'dismissed' (which
// covers no_violation and a granted appeal) and undecided states do not.
now := time.Now().UTC()
for _, status := range []string{"resolved", "action_failed", "dismissed", "open"} {
if _, err := pool.Exec(ctx, `
INSERT INTO moderation_cases (
target_peer_type, target_peer_id, status, severity, report_count,
distinct_reporter_count, first_report_at, last_report_at, created_at, updated_at
) VALUES ('user', $1, $2, 1, 1, 1, $3, $3, $3, $3)`, userID, status, now); err != nil {
t.Fatalf("seed moderation case %s: %v", status, err)
}
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(),
`DELETE FROM moderation_cases WHERE target_peer_type = 'user' AND target_peer_id = $1`, userID)
})
if _, err := pool.Exec(ctx, `UPDATE users SET scam = true WHERE id = $1`, userID); err != nil {
t.Fatalf("set scam: %v", err)
}
// Two private messages, each materialised as the sender's and the recipient's
// box: the distinct count must report two, not four.
peerID := ratingTestUser(t, pool, 3_350_000_000+seed, createdAt)
for i := 1; i <= 2; i++ {
var messageID int64
if err := pool.QueryRow(ctx, `
INSERT INTO private_messages (sender_user_id, recipient_user_id, message_date, body)
VALUES ($1, $2, 0, 'hi')
RETURNING id`, userID, peerID).Scan(&messageID); err != nil {
t.Fatalf("seed private message: %v", err)
}
for _, box := range []struct {
owner int64
peer int64
outgoing bool
}{{userID, peerID, true}, {peerID, userID, false}} {
if _, err := pool.Exec(ctx, `
INSERT INTO message_boxes (
owner_user_id, box_id, private_message_id, message_sender_id, peer_type, peer_id,
from_user_id, message_date, outgoing, body
) VALUES ($1, $2, $3, $4, 'user', $5, $4, 0, $6, 'hi')`,
box.owner, i, messageID, userID, box.peer, box.outgoing); err != nil {
t.Fatalf("seed message box: %v", err)
}
}
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM message_boxes WHERE message_sender_id = $1`, userID)
_, _ = pool.Exec(context.Background(), `DELETE FROM private_messages WHERE sender_user_id = $1`, userID)
})
signals, err := store.AccountRatingSignals(ctx, userID)
if err != nil {
t.Fatalf("signals: %v", err)
}
if signals.UserID != userID || signals.StarsReceived != 2000 || signals.StarsSpent != 400 ||
signals.GiftsReceived != 2 || signals.ModerationCases != 2 || !signals.Scam || signals.Fake ||
signals.MessagesSent != 2 || signals.AccountAgeDays != 45 || signals.Manual != 0 {
t.Fatalf("signals = %+v", signals)
}
rating := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
if rating.PenaltyComponent == 0 || rating.StarsComponent != 2000+100 {
t.Fatalf("computed rating = %+v", rating)
}
if _, err := store.AccountRatingSignals(ctx, userID+7_000_000); !errors.Is(err, domain.ErrUserNotFound) {
t.Fatalf("signals for unknown user err = %v, want ErrUserNotFound", err)
}
}
// TestAccountRatingLeaderboardPaging covers the keyset walk over
// (level DESC, stars DESC, user_id).
func TestAccountRatingLeaderboardPaging(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
now := time.Now().UTC()
scores := []int64{100, 400, 900, 1600}
ids := make([]int64, 0, len(scores))
for i, score := range scores {
userID := ratingTestUser(t, pool, 3_400_000_000+seed+int64(i), now.AddDate(0, 0, -1))
ids = append(ids, userID)
rating := domain.ComputeAccountRating(domain.AccountRatingSignals{UserID: userID, Manual: score},
domain.DefaultAccountRatingWeights(), now)
if _, changed, err := store.SaveAccountRating(ctx, rating); err != nil || !changed {
t.Fatalf("save rating %d: changed=%v err=%v", userID, changed, err)
}
}
page, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: 2, Limit: 2})
if err != nil || len(page) != 2 {
t.Fatalf("first page = %+v err=%v", page, err)
}
if page[0].Level < page[1].Level {
t.Fatalf("leaderboard must be level-descending: %+v", page)
}
next, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{
MinLevel: 2, BeforeID: page[len(page)-1].UserID, Limit: 10,
})
if err != nil {
t.Fatalf("second page: %v", err)
}
for _, item := range next {
if item.UserID == page[0].UserID || item.UserID == page[1].UserID {
t.Fatalf("keyset page repeated user %d", item.UserID)
}
if item.Level > page[len(page)-1].Level {
t.Fatalf("keyset page went backwards: %+v after %+v", item, page)
}
}
if _, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: -1}); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
t.Fatalf("negative level filter must be rejected")
}
_ = ids
}
// TestAccountRatingUnratedAccountsSeedsTheReadModel proves the SQL behind the
// bootstrap pass: only accounts with no projection are returned, bots and deleted
// tombstones are excluded, the walk is oldest-account-first, and a user drops out of
// the candidate set the moment a projection exists.
//
// Without this query the read model can never populate itself -- StaleAccountRatings
// walks account_rating and so cannot return a user who is not in it.
func TestAccountRatingUnratedAccountsSeedsTheReadModel(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
base := time.Now().UTC().Add(-72 * time.Hour).Truncate(time.Second)
oldest := ratingTestUser(t, pool, 4_100_000_000+seed, base)
middle := ratingTestUser(t, pool, 4_200_000_000+seed, base.Add(time.Hour))
newest := ratingTestUser(t, pool, 4_300_000_000+seed, base.Add(2*time.Hour))
bot := ratingTestUser(t, pool, 4_400_000_000+seed, base.Add(3*time.Hour))
deleted := ratingTestUser(t, pool, 4_500_000_000+seed, base.Add(4*time.Hour))
if _, err := pool.Exec(ctx, `UPDATE users SET is_bot = true WHERE id = $1`, bot); err != nil {
t.Fatalf("mark bot: %v", err)
}
// A deleted account is a tombstone: every profile field is already cleared, so
// there is no rating to show and no reason to compute one.
if _, err := pool.Exec(ctx, `
UPDATE users SET deleted_at = now(), deletion_source = 'manual', deletion_reason = 'test',
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
verified = false, support = false, premium_expires_at = NULL,
emoji_status_document_id = 0, emoji_status_until = 0,
emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb,
color_set = false, color = 0, color_background_emoji_id = 0,
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
birthday_day = 0, birthday_month = 0, birthday_year = 0,
personal_channel_id = 0, last_seen_at = 0, account_delete_at = NULL
WHERE id = $1`, deleted); err != nil {
t.Fatalf("mark deleted: %v", err)
}
// The table is shared with every other account in the test database, so assert
// on relative order and membership rather than on an exact page.
positions := func(t *testing.T) (map[int64]int, []int64) {
t.Helper()
ids, err := store.UnratedAccounts(ctx, maxAccountRatingListLimit)
if err != nil {
t.Fatalf("UnratedAccounts: %v", err)
}
index := make(map[int64]int, len(ids))
for i, id := range ids {
index[id] = i
}
return index, ids
}
index, ids := positions(t)
for _, id := range []int64{oldest, middle, newest} {
if _, ok := index[id]; !ok {
t.Fatalf("account %d with no projection is absent from %d candidates", id, len(ids))
}
}
if _, ok := index[bot]; ok {
t.Fatalf("bot %d was offered as a rating candidate", bot)
}
if _, ok := index[deleted]; ok {
t.Fatalf("deleted account %d was offered as a rating candidate", deleted)
}
// The service accounts are infrastructure. The platform account in particular is
// NOT flagged is_bot, so excluding bots alone would still have seeded it -- which
// is exactly how it got a rating.
for _, serviceID := range domain.SystemUserIDs() {
if _, ok := index[serviceID]; ok {
t.Fatalf("service account %d was offered as a rating candidate", serviceID)
}
}
if !(index[oldest] < index[middle] && index[middle] < index[newest]) {
t.Fatalf("candidate order = oldest %d, middle %d, newest %d; want oldest first",
index[oldest], index[middle], index[newest])
}
// Seeding one account removes it from the candidate set, so the pass converges
// instead of offering the same user every cycle.
if _, changed, err := store.SaveAccountRating(ctx, domain.AccountRating{
UserID: middle, Level: 1, Stars: 150,
CurrentLevelStars: domain.AccountRatingLevelThreshold(1),
ComputedAt: time.Now().UTC(), Version: 1,
}); err != nil || !changed {
t.Fatalf("seed projection = %v changed=%v", err, changed)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM account_rating WHERE user_id = $1`, middle)
})
index, _ = positions(t)
if _, ok := index[middle]; ok {
t.Fatalf("account %d is still a candidate after being seeded", middle)
}
if _, ok := index[oldest]; !ok {
t.Fatalf("seeding %d removed unrelated candidate %d", middle, oldest)
}
// The limit is honoured, so one cycle can never walk the whole users table.
capped, err := store.UnratedAccounts(ctx, 2)
if err != nil {
t.Fatalf("UnratedAccounts with a limit: %v", err)
}
if len(capped) != 2 {
t.Fatalf("limited candidates = %d, want 2", len(capped))
}
}

View file

@ -70,7 +70,7 @@ func (s *BotStore) CreateBotAccount(ctx context.Context, user domain.User, profi
return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: insert user: %w", err)
}
if usernameLower := strings.ToLower(row.Username); usernameLower != "" {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, usernameLower); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, row.Username, usernameLower); err != nil {
return domain.User{}, domain.BotProfile{}, err
}
}
@ -139,7 +139,7 @@ func (s *BotStore) DeleteBotAccount(ctx context.Context, botUserID int64) (domai
if err := purgeDeletedAccountPrivateState(ctx, tx, botUserID, now); err != nil {
return domain.User{}, err
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, botUserID, ""); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, botUserID, "", ""); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: release username: %w", err)
}
// Drop the bots row so the token can no longer authenticate a login.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,7 @@ package postgres
import (
"context"
"strings"
"testing"
"telesrv/internal/domain"
@ -123,7 +124,12 @@ SELECT peer_id
FROM peer_usernames
WHERE username_lower = $1 AND peer_type = 'channel'
`, publicUsername)
requirePlanContains(t, usernameLookupPlan, "peer_usernames_pkey")
// 0150 adds peer_usernames_peer_order_idx, which covers this lookup as an
// index-only scan; either index is an acceptable plan, a partition scan is not.
if !strings.Contains(usernameLookupPlan, "peer_usernames_pkey") &&
!strings.Contains(usernameLookupPlan, "peer_usernames_peer_order_idx") {
t.Fatalf("username lookup plan = %s, want a peer_usernames index scan", usernameLookupPlan)
}
requirePlanNotMatches(t, usernameLookupPlan, `channels_p\d+`)
usernameChannelDetailPlan := explainText(t, ctx, tx, `

View file

@ -238,7 +238,7 @@ func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChan
if strings.EqualFold(channel.Username, username) {
return domain.Channel{}, domain.ErrChannelNotModified
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, req.ChannelID, usernameLower); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, req.ChannelID, username, usernameLower); err != nil {
return domain.Channel{}, err
}
if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, req.ChannelID, username); err != nil {
@ -426,7 +426,7 @@ func (s *ChannelStore) SetChannelUsernameAdmin(ctx context.Context, channelID in
if strings.EqualFold(channel.Username, username) {
return channel, nil
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, channelID, usernameLower); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, channelID, username, usernameLower); err != nil {
return domain.Channel{}, err
}
if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, channelID, username); err != nil {
@ -517,7 +517,17 @@ func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerU
}
return domain.Channel{}, false, fmt.Errorf("resolve public channel username channel: %w", err)
}
if !publicPreviewableChannel(ch) || !strings.EqualFold(ch.Username, usernameLower) {
if !publicPreviewableChannel(ch) {
return domain.Channel{}, false, nil
}
// A collectible row is authoritative for its own name: the channel's scalar
// username column only ever mirrors the editable slot, so re-checking it here
// would make collectible names unresolvable. The scalar comparison stays for
// the editable slot, where it guards against a stale registry row.
if !owner.collectible && !strings.EqualFold(ch.Username, usernameLower) {
return domain.Channel{}, false, nil
}
if owner.collectible && !owner.active {
return domain.Channel{}, false, nil
}
return ch, true, nil

View file

@ -71,8 +71,8 @@ func TestChannelStoreResolvePublicUsernameRejectsStaleIndex(t *testing.T) {
t.Fatalf("clear username: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO peer_usernames (username_lower, peer_type, peer_id)
VALUES ($1,'channel',$2)
INSERT INTO peer_usernames (username_lower, username, peer_type, peer_id, active, editable, sort_order)
VALUES ($1,$1,'channel',$2,true,true,0)
ON CONFLICT (username_lower) DO UPDATE SET peer_type = EXCLUDED.peer_type, peer_id = EXCLUDED.peer_id, updated_at = now()
`, strings.ToLower(publicUsername), publicChannel.ID); err != nil {
t.Fatalf("insert stale username index: %v", err)

View file

@ -0,0 +1,750 @@
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// CollectibleUsernameStore is the PostgreSQL implementation of the collectible
// username registry and the asset lifecycle behind it.
//
// The asset (collectible_usernames) and its registry projection (peer_usernames)
// are always written in one transaction: an asset can never be owned without
// being resolvable, and a resolvable collectible row always has a live owner.
// The asset row is locked with SELECT ... FOR UPDATE before any mutation, and the
// name itself is locked through the registry row, so two concurrent commands on
// the same name or the same asset serialise instead of interleaving.
type CollectibleUsernameStore struct {
db sqlcgen.DBTX
}
// NewCollectibleUsernameStore builds the store on a pgx pool or transaction.
func NewCollectibleUsernameStore(db sqlcgen.DBTX) *CollectibleUsernameStore {
return &CollectibleUsernameStore{db: db}
}
var (
_ store.UsernameRegistryStore = (*CollectibleUsernameStore)(nil)
_ store.CollectibleUsernameStore = (*CollectibleUsernameStore)(nil)
)
const (
defaultCollectibleUsernameListLimit = 50
maxCollectibleUsernameListLimit = 200
)
// collectibleUsernameColumns is the asset projection shared by every reader.
const collectibleUsernameColumns = `id, username, status, owner_peer_type, owner_peer_id,
purchase_date, currency, amount, crypto_currency, crypto_amount, url,
original_owner_peer_type, original_owner_peer_id, transfer_count, version,
created_at, updated_at`
// PeerUsernames returns the peer's registry rows in projection order.
func (s *CollectibleUsernameStore) PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("collectible username store is not configured")
}
return listPeerUsernames(ctx, s.db, peer)
}
// PeerUsernamesBatch resolves several peers in one round trip.
func (s *CollectibleUsernameStore) PeerUsernamesBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("collectible username store is not configured")
}
return listPeerUsernamesBatch(ctx, s.db, peers)
}
// SetUsernameActive toggles one collectible row. The editable slot is rejected:
// the client owns it through account.updateUsername, not through this path.
func (s *CollectibleUsernameStore) SetUsernameActive(ctx context.Context, peer domain.Peer, username string, active bool) (bool, error) {
if s == nil || s.db == nil {
return false, fmt.Errorf("collectible username store is not configured")
}
username = domain.NormalizeUsername(username)
if peer.Type == "" || peer.ID <= 0 || username == "" {
return false, domain.ErrUsernameInvalid
}
usernameLower := strings.ToLower(username)
changed := false
err := withTx(ctx, s.db, "set collectible username active", func(tx pgx.Tx) error {
current, err := lockPeerUsernamesTx(ctx, tx, peer)
if err != nil {
return err
}
if err := domain.ValidateUsernameToggle(current, username, active); err != nil {
return err
}
tag, err := tx.Exec(ctx, `
UPDATE peer_usernames SET active = $4, updated_at = now()
WHERE username_lower = $1 AND peer_type = $2 AND peer_id = $3
AND collectible_id IS NOT NULL AND active <> $4`,
usernameLower, string(peer.Type), peer.ID, active)
if err != nil {
return fmt.Errorf("update collectible username active: %w", err)
}
changed = tag.RowsAffected() > 0
return nil
})
if err != nil {
return false, err
}
return changed, nil
}
// ReorderUsernames rewrites the peer's username sort order. order carries every
// active username the peer has, the editable slot included -- see
// domain.ValidateUsernameReorder -- so the editable row is repositioned like any
// other and a collectible may end up first.
func (s *CollectibleUsernameStore) ReorderUsernames(ctx context.Context, peer domain.Peer, order []string) (bool, error) {
if s == nil || s.db == nil {
return false, fmt.Errorf("collectible username store is not configured")
}
if peer.Type == "" || peer.ID <= 0 {
return false, domain.ErrUsernameInvalid
}
changed := false
err := withTx(ctx, s.db, "reorder collectible usernames", func(tx pgx.Tx) error {
current, err := lockPeerUsernamesTx(ctx, tx, peer)
if err != nil {
return err
}
next, err := domain.ApplyUsernameReorder(current, order)
if err != nil {
return err
}
previous := make(map[string]int, len(current))
for _, item := range current {
previous[strings.ToLower(item.Username)] = item.SortOrder
}
// Renumbering always happens; "changed" is about what a client can see.
changed = !domain.SameUsernameOrder(current, next)
for _, item := range next {
key := strings.ToLower(item.Username)
if key == "" || previous[key] == item.SortOrder {
continue
}
if _, err := tx.Exec(ctx, `
UPDATE peer_usernames SET sort_order = $4, updated_at = now()
WHERE username_lower = $1 AND peer_type = $2 AND peer_id = $3`,
key, string(peer.Type), peer.ID, item.SortOrder); err != nil {
return fmt.Errorf("update username sort order: %w", err)
}
}
return nil
})
if err != nil {
return false, err
}
return changed, nil
}
// DeactivateAllUsernames clears the active flag on every collectible row, which
// is what losing a public surface does to the peer's collectible names. The
// editable slot keeps its own flag.
func (s *CollectibleUsernameStore) DeactivateAllUsernames(ctx context.Context, peer domain.Peer) (bool, error) {
if s == nil || s.db == nil {
return false, fmt.Errorf("collectible username store is not configured")
}
if peer.Type == "" || peer.ID <= 0 {
return false, domain.ErrUsernameInvalid
}
tag, err := s.db.Exec(ctx, `
UPDATE peer_usernames SET active = false, updated_at = now()
WHERE peer_type = $1 AND peer_id = $2 AND collectible_id IS NOT NULL AND active`,
string(peer.Type), peer.ID)
if err != nil {
return false, fmt.Errorf("deactivate collectible usernames: %w", err)
}
return tag.RowsAffected() > 0, nil
}
// MintCollectibleUsername creates the asset, optionally assigning it in the same
// transaction. A non-empty CommandKey makes the mint replay-safe: the recorded
// provenance row carries the key, so a retry returns the original asset.
func (s *CollectibleUsernameStore) MintCollectibleUsername(ctx context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
if s == nil || s.db == nil {
return domain.CollectibleUsername{}, false, fmt.Errorf("collectible username store is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
usernameLower := strings.ToLower(req.Username)
var asset domain.CollectibleUsername
created := false
err := withTx(ctx, s.db, "mint collectible username", func(tx pgx.Tx) error {
if replayed, found, err := replayCollectibleUsernameCommand(ctx, tx, req.CommandKey); err != nil {
return err
} else if found {
asset = replayed
return nil
}
now := time.Now().UTC()
purchaseDate := req.PurchaseDate.UTC()
if req.PurchaseDate.IsZero() {
purchaseDate = now
}
// The registry row locks the name against editable usernames. Occupancy of
// the asset itself is decided by the live rows only: 0152 narrowed
// uniqueness to status <> 'burned', so a retired name can be issued again
// while its burned rows stay as provenance.
if _, found, err := getPeerUsernameOwner(ctx, tx, usernameLower, true); err != nil {
return err
} else if found {
return domain.ErrUsernameOccupied
}
var existing int64
switch err := tx.QueryRow(ctx, `
SELECT id FROM collectible_usernames
WHERE username_lower = $1 AND status <> 'burned' FOR UPDATE`, usernameLower).Scan(&existing); {
case err == nil:
return domain.ErrUsernameOccupied
case errors.Is(err, pgx.ErrNoRows):
default:
return fmt.Errorf("lock collectible username: %w", err)
}
owner := req.Owner
status := domain.CollectibleUsernameStatusVault
if owner.Type != "" {
status = domain.CollectibleUsernameStatusOwned
count, err := countPeerCollectibleUsernamesTx(ctx, tx, string(owner.Type), owner.ID)
if err != nil {
return err
}
if count >= domain.MaxPeerCollectibleUsernames {
return domain.ErrCollectibleUsernameLimit
}
}
var collectibleID int64
if err := tx.QueryRow(ctx, `
INSERT INTO collectible_usernames
(username, username_lower, status, owner_peer_type, owner_peer_id, purchase_date,
currency, amount, crypto_currency, crypto_amount, url,
original_owner_peer_type, original_owner_peer_id, transfer_count, version,
created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$4,$5,0,1,$12,$12)
RETURNING id`,
req.Username, usernameLower, string(status), string(owner.Type), owner.ID, purchaseDate,
req.Currency, req.Amount, req.CryptoCurrency, req.CryptoAmount, req.URL, now,
).Scan(&collectibleID); err != nil {
if isUniqueViolation(err) {
return domain.ErrUsernameOccupied
}
return fmt.Errorf("insert collectible username: %w", err)
}
if owner.Type != "" {
if err := insertCollectiblePeerUsernameTx(ctx, tx, string(owner.Type), owner.ID,
req.Username, usernameLower, collectibleID); err != nil {
return err
}
}
// A mint that assigns an owner records a single 'mint' row carrying the
// recipient: the command key is unique, so one command owns one row.
if err := insertCollectibleUsernameTransferTx(ctx, tx, collectibleUsernameTransfer{
collectibleID: collectibleID,
kind: domain.CollectibleUsernameKindMint,
to: owner,
currency: req.Currency,
amount: req.Amount,
actor: req.Actor,
reason: req.Reason,
commandKey: req.CommandKey,
createdAt: now,
}); err != nil {
return err
}
loaded, err := collectibleUsernameByIDTx(ctx, tx, collectibleID)
if err != nil {
return err
}
asset = loaded
created = true
return nil
})
if err != nil {
return domain.CollectibleUsername{}, false, err
}
return asset, created, nil
}
// TransferCollectibleUsername moves the asset to req.To, out of the vault or from
// the current holder. The old registry row is removed and the new one inserted in
// the same transaction, so the name never resolves to the wrong peer.
func (s *CollectibleUsernameStore) TransferCollectibleUsername(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
if s == nil || s.db == nil {
return domain.CollectibleUsername{}, false, fmt.Errorf("collectible username store is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
usernameLower := strings.ToLower(req.Username)
var asset domain.CollectibleUsername
changed := false
err := withTx(ctx, s.db, "transfer collectible username", func(tx pgx.Tx) error {
if replayed, found, err := replayCollectibleUsernameCommand(ctx, tx, req.CommandKey); err != nil {
return err
} else if found {
asset = replayed
return nil
}
current, err := lockCollectibleUsernameTx(ctx, tx, usernameLower)
if err != nil {
return err
}
if current.Status == domain.CollectibleUsernameStatusBurned {
return domain.ErrCollectibleUsernameBurned
}
if current.Owned() && current.Owner == req.To {
asset = current
return nil
}
now := time.Now().UTC()
if err := deleteCollectiblePeerUsernameTx(ctx, tx, current.ID); err != nil {
return err
}
count, err := countPeerCollectibleUsernamesTx(ctx, tx, string(req.To.Type), req.To.ID)
if err != nil {
return err
}
if count >= domain.MaxPeerCollectibleUsernames {
return domain.ErrCollectibleUsernameLimit
}
if err := insertCollectiblePeerUsernameTx(ctx, tx, string(req.To.Type), req.To.ID,
current.Username, usernameLower, current.ID); err != nil {
return err
}
// The original owner is the first holder and is recorded once: a name that
// left the vault keeps its provenance across every later move and the burn.
if _, err := tx.Exec(ctx, `
UPDATE collectible_usernames
SET status = 'owned',
owner_peer_type = $2,
owner_peer_id = $3,
original_owner_peer_type = CASE WHEN original_owner_peer_type = '' THEN $2 ELSE original_owner_peer_type END,
original_owner_peer_id = CASE WHEN original_owner_peer_type = '' THEN $3 ELSE original_owner_peer_id END,
transfer_count = transfer_count + 1,
version = version + 1,
updated_at = $4
WHERE id = $1`, current.ID, string(req.To.Type), req.To.ID, now); err != nil {
return fmt.Errorf("update transferred collectible username: %w", err)
}
if err := insertCollectibleUsernameTransferTx(ctx, tx, collectibleUsernameTransfer{
collectibleID: current.ID,
kind: domain.CollectibleUsernameKindTransfer,
from: current.Owner,
to: req.To,
actor: req.Actor,
reason: req.Reason,
commandKey: req.CommandKey,
createdAt: now,
}); err != nil {
return err
}
loaded, err := collectibleUsernameByIDTx(ctx, tx, current.ID)
if err != nil {
return err
}
asset = loaded
changed = true
return nil
})
if err != nil {
return domain.CollectibleUsername{}, false, err
}
return asset, changed, nil
}
// RevokeCollectibleUsername returns the asset to the vault, or burns it when
// req.Burn is set. Either way the registry row goes away, so the name stops
// resolving to the former holder; a burn additionally retires the asset.
func (s *CollectibleUsernameStore) RevokeCollectibleUsername(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
if s == nil || s.db == nil {
return domain.CollectibleUsername{}, false, fmt.Errorf("collectible username store is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
usernameLower := strings.ToLower(req.Username)
var asset domain.CollectibleUsername
changed := false
err := withTx(ctx, s.db, "revoke collectible username", func(tx pgx.Tx) error {
if replayed, found, err := replayCollectibleUsernameCommand(ctx, tx, req.CommandKey); err != nil {
return err
} else if found {
asset = replayed
return nil
}
current, err := lockCollectibleUsernameTx(ctx, tx, usernameLower)
if err != nil {
return err
}
if current.Status == domain.CollectibleUsernameStatusBurned {
return domain.ErrCollectibleUsernameBurned
}
if !req.Burn && !current.Owned() {
// Already in the vault: nothing to release, nothing to record.
asset = current
return nil
}
now := time.Now().UTC()
if err := deleteCollectiblePeerUsernameTx(ctx, tx, current.ID); err != nil {
return err
}
status := domain.CollectibleUsernameStatusVault
kind := domain.CollectibleUsernameKindRevoke
if req.Burn {
status = domain.CollectibleUsernameStatusBurned
kind = domain.CollectibleUsernameKindBurn
}
if _, err := tx.Exec(ctx, `
UPDATE collectible_usernames
SET status = $2,
owner_peer_type = '',
owner_peer_id = 0,
version = version + 1,
updated_at = $3
WHERE id = $1`, current.ID, string(status), now); err != nil {
return fmt.Errorf("update revoked collectible username: %w", err)
}
if err := insertCollectibleUsernameTransferTx(ctx, tx, collectibleUsernameTransfer{
collectibleID: current.ID,
kind: kind,
from: current.Owner,
actor: req.Actor,
reason: req.Reason,
commandKey: req.CommandKey,
createdAt: now,
}); err != nil {
return err
}
loaded, err := collectibleUsernameByIDTx(ctx, tx, current.ID)
if err != nil {
return err
}
asset = loaded
changed = true
return nil
})
if err != nil {
return domain.CollectibleUsername{}, false, err
}
return asset, changed, nil
}
// DeleteCollectibleUsername removes the live asset for a name outright: the
// registry row, the asset and its provenance log all go away, and the name
// becomes free for any use. This is the operator's escape hatch for a mistaken
// issue, as opposed to Revoke+Burn, which retires an asset but keeps its history.
//
// The command key cannot make this idempotent -- a replay has no record left to
// return -- so a second call simply reports deleted=false once no live asset
// remains.
func (s *CollectibleUsernameStore) DeleteCollectibleUsername(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
if s == nil || s.db == nil {
return false, fmt.Errorf("collectible username store is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return false, err
}
usernameLower := strings.ToLower(req.Username)
deleted := false
err := withTx(ctx, s.db, "delete collectible username", func(tx pgx.Tx) error {
var id int64
switch err := tx.QueryRow(ctx, `
SELECT id FROM collectible_usernames
WHERE username_lower = $1 AND status <> 'burned'
ORDER BY id DESC
LIMIT 1
FOR UPDATE`, usernameLower).Scan(&id); {
case err == nil:
case errors.Is(err, pgx.ErrNoRows):
// Either the name was never issued, or only burned history remains.
// Both are "nothing live to delete" rather than an error, so a repeated
// command stays safe.
return nil
default:
return fmt.Errorf("lock collectible username for delete: %w", err)
}
if err := deleteCollectiblePeerUsernameTx(ctx, tx, id); err != nil {
return err
}
// collectible_username_transfers references the asset with ON DELETE
// CASCADE, so the provenance rows go with it.
if _, err := tx.Exec(ctx, `DELETE FROM collectible_usernames WHERE id = $1`, id); err != nil {
return fmt.Errorf("delete collectible username: %w", err)
}
deleted = true
return nil
})
if err != nil {
return false, err
}
return deleted, nil
}
// CollectibleUsername looks the asset up by name. A live asset wins; when the
// name only has burned rows the newest one is returned, because the provenance of
// a retired name still has to be inspectable.
func (s *CollectibleUsernameStore) CollectibleUsername(ctx context.Context, username string) (domain.CollectibleUsername, error) {
if s == nil || s.db == nil {
return domain.CollectibleUsername{}, fmt.Errorf("collectible username store is not configured")
}
usernameLower := strings.ToLower(domain.NormalizeUsername(username))
if usernameLower == "" {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
asset, err := scanCollectibleUsername(s.db.QueryRow(ctx, `
SELECT `+collectibleUsernameColumns+`
FROM collectible_usernames
WHERE username_lower = $1
ORDER BY (status <> 'burned') DESC, id DESC
LIMIT 1`, usernameLower))
if errors.Is(err, pgx.ErrNoRows) {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
if err != nil {
return domain.CollectibleUsername{}, fmt.Errorf("get collectible username: %w", err)
}
return asset, nil
}
// CollectibleUsernameByID looks the asset up by identity.
func (s *CollectibleUsernameStore) CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error) {
if s == nil || s.db == nil {
return domain.CollectibleUsername{}, fmt.Errorf("collectible username store is not configured")
}
if id <= 0 {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
asset, err := collectibleUsernameByIDTx(ctx, s.db, id)
if err != nil {
return domain.CollectibleUsername{}, err
}
return asset, nil
}
// ListCollectibleUsernames is the admin listing query with keyset paging on the
// asset id, which matches the (status, id DESC) and (owner, id DESC) indexes.
func (s *CollectibleUsernameStore) ListCollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("collectible username store is not configured")
}
if filter.Status != "" && !filter.Status.Valid() {
return nil, domain.ErrCollectibleUsernameStateInvalid
}
if filter.Owner.Type != "" && filter.Owner.ID <= 0 {
return nil, domain.ErrCollectibleUsernameStateInvalid
}
limit := filter.Limit
if limit <= 0 {
limit = defaultCollectibleUsernameListLimit
}
if limit > maxCollectibleUsernameListLimit {
limit = maxCollectibleUsernameListLimit
}
query := strings.ToLower(domain.NormalizeUsername(filter.Query))
rows, err := s.db.Query(ctx, `
SELECT `+collectibleUsernameColumns+`
FROM collectible_usernames
WHERE ($1 = '' OR status = $1)
AND ($2 = '' OR (owner_peer_type = $2 AND owner_peer_id = $3))
AND ($4 = '' OR username_lower LIKE $5 || '%')
AND ($6 = 0 OR id < $6)
ORDER BY id DESC
LIMIT $7`,
string(filter.Status), string(filter.Owner.Type), filter.Owner.ID,
query, escapeLike(query), filter.BeforeID, limit)
if err != nil {
return nil, fmt.Errorf("list collectible usernames: %w", err)
}
defer rows.Close()
out := make([]domain.CollectibleUsername, 0, limit)
for rows.Next() {
asset, err := scanCollectibleUsername(rows)
if err != nil {
return nil, fmt.Errorf("scan collectible username: %w", err)
}
out = append(out, asset)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate collectible usernames: %w", err)
}
return out, nil
}
// CollectibleUsernameTransfers returns the provenance log, newest first.
func (s *CollectibleUsernameStore) CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("collectible username store is not configured")
}
if collectibleID <= 0 {
return nil, domain.ErrCollectibleUsernameNotFound
}
if limit <= 0 {
limit = defaultCollectibleUsernameListLimit
}
if limit > maxCollectibleUsernameListLimit {
limit = maxCollectibleUsernameListLimit
}
rows, err := s.db.Query(ctx, `
SELECT id, collectible_id, kind, from_peer_type, from_peer_id, to_peer_type, to_peer_id,
currency, amount, actor, reason, COALESCE(command_key, ''), created_at
FROM collectible_username_transfers
WHERE collectible_id = $1
ORDER BY id DESC
LIMIT $2`, collectibleID, limit)
if err != nil {
return nil, fmt.Errorf("list collectible username transfers: %w", err)
}
defer rows.Close()
out := make([]domain.CollectibleUsernameTransfer, 0, limit)
for rows.Next() {
var item domain.CollectibleUsernameTransfer
var kind, fromType, toType string
if err := rows.Scan(&item.ID, &item.CollectibleID, &kind, &fromType, &item.From.ID,
&toType, &item.To.ID, &item.Currency, &item.Amount, &item.Actor, &item.Reason,
&item.CommandKey, &item.CreatedAt); err != nil {
return nil, fmt.Errorf("scan collectible username transfer: %w", err)
}
item.Kind = domain.CollectibleUsernameTransferKind(kind)
item.From.Type = domain.PeerType(fromType)
item.To.Type = domain.PeerType(toType)
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate collectible username transfers: %w", err)
}
return out, nil
}
type collectibleUsernameTransfer struct {
collectibleID int64
kind domain.CollectibleUsernameTransferKind
from domain.Peer
to domain.Peer
currency string
amount int64
actor string
reason string
commandKey string
createdAt time.Time
}
func insertCollectibleUsernameTransferTx(ctx context.Context, tx pgx.Tx, entry collectibleUsernameTransfer) error {
if _, err := tx.Exec(ctx, `
INSERT INTO collectible_username_transfers
(collectible_id, kind, from_peer_type, from_peer_id, to_peer_type, to_peer_id,
currency, amount, actor, reason, command_key, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NULLIF($11,''),$12)`,
entry.collectibleID, string(entry.kind), string(entry.from.Type), entry.from.ID,
string(entry.to.Type), entry.to.ID, entry.currency, entry.amount,
entry.actor, entry.reason, entry.commandKey, entry.createdAt); err != nil {
return fmt.Errorf("insert collectible username transfer: %w", err)
}
return nil
}
// replayCollectibleUsernameCommand resolves an already-recorded command key to
// the asset it touched, which is what makes mint/transfer/revoke retry-safe.
//
// The transaction-scoped advisory lock serialises commands sharing a key, so two
// concurrent retries cannot both pass the lookup and race on the unique
// command_key index -- the second one waits and then observes the recorded row.
func replayCollectibleUsernameCommand(ctx context.Context, tx pgx.Tx, commandKey string) (domain.CollectibleUsername, bool, error) {
if commandKey == "" {
return domain.CollectibleUsername{}, false, nil
}
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(hashtextextended('collectible-username:' || $1::text, 0))`, commandKey); err != nil {
return domain.CollectibleUsername{}, false, fmt.Errorf("lock collectible username command: %w", err)
}
var collectibleID int64
err := tx.QueryRow(ctx, `
SELECT collectible_id FROM collectible_username_transfers WHERE command_key = $1`, commandKey).Scan(&collectibleID)
if errors.Is(err, pgx.ErrNoRows) {
return domain.CollectibleUsername{}, false, nil
}
if err != nil {
return domain.CollectibleUsername{}, false, fmt.Errorf("lookup collectible username command: %w", err)
}
asset, err := collectibleUsernameByIDTx(ctx, tx, collectibleID)
if err != nil {
return domain.CollectibleUsername{}, false, err
}
return asset, true, nil
}
// lockCollectibleUsernameTx locks the row a name currently resolves to. After
// 0151 one name can carry several burned rows plus at most one live row, so the
// live row wins and the newest burned row is the fallback. That keeps a mutation
// of a retired name reporting ErrCollectibleUsernameBurned instead of degrading
// to a not-found.
func lockCollectibleUsernameTx(ctx context.Context, tx pgx.Tx, usernameLower string) (domain.CollectibleUsername, error) {
asset, err := scanCollectibleUsername(tx.QueryRow(ctx, `
SELECT `+collectibleUsernameColumns+`
FROM collectible_usernames
WHERE username_lower = $1
ORDER BY (status <> 'burned') DESC, id DESC
LIMIT 1
FOR UPDATE`, usernameLower))
if errors.Is(err, pgx.ErrNoRows) {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
if err != nil {
return domain.CollectibleUsername{}, fmt.Errorf("lock collectible username: %w", err)
}
return asset, nil
}
func collectibleUsernameByIDTx(ctx context.Context, db sqlcgen.DBTX, id int64) (domain.CollectibleUsername, error) {
asset, err := scanCollectibleUsername(db.QueryRow(ctx, `
SELECT `+collectibleUsernameColumns+`
FROM collectible_usernames WHERE id = $1`, id))
if errors.Is(err, pgx.ErrNoRows) {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
if err != nil {
return domain.CollectibleUsername{}, fmt.Errorf("get collectible username by id: %w", err)
}
return asset, nil
}
func scanCollectibleUsername(row pgx.Row) (domain.CollectibleUsername, error) {
var asset domain.CollectibleUsername
var status, ownerType, originalOwnerType string
if err := row.Scan(&asset.ID, &asset.Username, &status, &ownerType, &asset.Owner.ID,
&asset.PurchaseDate, &asset.Currency, &asset.Amount, &asset.CryptoCurrency,
&asset.CryptoAmount, &asset.URL, &originalOwnerType, &asset.OriginalOwner.ID,
&asset.TransferCount, &asset.Version, &asset.CreatedAt, &asset.UpdatedAt); err != nil {
return domain.CollectibleUsername{}, err
}
asset.Status = domain.CollectibleUsernameStatus(status)
asset.Owner.Type = domain.PeerType(ownerType)
asset.OriginalOwner.Type = domain.PeerType(originalOwnerType)
return asset, nil
}

View file

@ -0,0 +1,614 @@
package postgres
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
// collectibleTestUser inserts a bare user row and returns its user peer. The
// registry rows for collectibles reference no peer table, but the editable-slot
// assertions and the peer-deletion trigger both need a real user.
func collectibleTestUser(t *testing.T, pool *pgxpool.Pool, seed int64, username string) domain.Peer {
t.Helper()
ctx := context.Background()
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO users (access_hash, phone, first_name, username)
VALUES ($1, $2, 'collectible test', $3)
RETURNING id`, seed, fmt.Sprintf("%d", seed), username).Scan(&id); err != nil {
t.Fatalf("insert collectible test user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
})
return domain.Peer{Type: domain.PeerTypeUser, ID: id}
}
// setEditableUsername installs an editable registry row through the same helper
// the client-driven username path uses.
func setEditableUsername(t *testing.T, pool *pgxpool.Pool, peer domain.Peer, username string) {
t.Helper()
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin editable username: %v", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, peer.ID, username, lowerASCII(username)); err != nil {
t.Fatalf("set editable username %q: %v", username, err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit editable username: %v", err)
}
}
func lowerASCII(s string) string {
out := []byte(s)
for i := range out {
if out[i] >= 'A' && out[i] <= 'Z' {
out[i] += 'a' - 'A'
}
}
return string(out)
}
func cleanupCollectible(t *testing.T, pool *pgxpool.Pool, usernameLower string) {
t.Helper()
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(),
`DELETE FROM collectible_usernames WHERE username_lower = $1`, usernameLower)
})
}
func mintRequest(username string, owner domain.Peer, commandKey string) domain.MintCollectibleUsernameRequest {
return domain.MintCollectibleUsernameRequest{
Username: username,
Owner: owner,
PurchaseDate: time.Now().UTC().Truncate(time.Second),
Currency: domain.CollectibleCurrencyStars,
Amount: 5000,
URL: "https://fragment.example/" + username,
Actor: "ops",
Reason: "integration test",
CommandKey: commandKey,
}
}
func registryRows(t *testing.T, pool *pgxpool.Pool, peer domain.Peer) []domain.Username {
t.Helper()
list, err := listPeerUsernames(context.Background(), pool, peer)
if err != nil {
t.Fatalf("list peer usernames: %v", err)
}
return list
}
// TestCollectibleUsernameMintIntoVault covers a vault mint: the asset exists, the
// name is not projected into any peer's registry, and the provenance log records
// the mint.
func TestCollectibleUsernameMintIntoVault(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
name := fmt.Sprintf("vault%d", time.Now().UnixNano()%1_000_000)
cleanupCollectible(t, pool, lowerASCII(name))
asset, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, domain.Peer{}, ""))
if err != nil || !created {
t.Fatalf("mint into vault created=%v err=%v", created, err)
}
if asset.Status != domain.CollectibleUsernameStatusVault || asset.Owned() ||
asset.Version != 1 || asset.TransferCount != 0 || asset.Username != name {
t.Fatalf("vault asset = %+v", asset)
}
if err := asset.Validate(); err != nil {
t.Fatalf("vault asset invariants: %v", err)
}
var registry int
if err := pool.QueryRow(ctx,
`SELECT count(*) FROM peer_usernames WHERE collectible_id = $1`, asset.ID).Scan(&registry); err != nil {
t.Fatal(err)
}
if registry != 0 {
t.Fatalf("vault asset must not be projected, got %d registry rows", registry)
}
transfers, err := store.CollectibleUsernameTransfers(ctx, asset.ID, 10)
if err != nil || len(transfers) != 1 || transfers[0].Kind != domain.CollectibleUsernameKindMint {
t.Fatalf("transfers=%+v err=%v", transfers, err)
}
// A vault revoke has nothing to release and must stay a no-op.
same, changed, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{Username: name})
if err != nil || changed || same.Version != asset.Version {
t.Fatalf("vault revoke changed=%v asset=%+v err=%v", changed, same, err)
}
}
// TestCollectibleUsernameMintWithOwnerAndReplay covers a mint that assigns the
// asset immediately, and the command-key replay that must return the recorded
// state instead of minting twice.
func TestCollectibleUsernameMintWithOwnerAndReplay(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
owner := collectibleTestUser(t, pool, 2_100_000_000+seed, "")
name := fmt.Sprintf("owned%d", seed)
cleanupCollectible(t, pool, lowerASCII(name))
key := fmt.Sprintf("mint-%d", seed)
asset, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, owner, key))
if err != nil || !created {
t.Fatalf("mint with owner created=%v err=%v", created, err)
}
if asset.Status != domain.CollectibleUsernameStatusOwned || asset.Owner != owner ||
asset.OriginalOwner != owner || asset.TransferCount != 0 {
t.Fatalf("owned asset = %+v", asset)
}
if err := asset.Validate(); err != nil {
t.Fatalf("owned asset invariants: %v", err)
}
list := registryRows(t, pool, owner)
if len(list) != 1 || list[0].Username != name || list[0].Editable ||
!list[0].Active || list[0].CollectibleID != asset.ID {
t.Fatalf("registry = %+v", list)
}
replay, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, owner, key))
if err != nil || created || replay.ID != asset.ID || replay.Version != asset.Version {
t.Fatalf("replay created=%v asset=%+v err=%v", created, replay, err)
}
var assets int
if err := pool.QueryRow(ctx,
`SELECT count(*) FROM collectible_usernames WHERE username_lower = $1`, lowerASCII(name)).Scan(&assets); err != nil {
t.Fatal(err)
}
if assets != 1 {
t.Fatalf("replay must not mint again, got %d assets", assets)
}
// The same name cannot be minted twice, even under a different command key.
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, domain.Peer{}, key+"-again")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("duplicate mint err = %v, want ErrUsernameOccupied", err)
}
}
// TestCollectibleUsernameMintRejectsOccupiedEditableName proves the collectible
// registry and the editable slot share one occupancy namespace.
func TestCollectibleUsernameMintRejectsOccupiedEditableName(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
holder := collectibleTestUser(t, pool, 2_200_000_000+seed, "")
name := fmt.Sprintf("taken%d", seed)
setEditableUsername(t, pool, holder, name)
cleanupCollectible(t, pool, lowerASCII(name))
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, domain.Peer{}, "")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("mint over editable name err = %v, want ErrUsernameOccupied", err)
}
if _, err := store.CollectibleUsername(ctx, name); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("rejected mint must leave no asset, err = %v", err)
}
}
// TestCollectibleUsernameTransferPreservesRecipientEditableSlot is the core
// regression: moving an asset must not disturb either peer's editable username.
func TestCollectibleUsernameTransferPreservesRecipientEditableSlot(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
from := collectibleTestUser(t, pool, 2_300_000_000+seed, "")
to := collectibleTestUser(t, pool, 2_400_000_000+seed, "")
fromEditable := fmt.Sprintf("sender%d", seed)
toEditable := fmt.Sprintf("recip%d", seed)
setEditableUsername(t, pool, from, fromEditable)
setEditableUsername(t, pool, to, toEditable)
name := fmt.Sprintf("moved%d", seed)
cleanupCollectible(t, pool, lowerASCII(name))
asset, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, from, ""))
if err != nil {
t.Fatalf("mint: %v", err)
}
key := fmt.Sprintf("transfer-%d", seed)
moved, changed, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: name, To: to, Actor: "ops", Reason: "sold", CommandKey: key,
})
if err != nil || !changed {
t.Fatalf("transfer changed=%v err=%v", changed, err)
}
if moved.Owner != to || moved.OriginalOwner != from || moved.TransferCount != 1 ||
moved.Version != asset.Version+1 {
t.Fatalf("transferred asset = %+v", moved)
}
fromList := registryRows(t, pool, from)
if len(fromList) != 1 || fromList[0].Username != fromEditable || !fromList[0].Editable {
t.Fatalf("sender registry = %+v, editable slot must survive", fromList)
}
toList := registryRows(t, pool, to)
if len(toList) != 2 || toList[0].Username != toEditable || !toList[0].Editable ||
toList[1].Username != name || !toList[1].Collectible() {
t.Fatalf("recipient registry = %+v", toList)
}
replay, changed, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: name, To: to, CommandKey: key,
})
if err != nil || changed || replay.Version != moved.Version {
t.Fatalf("transfer replay changed=%v asset=%+v err=%v", changed, replay, err)
}
// Revoking back to the vault releases the recipient's registry row only.
vaulted, changed, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: name, Actor: "ops", Reason: "recalled", CommandKey: key + "-revoke",
})
if err != nil || !changed {
t.Fatalf("revoke changed=%v err=%v", changed, err)
}
if vaulted.Status != domain.CollectibleUsernameStatusVault || vaulted.Owned() ||
vaulted.OriginalOwner != from {
t.Fatalf("revoked asset = %+v", vaulted)
}
toList = registryRows(t, pool, to)
if len(toList) != 1 || toList[0].Username != toEditable {
t.Fatalf("recipient registry after revoke = %+v", toList)
}
}
// TestCollectibleUsernameBurnReleasesName covers a burn: the asset is retired and
// the name stops resolving, so an ordinary peer can claim it as its editable slot.
func TestCollectibleUsernameBurnReleasesName(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
holder := collectibleTestUser(t, pool, 2_500_000_000+seed, "")
claimer := collectibleTestUser(t, pool, 2_600_000_000+seed, "")
name := fmt.Sprintf("burned%d", seed)
cleanupCollectible(t, pool, lowerASCII(name))
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); err != nil {
t.Fatalf("mint: %v", err)
}
burned, changed, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: name, Burn: true, Actor: "ops", Reason: "abuse",
})
if err != nil || !changed {
t.Fatalf("burn changed=%v err=%v", changed, err)
}
if burned.Status != domain.CollectibleUsernameStatusBurned || burned.Owned() {
t.Fatalf("burned asset = %+v", burned)
}
if list := registryRows(t, pool, holder); len(list) != 0 {
t.Fatalf("holder registry after burn = %+v", list)
}
// The freed name is claimable as an ordinary editable username.
setEditableUsername(t, pool, claimer, name)
if list := registryRows(t, pool, claimer); len(list) != 1 || !list[0].Editable || list[0].Username != name {
t.Fatalf("claimer registry = %+v", list)
}
// Every further mutation of a burned asset is refused.
if _, _, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: name, To: holder,
}); !errors.Is(err, domain.ErrCollectibleUsernameBurned) {
t.Fatalf("transfer of burned asset err = %v, want ErrCollectibleUsernameBurned", err)
}
if _, _, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: name, Burn: true,
}); !errors.Is(err, domain.ErrCollectibleUsernameBurned) {
t.Fatalf("re-burn err = %v, want ErrCollectibleUsernameBurned", err)
}
if _, _, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: fmt.Sprintf("ghost%d", seed), To: holder,
}); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("transfer of unknown asset err = %v, want ErrCollectibleUsernameNotFound", err)
}
}
// TestCollectibleUsernamePeerLimit covers the per-peer bound on both entry paths:
// minting straight to the holder and transferring into it.
func TestCollectibleUsernamePeerLimit(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
holder := collectibleTestUser(t, pool, 2_700_000_000+seed, "")
prefix := fmt.Sprintf("lim%d", seed)
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(),
`DELETE FROM collectible_usernames WHERE username_lower LIKE $1 || '%'`, lowerASCII(prefix))
})
for i := 0; i < domain.MaxPeerCollectibleUsernames; i++ {
name := fmt.Sprintf("%sn%02d", prefix, i)
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); err != nil {
t.Fatalf("mint %s: %v", name, err)
}
}
if list := registryRows(t, pool, holder); len(list) != domain.MaxPeerCollectibleUsernames {
t.Fatalf("registry size = %d", len(list))
}
overflow := prefix + "over"
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(overflow, holder, "")); !errors.Is(err, domain.ErrCollectibleUsernameLimit) {
t.Fatalf("mint over limit err = %v, want ErrCollectibleUsernameLimit", err)
}
if _, err := store.CollectibleUsername(ctx, overflow); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("rejected mint must not leave an asset: %v", err)
}
vaulted, _, err := store.MintCollectibleUsername(ctx, mintRequest(prefix+"vault", domain.Peer{}, ""))
if err != nil {
t.Fatalf("mint vault asset: %v", err)
}
if _, _, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: vaulted.Username, To: holder,
}); !errors.Is(err, domain.ErrCollectibleUsernameLimit) {
t.Fatalf("transfer over limit err = %v, want ErrCollectibleUsernameLimit", err)
}
// The refused transfer must not have released the asset from the vault.
after, err := store.CollectibleUsername(ctx, vaulted.Username)
if err != nil || after.Status != domain.CollectibleUsernameStatusVault {
t.Fatalf("vault asset after refused transfer = %+v err=%v", after, err)
}
owned, err := store.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
Owner: holder, Status: domain.CollectibleUsernameStatusOwned, Limit: 100,
})
if err != nil || len(owned) != domain.MaxPeerCollectibleUsernames {
t.Fatalf("list owned = %d err=%v", len(owned), err)
}
prefixed, err := store.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
Query: prefix + "n0", Limit: 100,
})
if err != nil || len(prefixed) != 10 {
t.Fatalf("list by prefix = %d err=%v", len(prefixed), err)
}
}
// TestCollectibleUsernameRegistryToggleAndReorder covers the registry-only
// surface: activation, ordering and the bulk deactivation, none of which may
// touch the editable slot.
func TestCollectibleUsernameRegistryToggleAndReorder(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
holder := collectibleTestUser(t, pool, 2_800_000_000+seed, "")
editable := fmt.Sprintf("edit%d", seed)
setEditableUsername(t, pool, holder, editable)
first := fmt.Sprintf("alpha%d", seed)
second := fmt.Sprintf("beta%d", seed)
cleanupCollectible(t, pool, lowerASCII(first))
cleanupCollectible(t, pool, lowerASCII(second))
for _, name := range []string{first, second} {
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); err != nil {
t.Fatalf("mint %s: %v", name, err)
}
}
changed, err := store.SetUsernameActive(ctx, holder, first, false)
if err != nil || !changed {
t.Fatalf("deactivate collectible changed=%v err=%v", changed, err)
}
if _, err := store.SetUsernameActive(ctx, holder, editable, false); !errors.Is(err, domain.ErrUsernameNotCollectible) {
t.Fatalf("toggling the editable slot err = %v, want ErrUsernameNotCollectible", err)
}
// first is inactive now, so a client would send only the active names; the
// editable slot is one of them and listing it is required, not rejected.
changed, err = store.ReorderUsernames(ctx, holder, []string{editable, second})
if err != nil || !changed {
t.Fatalf("reorder changed=%v err=%v", changed, err)
}
list := registryRows(t, pool, holder)
if len(list) != 3 || list[0].Username != editable || list[1].Username != second || list[2].Username != first {
t.Fatalf("registry order = %+v", list)
}
// Re-sending the order the peer already has is a no-op a client can repeat.
if changed, err := store.ReorderUsernames(ctx, holder, []string{editable, second}); err != nil || changed {
t.Fatalf("repeat reorder changed=%v err=%v", changed, err)
}
// A collectible may be promoted above the editable slot, which is what makes
// it the peer's primary username for clients.
changed, err = store.ReorderUsernames(ctx, holder, []string{second, editable})
if err != nil || !changed {
t.Fatalf("promote collectible changed=%v err=%v", changed, err)
}
list = registryRows(t, pool, holder)
if len(list) != 3 || list[0].Username != second || list[1].Username != editable {
t.Fatalf("registry order after promoting a collectible = %+v", list)
}
if domain.ActiveUsername(list) != second {
t.Fatalf("active username after promoting a collectible = %q, want %q", domain.ActiveUsername(list), second)
}
changed, err = store.ReorderUsernames(ctx, holder, []string{editable, second})
if err != nil || !changed {
t.Fatalf("restore order changed=%v err=%v", changed, err)
}
// An order that omits an active username is still rejected, and so is one
// naming something the peer does not own.
if _, err := store.ReorderUsernames(ctx, holder, []string{second}); !errors.Is(err, domain.ErrUsernameOrderInvalid) {
t.Fatalf("partial reorder err = %v, want ErrUsernameOrderInvalid", err)
}
if _, err := store.ReorderUsernames(ctx, holder, []string{editable, second, "nobodyowns" + editable}); !errors.Is(err, domain.ErrUsernameOrderInvalid) {
t.Fatalf("reorder with a foreign name err = %v, want ErrUsernameOrderInvalid", err)
}
changed, err = store.DeactivateAllUsernames(ctx, holder)
if err != nil || !changed {
t.Fatalf("deactivate all changed=%v err=%v", changed, err)
}
list = registryRows(t, pool, holder)
if len(list) != 3 || !list[0].Active || list[1].Active || list[2].Active {
t.Fatalf("registry after deactivate all = %+v", list)
}
batch, err := store.PeerUsernamesBatch(ctx, []domain.Peer{holder, {Type: domain.PeerTypeUser, ID: holder.ID + 1}})
if err != nil || len(batch[holder]) != 3 {
t.Fatalf("batch = %+v err=%v", batch, err)
}
if domain.ActiveUsername(batch[holder]) != editable {
t.Fatalf("active username = %q", domain.ActiveUsername(batch[holder]))
}
}
// TestCollectibleUsernameEditableEditKeepsCollectibles pins the peer_username.go
// surgery: rewriting or clearing the editable slot must leave collectible rows
// and their assets untouched.
func TestCollectibleUsernameEditableEditKeepsCollectibles(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
holder := collectibleTestUser(t, pool, 2_900_000_000+seed, "")
name := fmt.Sprintf("keep%d", seed)
cleanupCollectible(t, pool, lowerASCII(name))
asset, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, ""))
if err != nil {
t.Fatalf("mint: %v", err)
}
setEditableUsername(t, pool, holder, fmt.Sprintf("one%d", seed))
setEditableUsername(t, pool, holder, fmt.Sprintf("two%d", seed))
setEditableUsername(t, pool, holder, "")
list := registryRows(t, pool, holder)
if len(list) != 1 || list[0].CollectibleID != asset.ID {
t.Fatalf("registry after editable churn = %+v", list)
}
stored, err := store.CollectibleUsernameByID(ctx, asset.ID)
if err != nil || stored.Owner != holder {
t.Fatalf("asset after editable churn = %+v err=%v", stored, err)
}
// The editable slot may not duplicate a name the peer holds as a collectible.
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, holder.ID, name, lowerASCII(name)); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("editable slot over own collectible err = %v, want ErrUsernameOccupied", err)
}
}
// TestCollectibleUsernameReissueAfterBurn covers migration 0152: uniqueness now
// spans live assets only, so a burned name can be issued again while its burned
// rows remain as provenance.
func TestCollectibleUsernameReissueAfterBurn(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
holder := collectibleTestUser(t, pool, 3_500_000_000+seed, "")
name := fmt.Sprintf("reissue%d", seed)
cleanupCollectible(t, pool, lowerASCII(name))
first, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, ""))
if err != nil {
t.Fatalf("first mint: %v", err)
}
if _, _, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: name, Burn: true, Actor: "ops", Reason: "retire",
}); err != nil {
t.Fatalf("burn: %v", err)
}
second, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, ""))
if err != nil || !created {
t.Fatalf("reissue: created=%v err=%v", created, err)
}
if second.ID == first.ID {
t.Fatalf("reissue reused asset id %d", second.ID)
}
// Both rows coexist: the live one is what the name resolves to.
live, err := store.CollectibleUsername(ctx, name)
if err != nil || live.ID != second.ID {
t.Fatalf("lookup after reissue = %+v err=%v, want live asset %d", live, err, second.ID)
}
burned, err := store.CollectibleUsernameByID(ctx, first.ID)
if err != nil || burned.Status != domain.CollectibleUsernameStatusBurned {
t.Fatalf("burned provenance row = %+v err=%v", burned, err)
}
var rowCount int
if err := pool.QueryRow(ctx,
`SELECT count(*) FROM collectible_usernames WHERE username_lower = $1`,
lowerASCII(name)).Scan(&rowCount); err != nil {
t.Fatalf("count rows: %v", err)
}
if rowCount != 2 {
t.Fatalf("rows for reissued name = %d, want 2", rowCount)
}
// The live asset still blocks another mint.
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("mint over live asset err = %v, want ErrUsernameOccupied", err)
}
if list := registryRows(t, pool, holder); len(list) != 1 || list[0].CollectibleID != second.ID {
t.Fatalf("holder registry after reissue = %+v", list)
}
}
// TestCollectibleUsernameDelete covers the hard delete: asset, registry row and
// provenance all disappear and the name becomes fully free.
func TestCollectibleUsernameDelete(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewCollectibleUsernameStore(pool)
seed := time.Now().UnixNano() % 1_000_000
holder := collectibleTestUser(t, pool, 3_600_000_000+seed, "")
name := fmt.Sprintf("mistake%d", seed)
cleanupCollectible(t, pool, lowerASCII(name))
editable := fmt.Sprintf("keep%d", seed)
setEditableUsername(t, pool, holder, editable)
asset, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, ""))
if err != nil {
t.Fatalf("mint: %v", err)
}
deleted, err := store.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: "@" + name, Actor: "ops", Reason: "issued by mistake",
})
if err != nil || !deleted {
t.Fatalf("delete: deleted=%v err=%v", deleted, err)
}
if _, err := store.CollectibleUsernameByID(ctx, asset.ID); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("asset after delete err = %v, want not found", err)
}
// ON DELETE CASCADE took the provenance rows with the asset.
var transfers int
if err := pool.QueryRow(ctx,
`SELECT count(*) FROM collectible_username_transfers WHERE collectible_id = $1`,
asset.ID).Scan(&transfers); err != nil {
t.Fatalf("count transfers: %v", err)
}
if transfers != 0 {
t.Fatalf("provenance rows after delete = %d, want 0", transfers)
}
// The holder keeps its editable slot and loses only the collectible row.
list := registryRows(t, pool, holder)
if len(list) != 1 || !list[0].Editable || list[0].Username != editable {
t.Fatalf("holder registry after delete = %+v", list)
}
// The name is free again, with no burned history left behind.
if _, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); err != nil || !created {
t.Fatalf("mint after delete: created=%v err=%v", created, err)
}
// Deleting a name that has no live asset is a no-op, not an error.
if _, _, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: name, Burn: true, Actor: "ops", Reason: "retire",
}); err != nil {
t.Fatalf("burn before repeat delete: %v", err)
}
deleted, err = store.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: name, Actor: "ops", Reason: "again",
})
if err != nil || deleted {
t.Fatalf("delete of burned-only name = %v err=%v, want (false, nil)", deleted, err)
}
deleted, err = store.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: fmt.Sprintf("absent%d", seed), Actor: "ops", Reason: "again",
})
if err != nil || deleted {
t.Fatalf("delete of unknown name = %v err=%v, want (false, nil)", deleted, err)
}
}

View file

@ -99,13 +99,15 @@ WITH desired (
EXCLUDED.about, EXCLUDED.is_bot, EXCLUDED.bot_info_version
)
)
INSERT INTO peer_usernames (username_lower, peer_type, peer_id)
SELECT lower(username), 'user', id
INSERT INTO peer_usernames (username_lower, username, peer_type, peer_id, active, editable, sort_order)
SELECT lower(username), username, 'user', id, true, true, 0
FROM desired
ON CONFLICT (peer_type, peer_id) DO UPDATE SET
ON CONFLICT (peer_type, peer_id) WHERE editable DO UPDATE SET
username_lower = EXCLUDED.username_lower,
username = EXCLUDED.username,
updated_at = now()
WHERE peer_usernames.username_lower IS DISTINCT FROM EXCLUDED.username_lower
WHERE (peer_usernames.username_lower, peer_usernames.username)
IS DISTINCT FROM (EXCLUDED.username_lower, EXCLUDED.username)
`, u.ID, u.AccessHash, u.Phone, u.FirstName, u.LastName, u.Username, u.CountryCode, u.Verified, u.Support, u.About, u.Bot, u.BotInfoVersion); err != nil {
return fmt.Errorf("ensure official system user: %w", err)
}

View file

@ -18,25 +18,40 @@ const (
peerUsernameTypeChannel = "channel"
)
// peerUsernameColumns is the registry projection shared by every reader. The
// collectible id is coalesced so domain.Username keeps a plain int64 zero for
// the editable slot.
const peerUsernameColumns = `username, active, editable, sort_order, COALESCE(collectible_id, 0)`
type peerUsernameOwner struct {
peerType string
peerID int64
// collectible marks a row backed by a collectible asset. Such a row is never
// editable, so the client-driven username path must not reuse or delete it.
collectible bool
// active mirrors the registry flag. An inactive name stays occupied for
// uniqueness purposes but must not resolve to its holder.
active bool
}
func (o peerUsernameOwner) matches(peerType string, peerID int64) bool {
return o.peerType == peerType && o.peerID == peerID
}
// getPeerUsernameOwner resolves the holder of a name across the whole registry:
// username uniqueness is global and covers collectible rows as well as editable
// ones, so occupancy checks and username resolution keep a single source of
// truth.
func getPeerUsernameOwner(ctx context.Context, db sqlcgen.DBTX, usernameLower string, forUpdate bool) (peerUsernameOwner, bool, error) {
if usernameLower == "" {
return peerUsernameOwner{}, false, nil
}
query := `SELECT peer_type, peer_id FROM peer_usernames WHERE username_lower = $1`
query := `SELECT peer_type, peer_id, collectible_id IS NOT NULL, active FROM peer_usernames WHERE username_lower = $1`
if forUpdate {
query += ` FOR UPDATE`
}
var owner peerUsernameOwner
err := db.QueryRow(ctx, query, usernameLower).Scan(&owner.peerType, &owner.peerID)
err := db.QueryRow(ctx, query, usernameLower).Scan(&owner.peerType, &owner.peerID, &owner.collectible, &owner.active)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return peerUsernameOwner{}, false, nil
@ -54,25 +69,35 @@ func peerUsernameAvailable(ctx context.Context, db sqlcgen.DBTX, usernameLower,
return owner.matches(peerType, peerID), nil
}
func replacePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, usernameLower string) error {
// replacePeerUsernameTx rewrites the peer's editable username slot. username is
// the display form (original case) and usernameLower its registry key; an empty
// pair clears the slot.
//
// Only the editable row is replaced. Collectible rows belong to assets in
// collectible_usernames and must survive every client-driven username edit,
// otherwise account.updateUsername would silently release a minted asset.
func replacePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, username, usernameLower string) error {
if usernameLower != "" {
owner, found, err := getPeerUsernameOwner(ctx, tx, usernameLower, true)
if err != nil {
return err
}
if found && !owner.matches(peerType, peerID) {
// A collectible row occupies the name even for its own holder: the
// editable slot cannot duplicate a name the peer already holds as an asset.
if found && (!owner.matches(peerType, peerID) || owner.collectible) {
return domain.ErrUsernameOccupied
}
}
if _, err := tx.Exec(ctx, `DELETE FROM peer_usernames WHERE peer_type = $1 AND peer_id = $2`, peerType, peerID); err != nil {
if _, err := tx.Exec(ctx, `
DELETE FROM peer_usernames WHERE peer_type = $1 AND peer_id = $2 AND editable`, peerType, peerID); err != nil {
return fmt.Errorf("delete peer username: %w", err)
}
if usernameLower == "" {
return nil
}
if _, err := tx.Exec(ctx, `
INSERT INTO peer_usernames (username_lower, peer_type, peer_id)
VALUES ($1, $2, $3)`, usernameLower, peerType, peerID); err != nil {
INSERT INTO peer_usernames (username_lower, peer_type, peer_id, username, active, editable, sort_order, collectible_id)
VALUES ($1, $2, $3, $4, true, true, 0, NULL)`, usernameLower, peerType, peerID, username); err != nil {
if isUniqueViolation(err) {
return domain.ErrUsernameOccupied
}
@ -81,13 +106,164 @@ VALUES ($1, $2, $3)`, usernameLower, peerType, peerID); err != nil {
return nil
}
// deletePeerUsernameTx clears the peer's editable slot only. Collectible rows are
// released through the asset lifecycle (revoke/burn) or by the peer-deletion
// trigger, never by an editable-slot edit.
func deletePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64) error {
if _, err := tx.Exec(ctx, `DELETE FROM peer_usernames WHERE peer_type = $1 AND peer_id = $2`, peerType, peerID); err != nil {
if _, err := tx.Exec(ctx, `
DELETE FROM peer_usernames WHERE peer_type = $1 AND peer_id = $2 AND editable`, peerType, peerID); err != nil {
return fmt.Errorf("delete peer username: %w", err)
}
return nil
}
// listPeerUsernames returns the peer's registry rows in projection order:
// editable slot first, then collectibles by stored sort order.
func listPeerUsernames(ctx context.Context, db sqlcgen.DBTX, peer domain.Peer) ([]domain.Username, error) {
if peer.Type == "" || peer.ID <= 0 {
return nil, nil
}
rows, err := db.Query(ctx, `
SELECT `+peerUsernameColumns+`
FROM peer_usernames
WHERE peer_type = $1 AND peer_id = $2
ORDER BY editable DESC, sort_order, username_lower`, string(peer.Type), peer.ID)
if err != nil {
return nil, fmt.Errorf("list peer usernames: %w", err)
}
defer rows.Close()
out := make([]domain.Username, 0, 4)
for rows.Next() {
var item domain.Username
if err := rows.Scan(&item.Username, &item.Active, &item.Editable, &item.SortOrder, &item.CollectibleID); err != nil {
return nil, fmt.Errorf("scan peer username: %w", err)
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate peer usernames: %w", err)
}
return domain.SortUsernames(out), nil
}
// listPeerUsernamesBatch resolves several peers in one round trip.
func listPeerUsernamesBatch(ctx context.Context, db sqlcgen.DBTX, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
out := make(map[domain.Peer][]domain.Username, len(peers))
types := make([]string, 0, len(peers))
ids := make([]int64, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, peer := range peers {
if peer.Type == "" || peer.ID <= 0 {
continue
}
if _, dup := seen[peer]; dup {
continue
}
seen[peer] = struct{}{}
types = append(types, string(peer.Type))
ids = append(ids, peer.ID)
}
if len(types) == 0 {
return out, nil
}
rows, err := db.Query(ctx, `
SELECT peer_type, peer_id, `+peerUsernameColumns+`
FROM peer_usernames
WHERE (peer_type, peer_id) IN (SELECT t, i FROM unnest($1::text[], $2::bigint[]) AS s(t, i))
ORDER BY peer_type, peer_id, editable DESC, sort_order, username_lower`, types, ids)
if err != nil {
return nil, fmt.Errorf("list peer usernames batch: %w", err)
}
defer rows.Close()
for rows.Next() {
var peer domain.Peer
var peerType string
var item domain.Username
if err := rows.Scan(&peerType, &peer.ID, &item.Username, &item.Active, &item.Editable,
&item.SortOrder, &item.CollectibleID); err != nil {
return nil, fmt.Errorf("scan peer username batch: %w", err)
}
peer.Type = domain.PeerType(peerType)
out[peer] = append(out[peer], item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate peer usernames batch: %w", err)
}
for peer, list := range out {
out[peer] = domain.SortUsernames(list)
}
return out, nil
}
// lockPeerUsernamesTx reads the peer's registry rows with row locks so a toggle
// or reorder validated against the list cannot race a concurrent asset move.
func lockPeerUsernamesTx(ctx context.Context, tx pgx.Tx, peer domain.Peer) ([]domain.Username, error) {
rows, err := tx.Query(ctx, `
SELECT `+peerUsernameColumns+`
FROM peer_usernames
WHERE peer_type = $1 AND peer_id = $2
ORDER BY username_lower
FOR UPDATE`, string(peer.Type), peer.ID)
if err != nil {
return nil, fmt.Errorf("lock peer usernames: %w", err)
}
defer rows.Close()
out := make([]domain.Username, 0, 4)
for rows.Next() {
var item domain.Username
if err := rows.Scan(&item.Username, &item.Active, &item.Editable, &item.SortOrder, &item.CollectibleID); err != nil {
return nil, fmt.Errorf("scan locked peer username: %w", err)
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate locked peer usernames: %w", err)
}
return domain.SortUsernames(out), nil
}
// countPeerCollectibleUsernamesTx bounds the collectible rows a peer may hold.
func countPeerCollectibleUsernamesTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64) (int, error) {
var count int
if err := tx.QueryRow(ctx, `
SELECT count(*) FROM peer_usernames
WHERE peer_type = $1 AND peer_id = $2 AND collectible_id IS NOT NULL`, peerType, peerID).Scan(&count); err != nil {
return 0, fmt.Errorf("count peer collectible usernames: %w", err)
}
return count, nil
}
// insertCollectiblePeerUsernameTx projects an owned asset into the registry. The
// row sorts after every collectible the peer already holds and always carries
// collectible_id, which the schema forbids on an editable row.
func insertCollectiblePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, username, usernameLower string, collectibleID int64) error {
if _, err := tx.Exec(ctx, `
INSERT INTO peer_usernames (username_lower, peer_type, peer_id, username, active, editable, sort_order, collectible_id, updated_at)
SELECT $1, $2, $3, $4, true, false, LEAST(
COALESCE((
SELECT max(sort_order) + 1 FROM peer_usernames
WHERE peer_type = $2 AND peer_id = $3 AND collectible_id IS NOT NULL
), 0), $6::int
), $5, now()`,
usernameLower, peerType, peerID, username, collectibleID, domain.MaxUsernameSortOrder); err != nil {
if isUniqueViolation(err) {
return domain.ErrUsernameOccupied
}
return fmt.Errorf("insert collectible peer username: %w", err)
}
return nil
}
// deleteCollectiblePeerUsernameTx removes the registry projection of an asset,
// releasing the name for anyone else the moment the asset stops being owned.
func deleteCollectiblePeerUsernameTx(ctx context.Context, tx pgx.Tx, collectibleID int64) error {
if _, err := tx.Exec(ctx, `
DELETE FROM peer_usernames WHERE collectible_id = $1`, collectibleID); err != nil {
return fmt.Errorf("delete collectible peer username: %w", err)
}
return nil
}
func isUniqueConstraint(err error, constraintName string) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation && pgErr.ConstraintName == constraintName

View file

@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err)
}
if status.Dirty || status.Empty || status.Version != 150 {
t.Fatalf("migration status = %+v, want clean version 150", status)
if status.Dirty || status.Empty || status.Version != 158 {
t.Fatalf("migration status = %+v, want clean version 158", status)
}
}

View file

@ -0,0 +1,266 @@
package postgres
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
// TestStarGiftResaleClearsSellerProfileStatePostgres pins the full seller-side
// teardown of a resale: a collectible that was worn as an emoji status and
// pinned to the profile must, the moment somebody buys it, stop being worn, stop
// being pinned, leave the seller's saved-gift list, and produce the three
// durable seller-visible updates the clients need to converge on that
// (user_emoji_status for the cleared status, new_message for the sale card,
// edit_message for the retired older card).
//
// This is the "my sold gift is still on my profile" report: everything below is
// server state and server push, so a client still showing the gift after this
// test passes is showing its own cache, not our state.
func TestStarGiftResaleClearsSellerProfileStatePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
now := int(time.Now().Unix())
users := NewUserStore(pool)
seller := createTestUser(t, ctx, users, "+1882"+suffix+"01", "ResaleSeller", "")
buyer := createTestUser(t, ctx, users, "+1882"+suffix+"02", "ResaleBuyer2", "")
sellerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: seller.ID}
stars := NewStarsStore(pool)
for _, u := range []domain.User{seller, buyer} {
if _, _, err := stars.EnsureGrant(ctx, u.ID, 10000, now); err != nil {
t.Fatalf("grant: %v", err)
}
}
gifts := NewStarGiftStore(pool)
base := time.Now().UnixNano() & 0x7ffffffffffff000
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "ResaleSync " + suffix, Stars: 50, ConvertStars: 20, Enabled: true,
Document: collectibleTestDocument(base, "resale-sync.tgs"),
Blob: collectibleTestBlob(base, "resale-sync"), Animation: collectibleTestAnimation("resale-sync.tgs"),
Actor: "integration", CommandID: "resale-sync-catalog-" + suffix,
})
if err != nil {
t.Fatalf("catalog: %v", err)
}
if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 20, SlugPrefix: "rsy-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleModel, Name: "Base", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(base+1, "model.tgs"), Blob: collectibleTestBlobPtr(base+1, "model"), Animation: collectibleTestAnimationPtr("model.tgs")},
{Kind: domain.StarGiftCollectibleModel, Name: "Base Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(base+4, "model-two.tgs"), Blob: collectibleTestBlobPtr(base+4, "model-two"), Animation: collectibleTestAnimationPtr("model-two.tgs")},
},
Patterns: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestPatternDocumentPtr(base+3, "pattern.tgs"), Blob: collectibleTestBlobPtr(base+3, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")},
{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestPatternDocumentPtr(base+5, "pattern-two.tgs"), Blob: collectibleTestBlobPtr(base+5, "pattern-two"), Animation: collectibleTestAnimationPtr("pattern-two.tgs")},
},
Backdrops: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Night", BackdropID: 77, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000},
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Day", BackdropID: 78, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000},
},
Actor: "integration", CommandID: "resale-sync-pool-" + suffix,
}); err != nil {
t.Fatalf("pool: %v", err)
}
messages := NewMessageStore(pool)
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{
StarsProceedsPermille: 900, TONProceedsPermille: 900,
}))
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 500,
}))
purchase := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{
BuyerUserID: seller.ID, To: sellerPeer, GiftID: entry.Gift.ID, IncludeUpgrade: true,
CommandKey: "resale-sync-purchase-" + suffix, Date: now,
})
bought, err := lifecycle.PurchaseStarGift(ctx, purchase)
if err != nil {
t.Fatalf("purchase: %v", err)
}
upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: seller.ID, Ref: domain.SavedStarGiftRef{Owner: sellerPeer, MsgID: bought.Saved.MsgID},
RequirePrepaid: true, KeepOriginalDetails: true, CommandKey: "resale-sync-upgrade-" + suffix, Date: now + 1,
})
if err != nil {
t.Fatalf("upgrade: %v", err)
}
// Wear it.
selected, valid := domain.CollectibleEmojiStatus(upgraded.Unique)
if !valid {
t.Fatalf("cannot wear: %+v", upgraded.Unique)
}
if _, err := users.UpdateEmojiStatus(ctx, seller.ID, domain.UserEmojiStatus{
DocumentID: selected.DocumentID, Collectible: selected,
}); err != nil {
t.Fatalf("wear: %v", err)
}
// Pin it to the profile.
if err := gifts.SetPinned(ctx, sellerPeer, []int64{upgraded.Saved.ID}); err != nil {
t.Fatalf("pin: %v", err)
}
var pinnedOrder int
if err := pool.QueryRow(ctx, `SELECT pinned_order FROM peer_star_gifts WHERE id=$1`, upgraded.Saved.ID).Scan(&pinnedOrder); err != nil {
t.Fatalf("read pin: %v", err)
}
if pinnedOrder == 0 {
t.Fatalf("gift was not pinned before the sale")
}
listed, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{
ActorUserID: seller.ID, Ref: domain.SavedStarGiftRef{Owner: sellerPeer, MsgID: upgraded.Saved.MsgID},
Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 500}, Date: now + 2,
})
if err != nil {
t.Fatalf("list: %v", err)
}
// The seller's profile-visible state right before the sale.
beforePage, err := gifts.ListByOwner(ctx, sellerPeer, true, "", 20)
if err != nil {
t.Fatalf("list before: %v", err)
}
if len(beforePage.Gifts) != 1 {
t.Fatalf("seller saved gifts before the sale = %d, want 1", len(beforePage.Gifts))
}
sellerPtsBefore, err := NewUpdateEventStore(pool).MaxContiguousPts(ctx, seller.ID)
if err != nil {
t.Fatalf("pts before: %v", err)
}
resold, err := lifecycle.PurchaseResaleStarGift(ctx, domain.StarGiftResalePurchaseRequest{
BuyerUserID: buyer.ID, Slug: listed.Slug, To: domain.Peer{Type: domain.PeerTypeUser, ID: buyer.ID},
Amount: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 500}, FormID: 31001,
CommandKey: "resale-sync-resale-" + suffix, Date: now + 3,
})
if err != nil {
t.Fatalf("resale: %v", err)
}
buyerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: buyer.ID}
if resold.Unique.Owner != buyerPeer || resold.Saved.Owner != buyerPeer {
t.Fatalf("resale ownership = unique %+v saved %+v, want %+v", resold.Unique.Owner, resold.Saved.Owner, buyerPeer)
}
// The gift is no longer pinned to the seller's profile.
if err := pool.QueryRow(ctx, `SELECT pinned_order FROM peer_star_gifts WHERE id=$1`, upgraded.Saved.ID).Scan(&pinnedOrder); err != nil {
t.Fatalf("read pin after: %v", err)
}
if pinnedOrder != 0 {
t.Fatalf("sold gift is still pinned to the seller's profile: pinned_order = %d", pinnedOrder)
}
// The seller no longer wears it: the lifecycle trigger cleared the status.
var docID int64
var collectibleID *int64
if err := pool.QueryRow(ctx, `SELECT emoji_status_document_id,emoji_status_collectible_id FROM users WHERE id=$1`,
seller.ID).Scan(&docID, &collectibleID); err != nil {
t.Fatalf("read status: %v", err)
}
if docID != 0 || collectibleID != nil {
t.Fatalf("seller still wears the sold collectible: document_id=%d collectible_id=%v", docID, collectibleID)
}
// And it has left the seller's saved-gift list entirely.
afterPage, err := gifts.ListByOwner(ctx, sellerPeer, true, "", 20)
if err != nil {
t.Fatalf("list after: %v", err)
}
if len(afterPage.Gifts) != 0 {
t.Fatalf("seller still owns %d saved gifts after the sale", len(afterPage.Gifts))
}
buyerPage, err := gifts.ListByOwner(ctx, buyerPeer, true, "", 20)
if err != nil {
t.Fatalf("buyer list after: %v", err)
}
if len(buyerPage.Gifts) != 1 || buyerPage.Gifts[0].ID != upgraded.Saved.ID || buyerPage.Gifts[0].PinnedOrder != 0 {
t.Fatalf("buyer saved gifts = %+v, want exactly the unpinned sold gift", buyerPage.Gifts)
}
// Every seller-visible consequence is also a durable update the clients can
// converge on, in contiguous pts order and with no gap.
sellerPtsAfter, err := NewUpdateEventStore(pool).MaxContiguousPts(ctx, seller.ID)
if err != nil {
t.Fatalf("pts after: %v", err)
}
rows, err := pool.Query(ctx, `SELECT pts,event_type FROM user_update_events
WHERE user_id=$1 AND pts>$2 ORDER BY pts`, seller.ID, sellerPtsBefore)
if err != nil {
t.Fatalf("events: %v", err)
}
kinds := make([]string, 0, 3)
pointers := make([]int, 0, 3)
for rows.Next() {
var pts int
var kind string
if err := rows.Scan(&pts, &kind); err != nil {
rows.Close()
t.Fatal(err)
}
pointers = append(pointers, pts)
kinds = append(kinds, kind)
}
rows.Close()
if err := rows.Err(); err != nil {
t.Fatal(err)
}
want := []string{
string(domain.UpdateEventUserEmojiStatus),
string(domain.UpdateEventNewMessage),
string(domain.UpdateEventEditMessage),
}
if len(kinds) != len(want) {
t.Fatalf("seller update events = %v (pts %v), want %v", kinds, pointers, want)
}
for i, kind := range want {
if kinds[i] != kind {
t.Fatalf("seller update events = %v, want %v", kinds, want)
}
if pointers[i] != sellerPtsBefore+1+i {
t.Fatalf("seller update pts = %v, want contiguous from %d", pointers, sellerPtsBefore+1)
}
}
if sellerPtsAfter != sellerPtsBefore+len(want) {
t.Fatalf("seller contiguous pts = %d, want %d", sellerPtsAfter, sellerPtsBefore+len(want))
}
// Each of them is also queued for online dispatch, and none of them is
// suppressed by the buyer's origin session.
var dispatched int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
WHERE target_user_id=$1 AND pts>$2 AND exclude_auth_key_id=0 AND exclude_session_id=0`,
seller.ID, sellerPtsBefore).Scan(&dispatched); err != nil {
t.Fatalf("dispatch rows: %v", err)
}
if dispatched != len(want) {
t.Fatalf("seller dispatch rows = %d, want %d", dispatched, len(want))
}
// The cleared status must serialise as an explicit empty status, not as a
// dropped update: a client that never sees it keeps rendering the sold gift.
events, err := NewUpdateEventStore(pool).ListAfter(ctx, seller.ID, sellerPtsBefore, 10)
if err != nil {
t.Fatalf("read events: %v", err)
}
found := false
for _, event := range events {
if event.Type != domain.UpdateEventUserEmojiStatus {
continue
}
found = true
if event.UserID != seller.ID || !event.EmojiStatus.Empty() || !event.EmojiStatus.Valid() {
t.Fatalf("cleared emoji status event = %+v, want a valid empty status for %d", event, seller.ID)
}
}
if !found {
t.Fatalf("no user_emoji_status event in %+v", events)
}
}

View file

@ -97,13 +97,30 @@ func (s *UserStore) ByUsername(ctx context.Context, username string) (domain.Use
row, err := s.q.GetUserByUsername(ctx, username)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, false, nil
// The scalar users.username column only holds the editable slot, so a
// collectible username resolves through the registry instead. This is a
// fallback rather than the primary path: the fast lookup above stays
// untouched for every pre-existing username.
return s.byCollectibleUsername(ctx, strings.ToLower(username))
}
return domain.User{}, false, fmt.Errorf("get user by username: %w", err)
}
return userFromModel(row), true, nil
}
// byCollectibleUsername resolves an active collectible username to its holder.
// An inactive (client-hidden) name stays occupied but must not resolve.
func (s *UserStore) byCollectibleUsername(ctx context.Context, usernameLower string) (domain.User, bool, error) {
owner, found, err := getPeerUsernameOwner(ctx, s.db, usernameLower, false)
if err != nil {
return domain.User{}, false, fmt.Errorf("get user by collectible username: %w", err)
}
if !found || !owner.collectible || !owner.active || owner.peerType != peerUsernameTypeUser {
return domain.User{}, false, nil
}
return s.ByID(ctx, owner.peerID)
}
func (s *UserStore) CheckUsername(ctx context.Context, userID int64, username string) (bool, error) {
usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
if usernameLower == "" {
@ -210,7 +227,7 @@ func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username s
}
return domain.User{}, fmt.Errorf("lock user for username update: %w", err)
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, usernameLower); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, username, usernameLower); err != nil {
return domain.User{}, err
}
row, err := qtx.UpdateUserUsername(ctx, sqlcgen.UpdateUserUsernameParams{
@ -280,7 +297,7 @@ func (s *UserStore) Create(ctx context.Context, u domain.User) (domain.User, err
}
usernameLower := strings.ToLower(row.Username)
if usernameLower != "" {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, usernameLower); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, row.Username, usernameLower); err != nil {
return domain.User{}, err
}
}

Some files were not shown because too many files have changed in this diff Show more