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