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

This commit is contained in:
onysd 2026-08-03 23:29:20 +03:00
commit ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -2,12 +2,13 @@ package adminapi
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@ -23,6 +24,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 {
@ -72,6 +87,54 @@ type Service interface {
EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error)
StarGiftCollectibles(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
StarGiftCollectibleAnimation(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
ModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error)
ModerationCase(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error)
ModerationReport(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error)
ClaimModerationCase(ctx context.Context, caseID, expectedVersion int64, actor string) (domain.ModerationCase, error)
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) {
@ -88,7 +151,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(),
@ -110,9 +173,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 {
@ -166,20 +230,57 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /v1/emoji/{id}/animation", s.authenticated(s.handleEmojiAnimation))
mux.HandleFunc("GET /v1/gifts/{id}/collectibles", s.authenticated(s.handleStarGiftCollectibles))
mux.HandleFunc("GET /v1/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.authenticated(s.handleStarGiftCollectibleAnimation))
mux.HandleFunc("GET /v1/moderation/cases", s.authenticated(s.handleModerationCases))
mux.HandleFunc("GET /v1/moderation/cases/{id}", s.authenticated(s.handleModerationCase))
mux.HandleFunc("GET /v1/moderation/reports/{id}", s.authenticated(s.handleModerationReport))
mux.HandleFunc("POST /v1/moderation/cases/{id}/claim", s.authenticated(s.handleClaimModerationCase))
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) handleAccountAvatar(w http.ResponseWriter, r *http.Request) {
userID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || userID <= 0 {
@ -872,6 +973,662 @@ func (s *Server) handleStarGiftCollectibleAnimation(w http.ResponseWriter, r *ht
_, _ = w.Write(raw)
}
type moderationClaimRequest struct {
ExpectedVersion int64 `json:"expected_version"`
Actor string `json:"actor"`
}
type moderationActionRequest struct {
Kind domain.ModerationActionKind `json:"kind"`
Payload json.RawMessage `json:"payload"`
}
type moderationDecisionRequest struct {
ExpectedVersion int64 `json:"expected_version"`
Actor string `json:"actor"`
Reason string `json:"reason"`
CommandID string `json:"command_id"`
Kind domain.ModerationDecisionKind `json:"kind"`
Actions []moderationActionRequest `json:"actions"`
}
type moderationAppealRequest struct {
AppellantUserID int64 `json:"appellant_user_id"`
Text string `json:"text"`
}
type moderationAppealReviewRequest struct {
ExpectedVersion int64 `json:"expected_version"`
Actor string `json:"actor"`
Reason string `json:"reason"`
CommandID string `json:"command_id"`
Granted bool `json:"granted"`
Actions []moderationActionRequest `json:"actions"`
}
func (s *Server) handleModerationCases(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
limit := 50
if raw := query.Get("limit"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid limit")
return
}
limit = parsed
}
filter := domain.ModerationCaseFilter{
AssignedTo: query.Get("assigned_to"),
Limit: limit,
}
if raw := query.Get("statuses"); raw != "" {
for _, status := range strings.Split(raw, ",") {
if status = strings.TrimSpace(status); status != "" {
filter.Statuses = append(filter.Statuses, domain.ModerationCaseStatus(status))
}
}
}
if raw := query.Get("target_id"); raw != "" {
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid target id")
return
}
filter.Target = domain.Peer{
Type: domain.PeerType(query.Get("target_type")), ID: id,
}
}
if raw := query.Get("before_updated_at"); raw != "" {
parsed, err := time.Parse(time.RFC3339Nano, raw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid before_updated_at")
return
}
filter.BeforeUpdate = parsed
filter.BeforeID, _ = strconv.ParseInt(query.Get("before_id"), 10, 64)
}
items, err := s.svc.ModerationCases(r.Context(), filter)
if err != nil {
writeModerationError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"cases": moderationCasesResponse(items),
})
}
func (s *Server) handleModerationCase(w http.ResponseWriter, r *http.Request) {
caseID, ok := moderationPathID(w, r, "id")
if !ok {
return
}
detail, found, err := s.svc.ModerationCase(r.Context(), caseID)
if err != nil {
writeModerationError(w, err)
return
}
if !found {
writeError(w, http.StatusNotFound, "moderation case not found")
return
}
writeJSON(w, http.StatusOK, moderationCaseDetailResponse(detail))
}
func (s *Server) handleModerationReport(w http.ResponseWriter, r *http.Request) {
reportID, ok := moderationPathID(w, r, "id")
if !ok {
return
}
report, found, err := s.svc.ModerationReport(r.Context(), reportID)
if err != nil {
writeModerationError(w, err)
return
}
if !found {
writeError(w, http.StatusNotFound, "moderation report not found")
return
}
writeJSON(w, http.StatusOK, moderationReportResponse(report))
}
func (s *Server) handleClaimModerationCase(w http.ResponseWriter, r *http.Request) {
caseID, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var request moderationClaimRequest
if !decodeJSON(w, r, &request) {
return
}
item, err := s.svc.ClaimModerationCase(
r.Context(), caseID, request.ExpectedVersion, request.Actor,
)
if err != nil {
writeModerationError(w, err)
return
}
writeJSON(w, http.StatusOK, item)
}
func (s *Server) handleDecideModerationCase(w http.ResponseWriter, r *http.Request) {
caseID, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var request moderationDecisionRequest
if !decodeJSON(w, r, &request) {
return
}
detail, created, err := s.svc.DecideModerationCase(
r.Context(), moderationDecisionDomain(caseID, 0, request),
)
if err != nil {
writeModerationError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"created": created, "case": moderationCaseDetailResponse(detail),
})
}
func (s *Server) handleSubmitModerationAppeal(w http.ResponseWriter, r *http.Request) {
caseID, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var request moderationAppealRequest
if !decodeJSON(w, r, &request) {
return
}
appeal, created, err := s.svc.SubmitModerationAppeal(
r.Context(), caseID, request.AppellantUserID, request.Text,
)
if err != nil {
writeModerationError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"created": created, "appeal": appeal,
})
}
func (s *Server) handleReviewModerationAppeal(w http.ResponseWriter, r *http.Request) {
caseID, ok := moderationPathID(w, r, "id")
if !ok {
return
}
appealID, ok := moderationPathID(w, r, "appeal_id")
if !ok {
return
}
var request moderationAppealReviewRequest
if !decodeJSON(w, r, &request) {
return
}
kind := domain.ModerationDecisionAppealDeny
if request.Granted {
kind = domain.ModerationDecisionAppealGrant
}
decision := moderationDecisionRequest{
ExpectedVersion: request.ExpectedVersion, Actor: request.Actor,
Reason: request.Reason, CommandID: request.CommandID,
Kind: kind, Actions: request.Actions,
}
detail, created, err := s.svc.ReviewModerationAppeal(
r.Context(), moderationDecisionDomain(caseID, appealID, decision),
)
if err != nil {
writeModerationError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"created": created, "case": moderationCaseDetailResponse(detail),
})
}
func moderationCasesResponse(items []domain.ModerationCase) []domain.ModerationCase {
if items == nil {
return []domain.ModerationCase{}
}
return items
}
func moderationCaseDetailResponse(detail domain.ModerationCaseDetail) domain.ModerationCaseDetail {
if detail.Decisions == nil {
detail.Decisions = []domain.ModerationDecision{}
}
if detail.Actions == nil {
detail.Actions = []domain.ModerationAction{}
}
if detail.Appeals == nil {
detail.Appeals = []domain.ModerationAppeal{}
}
return detail
}
func moderationReportResponse(report domain.ModerationReport) domain.ModerationReport {
if report.MediaHolds == nil {
report.MediaHolds = []domain.ModerationMediaHold{}
}
return report
}
func moderationDecisionDomain(caseID, appealID int64, request moderationDecisionRequest) domain.ModerationDecisionRequest {
actions := make([]domain.ModerationActionDraft, 0, len(request.Actions))
for _, action := range request.Actions {
payload := action.Payload
if len(payload) == 0 {
payload = json.RawMessage(`{}`)
}
actions = append(actions, domain.ModerationActionDraft{
Kind: action.Kind, Payload: payload,
})
}
return domain.ModerationDecisionRequest{
CaseID: caseID, AppealID: appealID,
ExpectedVersion: request.ExpectedVersion, Actor: request.Actor,
Reason: request.Reason, CommandID: request.CommandID,
Kind: request.Kind, Actions: actions,
}
}
func moderationPathID(w http.ResponseWriter, r *http.Request, name string) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue(name), 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid "+name)
return 0, false
}
return id, true
}
func writeModerationError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrModerationCaseNotFound),
errors.Is(err, domain.ErrModerationReportNotFound),
errors.Is(err, domain.ErrModerationEvidenceNotFound):
writeError(w, http.StatusNotFound, err.Error())
case errors.Is(err, domain.ErrModerationPermissionDenied):
writeError(w, http.StatusForbidden, err.Error())
case errors.Is(err, domain.ErrModerationCaseConflict),
errors.Is(err, domain.ErrModerationActionConflict):
writeError(w, http.StatusConflict, err.Error())
case errors.Is(err, domain.ErrModerationRateLimited):
writeError(w, http.StatusTooManyRequests, err.Error())
case errors.Is(err, domain.ErrModerationCaseInvalid),
errors.Is(err, domain.ErrModerationActionInvalid),
errors.Is(err, domain.ErrModerationReportInvalid):
writeError(w, http.StatusBadRequest, err.Error())
default:
writeError(w, http.StatusInternalServerError, err.Error())
}
}
func (s *Server) handleMintCollectibleUsername(w http.ResponseWriter, r *http.Request) {
var req admin.MintCollectibleUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.MintCollectibleUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleTransferCollectibleUsername(w http.ResponseWriter, r *http.Request) {
var req admin.TransferCollectibleUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.TransferCollectibleUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleRevokeCollectibleUsername(w http.ResponseWriter, r *http.Request) {
var req admin.RevokeCollectibleUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.RevokeCollectibleUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleDeleteCollectibleUsername(w http.ResponseWriter, r *http.Request) {
var req admin.DeleteCollectibleUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.DeleteCollectibleUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleRecomputeAccountRating(w http.ResponseWriter, r *http.Request) {
var req admin.RecomputeAccountRatingRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.RecomputeAccountRating(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleAdjustAccountRating(w http.ResponseWriter, r *http.Request) {
var req admin.AdjustAccountRatingRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.AdjustAccountRating(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleCollectibleUsernames(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
filter := domain.CollectibleUsernameFilter{
Status: domain.CollectibleUsernameStatus(strings.TrimSpace(query.Get("status"))),
Query: query.Get("q"),
}
if filter.Status != "" && !filter.Status.Valid() {
writeCodedError(w, http.StatusBadRequest, admin.CodeCollectibleStateInvalid, "invalid status")
return
}
owner, ok := collectibleOwnerFilter(w, query)
if !ok {
return
}
filter.Owner = owner
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
filter.Limit = limit
beforeID, ok := optionalQueryInt64(w, query, "before_id")
if !ok {
return
}
filter.BeforeID = beforeID
items, err := s.svc.CollectibleUsernames(r.Context(), filter)
if err != nil {
writeCollectibleUsernameError(w, err)
return
}
assets := make([]map[string]any, 0, len(items))
for _, item := range items {
assets = append(assets, collectibleUsernameResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{"assets": assets})
}
func (s *Server) handleCollectibleUsername(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
asset, err := s.svc.CollectibleUsernameByID(r.Context(), id)
if err != nil {
writeCollectibleUsernameError(w, err)
return
}
limit, ok := optionalQueryInt(w, r.URL.Query(), "limit")
if !ok {
return
}
transfers, err := s.svc.CollectibleUsernameTransfers(r.Context(), asset.ID, limit)
if err != nil {
writeCollectibleUsernameError(w, err)
return
}
log := make([]map[string]any, 0, len(transfers))
for _, item := range transfers {
log = append(log, collectibleUsernameTransferResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{
"asset": collectibleUsernameResponse(asset), "transfers": log,
})
}
func (s *Server) handleAccountRatings(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
minLevel, ok := optionalQueryInt(w, query, "min_level")
if !ok {
return
}
userID, ok := optionalQueryInt64(w, query, "user_id")
if !ok {
return
}
beforeID, ok := optionalQueryInt64(w, query, "before_id")
if !ok {
return
}
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
items, err := s.svc.AccountRatings(r.Context(), domain.AccountRatingFilter{
MinLevel: minLevel, UserID: userID, BeforeID: beforeID, Limit: limit,
})
if err != nil {
writeAccountRatingError(w, err)
return
}
ratings := make([]map[string]any, 0, len(items))
for _, item := range items {
ratings = append(ratings, accountRatingResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{"ratings": ratings})
}
func (s *Server) handleAccountRating(w http.ResponseWriter, r *http.Request) {
userID, ok := moderationPathID(w, r, "id")
if !ok {
return
}
rating, err := s.svc.AccountRating(r.Context(), userID)
if err != nil {
writeAccountRatingError(w, err)
return
}
limit, ok := optionalQueryInt(w, r.URL.Query(), "limit")
if !ok {
return
}
events, err := s.svc.AccountRatingEvents(r.Context(), userID, limit)
if err != nil {
writeAccountRatingError(w, err)
return
}
ledger := make([]map[string]any, 0, len(events))
for _, item := range events {
ledger = append(ledger, accountRatingEventResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{
"rating": accountRatingResponse(rating), "events": ledger,
})
}
// collectibleOwnerFilter reads the optional owner filter. At most one of the two
// identifiers may be present, mirroring the mint/transfer request shape.
func collectibleOwnerFilter(w http.ResponseWriter, query url.Values) (domain.Peer, bool) {
userID, ok := optionalQueryInt64(w, query, "owner_user_id")
if !ok {
return domain.Peer{}, false
}
channelID, ok := optionalQueryInt64(w, query, "owner_channel_id")
if !ok {
return domain.Peer{}, false
}
switch {
case userID > 0 && channelID > 0:
writeError(w, http.StatusBadRequest, "at most one owner filter is allowed")
return domain.Peer{}, false
case userID > 0:
return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, true
case channelID > 0:
return domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, true
default:
return domain.Peer{}, true
}
}
func optionalQueryInt64(w http.ResponseWriter, query url.Values, name string) (int64, bool) {
raw := strings.TrimSpace(query.Get(name))
if raw == "" {
return 0, true
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value < 0 {
writeError(w, http.StatusBadRequest, "invalid "+name)
return 0, false
}
return value, true
}
func optionalQueryInt(w http.ResponseWriter, query url.Values, name string) (int, bool) {
value, ok := optionalQueryInt64(w, query, name)
if !ok {
return 0, false
}
if value > math.MaxInt32 {
writeError(w, http.StatusBadRequest, "invalid "+name)
return 0, false
}
return int(value), true
}
// collectibleUsernameResponse renders one asset. Every int64 crosses the JSON
// boundary as a decimal string: asset ids and nanoton amounts exceed the exact
// range of a JSON number, and a rounded id would address the wrong asset.
func collectibleUsernameResponse(asset domain.CollectibleUsername) map[string]any {
out := map[string]any{
"id": strconv.FormatInt(asset.ID, 10),
"username": asset.Username,
"status": string(asset.Status),
"owner_type": string(asset.Owner.Type),
"owner_id": strconv.FormatInt(asset.Owner.ID, 10),
"purchase_date": asset.Info().PurchaseDate,
"currency": asset.Currency,
"amount": strconv.FormatInt(asset.Amount, 10),
"crypto_currency": asset.CryptoCurrency,
"crypto_amount": strconv.FormatInt(asset.CryptoAmount, 10),
"url": asset.URL,
"original_owner_type": string(asset.OriginalOwner.Type),
"original_owner_id": strconv.FormatInt(asset.OriginalOwner.ID, 10),
"transfer_count": asset.TransferCount,
"version": strconv.FormatInt(asset.Version, 10),
}
if !asset.CreatedAt.IsZero() {
out["created_at"] = asset.CreatedAt.UTC().Format(time.RFC3339)
}
if !asset.UpdatedAt.IsZero() {
out["updated_at"] = asset.UpdatedAt.UTC().Format(time.RFC3339)
}
return out
}
func collectibleUsernameTransferResponse(item domain.CollectibleUsernameTransfer) map[string]any {
out := map[string]any{
"id": strconv.FormatInt(item.ID, 10),
"collectible_id": strconv.FormatInt(item.CollectibleID, 10),
"kind": string(item.Kind),
"from_type": string(item.From.Type),
"from_id": strconv.FormatInt(item.From.ID, 10),
"to_type": string(item.To.Type),
"to_id": strconv.FormatInt(item.To.ID, 10),
"currency": item.Currency,
"amount": strconv.FormatInt(item.Amount, 10),
"actor": item.Actor,
"reason": item.Reason,
"command_key": item.CommandKey,
}
if !item.CreatedAt.IsZero() {
out["created_at"] = item.CreatedAt.UTC().Format(time.RFC3339)
}
return out
}
// accountRatingResponse renders one composite rating. The score and every
// component stay decimal strings for the same exactness reason as the asset ids.
func accountRatingResponse(rating domain.AccountRating) map[string]any {
out := map[string]any{
"user_id": strconv.FormatInt(rating.UserID, 10),
"level": rating.Level,
"stars": strconv.FormatInt(rating.Stars, 10),
"current_level_stars": strconv.FormatInt(rating.CurrentLevelStars, 10),
"has_next_level": rating.HasNextLevel,
"stars_component": strconv.FormatInt(rating.StarsComponent, 10),
"activity_component": strconv.FormatInt(rating.ActivityComponent, 10),
"penalty_component": strconv.FormatInt(rating.PenaltyComponent, 10),
"manual_component": strconv.FormatInt(rating.ManualComponent, 10),
"pending_stars": strconv.FormatInt(rating.PendingStars, 10),
"version": strconv.FormatInt(rating.Version, 10),
}
if rating.HasNextLevel {
out["next_level_stars"] = strconv.FormatInt(rating.NextLevelStars, 10)
}
if !rating.PendingDate.IsZero() {
out["pending_date"] = rating.PendingDate.UTC().Format(time.RFC3339)
}
if !rating.ComputedAt.IsZero() {
out["computed_at"] = rating.ComputedAt.UTC().Format(time.RFC3339)
}
if !rating.UpdatedAt.IsZero() {
out["updated_at"] = rating.UpdatedAt.UTC().Format(time.RFC3339)
}
return out
}
func accountRatingEventResponse(event domain.AccountRatingEvent) map[string]any {
out := map[string]any{
"id": strconv.FormatInt(event.ID, 10),
"user_id": strconv.FormatInt(event.UserID, 10),
"kind": string(event.Kind),
"amount": strconv.FormatInt(event.Amount, 10),
"reason": event.Reason,
"actor": event.Actor,
"command_key": event.CommandKey,
}
if !event.CreatedAt.IsZero() {
out["created_at"] = event.CreatedAt.UTC().Format(time.RFC3339)
}
return out
}
// writeCollectibleUsernameError maps a collectible-username failure onto its
// stable admin code and the matching HTTP status, the way writeModerationError
// does for moderation. An unmapped failure stays a 500 with its own text rather
// than being dressed up as a client error.
func writeCollectibleUsernameError(w http.ResponseWriter, err error) {
code := admin.CollectibleUsernameErrorCode(err)
status := http.StatusInternalServerError
switch code {
case admin.CodeCollectibleNotFound:
status = http.StatusNotFound
case admin.CodeUsernameOccupied, admin.CodeCollectibleBurned,
admin.CodeCollectiblePeerLimit, admin.CodeCollectibleNotOwned:
status = http.StatusConflict
case admin.CodeUsernameInvalid, admin.CodeUsernameNotCollectible,
admin.CodeCollectibleCurrencyInvalid, admin.CodeCollectibleStateInvalid:
status = http.StatusBadRequest
}
writeCodedError(w, status, code, err.Error())
}
func writeAccountRatingError(w http.ResponseWriter, err error) {
code := admin.AccountRatingErrorCode(err)
status := http.StatusInternalServerError
switch code {
case admin.CodeRatingNotFound:
status = http.StatusNotFound
case admin.CodeRatingAdjustmentInvalid, admin.CodeRatingWeightsInvalid:
status = http.StatusBadRequest
}
writeCodedError(w, status, code, err.Error())
}
func writeCodedError(w http.ResponseWriter, status int, code, msg string) {
body := map[string]string{"error": msg}
if code != "" {
body["code"] = code
}
writeJSON(w, status, body)
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
defer r.Body.Close()
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))

View file

@ -3,6 +3,7 @@ package adminapi
import (
"bytes"
"context"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
@ -43,6 +44,226 @@ func TestAdminAPISetAccountFrozen(t *testing.T) {
}
}
type captureModerationService struct {
fakeService
filter domain.ModerationCaseFilter
decision domain.ModerationDecisionRequest
appealReview domain.ModerationDecisionRequest
}
func (s *captureModerationService) ModerationCases(_ context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
s.filter = filter
return []domain.ModerationCase{{ID: 7}}, nil
}
func (s *captureModerationService) DecideModerationCase(_ context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
s.decision = request
return domain.ModerationCaseDetail{Case: domain.ModerationCase{ID: request.CaseID}}, true, nil
}
func (s *captureModerationService) ReviewModerationAppeal(_ context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
s.appealReview = request
return domain.ModerationCaseDetail{Case: domain.ModerationCase{ID: request.CaseID}}, true, nil
}
func TestAdminAPIModerationQueueDecisionAndAppealReview(t *testing.T) {
svc := &captureModerationService{}
srv := &Server{token: "secret", svc: svc}
listRequest := httptest.NewRequest(
http.MethodGet,
"/v1/moderation/cases?statuses=open,action_failed&assigned_to=alice&target_type=user&target_id=99&limit=25",
nil,
)
listRequest.Header.Set("Authorization", "Bearer secret")
list := httptest.NewRecorder()
srv.routes().ServeHTTP(list, listRequest)
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"ID":7`) {
t.Fatalf("list status=%d body=%s", list.Code, list.Body.String())
}
if len(svc.filter.Statuses) != 2 ||
svc.filter.Statuses[0] != domain.ModerationCaseOpen ||
svc.filter.Statuses[1] != domain.ModerationCaseActionFailed ||
svc.filter.AssignedTo != "alice" ||
svc.filter.Target != (domain.Peer{Type: domain.PeerTypeUser, ID: 99}) ||
svc.filter.Limit != 25 {
t.Fatalf("filter=%+v", svc.filter)
}
decisionRequest := httptest.NewRequest(
http.MethodPost, "/v1/moderation/cases/7/decide",
strings.NewReader(`{"expected_version":3,"actor":"alice","reason":"confirmed","command_id":"decision-7","kind":"violation","actions":[{"kind":"mark_scam","payload":{}}]}`),
)
decisionRequest.Header.Set("Authorization", "Bearer secret")
decision := httptest.NewRecorder()
srv.routes().ServeHTTP(decision, decisionRequest)
if decision.Code != http.StatusOK ||
!strings.Contains(decision.Body.String(), `"created":true`) ||
svc.decision.CaseID != 7 || svc.decision.ExpectedVersion != 3 ||
svc.decision.Kind != domain.ModerationDecisionViolation ||
len(svc.decision.Actions) != 1 ||
svc.decision.Actions[0].Kind != domain.ModerationActionMarkScam {
t.Fatalf("decision status=%d request=%+v body=%s",
decision.Code, svc.decision, decision.Body.String())
}
reviewRequest := httptest.NewRequest(
http.MethodPost, "/v1/moderation/cases/7/appeals/8/review",
strings.NewReader(`{"expected_version":5,"actor":"bob","reason":"appeal accepted","command_id":"appeal-8","granted":true,"actions":[{"kind":"clear_peer_flags","payload":{}}]}`),
)
reviewRequest.Header.Set("Authorization", "Bearer secret")
review := httptest.NewRecorder()
srv.routes().ServeHTTP(review, reviewRequest)
if review.Code != http.StatusOK ||
svc.appealReview.CaseID != 7 || svc.appealReview.AppealID != 8 ||
svc.appealReview.Kind != domain.ModerationDecisionAppealGrant ||
len(svc.appealReview.Actions) != 1 ||
svc.appealReview.Actions[0].Kind != domain.ModerationActionClearPeerFlags {
t.Fatalf("review status=%d request=%+v body=%s",
review.Code, svc.appealReview, review.Body.String())
}
}
type emptyModerationCollectionsService struct {
fakeService
}
func (emptyModerationCollectionsService) ModerationCases(
context.Context,
domain.ModerationCaseFilter,
) ([]domain.ModerationCase, error) {
return nil, nil
}
func (emptyModerationCollectionsService) ModerationCase(
_ context.Context,
caseID int64,
) (domain.ModerationCaseDetail, bool, error) {
return domain.ModerationCaseDetail{
Case: domain.ModerationCase{ID: caseID},
ReportIDs: []int64{9},
}, true, nil
}
func (emptyModerationCollectionsService) ModerationReport(
_ context.Context,
reportID int64,
) (domain.ModerationReport, bool, error) {
return domain.ModerationReport{
ID: reportID,
Items: []domain.ModerationReportItem{{ItemID: 10}},
}, true, nil
}
func (emptyModerationCollectionsService) DecideModerationCase(
_ context.Context,
request domain.ModerationDecisionRequest,
) (domain.ModerationCaseDetail, bool, error) {
return domain.ModerationCaseDetail{
Case: domain.ModerationCase{ID: request.CaseID},
ReportIDs: []int64{9},
}, true, nil
}
func (emptyModerationCollectionsService) ReviewModerationAppeal(
_ context.Context,
request domain.ModerationDecisionRequest,
) (domain.ModerationCaseDetail, bool, error) {
return domain.ModerationCaseDetail{
Case: domain.ModerationCase{ID: request.CaseID},
ReportIDs: []int64{9},
}, true, nil
}
func TestAdminAPIModerationCollectionsAreJSONArrays(t *testing.T) {
srv := &Server{token: "secret", svc: emptyModerationCollectionsService{}}
tests := []struct {
name string
method string
path string
body string
keys []string
nonEmptyKeys []string
nested string
}{
{
name: "empty queue", method: http.MethodGet,
path: "/v1/moderation/cases", keys: []string{"cases"},
},
{
name: "fresh case", method: http.MethodGet,
path: "/v1/moderation/cases/7",
keys: []string{"Decisions", "Actions", "Appeals"},
nonEmptyKeys: []string{"ReportIDs"},
},
{
name: "report without media holds", method: http.MethodGet,
path: "/v1/moderation/reports/9",
keys: []string{"MediaHolds"},
nonEmptyKeys: []string{"Items"},
},
{
name: "decision response", method: http.MethodPost,
path: "/v1/moderation/cases/7/decide", body: `{}`,
nested: "case",
keys: []string{"Decisions", "Actions", "Appeals"},
nonEmptyKeys: []string{"ReportIDs"},
},
{
name: "appeal review response", method: http.MethodPost,
path: "/v1/moderation/cases/7/appeals/8/review", body: `{}`,
nested: "case",
keys: []string{"Decisions", "Actions", "Appeals"},
nonEmptyKeys: []string{"ReportIDs"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var response map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if tt.nested != "" {
nestedValue := response[tt.nested]
var ok bool
response, ok = nestedValue.(map[string]any)
if !ok {
t.Fatalf("%s=%T, want object; body=%s",
tt.nested, nestedValue, rec.Body.String())
}
}
for _, key := range tt.keys {
value, ok := response[key]
if !ok {
t.Fatalf("%s missing; body=%s", key, rec.Body.String())
}
items, ok := value.([]any)
if !ok || len(items) != 0 {
t.Fatalf("%s=%#v, want empty JSON array; body=%s",
key, value, rec.Body.String())
}
}
for _, key := range tt.nonEmptyKeys {
value, ok := response[key]
if !ok {
t.Fatalf("%s missing; body=%s", key, rec.Body.String())
}
items, ok := value.([]any)
if !ok || len(items) == 0 {
t.Fatalf("%s=%#v, want non-empty JSON array; body=%s",
key, value, rec.Body.String())
}
}
})
}
}
func TestAdminAPISetVerified(t *testing.T) {
srv := &Server{token: "secret", svc: fakeService{}}
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/set-verified", strings.NewReader(`{"command_id":"c2","actor":"ops","reason":"official","dry_run":true,"user_id":1001,"verified":true}`))
@ -397,3 +618,397 @@ func (fakeService) StarGiftCollectibles(context.Context, int64) (domain.StarGift
func (fakeService) StarGiftCollectibleAnimation(context.Context, int64, domain.StarGiftCollectibleAttributeKind, int64) ([]byte, bool, error) {
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
}
func (fakeService) ModerationCases(context.Context, domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
return nil, nil
}
func (fakeService) ModerationCase(context.Context, int64) (domain.ModerationCaseDetail, bool, error) {
return domain.ModerationCaseDetail{}, false, nil
}
func (fakeService) ModerationReport(context.Context, int64) (domain.ModerationReport, bool, error) {
return domain.ModerationReport{}, false, nil
}
func (fakeService) ClaimModerationCase(context.Context, int64, int64, string) (domain.ModerationCase, error) {
return domain.ModerationCase{}, nil
}
func (fakeService) DecideModerationCase(context.Context, domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
return domain.ModerationCaseDetail{}, true, nil
}
func (fakeService) SubmitModerationAppeal(context.Context, int64, int64, string) (domain.ModerationAppeal, bool, error) {
return domain.ModerationAppeal{}, true, nil
}
func (fakeService) ReviewModerationAppeal(context.Context, domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
return domain.ModerationCaseDetail{}, true, nil
}
type captureCollectibleUsernameService struct {
fakeService
mint admin.MintCollectibleUsernameRequest
transfer admin.TransferCollectibleUsernameRequest
revoke admin.RevokeCollectibleUsernameRequest
del admin.DeleteCollectibleUsernameRequest
filter domain.CollectibleUsernameFilter
assetID int64
}
func (s *captureCollectibleUsernameService) MintCollectibleUsername(_ context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error) {
s.mint = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureCollectibleUsernameService) TransferCollectibleUsername(_ context.Context, req admin.TransferCollectibleUsernameRequest) (admin.CommandResult, error) {
s.transfer = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureCollectibleUsernameService) RevokeCollectibleUsername(_ context.Context, req admin.RevokeCollectibleUsernameRequest) (admin.CommandResult, error) {
s.revoke = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureCollectibleUsernameService) DeleteCollectibleUsername(_ context.Context, req admin.DeleteCollectibleUsernameRequest) (admin.CommandResult, error) {
s.del = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureCollectibleUsernameService) CollectibleUsernames(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
s.filter = filter
return []domain.CollectibleUsername{maxInt64Collectible()}, nil
}
func (s *captureCollectibleUsernameService) CollectibleUsernameByID(_ context.Context, id int64) (domain.CollectibleUsername, error) {
s.assetID = id
asset := maxInt64Collectible()
asset.ID = id
return asset, nil
}
func (s *captureCollectibleUsernameService) CollectibleUsernameTransfers(_ context.Context, collectibleID int64, _ int) ([]domain.CollectibleUsernameTransfer, error) {
return []domain.CollectibleUsernameTransfer{{
ID: 9223372036854775807,
CollectibleID: collectibleID,
Kind: domain.CollectibleUsernameKindMint,
To: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Currency: domain.CollectibleCurrencyTON,
Amount: 9223372036854775807,
Actor: "ops",
}}, nil
}
func maxInt64Collectible() domain.CollectibleUsername {
return domain.CollectibleUsername{
ID: 9223372036854775807,
Username: "durov",
Status: domain.CollectibleUsernameStatusOwned,
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Currency: domain.CollectibleCurrencyTON,
Amount: 9223372036854775807,
CryptoCurrency: domain.CollectibleCryptoCurrencyTON,
CryptoAmount: 9223372036854775807,
Version: 9223372036854775807,
}
}
type captureAccountRatingService struct {
fakeService
recompute admin.RecomputeAccountRatingRequest
adjust admin.AdjustAccountRatingRequest
filter domain.AccountRatingFilter
}
func (s *captureAccountRatingService) RecomputeAccountRating(_ context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error) {
s.recompute = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureAccountRatingService) AdjustAccountRating(_ context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error) {
s.adjust = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureAccountRatingService) AccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
s.filter = filter
return []domain.AccountRating{maxInt64Rating()}, nil
}
func (s *captureAccountRatingService) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
rating := maxInt64Rating()
rating.UserID = userID
return rating, nil
}
func (s *captureAccountRatingService) AccountRatingEvents(_ context.Context, userID int64, _ int) ([]domain.AccountRatingEvent, error) {
return []domain.AccountRatingEvent{{
ID: 9223372036854775807, UserID: userID,
Kind: domain.AccountRatingEventManual, Amount: -9223372036854775807,
Actor: "ops", Reason: "abuse",
}}, nil
}
func maxInt64Rating() domain.AccountRating {
return domain.AccountRating{
UserID: 1001,
Level: 7,
Stars: 9223372036854775807,
CurrentLevelStars: 4900,
NextLevelStars: 6400,
HasNextLevel: true,
StarsComponent: 9223372036854775807,
ManualComponent: -1500,
Version: 9223372036854775807,
}
}
func TestAdminAPICollectibleUsernameCommandsRequireToken(t *testing.T) {
srv := &Server{token: "secret", svc: fakeService{}}
for _, path := range []string{
"/v1/collectible-usernames/mint",
"/v1/collectible-usernames/transfer",
"/v1/collectible-usernames/revoke",
"/v1/account-ratings/recompute",
"/v1/account-ratings/adjust",
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s status=%d, want 401", path, rec.Code)
}
}
for _, path := range []string{
"/v1/collectible-usernames",
"/v1/collectible-usernames/7",
"/v1/account-ratings",
"/v1/account-ratings/7",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s status=%d, want 401", path, rec.Code)
}
}
}
func TestAdminAPIMintCollectibleUsernameForwardsExactInt64AndDryRun(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
svc := &captureCollectibleUsernameService{}
srv := &Server{token: "secret", svc: svc}
req := httptest.NewRequest(http.MethodPost, "/v1/collectible-usernames/mint", strings.NewReader(`{
"command_id":"mint-1","actor":"ops","reason":"fragment import","dry_run":true,
"username":"durov","owner_user_id":"1001","currency":"TON","amount":"9223372036854775807",
"crypto_currency":"TON","crypto_amount":"250000000000",
"url":"https://fragment.example/durov","purchase_date":1700000000
}`))
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"command_id":"mint-1"`) {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"dry_run":true`) {
t.Fatalf("dry-run was not propagated: %s", rec.Body.String())
}
if svc.mint.Username != "durov" || svc.mint.OwnerUserID != 1001 || svc.mint.Amount != maxInt64 ||
svc.mint.CryptoAmount != 250000000000 || svc.mint.PurchaseDate != 1700000000 || !svc.mint.DryRun {
t.Fatalf("decoded mint request = %+v", svc.mint)
}
}
func TestAdminAPITransferAndRevokeCollectibleUsername(t *testing.T) {
svc := &captureCollectibleUsernameService{}
srv := &Server{token: "secret", svc: svc}
transfer := httptest.NewRequest(http.MethodPost, "/v1/collectible-usernames/transfer", strings.NewReader(
`{"command_id":"t-1","actor":"ops","reason":"sold","username":"durov","to_channel_id":"2002"}`))
transfer.Header.Set("Authorization", "Bearer secret")
transferRec := httptest.NewRecorder()
srv.routes().ServeHTTP(transferRec, transfer)
if transferRec.Code != http.StatusOK || svc.transfer.ToChannelID != 2002 || svc.transfer.Username != "durov" {
t.Fatalf("transfer status=%d request=%+v", transferRec.Code, svc.transfer)
}
revoke := httptest.NewRequest(http.MethodPost, "/v1/collectible-usernames/revoke", strings.NewReader(
`{"command_id":"r-1","actor":"ops","reason":"fraud","username":"durov","burn":true}`))
revoke.Header.Set("Authorization", "Bearer secret")
revokeRec := httptest.NewRecorder()
srv.routes().ServeHTTP(revokeRec, revoke)
if revokeRec.Code != http.StatusOK || !svc.revoke.Burn || svc.revoke.CommandID != "r-1" {
t.Fatalf("revoke status=%d request=%+v", revokeRec.Code, svc.revoke)
}
}
func TestAdminAPIAccountRatingCommands(t *testing.T) {
svc := &captureAccountRatingService{}
srv := &Server{token: "secret", svc: svc}
recompute := httptest.NewRequest(http.MethodPost, "/v1/account-ratings/recompute", strings.NewReader(
`{"command_id":"rc-1","actor":"ops","reason":"support ticket","dry_run":true,"user_id":"1001"}`))
recompute.Header.Set("Authorization", "Bearer secret")
recomputeRec := httptest.NewRecorder()
srv.routes().ServeHTTP(recomputeRec, recompute)
if recomputeRec.Code != http.StatusOK || svc.recompute.UserID != 1001 || !svc.recompute.DryRun {
t.Fatalf("recompute status=%d request=%+v body=%s", recomputeRec.Code, svc.recompute, recomputeRec.Body.String())
}
adjust := httptest.NewRequest(http.MethodPost, "/v1/account-ratings/adjust", strings.NewReader(
`{"command_id":"adj-1","actor":"ops","reason":"manual penalty","user_id":"1001","amount":"-2500"}`))
adjust.Header.Set("Authorization", "Bearer secret")
adjustRec := httptest.NewRecorder()
srv.routes().ServeHTTP(adjustRec, adjust)
if adjustRec.Code != http.StatusOK || svc.adjust.Amount != -2500 || svc.adjust.DryRun {
t.Fatalf("adjust status=%d request=%+v body=%s", adjustRec.Code, svc.adjust, adjustRec.Body.String())
}
}
func TestAdminAPICollectibleUsernameReadsUseDecimalStrings(t *testing.T) {
svc := &captureCollectibleUsernameService{}
srv := &Server{token: "secret", svc: svc}
list := httptest.NewRequest(http.MethodGet,
"/v1/collectible-usernames?status=owned&owner_user_id=1001&q=%40Durov&limit=25&before_id=42", nil)
list.Header.Set("Authorization", "Bearer secret")
listRec := httptest.NewRecorder()
srv.routes().ServeHTTP(listRec, list)
if listRec.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String())
}
if svc.filter.Status != domain.CollectibleUsernameStatusOwned ||
svc.filter.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: 1001}) ||
svc.filter.Query != "@Durov" || svc.filter.Limit != 25 || svc.filter.BeforeID != 42 {
t.Fatalf("collectible filter = %+v", svc.filter)
}
if !strings.Contains(listRec.Body.String(), `"id":"9223372036854775807"`) ||
!strings.Contains(listRec.Body.String(), `"amount":"9223372036854775807"`) {
t.Fatalf("list body lost int64 precision: %s", listRec.Body.String())
}
detail := httptest.NewRequest(http.MethodGet, "/v1/collectible-usernames/77", nil)
detail.Header.Set("Authorization", "Bearer secret")
detailRec := httptest.NewRecorder()
srv.routes().ServeHTTP(detailRec, detail)
if detailRec.Code != http.StatusOK || svc.assetID != 77 {
t.Fatalf("detail status=%d assetID=%d body=%s", detailRec.Code, svc.assetID, detailRec.Body.String())
}
var payload struct {
Asset map[string]any `json:"asset"`
Transfers []map[string]any `json:"transfers"`
}
if err := json.Unmarshal(detailRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode detail: %v", err)
}
if payload.Asset["id"] != "77" || len(payload.Transfers) != 1 ||
payload.Transfers[0]["amount"] != "9223372036854775807" ||
payload.Transfers[0]["collectible_id"] != "77" {
t.Fatalf("detail payload = %+v", payload)
}
}
func TestAdminAPIAccountRatingReadsUseDecimalStrings(t *testing.T) {
svc := &captureAccountRatingService{}
srv := &Server{token: "secret", svc: svc}
list := httptest.NewRequest(http.MethodGet, "/v1/account-ratings?min_level=3&user_id=1001&limit=10&before_id=99", nil)
list.Header.Set("Authorization", "Bearer secret")
listRec := httptest.NewRecorder()
srv.routes().ServeHTTP(listRec, list)
if listRec.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String())
}
if svc.filter.MinLevel != 3 || svc.filter.UserID != 1001 || svc.filter.Limit != 10 || svc.filter.BeforeID != 99 {
t.Fatalf("rating filter = %+v", svc.filter)
}
if !strings.Contains(listRec.Body.String(), `"stars":"9223372036854775807"`) {
t.Fatalf("rating list lost int64 precision: %s", listRec.Body.String())
}
detail := httptest.NewRequest(http.MethodGet, "/v1/account-ratings/1001", nil)
detail.Header.Set("Authorization", "Bearer secret")
detailRec := httptest.NewRecorder()
srv.routes().ServeHTTP(detailRec, detail)
if detailRec.Code != http.StatusOK {
t.Fatalf("detail status=%d body=%s", detailRec.Code, detailRec.Body.String())
}
var payload struct {
Rating map[string]any `json:"rating"`
Events []map[string]any `json:"events"`
}
if err := json.Unmarshal(detailRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode detail: %v", err)
}
if payload.Rating["user_id"] != "1001" || payload.Rating["stars"] != "9223372036854775807" ||
len(payload.Events) != 1 || payload.Events[0]["amount"] != "-9223372036854775807" {
t.Fatalf("rating detail payload = %+v", payload)
}
}
func TestAdminAPIMissingCollectibleAndRatingReportCodedErrors(t *testing.T) {
srv := &Server{token: "secret", svc: fakeService{}}
asset := httptest.NewRequest(http.MethodGet, "/v1/collectible-usernames/5", nil)
asset.Header.Set("Authorization", "Bearer secret")
assetRec := httptest.NewRecorder()
srv.routes().ServeHTTP(assetRec, asset)
if assetRec.Code != http.StatusNotFound ||
!strings.Contains(assetRec.Body.String(), `"code":"`+admin.CodeCollectibleNotFound+`"`) {
t.Fatalf("missing asset status=%d body=%s", assetRec.Code, assetRec.Body.String())
}
rating := httptest.NewRequest(http.MethodGet, "/v1/account-ratings/5", nil)
rating.Header.Set("Authorization", "Bearer secret")
ratingRec := httptest.NewRecorder()
srv.routes().ServeHTTP(ratingRec, rating)
if ratingRec.Code != http.StatusNotFound ||
!strings.Contains(ratingRec.Body.String(), `"code":"`+admin.CodeRatingNotFound+`"`) {
t.Fatalf("missing rating status=%d body=%s", ratingRec.Code, ratingRec.Body.String())
}
}
func (fakeService) MintCollectibleUsername(_ context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) TransferCollectibleUsername(_ context.Context, req admin.TransferCollectibleUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeCollectibleUsername(_ context.Context, req admin.RevokeCollectibleUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) DeleteCollectibleUsername(_ context.Context, req admin.DeleteCollectibleUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) CollectibleUsernames(context.Context, domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
return nil, nil
}
func (fakeService) CollectibleUsernameByID(context.Context, int64) (domain.CollectibleUsername, error) {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
func (fakeService) CollectibleUsernameTransfers(context.Context, int64, int) ([]domain.CollectibleUsernameTransfer, error) {
return nil, nil
}
func (fakeService) RecomputeAccountRating(_ context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) AdjustAccountRating(_ context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) AccountRating(context.Context, int64) (domain.AccountRating, error) {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
func (fakeService) AccountRatings(context.Context, domain.AccountRatingFilter) ([]domain.AccountRating, error) {
return nil, nil
}
func (fakeService) AccountRatingEvents(context.Context, int64, int) ([]domain.AccountRatingEvent, error) {
return nil, nil
}

View file

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

View file

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

View file

@ -3,7 +3,9 @@ package account
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"strings"
@ -26,6 +28,10 @@ const (
codeChannelEmailChange = "email_change"
codeChannelEmailLogin = "email_login"
codeChannelEmailSetupRequired = "email_setup_required"
codeChannelPasswordRecovery = "password_recovery"
passwordRecoveryCodePrefix = "password-recovery:"
passwordRecoveryCodeTTL = 15 * time.Minute
passwordRecoveryCASRetries = 32
)
// Service 提供账号安全配置查询。
@ -411,11 +417,9 @@ func (s *Service) RequestPasswordRecovery(ctx context.Context, userID int64) (st
if !settings.HasPassword || settings.RecoveryEmail == "" {
return "", domain.ErrPasswordRecoveryNA
}
// A fixed recovery code was used here regardless of whether it was
// actually emailed anywhere, which let anyone reset 2FA on any account
// with a recovery email set. Recovery now requires a real sender and a
// freshly generated code delivered to it -- no sender, no recovery.
if s.loginEmailSender == nil {
// Recovery has no development-code fallback. If the server cannot both
// persist and deliver a fresh code, it must report the flow unavailable.
if s == nil || s.codes == nil || s.loginEmailSender == nil {
return "", domain.ErrPasswordRecoveryNA
}
code, err := randomDigits(s.loginEmailCodeLength)
@ -426,14 +430,20 @@ func (s *Service) RequestPasswordRecovery(ctx context.Context, userID int64) (st
if err != nil {
return "", err
}
expiresAtUnix := time.Now().Unix() + recoveryCodeTTL
expiresAt := time.Unix(expiresAtUnix, 0)
settings.RecoveryCode = code
settings.RecoveryCodeExpiresAt = expiresAtUnix
if s.passwords != nil {
if err := s.passwords.Save(ctx, userID, settings); err != nil {
return "", err
}
expiresAt := time.Now().Add(passwordRecoveryCodeTTL)
key := passwordRecoveryCodeKey(userID)
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
UserID: userID,
Email: normalizeLoginEmail(settings.RecoveryEmail),
Code: code,
DeliveryID: deliveryID,
Channel: codeChannelPasswordRecovery,
MaxAttempts: s.loginEmailCodeMaxAttempts,
RecoveryBinding: passwordRecoveryBinding(settings),
}
if err := s.codes.Set(ctx, key, rec, passwordRecoveryCodeTTL); err != nil {
return "", err
}
if err := deliverOTP(ctx, s.loginEmailSender, otpdelivery.Request{
DeliveryID: deliveryID,
@ -443,14 +453,12 @@ func (s *Service) RequestPasswordRecovery(ctx context.Context, userID int64) (st
Code: code,
ExpiresAt: expiresAt,
}); err != nil {
if s.passwords != nil {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
settings.RecoveryCode = ""
settings.RecoveryCodeExpiresAt = 0
_ = s.passwords.Save(cleanupCtx, userID, settings)
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
if cleanupErr := s.deletePasswordRecoveryCode(cleanupCtx, key, deliveryID); cleanupErr != nil {
return "", fmt.Errorf("deliver password recovery code: %w (cleanup failed: %v)", err, cleanupErr)
}
return "", err
return "", fmt.Errorf("deliver password recovery code: %w", err)
}
return emailPattern(settings.RecoveryEmail), nil
}
@ -460,23 +468,35 @@ func (s *Service) CheckRecoveryPassword(ctx context.Context, userID int64, code
if err != nil {
return err
}
return checkRecoveryCode(settings, code)
if !settings.HasPassword || settings.RecoveryEmail == "" {
return domain.ErrPasswordRecoveryExpired
}
return s.verifyPasswordRecoveryCode(ctx, userID, passwordRecoveryBinding(settings), code, false)
}
func (s *Service) RecoverPassword(ctx context.Context, userID int64, code string, input *domain.PasswordInputSettings) error {
if s == nil || s.passwords == nil || userID == 0 {
return nil
return domain.ErrPasswordRecoveryExpired
}
settings, err := s.GetPasswordWithoutRefresh(ctx, userID)
if err != nil {
return err
}
if err := checkRecoveryCode(settings, code); err != nil {
return err
if !settings.HasPassword || settings.RecoveryEmail == "" {
return domain.ErrPasswordRecoveryExpired
}
binding := passwordRecoveryBinding(settings)
if input == nil || len(input.NewPasswordHash) == 0 {
settings = defaultPasswordSettings()
return s.passwords.Save(ctx, userID, settings)
if err := s.verifyPasswordRecoveryCode(ctx, userID, binding, code, true); err != nil {
return err
}
return s.passwords.Save(ctx, userID, defaultPasswordSettings())
}
// Reject invalid proofs before doing the comparatively expensive SRP
// verifier/challenge work. The final consuming check below still decides
// the single winner if the code changes concurrently.
if err := s.verifyPasswordRecoveryCode(ctx, userID, binding, code, false); err != nil {
return err
}
if err := validateNewPasswordSettings(*input); err != nil {
return err
@ -495,8 +515,9 @@ func (s *Service) RecoverPassword(ctx context.Context, userID int64, code string
if input.HasHint {
settings.Hint = input.Hint
}
settings.RecoveryCode = ""
settings.RecoveryCodeExpiresAt = 0
if err := s.verifyPasswordRecoveryCode(ctx, userID, binding, code, true); err != nil {
return err
}
return s.passwords.Save(ctx, userID, normalizePasswordSettings(settings))
}
@ -555,32 +576,123 @@ func (s *Service) ResendPasswordEmail(ctx context.Context, userID int64) error {
}
func (s *Service) CancelPasswordEmail(ctx context.Context, userID int64) error {
if s != nil && s.codes != nil && userID != 0 {
if err := s.codes.Del(ctx, passwordRecoveryCodeKey(userID)); err != nil {
return err
}
}
settings, err := s.GetPasswordWithoutRefresh(ctx, userID)
if err != nil {
return err
}
settings.EmailUnconfirmedPattern = ""
settings.RecoveryCode = ""
settings.RecoveryCodeExpiresAt = 0
if s.passwords != nil {
return s.passwords.Save(ctx, userID, settings)
}
return nil
}
func checkRecoveryCode(settings domain.PasswordSettings, code string) error {
// No standing fixed-code fallback: an unrequested (or already consumed)
// recovery must not be satisfiable by any code at all.
if settings.RecoveryCode == "" {
return domain.ErrPasswordRecoveryNA
func passwordRecoveryCodeKey(userID int64) string {
return passwordRecoveryCodePrefix + fmt.Sprint(userID)
}
func passwordRecoveryBinding(settings domain.PasswordSettings) string {
sum := sha256.Sum256([]byte(fmt.Sprintf("%d\x00%s", settings.SRPID, normalizeLoginEmail(settings.RecoveryEmail))))
return hex.EncodeToString(sum[:])
}
func (s *Service) deletePasswordRecoveryCode(ctx context.Context, key, deliveryID string) error {
for attempt := 0; attempt < passwordRecoveryCASRetries; attempt++ {
snapshot, found, err := s.codes.GetSnapshot(ctx, key)
if err != nil || !found {
return err
}
if snapshot.Record.Channel != codeChannelPasswordRecovery || snapshot.Record.DeliveryID != deliveryID {
return nil
}
deleted, err := s.codes.CompareAndDelete(ctx, key, snapshot.Revision)
if err != nil {
return err
}
if deleted {
return nil
}
if err := ctx.Err(); err != nil {
return err
}
}
if settings.RecoveryCodeExpiresAt > 0 && time.Now().Unix() > settings.RecoveryCodeExpiresAt {
return domain.ErrEmailCodeInvalid
return fmt.Errorf("delete password recovery code: concurrent state did not settle")
}
// verifyPasswordRecoveryCode keeps check non-consuming while making the final
// recovery a single-winner CAS. Wrong attempts are counted atomically and the
// code is removed at the configured threshold.
func (s *Service) verifyPasswordRecoveryCode(ctx context.Context, userID int64, binding, code string, consume bool) error {
code = strings.TrimSpace(code)
if code == "" {
return domain.ErrRecoveryCodeEmpty
}
if subtle.ConstantTimeCompare([]byte(settings.RecoveryCode), []byte(code)) != 1 {
return domain.ErrEmailCodeInvalid
if s == nil || s.codes == nil || userID == 0 {
return domain.ErrPasswordRecoveryExpired
}
return nil
key := passwordRecoveryCodeKey(userID)
for attempt := 0; attempt < passwordRecoveryCASRetries; attempt++ {
snapshot, found, err := s.codes.GetSnapshot(ctx, key)
if err != nil {
return err
}
if !found {
return domain.ErrPasswordRecoveryExpired
}
rec := snapshot.Record
if rec.Channel != codeChannelPasswordRecovery || rec.UserID != userID || rec.RecoveryBinding == "" || rec.RecoveryBinding != binding {
deleted, err := s.codes.CompareAndDelete(ctx, key, snapshot.Revision)
if err != nil {
return err
}
if deleted {
return domain.ErrPasswordRecoveryExpired
}
continue
}
if subtle.ConstantTimeCompare([]byte(rec.Code), []byte(code)) == 1 {
if !consume {
return nil
}
deleted, err := s.codes.CompareAndDelete(ctx, key, snapshot.Revision)
if err != nil {
return err
}
if deleted {
return nil
}
continue
}
maxAttempts := rec.MaxAttempts
if maxAttempts <= 0 {
maxAttempts = s.loginEmailCodeMaxAttempts
}
if maxAttempts <= 0 {
maxAttempts = 1
}
rec.Attempts++
var applied bool
if rec.Attempts >= maxAttempts {
applied, err = s.codes.CompareAndDelete(ctx, key, snapshot.Revision)
} else {
applied, err = s.codes.CompareAndUpdate(ctx, key, snapshot.Revision, rec)
}
if err != nil {
return err
}
if applied {
return domain.ErrRecoveryCodeInvalid
}
if err := ctx.Err(); err != nil {
return err
}
}
return fmt.Errorf("verify password recovery code: concurrent state did not settle")
}
func randomBytesOrDefault(n int, fallback []byte) []byte {
@ -613,14 +725,24 @@ func randomDigits(n int) (string, error) {
if n <= 0 {
n = 6
}
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
var out strings.Builder
out.Grow(n)
for _, v := range b {
out.WriteByte(byte('0') + v%10)
var buf [32]byte
for out.Len() < n {
if _, err := rand.Read(buf[:]); err != nil {
return "", err
}
for _, v := range buf {
// Reject the top six values so every digit has exactly 25 source
// byte values instead of inheriting modulo bias from 256 %% 10.
if v >= 250 {
continue
}
out.WriteByte(byte('0') + v%10)
if out.Len() == n {
break
}
}
}
return out.String(), nil
}
@ -1072,6 +1194,41 @@ func (s *Service) GetAccountSettings(ctx context.Context, userID int64) (domain.
return settings, nil
}
// GetAccountSettingsBatch is the bounded cold loader behind the RPC read
// model. Missing rows are returned as explicit defaults so they are negative
// cached instead of being queried again.
func (s *Service) GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error) {
out := make(map[int64]domain.AccountSettings, len(userIDs))
for _, userID := range userIDs {
if userID > 0 {
out[userID] = domain.DefaultAccountSettings()
}
}
if s == nil || s.settings == nil || len(out) == 0 {
return out, nil
}
if batch, ok := s.settings.(store.AccountSettingsBatchStore); ok {
loaded, err := batch.GetAccountSettingsBatch(ctx, userIDs)
if err != nil {
return nil, err
}
for userID, settings := range loaded {
out[userID] = settings
}
return out, nil
}
for userID := range out {
settings, found, err := s.settings.GetAccountSettings(ctx, userID)
if err != nil {
return nil, err
}
if found {
out[userID] = settings
}
}
return out, nil
}
// SetGlobalPrivacy 持久化账号全局隐私开关,返回合并后的完整设置。
func (s *Service) SetGlobalPrivacy(ctx context.Context, userID int64, privacy domain.GlobalPrivacy) (domain.AccountSettings, error) {
settings, err := s.GetAccountSettings(ctx, userID)

View file

@ -6,12 +6,14 @@ import (
"crypto/sha512"
"errors"
"math/big"
"sync"
"testing"
"time"
"golang.org/x/crypto/pbkdf2"
"telesrv/internal/domain"
"telesrv/internal/otpdelivery"
"telesrv/internal/store/memory"
)
@ -95,8 +97,11 @@ func TestRecoverPasswordClearsTwoFactorPassword(t *testing.T) {
if pattern != "b***b@example.com" {
t.Fatalf("recovery pattern = %q, want masked email", pattern)
}
if sender.to != "bob@example.com" || sender.code == "" {
t.Fatalf("sender = %+v, want delivered code to bob@example.com", sender)
if sender.to != "bob@example.com" || sender.code == "" || len(sender.requests) != 1 || sender.requests[0].Purpose != otpdelivery.PurposePasswordRecovery {
t.Fatalf("recovery delivery = %+v, want one password-recovery email", sender)
}
if err := svc.CheckRecoveryPassword(ctx, userID, sender.code); err != nil {
t.Fatalf("CheckRecoveryPassword: %v", err)
}
if err := svc.RecoverPassword(ctx, userID, sender.code, nil); err != nil {
t.Fatalf("RecoverPassword clear: %v", err)
@ -110,6 +115,155 @@ func TestRecoverPasswordClearsTwoFactorPassword(t *testing.T) {
}
}
func TestPasswordRecoveryFailsClosedWithoutSenderOrIssuedCode(t *testing.T) {
ctx := context.Background()
const userID int64 = 1012
passwords := memory.NewPasswordStore()
if err := passwords.Save(ctx, userID, domain.PasswordSettings{
HasPassword: true, HasRecovery: true, RecoveryEmail: "owner@example.test",
SRPID: 7, SRPVerifier: []byte{1, 2, 3},
}); err != nil {
t.Fatal(err)
}
svc := NewService(passwords)
if _, err := svc.RequestPasswordRecovery(ctx, userID); !errors.Is(err, domain.ErrPasswordRecoveryNA) {
t.Fatalf("RequestPasswordRecovery err=%v, want unavailable", err)
}
if err := svc.RecoverPassword(ctx, userID, "12345", nil); !errors.Is(err, domain.ErrPasswordRecoveryExpired) {
t.Fatalf("standing fixed code err=%v, want expired", err)
}
if err := NewService(nil).RecoverPassword(ctx, userID, "12345", nil); !errors.Is(err, domain.ErrPasswordRecoveryExpired) {
t.Fatalf("missing password store err=%v, want fail-closed expiry", err)
}
}
func TestPasswordRecoveryAttemptLimitAndStateBinding(t *testing.T) {
ctx := context.Background()
const userID int64 = 1013
passwords := memory.NewPasswordStore()
settings := domain.PasswordSettings{
HasPassword: true, HasRecovery: true, RecoveryEmail: "owner@example.test",
SRPID: 8, SRPVerifier: []byte{4, 5, 6},
}
if err := passwords.Save(ctx, userID, settings); err != nil {
t.Fatal(err)
}
sender := &captureMailSender{}
svc := NewService(passwords,
WithLoginEmailVerification(memory.NewCodeStore(), sender, time.Minute, 3, 6))
if _, err := svc.RequestPasswordRecovery(ctx, userID); err != nil {
t.Fatal(err)
}
for attempt := 1; attempt <= 3; attempt++ {
if err := svc.CheckRecoveryPassword(ctx, userID, "000000"); !errors.Is(err, domain.ErrRecoveryCodeInvalid) {
t.Fatalf("wrong attempt %d err=%v, want invalid", attempt, err)
}
}
if err := svc.CheckRecoveryPassword(ctx, userID, sender.code); !errors.Is(err, domain.ErrPasswordRecoveryExpired) {
t.Fatalf("code after attempt limit err=%v, want expired", err)
}
if _, err := svc.RequestPasswordRecovery(ctx, userID); err != nil {
t.Fatal(err)
}
issued := sender.code
settings.SRPID++
if err := passwords.Save(ctx, userID, settings); err != nil {
t.Fatal(err)
}
if err := svc.CheckRecoveryPassword(ctx, userID, issued); !errors.Is(err, domain.ErrPasswordRecoveryExpired) {
t.Fatalf("code after 2FA state change err=%v, want expired", err)
}
}
func TestConcurrentPasswordRecoveryHasSingleConsumer(t *testing.T) {
ctx := context.Background()
const userID int64 = 1014
passwords := memory.NewPasswordStore()
if err := passwords.Save(ctx, userID, domain.PasswordSettings{
HasPassword: true, HasRecovery: true, RecoveryEmail: "owner@example.test",
SRPID: 9, SRPVerifier: []byte{7, 8, 9},
}); err != nil {
t.Fatal(err)
}
sender := &captureMailSender{}
svc := NewService(passwords,
WithLoginEmailVerification(memory.NewCodeStore(), sender, time.Minute, 5, 6))
if _, err := svc.RequestPasswordRecovery(ctx, userID); err != nil {
t.Fatal(err)
}
const workers = 24
start := make(chan struct{})
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
errs <- svc.RecoverPassword(ctx, userID, sender.code, nil)
}()
}
close(start)
wg.Wait()
close(errs)
successes := 0
for err := range errs {
if err == nil {
successes++
continue
}
if !errors.Is(err, domain.ErrPasswordRecoveryExpired) {
t.Fatalf("concurrent recovery err=%v", err)
}
}
if successes != 1 {
t.Fatalf("successful recoveries=%d, want 1", successes)
}
}
func TestPasswordRecoveryDeliveryFailureRemovesIssuedCode(t *testing.T) {
ctx := context.Background()
const userID int64 = 1015
passwords := memory.NewPasswordStore()
if err := passwords.Save(ctx, userID, domain.PasswordSettings{
HasPassword: true, HasRecovery: true, RecoveryEmail: "owner@example.test",
SRPID: 10, SRPVerifier: []byte{10},
}); err != nil {
t.Fatal(err)
}
sender := &captureMailSender{err: errors.New("provider rejected request")}
svc := NewService(passwords,
WithLoginEmailVerification(memory.NewCodeStore(), sender, time.Minute, 5, 6))
if _, err := svc.RequestPasswordRecovery(ctx, userID); err == nil {
t.Fatal("RequestPasswordRecovery succeeded after known delivery failure")
}
if err := svc.CheckRecoveryPassword(ctx, userID, sender.code); !errors.Is(err, domain.ErrPasswordRecoveryExpired) {
t.Fatalf("undelivered code err=%v, want expired", err)
}
}
func TestPasswordRecoveryUnknownDeliveryOutcomeKeepsIssuedCode(t *testing.T) {
ctx := context.Background()
const userID int64 = 1016
passwords := memory.NewPasswordStore()
if err := passwords.Save(ctx, userID, domain.PasswordSettings{
HasPassword: true, HasRecovery: true, RecoveryEmail: "owner@example.test",
SRPID: 11, SRPVerifier: []byte{11},
}); err != nil {
t.Fatal(err)
}
sender := &captureMailSender{err: &otpdelivery.OutcomeUnknownError{Cause: errors.New("provider ACK lost")}}
svc := NewService(passwords,
WithLoginEmailVerification(memory.NewCodeStore(), sender, time.Minute, 5, 6))
if _, err := svc.RequestPasswordRecovery(ctx, userID); err != nil {
t.Fatalf("RequestPasswordRecovery outcome-unknown err=%v", err)
}
if err := svc.CheckRecoveryPassword(ctx, userID, sender.code); err != nil {
t.Fatalf("outcome-unknown code was discarded: %v", err)
}
}
func TestResetPasswordWaitAndDecline(t *testing.T) {
ctx := context.Background()
const userID int64 = 1003

View file

@ -12,7 +12,6 @@ import (
const (
passwordHashSize = 256
recoveryCodeTTL = 15 * 60
)
var (

View file

@ -870,27 +870,24 @@ func (s *Service) CancelCodeForAuthKey(ctx context.Context, authKeyID [8]byte, p
// never offers a "Can't access this email?" escape hatch that can only ever
// fail (or, before this was locked down, silently succeed with the
// well-known fixed dev code).
//
// emailSignupEnabled accounts fail this even with a real phoneCodeSender:
// their "phone" is a synthetic 888-prefixed display number
// (domain.NewEmailSignupDisplayPhone), never a real number anyone can
// receive SMS on -- email is the actual identity there, regardless of what
// SMS infra exists for other (real-phone) accounts on this server.
func (s *Service) LoginEmailResetAvailable() bool {
return s.phoneCodeSender != nil && !s.emailSignupEnabled
return s != nil && s.phoneCodeSender != nil && s.codes != nil && s.users != nil && !s.emailSignupEnabled
}
// ConsumeLoginEmailReset authorizes auth.resetLoginEmail with the exact
// email-login hash previously issued for this phone owner. Possession of only
// a phone number is never sufficient to remove an authentication factor.
func (s *Service) ConsumeLoginEmailReset(ctx context.Context, phone, phoneCodeHash string) (int64, error) {
// This flow exists to fall back to an SMS code when the login email is
// unreachable. Two independent reasons it must refuse outright, before
// ClearLoginEmail runs so nothing is ever mutated on a doomed request:
// - no real phoneCodeSender: the "SMS code" is always the well-known
// TELESRV_DEV_AUTH_CODE (see createPhoneCode), so anyone who can call
// sendCode for a phone (no email access required) could strip the
// login-email requirement with a publicly known code.
// - emailSignupEnabled: this account's "phone" is a synthetic 888-
// prefixed display number (domain.NewEmailSignupDisplayPhone), never
// a real number anyone can receive SMS on. Email is the actual
// identity here regardless of whether a real SMS sender happens to
// be configured for other (real-phone) accounts on this server.
if s.phoneCodeSender == nil || s.emailSignupEnabled {
// Refuse before consuming the email proof or clearing any account state. If
// there is no real SMS sender, the successor code would be the public
// development code and could strip the login-email factor.
if !s.LoginEmailResetAvailable() || s.codes == nil {
return 0, ErrCodeInvalid
}
phone = normalizePhone(phone)
@ -1488,18 +1485,7 @@ func (s *Service) ResetAuthorization(ctx context.Context, userID, hash int64) (d
if revoker, ok := s.auths.(authorizationRevoker); ok {
return revoker.RevokeByHash(ctx, userID, hash)
}
target, found, err := s.authorizationByHash(ctx, userID, hash)
if err != nil || !found {
return target, found, err
}
if err := s.deleteAuthKey(ctx, target.AuthKeyID); err != nil {
return target, true, err
}
deleted, found, err := s.auths.DeleteByHash(ctx, userID, hash)
if err != nil || !found {
return deleted, found, err
}
return deleted, true, nil
return s.auths.DeleteByHash(ctx, userID, hash)
}
func (s *Service) ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
@ -1509,54 +1495,7 @@ func (s *Service) ResetAuthorizations(ctx context.Context, userID int64, keepAut
if revoker, ok := s.auths.(authorizationRevoker); ok {
return revoker.RevokeByUserExcept(ctx, userID, keepAuthKeyID)
}
targets, err := s.authorizationsByUserExcept(ctx, userID, keepAuthKeyID)
if err != nil {
return nil, err
}
for _, a := range targets {
if err := s.deleteAuthKey(ctx, a.AuthKeyID); err != nil {
return nil, err
}
}
deleted, err := s.auths.DeleteByUserExcept(ctx, userID, keepAuthKeyID)
if err != nil {
return nil, err
}
return deleted, nil
}
func (s *Service) deleteAuthKey(ctx context.Context, authKeyID [8]byte) error {
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
return nil
}
return s.authKeys.Delete(ctx, authKeyID)
}
func (s *Service) authorizationByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
items, err := s.auths.ListByUser(ctx, userID)
if err != nil {
return domain.Authorization{}, false, err
}
for _, a := range items {
if a.Hash == hash {
return a, true, nil
}
}
return domain.Authorization{}, false, nil
}
func (s *Service) authorizationsByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
items, err := s.auths.ListByUser(ctx, userID)
if err != nil {
return nil, err
}
out := make([]domain.Authorization, 0, len(items))
for _, a := range items {
if a.AuthKeyID != keepAuthKeyID {
out = append(out, a)
}
}
return out, nil
return s.auths.DeleteByUserExcept(ctx, userID, keepAuthKeyID)
}
func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error {

View file

@ -535,7 +535,7 @@ func TestLogOutThenSignInSameAuthKeySwitchesUser(t *testing.T) {
}
}
func TestResetAuthorizationDeletesProtocolAuthKey(t *testing.T) {
func TestResetAuthorizationKeepsProtocolAuthKeyForRPCLogout(t *testing.T) {
ctx := context.Background()
authz := memory.NewAuthorizationStore()
keys := memory.NewAuthKeyStore()
@ -562,15 +562,15 @@ func TestResetAuthorizationDeletesProtocolAuthKey(t *testing.T) {
if err != nil || !found || deleted.AuthKeyID != key {
t.Fatalf("ResetAuthorization deleted=%x found=%v err=%v, want key %x", deleted.AuthKeyID, found, err, key)
}
if _, found, err := keys.Get(ctx, key); err != nil || found {
t.Fatalf("auth key after reset found=%v err=%v, want missing", found, err)
if _, found, err := keys.Get(ctx, key); err != nil || !found {
t.Fatalf("auth key after reset found=%v err=%v, want present for RPC 401", found, err)
}
if _, found, err := svc.UserID(ctx, key); err != nil || found {
t.Fatalf("user after reset found=%v err=%v, want missing", found, err)
}
}
func TestResetAuthorizationsDeletesOnlyRevokedProtocolAuthKeys(t *testing.T) {
func TestResetAuthorizationsKeepsRevokedProtocolAuthKeys(t *testing.T) {
ctx := context.Background()
authz := memory.NewAuthorizationStore()
keys := memory.NewAuthKeyStore()
@ -600,12 +600,18 @@ func TestResetAuthorizationsDeletesOnlyRevokedProtocolAuthKeys(t *testing.T) {
if err != nil || len(deleted) != 1 || deleted[0].AuthKeyID != revoked {
t.Fatalf("ResetAuthorizations deleted=%v err=%v, want revoked key", deleted, err)
}
if _, found, err := keys.Get(ctx, revoked); err != nil || found {
t.Fatalf("revoked auth key found=%v err=%v, want missing", found, err)
if _, found, err := keys.Get(ctx, revoked); err != nil || !found {
t.Fatalf("revoked auth key found=%v err=%v, want present for RPC 401", found, err)
}
if _, found, err := keys.Get(ctx, keep); err != nil || !found {
t.Fatalf("kept auth key found=%v err=%v, want present", found, err)
}
if _, found, err := svc.UserID(ctx, revoked); err != nil || found {
t.Fatalf("revoked user found=%v err=%v, want missing", found, err)
}
if got, found, err := svc.UserID(ctx, keep); err != nil || !found || got != u.ID {
t.Fatalf("kept user=%d found=%v err=%v, want %d", got, found, err, u.ID)
}
}
func TestSignUpWritesOfficialLoginMessage(t *testing.T) {

View file

@ -443,8 +443,12 @@ func TestConsumeLoginEmailResetRequiresExactIssuedHash(t *testing.T) {
users := &switchablePhoneOwnerStore{UserStore: baseUsers}
codes := memory.NewCodeStore()
delivery := &captureLoginCodeDelivery{}
otp := &captureOTPSender{}
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345",
WithLoginCodeDelivery(delivery), WithPhoneCodeDelivery(&captureOTPSender{}, 5))
WithLoginCodeDelivery(delivery), WithPhoneCodeDelivery(otp, 5))
if !svc.LoginEmailResetAvailable() {
t.Fatal("LoginEmailResetAvailable=false with real SMS sender")
}
seed := func(hash, channel string) {
t.Helper()
if err := codes.Set(ctx, hash, store.PhoneCode{
@ -501,6 +505,33 @@ func TestConsumeLoginEmailResetRequiresExactIssuedHash(t *testing.T) {
}
}
func TestLoginEmailResetUnavailableWithoutRealSMSSender(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
owner, err := users.Create(ctx, domain.User{Phone: "15550009339", FirstName: "Owner"})
if err != nil {
t.Fatal(err)
}
codes := memory.NewCodeStore()
const hash = "unavailable-email-reset"
if err := codes.Set(ctx, hash, store.PhoneCode{
Version: store.PhoneCodeVersionCurrent, IssuedUserID: owner.ID,
Phone: owner.Phone, Code: "654321", Channel: codeChannelEmailLogin,
}, time.Minute); err != nil {
t.Fatal(err)
}
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345")
if svc.LoginEmailResetAvailable() {
t.Fatal("LoginEmailResetAvailable=true without real SMS sender")
}
if _, err := svc.ConsumeLoginEmailReset(ctx, owner.Phone, hash); !errors.Is(err, ErrCodeInvalid) {
t.Fatalf("ConsumeLoginEmailReset err=%v, want invalid", err)
}
if _, found, err := codes.Get(ctx, hash); err != nil || !found {
t.Fatalf("unavailable reset consumed proof found=%v err=%v", found, err)
}
}
func TestConcurrentLoginEmailResetHasSingleConsumer(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()

View file

@ -0,0 +1,53 @@
package authdiagnostics
import (
"context"
"telesrv/internal/domain"
"telesrv/internal/store"
)
type Service struct {
codes store.CodeStore
reports store.AuthDeliveryReportStore
}
func NewService(codes store.CodeStore, reports store.AuthDeliveryReportStore) *Service {
return &Service{codes: codes, reports: reports}
}
func (s *Service) ReportMissingCode(ctx context.Context, req domain.AuthMissingCodeReportRequest) (domain.AuthDeliveryReport, bool, error) {
phone := domain.NormalizePhone(req.Phone)
if s == nil || s.codes == nil || s.reports == nil ||
!domain.ValidPhone(phone) || req.PhoneCodeHash == "" {
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeInvalid
}
record, found, err := s.codes.Get(ctx, req.PhoneCodeHash)
if err != nil {
return domain.AuthDeliveryReport{}, false, err
}
if !found {
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeExpired
}
if record.Version != store.PhoneCodeVersionCurrent || record.Purpose != "" ||
record.Phone != phone || !store.LoginCodeChannelVerifiable(record.Channel) {
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeInvalid
}
var channel domain.AuthCodeDeliveryKind
switch record.Channel {
case store.PhoneCodeChannelPhone:
channel = domain.AuthCodeDeliveryPhone
case store.PhoneCodeChannelSMS:
channel = domain.AuthCodeDeliverySMS
default:
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeInvalid
}
report, err := domain.NewAuthDeliveryReport(
req.AuthKeyID, req.SessionID, req.ClientType, phone, req.PhoneCodeHash,
record.IssuedUserID, record.DeliveryID, channel, req.MNC, req.CreatedAt,
)
if err != nil {
return domain.AuthDeliveryReport{}, false, err
}
return s.reports.CreateAuthDeliveryReport(ctx, report)
}

View file

@ -0,0 +1,81 @@
package authdiagnostics
import (
"context"
"crypto/sha256"
"errors"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
func TestReportMissingCodeValidatesLiveDeliveryAndStoresOnlyHashes(t *testing.T) {
ctx := context.Background()
codes := memory.NewCodeStore()
reports := memory.NewAuthDeliveryReportStore()
const (
phone = "15550001234"
codeHash = "login-code-hash"
)
if err := codes.Set(ctx, codeHash, store.PhoneCode{
Version: store.PhoneCodeVersionCurrent, Phone: phone, Code: "12345",
DeliveryID: "delivery-1", Channel: store.PhoneCodeChannelSMS,
IssuedUserID: 42,
}, time.Hour); err != nil {
t.Fatal(err)
}
service := NewService(codes, reports)
now := time.Now().UTC()
req := domain.AuthMissingCodeReportRequest{
AuthKeyID: [8]byte{1, 2, 3}, SessionID: 99, ClientType: "tdesktop",
Phone: "+1 (555) 000-1234", PhoneCodeHash: codeHash, MNC: "46000",
CreatedAt: now,
}
first, created, err := service.ReportMissingCode(ctx, req)
if err != nil || !created {
t.Fatalf("first report created=%v err=%v", created, err)
}
second, created, err := service.ReportMissingCode(ctx, req)
if err != nil || created || second.ID != first.ID {
t.Fatalf("retry report=%+v created=%v err=%v", second, created, err)
}
stored := reports.Reports()
if len(stored) != 1 {
t.Fatalf("stored reports=%d, want 1", len(stored))
}
if stored[0].PhoneHash != sha256.Sum256([]byte(phone)) ||
stored[0].CodeHash != sha256.Sum256([]byte(codeHash)) {
t.Fatalf("stored hashes do not match normalized delivery identity: %+v", stored[0])
}
if stored[0].DeliveryID != "delivery-1" || stored[0].IssuedUserID != 42 ||
stored[0].Channel != domain.AuthCodeDeliverySMS {
t.Fatalf("stored delivery metadata=%+v", stored[0])
}
}
func TestReportMissingCodeRejectsUnknownOrMismatchedLoginState(t *testing.T) {
ctx := context.Background()
codes := memory.NewCodeStore()
service := NewService(codes, memory.NewAuthDeliveryReportStore())
now := time.Now().UTC()
base := domain.AuthMissingCodeReportRequest{
AuthKeyID: [8]byte{1}, SessionID: 10, Phone: "15550002222",
PhoneCodeHash: "missing", CreatedAt: now,
}
if _, _, err := service.ReportMissingCode(ctx, base); !errors.Is(err, domain.ErrPhoneCodeExpired) {
t.Fatalf("missing hash err=%v, want phone-code expired", err)
}
if err := codes.Set(ctx, "current", store.PhoneCode{
Version: store.PhoneCodeVersionCurrent, Phone: "15550003333",
Channel: store.PhoneCodeChannelPhone,
}, time.Hour); err != nil {
t.Fatal(err)
}
base.PhoneCodeHash = "current"
if _, _, err := service.ReportMissingCode(ctx, base); !errors.Is(err, domain.ErrPhoneCodeInvalid) {
t.Fatalf("mismatched phone err=%v, want phone-code invalid", err)
}
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -216,6 +216,21 @@ func (s *Service) GetChannels(ctx context.Context, userID int64, channelIDs []in
return s.channels.GetChannels(ctx, userID, ids)
}
// GetChannelsAuthoritative bypasses the app-level versioned read model for a
// durable channel_state refresh. PostgreSQL GetChannels is a bounded direct
// projection query, so the returned flag snapshot cannot be the pre-commit
// value that the event is intended to invalidate.
func (s *Service) GetChannelsAuthoritative(ctx context.Context, userID int64, channelIDs []int64) ([]domain.ChannelView, error) {
if s == nil || s.channels == nil || userID == 0 {
return nil, domain.ErrChannelInvalid
}
ids := uniqueNonZero(channelIDs)
if len(ids) == 0 {
return nil, nil
}
return s.channels.GetChannels(ctx, userID, ids)
}
// GetJoinableChannel returns a channel shell so RPC can verify access hash before join.
func (s *Service) GetJoinableChannel(ctx context.Context, userID, channelID int64) (domain.Channel, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
@ -582,7 +597,10 @@ func (s *Service) ResolvePublicUsername(ctx context.Context, userID int64, usern
return domain.Channel{}, false, domain.ErrChannelInvalid
}
username = normalizeChannelUsername(username)
if !validChannelUsername(username) {
// Public resolution also covers Fragment-style collectible usernames,
// whose protocol minimum is four characters. Channel username mutation
// remains on the ordinary 5..32 validation path above.
if !domain.ValidCollectibleUsername(username) {
return domain.Channel{}, false, domain.ErrUsernameInvalid
}
return s.channels.ResolvePublicChannelUsername(ctx, userID, username)
@ -1031,6 +1049,29 @@ func (s *Service) ListMessageReactions(ctx context.Context, userID int64, req do
return s.channels.ListChannelMessageReactions(ctx, req)
}
type messageReactionLookupStore interface {
FindChannelMessageReaction(ctx context.Context, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error)
}
func (s *Service) FindMessageReaction(ctx context.Context, userID int64, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error) {
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 ||
req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID ||
req.ReactorUserID == 0 {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
}
if req.ViewerUserID == 0 {
req.ViewerUserID = userID
}
if req.ViewerUserID != userID {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
}
lookup, ok := s.channels.(messageReactionLookupStore)
if !ok {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
}
return lookup.FindChannelMessageReaction(ctx, req)
}
type messageReactionUsageStore interface {
RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error
}
@ -1127,34 +1168,6 @@ func (s *Service) ClearRecentReactions(ctx context.Context, userID int64) error
return s.channels.ClearRecentMessageReactions(ctx, userID)
}
// SavedReactionTags returns account-level saved-message reaction tag titles.
func (s *Service) SavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) {
if s == nil || s.channels == nil || userID == 0 {
return nil, domain.ErrChannelInvalid
}
if limit <= 0 {
return []domain.SavedReactionTag{}, nil
}
if limit > domain.MaxSavedReactionTags {
limit = domain.MaxSavedReactionTags
}
return s.channels.ListSavedReactionTags(ctx, userID, limit)
}
// UpdateSavedReactionTag stores the account-level custom title for one saved-message reaction tag.
func (s *Service) UpdateSavedReactionTag(ctx context.Context, userID int64, tag domain.SavedReactionTag) error {
if s == nil || s.channels == nil || userID == 0 {
return domain.ErrChannelInvalid
}
if tag.UserID == 0 {
tag.UserID = userID
}
if tag.UserID != userID || tag.Reaction.Type != domain.MessageReactionEmoji || tag.Reaction.Emoticon == "" {
return domain.ErrChannelInvalid
}
return s.channels.UpsertSavedReactionTag(ctx, tag)
}
// ReadMessageContents returns visible channel messages whose content-read state can be synced.
func (s *Service) ReadMessageContents(ctx context.Context, userID int64, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) {
if s == nil || s.channels == nil || userID == 0 {
@ -1445,6 +1458,30 @@ func (s *Service) DeleteMessages(ctx context.Context, userID int64, req domain.D
return s.channels.DeleteChannelMessages(ctx, req)
}
type moderationChannelMessageStore interface {
ModerationDeleteChannelMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error)
}
// ModerationDeleteMessages is the explicit server-authority deletion path used
// only by the durable moderation action worker. It never accepts a client
// identity and therefore cannot be reached by ordinary RPC permission checks.
func (s *Service) ModerationDeleteMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error) {
if s == nil || s.channels == nil || channelID <= 0 ||
len(ids) == 0 || len(ids) > domain.MaxDeleteMessageIDs {
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
}
for _, id := range ids {
if id <= 0 || id > domain.MaxMessageBoxID {
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
}
}
store, ok := s.channels.(moderationChannelMessageStore)
if !ok {
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
}
return store.ModerationDeleteChannelMessages(ctx, channelID, append([]int(nil), ids...), date)
}
// DeleteHistory clears the current user's history view or deletes a bounded channel history page for everyone.
func (s *Service) DeleteHistory(ctx context.Context, userID int64, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) {
if s == nil || s.channels == nil || userID == 0 {
@ -2233,6 +2270,21 @@ func (s *Service) FilterActiveMemberIDs(ctx context.Context, channelID int64, us
return s.channels.FilterActiveChannelMemberIDs(ctx, channelID, candidates)
}
// FilterMessageAudienceIDs keeps active members and currently authorized
// public-preview viewers from a bounded online candidate set. The store performs
// one batched authoritative check per bounded chunk so runtime session indexes
// never become an access-control source of truth.
func (s *Service) FilterMessageAudienceIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) {
if s == nil || s.channels == nil || channelID == 0 {
return nil, domain.ErrChannelInvalid
}
candidates := uniqueNonZero(userIDs)
if len(candidates) == 0 {
return nil, nil
}
return s.channels.FilterChannelMessageAudienceIDs(ctx, channelID, candidates)
}
// GetDifference returns channel-scoped pts difference.
func (s *Service) GetDifference(ctx context.Context, userID int64, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) {
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 {
@ -2249,7 +2301,8 @@ func (s *Service) GetDifference(ctx context.Context, userID int64, req domain.Ch
if err != nil {
return domain.ChannelDifference{}, err
}
return s.filterBotChannelDifference(ctx, userID, diff), nil
diff = s.filterBotChannelDifference(ctx, userID, diff)
return diff, nil
}
// ClearDanglingPinnedMessage 清除指向已删除消息的悬挂置顶值unpinAll 自愈)。

View file

@ -2904,7 +2904,10 @@ func TestListSendAsChannelsFiltersPostMessageRights(t *testing.T) {
func TestPublicChannelSearchAndResolveUsername(t *testing.T) {
ctx := context.Background()
service := NewService(memory.NewChannelStore())
channelStore := memory.NewChannelStore()
registry := memory.NewCollectibleUsernameStore()
channelStore.AttachUsernameRegistry(registry)
service := NewService(channelStore)
created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
Title: "CU Public Lab",
MemberUserIDs: []int64{1002},
@ -2945,6 +2948,53 @@ func TestPublicChannelSearchAndResolveUsername(t *testing.T) {
if err != nil || !found || resolved.ID != public.ID {
t.Fatalf("ResolvePublicUsername = %+v found %v err %v, want public channel", resolved, found, err)
}
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: public.ID}
if _, created, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{
Username: "nfc4",
Owner: peer,
Currency: domain.CollectibleCurrencyStars,
Amount: 1,
Actor: "test",
}); err != nil || !created {
t.Fatalf("mint channel collectible: created=%v err=%v", created, err)
}
resolved, found, err = service.ResolvePublicUsername(ctx, 1003, "@NFC4")
if err != nil || !found || resolved.ID != public.ID {
t.Fatalf("ResolvePublicUsername collectible = %+v found %v err %v, want public channel", resolved, found, err)
}
collectibleSearch, err := service.SearchPublicChannels(ctx, 1003, "nfc", 10)
if err != nil || len(collectibleSearch.Results) != 1 || collectibleSearch.Results[0].ID != public.ID {
t.Fatalf("collectible channel search = %+v err=%v, want public channel", collectibleSearch, err)
}
if _, err := service.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{
ChannelID: public.ID,
Username: "",
}); err != nil {
t.Fatalf("clear editable username: %v", err)
}
resolved, found, err = service.ResolvePublicUsername(ctx, 1003, "nfc4")
if err != nil || !found || resolved.ID != public.ID {
t.Fatalf("NFT-only ResolvePublicUsername = %+v found=%v err=%v", resolved, found, err)
}
if view, err := service.GetChannel(ctx, 1003, public.ID); err != nil || view.Channel.ID != public.ID {
t.Fatalf("NFT-only public preview = %+v err=%v", view, err)
}
if _, err := service.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{
ChannelID: public.ID,
Username: "cu_public_lab",
}); err != nil {
t.Fatalf("restore editable username: %v", err)
}
if changed, err := registry.SetUsernameActive(ctx, peer, "nfc4", false); err != nil || !changed {
t.Fatalf("deactivate channel collectible: changed=%v err=%v", changed, err)
}
if _, found, err := service.ResolvePublicUsername(ctx, 1003, "nfc4"); err != nil || found {
t.Fatalf("inactive collectible resolve found=%v err=%v, want hidden", found, err)
}
hiddenSearch, err := service.SearchPublicChannels(ctx, 1003, "nfc4", 10)
if err != nil || len(hiddenSearch.Results) != 0 {
t.Fatalf("inactive collectible search = %+v err=%v, want empty", hiddenSearch, err)
}
}
func TestPublicChannelPreviewAllowsNonMemberHistory(t *testing.T) {
@ -3014,12 +3064,37 @@ func TestPublicChannelPreviewAllowsNonMemberHistory(t *testing.T) {
if err != nil {
t.Fatalf("non-member GetDifference public preview: %v", err)
}
if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.Events) != 0 || len(diff.NewMessages) != 0 || len(diff.OtherUpdates) != 0 {
t.Fatalf("preview diff = %+v, want empty public preview difference at current pts", diff)
if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.Events) != 1 || len(diff.NewMessages) != 1 || len(diff.OtherUpdates) != 0 {
t.Fatalf("preview diff = %+v, want one public preview message at current pts", diff)
}
if diff.NewMessages[0].ID != sent.Message.ID || diff.NewMessages[0].Body != sent.Message.Body {
t.Fatalf("preview diff message = %+v, want sent public post %+v", diff.NewMessages[0], sent.Message)
}
if diff.Dialog.UnreadCount != 0 || diff.Dialog.ReadInboxMaxID < sent.Message.ID {
t.Fatalf("preview diff dialog = %+v, want read-only public preview dialog", diff.Dialog)
}
audience, err := service.FilterMessageAudienceIDs(ctx, public.ID, []int64{viewerID, ownerID, viewerID})
if err != nil || len(audience) != 2 {
t.Fatalf("public message audience = %v err %v, want owner and preview viewer", audience, err)
}
if _, err := service.JoinChannel(ctx, viewerID, public.ID, 21); err != nil {
t.Fatalf("JoinChannel public preview viewer: %v", err)
}
if _, err := service.LeaveChannel(ctx, viewerID, public.ID, 22); err != nil {
t.Fatalf("LeaveChannel public preview viewer: %v", err)
}
filtered, err := service.GetDifference(ctx, viewerID, domain.ChannelDifferenceRequest{
ChannelID: public.ID,
Pts: sent.Event.Pts,
Limit: 10,
})
if err != nil {
t.Fatalf("preview difference across participant events: %v", err)
}
if !filtered.Final || filtered.Pts != sent.Event.Pts || len(filtered.Events) != 0 ||
len(filtered.NewMessages) != 0 || len(filtered.OtherUpdates) != 0 {
t.Fatalf("difference after transient participant changes = %+v, want unchanged PTS", filtered)
}
private, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{
Title: "Private Preview",
@ -3047,6 +3122,9 @@ func TestPublicChannelPreviewAllowsNonMemberHistory(t *testing.T) {
if _, err := service.GetDifference(ctx, viewerID, domain.ChannelDifferenceRequest{ChannelID: public.ID, Pts: created.Event.Pts, Limit: 10}); !errors.Is(err, domain.ErrChannelUserBanned) {
t.Fatalf("banned public preview GetDifference err = %v, want ErrChannelUserBanned", err)
}
if audience, err := service.FilterMessageAudienceIDs(ctx, public.ID, []int64{viewerID}); err != nil || len(audience) != 0 {
t.Fatalf("banned public message audience = %v err %v, want empty", audience, err)
}
}
func TestChannelDifferenceStartsAtMemberAvailableMinPts(t *testing.T) {

View file

@ -0,0 +1,31 @@
package clienttelemetry
import (
"context"
"fmt"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
type Service struct {
store store.ClientTelemetryStore
}
func NewService(telemetryStore store.ClientTelemetryStore) *Service {
return &Service{store: telemetryStore}
}
func (s *Service) Record(ctx context.Context, userID int64, kind domain.ClientTelemetryKind, peer domain.Peer, subjectIDs []int64, payload any, createdAt time.Time) (domain.ClientTelemetryEvent, bool, error) {
if s == nil || s.store == nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("client telemetry store is not configured")
}
event, err := domain.NewClientTelemetryEvent(
userID, kind, peer, subjectIDs, payload, createdAt,
)
if err != nil {
return domain.ClientTelemetryEvent{}, false, err
}
return s.store.CreateClientTelemetry(ctx, event)
}

View file

@ -22,6 +22,7 @@ const maxCloseFriendsCount = 5000
type phonePrivacyService interface {
userprojection.PrivacyEvaluator
userprojection.BatchPrivacyEvaluator
AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error)
}
@ -123,19 +124,17 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con
return domain.Contact{}, ErrContactNameEmpty
}
// Android 的 contacts.addContact 会提交带 "+" 前缀的号码TDesktop 传纯数字或空),
// 归一成纯数字;无数字时落空串,走下方 target.Phone 回填。
// 归一成纯数字。空串表示客户端只按 user id 添加联系人,必须原样保留;
// TL 明确允许省略号码,服务端不得从 target 全局资料反向补出隐私号码。
input.Phone = digitsOnly(input.Phone)
if s.users != nil {
target, found, err := s.users.ByID(ctx, input.ContactUserID)
_, found, err := s.users.ByID(ctx, input.ContactUserID)
if err != nil {
return domain.Contact{}, err
}
if !found {
return domain.Contact{}, ErrContactIDInvalid
}
if input.Phone == "" {
input.Phone = target.Phone
}
}
contact, err := s.contacts.Upsert(ctx, userID, input)
if err != nil {
@ -150,7 +149,9 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con
return s.projectContact(ctx, userID, contact)
}
// AcceptContact shares the current user's phone/profile with an existing one-way contact.
// AcceptContact creates the reciprocal contact for an existing one-way contact.
// Phone visibility remains governed exclusively by account privacy rules; this
// RPC has no protocol flag authorizing a hidden phone-number exception.
func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64) (domain.Contact, error) {
if s == nil || s.contacts == nil || s.users == nil || userID == 0 || contactUserID == 0 || contactUserID == userID {
return domain.Contact{}, ErrContactIDInvalid
@ -189,11 +190,6 @@ func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64
return domain.Contact{}, err
}
s.InvalidateViewers(userID, contactUserID)
if s.privacy != nil {
if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, contactUserID); err != nil {
return domain.Contact{}, err
}
}
contact, found, err := s.contacts.Get(ctx, userID, target.ID)
if err != nil {
return domain.Contact{}, err
@ -235,6 +231,30 @@ func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []dom
if err != nil {
return domain.ImportContactsResult{}, err
}
if s.privacy != nil && len(targets) > 0 {
targetIDs := make([]int64, 0, len(targets))
for _, target := range targets {
if target.ID != 0 && target.ID != userID {
targetIDs = append(targetIDs, target.ID)
}
}
visibility, err := s.privacy.CanSeeBatch(
ctx,
targetIDs,
userID,
[]domain.PrivacyKey{domain.PrivacyKeyAddedByPhone},
)
if err != nil {
return domain.ImportContactsResult{}, err
}
allowed := targets[:0]
for _, target := range targets {
if visibility[target.ID][domain.PrivacyKeyAddedByPhone] {
allowed = append(allowed, target)
}
}
targets = allowed
}
byPhone := make(map[string]domain.User, len(targets))
for _, target := range targets {
if target.Phone != "" {
@ -310,10 +330,52 @@ func (s *Service) Search(ctx context.Context, userID int64, query string, limit
if limit <= 0 || limit > maxSearchLimit {
limit = maxSearchLimit
}
res, err := s.users.Search(ctx, userID, query, normalizePhone(query), limit)
phoneQuery := ""
if isPhoneSearchQuery(query) {
phoneQuery = normalizePhone(query)
}
res, err := s.users.Search(ctx, userID, query, phoneQuery, limit)
if err != nil {
return domain.UserSearchResult{}, err
}
if s.privacy != nil && phoneQuery != "" && len(res.MyResults)+len(res.Results) > 0 {
targetIDs := make([]int64, 0, len(res.MyResults)+len(res.Results))
for _, target := range res.MyResults {
if target.ID != 0 && target.ID != userID {
targetIDs = append(targetIDs, target.ID)
}
}
for _, target := range res.Results {
if target.ID != 0 && target.ID != userID {
targetIDs = append(targetIDs, target.ID)
}
}
visibility, err := s.privacy.CanSeeBatch(
ctx,
targetIDs,
userID,
[]domain.PrivacyKey{domain.PrivacyKeyAddedByPhone},
)
if err != nil {
return domain.UserSearchResult{}, err
}
knownContacts := map[int64]domain.Contact{}
if s.contacts != nil && len(targetIDs) > 0 {
knownContacts, err = s.contacts.GetMany(ctx, userID, targetIDs)
if err != nil {
return domain.UserSearchResult{}, err
}
}
allowed := func(target domain.User) bool {
if visibility[target.ID][domain.PrivacyKeyAddedByPhone] {
return true
}
contact, found := knownContacts[target.ID]
return found && contact.Phone != "" && strings.HasPrefix(contact.Phone, phoneQuery)
}
res.MyResults = filterSearchUsers(res.MyResults, allowed)
res.Results = filterSearchUsers(res.Results, allowed)
}
return s.projectSearchResult(ctx, userID, res)
}
@ -452,11 +514,14 @@ func (s *Service) peerCanSeeCurrentUserPhone(ctx context.Context, ownerUserID, v
if s.contacts == nil {
return false, nil
}
_, found, err := s.contacts.Get(ctx, viewerUserID, ownerUserID)
contact, found, err := s.contacts.Get(ctx, viewerUserID, ownerUserID)
if err != nil {
return false, err
}
return found, nil
// Merely adding the owner by user id does not mean the viewer knows the
// owner's phone. Only a non-empty owner-scoped contact phone can suppress the
// "share my phone" prompt when PhoneNumber privacy itself denies visibility.
return found && contact.Phone != "", nil
}
// BlockContact adds peer to the current user's blocklist.
@ -506,7 +571,22 @@ func (s *Service) GetBlocked(ctx context.Context, userID int64, offset, limit in
if limit <= 0 || limit > 100 {
limit = 100
}
return s.contacts.ListBlocked(ctx, userID, offset, limit)
list, err := s.contacts.ListBlocked(ctx, userID, offset, limit)
if err != nil || len(list.Blocked) == 0 || s.projector == nil {
return list, err
}
users := make([]domain.User, len(list.Blocked))
for i := range list.Blocked {
users[i] = list.Blocked[i].User
}
projected, err := s.projector.ForViewer(ctx, userID, users)
if err != nil {
return domain.BlockedContactList{}, err
}
for i := range list.Blocked {
list.Blocked[i].User = projected[i]
}
return list, nil
}
func (s *Service) ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error) {
@ -590,6 +670,34 @@ func normalizePhone(phone string) string {
return phone
}
func isPhoneSearchQuery(query string) bool {
query = strings.TrimSpace(query)
if query == "" {
return false
}
hasDigit := false
for _, r := range query {
switch {
case r >= '0' && r <= '9':
hasDigit = true
case r == '+', r == ' ', r == '-', r == '(', r == ')':
default:
return false
}
}
return hasDigit
}
func filterSearchUsers(users []domain.User, keep func(domain.User) bool) []domain.User {
out := users[:0]
for _, user := range users {
if keep(user) {
out = append(out, user)
}
}
return out
}
func normalizeCloseFriendIDs(userID int64, ids []int64) []int64 {
out := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))

View file

@ -168,6 +168,151 @@ func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) {
}
}
func TestAddContactWithoutPhoneDoesNotBackfillTargetPhone(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
contactsStore := memory.NewContactStore()
owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
target, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Target"})
if err != nil {
t.Fatalf("create target: %v", err)
}
privacySvc := privacyapp.NewService(memory.NewPrivacyStore(), contactsStore)
svc := NewService(contactsStore, users).Configure(WithPrivacyEvaluator(privacySvc))
contact, err := svc.AddContact(ctx, owner.ID, domain.ContactInput{
ContactUserID: target.ID,
FirstName: "Saved",
Phone: "",
})
if err != nil {
t.Fatalf("AddContact: %v", err)
}
if contact.Phone != "" || contact.User.Phone != "" {
t.Fatalf("projected contact phone = local %q user %q, want both empty", contact.Phone, contact.User.Phone)
}
stored, found, err := contactsStore.Get(ctx, owner.ID, target.ID)
if err != nil || !found {
t.Fatalf("stored contact found=%v err=%v", found, err)
}
if stored.Phone != "" || stored.User.Phone != "" {
t.Fatalf("stored contact phone = local %q user %q, want both empty", stored.Phone, stored.User.Phone)
}
}
func TestImportContactsHonorsAddedByPhoneInOneBatch(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
contactsStore := memory.NewContactStore()
owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
target, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Target"})
if err != nil {
t.Fatalf("create target: %v", err)
}
privacySvc := privacyapp.NewService(memory.NewPrivacyStore(), contactsStore)
if _, err := privacySvc.SetRules(ctx, target.ID, domain.PrivacyKeyAddedByPhone, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
t.Fatalf("set AddedByPhone: %v", err)
}
svc := NewService(contactsStore, users).Configure(WithPrivacyEvaluator(privacySvc))
input := []domain.ContactInput{{ClientID: 1, Phone: target.Phone, FirstName: "Saved"}}
hidden, err := svc.ImportContacts(ctx, owner.ID, input)
if err != nil {
t.Fatalf("ImportContacts hidden: %v", err)
}
if len(hidden.Imported) != 0 || len(hidden.Contacts) != 0 {
t.Fatalf("hidden import = %+v, want no resolved target", hidden)
}
if _, err := contactsStore.Upsert(ctx, target.ID, domain.ContactInput{
ContactUserID: owner.ID,
FirstName: owner.FirstName,
}); err != nil {
t.Fatalf("target add owner: %v", err)
}
visible, err := svc.ImportContacts(ctx, owner.ID, input)
if err != nil {
t.Fatalf("ImportContacts visible: %v", err)
}
if len(visible.Imported) != 1 || visible.Imported[0].UserID != target.ID || len(visible.Contacts) != 1 {
t.Fatalf("visible import = %+v, want target %d", visible, target.ID)
}
}
func TestPhoneSearchHonorsAddedByPhone(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
contactsStore := memory.NewContactStore()
owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
target, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Target"})
if err != nil {
t.Fatalf("create target: %v", err)
}
privacySvc := privacyapp.NewService(memory.NewPrivacyStore(), contactsStore)
if _, err := privacySvc.SetRules(ctx, target.ID, domain.PrivacyKeyAddedByPhone, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
t.Fatalf("set AddedByPhone: %v", err)
}
svc := NewService(contactsStore, users).Configure(WithPrivacyEvaluator(privacySvc))
hidden, err := svc.Search(ctx, owner.ID, "+1 (555) 123-4567", 50)
if err != nil {
t.Fatalf("Search hidden: %v", err)
}
if len(hidden.Results) != 0 {
t.Fatalf("hidden phone search results = %+v, want empty", hidden.Results)
}
if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{
ContactUserID: target.ID,
FirstName: target.FirstName,
Phone: "",
}); err != nil {
t.Fatalf("owner add target without phone: %v", err)
}
stillHidden, err := svc.Search(ctx, owner.ID, target.Phone, 50)
if err != nil {
t.Fatalf("Search owner-only contact: %v", err)
}
if len(stillHidden.MyResults)+len(stillHidden.Results) != 0 {
t.Fatalf("owner-only empty-phone contact search = %+v, want hidden", stillHidden)
}
if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{
ContactUserID: target.ID,
FirstName: target.FirstName,
Phone: target.Phone,
}); err != nil {
t.Fatalf("owner save target phone: %v", err)
}
knownLocally, err := svc.Search(ctx, owner.ID, target.Phone, 50)
if err != nil {
t.Fatalf("Search locally known phone: %v", err)
}
if len(knownLocally.MyResults)+len(knownLocally.Results) != 1 {
t.Fatalf("locally known phone search = %+v, want one local contact", knownLocally)
}
if _, err := contactsStore.Upsert(ctx, target.ID, domain.ContactInput{
ContactUserID: owner.ID,
FirstName: owner.FirstName,
}); err != nil {
t.Fatalf("target add owner: %v", err)
}
visible, err := svc.Search(ctx, owner.ID, target.Phone, 50)
if err != nil {
t.Fatalf("Search visible: %v", err)
}
if len(visible.Results) != 1 || visible.Results[0].ID != target.ID {
t.Fatalf("visible phone search = %+v, want target %d", visible.Results, target.ID)
}
}
func TestGetContactsProjectsCurrentProfilePhoto(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
@ -387,8 +532,8 @@ func TestAddContactNormalizesPhoneToDigits(t *testing.T) {
if err != nil {
t.Fatalf("AddContact digitless phone: %v", err)
}
if emptied.Phone != bob.Phone {
t.Fatalf("digitless phone contact = %q, want fallback to target phone %q", emptied.Phone, bob.Phone)
if emptied.Phone != "" || emptied.User.Phone != "" {
t.Fatalf("digitless phone contact = local %q user %q, want empty without account-phone fallback", emptied.Phone, emptied.User.Phone)
}
}
@ -499,6 +644,46 @@ func TestAcceptContactRequiresExistingContactRequest(t *testing.T) {
}
}
func TestSearchFindsOnlyActiveCollectibleUsernames(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
registry := memory.NewCollectibleUsernameStore()
users.AttachUsernameRegistry(registry)
viewer, err := users.Create(ctx, domain.User{Phone: "15550000101", FirstName: "Viewer"})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
target, err := users.Create(ctx, domain.User{Phone: "15550000102", FirstName: "Unrelated", Username: "target_slot"})
if err != nil {
t.Fatalf("create target: %v", err)
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: target.ID}
if _, err := registry.SetEditableUsername(ctx, peer, target.Username); err != nil {
t.Fatalf("seed editable username: %v", err)
}
if _, created, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{
Username: "nft4",
Owner: peer,
Currency: domain.CollectibleCurrencyStars,
Amount: 1,
Actor: "test",
}); err != nil || !created {
t.Fatalf("mint collectible: created=%v err=%v", created, err)
}
svc := NewService(memory.NewContactStore(), users)
found, err := svc.Search(ctx, viewer.ID, "@NFT4", 10)
if err != nil || len(found.Results) != 1 || found.Results[0].ID != target.ID {
t.Fatalf("search active collectible = %+v err=%v, want target", found, err)
}
if changed, err := registry.SetUsernameActive(ctx, peer, "nft4", false); err != nil || !changed {
t.Fatalf("deactivate collectible: changed=%v err=%v", changed, err)
}
hidden, err := svc.Search(ctx, viewer.ID, "nft4", 10)
if err != nil || len(hidden.Results) != 0 || len(hidden.MyResults) != 0 {
t.Fatalf("search inactive collectible = %+v err=%v, want empty", hidden, err)
}
}
func contactByID(t *testing.T, list domain.ContactList, id int64) domain.Contact {
t.Helper()
for _, contact := range list.Contacts {

View file

@ -342,11 +342,24 @@ func (s *Service) appendMissingChannelPeerPreviews(ctx context.Context, userID i
if !ok || view.Forbidden {
continue
}
if view.Self.Status == domain.ChannelMemberLeft && !view.Self.Guest {
// A visible public preview still needs one transient dialog so a
// client can finish bootstrapping the requested peer. Keep the top
// message and read state at zero: the response is an admission
// token, not a persisted/chat-list dialog snapshot. In particular,
// clients that persist non-zero top dialogs will instead continue
// with messages.getHistory, which is the authoritative preview
// history path.
out.Dialogs = append(out.Dialogs, publicChannelPreviewBootstrapDialog(view))
out.Channels = append(out.Channels, view.Channel)
out.Count++
present[channelID] = struct{}{}
continue
}
// Linked discussion guests need a transient peer-dialog snapshot so
// TDesktop can finish materializing the comments History after
// requestSelf. ChannelLeft keeps the snapshot out of the main chat list,
// and Guest guarantees this path never turns an ordinary public preview
// into a dialog.
// while Guest authorizes the target's real top-message snapshot.
if view.Self.Status != domain.ChannelMemberActive && !view.Self.Guest {
continue
}
@ -378,6 +391,14 @@ func (s *Service) appendMissingChannelPeerPreviews(ctx context.Context, userID i
return out, nil
}
func publicChannelPreviewBootstrapDialog(view domain.ChannelView) domain.Dialog {
return domain.Dialog{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: view.Channel.ID},
ChannelLeft: true,
Pts: view.Channel.Pts,
}
}
func isChannelPreviewAccessError(err error) bool {
return errors.Is(err, domain.ErrChannelPrivate) ||
errors.Is(err, domain.ErrChannelUserBanned) ||
@ -387,22 +408,24 @@ func isChannelPreviewAccessError(err error) bool {
func dialogFromChannelView(view domain.ChannelView) domain.Dialog {
dialog := view.Dialog
return domain.Dialog{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
ChannelLeft: view.Self.Status == domain.ChannelMemberLeft,
FolderID: dialog.FolderID,
TopMessage: dialog.TopMessageID,
TopMessageDate: dialog.TopMessageDate,
ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount,
UnreadMentions: dialog.UnreadMentions,
UnreadReactions: dialog.UnreadReactions,
Pinned: dialog.Pinned,
PinnedOrder: dialog.PinnedOrder,
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
Pts: view.Channel.Pts,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
ChannelLeft: view.Self.Status == domain.ChannelMemberLeft,
FolderID: dialog.FolderID,
TopMessage: dialog.TopMessageID,
TopMessageDate: dialog.TopMessageDate,
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount,
UnreadMentions: dialog.UnreadMentions,
UnreadReactions: dialog.UnreadReactions,
Pinned: dialog.Pinned,
PinnedOrder: dialog.PinnedOrder,
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
Pts: view.Channel.Pts,
}
}

View file

@ -60,6 +60,7 @@ type countingDialogChannelStore struct {
getChannelCalls int
getChannelsCalls int
getChannelDialogsCalls int
listHistoryCalls int
}
func (s *countingDialogChannelStore) GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) {
@ -77,6 +78,11 @@ func (s *countingDialogChannelStore) GetChannelDialogs(ctx context.Context, view
return s.ChannelStore.GetChannelDialogs(ctx, viewerUserID, channelIDs)
}
func (s *countingDialogChannelStore) ListChannelHistory(ctx context.Context, viewerUserID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) {
s.listHistoryCalls++
return s.ChannelStore.ListChannelHistory(ctx, viewerUserID, filter)
}
func TestGetDialogsHashUsesWarmStableHashCacheAndInvalidatesOnWrite(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1001
@ -695,7 +701,7 @@ func TestGetPeerDialogsRejectsHugeVector(t *testing.T) {
}
}
func TestGetPeerDialogsSkipsPublicChannelPreviewForNonMember(t *testing.T) {
func TestGetPeerDialogsReturnsZeroTopBootstrapForPublicChannelPreview(t *testing.T) {
ctx := context.Background()
channelStore := memory.NewChannelStore()
channels := appchannels.NewService(channelStore)
@ -716,12 +722,13 @@ func TestGetPeerDialogsSkipsPublicChannelPreviewForNonMember(t *testing.T) {
}); err != nil {
t.Fatalf("UpdateUsername public: %v", err)
}
if _, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
sent, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
ChannelID: public.Channel.ID,
RandomID: 99,
Message: "public peer dialog top",
Date: 1700002010,
}); err != nil {
})
if err != nil {
t.Fatalf("SendMessage public: %v", err)
}
private, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
@ -740,8 +747,19 @@ func TestGetPeerDialogsSkipsPublicChannelPreviewForNonMember(t *testing.T) {
if err != nil {
t.Fatalf("GetPeerDialogs public preview: %v", err)
}
if len(list.Dialogs) != 0 || len(list.ChannelMessages) != 0 || len(list.Channels) != 0 || list.Count != 0 {
t.Fatalf("peer dialogs = %+v, want no materialized public preview dialog", list)
if len(list.Dialogs) != 1 || len(list.ChannelMessages) != 0 || len(list.Channels) != 1 || list.Count != 1 {
t.Fatalf("peer dialogs = %+v, want one zero-top public preview bootstrap", list)
}
dialog := list.Dialogs[0]
if dialog.Peer.Type != domain.PeerTypeChannel || dialog.Peer.ID != public.Channel.ID ||
!dialog.ChannelLeft || dialog.TopMessage != 0 || dialog.TopMessageDate != 0 ||
dialog.ReadInboxMaxID != 0 || dialog.ReadOutboxMaxID != 0 ||
dialog.UnreadCount != 0 || dialog.UnreadMentions != 0 ||
dialog.UnreadReactions != 0 || dialog.Pts != sent.Event.Pts {
t.Fatalf("public preview bootstrap dialog = %+v", dialog)
}
if list.Channels[0].ID != public.Channel.ID {
t.Fatalf("public preview channels = %+v, want channel %d", list.Channels, public.Channel.ID)
}
}
@ -810,6 +828,7 @@ func TestGetPeerDialogsBatchesMissingChannelVisibilityChecks(t *testing.T) {
channelStore.getChannelCalls = 0
channelStore.getChannelsCalls = 0
channelStore.listHistoryCalls = 0
list, err := dialogs.GetPeerDialogs(ctx, 1002, []domain.Peer{
{Type: domain.PeerTypeChannel, ID: first.Channel.ID},
{Type: domain.PeerTypeChannel, ID: private.Channel.ID},
@ -822,8 +841,16 @@ func TestGetPeerDialogsBatchesMissingChannelVisibilityChecks(t *testing.T) {
if channelStore.getChannelsCalls != 1 || channelStore.getChannelCalls != 0 {
t.Fatalf("visibility channel calls: GetChannels=%d GetChannel=%d, want one batch call only", channelStore.getChannelsCalls, channelStore.getChannelCalls)
}
if len(list.Dialogs) != 0 || len(list.ChannelMessages) != 0 || len(list.Channels) != 0 || list.Count != 0 {
t.Fatalf("peer dialogs = %+v, want no public preview dialogs", list)
if channelStore.listHistoryCalls != 0 {
t.Fatalf("public preview history calls = %d, want zero", channelStore.listHistoryCalls)
}
if len(list.Dialogs) != 2 || len(list.ChannelMessages) != 0 || len(list.Channels) != 2 || list.Count != 2 {
t.Fatalf("peer dialogs = %+v, want two deduplicated zero-top public previews", list)
}
for _, dialog := range list.Dialogs {
if !dialog.ChannelLeft || dialog.TopMessage != 0 || dialog.ReadInboxMaxID != 0 || dialog.ReadOutboxMaxID != 0 {
t.Fatalf("public preview bootstrap dialog = %+v", dialog)
}
}
}

View file

@ -0,0 +1,575 @@
package files
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash"
"image/jpeg"
"io"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"time"
"telesrv/internal/domain"
"go.uber.org/zap"
)
const (
premiumPromoSeedStateKey = "files.premium_promo"
premiumPromoSeedStateVersion = "premium-promo-v1"
premiumPromoManifestName = "premium_promo.json"
premiumPromoMaxVideos = 128
premiumPromoMaxVideoSize = int64(64 << 20)
premiumPromoMaxThumbSize = int64(4 << 20)
premiumPromoMaxTotalSize = int64(512 << 20)
)
// PremiumPromoSeedStats reports the startup import outcome. Videos is the
// number of usable catalog entries; Blobs counts main/thumbnail blobs written
// during this run.
type PremiumPromoSeedStats struct {
Videos int
Blobs int
Skipped bool
}
type premiumPromoSeedJSON struct {
APICall string `json:"api_call"`
StatusText string `json:"status_text"`
VideoSections []string `json:"video_sections"`
Videos []seedDocumentJSON `json:"videos"`
PeriodOptions []json.RawMessage `json:"period_options"`
}
type premiumPromoSeedVideo struct {
section string
document domain.Document
mainPath string
thumbPath string
thumbType string
}
// SeedPremiumPromo imports the exported promo videos into the ordinary
// document/file_blob storage. A missing root is an optional-resource fallback;
// once the directory exists, malformed or incomplete data is a startup error.
func (s *Service) SeedPremiumPromo(ctx context.Context, root string) (PremiumPromoSeedStats, error) {
var stats PremiumPromoSeedStats
if root == "" {
s.clearPremiumPromo()
stats.Skipped = true
s.warnPremiumPromoMissing(root, errors.New("seed dir is empty"))
return stats, nil
}
info, err := os.Stat(root)
if err != nil {
if os.IsNotExist(err) {
s.clearPremiumPromo()
stats.Skipped = true
s.warnPremiumPromoMissing(root, err)
return stats, nil
}
return stats, fmt.Errorf("stat premium promo seed dir %q: %w", root, err)
}
if !info.IsDir() {
return stats, fmt.Errorf("premium promo seed path %q is not a directory", root)
}
manifestPath := filepath.Join(root, premiumPromoManifestName)
raw, err := os.ReadFile(manifestPath)
if err != nil {
return stats, fmt.Errorf("read premium promo manifest %q: %w", manifestPath, err)
}
videos, err := parsePremiumPromoSeed(root, raw)
if err != nil {
return stats, fmt.Errorf("validate premium promo seed: %w", err)
}
for i := range videos {
videos[i].document.DCID = s.dc
}
stats.Videos = len(videos)
stateHash, err := premiumPromoSeedHash(raw, videos, s.dc)
if err != nil {
return stats, fmt.Errorf("hash premium promo seed: %w", err)
}
stateMatches, err := s.seedStateMatches(ctx, premiumPromoSeedStateKey, stateHash)
if err != nil {
return stats, fmt.Errorf("read premium promo seed state: %w", err)
}
if stateMatches {
if catalog, ready, err := s.loadPremiumPromoCatalog(ctx, videos); err != nil {
return stats, fmt.Errorf("verify premium promo catalog: %w", err)
} else if ready {
s.setPremiumPromoCatalog(catalog)
stats.Skipped = true
return stats, nil
}
}
for _, video := range videos {
existing, found, err := s.media.GetDocument(ctx, video.document.ID)
if err != nil {
return stats, fmt.Errorf("read premium promo document %d: %w", video.document.ID, err)
}
if found && existing.AccessHash != video.document.AccessHash {
return stats, fmt.Errorf(
"premium promo document %d collides with access_hash %d (seed has %d)",
video.document.ID,
existing.AccessHash,
video.document.AccessHash,
)
}
forceBlobWrite := !stateMatches
if wrote, err := s.putPremiumPromoBlob(
ctx,
fmt.Sprintf("doc:%d", video.document.ID),
video.mainPath,
video.document.MimeType,
video.document.Size,
forceBlobWrite,
); err != nil {
return stats, fmt.Errorf("import premium promo video %d: %w", video.document.ID, err)
} else if wrote {
stats.Blobs++
}
thumb := video.document.Thumbs[0]
if wrote, err := s.putPremiumPromoBlob(
ctx,
fmt.Sprintf("doc:%d:%s", video.document.ID, video.thumbType),
video.thumbPath,
"image/jpeg",
int64(thumb.Size),
forceBlobWrite,
); err != nil {
return stats, fmt.Errorf("import premium promo thumbnail %d: %w", video.document.ID, err)
} else if wrote {
stats.Blobs++
}
if err := s.media.PutDocument(ctx, video.document); err != nil {
return stats, fmt.Errorf("store premium promo document %d: %w", video.document.ID, err)
}
}
catalog, ready, err := s.loadPremiumPromoCatalog(ctx, videos)
if err != nil {
return stats, fmt.Errorf("verify imported premium promo catalog: %w", err)
}
if !ready {
return stats, errors.New("premium promo catalog is incomplete after import")
}
if err := s.putSeedState(ctx, premiumPromoSeedStateKey, stateHash); err != nil {
return stats, fmt.Errorf("record premium promo seed state: %w", err)
}
s.setPremiumPromoCatalog(catalog)
return stats, nil
}
// PremiumPromo returns a deep copy so callers cannot mutate the startup
// catalog or another request's response.
func (s *Service) PremiumPromo(_ context.Context) (domain.PremiumPromoCatalog, bool, error) {
s.premiumPromoMu.RLock()
defer s.premiumPromoMu.RUnlock()
if !s.premiumPromoReady {
return domain.PremiumPromoCatalog{}, false, nil
}
return domain.PremiumPromoCatalog{
VideoSections: append([]string(nil), s.premiumPromo.VideoSections...),
Videos: copyDocuments(s.premiumPromo.Videos),
}, true, nil
}
func (s *Service) setPremiumPromoCatalog(catalog domain.PremiumPromoCatalog) {
s.premiumPromoMu.Lock()
defer s.premiumPromoMu.Unlock()
s.premiumPromo = domain.PremiumPromoCatalog{
VideoSections: append([]string(nil), catalog.VideoSections...),
Videos: copyDocuments(catalog.Videos),
}
s.premiumPromoReady = true
}
func (s *Service) clearPremiumPromo() {
s.premiumPromoMu.Lock()
defer s.premiumPromoMu.Unlock()
s.premiumPromo = domain.PremiumPromoCatalog{}
s.premiumPromoReady = false
}
func (s *Service) warnPremiumPromoMissing(root string, err error) {
if s.log == nil {
return
}
s.log.Warn(
"Premium promo seed 目录不存在help.getPremiumPromo 将返回无视频兼容响应",
zap.String("dir", root),
zap.Error(err),
)
}
func parsePremiumPromoSeed(root string, raw []byte) ([]premiumPromoSeedVideo, error) {
var parsed premiumPromoSeedJSON
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, fmt.Errorf("parse %s: %w", premiumPromoManifestName, err)
}
if parsed.APICall != "help.getPremiumPromo" {
return nil, fmt.Errorf("api_call = %q, want help.getPremiumPromo", parsed.APICall)
}
if len(parsed.VideoSections) == 0 || len(parsed.VideoSections) > premiumPromoMaxVideos {
return nil, fmt.Errorf("video_sections count %d is outside 1..%d", len(parsed.VideoSections), premiumPromoMaxVideos)
}
if len(parsed.VideoSections) != len(parsed.Videos) {
return nil, fmt.Errorf("video_sections count %d does not match videos count %d", len(parsed.VideoSections), len(parsed.Videos))
}
seenSections := make(map[string]struct{}, len(parsed.VideoSections))
seenDocuments := make(map[int64]struct{}, len(parsed.Videos))
out := make([]premiumPromoSeedVideo, 0, len(parsed.Videos))
var totalSize int64
for i, dj := range parsed.Videos {
section := parsed.VideoSections[i]
if !validPremiumPromoSection(section) {
return nil, fmt.Errorf("video_sections[%d] %q is invalid", i, section)
}
if _, exists := seenSections[section]; exists {
return nil, fmt.Errorf("duplicate video section %q", section)
}
seenSections[section] = struct{}{}
if dj.ID <= 0 {
return nil, fmt.Errorf("videos[%d].id must be positive", i)
}
if _, exists := seenDocuments[dj.ID]; exists {
return nil, fmt.Errorf("duplicate video document id %d", dj.ID)
}
seenDocuments[dj.ID] = struct{}{}
if dj.AccessHash == 0 {
return nil, fmt.Errorf("videos[%d].access_hash must be non-zero", i)
}
fileReference, err := hex.DecodeString(dj.FileReference)
if err != nil || len(fileReference) == 0 {
return nil, fmt.Errorf("videos[%d].file_reference is not non-empty hex", i)
}
date, err := time.Parse(time.RFC3339, dj.Date)
if err != nil || date.Unix() < 0 || date.Unix() > 1<<31-1 {
return nil, fmt.Errorf("videos[%d].date %q is outside TL int date range", i, dj.Date)
}
if dj.MimeType != "video/mp4" {
return nil, fmt.Errorf("videos[%d].mime_type = %q, want video/mp4", i, dj.MimeType)
}
if dj.Size <= 0 || dj.Size > premiumPromoMaxVideoSize {
return nil, fmt.Errorf("videos[%d].size %d is outside 1..%d", i, dj.Size, premiumPromoMaxVideoSize)
}
if err := validatePremiumPromoAttributes(i, dj.Attributes); err != nil {
return nil, err
}
mainPath := filepath.Join(root, "documents", fmt.Sprintf("%d.mp4", dj.ID))
mainInfo, err := regularFileInfo(mainPath)
if err != nil {
return nil, fmt.Errorf("videos[%d] main file: %w", i, err)
}
if mainInfo.Size() != dj.Size {
return nil, fmt.Errorf("videos[%d] main file size %d does not match manifest %d", i, mainInfo.Size(), dj.Size)
}
if err := validateMP4Header(mainPath); err != nil {
return nil, fmt.Errorf("videos[%d] main file: %w", i, err)
}
thumbPath := filepath.Join(root, "thumbs", fmt.Sprintf("%d.jpg", dj.ID))
thumbInfo, err := regularFileInfo(thumbPath)
if err != nil {
return nil, fmt.Errorf("videos[%d] thumbnail: %w", i, err)
}
if thumbInfo.Size() <= 0 || thumbInfo.Size() > premiumPromoMaxThumbSize {
return nil, fmt.Errorf("videos[%d] thumbnail size %d is outside 1..%d", i, thumbInfo.Size(), premiumPromoMaxThumbSize)
}
w, h, err := jpegDimensions(thumbPath)
if err != nil {
return nil, fmt.Errorf("videos[%d] thumbnail: %w", i, err)
}
thumbType := premiumPromoThumbType(w, h)
attributes := seedDocumentAttributes(dj.Attributes)
document := domain.Document{
ID: dj.ID,
AccessHash: dj.AccessHash,
FileReference: fileReference,
Date: int(date.Unix()),
MimeType: dj.MimeType,
Size: dj.Size,
DCID: 0, // overwritten with the canonical server DC by the caller
Attributes: attributes,
Thumbs: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindDefault,
Type: thumbType,
W: w,
H: h,
Size: int(thumbInfo.Size()),
}},
}
out = append(out, premiumPromoSeedVideo{
section: section,
document: document,
mainPath: mainPath,
thumbPath: thumbPath,
thumbType: thumbType,
})
totalSize += mainInfo.Size() + thumbInfo.Size()
if totalSize > premiumPromoMaxTotalSize {
return nil, fmt.Errorf("premium promo source bytes %d exceed limit %d", totalSize, premiumPromoMaxTotalSize)
}
}
return out, nil
}
func validatePremiumPromoAttributes(index int, attrs []seedAttrJSON) error {
var filename, video, animated int
for j, attr := range attrs {
switch attr.Type {
case "DocumentAttributeFilename":
filename++
if strings.TrimSpace(attr.FileName) == "" {
return fmt.Errorf("videos[%d].attributes[%d] has empty file_name", index, j)
}
case "DocumentAttributeVideo":
video++
if attr.W <= 0 || attr.W > 16384 || attr.H <= 0 || attr.H > 16384 {
return fmt.Errorf("videos[%d].attributes[%d] has invalid video dimensions %dx%d", index, j, attr.W, attr.H)
}
if attr.Duration <= 0 || attr.Duration > 3600 {
return fmt.Errorf("videos[%d].attributes[%d] has invalid duration %v", index, j, attr.Duration)
}
case "DocumentAttributeAnimated":
animated++
default:
return fmt.Errorf("videos[%d].attributes[%d] has unsupported type %q", index, j, attr.Type)
}
}
if filename != 1 || video != 1 || animated > 1 {
return fmt.Errorf("videos[%d] must contain exactly one filename/video and at most one animated attribute", index)
}
return nil
}
func validPremiumPromoSection(section string) bool {
if section == "" || len(section) > 64 {
return false
}
for _, r := range section {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' {
return false
}
}
return true
}
func regularFileInfo(path string) (os.FileInfo, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("%q is not a regular file", path)
}
return info, nil
}
func validateMP4Header(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
header := make([]byte, 12)
if _, err := io.ReadFull(f, header); err != nil {
return fmt.Errorf("read MP4 header: %w", err)
}
if string(header[4:8]) != "ftyp" {
return errors.New("missing ISO BMFF ftyp header")
}
return nil
}
func jpegDimensions(path string) (int, int, error) {
f, err := os.Open(path)
if err != nil {
return 0, 0, err
}
defer f.Close()
cfg, err := jpeg.DecodeConfig(f)
if err != nil {
return 0, 0, fmt.Errorf("decode JPEG config: %w", err)
}
if cfg.Width <= 0 || cfg.Width > 16384 || cfg.Height <= 0 || cfg.Height > 16384 {
return 0, 0, fmt.Errorf("invalid JPEG dimensions %dx%d", cfg.Width, cfg.Height)
}
return cfg.Width, cfg.Height, nil
}
func premiumPromoThumbType(w, h int) string {
maxDimension := w
if h > maxDimension {
maxDimension = h
}
switch {
case maxDimension <= 100:
return "s"
case maxDimension <= 320:
return "m"
case maxDimension <= 800:
return "x"
case maxDimension <= 1280:
return "y"
default:
return "w"
}
}
func premiumPromoSeedHash(raw []byte, videos []premiumPromoSeedVideo, dc int) (string, error) {
return seedStateHash(func(h hash.Hash) error {
writeSeedStateHeader(h, premiumPromoSeedStateVersion, dc)
if _, err := h.Write(raw); err != nil {
return err
}
paths := make([]string, 0, len(videos)*2)
for _, video := range videos {
paths = append(paths, video.mainPath, video.thumbPath)
}
sort.Strings(paths)
for _, path := range paths {
info, err := regularFileInfo(path)
if err != nil {
return err
}
rel := filepath.Join(filepath.Base(filepath.Dir(path)), filepath.Base(path))
_, _ = fmt.Fprintf(h, "\nfile=%s\x00size=%d\x00mtime=%d", filepath.ToSlash(rel), info.Size(), info.ModTime().UnixNano())
}
return nil
})
}
func (s *Service) loadPremiumPromoCatalog(ctx context.Context, videos []premiumPromoSeedVideo) (domain.PremiumPromoCatalog, bool, error) {
ids := make([]int64, 0, len(videos))
locationKeys := make([]string, 0, len(videos)*2)
for i := range videos {
videos[i].document.DCID = s.dc
ids = append(ids, videos[i].document.ID)
locationKeys = append(
locationKeys,
fmt.Sprintf("doc:%d", videos[i].document.ID),
fmt.Sprintf("doc:%d:%s", videos[i].document.ID, videos[i].thumbType),
)
}
stored, err := s.media.GetDocuments(ctx, ids)
if err != nil {
return domain.PremiumPromoCatalog{}, false, err
}
if len(stored) != len(videos) {
return domain.PremiumPromoCatalog{}, false, nil
}
byID := make(map[int64]domain.Document, len(stored))
for _, doc := range stored {
byID[doc.ID] = doc
}
blobs, err := s.media.GetFileBlobs(ctx, locationKeys)
if err != nil {
return domain.PremiumPromoCatalog{}, false, err
}
catalog := domain.PremiumPromoCatalog{
VideoSections: make([]string, 0, len(videos)),
Videos: make([]domain.Document, 0, len(videos)),
}
for _, video := range videos {
storedDoc, ok := byID[video.document.ID]
if !ok || !premiumPromoDocumentEqual(storedDoc, video.document) {
return domain.PremiumPromoCatalog{}, false, nil
}
mainKey := fmt.Sprintf("doc:%d", video.document.ID)
thumbKey := fmt.Sprintf("doc:%d:%s", video.document.ID, video.thumbType)
if !s.premiumPromoBlobReady(ctx, blobs[mainKey], mainKey, video.document.Size, video.document.MimeType) {
return domain.PremiumPromoCatalog{}, false, nil
}
if !s.premiumPromoBlobReady(ctx, blobs[thumbKey], thumbKey, int64(video.document.Thumbs[0].Size), "image/jpeg") {
return domain.PremiumPromoCatalog{}, false, nil
}
catalog.VideoSections = append(catalog.VideoSections, video.section)
catalog.Videos = append(catalog.Videos, storedDoc)
}
return catalog, true, nil
}
func premiumPromoDocumentEqual(got, want domain.Document) bool {
return got.ID == want.ID &&
got.AccessHash == want.AccessHash &&
bytes.Equal(got.FileReference, want.FileReference) &&
got.Date == want.Date &&
got.MimeType == want.MimeType &&
got.Size == want.Size &&
got.DCID == want.DCID &&
reflect.DeepEqual(got.Attributes, want.Attributes) &&
reflect.DeepEqual(got.Thumbs, want.Thumbs)
}
func (s *Service) premiumPromoBlobReady(ctx context.Context, blob domain.FileBlob, locationKey string, size int64, mimeType string) bool {
if blob.LocationKey != locationKey ||
blob.Backend != domain.MediaBackend(s.blobs.Name()) ||
blob.ObjectKey == "" ||
blob.Size != size ||
blob.MimeType != mimeType {
return false
}
_, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, 1)
return err == nil && total == size
}
func (s *Service) putPremiumPromoBlob(
ctx context.Context,
locationKey string,
path string,
mimeType string,
wantSize int64,
force bool,
) (bool, error) {
if !force {
if blob, found, err := s.media.GetFileBlob(ctx, locationKey); err != nil {
return false, err
} else if found && s.premiumPromoBlobReady(ctx, blob, locationKey, wantSize, mimeType) {
return false, nil
}
}
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
objectKey, size, sum, err := s.blobs.PutReader(ctx, f)
if err != nil {
return false, err
}
if size != wantSize {
return false, fmt.Errorf("streamed size %d does not match validated size %d", size, wantSize)
}
blob := domain.FileBlob{
LocationKey: locationKey,
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: size,
SHA256: append([]byte(nil), sum...),
MimeType: mimeType,
}
if err := s.media.PutFileBlob(ctx, blob); err != nil {
return false, err
}
s.blobCache.put(locationKey, blob)
return true, nil
}

View file

@ -0,0 +1,292 @@
package files
import (
"bytes"
"context"
"encoding/json"
"fmt"
"image"
"image/color"
"image/jpeg"
"os"
"path/filepath"
"sync"
"testing"
"telesrv/internal/domain"
)
func TestSeedPremiumPromoImportsDownloadsSkipsAndRepairs(t *testing.T) {
ctx := context.Background()
root, videoBytes, thumbBytes := writePremiumPromoFixture(t)
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
svc := NewService(media, blobs, 7, WithVideoThumbnailer(nil), WithGIFTranscoder(nil))
first, err := svc.SeedPremiumPromo(ctx, root)
if err != nil {
t.Fatalf("SeedPremiumPromo first: %v", err)
}
if first.Skipped || first.Videos != 1 || first.Blobs != 2 {
t.Fatalf("first stats = %+v, want one video and two blobs", first)
}
catalog, found, err := svc.PremiumPromo(ctx)
if err != nil || !found {
t.Fatalf("PremiumPromo found=%v err=%v", found, err)
}
if len(catalog.VideoSections) != 1 || catalog.VideoSections[0] != "no_ads" || len(catalog.Videos) != 1 {
t.Fatalf("catalog = %+v", catalog)
}
doc := catalog.Videos[0]
if doc.DCID != 7 || doc.MimeType != "video/mp4" || len(doc.Thumbs) != 1 || doc.Thumbs[0].Type != "m" {
t.Fatalf("document = %+v", doc)
}
main, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("doc:%d", doc.ID),
Limit: len(videoBytes) + 1,
})
if err != nil || !ok {
t.Fatalf("download main ok=%v err=%v", ok, err)
}
if !bytes.Equal(main.Bytes, videoBytes) {
t.Fatalf("downloaded main = %x, want %x", main.Bytes, videoBytes)
}
thumb, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("doc:%d:%s", doc.ID, doc.Thumbs[0].Type),
Limit: len(thumbBytes) + 1,
})
if err != nil || !ok {
t.Fatalf("download thumb ok=%v err=%v", ok, err)
}
if !bytes.Equal(thumb.Bytes, thumbBytes) {
t.Fatalf("downloaded thumb differs: got %d bytes, want %d", len(thumb.Bytes), len(thumbBytes))
}
// Returned values are request-owned: mutating one response must not corrupt
// the immutable catalog seen by later/concurrent requests.
catalog.VideoSections[0] = "mutated"
catalog.Videos[0].FileReference[0] ^= 0xff
catalog.Videos[0].Thumbs[0].Type = "z"
again, found, err := svc.PremiumPromo(ctx)
if err != nil || !found {
t.Fatalf("PremiumPromo again found=%v err=%v", found, err)
}
if again.VideoSections[0] != "no_ads" || again.Videos[0].Thumbs[0].Type != "m" || again.Videos[0].FileReference[0] != 0 {
t.Fatalf("catalog was mutated through returned value: %+v", again)
}
var readers sync.WaitGroup
readerErrors := make(chan error, 32)
for i := 0; i < 32; i++ {
readers.Add(1)
go func() {
defer readers.Done()
got, found, err := svc.PremiumPromo(ctx)
if err != nil || !found || len(got.Videos) != 1 {
readerErrors <- fmt.Errorf("found=%v videos=%d err=%v", found, len(got.Videos), err)
return
}
got.VideoSections[0] = "request-owned"
got.Videos[0].FileReference[0] = 0x7f
}()
}
readers.Wait()
close(readerErrors)
for err := range readerErrors {
t.Error(err)
}
second, err := svc.SeedPremiumPromo(ctx, root)
if err != nil {
t.Fatalf("SeedPremiumPromo unchanged: %v", err)
}
if !second.Skipped || second.Videos != 1 || second.Blobs != 0 {
t.Fatalf("unchanged stats = %+v, want skipped catalog", second)
}
mainKey := fmt.Sprintf("doc:%d", doc.ID)
media.mu.Lock()
delete(media.blobs, mainKey)
media.mu.Unlock()
repaired, err := svc.SeedPremiumPromo(ctx, root)
if err != nil {
t.Fatalf("SeedPremiumPromo repair: %v", err)
}
if repaired.Skipped || repaired.Videos != 1 || repaired.Blobs != 1 {
t.Fatalf("repair stats = %+v, want one repaired blob", repaired)
}
if _, ok, err := media.GetFileBlob(ctx, mainKey); err != nil || !ok {
t.Fatalf("repaired main blob ok=%v err=%v", ok, err)
}
}
func TestSeedPremiumPromoMissingAndInvalidSources(t *testing.T) {
ctx := context.Background()
newService := func(t *testing.T) *Service {
t.Helper()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
return NewService(newFakeMediaStore(), blobs, 2, WithVideoThumbnailer(nil), WithGIFTranscoder(nil))
}
t.Run("missing directory falls back", func(t *testing.T) {
svc := newService(t)
stats, err := svc.SeedPremiumPromo(ctx, filepath.Join(t.TempDir(), "missing"))
if err != nil || !stats.Skipped {
t.Fatalf("stats=%+v err=%v, want optional-resource fallback", stats, err)
}
if _, found, err := svc.PremiumPromo(ctx); err != nil || found {
t.Fatalf("PremiumPromo found=%v err=%v, want unavailable", found, err)
}
})
t.Run("existing directory without manifest fails", func(t *testing.T) {
svc := newService(t)
if _, err := svc.SeedPremiumPromo(ctx, t.TempDir()); err == nil {
t.Fatal("existing incomplete seed directory was accepted")
}
})
t.Run("positional vectors must match", func(t *testing.T) {
root, _, _ := writePremiumPromoFixture(t)
rewritePremiumPromoManifest(t, root, func(m map[string]any) {
m["video_sections"] = []string{"no_ads", "extra"}
})
if _, err := newService(t).SeedPremiumPromo(ctx, root); err == nil {
t.Fatal("mismatched video_sections/videos was accepted")
}
})
t.Run("manifest size must match file", func(t *testing.T) {
root, _, _ := writePremiumPromoFixture(t)
rewritePremiumPromoManifest(t, root, func(m map[string]any) {
videos := m["videos"].([]any)
videos[0].(map[string]any)["size"] = float64(999)
})
if _, err := newService(t).SeedPremiumPromo(ctx, root); err == nil {
t.Fatal("wrong video size was accepted")
}
})
t.Run("missing thumbnail fails", func(t *testing.T) {
root, _, _ := writePremiumPromoFixture(t)
thumbPath := filepath.Join(root, "thumbs", "1000000000000001.jpg")
if err := os.Remove(thumbPath); err != nil {
t.Fatal(err)
}
if _, err := newService(t).SeedPremiumPromo(ctx, root); err == nil {
t.Fatal("missing thumbnail was accepted")
}
})
}
func TestSeedPremiumPromoFromRealExport(t *testing.T) {
root := os.Getenv("TELESRV_REAL_PREMIUM_PROMO_SEED_DIR")
if root == "" {
t.Skip("TELESRV_REAL_PREMIUM_PROMO_SEED_DIR not set")
}
if _, err := os.Stat(root); err != nil {
t.Skipf("seed dir %s not present: %v", root, err)
}
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
svc := NewService(newFakeMediaStore(), blobs, 2, WithVideoThumbnailer(nil), WithGIFTranscoder(nil))
stats, err := svc.SeedPremiumPromo(context.Background(), root)
if err != nil {
t.Fatalf("SeedPremiumPromo: %v", err)
}
catalog, found, err := svc.PremiumPromo(context.Background())
if err != nil || !found {
t.Fatalf("PremiumPromo found=%v err=%v", found, err)
}
if stats.Videos != 31 || len(catalog.VideoSections) != 31 || len(catalog.Videos) != 31 {
t.Fatalf("stats=%+v sections=%d videos=%d, want 31", stats, len(catalog.VideoSections), len(catalog.Videos))
}
t.Logf("real premium promo seed: videos=%d blobs=%d", stats.Videos, stats.Blobs)
}
func writePremiumPromoFixture(t *testing.T) (string, []byte, []byte) {
t.Helper()
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "documents"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "thumbs"), 0o755); err != nil {
t.Fatal(err)
}
const documentID int64 = 1000000000000001
videoBytes := []byte{0, 0, 0, 24, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm', 0, 0, 0, 0}
if err := os.WriteFile(filepath.Join(root, "documents", fmt.Sprintf("%d.mp4", documentID)), videoBytes, 0o644); err != nil {
t.Fatal(err)
}
img := image.NewRGBA(image.Rect(0, 0, 160, 240))
for y := 0; y < 240; y++ {
for x := 0; x < 160; x++ {
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 0x88, A: 0xff})
}
}
var thumb bytes.Buffer
if err := jpeg.Encode(&thumb, img, &jpeg.Options{Quality: 80}); err != nil {
t.Fatal(err)
}
thumbBytes := thumb.Bytes()
if err := os.WriteFile(filepath.Join(root, "thumbs", fmt.Sprintf("%d.jpg", documentID)), thumbBytes, 0o644); err != nil {
t.Fatal(err)
}
manifest := premiumPromoSeedJSON{
APICall: "help.getPremiumPromo",
StatusText: "ignored source status",
VideoSections: []string{"no_ads"},
Videos: []seedDocumentJSON{{
ID: documentID,
AccessHash: -7,
FileReference: "00112233445566778899aabbccddeeff",
Date: "2026-01-02T03:04:05Z",
MimeType: "video/mp4",
Size: int64(len(videoBytes)),
DCID: 4,
Attributes: []seedAttrJSON{
{Type: "DocumentAttributeFilename", FileName: "promo.mp4"},
{Type: "DocumentAttributeVideo", W: 720, H: 1070, Duration: 5, SupportsStreaming: true},
{Type: "DocumentAttributeAnimated"},
},
}},
}
raw, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, premiumPromoManifestName), raw, 0o644); err != nil {
t.Fatal(err)
}
return root, append([]byte(nil), videoBytes...), append([]byte(nil), thumbBytes...)
}
func rewritePremiumPromoManifest(t *testing.T, root string, mutate func(map[string]any)) {
t.Helper()
path := filepath.Join(root, premiumPromoManifestName)
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var manifest map[string]any
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatal(err)
}
mutate(manifest)
raw, err = json.MarshalIndent(manifest, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, raw, 0o644); err != nil {
t.Fatal(err)
}
}

View file

@ -7,6 +7,7 @@ import (
"fmt"
"hash"
"io"
"sync"
"time"
"telesrv/internal/domain"
@ -71,6 +72,13 @@ type Service struct {
// effectsHash 在 seed 时算一次,handler 直接比对返回 NotModified,无需每次 RPC 重算。
effects []domain.AvailableEffect
effectsHash int
// premiumPromo is populated during startup seed and then read by RPC
// handlers. Keep a lock so the ownership boundary remains race-safe even
// when exercised concurrently in tests.
premiumPromoMu sync.RWMutex
premiumPromo domain.PremiumPromoCatalog
premiumPromoReady bool
}
// Option 配置 files 服务的可选能力。

View file

@ -9,6 +9,7 @@ import (
"strings"
"sync"
compatandroid "telesrv/internal/compat/android"
"telesrv/internal/domain"
"telesrv/internal/seed/catalog"
"telesrv/internal/store"
@ -56,9 +57,10 @@ 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,"giveaway_gifts_purchase_available":true,"giveaway_boosts_per_premium":4,"giveaway_countries_max":10,"giveaway_add_peers_max":10,"giveaway_period_max":604800,"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 tdesktopNoForwardsAppConfig = `,"no_forwards_request_expire_period":86400`
const defaultAppConfigHash = 24 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
const defaultAppConfigHash = 27 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
// Service 提供客户端启动配置与国家区号目录。
//
@ -137,7 +139,8 @@ func defaultAppConfig(mapboxToken string, emailSignupEnable bool, emailSignupPho
}
func defaultAppConfigJSON(mapboxToken string, emailSignupEnable bool, emailSignupPhonePrefixes []string) []byte {
base := tdesktopDefaultAppConfigBase
androidInvoiceBilling := `,"premium_playmarket_direct_currency_list":` + compatandroid.DirectInvoiceCurrenciesJSON()
base := tdesktopDefaultAppConfigBase + tdesktopNoForwardsAppConfig + androidInvoiceBilling
if emailSignupEnable {
base += `,"email_signup_enabled":true`
if len(emailSignupPhonePrefixes) > 0 {
@ -172,8 +175,10 @@ func defaultAppConfigHashFor(mapboxToken string, emailSignupEnable bool, emailSi
}
// GetAppConfig returns the cached global app config plus an authenticated,
// per-account freeze overlay. The overlay owns its own deterministic hash so a
// FROZEN_METHOD_INVALID-triggered refresh can never be answered notModified.
// per-account freeze overlay. Only active freezes add account fields; an
// inactive account receives the field-free base config. The overlay owns its
// own deterministic hash so a FROZEN_METHOD_INVALID-triggered refresh and a
// later unfreeze can never be answered notModified against the other state.
func (s *Service) GetAppConfig(ctx context.Context, userID int64, hash int) (domain.AppConfig, bool, error) {
cfg := s.loadAppConfig(ctx)
var err error
@ -197,15 +202,6 @@ func (s *Service) accountAppConfig(ctx context.Context, userID int64, base domai
}
}
if userID > 0 {
// DrKLO applies only keys present in the new JSON object and retains old
// SharedPreferences values for missing keys. Authenticated non-frozen
// accounts therefore need an explicit zero/empty triplet to converge after
// an unfreeze; merely omitting the overlay works in TDesktop but leaves
// Android frozen indefinitely. Unauthenticated config remains unscoped.
values["freeze_since_date"] = json.RawMessage("0")
values["freeze_until_date"] = json.RawMessage("0")
values["freeze_appeal_url"] = json.RawMessage(`""`)
changed = true
if s != nil && s.accountFreeze != nil {
freeze, found, err := s.accountFreeze.AccountFreeze(ctx, userID)
if err != nil {
@ -216,6 +212,7 @@ func (s *Service) accountAppConfig(ctx context.Context, userID int64, base domai
values["freeze_until_date"] = json.RawMessage(strconv.FormatInt(freeze.Until.Unix(), 10))
appeal, _ := json.Marshal(freeze.AppealURL)
values["freeze_appeal_url"] = appeal
changed = true
}
}
}

View file

@ -46,7 +46,7 @@ func TestAccountAppConfigFreezeOverlayIsUserScopedAndHashAware(t *testing.T) {
if err != nil || notModified || other.Hash != normal.Hash {
t.Fatalf("other user = hash:%d notModified:%v err:%v", other.Hash, notModified, err)
}
assertClearedFreezeConfig(t, other.JSON)
assertNoFreezeConfig(t, other.JSON)
unauthorized, _, err := svc.GetAppConfig(context.Background(), 0, 0)
if err != nil {
t.Fatal(err)
@ -58,21 +58,24 @@ func TestAccountAppConfigFreezeOverlayIsUserScopedAndHashAware(t *testing.T) {
if err != nil || notModified || unfrozen.Hash != normal.Hash {
t.Fatalf("unfreeze refresh = hash:%d notModified:%v err:%v", unfrozen.Hash, notModified, err)
}
assertClearedFreezeConfig(t, unfrozen.JSON)
assertNoFreezeConfig(t, unfrozen.JSON)
}
func TestAuthenticatedAppConfigClearsPersistedFreezeWithoutProvider(t *testing.T) {
func TestAuthenticatedNonFrozenAppConfigOmitsFreezeFieldsAndReusesBaseHash(t *testing.T) {
svc := NewService(nil, nil)
unauthorized, _, err := svc.GetAppConfig(context.Background(), 0, 0)
if err != nil {
t.Fatal(err)
}
assertNoFreezeConfig(t, unauthorized.JSON)
authenticated, notModified, err := svc.GetAppConfig(context.Background(), 1001, unauthorized.Hash)
if err != nil || notModified || authenticated.Hash == unauthorized.Hash {
t.Fatalf("authenticated clear config = hash:%d base:%d notModified:%v err:%v", authenticated.Hash, unauthorized.Hash, notModified, err)
authenticated, notModified, err := svc.GetAppConfig(context.Background(), 1001, 0)
if err != nil || notModified || authenticated.Hash != unauthorized.Hash {
t.Fatalf("authenticated config = hash:%d base:%d notModified:%v err:%v", authenticated.Hash, unauthorized.Hash, notModified, err)
}
assertNoFreezeConfig(t, authenticated.JSON)
if _, notModified, err := svc.GetAppConfig(context.Background(), 1001, authenticated.Hash); err != nil || !notModified {
t.Fatalf("authenticated hash replay = notModified:%v err:%v", notModified, err)
}
assertClearedFreezeConfig(t, authenticated.JSON)
}
func TestAccountAppConfigStripsGlobalFreezeFields(t *testing.T) {
@ -112,17 +115,6 @@ func assertNoFreezeConfig(t *testing.T, body []byte) {
}
}
func assertClearedFreezeConfig(t *testing.T, body []byte) {
t.Helper()
var values map[string]any
if err := json.Unmarshal(body, &values); err != nil {
t.Fatal(err)
}
if values["freeze_since_date"] != float64(0) || values["freeze_until_date"] != float64(0) || values["freeze_appeal_url"] != "" {
t.Fatalf("freeze clear config = %#v", values)
}
}
type fakeAccountFreezeProvider struct {
items map[int64]domain.AccountFreeze
}

View file

@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"testing"
"telesrv/internal/domain"
)
// TestAppConfigPremiumKeys 断言 premium / Stars 相关 key 完整下发且 hash 已递增:
@ -26,6 +28,9 @@ func TestAppConfigPremiumKeys(t *testing.T) {
if err := json.Unmarshal(cfg.JSON, &decoded); err != nil {
t.Fatalf("app config json invalid: %v", err)
}
if period, ok := decoded["no_forwards_request_expire_period"].(float64); !ok || int(period) != domain.PrivateNoForwardsRequestExpirePeriod {
t.Fatalf("no_forwards_request_expire_period = %v, want %d", decoded["no_forwards_request_expire_period"], domain.PrivateNoForwardsRequestExpirePeriod)
}
if blocked, ok := decoded["premium_purchase_blocked"].(bool); !ok || blocked {
t.Fatalf("premium_purchase_blocked = %v, want false (star gift 送礼入口耦合此 flag)", decoded["premium_purchase_blocked"])
}
@ -37,6 +42,13 @@ func TestAppConfigPremiumKeys(t *testing.T) {
if blocked, ok := decoded["stargifts_blocked"].(bool); !ok || blocked {
t.Fatalf("stargifts_blocked = %v, want false (DrKLO GiftSheet 据此隐藏礼物网格)", decoded["stargifts_blocked"])
}
if available, ok := decoded["giveaway_gifts_purchase_available"].(bool); !ok || !available {
t.Fatalf("giveaway_gifts_purchase_available = %v, want true", decoded["giveaway_gifts_purchase_available"])
}
directCurrencies, ok := decoded["premium_playmarket_direct_currency_list"].([]any)
if !ok || len(directCurrencies) == 0 || !containsJSONCurrency(directCurrencies, "USD") {
t.Fatalf("premium_playmarket_direct_currency_list = %#v, want non-empty list containing USD", decoded["premium_playmarket_direct_currency_list"])
}
if posting, ok := decoded["rich_message_posting"].(string); !ok || posting != "enabled" {
t.Fatalf("rich_message_posting = %v, want enabled (TDesktop 富文本编辑入口默认打开)", decoded["rich_message_posting"])
}
@ -45,39 +57,44 @@ 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,
"giveaway_boosts_per_premium": 4,
"giveaway_countries_max": 10,
"giveaway_add_peers_max": 10,
"giveaway_period_max": 604800,
"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)
@ -93,6 +110,15 @@ func TestAppConfigPremiumKeys(t *testing.T) {
}
}
func containsJSONCurrency(values []any, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func TestAppConfigOmitsMapboxTokenByDefault(t *testing.T) {
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0, 0)
if err != nil || notModified {

View file

@ -2,11 +2,13 @@ package langpack
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
@ -205,8 +207,8 @@ func TestSeedDirectoryReconcilesManifest(t *testing.T) {
t.Fatalf("reconcile removed file = %d, %v", seeded, err)
}
languages, err = service.ListLanguages(ctx, "tdesktop")
if err != nil || len(languages) != 0 {
t.Fatalf("languages after removal = %+v, err %v", languages, err)
if !errors.Is(err, domain.ErrLangPackInvalid) || len(languages) != 0 {
t.Fatalf("languages after removal = %+v, err %v, want ErrLangPackInvalid", languages, err)
}
}

View file

@ -70,6 +70,9 @@ func (s *Service) GetDifference(ctx context.Context, langPack, langCode string,
if s == nil || s.packs == nil {
return domain.LangPack{LangPack: packName, LangCode: code, FromVersion: fromVersion}, nil
}
if err := s.validateLanguage(ctx, packName, code); err != nil {
return domain.LangPack{}, err
}
var (
pack domain.LangPack
err error
@ -96,6 +99,9 @@ func (s *Service) GetStrings(ctx context.Context, langPack, langCode string, key
if s == nil || s.packs == nil {
return domain.LangPack{LangPack: packName, LangCode: code}, nil
}
if err := s.validateLanguage(ctx, packName, code); err != nil {
return domain.LangPack{}, err
}
pack, err := s.effectivePack(ctx, packName, code)
if err != nil {
return domain.LangPack{}, err
@ -123,7 +129,14 @@ func (s *Service) ListLanguages(ctx context.Context, langPack string) ([]domain.
if s == nil || s.packs == nil {
return nil, nil
}
return s.cachedLanguages(ctx, packName)
languages, err := s.cachedLanguages(ctx, packName)
if err != nil {
return nil, err
}
if len(languages) == 0 {
return nil, domain.ErrLangPackInvalid
}
return languages, nil
}
func normalizePack(langPack string) string {
@ -131,6 +144,9 @@ func normalizePack(langPack string) string {
if pack == "" {
return "tdesktop"
}
if pack == "web" {
return "webk"
}
return pack
}
@ -262,6 +278,22 @@ func (s *Service) cachedLanguages(ctx context.Context, langPack string) ([]domai
}
}
func (s *Service) validateLanguage(ctx context.Context, langPack, langCode string) error {
languages, err := s.cachedLanguages(ctx, langPack)
if err != nil {
return err
}
if len(languages) == 0 {
return domain.ErrLangPackInvalid
}
for _, language := range languages {
if normalizeCode(language.LangCode) == langCode {
return nil
}
}
return domain.ErrLangCodeNotSupported
}
func (s *Service) brandPack(pack domain.LangPack) domain.LangPack {
for i := range pack.Strings {
item := &pack.Strings[i]

View file

@ -2,6 +2,7 @@ package langpack
import (
"context"
"errors"
"sync"
"testing"
"time"
@ -11,6 +12,106 @@ import (
"telesrv/internal/store/memory"
)
func TestServiceNormalizesWebAliasAndRejectsUnknownCatalogEntries(t *testing.T) {
ctx := context.Background()
packs := memory.NewLangPackStore()
svc := NewService(packs)
for _, pack := range []domain.LangPack{
{
LangPack: "webk",
LangCode: "en",
Version: 7,
Strings: []domain.LangPackString{{Key: "lng_settings_language", Value: "Language"}},
},
{
LangPack: "webk",
LangCode: "zh-hans",
Version: 9,
Strings: []domain.LangPackString{{Key: "lng_settings_language", Value: "语言"}},
},
} {
if err := packs.UpsertPack(ctx, pack); err != nil {
t.Fatalf("seed %s/%s: %v", pack.LangPack, pack.LangCode, err)
}
}
languages, err := svc.ListLanguages(ctx, " WEB ")
if err != nil {
t.Fatalf("list web languages: %v", err)
}
if len(languages) != 2 || findLanguage(languages, "zh-hans") == nil {
t.Fatalf("web languages = %+v, want canonical webk catalog", languages)
}
full, err := svc.GetLangPack(ctx, "web", "ZH_HANS")
if err != nil {
t.Fatalf("get web langpack: %v", err)
}
if full.LangPack != "webk" || full.LangCode != "zh-hans" || full.Version != 9 || stringValue(full.Strings, "lng_settings_language") != "语言" {
t.Fatalf("web langpack = %+v, want canonical webk/zh-hans", full)
}
diff, err := svc.GetDifference(ctx, "web", "zh-hans", 1)
if err != nil {
t.Fatalf("get web difference: %v", err)
}
if diff.LangPack != "webk" || diff.FromVersion != 1 || len(diff.Strings) != 1 {
t.Fatalf("web difference = %+v, want canonical webk delta", diff)
}
selected, err := svc.GetStrings(ctx, "web", "zh-hans", []string{"lng_settings_language"})
if err != nil {
t.Fatalf("get web strings: %v", err)
}
if selected.LangPack != "webk" || stringValue(selected.Strings, "lng_settings_language") != "语言" {
t.Fatalf("web strings = %+v, want selected webk string", selected)
}
invalidPackCalls := map[string]func() error{
"list": func() error {
_, err := svc.ListLanguages(ctx, "web-invalid")
return err
},
"full": func() error {
_, err := svc.GetLangPack(ctx, "web-invalid", "en")
return err
},
"difference": func() error {
_, err := svc.GetDifference(ctx, "web-invalid", "en", 1)
return err
},
"strings": func() error {
_, err := svc.GetStrings(ctx, "web-invalid", "en", []string{"key"})
return err
},
}
for name, call := range invalidPackCalls {
if err := call(); !errors.Is(err, domain.ErrLangPackInvalid) {
t.Fatalf("%s invalid pack error = %v, want ErrLangPackInvalid", name, err)
}
}
unsupportedCodeCalls := map[string]func() error{
"full": func() error {
_, err := svc.GetLangPack(ctx, "web", "fr")
return err
},
"difference": func() error {
_, err := svc.GetDifference(ctx, "web", "fr", 1)
return err
},
"strings": func() error {
_, err := svc.GetStrings(ctx, "web", "fr", []string{"key"})
return err
},
}
for name, call := range unsupportedCodeCalls {
if err := call(); !errors.Is(err, domain.ErrLangCodeNotSupported) {
t.Fatalf("%s unsupported code error = %v, want ErrLangCodeNotSupported", name, err)
}
}
}
func TestServiceNormalizesWebARawLangCode(t *testing.T) {
ctx := context.Background()
packs := memory.NewLangPackStore()

View file

@ -67,6 +67,19 @@ type LoginCodeDeliveryRetentionStore interface {
DeleteExpiredLoginCodeDeliveries(ctx context.Context, expiredBefore time.Time, limit int) (int, error)
}
type ClientTelemetryRetentionStore interface {
DeleteExpiredClientTelemetry(ctx context.Context, olderThan time.Time, limit int) (int, error)
}
type AuthDeliveryReportRetentionStore interface {
DeleteExpiredAuthDeliveryReports(ctx context.Context, olderThan time.Time, limit int) (int, error)
}
type ModerationRetentionStore interface {
DeleteExpiredSponsoredMessageImpressions(ctx context.Context, olderThan time.Time, limit int) (int, error)
DeleteExpiredModerationAppealLinks(ctx context.Context, olderThan time.Time, limit int) (int, error)
}
// botAPIConfirmedGrace 是已确认 Bot API update 行的删除宽限:确认水位之下的行不会再被
// getUpdates 读取fromID 恒 > confirmed宽限仅防御 offset 回拨调试;回收目标是清堆积。
const botAPIConfirmedGrace = 15 * time.Minute
@ -92,24 +105,29 @@ const (
// 前缀;落后或缺 state 的任一设备都会把 floor 压回 0。客户端偶然带回已确认前的旧 pts 时,
// updates 服务通过普通 differenceSlice checkpoint 推进,不发送 differenceTooLong。
type RetentionWorker struct {
outbox DispatchOutboxRetentionStore
tempKeys TempAuthKeyRetentionStore // 可为 nil不回收 temp key 绑定)
authKeySessionLayers AuthKeySessionLayerRetentionStore
botAPIUpdates BotAPIUpdateRetentionStore // 可为 nil不回收 Bot API 队列)
userUpdates UserUpdateEventRetentionStore
channelUpdates ChannelUpdateEventRetentionStore
loginCodeDeliveries LoginCodeDeliveryRetentionStore
orphanAuthKeys OrphanAuthKeyRetentionStore
activeAuthKeys ActiveRawAuthKeyProvider
activeAuthKeyHeartbeat ActiveAuthKeyHeartbeatStore
logger *zap.Logger
retention time.Duration
botAPIRetention time.Duration
orphanRetention time.Duration
outboxPoisonRetention time.Duration
outboxPoisonInterval time.Duration
interval time.Duration
batch int
outbox DispatchOutboxRetentionStore
tempKeys TempAuthKeyRetentionStore // 可为 nil不回收 temp key 绑定)
authKeySessionLayers AuthKeySessionLayerRetentionStore
botAPIUpdates BotAPIUpdateRetentionStore // 可为 nil不回收 Bot API 队列)
userUpdates UserUpdateEventRetentionStore
channelUpdates ChannelUpdateEventRetentionStore
loginCodeDeliveries LoginCodeDeliveryRetentionStore
clientTelemetry ClientTelemetryRetentionStore
authDeliveryReports AuthDeliveryReportRetentionStore
moderation ModerationRetentionStore
orphanAuthKeys OrphanAuthKeyRetentionStore
activeAuthKeys ActiveRawAuthKeyProvider
activeAuthKeyHeartbeat ActiveAuthKeyHeartbeatStore
logger *zap.Logger
retention time.Duration
botAPIRetention time.Duration
orphanRetention time.Duration
clientTelemetryRetention time.Duration
authDeliveryReportRetention time.Duration
outboxPoisonRetention time.Duration
outboxPoisonInterval time.Duration
interval time.Duration
batch int
}
func NewRetentionWorker(outbox DispatchOutboxRetentionStore, tempKeys TempAuthKeyRetentionStore, logger *zap.Logger, retention, interval time.Duration, batch int) *RetentionWorker {
@ -191,6 +209,29 @@ func (w *RetentionWorker) WithAuthKeySessionLayerRetention(store AuthKeySessionL
return w
}
func (w *RetentionWorker) WithClientTelemetryRetention(store ClientTelemetryRetentionStore, retention time.Duration) *RetentionWorker {
if retention <= 0 {
retention = 30 * 24 * time.Hour
}
w.clientTelemetry = store
w.clientTelemetryRetention = retention
return w
}
func (w *RetentionWorker) WithAuthDeliveryReportRetention(store AuthDeliveryReportRetentionStore, retention time.Duration) *RetentionWorker {
if retention <= 0 {
retention = 30 * 24 * time.Hour
}
w.authDeliveryReports = store
w.authDeliveryReportRetention = retention
return w
}
func (w *RetentionWorker) WithModerationRetention(store ModerationRetentionStore) *RetentionWorker {
w.moderation = store
return w
}
// WithOrphanAuthKeyRetention 启用未授权握手 key 的有界回收。active 必须提供 raw key
// 不能提供 temp→perm business key否则未登录或 PFS 连接会被误判为 orphan。
func (w *RetentionWorker) WithOrphanAuthKeyRetention(store OrphanAuthKeyRetentionStore, active ActiveRawAuthKeyProvider, retention time.Duration) *RetentionWorker {
@ -275,6 +316,45 @@ func (w *RetentionWorker) runRetentionOnce(ctx context.Context) {
w.logger.Info("expired login-code delivery receipt cleanup complete", zap.Int("deleted", deleted))
}
}
if w.clientTelemetry != nil {
deleted, err := w.clientTelemetry.DeleteExpiredClientTelemetry(
ctx, time.Now().Add(-w.clientTelemetryRetention), w.batch,
)
if err != nil {
w.logger.Warn("回收过期客户端 telemetry 失败", zap.Error(err))
} else if deleted > 0 {
w.logger.Info("回收过期客户端 telemetry 完成", zap.Int("deleted", deleted))
}
}
if w.authDeliveryReports != nil {
deleted, err := w.authDeliveryReports.DeleteExpiredAuthDeliveryReports(
ctx, time.Now().Add(-w.authDeliveryReportRetention), w.batch,
)
if err != nil {
w.logger.Warn("回收过期验证码投递诊断失败", zap.Error(err))
} else if deleted > 0 {
w.logger.Info("回收过期验证码投递诊断完成", zap.Int("deleted", deleted))
}
}
if w.moderation != nil {
now := time.Now()
impressions, err := w.moderation.DeleteExpiredSponsoredMessageImpressions(
ctx, now, w.batch,
)
if err != nil {
w.logger.Warn("回收过期 sponsored impression 失败", zap.Error(err))
} else if impressions > 0 {
w.logger.Info("回收过期 sponsored impression 完成", zap.Int("deleted", impressions))
}
links, err := w.moderation.DeleteExpiredModerationAppealLinks(
ctx, now, w.batch,
)
if err != nil {
w.logger.Warn("回收过期审核申诉链接失败", zap.Error(err))
} else if links > 0 {
w.logger.Info("回收过期审核申诉链接完成", zap.Int("deleted", links))
}
}
if w.tempKeys != nil {
expiredBefore := time.Now().Add(-tempAuthKeyExpiryGrace).Unix()
tempDeleted, err := w.tempKeys.DeleteExpired(ctx, expiredBefore, w.batch)

View file

@ -36,7 +36,7 @@ func TestRetentionWorkerUsesIndependentOutboxPoisonPolicyAndSignalsRelease(t *te
if outbox.calls != 1 || outbox.olderThan != 2*time.Minute || outbox.limit != 73 {
t.Fatalf("outbox poison calls/args = %d/%v/%d, want 1/2m/73", outbox.calls, outbox.olderThan, outbox.limit)
}
entries := logs.FilterMessage("terminal failed dispatch_outbox 已结束隔离并释放用户 lane").All()
entries := logs.FilterMessage("terminal-failed dispatch_outbox rows released from quarantine and unfroze their user lane").All()
if len(entries) != 1 {
t.Fatalf("poison release error signals = %d, want 1", len(entries))
}
@ -131,6 +131,86 @@ func TestRetentionWorkerReclaimsExpiredLoginCodeDeliveryReceipts(t *testing.T) {
}
}
type fakeReportRetention struct {
telemetryBefore time.Time
authBefore time.Time
sponsoredBefore time.Time
appealBefore time.Time
telemetryCalls int
authCalls int
sponsoredCalls int
appealCalls int
limit int
}
func (f *fakeReportRetention) DeleteExpiredClientTelemetry(_ context.Context, before time.Time, limit int) (int, error) {
f.telemetryCalls++
f.telemetryBefore = before
f.limit = limit
return 1, nil
}
func (f *fakeReportRetention) DeleteExpiredAuthDeliveryReports(_ context.Context, before time.Time, limit int) (int, error) {
f.authCalls++
f.authBefore = before
f.limit = limit
return 1, nil
}
func (f *fakeReportRetention) DeleteExpiredSponsoredMessageImpressions(_ context.Context, before time.Time, limit int) (int, error) {
f.sponsoredCalls++
f.sponsoredBefore = before
f.limit = limit
return 1, nil
}
func (f *fakeReportRetention) DeleteExpiredModerationAppealLinks(_ context.Context, before time.Time, limit int) (int, error) {
f.appealCalls++
f.appealBefore = before
f.limit = limit
return 1, nil
}
func TestRetentionWorkerSeparatesTelemetryDiagnosticsAndModerationCapabilities(t *testing.T) {
const (
telemetryTTL = 7 * 24 * time.Hour
authTTL = 14 * 24 * time.Hour
batch = 47
)
store := &fakeReportRetention{}
w := NewRetentionWorker(
&fakeOutboxRetention{}, nil, zap.NewNop(),
168*time.Hour, time.Hour, batch,
).WithClientTelemetryRetention(store, telemetryTTL).
WithAuthDeliveryReportRetention(store, authTTL).
WithModerationRetention(store)
before := time.Now()
w.runRetentionOnce(context.Background())
after := time.Now()
if store.telemetryCalls != 1 || store.authCalls != 1 ||
store.sponsoredCalls != 1 || store.appealCalls != 1 ||
store.limit != batch {
t.Fatalf("calls telemetry/auth/sponsored/appeal=%d/%d/%d/%d limit=%d",
store.telemetryCalls, store.authCalls,
store.sponsoredCalls, store.appealCalls, store.limit)
}
if store.telemetryBefore.Before(before.Add(-telemetryTTL)) ||
store.telemetryBefore.After(after.Add(-telemetryTTL)) {
t.Fatalf("telemetry boundary=%v", store.telemetryBefore)
}
if store.authBefore.Before(before.Add(-authTTL)) ||
store.authBefore.After(after.Add(-authTTL)) {
t.Fatalf("auth boundary=%v", store.authBefore)
}
if store.sponsoredBefore.Before(before) ||
store.sponsoredBefore.After(after) ||
store.appealBefore.Before(before) ||
store.appealBefore.After(after) {
t.Fatalf("moderation capability boundaries sponsored=%v appeal=%v",
store.sponsoredBefore, store.appealBefore)
}
}
func (f *fakeBotAPIRetention) DeleteDeliveredOrExpired(_ context.Context, confirmedGrace, maxAge time.Duration, limit int) (int, error) {
f.calls++
f.confirmedGrace = confirmedGrace
@ -308,7 +388,7 @@ func TestRetentionWorkerSkipsOrphanDeleteWhenHeartbeatFails(t *testing.T) {
if store.heartbeatCalls != 1 || store.calls != 0 {
t.Fatalf("heartbeat/delete calls = %d/%d, want 1/0", store.heartbeatCalls, store.calls)
}
entries := logs.FilterMessage("刷新 active raw auth key heartbeat 失败,本轮跳过 orphan GC").All()
entries := logs.FilterMessage("refreshing active raw auth key heartbeat failed, skipping orphan GC this round").All()
if len(entries) != 1 || entries[0].ContextMap()["signal"] != "auth_key_heartbeat_failed" {
t.Fatalf("heartbeat failure signals = %+v", entries)
}

View file

@ -222,6 +222,42 @@ func (s *Service) SetChatTheme(ctx context.Context, userID int64, req domain.Set
return out, nil
}
// GetPrivateNoForwards returns the canonical content-protection state for one
// ordinary private chat.
func (s *Service) GetPrivateNoForwards(ctx context.Context, userID, peerUserID int64) (domain.PrivateNoForwardsState, error) {
if s == nil || s.messages == nil || userID == 0 || peerUserID == 0 || userID == peerUserID {
return domain.PrivateNoForwardsState{}, domain.ErrMessageIDInvalid
}
backend, ok := s.messages.(store.PrivateNoForwardsStore)
if !ok {
return domain.PrivateNoForwardsState{}, nil
}
return backend.GetPrivateNoForwards(ctx, userID, peerUserID)
}
// TogglePrivateNoForwards atomically mutates the pair state and appends the
// corresponding service message when the official state machine requires one.
func (s *Service) TogglePrivateNoForwards(ctx context.Context, userID int64, req domain.TogglePrivateNoForwardsRequest) (domain.TogglePrivateNoForwardsResult, error) {
if s == nil || s.messages == nil || userID == 0 {
return domain.TogglePrivateNoForwardsResult{}, domain.ErrMessageIDInvalid
}
if req.ActorUserID == 0 {
req.ActorUserID = userID
}
if req.ActorUserID != userID || req.PeerUserID == 0 || req.PeerUserID == userID ||
req.RequestMsgID < 0 || req.RequestMsgID > domain.MaxMessageBoxID {
return domain.TogglePrivateNoForwardsResult{}, domain.ErrMessageIDInvalid
}
if err := s.ensureCanSend(ctx, userID); err != nil {
return domain.TogglePrivateNoForwardsResult{}, err
}
backend, ok := s.messages.(store.PrivateNoForwardsStore)
if !ok {
return domain.TogglePrivateNoForwardsResult{}, domain.ErrMessageIDInvalid
}
return backend.TogglePrivateNoForwards(ctx, req)
}
func chatThemeServiceMedia(emoticon string) *domain.MessageMedia {
return &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
@ -433,6 +469,31 @@ func (s *Service) GetMessageReactions(ctx context.Context, userID int64, req dom
return s.messages.GetMessageReactions(ctx, req)
}
// SavedReactionTags returns the global or one-sub-dialog Saved Messages tag list.
func (s *Service) SavedReactionTags(ctx context.Context, userID int64, savedPeer domain.Peer, limit int) ([]domain.SavedReactionTag, error) {
if s == nil || s.messages == nil || userID == 0 {
return nil, nil
}
if limit <= 0 || limit > domain.MaxSavedReactionTags {
limit = domain.MaxSavedReactionTags
}
return s.messages.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{
UserID: userID,
SavedPeer: savedPeer,
Limit: limit,
})
}
// UpdateSavedReactionTag stores or removes the optional global title for one
// tag that is currently assigned to at least one visible Saved Message.
func (s *Service) UpdateSavedReactionTag(ctx context.Context, userID int64, tag domain.SavedReactionTag) error {
if s == nil || s.messages == nil || userID == 0 || !tag.Reaction.Valid() {
return domain.ErrReactionInvalid
}
tag.UserID = userID
return s.messages.UpsertSavedReactionTag(ctx, tag)
}
// EditMessage 编辑当前账号发出的私聊文本消息。
func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.EditMessageRequest) (domain.EditMessageResult, error) {
if s == nil || s.messages == nil || userID == 0 {

View file

@ -682,6 +682,14 @@ func (s projectionMessageStore) GetMessageReactions(context.Context, domain.Priv
return domain.PrivateMessageReactionsResult{}, nil
}
func (s projectionMessageStore) ListSavedReactionTags(context.Context, domain.SavedReactionTagsRequest) ([]domain.SavedReactionTag, error) {
return nil, nil
}
func (s projectionMessageStore) UpsertSavedReactionTag(context.Context, domain.SavedReactionTag) error {
return nil
}
func (s projectionMessageStore) VoteMessagePoll(context.Context, domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) {
return domain.PrivateMessagePollResult{}, nil
}

View file

@ -0,0 +1,512 @@
package moderation
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"go.uber.org/zap"
"telesrv/internal/admin"
"telesrv/internal/domain"
"telesrv/internal/store"
)
type moderationAdminActions interface {
SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error)
SetUserFlags(ctx context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error)
SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error)
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
}
type moderationChannelDeleter interface {
ModerationDeleteMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error)
}
type moderationChannelDeleteNotifier interface {
NotifyModerationChannelDeletion(ctx context.Context, result domain.DeleteChannelMessagesResult)
}
type moderationAccountDeleter interface {
ExecuteAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error)
}
type moderationAppealLinkIssuer interface {
IssueAppealLink(ctx context.Context, caseID, appellantUserID int64, expiresAt, now time.Time) (string, error)
}
type ActionExecutor struct {
admin moderationAdminActions
channels moderationChannelDeleter
channelNotifier moderationChannelDeleteNotifier
accounts moderationAccountDeleter
appealLinks moderationAppealLinkIssuer
publicBaseURL string
now func() time.Time
}
type ActionExecutorOption func(*ActionExecutor)
func WithAppealLinks(issuer moderationAppealLinkIssuer, publicBaseURL string) ActionExecutorOption {
return func(executor *ActionExecutor) {
executor.appealLinks = issuer
executor.publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/")
}
}
func WithActionClock(now func() time.Time) ActionExecutorOption {
return func(executor *ActionExecutor) {
if now != nil {
executor.now = now
}
}
}
func NewActionExecutor(adminActions moderationAdminActions, channels moderationChannelDeleter, channelNotifier moderationChannelDeleteNotifier, accounts moderationAccountDeleter, opts ...ActionExecutorOption) *ActionExecutor {
executor := &ActionExecutor{
admin: adminActions, channels: channels,
channelNotifier: channelNotifier, accounts: accounts,
now: func() time.Time { return time.Now().UTC() },
}
for _, opt := range opts {
if opt != nil {
opt(executor)
}
}
return executor
}
type freezeAccountActionPayload struct {
Until time.Time `json:"until,omitempty"`
AppealURL string `json:"appeal_url,omitempty"`
}
type deletePrivateMessageActionPayload struct {
OwnerUserID int64 `json:"owner_user_id"`
IDs []int `json:"ids"`
Revoke bool `json:"revoke"`
}
type deleteChannelMessageActionPayload struct {
IDs []int `json:"ids"`
}
func (e *ActionExecutor) Execute(ctx context.Context, detail domain.ModerationCaseDetail, action domain.ModerationAction) error {
if e == nil || action.CaseID != detail.Case.ID {
return domain.ErrModerationActionInvalid
}
actor, _, ok := decisionAuditContext(detail.Decisions, action.DecisionID)
if !ok {
return domain.ErrModerationActionInvalid
}
meta := admin.CommandMeta{
CommandID: action.CommandID, Actor: actor,
Reason: fmt.Sprintf("moderation case %d decision %d", detail.Case.ID, action.DecisionID),
}
switch action.Kind {
case domain.ModerationActionMarkScam:
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
return err
}
return e.setPeerFlags(ctx, detail.Case.Target, true, false, meta)
case domain.ModerationActionMarkFake:
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
return err
}
return e.setPeerFlags(ctx, detail.Case.Target, false, true, meta)
case domain.ModerationActionClearPeerFlags:
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
return err
}
return e.setPeerFlags(ctx, detail.Case.Target, false, false, meta)
case domain.ModerationActionFreezeAccount, domain.ModerationActionUnfreezeAccount:
if e.admin == nil || detail.Case.Target.Type != domain.PeerTypeUser {
return domain.ErrModerationActionInvalid
}
var payload freezeAccountActionPayload
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
return err
}
frozen := action.Kind == domain.ModerationActionFreezeAccount
if !frozen && (!payload.Until.IsZero() || payload.AppealURL != "") {
return domain.ErrModerationActionInvalid
}
if frozen {
now := e.now().UTC()
if payload.Until.IsZero() {
payload.Until = now.Add(30 * 24 * time.Hour)
}
if !payload.Until.After(now) {
return domain.ErrModerationActionInvalid
}
if payload.AppealURL == "" {
if e.appealLinks == nil || e.publicBaseURL == "" {
return fmt.Errorf("moderation appeal link issuer is not configured")
}
linkExpiresAt := payload.Until
maxLinkExpiry := now.Add(domain.MaxModerationAppealLinkLifetime)
if linkExpiresAt.After(maxLinkExpiry) {
linkExpiresAt = maxLinkExpiry
}
token, err := e.appealLinks.IssueAppealLink(
ctx, detail.Case.ID, detail.Case.Target.ID,
linkExpiresAt, now,
)
if err != nil {
return err
}
payload.AppealURL = e.publicBaseURL + "/appeal/" + token
}
}
_, err := e.admin.SetAccountFrozen(ctx, admin.SetAccountFrozenRequest{
CommandMeta: meta, UserID: detail.Case.Target.ID, Frozen: frozen,
Until: payload.Until, AppealURL: payload.AppealURL,
})
return err
case domain.ModerationActionDeletePrivateMessage:
if e.admin == nil || detail.Case.Target.Type != domain.PeerTypeUser {
return domain.ErrModerationActionInvalid
}
var payload deletePrivateMessageActionPayload
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
return err
}
if payload.OwnerUserID <= 0 || len(payload.IDs) == 0 ||
len(payload.IDs) > domain.MaxDeleteMessageIDs {
return domain.ErrModerationActionInvalid
}
_, err := e.admin.DeletePrivateMessages(ctx, admin.DeletePrivateMessagesRequest{
CommandMeta: meta, OwnerUserID: payload.OwnerUserID,
Peer: detail.Case.Target, IDs: payload.IDs, Revoke: payload.Revoke,
})
return err
case domain.ModerationActionDeleteChannelMessage:
if e.channels == nil || detail.Case.Target.Type != domain.PeerTypeChannel {
return domain.ErrModerationActionInvalid
}
var payload deleteChannelMessageActionPayload
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
return err
}
if len(payload.IDs) == 0 || len(payload.IDs) > domain.MaxDeleteMessageIDs {
return domain.ErrModerationActionInvalid
}
result, err := e.channels.ModerationDeleteMessages(
ctx, detail.Case.Target.ID, payload.IDs, int(e.now().Unix()),
)
if err != nil {
return err
}
if e.channelNotifier != nil {
e.channelNotifier.NotifyModerationChannelDeletion(ctx, result)
}
return nil
case domain.ModerationActionDeleteAccount:
if e.accounts == nil || detail.Case.Target.Type != domain.PeerTypeUser {
return domain.ErrModerationActionInvalid
}
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
return err
}
_, err := e.accounts.ExecuteAccountDeletion(
ctx, detail.Case.Target.ID, domain.AccountDeletionManual,
fmt.Sprintf("moderation case %d", detail.Case.ID), e.now().UTC(),
)
return err
default:
return domain.ErrModerationActionInvalid
}
}
func (s *Service) validateDecisionActions(ctx context.Context, detail domain.ModerationCaseDetail, actions []domain.ModerationActionDraft) error {
if len(actions) == 0 {
return nil
}
seen := make(map[domain.ModerationActionKind]struct{}, len(actions))
flagActions := 0
freezeActions := 0
hasDeleteAccount := false
for _, action := range actions {
if _, duplicate := seen[action.Kind]; duplicate {
return domain.ErrModerationActionInvalid
}
seen[action.Kind] = struct{}{}
switch action.Kind {
case domain.ModerationActionMarkScam, domain.ModerationActionMarkFake,
domain.ModerationActionClearPeerFlags:
flagActions++
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
return err
}
case domain.ModerationActionFreezeAccount, domain.ModerationActionUnfreezeAccount:
freezeActions++
if detail.Case.Target.Type != domain.PeerTypeUser {
return domain.ErrModerationActionInvalid
}
var payload freezeAccountActionPayload
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
return err
}
if action.Kind == domain.ModerationActionUnfreezeAccount &&
(!payload.Until.IsZero() || payload.AppealURL != "") {
return domain.ErrModerationActionInvalid
}
case domain.ModerationActionDeletePrivateMessage:
var payload deletePrivateMessageActionPayload
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
return err
}
if detail.Case.Target.Type != domain.PeerTypeUser ||
payload.OwnerUserID <= 0 ||
!validModerationMessageIDs(payload.IDs) ||
!s.privateDeletionCoveredByEvidence(ctx, detail, payload) {
return domain.ErrModerationEvidenceNotFound
}
case domain.ModerationActionDeleteChannelMessage:
var payload deleteChannelMessageActionPayload
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
return err
}
if detail.Case.Target.Type != domain.PeerTypeChannel ||
!validModerationMessageIDs(payload.IDs) ||
!s.channelDeletionCoveredByEvidence(ctx, detail, payload.IDs) {
return domain.ErrModerationEvidenceNotFound
}
case domain.ModerationActionDeleteAccount:
hasDeleteAccount = true
if detail.Case.Target.Type != domain.PeerTypeUser {
return domain.ErrModerationActionInvalid
}
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
return err
}
default:
return domain.ErrModerationActionInvalid
}
}
if flagActions > 1 || freezeActions > 1 ||
(hasDeleteAccount && len(actions) != 1) {
return domain.ErrModerationActionInvalid
}
return nil
}
func (s *Service) privateDeletionCoveredByEvidence(ctx context.Context, detail domain.ModerationCaseDetail, payload deletePrivateMessageActionPayload) bool {
needed := make(map[int]struct{}, len(payload.IDs))
for _, id := range payload.IDs {
needed[id] = struct{}{}
}
for _, reportID := range detail.ReportIDs {
report, found, err := s.Report(ctx, reportID)
if err != nil || !found || report.ReporterUserID != payload.OwnerUserID {
continue
}
for _, item := range report.Items {
if item.Kind == domain.ModerationItemMessage &&
item.Peer == detail.Case.Target {
delete(needed, int(item.ItemID))
}
}
}
return len(needed) == 0
}
func (s *Service) channelDeletionCoveredByEvidence(ctx context.Context, detail domain.ModerationCaseDetail, ids []int) bool {
needed := make(map[int]struct{}, len(ids))
for _, id := range ids {
needed[id] = struct{}{}
}
for _, reportID := range detail.ReportIDs {
report, found, err := s.Report(ctx, reportID)
if err != nil || !found {
continue
}
for _, item := range report.Items {
if item.Kind == domain.ModerationItemMessage &&
item.Peer == detail.Case.Target {
delete(needed, int(item.ItemID))
}
}
}
return len(needed) == 0
}
func validModerationMessageIDs(ids []int) bool {
if len(ids) == 0 || len(ids) > domain.MaxDeleteMessageIDs {
return false
}
seen := make(map[int]struct{}, len(ids))
for _, id := range ids {
if id <= 0 || id > domain.MaxMessageBoxID {
return false
}
if _, duplicate := seen[id]; duplicate {
return false
}
seen[id] = struct{}{}
}
return true
}
func (e *ActionExecutor) setPeerFlags(ctx context.Context, target domain.Peer, scam, fake bool, meta admin.CommandMeta) error {
if e.admin == nil {
return domain.ErrModerationActionInvalid
}
switch target.Type {
case domain.PeerTypeUser:
_, err := e.admin.SetUserFlags(ctx, admin.SetUserFlagsRequest{
CommandMeta: meta, UserID: target.ID, Scam: scam, Fake: fake,
})
return err
case domain.PeerTypeChannel:
_, err := e.admin.SetChannelFlags(ctx, admin.SetChannelFlagsRequest{
CommandMeta: meta, ChannelID: target.ID, Scam: scam, Fake: fake,
})
return err
default:
return domain.ErrModerationActionInvalid
}
}
func decisionAuditContext(decisions []domain.ModerationDecision, decisionID int64) (string, string, bool) {
for _, decision := range decisions {
if decision.ID == decisionID {
return decision.Actor, decision.Reason, true
}
}
return "", "", false
}
func decodeStrictActionPayload(raw json.RawMessage, target any) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return domain.ErrModerationActionInvalid
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return domain.ErrModerationActionInvalid
}
return nil
}
type ActionWorker struct {
store store.ModerationCaseStore
executor *ActionExecutor
interval time.Duration
lease time.Duration
batch int
log *zap.Logger
}
func NewActionWorker(caseStore store.ModerationCaseStore, executor *ActionExecutor, log *zap.Logger) *ActionWorker {
if log == nil {
log = zap.NewNop()
}
return &ActionWorker{
store: caseStore, executor: executor,
interval: time.Second, lease: 30 * time.Second, batch: 20, log: log,
}
}
func (w *ActionWorker) Run(ctx context.Context) {
if w == nil || w.store == nil || w.executor == nil {
return
}
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
if err := w.runOnce(ctx); err != nil && ctx.Err() == nil {
w.log.Warn("审核处置任务执行失败", zap.Error(err))
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (w *ActionWorker) runOnce(ctx context.Context) error {
now := time.Now().UTC()
actions, err := w.store.ClaimModerationActions(ctx, now, w.batch, w.lease)
if err != nil {
return err
}
for _, action := range actions {
current, currentErr := w.store.IsModerationActionCurrent(ctx, action)
if currentErr == nil && !current {
if err := w.store.SupersedeModerationAction(
ctx, action.ID, action.Attempts, time.Now().UTC(),
); err != nil {
w.log.Warn("提交已被新案件取代的审核处置失败",
zap.Int64("case_id", action.CaseID),
zap.Int64("action_id", action.ID),
zap.String("kind", string(action.Kind)),
zap.Error(err))
} else {
w.log.Info("审核处置已被同目标的更新处置取代",
zap.Int64("case_id", action.CaseID),
zap.Int64("action_id", action.ID),
zap.String("kind", string(action.Kind)))
}
continue
}
detail, found, getErr := w.store.GetModerationCase(ctx, action.CaseID)
execErr := currentErr
if execErr == nil {
execErr = getErr
}
if execErr == nil && !found {
execErr = domain.ErrModerationCaseNotFound
}
if execErr == nil {
execErr = w.executor.Execute(ctx, detail, action)
}
finishedAt := time.Now().UTC()
retryAt := finishedAt
errorText := ""
if execErr != nil {
errorText = execErr.Error()
retryAt = finishedAt.Add(moderationActionRetryDelay(action.Attempts))
}
if err := w.store.CompleteModerationAction(
ctx, action.ID, action.Attempts, execErr == nil,
errorText, retryAt, finishedAt,
); err != nil {
w.log.Warn("提交审核处置结果失败",
zap.Int64("action_id", action.ID),
zap.Int("attempts", action.Attempts),
zap.Error(err))
continue
}
if execErr != nil {
w.log.Warn("审核处置等待重试",
zap.Int64("case_id", action.CaseID),
zap.Int64("action_id", action.ID),
zap.String("kind", string(action.Kind)),
zap.Int("attempts", action.Attempts),
zap.Error(execErr))
} else {
w.log.Info("审核处置完成",
zap.Int64("case_id", action.CaseID),
zap.Int64("action_id", action.ID),
zap.String("kind", string(action.Kind)))
}
}
return nil
}
func moderationActionRetryDelay(attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
delay := time.Second << min(attempt-1, 10)
if delay > time.Hour {
return time.Hour
}
return delay
}

View file

@ -0,0 +1,325 @@
package moderation
import (
"context"
"errors"
"testing"
"time"
"go.uber.org/zap"
"telesrv/internal/admin"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type captureModerationAdmin struct {
userFlags []admin.SetUserFlagsRequest
frozen []admin.SetAccountFrozenRequest
}
func (a *captureModerationAdmin) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
a.frozen = append(a.frozen, req)
return admin.CommandResult{}, nil
}
func (a *captureModerationAdmin) SetUserFlags(_ context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error) {
a.userFlags = append(a.userFlags, req)
return admin.CommandResult{}, nil
}
func (*captureModerationAdmin) SetChannelFlags(context.Context, admin.SetChannelFlagsRequest) (admin.CommandResult, error) {
return admin.CommandResult{}, nil
}
func (*captureModerationAdmin) DeletePrivateMessages(context.Context, admin.DeletePrivateMessagesRequest) (admin.CommandResult, error) {
return admin.CommandResult{}, nil
}
func TestActionWorkerAppliesFakeFlagAndResolvesCase(t *testing.T) {
ctx := context.Background()
now := time.Now().UTC().Add(-10 * time.Second)
reports := memory.NewModerationReportStore()
service := NewService(reports)
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
ReporterUserID: 101, Source: domain.ModerationSourceAccountPeer,
Target: target, Reason: domain.ModerationReasonFake, Option: "fake",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"schema_version":1}`),
}},
CreatedAt: now,
}); err != nil {
t.Fatal(err)
}
cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{Limit: 10})
if err != nil || len(cases) != 1 {
t.Fatalf("cases=%+v err=%v", cases, err)
}
claimed, err := service.ClaimCase(ctx, cases[0].ID, cases[0].Version, "reviewer", now.Add(time.Second))
if err != nil {
t.Fatal(err)
}
decision, created, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
Actor: "reviewer", Reason: "impersonation confirmed",
CommandID: "mod-fake-1", Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(2 * time.Second),
})
if err != nil || !created || decision.Case.Status != domain.ModerationCaseActionPending {
t.Fatalf("decision=%+v created=%v err=%v", decision, created, err)
}
adminActions := &captureModerationAdmin{}
worker := NewActionWorker(
reports,
NewActionExecutor(adminActions, nil, nil, nil),
zap.NewNop(),
)
if err := worker.runOnce(ctx); err != nil {
t.Fatal(err)
}
if len(adminActions.userFlags) != 1 {
t.Fatalf("flag actions=%d, want 1", len(adminActions.userFlags))
}
flag := adminActions.userFlags[0]
if flag.UserID != target.ID || flag.Scam || !flag.Fake ||
flag.CommandID != "mod-fake-1:000" || flag.Actor != "reviewer" {
t.Fatalf("flag request=%+v", flag)
}
resolved, found, err := service.Case(ctx, claimed.ID)
if err != nil || !found || resolved.Case.Status != domain.ModerationCaseResolved ||
resolved.Actions[0].Status != domain.ModerationActionSucceeded {
t.Fatalf("resolved=%+v found=%v err=%v", resolved, found, err)
}
}
func TestActionWorkerSupersedesOlderTargetSanction(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_750_000_000, 0).UTC()
reports := memory.NewModerationReportStore()
service := NewService(reports)
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
createDecision := func(reporter int64, command string, kind domain.ModerationActionKind, at time.Time) int64 {
t.Helper()
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
ReporterUserID: reporter, Source: domain.ModerationSourceAccountPeer,
Target: target, Reason: domain.ModerationReasonFake, Option: command,
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"schema_version":1}`),
}},
CreatedAt: at,
}); err != nil {
t.Fatal(err)
}
cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{
Statuses: []domain.ModerationCaseStatus{domain.ModerationCaseOpen},
Target: target, Limit: 10,
})
if err != nil || len(cases) != 1 {
t.Fatalf("open cases=%+v err=%v", cases, err)
}
claimed, err := service.ClaimCase(ctx, cases[0].ID, cases[0].Version, "reviewer", at.Add(time.Second))
if err != nil {
t.Fatal(err)
}
if _, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
Actor: "reviewer", Reason: "confirmed", CommandID: command,
Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{Kind: kind, Payload: []byte(`{}`)}},
CreatedAt: at.Add(2 * time.Second),
}); err != nil {
t.Fatal(err)
}
return claimed.ID
}
oldCaseID := createDecision(101, "old-scam", domain.ModerationActionMarkScam, now)
newCaseID := createDecision(102, "new-fake", domain.ModerationActionMarkFake, now.Add(3*time.Second))
adminActions := &captureModerationAdmin{}
worker := NewActionWorker(
reports,
NewActionExecutor(adminActions, nil, nil, nil),
zap.NewNop(),
)
if err := worker.runOnce(ctx); err != nil {
t.Fatal(err)
}
if len(adminActions.userFlags) != 1 || adminActions.userFlags[0].Scam ||
!adminActions.userFlags[0].Fake {
t.Fatalf("flag actions=%+v", adminActions.userFlags)
}
oldDetail, _, err := service.Case(ctx, oldCaseID)
if err != nil || oldDetail.Case.Status != domain.ModerationCaseResolved ||
len(oldDetail.Actions) != 1 ||
oldDetail.Actions[0].Status != domain.ModerationActionSuperseded {
t.Fatalf("old detail=%+v err=%v", oldDetail, err)
}
newDetail, _, err := service.Case(ctx, newCaseID)
if err != nil || newDetail.Case.Status != domain.ModerationCaseResolved ||
len(newDetail.Actions) != 1 ||
newDetail.Actions[0].Status != domain.ModerationActionSucceeded {
t.Fatalf("new detail=%+v err=%v", newDetail, err)
}
}
func TestAppealCannotClearSanctionOwnedByNewerCase(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_750_000_000, 0).UTC()
reports := memory.NewModerationReportStore()
service := NewService(reports)
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
createCase := func(reporter int64, option, command string, at time.Time) domain.ModerationCase {
t.Helper()
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
ReporterUserID: reporter, Source: domain.ModerationSourceAccountPeer,
Target: target, Reason: domain.ModerationReasonFake, Option: option,
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"schema_version":1}`),
}},
CreatedAt: at,
}); err != nil {
t.Fatal(err)
}
items, err := service.ListCases(ctx, domain.ModerationCaseFilter{
Statuses: []domain.ModerationCaseStatus{domain.ModerationCaseOpen},
Target: target, Limit: 10,
})
if err != nil || len(items) != 1 {
t.Fatalf("open cases=%+v err=%v", items, err)
}
claimed, err := service.ClaimCase(ctx, items[0].ID, items[0].Version, "reviewer", at.Add(time.Second))
if err != nil {
t.Fatal(err)
}
if _, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
Actor: "reviewer", Reason: "confirmed", CommandID: command,
Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
}},
CreatedAt: at.Add(2 * time.Second),
}); err != nil {
t.Fatal(err)
}
detail, _, err := service.Case(ctx, claimed.ID)
if err != nil {
t.Fatal(err)
}
return detail.Case
}
oldCase := createCase(101, "old", "old", now)
adminActions := &captureModerationAdmin{}
worker := NewActionWorker(reports, NewActionExecutor(adminActions, nil, nil, nil), zap.NewNop())
if err := worker.runOnce(ctx); err != nil {
t.Fatal(err)
}
appeal, _, err := service.SubmitAppeal(ctx, oldCase.ID, target.ID, "mistake", now.Add(3*time.Second))
if err != nil {
t.Fatal(err)
}
newCase := createCase(102, "new", "new", now.Add(4*time.Second))
if newCase.ID == oldCase.ID {
t.Fatal("new report reused decided case")
}
oldDetail, _, err := service.Case(ctx, oldCase.ID)
if err != nil {
t.Fatal(err)
}
claimed, err := service.ClaimCase(ctx, oldCase.ID, oldDetail.Case.Version, "reviewer", now.Add(8*time.Second))
if err != nil {
t.Fatal(err)
}
_, _, err = service.ReviewAppeal(ctx, domain.ModerationDecisionRequest{
CaseID: oldCase.ID, AppealID: appeal.ID,
ExpectedVersion: claimed.Version, Actor: "reviewer",
Reason: "grant", CommandID: "stale-appeal",
Kind: domain.ModerationDecisionAppealGrant,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionClearPeerFlags, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(9 * time.Second),
})
if !errors.Is(err, domain.ErrModerationActionConflict) {
t.Fatalf("ReviewAppeal error=%v", err)
}
}
type captureAppealLinkIssuer struct {
caseID int64
appellantID int64
expiresAt time.Time
issuedAt time.Time
returnedToken string
}
func (i *captureAppealLinkIssuer) IssueAppealLink(_ context.Context, caseID, appellantUserID int64, expiresAt, now time.Time) (string, error) {
i.caseID = caseID
i.appellantID = appellantUserID
i.expiresAt = expiresAt
i.issuedAt = now
return i.returnedToken, nil
}
func TestActionExecutorFreezeDefaultsAndBoundsAppealLink(t *testing.T) {
now := time.Unix(1_750_000_000, 0).UTC()
adminActions := &captureModerationAdmin{}
issuer := &captureAppealLinkIssuer{returnedToken: "token"}
executor := NewActionExecutor(
adminActions, nil, nil, nil,
WithActionClock(func() time.Time { return now }),
WithAppealLinks(issuer, "https://example.test/"),
)
detail := domain.ModerationCaseDetail{
Case: domain.ModerationCase{
ID: 10, Target: domain.Peer{Type: domain.PeerTypeUser, ID: 20},
},
Decisions: []domain.ModerationDecision{{
ID: 30, Actor: "reviewer",
}},
}
action := domain.ModerationAction{
CaseID: 10, DecisionID: 30,
Kind: domain.ModerationActionFreezeAccount,
Payload: []byte(`{}`), CommandID: "freeze:000",
}
if err := executor.Execute(context.Background(), detail, action); err != nil {
t.Fatal(err)
}
if len(adminActions.frozen) != 1 {
t.Fatalf("freeze calls=%d", len(adminActions.frozen))
}
req := adminActions.frozen[0]
wantUntil := now.Add(30 * 24 * time.Hour)
if !req.Frozen || req.UserID != 20 || !req.Until.Equal(wantUntil) ||
req.AppealURL != "https://example.test/appeal/token" {
t.Fatalf("freeze request=%+v", req)
}
if issuer.caseID != 10 || issuer.appellantID != 20 ||
!issuer.expiresAt.Equal(wantUntil) || !issuer.issuedAt.Equal(now) {
t.Fatalf("appeal issue=%+v", issuer)
}
adminActions.frozen = nil
longUntil := now.Add(365 * 24 * time.Hour)
action.Payload = []byte(`{"until":"` + longUntil.Format(time.RFC3339Nano) + `"}`)
if err := executor.Execute(context.Background(), detail, action); err != nil {
t.Fatal(err)
}
if !adminActions.frozen[0].Until.Equal(longUntil) {
t.Fatalf("long freeze until=%v", adminActions.frozen[0].Until)
}
if want := now.Add(domain.MaxModerationAppealLinkLifetime); !issuer.expiresAt.Equal(want) {
t.Fatalf("link expiry=%v want=%v", issuer.expiresAt, want)
}
}

View file

@ -0,0 +1,83 @@
package moderation
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"time"
"telesrv/internal/domain"
)
const moderationAppealTokenBytes = 32
// IssueAppealLink creates a hash-only, time-bounded bearer capability. The raw
// token is returned once and must only be embedded in the affected user's
// client-visible appeal URL.
func (s *Service) IssueAppealLink(ctx context.Context, caseID, appellantUserID int64, expiresAt, now time.Time) (string, error) {
if s == nil || s.cases == nil {
return "", fmt.Errorf("moderation case store is not configured")
}
for attempt := 0; attempt < 3; attempt++ {
raw := make([]byte, moderationAppealTokenBytes)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate moderation appeal token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(raw)
link := domain.ModerationAppealLink{
CaseID: caseID, AppellantUserID: appellantUserID,
TokenHash: sha256.Sum256(raw), ExpiresAt: expiresAt.UTC(),
CreatedAt: now.UTC(),
}
if _, err := s.cases.IssueModerationAppealLink(ctx, link); err == nil {
return token, nil
} else if !errors.Is(err, domain.ErrModerationActionConflict) {
return "", err
}
}
return "", domain.ErrModerationActionConflict
}
func (s *Service) ResolveAppealLink(ctx context.Context, token string, now time.Time) (domain.ModerationAppealLink, bool, error) {
if s == nil || s.cases == nil {
return domain.ModerationAppealLink{}, false, fmt.Errorf("moderation case store is not configured")
}
hash, err := moderationAppealTokenHash(token)
if err != nil {
return domain.ModerationAppealLink{}, false, err
}
return s.cases.GetModerationAppealLink(ctx, hash, now.UTC())
}
func (s *Service) Appeal(ctx context.Context, appealID int64) (domain.ModerationAppeal, bool, error) {
if s == nil || s.cases == nil {
return domain.ModerationAppeal{}, false, fmt.Errorf("moderation case store is not configured")
}
return s.cases.GetModerationAppeal(ctx, appealID)
}
func (s *Service) SubmitAppealLink(ctx context.Context, token, text string, now time.Time) (domain.ModerationAppeal, bool, error) {
if s == nil || s.cases == nil {
return domain.ModerationAppeal{}, false, fmt.Errorf("moderation case store is not configured")
}
hash, err := moderationAppealTokenHash(token)
if err != nil {
return domain.ModerationAppeal{}, false, err
}
return s.cases.SubmitModerationAppealByLink(ctx, hash, text, now.UTC())
}
func moderationAppealTokenHash(token string) ([sha256.Size]byte, error) {
if len(token) != base64.RawURLEncoding.EncodedLen(moderationAppealTokenBytes) {
return [sha256.Size]byte{}, domain.ErrModerationAppealLinkInvalid
}
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil || len(raw) != moderationAppealTokenBytes ||
base64.RawURLEncoding.EncodeToString(raw) != token {
return [sha256.Size]byte{}, domain.ErrModerationAppealLinkInvalid
}
return sha256.Sum256(raw), nil
}

View file

@ -0,0 +1,178 @@
package moderation
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestAppealLinkSubmissionIsHashOnlyIdempotentAndExpires(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_750_000_000, 0).UTC()
store := memory.NewModerationReportStore()
service := NewService(store)
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
ReporterUserID: 101, Source: domain.ModerationSourceAccountPeer,
Target: target, Reason: domain.ModerationReasonFake, Option: "fake",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"schema_version":1}`),
}},
CreatedAt: now,
}); err != nil {
t.Fatal(err)
}
cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{Limit: 10})
if err != nil || len(cases) != 1 {
t.Fatalf("cases=%+v err=%v", cases, err)
}
claimed, err := service.ClaimCase(
ctx, cases[0].ID, cases[0].Version, "reviewer", now.Add(time.Second),
)
if err != nil {
t.Fatal(err)
}
detail, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
Actor: "reviewer", Reason: "confirmed", CommandID: "appeal-link-decision",
Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(2 * time.Second),
})
if err != nil {
t.Fatal(err)
}
token, err := service.IssueAppealLink(
ctx, claimed.ID, target.ID, now.Add(24*time.Hour), now.Add(3*time.Second),
)
if err != nil {
t.Fatal(err)
}
if len(token) != 43 {
t.Fatalf("token length=%d", len(token))
}
link, found, err := service.ResolveAppealLink(ctx, token, now.Add(4*time.Second))
if err != nil || !found || link.TokenHash == ([32]byte{}) {
t.Fatalf("link=%+v found=%v err=%v", link, found, err)
}
expiredToken, err := service.IssueAppealLink(
ctx, claimed.ID, target.ID, now.Add(10*time.Second), now.Add(4*time.Second),
)
if err != nil {
t.Fatal(err)
}
for i := 0; i < domain.MaxModerationAppealLinksPerCase-2; i++ {
issuedAt := now.Add(time.Duration(20+i) * time.Second)
if _, err := service.IssueAppealLink(
ctx, claimed.ID, target.ID, now.Add(time.Hour), issuedAt,
); err != nil {
t.Fatalf("issue bounded link %d: %v", i, err)
}
}
if _, err := service.IssueAppealLink(
ctx, claimed.ID, target.ID, now.Add(time.Hour), now.Add(time.Minute),
); !errors.Is(err, domain.ErrModerationActionConflict) {
t.Fatalf("appeal link overflow err=%v", err)
}
if actions, err := store.ClaimModerationActions(
ctx, now.Add(5*time.Second), 10, time.Minute,
); err != nil || len(actions) != 1 {
t.Fatalf("actions=%+v err=%v", actions, err)
} else if err := store.CompleteModerationAction(
ctx, actions[0].ID, actions[0].Attempts, true, "",
time.Time{}, now.Add(6*time.Second),
); err != nil {
t.Fatal(err)
}
appeal, created, err := service.SubmitAppealLink(
ctx, token, "The account was impersonated.", now.Add(7*time.Second),
)
if err != nil || !created || appeal.CaseID != claimed.ID ||
appeal.AppellantUserID != target.ID {
t.Fatalf("appeal=%+v created=%v err=%v", appeal, created, err)
}
retry, created, err := service.SubmitAppealLink(
ctx, token, "A different retry body must not create another appeal.",
now.Add(8*time.Second),
)
if err != nil || created || retry.ID != appeal.ID || retry.Text != appeal.Text {
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
}
appealed, found, err := service.Case(ctx, detail.Case.ID)
if err != nil || !found ||
appealed.Case.Status != domain.ModerationCaseAppealReview ||
len(appealed.Appeals) != 1 {
t.Fatalf("appealed=%+v found=%v err=%v", appealed, found, err)
}
appealClaim, err := service.ClaimCase(
ctx, appealed.Case.ID, appealed.Case.Version,
"appeal-reviewer", now.Add(8*time.Second),
)
if err != nil {
t.Fatal(err)
}
grant := domain.ModerationDecisionRequest{
CaseID: appealClaim.ID, AppealID: appeal.ID,
ExpectedVersion: appealClaim.Version, Actor: "appeal-reviewer",
Reason: "original evidence was insufficient",
CommandID: "appeal-grant-without-remedy",
Kind: domain.ModerationDecisionAppealGrant,
CreatedAt: now.Add(9 * time.Second),
}
if _, _, err := service.ReviewAppeal(ctx, grant); !errors.Is(
err, domain.ErrModerationActionInvalid,
) {
t.Fatalf("grant without required flag remedy err=%v", err)
}
grant.CommandID = "appeal-grant-with-remedy"
grant.Actions = []domain.ModerationActionDraft{{
Kind: domain.ModerationActionClearPeerFlags, Payload: []byte(`{}`),
}}
granted, created, err := service.ReviewAppeal(ctx, grant)
if err != nil || !created ||
granted.Case.Status != domain.ModerationCaseActionPending {
t.Fatalf("granted=%+v created=%v err=%v", granted, created, err)
}
remedies, err := store.ClaimModerationActions(
ctx, now.Add(10*time.Second), 10, time.Minute,
)
if err != nil || len(remedies) != 1 ||
remedies[0].Kind != domain.ModerationActionClearPeerFlags {
t.Fatalf("remedies=%+v err=%v", remedies, err)
}
if err := store.CompleteModerationAction(
ctx, remedies[0].ID, remedies[0].Attempts, true, "",
time.Time{}, now.Add(11*time.Second),
); err != nil {
t.Fatal(err)
}
dismissed, found, err := service.Case(ctx, appealed.Case.ID)
if err != nil || !found ||
dismissed.Case.Status != domain.ModerationCaseDismissed {
t.Fatalf("dismissed=%+v found=%v err=%v", dismissed, found, err)
}
if _, found, err := service.ResolveAppealLink(
ctx, expiredToken, now.Add(10*time.Second),
); err != nil || found {
t.Fatalf("expired resolve found=%v err=%v", found, err)
}
if _, _, err := service.SubmitAppealLink(
ctx, expiredToken, "too late", now.Add(10*time.Second),
); !errors.Is(err, domain.ErrModerationAppealLinkInvalid) {
t.Fatalf("expired submit err=%v", err)
}
if _, _, err := service.ResolveAppealLink(ctx, "not-a-token", now); !errors.Is(
err, domain.ErrModerationAppealLinkInvalid,
) {
t.Fatalf("invalid token err=%v", err)
}
}

View file

@ -0,0 +1,180 @@
package moderation
import (
"context"
"fmt"
"sort"
"time"
"telesrv/internal/domain"
)
func (s *Service) ListCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
if s == nil || s.cases == nil {
return nil, fmt.Errorf("moderation case store is not configured")
}
return s.cases.ListModerationCases(ctx, filter)
}
func (s *Service) Case(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error) {
if s == nil || s.cases == nil {
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation case store is not configured")
}
return s.cases.GetModerationCase(ctx, caseID)
}
func (s *Service) ClaimCase(ctx context.Context, caseID, expectedVersion int64, actor string, now time.Time) (domain.ModerationCase, error) {
if s == nil || s.cases == nil {
return domain.ModerationCase{}, fmt.Errorf("moderation case store is not configured")
}
return s.cases.ClaimModerationCase(ctx, caseID, expectedVersion, actor, now)
}
func (s *Service) DecideCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
if s == nil || s.cases == nil {
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation case store is not configured")
}
prepared, err := domain.NewModerationDecisionRequest(request)
if err != nil {
return domain.ModerationCaseDetail{}, false, err
}
detail, found, err := s.cases.GetModerationCase(ctx, prepared.CaseID)
if err != nil {
return domain.ModerationCaseDetail{}, false, err
}
if !found {
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
}
if err := s.validateDecisionActions(ctx, detail, prepared.Actions); err != nil {
return domain.ModerationCaseDetail{}, false, err
}
return s.cases.DecideModerationCase(ctx, prepared)
}
func (s *Service) SubmitAppeal(ctx context.Context, caseID, appellantUserID int64, text string, now time.Time) (domain.ModerationAppeal, bool, error) {
if s == nil || s.cases == nil {
return domain.ModerationAppeal{}, false, fmt.Errorf("moderation case store is not configured")
}
detail, found, err := s.cases.GetModerationCase(ctx, caseID)
if err != nil {
return domain.ModerationAppeal{}, false, err
}
if !found {
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseNotFound
}
switch detail.Case.Target.Type {
case domain.PeerTypeUser:
if detail.Case.Target.ID != appellantUserID {
return domain.ModerationAppeal{}, false, domain.ErrModerationPermissionDenied
}
case domain.PeerTypeChannel:
if s.channels == nil {
return domain.ModerationAppeal{}, false, domain.ErrModerationPermissionDenied
}
view, err := s.channels.ResolveChannel(ctx, appellantUserID, detail.Case.Target.ID)
if err != nil || view.Forbidden ||
(view.Self.Role != domain.ChannelRoleCreator &&
view.Self.Role != domain.ChannelRoleAdmin) {
return domain.ModerationAppeal{}, false, domain.ErrModerationPermissionDenied
}
default:
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseInvalid
}
appeal, err := domain.NewModerationAppeal(
caseID, appellantUserID, detail.Case.Status, text, now,
)
if err != nil {
return domain.ModerationAppeal{}, false, err
}
return s.cases.CreateModerationAppeal(ctx, appeal)
}
func (s *Service) ReviewAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
if s == nil || s.cases == nil {
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation case store is not configured")
}
prepared, err := domain.NewModerationDecisionRequest(request)
if err != nil {
return domain.ModerationCaseDetail{}, false, err
}
if prepared.AppealID <= 0 ||
(prepared.Kind != domain.ModerationDecisionAppealGrant &&
prepared.Kind != domain.ModerationDecisionAppealDeny) {
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseInvalid
}
detail, found, err := s.cases.GetModerationCase(ctx, prepared.CaseID)
if err != nil {
return domain.ModerationCaseDetail{}, false, err
}
if !found {
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
}
appealFound := false
for _, appeal := range detail.Appeals {
if appeal.ID == prepared.AppealID &&
appeal.Status == domain.ModerationAppealPending {
appealFound = true
break
}
}
if !appealFound {
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
}
if err := s.validateDecisionActions(ctx, detail, prepared.Actions); err != nil {
return domain.ModerationCaseDetail{}, false, err
}
if prepared.Kind == domain.ModerationDecisionAppealGrant {
if err := validateAppealRemedyActions(detail, prepared.Actions); err != nil {
return domain.ModerationCaseDetail{}, false, err
}
}
return s.cases.ReviewModerationAppeal(ctx, prepared)
}
func validateAppealRemedyActions(detail domain.ModerationCaseDetail, actions []domain.ModerationActionDraft) error {
history := append([]domain.ModerationAction(nil), detail.Actions...)
sort.Slice(history, func(i, j int) bool { return history[i].ID < history[j].ID })
var flagsActive, freezeActive, irreversible bool
for _, action := range history {
if action.Status != domain.ModerationActionSucceeded {
continue
}
switch action.Kind {
case domain.ModerationActionMarkScam, domain.ModerationActionMarkFake:
flagsActive = true
case domain.ModerationActionClearPeerFlags:
flagsActive = false
case domain.ModerationActionFreezeAccount:
freezeActive = true
case domain.ModerationActionUnfreezeAccount:
freezeActive = false
case domain.ModerationActionDeletePrivateMessage,
domain.ModerationActionDeleteChannelMessage,
domain.ModerationActionDeleteAccount:
irreversible = true
}
}
if irreversible {
return domain.ErrModerationActionInvalid
}
expected := make(map[domain.ModerationActionKind]bool, 2)
if flagsActive {
expected[domain.ModerationActionClearPeerFlags] = true
}
if freezeActive {
expected[domain.ModerationActionUnfreezeAccount] = true
}
if len(actions) != len(expected) {
return domain.ErrModerationActionInvalid
}
for _, action := range actions {
if !expected[action.Kind] {
return domain.ErrModerationActionInvalid
}
delete(expected, action.Kind)
}
if len(expected) != 0 {
return domain.ErrModerationActionInvalid
}
return nil
}

View file

@ -0,0 +1,43 @@
package moderation
import (
"errors"
"testing"
"telesrv/internal/domain"
)
func TestValidateAppealRemedyActionsMatchesOnlyAppliedReversibleState(t *testing.T) {
detail := domain.ModerationCaseDetail{Actions: []domain.ModerationAction{
{ID: 2, Kind: domain.ModerationActionFreezeAccount, Status: domain.ModerationActionSucceeded},
{ID: 1, Kind: domain.ModerationActionMarkScam, Status: domain.ModerationActionSucceeded},
{ID: 3, Kind: domain.ModerationActionDeletePrivateMessage, Status: domain.ModerationActionFailed},
}}
remedies := []domain.ModerationActionDraft{
{Kind: domain.ModerationActionClearPeerFlags},
{Kind: domain.ModerationActionUnfreezeAccount},
}
if err := validateAppealRemedyActions(detail, remedies); err != nil {
t.Fatalf("valid remedies err=%v", err)
}
if err := validateAppealRemedyActions(
detail, remedies[:1],
); !errors.Is(err, domain.ErrModerationActionInvalid) {
t.Fatalf("missing unfreeze err=%v", err)
}
if err := validateAppealRemedyActions(detail, []domain.ModerationActionDraft{
{Kind: domain.ModerationActionMarkFake},
{Kind: domain.ModerationActionUnfreezeAccount},
}); !errors.Is(err, domain.ErrModerationActionInvalid) {
t.Fatalf("new punishment in appeal err=%v", err)
}
detail.Actions = append(detail.Actions, domain.ModerationAction{
ID: 4, Kind: domain.ModerationActionDeletePrivateMessage,
Status: domain.ModerationActionSucceeded,
})
if err := validateAppealRemedyActions(
detail, remedies,
); !errors.Is(err, domain.ErrModerationActionInvalid) {
t.Fatalf("irreversible grant err=%v", err)
}
}

View file

@ -0,0 +1,739 @@
package moderation
import (
"bytes"
"context"
"encoding/json"
"fmt"
"sort"
"strconv"
"time"
"telesrv/internal/domain"
)
type privateMessageReader interface {
GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error)
GetMessageReactions(ctx context.Context, userID int64, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error)
}
type channelMessageReader interface {
GetMessages(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelHistory, error)
FindMessageReaction(ctx context.Context, userID int64, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error)
}
type storyReader interface {
GetStoriesByID(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, now int) (domain.StoryList, error)
}
type userReader interface {
ByID(ctx context.Context, viewerUserID, userID int64) (domain.User, bool, error)
}
type channelPeerReader interface {
ResolveChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error)
}
type profilePhotoReader interface {
GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]domain.Photo, int, error)
}
func (s *Service) ReportMessages(ctx context.Context, req domain.ModerationMessageReportRequest) (domain.ModerationReport, bool, error) {
ids, err := canonicalPositiveIDs(req.MessageIDs, domain.MaxMessageBoxID)
if err != nil || req.ReporterUserID <= 0 || req.Target.ID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
items := make([]domain.ModerationReportItem, 0, len(ids))
holds := make([]domain.ModerationMediaHold, 0)
switch req.Target.Type {
case domain.PeerTypeUser:
if s == nil || s.privateMessages == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation private message reader is not configured")
}
list, err := s.privateMessages.GetMessages(ctx, req.ReporterUserID, ids)
if err != nil {
return domain.ModerationReport{}, false, err
}
byID := make(map[int]domain.Message, len(list.Messages))
for _, message := range list.Messages {
if message.Peer == req.Target {
byID[message.ID] = message
}
}
for _, id := range ids {
message, found := byID[id]
if !found {
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
evidence, err := json.Marshal(privateMessageEvidence(message))
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("marshal private message evidence: %w", err)
}
items = append(items, domain.ModerationReportItem{
Kind: domain.ModerationItemMessage, Peer: req.Target,
ItemID: int64(message.ID), AuthorUserID: message.From.ID,
EvidenceSchemaVersion: 1, Evidence: evidence,
})
holds = append(holds, mediaHolds(len(items)-1, message.Media)...)
}
case domain.PeerTypeChannel:
if s == nil || s.channelMessages == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation channel message reader is not configured")
}
history, err := s.channelMessages.GetMessages(ctx, req.ReporterUserID, req.Target.ID, ids)
if err != nil {
return domain.ModerationReport{}, false, err
}
byID := make(map[int]domain.ChannelMessage, len(history.Messages))
for _, message := range history.Messages {
if message.ChannelID == req.Target.ID && !message.Deleted {
byID[message.ID] = message
}
}
for _, id := range ids {
message, found := byID[id]
if !found {
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
evidence, err := marshalChannelMessageEvidence(message)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("marshal channel message evidence: %w", err)
}
items = append(items, domain.ModerationReportItem{
Kind: domain.ModerationItemMessage, Peer: req.Target,
ItemID: int64(message.ID), AuthorUserID: message.SenderUserID,
EvidenceSchemaVersion: 1, Evidence: evidence,
})
holds = append(holds, mediaHolds(len(items)-1, message.Media)...)
}
default:
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
return s.AcceptReport(ctx, domain.ModerationReportDraft{
ReporterUserID: req.ReporterUserID,
Source: domain.ModerationSourceMessages,
Target: req.Target,
Reason: req.Reason,
Option: req.Option,
Comment: req.Comment,
Items: items,
MediaHolds: dedupeMediaHolds(holds),
CreatedAt: req.CreatedAt,
})
}
func (s *Service) ReportPeer(ctx context.Context, reporterUserID int64, source domain.ModerationReportSource, target domain.Peer, reason domain.ModerationReason, option, comment string, createdAt time.Time) (domain.ModerationReport, bool, error) {
if source != domain.ModerationSourceAccountPeer && source != domain.ModerationSourceMessagesSpam {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
snapshot := peerEvidenceV1{SchemaVersion: 1, Target: target}
switch target.Type {
case domain.PeerTypeUser:
if s == nil || s.users == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation user reader is not configured")
}
user, found, err := s.users.ByID(ctx, reporterUserID, target.ID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found || user.Deleted {
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
snapshot.User = &peerUserEvidenceV1{
ID: user.ID, FirstName: user.FirstName, LastName: user.LastName,
Username: user.Username, About: user.About, Bot: user.Bot,
Verified: user.Verified, Scam: user.Scam, Fake: user.Fake,
PhotoID: user.PhotoID,
}
case domain.PeerTypeChannel:
if s == nil || s.channels == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation channel reader is not configured")
}
view, err := s.channels.ResolveChannel(ctx, reporterUserID, target.ID)
if err != nil {
return domain.ModerationReport{}, false, err
}
channel := view.Channel
snapshot.Channel = &peerChannelEvidenceV1{
ID: channel.ID, Title: channel.Title, About: channel.About,
Username: channel.Username, Broadcast: channel.Broadcast,
Megagroup: channel.Megagroup, Verified: channel.Verified,
Scam: channel.Scam, Fake: channel.Fake, PhotoID: channel.PhotoID,
}
default:
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
evidence, err := json.Marshal(snapshot)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("marshal peer evidence: %w", err)
}
authorUserID := int64(0)
if target.Type == domain.PeerTypeUser {
authorUserID = target.ID
}
return s.AcceptReport(ctx, domain.ModerationReportDraft{
ReporterUserID: reporterUserID, Source: source, Target: target,
Reason: reason, Option: option, Comment: comment,
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
AuthorUserID: authorUserID, EvidenceSchemaVersion: 1,
Evidence: evidence,
}},
CreatedAt: createdAt,
})
}
func (s *Service) ReportProfilePhoto(ctx context.Context, req domain.ModerationProfilePhotoReportRequest) (domain.ModerationReport, bool, error) {
if req.ReporterUserID <= 0 || req.Target.ID <= 0 || req.PhotoID <= 0 ||
!req.Reason.Valid() || s == nil || s.photos == nil {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
photos, _, err := s.photos.GetProfilePhotos(ctx, req.Target.Type, req.Target.ID, -1, 1, req.PhotoID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if len(photos) != 1 || photos[0].ID != req.PhotoID ||
photos[0].AccessHash != req.AccessHash {
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
photo := photos[0]
if len(req.FileReference) > 0 && len(photo.FileReference) > 0 &&
!bytes.Equal(req.FileReference, photo.FileReference) {
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
evidence, err := json.Marshal(profilePhotoEvidenceV1{
SchemaVersion: 1, Owner: req.Target, Photo: photo,
})
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("marshal profile photo evidence: %w", err)
}
authorUserID := int64(0)
if req.Target.Type == domain.PeerTypeUser {
authorUserID = req.Target.ID
}
return s.AcceptReport(ctx, domain.ModerationReportDraft{
ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceProfilePhoto,
Target: req.Target, Reason: req.Reason, Option: string(req.Reason),
Comment: req.Comment, CreatedAt: req.CreatedAt,
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemProfilePhoto, Peer: req.Target,
ItemID: req.PhotoID, AuthorUserID: authorUserID,
EvidenceSchemaVersion: 1, Evidence: evidence,
}},
MediaHolds: photoHolds(0, photo),
})
}
func (s *Service) ReportChannelSpam(ctx context.Context, req domain.ModerationChannelSpamReportRequest) (domain.ModerationReport, bool, error) {
if req.ReporterUserID <= 0 || req.ChannelID <= 0 || req.ParticipantUserID <= 0 ||
s == nil || s.channelMessages == nil {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
ids, err := canonicalPositiveIDs(req.MessageIDs, domain.MaxMessageBoxID)
if err != nil {
return domain.ModerationReport{}, false, err
}
history, err := s.channelMessages.GetMessages(ctx, req.ReporterUserID, req.ChannelID, ids)
if err != nil {
return domain.ModerationReport{}, false, err
}
byID := make(map[int]domain.ChannelMessage, len(history.Messages))
for _, message := range history.Messages {
if message.ChannelID == req.ChannelID && !message.Deleted {
byID[message.ID] = message
}
}
items := make([]domain.ModerationReportItem, 0, len(ids))
holds := make([]domain.ModerationMediaHold, 0)
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
for _, id := range ids {
message, found := byID[id]
if !found || message.SenderUserID != req.ParticipantUserID {
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
evidence, err := marshalChannelMessageEvidence(message)
if err != nil {
return domain.ModerationReport{}, false, err
}
items = append(items, domain.ModerationReportItem{
Kind: domain.ModerationItemMessage, Peer: target,
ItemID: int64(message.ID), AuthorUserID: req.ParticipantUserID,
EvidenceSchemaVersion: 1, Evidence: evidence,
})
holds = append(holds, mediaHolds(len(items)-1, message.Media)...)
}
return s.AcceptReport(ctx, domain.ModerationReportDraft{
ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceChannelSpam,
Target: target, Reason: domain.ModerationReasonSpam, Option: "spam",
Items: items, MediaHolds: dedupeMediaHolds(holds), CreatedAt: req.CreatedAt,
})
}
func (s *Service) ReportReaction(ctx context.Context, req domain.ModerationReactionReportRequest) (domain.ModerationReport, bool, error) {
if req.ReporterUserID <= 0 || req.Target.ID <= 0 || req.MessageID <= 0 ||
req.MessageID > domain.MaxMessageBoxID || req.ReactorUserID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
var evidence []byte
var err error
switch req.Target.Type {
case domain.PeerTypeUser:
if s == nil || s.privateMessages == nil {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
result, err := s.privateMessages.GetMessageReactions(ctx, req.ReporterUserID, domain.PrivateMessageReactionsRequest{
OwnerUserID: req.ReporterUserID, Peer: req.Target, IDs: []int{req.MessageID},
})
if err != nil {
return domain.ModerationReport{}, false, err
}
if len(result.Messages) != 1 || result.Messages[0].ID != req.MessageID ||
result.Messages[0].Peer != req.Target {
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
reactions := reactionRowsForUser(result.Messages[0].Reactions, req.ReactorUserID)
if len(reactions) == 0 {
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
evidence, err = json.Marshal(privateReactionEvidenceV1{
SchemaVersion: 1, Message: privateMessageEvidence(result.Messages[0]),
ReactorUserID: req.ReactorUserID, Reactions: reactionEvidenceRows(reactions),
})
case domain.PeerTypeChannel:
if s == nil || s.channelMessages == nil {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
lookup, found, lookupErr := s.channelMessages.FindMessageReaction(ctx, req.ReporterUserID, domain.ChannelMessageReactionLookupRequest{
ViewerUserID: req.ReporterUserID, ChannelID: req.Target.ID,
MessageID: req.MessageID, ReactorUserID: req.ReactorUserID,
})
if lookupErr != nil {
return domain.ModerationReport{}, false, lookupErr
}
if !found {
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
evidence, err = json.Marshal(channelReactionEvidenceV1{
SchemaVersion: 1, Message: channelMessageEvidence(lookup.Message),
ReactorUserID: req.ReactorUserID, Reactions: reactionEvidenceRows(lookup.Reactions),
})
default:
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("marshal reaction evidence: %w", err)
}
return s.AcceptReport(ctx, domain.ModerationReportDraft{
ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceReaction,
Target: req.Target, Reason: domain.ModerationReasonOther,
Option: "reaction", CreatedAt: req.CreatedAt,
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemReaction, Peer: req.Target,
ItemID: int64(req.MessageID), SecondaryID: req.ReactorUserID,
AuthorUserID: req.ReactorUserID, EvidenceSchemaVersion: 1,
Evidence: evidence,
}},
})
}
func (s *Service) ReportEncryptedSpam(ctx context.Context, reporterUserID int64, chat domain.SecretChat, createdAt time.Time) (domain.ModerationReport, bool, error) {
if reporterUserID <= 0 || !chat.HasParticipant(reporterUserID) || chat.ID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationPermissionDenied
}
offenderUserID := chat.PeerOf(reporterUserID)
if offenderUserID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
target := domain.Peer{Type: domain.PeerTypeUser, ID: offenderUserID}
evidence, err := json.Marshal(encryptedChatEvidenceV1{
SchemaVersion: 1, ChatID: chat.ID, State: chat.State,
AdminUserID: chat.AdminUserID, ParticipantUserID: chat.ParticipantUserID,
Date: chat.Date,
})
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("marshal encrypted chat evidence: %w", err)
}
return s.AcceptReport(ctx, domain.ModerationReportDraft{
ReporterUserID: reporterUserID, Source: domain.ModerationSourceEncryptedSpam,
Target: target, Reason: domain.ModerationReasonSpam, Option: "spam",
CreatedAt: createdAt,
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemEncryptedChat, Peer: target,
ItemID: int64(chat.ID), AuthorUserID: offenderUserID,
EvidenceSchemaVersion: 1, Evidence: evidence,
}},
})
}
func (s *Service) ReportStories(ctx context.Context, req domain.ModerationStoryReportRequest) (domain.ModerationReport, bool, error) {
ids, err := canonicalPositiveIDs(req.StoryIDs, domain.MaxStoryID)
if err != nil || req.ReporterUserID <= 0 || req.Target.ID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
if s == nil || s.stories == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation story reader is not configured")
}
list, err := s.stories.GetStoriesByID(ctx, req.ReporterUserID, req.Target, ids, int(req.CreatedAt.Unix()))
if err != nil {
return domain.ModerationReport{}, false, err
}
byID := make(map[int]domain.Story, len(list.Stories))
for _, story := range list.Stories {
if story.Owner == req.Target && !story.Deleted {
byID[story.ID] = story
}
}
items := make([]domain.ModerationReportItem, 0, len(ids))
holds := make([]domain.ModerationMediaHold, 0)
for _, id := range ids {
story, found := byID[id]
if !found {
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
evidence, err := json.Marshal(storyEvidenceV1{
SchemaVersion: 1, Owner: story.Owner, StoryID: story.ID,
Date: story.Date, ExpireDate: story.ExpireDate, Pinned: story.Pinned,
Public: story.Public, CloseFriends: story.CloseFriends,
Contacts: story.Contacts, SelectedContacts: story.SelectedContacts,
NoForwards: story.NoForwards, Edited: story.Edited,
Caption: story.Caption, Entities: story.Entities, Media: story.Media,
MediaAreas: story.MediaAreas, Forward: story.Forward,
})
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("marshal story evidence: %w", err)
}
authorUserID := int64(0)
if story.Owner.Type == domain.PeerTypeUser {
authorUserID = story.Owner.ID
}
items = append(items, domain.ModerationReportItem{
Kind: domain.ModerationItemStory, Peer: story.Owner,
ItemID: int64(story.ID), AuthorUserID: authorUserID,
EvidenceSchemaVersion: 1, Evidence: evidence,
})
holds = append(holds, mediaHolds(len(items)-1, story.Media)...)
}
return s.AcceptReport(ctx, domain.ModerationReportDraft{
ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceStory,
Target: req.Target, Reason: req.Reason, Option: req.Option,
Comment: req.Comment, Items: items,
MediaHolds: dedupeMediaHolds(holds), CreatedAt: req.CreatedAt,
})
}
func (s *Service) ReportEphemeral(ctx context.Context, reporterUserID int64, target domain.EphemeralMessage, reason domain.ModerationReason, option, comment string, createdAt time.Time) (domain.ModerationReport, bool, error) {
legacy := domain.NewEphemeralAbuseReport(reporterUserID, option, comment, target, createdAt)
if err := legacy.Validate(); err != nil {
return domain.ModerationReport{}, false, err
}
evidence, err := json.Marshal(struct {
SchemaVersion int `json:"schema_version"`
Evidence domain.EphemeralReportEvidence `json:"evidence"`
}{SchemaVersion: 1, Evidence: legacy.Evidence})
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("marshal ephemeral report evidence: %w", err)
}
holds := mediaHolds(0, target.Content.Media)
return s.AcceptReport(ctx, domain.ModerationReportDraft{
ReporterUserID: reporterUserID, Source: domain.ModerationSourceEphemeral,
Target: target.Peer, Reason: reason, Option: option, Comment: comment,
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemEphemeral, Peer: target.Peer,
ItemID: int64(target.ID), AuthorUserID: target.SenderUserID,
EvidenceSchemaVersion: 1, Evidence: evidence,
}},
MediaHolds: holds, CreatedAt: createdAt,
})
}
type peerEvidenceV1 struct {
SchemaVersion int `json:"schema_version"`
Target domain.Peer `json:"target"`
User *peerUserEvidenceV1 `json:"user,omitempty"`
Channel *peerChannelEvidenceV1 `json:"channel,omitempty"`
}
type peerUserEvidenceV1 struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
About string `json:"about"`
Bot bool `json:"bot,omitempty"`
Verified bool `json:"verified,omitempty"`
Scam bool `json:"scam,omitempty"`
Fake bool `json:"fake,omitempty"`
PhotoID int64 `json:"photo_id,omitempty"`
}
type peerChannelEvidenceV1 struct {
ID int64 `json:"id"`
Title string `json:"title"`
About string `json:"about"`
Username string `json:"username"`
Broadcast bool `json:"broadcast,omitempty"`
Megagroup bool `json:"megagroup,omitempty"`
Verified bool `json:"verified,omitempty"`
Scam bool `json:"scam,omitempty"`
Fake bool `json:"fake,omitempty"`
PhotoID int64 `json:"photo_id,omitempty"`
}
type profilePhotoEvidenceV1 struct {
SchemaVersion int `json:"schema_version"`
Owner domain.Peer `json:"owner"`
Photo domain.Photo `json:"photo"`
}
type privateReactionEvidenceV1 struct {
SchemaVersion int `json:"schema_version"`
Message privateMessageEvidenceV1 `json:"message"`
ReactorUserID int64 `json:"reactor_user_id"`
Reactions []messageReactionEvidenceV1 `json:"reactions"`
}
type channelReactionEvidenceV1 struct {
SchemaVersion int `json:"schema_version"`
Message channelMessageEvidenceV1 `json:"message"`
ReactorUserID int64 `json:"reactor_user_id"`
Reactions []messageReactionEvidenceV1 `json:"reactions"`
}
type messageReactionEvidenceV1 struct {
UserID int64 `json:"user_id"`
Type domain.MessageReactionType `json:"type"`
Value string `json:"value"`
Big bool `json:"big,omitempty"`
Unread bool `json:"unread,omitempty"`
ChosenOrder int `json:"chosen_order,omitempty"`
Date int `json:"date"`
}
type encryptedChatEvidenceV1 struct {
SchemaVersion int `json:"schema_version"`
ChatID int `json:"chat_id"`
State domain.SecretChatState `json:"state"`
AdminUserID int64 `json:"admin_user_id"`
ParticipantUserID int64 `json:"participant_user_id"`
Date int `json:"date"`
}
type privateMessageEvidenceV1 struct {
SchemaVersion int `json:"schema_version"`
MessageID int `json:"message_id"`
UID int64 `json:"uid"`
Peer domain.Peer `json:"peer"`
From domain.Peer `json:"from"`
Date int `json:"date"`
EditDate int `json:"edit_date,omitempty"`
Body string `json:"body"`
Entities []domain.MessageEntity `json:"entities,omitempty"`
ReplyTo *domain.MessageReply `json:"reply_to,omitempty"`
Forward *domain.MessageForward `json:"forward,omitempty"`
Reactions *domain.ChannelMessageReactions `json:"reactions,omitempty"`
Media *domain.MessageMedia `json:"media,omitempty"`
RichMessage *domain.MessageRichMessage `json:"rich_message,omitempty"`
GroupedID int64 `json:"grouped_id,omitempty"`
}
type channelMessageEvidenceV1 struct {
SchemaVersion int `json:"schema_version"`
ChannelID int64 `json:"channel_id"`
MessageID int `json:"message_id"`
SenderUserID int64 `json:"sender_user_id"`
From domain.Peer `json:"from"`
SendAs *domain.Peer `json:"send_as,omitempty"`
Date int `json:"date"`
EditDate int `json:"edit_date,omitempty"`
Post bool `json:"post,omitempty"`
Body string `json:"body"`
Entities []domain.MessageEntity `json:"entities,omitempty"`
ReplyTo *domain.MessageReply `json:"reply_to,omitempty"`
Forward *domain.MessageForward `json:"forward,omitempty"`
Reactions *domain.ChannelMessageReactions `json:"reactions,omitempty"`
Action *domain.ChannelMessageAction `json:"action,omitempty"`
Media *domain.MessageMedia `json:"media,omitempty"`
RichMessage *domain.MessageRichMessage `json:"rich_message,omitempty"`
GroupedID int64 `json:"grouped_id,omitempty"`
}
type storyEvidenceV1 struct {
SchemaVersion int `json:"schema_version"`
Owner domain.Peer `json:"owner"`
StoryID int `json:"story_id"`
Date int `json:"date"`
ExpireDate int `json:"expire_date"`
Pinned bool `json:"pinned,omitempty"`
Public bool `json:"public,omitempty"`
CloseFriends bool `json:"close_friends,omitempty"`
Contacts bool `json:"contacts,omitempty"`
SelectedContacts bool `json:"selected_contacts,omitempty"`
NoForwards bool `json:"no_forwards,omitempty"`
Edited bool `json:"edited,omitempty"`
Caption string `json:"caption"`
Entities []domain.MessageEntity `json:"entities,omitempty"`
Media *domain.MessageMedia `json:"media,omitempty"`
MediaAreas []domain.StoryMediaArea `json:"media_areas,omitempty"`
Forward *domain.StoryForward `json:"forward,omitempty"`
}
func privateMessageEvidence(message domain.Message) privateMessageEvidenceV1 {
return privateMessageEvidenceV1{
SchemaVersion: 1, MessageID: message.ID, UID: message.UID,
Peer: message.Peer, From: message.From, Date: message.Date,
EditDate: message.EditDate, Body: message.Body,
Entities: message.Entities, ReplyTo: message.ReplyTo,
Forward: message.Forward, Reactions: message.Reactions,
Media: message.Media, RichMessage: message.RichMessage,
GroupedID: message.GroupedID,
}
}
func channelMessageEvidence(message domain.ChannelMessage) channelMessageEvidenceV1 {
return channelMessageEvidenceV1{
SchemaVersion: 1, ChannelID: message.ChannelID,
MessageID: message.ID, SenderUserID: message.SenderUserID,
From: message.From, SendAs: message.SendAs, Date: message.Date,
EditDate: message.EditDate, Post: message.Post, Body: message.Body,
Entities: message.Entities, ReplyTo: message.ReplyTo,
Forward: message.Forward, Reactions: message.Reactions,
Action: message.Action, Media: message.Media,
RichMessage: message.RichMessage, GroupedID: message.GroupedID,
}
}
func marshalChannelMessageEvidence(message domain.ChannelMessage) ([]byte, error) {
evidence, err := json.Marshal(channelMessageEvidence(message))
if err != nil {
return nil, fmt.Errorf("marshal channel message evidence: %w", err)
}
return evidence, nil
}
func reactionRowsForUser(reactions *domain.ChannelMessageReactions, userID int64) []domain.ChannelMessagePeerReaction {
if reactions == nil || userID <= 0 {
return nil
}
rows := make([]domain.ChannelMessagePeerReaction, 0, len(reactions.Recent))
for _, reaction := range reactions.Recent {
if reaction.UserID == userID {
rows = append(rows, reaction)
}
}
return rows
}
func reactionEvidenceRows(rows []domain.ChannelMessagePeerReaction) []messageReactionEvidenceV1 {
out := make([]messageReactionEvidenceV1, 0, len(rows))
for _, row := range rows {
out = append(out, messageReactionEvidenceV1{
UserID: row.UserID, Type: row.Reaction.Type,
Value: row.Reaction.Value(), Big: row.Big, Unread: row.Unread,
ChosenOrder: row.ChosenOrder, Date: row.Date,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].ChosenOrder != out[j].ChosenOrder {
return out[i].ChosenOrder < out[j].ChosenOrder
}
if out[i].Type != out[j].Type {
return out[i].Type < out[j].Type
}
return out[i].Value < out[j].Value
})
return out
}
func canonicalPositiveIDs(ids []int, max int) ([]int, error) {
if len(ids) == 0 || len(ids) > domain.MaxModerationReportItems {
return nil, domain.ErrModerationReportInvalid
}
seen := make(map[int]struct{}, len(ids))
out := make([]int, 0, len(ids))
for _, id := range ids {
if id <= 0 || id > max {
return nil, domain.ErrModerationReportInvalid
}
if _, duplicate := seen[id]; !duplicate {
seen[id] = struct{}{}
out = append(out, id)
}
}
sort.Ints(out)
return out, nil
}
func mediaHolds(itemIndex int, media *domain.MessageMedia) []domain.ModerationMediaHold {
if media == nil {
return nil
}
holds := make([]domain.ModerationMediaHold, 0, 8)
addPhoto := func(photo *domain.Photo) {
if photo == nil || photo.ID <= 0 {
return
}
for _, size := range photo.Sizes {
if size.Type != "" {
holds = append(holds, domain.ModerationMediaHold{
ItemIndex: itemIndex, Kind: domain.ModerationMediaPhoto,
StorageKey: "photo:" + strconv.FormatInt(photo.ID, 10) + ":" + size.Type,
})
}
}
}
addDocument := func(document *domain.Document) {
if document == nil || document.ID <= 0 {
return
}
prefix := "doc:" + strconv.FormatInt(document.ID, 10)
holds = append(holds, domain.ModerationMediaHold{
ItemIndex: itemIndex, Kind: domain.ModerationMediaDocument,
StorageKey: prefix,
})
for _, thumb := range document.Thumbs {
if thumb.Type != "" {
holds = append(holds, domain.ModerationMediaHold{
ItemIndex: itemIndex, Kind: domain.ModerationMediaDocument,
StorageKey: prefix + ":" + thumb.Type,
})
}
}
}
addPhoto(media.Photo)
addDocument(media.Document)
addDocument(media.LivePhotoVideo)
return dedupeMediaHolds(holds)
}
func photoHolds(itemIndex int, photo domain.Photo) []domain.ModerationMediaHold {
if photo.ID <= 0 {
return nil
}
holds := make([]domain.ModerationMediaHold, 0, len(photo.Sizes))
for _, size := range photo.Sizes {
if size.Type == "" {
continue
}
holds = append(holds, domain.ModerationMediaHold{
ItemIndex: itemIndex, Kind: domain.ModerationMediaPhoto,
StorageKey: "photo:" + strconv.FormatInt(photo.ID, 10) + ":" + size.Type,
})
}
return dedupeMediaHolds(holds)
}
func dedupeMediaHolds(holds []domain.ModerationMediaHold) []domain.ModerationMediaHold {
if len(holds) == 0 {
return nil
}
seen := make(map[domain.ModerationMediaHold]struct{}, len(holds))
out := make([]domain.ModerationMediaHold, 0, len(holds))
for _, hold := range holds {
if _, duplicate := seen[hold]; duplicate {
continue
}
seen[hold] = struct{}{}
out = append(out, hold)
}
return out
}

View file

@ -0,0 +1,106 @@
package moderation
import (
"context"
"encoding/json"
"fmt"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// MigrateLegacyEphemeralReports converts every pre-unified durable report into
// the canonical moderation shape. The store commits the new report and its
// legacy provenance mapping atomically; rerunning after a crash is safe.
func (s *Service) MigrateLegacyEphemeralReports(ctx context.Context, source store.LegacyEphemeralReportReader, batchSize int) (int, error) {
if s == nil || s.reports == nil || source == nil {
return 0, fmt.Errorf("legacy ephemeral report migration is not configured")
}
if batchSize <= 0 || batchSize > 1000 {
return 0, fmt.Errorf("legacy ephemeral report batch limit out of range")
}
importer, ok := s.reports.(store.LegacyEphemeralReportImporter)
if !ok {
return 0, fmt.Errorf("moderation report store does not support legacy imports")
}
migrated := 0
for {
rows, err := source.ListUnmigratedEphemeralReports(ctx, batchSize)
if err != nil {
return migrated, err
}
for _, legacy := range rows {
report, err := legacyEphemeralModerationReport(legacy.Report)
if err != nil {
return migrated, fmt.Errorf("convert legacy ephemeral report %d: %w", legacy.ID, err)
}
if _, _, err := importer.ImportLegacyEphemeralReport(ctx, legacy.ID, report); err != nil {
return migrated, fmt.Errorf("import legacy ephemeral report %d: %w", legacy.ID, err)
}
migrated++
}
if len(rows) < batchSize {
return migrated, nil
}
}
}
func legacyEphemeralModerationReport(legacy domain.EphemeralAbuseReport) (domain.ModerationReport, error) {
if err := legacy.Validate(); err != nil {
return domain.ModerationReport{}, err
}
reason, ok := legacyEphemeralModerationReason(legacy.Option)
if !ok {
return domain.ModerationReport{}, fmt.Errorf("%w: unsupported legacy option %q", domain.ErrModerationReportInvalid, legacy.Option)
}
evidence, err := json.Marshal(struct {
SchemaVersion int `json:"schema_version"`
Evidence domain.EphemeralReportEvidence `json:"evidence"`
}{SchemaVersion: 1, Evidence: legacy.Evidence})
if err != nil {
return domain.ModerationReport{}, fmt.Errorf("marshal legacy ephemeral evidence: %w", err)
}
return domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: legacy.ReporterUserID,
Source: domain.ModerationSourceEphemeral,
Target: legacy.Evidence.Peer,
Reason: reason,
Option: legacy.Option,
Comment: legacy.Comment,
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemEphemeral,
Peer: legacy.Evidence.Peer,
ItemID: int64(legacy.Evidence.MessageID),
AuthorUserID: legacy.Evidence.SenderUserID,
EvidenceSchemaVersion: 1,
Evidence: evidence,
}},
MediaHolds: mediaHolds(0, legacy.Evidence.Content.Media),
CreatedAt: legacy.CreatedAt,
})
}
func legacyEphemeralModerationReason(option string) (domain.ModerationReason, bool) {
switch option {
case "spam":
return domain.ModerationReasonSpam, true
case "violence":
return domain.ModerationReasonViolence, true
case "pornography":
return domain.ModerationReasonPornography, true
case "child_abuse":
return domain.ModerationReasonChildAbuse, true
case "illegal_drugs":
return domain.ModerationReasonIllegalDrugs, true
case "personal_details":
return domain.ModerationReasonPersonalDetails, true
case "copyright":
return domain.ModerationReasonCopyright, true
case "fake":
return domain.ModerationReasonFake, true
case "other", "other:comment":
return domain.ModerationReasonOther, true
default:
return "", false
}
}

View file

@ -0,0 +1,88 @@
package moderation
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
type legacyEphemeralReader struct {
rows []store.LegacyEphemeralReport
}
func (r *legacyEphemeralReader) ListUnmigratedEphemeralReports(_ context.Context, limit int) ([]store.LegacyEphemeralReport, error) {
if len(r.rows) == 0 {
return nil, nil
}
if limit > len(r.rows) {
limit = len(r.rows)
}
out := append([]store.LegacyEphemeralReport(nil), r.rows[:limit]...)
r.rows = r.rows[limit:]
return out, nil
}
type legacyModerationImporter struct {
*memory.ModerationReportStore
mappings map[int64]int64
}
func (s *legacyModerationImporter) ImportLegacyEphemeralReport(ctx context.Context, legacyID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
if reportID, ok := s.mappings[legacyID]; ok {
existing, _, err := s.GetModerationReport(ctx, reportID)
return existing, false, err
}
stored, created, err := s.CreateModerationReport(ctx, report)
if err == nil {
s.mappings[legacyID] = stored.ID
}
return stored, created, err
}
func TestMigrateLegacyEphemeralReportsPreservesEvidenceAndMediaHolds(t *testing.T) {
now := time.Now().UTC()
reporter := int64(101)
message := domain.EphemeralMessage{
ID: 44, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 303},
SenderUserID: 202, ReceiverUserID: reporter, Date: int(now.Unix()),
Content: domain.EphemeralContent{
Message: "evidence",
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindDocument,
Document: &domain.Document{
ID: 909, AccessHash: 1, MimeType: "text/plain", Size: 8,
},
},
},
Version: 1, CreatedAt: now, ExpiresAt: now.Add(time.Hour),
}
legacy := domain.NewEphemeralAbuseReport(reporter, "spam", "review", message, now)
source := &legacyEphemeralReader{rows: []store.LegacyEphemeralReport{{ID: 7, Report: legacy}}}
target := &legacyModerationImporter{
ModerationReportStore: memory.NewModerationReportStore(),
mappings: make(map[int64]int64),
}
service := NewService(target)
count, err := service.MigrateLegacyEphemeralReports(context.Background(), source, 10)
if err != nil || count != 1 {
t.Fatalf("migrate count=%d err=%v", count, err)
}
reports := target.Reports()
if len(reports) != 1 {
t.Fatalf("reports=%d, want 1", len(reports))
}
got := reports[0]
if got.Source != domain.ModerationSourceEphemeral ||
got.Target != message.Peer || len(got.Items) != 1 ||
got.Items[0].AuthorUserID != message.SenderUserID {
t.Fatalf("migrated report=%+v", got)
}
if len(got.MediaHolds) != 1 ||
got.MediaHolds[0].StorageKey != "doc:909" {
t.Fatalf("media holds=%+v", got.MediaHolds)
}
}

View file

@ -0,0 +1,102 @@
package moderation
import (
"context"
"crypto/sha256"
"time"
"telesrv/internal/domain"
)
func (s *Service) SponsoredImpression(ctx context.Context, userID int64, randomID []byte, now time.Time) (domain.SponsoredMessageImpression, error) {
if s == nil || s.registry == nil || len(randomID) == 0 {
return domain.SponsoredMessageImpression{}, domain.ErrModerationEvidenceNotFound
}
impression, found, err := s.registry.GetSponsoredMessageImpression(
ctx, userID, sha256.Sum256(randomID), now,
)
if err != nil {
return domain.SponsoredMessageImpression{}, err
}
if !found {
return domain.SponsoredMessageImpression{}, domain.ErrModerationImpressionExpired
}
return impression, nil
}
func (s *Service) ReportSponsored(ctx context.Context, userID int64, randomID []byte, reason domain.ModerationReason, option string, now time.Time) (domain.ModerationReport, bool, error) {
impression, err := s.SponsoredImpression(ctx, userID, randomID, now)
if err != nil {
return domain.ModerationReport{}, false, err
}
if impression.ReportID > 0 {
report, found, err := s.Report(ctx, impression.ReportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, domain.ErrModerationReportNotFound
}
return report, false, nil
}
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: userID, Source: domain.ModerationSourceSponsored,
Target: impression.Target, Reason: reason, Option: option,
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemSponsored, Peer: impression.Target,
ItemID: impression.ID, AuthorUserID: impression.AuthorUserID,
EvidenceSchemaVersion: impression.EvidenceSchemaVersion,
Evidence: impression.Evidence,
}},
CreatedAt: now,
})
if err != nil {
return domain.ModerationReport{}, false, err
}
return s.registry.CreateSponsoredModerationReport(ctx, impression.ID, report)
}
func (s *Service) ReportAntiSpamFalsePositive(ctx context.Context, reporterUserID, channelID int64, messageID int, now time.Time) (domain.ModerationReport, bool, error) {
if s == nil || s.registry == nil {
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
decision, found, err := s.registry.GetChannelAntiSpamDecision(
ctx, channelID, messageID,
)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
}
if decision.ReportID > 0 {
report, found, err := s.Report(ctx, decision.ReportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, domain.ErrModerationReportNotFound
}
return report, false, nil
}
target := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: reporterUserID,
Source: domain.ModerationSourceAntiSpamFalsePositive,
Target: target,
Reason: domain.ModerationReasonOther,
Option: "false_positive",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemAntiSpamDecision, Peer: target,
ItemID: decision.ID, SecondaryID: int64(messageID),
AuthorUserID: decision.AuthorUserID,
EvidenceSchemaVersion: decision.EvidenceSchemaVersion,
Evidence: decision.Evidence,
}},
CreatedAt: now,
})
if err != nil {
return domain.ModerationReport{}, false, err
}
return s.registry.CreateAntiSpamFalsePositiveReport(ctx, decision.ID, report)
}

View file

@ -0,0 +1,98 @@
package moderation
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestSponsoredReportRequiresIssuedImpressionAndLinksAtomically(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_750_000_000, 0).UTC()
store := memory.NewModerationReportStore()
service := NewService(store)
randomID := []byte("server-issued-random-id")
if _, _, err := service.ReportSponsored(
ctx, 11, randomID, domain.ModerationReasonSpam, "spam", now,
); !errors.Is(err, domain.ErrModerationImpressionExpired) {
t.Fatalf("unseen impression err=%v", err)
}
impression, err := domain.NewSponsoredMessageImpression(
11, randomID, domain.Peer{Type: domain.PeerTypeChannel, ID: 22},
33, []byte(`{"author_id":33,"creative_id":"creative-1"}`),
now, now.Add(time.Hour),
)
if err != nil {
t.Fatal(err)
}
impression, created, err := store.CreateSponsoredMessageImpression(ctx, impression)
if err != nil || !created {
t.Fatalf("impression=%+v created=%v err=%v", impression, created, err)
}
report, created, err := service.ReportSponsored(
ctx, 11, randomID, domain.ModerationReasonSpam, "spam", now.Add(time.Second),
)
if err != nil || !created || report.ID <= 0 {
t.Fatalf("report=%+v created=%v err=%v", report, created, err)
}
retry, created, err := service.ReportSponsored(
ctx, 11, randomID, domain.ModerationReasonFake, "fake", now.Add(2*time.Second),
)
if err != nil || created || retry.ID != report.ID {
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
}
if reports := store.Reports(); len(reports) != 1 ||
reports[0].Items[0].EvidenceHash != impression.EvidenceHash {
t.Fatalf("reports=%+v", reports)
}
if _, _, err := service.ReportSponsored(
ctx, 11, []byte("expired"),
domain.ModerationReasonSpam, "spam", now.Add(2*time.Hour),
); !errors.Is(err, domain.ErrModerationImpressionExpired) {
t.Fatalf("expired/unseen err=%v", err)
}
}
func TestAntiSpamFalsePositiveRequiresNativeDecisionAndIsIdempotent(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_750_000_000, 0).UTC()
store := memory.NewModerationReportStore()
service := NewService(store)
if _, _, err := service.ReportAntiSpamFalsePositive(
ctx, 11, 22, 33, now,
); !errors.Is(err, domain.ErrModerationEvidenceNotFound) {
t.Fatalf("missing decision err=%v", err)
}
decision, err := domain.NewChannelAntiSpamDecision(
22, 33, 44,
[]byte(`{"engine":"native-v1","score":0.99}`), now,
)
if err != nil {
t.Fatal(err)
}
decision, created, err := store.CreateChannelAntiSpamDecision(ctx, decision)
if err != nil || !created {
t.Fatalf("decision=%+v created=%v err=%v", decision, created, err)
}
report, created, err := service.ReportAntiSpamFalsePositive(
ctx, 11, 22, 33, now.Add(time.Second),
)
if err != nil || !created || report.ID <= 0 {
t.Fatalf("report=%+v created=%v err=%v", report, created, err)
}
retry, created, err := service.ReportAntiSpamFalsePositive(
ctx, 11, 22, 33, now.Add(2*time.Second),
)
if err != nil || created || retry.ID != report.ID {
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
}
if reports := store.Reports(); len(reports) != 1 ||
reports[0].Items[0].EvidenceHash != decision.EvidenceHash ||
reports[0].Items[0].SecondaryID != 33 {
t.Fatalf("reports=%+v", reports)
}
}

View file

@ -0,0 +1,89 @@
package moderation
import (
"context"
"fmt"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// Service owns moderation submission invariants. RPC handlers provide
// domain-only snapshots; the service canonicalizes and persists them before a
// client may observe a successful report response.
type Service struct {
reports store.ModerationReportStore
cases store.ModerationCaseStore
registry store.ModerationEvidenceRegistryStore
privateMessages privateMessageReader
channelMessages channelMessageReader
stories storyReader
users userReader
channels channelPeerReader
photos profilePhotoReader
}
type Option func(*Service)
func WithMessageReaders(private privateMessageReader, channels channelMessageReader) Option {
return func(service *Service) {
service.privateMessages = private
service.channelMessages = channels
}
}
func WithStoryReader(stories storyReader) Option {
return func(service *Service) {
service.stories = stories
}
}
func WithPeerReaders(users userReader, channels channelPeerReader) Option {
return func(service *Service) {
service.users = users
service.channels = channels
}
}
func WithProfilePhotoReader(photos profilePhotoReader) Option {
return func(service *Service) {
service.photos = photos
}
}
func NewService(reports store.ModerationReportStore, opts ...Option) *Service {
service := &Service{reports: reports}
if cases, ok := reports.(store.ModerationCaseStore); ok {
service.cases = cases
}
if registry, ok := reports.(store.ModerationEvidenceRegistryStore); ok {
service.registry = registry
}
for _, opt := range opts {
if opt != nil {
opt(service)
}
}
return service
}
func (s *Service) AcceptReport(ctx context.Context, draft domain.ModerationReportDraft) (domain.ModerationReport, bool, error) {
if s == nil || s.reports == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
}
report, err := domain.NewModerationReport(draft)
if err != nil {
return domain.ModerationReport{}, false, err
}
return s.reports.CreateModerationReport(ctx, report)
}
func (s *Service) Report(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error) {
if s == nil || s.reports == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
}
if reportID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
return s.reports.GetModerationReport(ctx, reportID)
}

View file

@ -0,0 +1,36 @@
package moderation
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestAcceptReportReturnsDurableRetry(t *testing.T) {
reports := memory.NewModerationReportStore()
service := NewService(reports)
draft := domain.ModerationReportDraft{
ReporterUserID: 100, Source: domain.ModerationSourceMessagesSpam,
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 200},
Reason: domain.ModerationReasonSpam, Option: "v1/spam",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemPeer,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 200},
ItemID: 200, AuthorUserID: 200, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"snapshot":"peer"}`),
}},
CreatedAt: time.Now().UTC(),
}
first, created, err := service.AcceptReport(context.Background(), draft)
if err != nil || !created {
t.Fatalf("first created=%v err=%v", created, err)
}
draft.CreatedAt = draft.CreatedAt.Add(time.Minute)
retry, created, err := service.AcceptReport(context.Background(), draft)
if err != nil || created || retry.ID != first.ID {
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
}
}

View file

@ -46,28 +46,27 @@ func newRegistry() *registry {
}
}
// sweepLocked 是 P1 的纯年龄 GC调用方持有 r.mu
// - 终态 tombstone 超过 tombstoneTTL → 回收(密钥材料随之销毁);
// - 非终态超过 2×ringTimeout → 直接回收(双端同时崩溃的兜底,防僵尸通话
// 吃满并发上限;不推送、不落历史,正常超时由客户端定时器与 P2 dispatcher 处理)。
func (r *registry) sweepLocked(nowUnix int64, ringTimeoutSec, tombstoneTTLSec int64) {
// sweepTombstonesLocked 只回收超过保留期的终态 tombstone调用方持有 r.mu
//
// 非终态绝不能在 GC 中按 Date 直接删除Requested/Ringing/Accepted 的超时必须由
// Service.ExpireDue 完成状态迁移、双端推送和历史落库Confirmed 没有服务端时长
// 上限,必须一直可供 signaling/discard 寻址,直到显式挂断或进程重启。
func (r *registry) sweepTombstonesLocked(nowUnix, tombstoneTTLSec int64) {
for id, e := range r.byID {
switch {
case e.call.Terminal():
if nowUnix-int64(e.call.DiscardedAt) > tombstoneTTLSec {
r.removeLocked(id, e, false)
}
default:
if nowUnix-int64(e.call.Date) > 2*ringTimeoutSec {
r.removeLocked(id, e, true)
}
if e.call.Terminal() && nowUnix-int64(e.call.DiscardedAt) > tombstoneTTLSec {
r.removeLocked(id, e, false)
}
}
}
func (r *registry) removeLocked(id int64, e *entry, wasActive bool) {
delete(r.byID, id)
delete(r.byRandom, randomKey{callerID: e.call.AdminID, randomID: e.call.RandomID})
key := randomKey{callerID: e.call.AdminID, randomID: e.call.RandomID}
// 终态后允许客户端复用 random_id 创建新通话;旧 tombstone 到期时不能
// 把已指向新 call 的幂等索引一并删掉。
if indexedID, ok := r.byRandom[key]; ok && indexedID == id {
delete(r.byRandom, key)
}
if wasActive {
r.decActiveLocked(e.call.AdminID)
}

View file

@ -45,6 +45,9 @@ type Config struct {
TombstoneTTL time.Duration
// MaxActivePerUser 是单用户并发非终态通话上限(防呼叫轰炸自锁)。
MaxActivePerUser int
// MaxRegistryEntries 是进程内 registry 的硬上限。达到上限时拒绝新通话,
// 不驱逐可能仍在进行的 Confirmed 通话。
MaxRegistryEntries int
// SignalingRatePerSecond 是单通话每秒信令转发上限;超限静默丢弃(不破坏客户端状态机)。
SignalingRatePerSecond int
}
@ -59,6 +62,9 @@ func (c Config) withDefaults() Config {
if c.MaxActivePerUser <= 0 {
c.MaxActivePerUser = 4
}
if c.MaxRegistryEntries <= 0 {
c.MaxRegistryEntries = 10_000
}
if c.SignalingRatePerSecond <= 0 {
c.SignalingRatePerSecond = 50
}
@ -103,7 +109,7 @@ func (s *Service) RequestCall(ctx context.Context, callerID int64, in domain.Pho
s.reg.mu.Lock()
defer s.reg.mu.Unlock()
s.reg.sweepLocked(nowUnix, int64(s.cfg.RingTimeout/time.Second), int64(s.cfg.TombstoneTTL/time.Second))
s.reg.sweepTombstonesLocked(nowUnix, int64(s.cfg.TombstoneTTL/time.Second))
// 幂等:同一 (callerID, randomID) 的未终结通话直接返回快照,吸收客户端重试。
key := randomKey{callerID: callerID, randomID: in.RandomID}
@ -112,6 +118,9 @@ func (s *Service) RequestCall(ctx context.Context, callerID int64, in domain.Pho
return e.call, nil
}
}
if len(s.reg.byID) >= s.cfg.MaxRegistryEntries {
return domain.PhoneCall{}, ErrOccupyFailed
}
if s.reg.active[callerID] >= s.cfg.MaxActivePerUser {
return domain.PhoneCall{}, ErrOccupyFailed
}
@ -306,7 +315,7 @@ func (s *Service) ExpireDue(ctx context.Context, now time.Time) []domain.PhoneCa
s.reg.markDiscardedLocked(e, reason, 0, int(nowUnix))
expired = append(expired, e.call)
}
s.reg.sweepLocked(nowUnix, ringSec, int64(s.cfg.TombstoneTTL/time.Second))
s.reg.sweepTombstonesLocked(nowUnix, int64(s.cfg.TombstoneTTL/time.Second))
return expired
}

View file

@ -72,6 +72,7 @@ func newTestService(clk clock.Clock, mutate ...func(*Config)) *Service {
RingTimeout: 90 * time.Second,
TombstoneTTL: 60 * time.Second,
MaxActivePerUser: 4,
MaxRegistryEntries: 10_000,
SignalingRatePerSecond: 50,
}
for _, fn := range mutate {
@ -265,9 +266,15 @@ func TestPhoneCallRandomIDIdempotent(t *testing.T) {
if err != nil || third.ID == first.ID {
t.Fatalf("post-discard request id = %d err=%v, want fresh call", third.ID, err)
}
// 旧 tombstone 到期回收时,不得误删已改指向新 call 的 random_id 索引。
clk.Advance(61 * time.Second)
retry, err := s.RequestCall(ctx, 1, req)
if err != nil || retry.ID != third.ID {
t.Fatalf("retry after old tombstone GC id = %d err=%v, want %d", retry.ID, err, third.ID)
}
}
func TestPhoneCallQuotaAndSweep(t *testing.T) {
func TestPhoneCallQuotaAndExpiry(t *testing.T) {
clk := newTestClock()
s := newTestService(clk, func(c *Config) { c.MaxActivePerUser = 2 })
ctx := context.Background()
@ -281,10 +288,53 @@ func TestPhoneCallQuotaAndSweep(t *testing.T) {
if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 99, RandomID: 99, GAHash: gaHash, Protocol: testProtocol()}); !errors.Is(err, ErrOccupyFailed) {
t.Fatalf("over quota err = %v, want ErrOccupyFailed", err)
}
// 双端崩溃兜底:超过 2×RingTimeout 的僵尸通话被纯年龄 GC 回收,配额释放。
clk.Advance(181 * time.Second)
// 未建立通话只能由 ExpireDue 迁入终态,确保 dispatcher 能推送并落历史;
// registry GC 不得静默删除 active call。
clk.Advance(91 * time.Second)
expired := s.ExpireDue(ctx, clk.Now())
if len(expired) != 2 {
t.Fatalf("expired = %d, want 2", len(expired))
}
if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 99, RandomID: 99, GAHash: gaHash, Protocol: testProtocol()}); err != nil {
t.Fatalf("request after sweep: %v", err)
t.Fatalf("request after expiry: %v", err)
}
}
func TestPhoneCallRegistryCapacityDoesNotEvictConfirmedCall(t *testing.T) {
clk := newTestClock()
s := newTestService(clk, func(c *Config) { c.MaxRegistryEntries = 1 })
ctx := context.Background()
ga, gaHash := testGA()
confirmed := mustRequest(t, s, 1, 2, gaHash)
if _, err := s.AcceptCall(ctx, 2, confirmed.ID, confirmed.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil {
t.Fatalf("accept: %v", err)
}
if _, _, err := s.ConfirmCall(ctx, 1, confirmed.ID, confirmed.AccessHash, ga, 1, testProtocol()); err != nil {
t.Fatalf("confirm: %v", err)
}
clk.Advance(365 * 24 * time.Hour)
if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 {
t.Fatalf("confirmed call expired after one year: %+v", got)
}
if _, err := s.RequestCall(ctx, 3, domain.PhoneCallRequest{
CalleeID: 4, RandomID: 2, GAHash: gaHash, Protocol: testProtocol(),
}); !errors.Is(err, ErrOccupyFailed) {
t.Fatalf("request at registry capacity err = %v, want ErrOccupyFailed", err)
}
if snap, ok := s.Lookup(ctx, confirmed.ID, confirmed.AccessHash); !ok || snap.State != domain.PhoneCallStateConfirmed {
t.Fatalf("confirmed call = %+v ok=%v, want preserved", snap, ok)
}
if _, _, err := s.DiscardCall(ctx, 1, confirmed.ID, confirmed.AccessHash, domain.PhoneCallDiscardReasonHangup, 1); err != nil {
t.Fatalf("discard: %v", err)
}
clk.Advance(61 * time.Second)
if _, err := s.RequestCall(ctx, 3, domain.PhoneCallRequest{
CalleeID: 4, RandomID: 2, GAHash: gaHash, Protocol: testProtocol(),
}); err != nil {
t.Fatalf("request after tombstone GC: %v", err)
}
}
@ -476,4 +526,13 @@ func TestPhoneCallExpireDue(t *testing.T) {
if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 {
t.Fatalf("second ExpireDue = %d, want 0", len(got))
}
// 回归:旧 registry GC 会在 2×RingTimeout 后静默删除 Confirmed导致后续
// sendSignalingData/discardCall 返回 CALL_PEER_INVALID。
clk.Advance(91 * time.Second)
if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 {
t.Fatalf("confirmed call expired after 2×RingTimeout: %+v", got)
}
if _, ok := s.Lookup(ctx, confirmedCall.ID, confirmedCall.AccessHash); !ok {
t.Fatal("confirmed call must remain addressable after 2×RingTimeout")
}
}

View file

@ -81,6 +81,31 @@ func (c *CachedPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.P
return err
}
c.InvalidateOwners(rules.OwnerUserID)
// 数据写入已提交预热失败不能伪装成写失败LISTEN/NOTIFY 也会在每个
// 实例上再次失效并预热,覆盖本实例通知晚于这里到达的时序。
_ = c.WarmOwners(ctx, rules.OwnerUserID)
return nil
}
// WarmOwners 在低频写/变更通知路径一次性装入 owner 的完整规则集。调用方必须先
// InvalidateOwnersepoch 保证预热期间若又发生失效,不会把旧快照写回。
func (c *CachedPrivacyStore) WarmOwners(ctx context.Context, ownerUserIDs ...int64) error {
owners := dedupPrivacyOwnerIDs(ownerUserIDs)
if len(owners) == 0 || c == nil || c.cache == nil {
return nil
}
// 隐私规则变更很少、读取极热。必须重建全部 key
// 不能只塞本次 key否则会把 owner 的其它持久规则误当成默认规则。
loadEpoch := c.cache.LoadEpoch()
list, err := c.inner.ListPrivacyRules(ctx, owners, allPrivacyRuleKeys)
if err != nil {
return err
}
snapshots := buildPrivacyRulesByOwner(list, owners)
for _, ownerUserID := range owners {
c.cache.StoreIfEpoch(ownerUserID, snapshots[ownerUserID], loadEpoch)
}
return nil
}
@ -160,6 +185,40 @@ func (c *CachedPrivacyStore) FlushReadModelCache() {
c.cache.Flush()
}
// InvalidateOwners lets Service be registered as the single privacy read-model
// cache group: rule snapshots and relationship facts then share one invalidation
// lifecycle.
func (s *Service) InvalidateOwners(ids ...int64) {
if s == nil || s.rules == nil {
return
}
if cache, ok := s.rules.(interface{ InvalidateOwners(...int64) }); ok {
cache.InvalidateOwners(ids...)
}
}
func (s *Service) WarmOwners(ctx context.Context, ids ...int64) error {
if s == nil || s.rules == nil {
return nil
}
if cache, ok := s.rules.(interface {
WarmOwners(context.Context, ...int64) error
}); ok {
return cache.WarmOwners(ctx, ids...)
}
return nil
}
func (s *Service) FlushReadModelCache() {
if s == nil {
return
}
if cache, ok := s.rules.(interface{ FlushReadModelCache() }); ok {
cache.FlushReadModelCache()
}
s.flushFactCaches()
}
// buildPrivacyRulesByOwner 把扁平规则按 owner 归组;每个 owner 都建一个条目(无规则即空 map),
// 这样「查过且无规则」的 owner 也被负缓存,不会反复打后端。
func buildPrivacyRulesByOwner(list []domain.PrivacyRules, owners []int64) map[int64]privacyRulesMap {

View file

@ -108,7 +108,7 @@ func TestCachedPrivacyStoreUsesOwnerSnapshot(t *testing.T) {
}
}
func TestCachedPrivacyStoreInvalidatesOnSet(t *testing.T) {
func TestCachedPrivacyStoreWarmsCompleteOwnerSnapshotOnSet(t *testing.T) {
ctx := context.Background()
base := memory.NewPrivacyStore()
counting := &countingPrivacyStore{PrivacyStore: base}
@ -121,24 +121,34 @@ func TestCachedPrivacyStoreInvalidatesOnSet(t *testing.T) {
t.Fatalf("set first: %v", err)
}
if _, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber); err != nil || !ok {
t.Fatalf("prime get ok=%v err=%v", ok, err)
t.Fatalf("first memory get ok=%v err=%v", ok, err)
}
if err := cached.SetPrivacyRules(ctx, domain.PrivacyRules{
OwnerUserID: 1001,
Key: domain.PrivacyKeyPhoneNumber,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}},
Key: domain.PrivacyKeyProfilePhoto,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
}); err != nil {
t.Fatalf("set second: %v", err)
}
got, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
if err != nil || !ok {
t.Fatalf("after invalidation get ok=%v err=%v", ok, err)
t.Fatalf("phone after second set ok=%v err=%v", ok, err)
}
if got.Rules[0].Kind != domain.PrivacyRuleAllowAll {
t.Fatalf("rules after invalidation = %+v, want allow all", got.Rules)
if got.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
t.Fatalf("phone rules after second set = %+v, want disallow all", got.Rules)
}
photo, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyProfilePhoto)
if err != nil || !ok {
t.Fatalf("photo after second set ok=%v err=%v", ok, err)
}
if photo.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
t.Fatalf("photo rules after second set = %+v, want disallow all", photo.Rules)
}
if counting.setCalls != 2 {
t.Fatalf("SetPrivacyRules calls = %d, want 2", counting.setCalls)
}
if counting.listCalls != 2 {
t.Fatalf("ListPrivacyRules calls = %d, want 2 after invalidation", counting.listCalls)
t.Fatalf("ListPrivacyRules calls = %d, want exactly one write-path warm per set and no read-path query", counting.listCalls)
}
}

View file

@ -0,0 +1,245 @@
package privacy
import (
"context"
"strconv"
"time"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
)
const (
defaultPrivacyViewerFactsTTL = 10 * time.Minute
defaultPrivacyMembershipTTL = 24 * time.Hour
privacyViewerFactsMaxEntries = 8192
privacyMembershipMaxEntries = 65536
)
// baseUserProvider returns viewer-independent user facts through the users read
// model. Implementations must batch cold misses rather than issue one query per
// user.
type baseUserProvider interface {
PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]domain.User, error)
}
// channelMembershipProvider is the cold loader behind the bounded membership
// read model. Privacy evaluation never calls it for a warm (chat,user) pair.
type channelMembershipProvider interface {
FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
}
type viewerFacts struct {
Found bool
Bot bool
PremiumUntil int64
}
type membershipKey struct {
ChatID int64
UserID int64
}
type evaluationNeeds struct {
viewerBase bool
chatIDs []int64
}
func newViewerFactsCache() *readmodelcache.Cache[int64, viewerFacts] {
return readmodelcache.New[int64, viewerFacts](readmodelcache.Config[int64, viewerFacts]{
MaxEntries: privacyViewerFactsMaxEntries,
TTL: defaultPrivacyViewerFactsTTL,
})
}
func newMembershipCache() *readmodelcache.Cache[membershipKey, bool] {
return readmodelcache.New[membershipKey, bool](readmodelcache.Config[membershipKey, bool]{
MaxEntries: privacyMembershipMaxEntries,
TTL: defaultPrivacyMembershipTTL,
KeyString: func(key membershipKey) string {
return strconv.FormatInt(key.ChatID, 10) + ":" + strconv.FormatInt(key.UserID, 10)
},
})
}
func needsForRules(rules domain.PrivacyRules) evaluationNeeds {
var needs evaluationNeeds
seenChats := make(map[int64]struct{})
for _, rule := range rules.Rules {
switch rule.Kind {
case domain.PrivacyRuleAllowPremium,
domain.PrivacyRuleAllowBots,
domain.PrivacyRuleDisallowBots:
needs.viewerBase = true
case domain.PrivacyRuleAllowChatParticipants,
domain.PrivacyRuleDisallowChatParticipants:
for _, chatID := range rule.ChatIDs {
if chatID <= 0 {
continue
}
if _, ok := seenChats[chatID]; ok {
continue
}
seenChats[chatID] = struct{}{}
needs.chatIDs = append(needs.chatIDs, chatID)
}
}
}
return needs
}
func mergeNeeds(dst *evaluationNeeds, src evaluationNeeds) {
if src.viewerBase {
dst.viewerBase = true
}
if len(src.chatIDs) == 0 {
return
}
seen := make(map[int64]struct{}, len(dst.chatIDs)+len(src.chatIDs))
for _, id := range dst.chatIDs {
seen[id] = struct{}{}
}
for _, id := range src.chatIDs {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
dst.chatIDs = append(dst.chatIDs, id)
}
}
func (s *Service) loadViewerFacts(ctx context.Context, viewerUserIDs []int64) (map[int64]viewerFacts, error) {
ids := dedupNonZero(viewerUserIDs)
if len(ids) == 0 {
return map[int64]viewerFacts{}, nil
}
loadMissing := func(ctx context.Context, missing []int64) (map[int64]viewerFacts, error) {
out := make(map[int64]viewerFacts, len(missing))
for _, id := range missing {
out[id] = viewerFacts{} // negative cache: user was not found.
}
if s == nil || s.baseUsers == nil {
return out, nil
}
users, err := s.baseUsers.PrivacyBaseUsers(ctx, missing)
if err != nil {
return nil, err
}
for _, user := range users {
if user.ID == 0 {
continue
}
out[user.ID] = viewerFacts{
Found: true,
Bot: user.Bot,
PremiumUntil: int64(user.PremiumUntil),
}
}
return out, nil
}
if s == nil || s.viewerFacts == nil {
return loadMissing(ctx, ids)
}
return s.viewerFacts.GetOrLoadBatch(ctx, ids,
func(int64) (int64, bool) { return 0, true },
loadMissing,
)
}
func (s *Service) loadMembershipFacts(ctx context.Context, chatIDs, viewerUserIDs []int64) (map[membershipKey]bool, error) {
chats := dedupNonZero(chatIDs)
viewers := dedupNonZero(viewerUserIDs)
if len(chats) == 0 || len(viewers) == 0 {
return map[membershipKey]bool{}, nil
}
keys := make([]membershipKey, 0, len(chats)*len(viewers))
for _, chatID := range chats {
for _, viewerID := range viewers {
keys = append(keys, membershipKey{ChatID: chatID, UserID: viewerID})
}
}
loadMissing := func(ctx context.Context, missing []membershipKey) (map[membershipKey]bool, error) {
out := make(map[membershipKey]bool, len(missing))
byChat := make(map[int64][]int64)
for _, key := range missing {
out[key] = false // negative cache: not an active member.
byChat[key.ChatID] = append(byChat[key.ChatID], key.UserID)
}
if s == nil || s.memberships == nil {
return out, nil
}
for chatID, userIDs := range byChat {
active, err := s.memberships.FilterActiveChannelMemberIDs(ctx, chatID, userIDs)
if err != nil {
return nil, err
}
for _, userID := range active {
out[membershipKey{ChatID: chatID, UserID: userID}] = true
}
}
return out, nil
}
if s == nil || s.membershipFacts == nil {
return loadMissing(ctx, keys)
}
return s.membershipFacts.GetOrLoadBatch(ctx, keys,
func(membershipKey) (int64, bool) { return 0, true },
loadMissing,
)
}
func applyViewerFacts(ctx *domain.PrivacyContext, facts viewerFacts, now int64) {
if ctx == nil || !facts.Found {
return
}
ctx.ViewerIsBot = facts.Bot
ctx.ViewerIsPremium = !facts.Bot && facts.PremiumUntil > now
}
func applyMembershipFacts(ctx *domain.PrivacyContext, chatIDs []int64, facts map[membershipKey]bool) {
if ctx == nil || len(chatIDs) == 0 {
return
}
for _, chatID := range chatIDs {
if facts[membershipKey{ChatID: chatID, UserID: ctx.ViewerUserID}] {
ctx.SharedChatIDs = append(ctx.SharedChatIDs, chatID)
}
}
}
// InvalidateViewerFacts invalidates bot/premium facts after a user-base change.
func (s *Service) InvalidateViewerFacts(userIDs ...int64) {
if s == nil || s.viewerFacts == nil {
return
}
s.viewerFacts.Invalidate(dedupNonZero(userIDs)...)
}
// InvalidateMembership invalidates one membership pair after a channel-member change.
func (s *Service) InvalidateMembership(channelID, userID int64) {
if s == nil || s.membershipFacts == nil || channelID == 0 || userID == 0 {
return
}
s.membershipFacts.Invalidate(membershipKey{ChatID: channelID, UserID: userID})
}
// InvalidateChannelMemberships invalidates all cached pairs for a changed/deleted channel.
func (s *Service) InvalidateChannelMemberships(channelID int64) {
if s == nil || s.membershipFacts == nil || channelID == 0 {
return
}
s.membershipFacts.InvalidateWhere(func(key membershipKey) bool { return key.ChatID == channelID })
}
func (s *Service) flushFactCaches() {
if s == nil {
return
}
if s.viewerFacts != nil {
s.viewerFacts.Flush()
}
if s.membershipFacts != nil {
s.membershipFacts.Flush()
}
}

View file

@ -3,21 +3,49 @@ package privacy
import (
"context"
"slices"
"time"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store"
)
const maxPrivacyRules = 100
const (
maxPrivacyRules = 100
maxPrivacyRuleIDs = 5000
)
// Service owns account privacy rules and viewer-specific evaluation.
type Service struct {
rules store.PrivacyStore
contacts store.ContactStore
rules store.PrivacyStore
contacts store.ContactStore
baseUsers baseUserProvider
memberships channelMembershipProvider
viewerFacts *readmodelcache.Cache[int64, viewerFacts]
membershipFacts *readmodelcache.Cache[membershipKey, bool]
now func() time.Time
}
func NewService(rules store.PrivacyStore, contacts store.ContactStore) *Service {
return &Service{rules: rules, contacts: contacts}
return &Service{
rules: rules,
contacts: contacts,
viewerFacts: newViewerFactsCache(),
membershipFacts: newMembershipCache(),
now: time.Now,
}
}
// ConfigureReadModels wires the cold loaders behind the bounded in-memory
// privacy fact caches. It is called after users/channels services are built to
// avoid a package dependency cycle.
func (s *Service) ConfigureReadModels(users baseUserProvider, memberships channelMembershipProvider) *Service {
if s == nil {
return s
}
s.baseUsers = users
s.memberships = memberships
return s
}
func (s *Service) GetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, error) {
@ -43,6 +71,19 @@ func (s *Service) GetRules(ctx context.Context, ownerUserID int64, key domain.Pr
}
func (s *Service) SetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) {
out, err := normalizedRules(ownerUserID, key, rules)
if err != nil {
return domain.PrivacyRules{}, err
}
if s != nil && s.rules != nil {
if err := s.rules.SetPrivacyRules(ctx, out); err != nil {
return domain.PrivacyRules{}, err
}
}
return out, nil
}
func normalizedRules(ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) {
if !ValidKey(key) {
return domain.PrivacyRules{}, domain.ErrPrivacyKeyInvalid
}
@ -52,13 +93,7 @@ func (s *Service) SetRules(ctx context.Context, ownerUserID int64, key domain.Pr
if err := validateRules(rules); err != nil {
return domain.PrivacyRules{}, err
}
out := domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: cloneRuleSlice(rules)}
if s != nil && s.rules != nil {
if err := s.rules.SetPrivacyRules(ctx, out); err != nil {
return domain.PrivacyRules{}, err
}
}
return out, nil
return domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: cloneRuleSlice(rules)}, nil
}
func (s *Service) AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error) {
@ -96,17 +131,33 @@ func (s *Service) CanSee(ctx context.Context, ownerUserID, viewerUserID int64, k
if err != nil {
return false, err
}
needs := needsForRules(rules)
evalCtx := domain.PrivacyContext{
OwnerUserID: ownerUserID,
ViewerUserID: viewerUserID,
}
if s != nil && s.contacts != nil {
if _, found, err := s.contacts.Get(ctx, ownerUserID, viewerUserID); err != nil {
if contact, found, err := s.contacts.Get(ctx, ownerUserID, viewerUserID); err != nil {
return false, err
} else if found {
evalCtx.ViewerIsContact = true
evalCtx.ViewerCloseFriend = contact.CloseFriend
}
}
if needs.viewerBase {
facts, err := s.loadViewerFacts(ctx, []int64{viewerUserID})
if err != nil {
return false, err
}
applyViewerFacts(&evalCtx, facts[viewerUserID], s.now().Unix())
}
if len(needs.chatIDs) > 0 {
facts, err := s.loadMembershipFacts(ctx, needs.chatIDs, []int64{viewerUserID})
if err != nil {
return false, err
}
applyMembershipFacts(&evalCtx, needs.chatIDs, facts)
}
return Evaluate(rules, evalCtx), nil
}
@ -183,6 +234,16 @@ func (s *Service) CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerU
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
}
}
var needs evaluationNeeds
for _, owner := range owners {
for _, key := range keys {
rules, ok := rulesByOwner[owner][key]
if !ok {
rules = defaultRules(owner, key)
}
mergeNeeds(&needs, needsForRules(rules))
}
}
// 批量取「viewer 是否在 owner 的联系人里」owner→viewer 方向,对应 CanSee 的
// contacts.Get(owner, viewer))。
var reverse map[int64]domain.Contact
@ -193,25 +254,97 @@ func (s *Service) CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerU
return nil, err
}
}
var baseFacts map[int64]viewerFacts
if needs.viewerBase {
var err error
baseFacts, err = s.loadViewerFacts(ctx, []int64{viewerUserID})
if err != nil {
return nil, err
}
}
var membershipFacts map[membershipKey]bool
if len(needs.chatIDs) > 0 {
var err error
membershipFacts, err = s.loadMembershipFacts(ctx, needs.chatIDs, []int64{viewerUserID})
if err != nil {
return nil, err
}
}
now := s.now().Unix()
for _, owner := range owners {
_, isContact := reverse[owner]
contact, isContact := reverse[owner]
m := make(map[domain.PrivacyKey]bool, len(keys))
for _, k := range keys {
rules, ok := rulesByOwner[owner][k]
if !ok {
rules = defaultRules(owner, k)
}
m[k] = Evaluate(rules, domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewerUserID,
ViewerIsContact: isContact,
})
evalCtx := domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewerUserID,
ViewerIsContact: isContact,
ViewerCloseFriend: isContact && contact.CloseFriend,
}
applyViewerFacts(&evalCtx, baseFacts[viewerUserID], now)
applyMembershipFacts(&evalCtx, needs.chatIDs, membershipFacts)
m[k] = Evaluate(rules, evalCtx)
}
out[owner] = m
}
return out, nil
}
// CanContactForFreeBatch evaluates the complete exception predicate for
// per-user contact requirements. Contacts are always free because the global
// setting is explicitly "noncontact peers"; privacyKeyNoPaidMessages adds
// exceptions beyond that relationship. Both facts come from the in-memory
// privacy/contact read models after their bounded cold loads.
func (s *Service) CanContactForFreeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64) (map[int64]bool, error) {
owners := dedupNonZero(ownerUserIDs)
out := make(map[int64]bool, len(owners))
if viewerUserID == 0 || len(owners) == 0 {
return out, nil
}
visibility, err := s.CanSeeBatch(
ctx,
owners,
viewerUserID,
[]domain.PrivacyKey{domain.PrivacyKeyNoPaidMessages},
)
if err != nil {
return nil, err
}
var contacts map[int64]domain.Contact
if s != nil && s.contacts != nil {
contacts, err = s.contacts.GetReverseContacts(ctx, viewerUserID, owners)
if err != nil {
return nil, err
}
}
for _, ownerUserID := range owners {
_, isContact := contacts[ownerUserID]
out[ownerUserID] = ownerUserID == viewerUserID ||
isContact ||
visibility[ownerUserID][domain.PrivacyKeyNoPaidMessages]
}
return out, nil
}
// ViewerIsPremium reads the same bounded viewer-facts read model used by
// AllowPremium privacy rules. Contact permission checks must not bypass that
// cache with a per-send users-table query.
func (s *Service) ViewerIsPremium(ctx context.Context, viewerUserID int64) (bool, error) {
if viewerUserID == 0 {
return false, nil
}
facts, err := s.loadViewerFacts(ctx, []int64{viewerUserID})
if err != nil {
return false, err
}
fact := facts[viewerUserID]
return fact.Found && !fact.Bot && fact.PremiumUntil > s.now().Unix(), nil
}
// CanSeeMatrix 批量评估 owners × viewers × keys 的可见性矩阵,结果等价于逐 (owner,viewer,key)
// 调 CanSee但只用一次 ListPrivacyRules + 每 owner 一次 GetMany(owner,viewers) + 内存 Evaluate
// (把 fan-out 投影从 O(viewer) 次 privacy 查询降到 O(owner))。返回 map[owner]map[viewer]map[key]bool。
@ -249,6 +382,33 @@ func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
}
}
var needs evaluationNeeds
for _, owner := range owners {
for _, key := range keys {
rules, ok := rulesByOwner[owner][key]
if !ok {
rules = defaultRules(owner, key)
}
mergeNeeds(&needs, needsForRules(rules))
}
}
var baseFacts map[int64]viewerFacts
if needs.viewerBase {
var err error
baseFacts, err = s.loadViewerFacts(ctx, viewers)
if err != nil {
return nil, err
}
}
var membershipFacts map[membershipKey]bool
if len(needs.chatIDs) > 0 {
var err error
membershipFacts, err = s.loadMembershipFacts(ctx, needs.chatIDs, viewers)
if err != nil {
return nil, err
}
}
now := s.now().Unix()
for _, owner := range owners {
// owner 的联系人中哪些是本批 viewer= privacy 的 ViewerIsContact对应 contacts.Get(owner,viewer))。
var ownerContacts map[int64]domain.Contact
@ -269,17 +429,21 @@ func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs
perViewer[viewer] = m
continue
}
_, isContact := ownerContacts[viewer]
contact, isContact := ownerContacts[viewer]
for _, k := range keys {
rules, ok := rulesByOwner[owner][k]
if !ok {
rules = defaultRules(owner, k)
}
m[k] = Evaluate(rules, domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewer,
ViewerIsContact: isContact,
})
evalCtx := domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewer,
ViewerIsContact: isContact,
ViewerCloseFriend: isContact && contact.CloseFriend,
}
applyViewerFacts(&evalCtx, baseFacts[viewer], now)
applyMembershipFacts(&evalCtx, needs.chatIDs, membershipFacts)
m[k] = Evaluate(rules, evalCtx)
}
perViewer[viewer] = m
}
@ -370,6 +534,7 @@ func validateRules(rules []domain.PrivacyRule) error {
if len(rules) > maxPrivacyRules {
return domain.ErrPrivacyRuleInvalid
}
totalIDs := 0
for _, rule := range rules {
switch rule.Kind {
case domain.PrivacyRuleAllowContacts,
@ -387,6 +552,20 @@ func validateRules(rules []domain.PrivacyRule) error {
default:
return domain.ErrPrivacyRuleInvalid
}
totalIDs += len(rule.UserIDs) + len(rule.ChatIDs)
if totalIDs > maxPrivacyRuleIDs {
return domain.ErrPrivacyRuleInvalid
}
for _, id := range rule.UserIDs {
if id <= 0 {
return domain.ErrPrivacyRuleInvalid
}
}
for _, id := range rule.ChatIDs {
if id <= 0 {
return domain.ErrPrivacyRuleInvalid
}
}
}
return nil
}

View file

@ -3,11 +3,44 @@ package privacy
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type countingBaseUsers struct {
calls int
users map[int64]domain.User
}
func (p *countingBaseUsers) PrivacyBaseUsers(_ context.Context, userIDs []int64) ([]domain.User, error) {
p.calls++
out := make([]domain.User, 0, len(userIDs))
for _, userID := range userIDs {
if user, ok := p.users[userID]; ok {
out = append(out, user)
}
}
return out, nil
}
type countingMemberships struct {
calls int
active map[int64]map[int64]bool
}
func (p *countingMemberships) FilterActiveChannelMemberIDs(_ context.Context, channelID int64, userIDs []int64) ([]int64, error) {
p.calls++
out := make([]int64, 0, len(userIDs))
for _, userID := range userIDs {
if p.active[channelID][userID] {
out = append(out, userID)
}
}
return out, nil
}
func TestDefaultPrivacyRules(t *testing.T) {
ctx := context.Background()
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore())
@ -192,3 +225,111 @@ func TestCanSeeMatrixEquivalentToCanSee(t *testing.T) {
}
}
}
func TestViewerFactsReadModelBatchesCachesAndInvalidates(t *testing.T) {
ctx := context.Background()
rules := memory.NewPrivacyStore()
users := &countingBaseUsers{users: map[int64]domain.User{
2001: {ID: 2001, PremiumUntil: 2000},
2002: {ID: 2002, Bot: true},
}}
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(users, nil)
svc.now = func() time.Time { return time.Unix(1000, 0) }
if _, err := svc.SetRules(ctx, 1001, domain.PrivacyKeyNoPaidMessages, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleAllowPremium},
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set premium rules: %v", err)
}
if _, err := svc.SetRules(ctx, 1002, domain.PrivacyKeyNoPaidMessages, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleAllowBots},
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set bot rules: %v", err)
}
got, err := svc.CanSeeMatrix(
ctx,
[]int64{1001, 1002},
[]int64{2001, 2002},
[]domain.PrivacyKey{domain.PrivacyKeyNoPaidMessages},
)
if err != nil {
t.Fatalf("CanSeeMatrix: %v", err)
}
if !got[1001][2001][domain.PrivacyKeyNoPaidMessages] ||
got[1001][2002][domain.PrivacyKeyNoPaidMessages] ||
got[1002][2001][domain.PrivacyKeyNoPaidMessages] ||
!got[1002][2002][domain.PrivacyKeyNoPaidMessages] {
t.Fatalf("unexpected premium/bot visibility matrix: %+v", got)
}
if users.calls != 1 {
t.Fatalf("base user cold loads = %d, want one batched load", users.calls)
}
if premium, err := svc.ViewerIsPremium(ctx, 2001); err != nil || !premium {
t.Fatalf("warm ViewerIsPremium = %v, err=%v; want true", premium, err)
}
if users.calls != 1 {
t.Fatalf("warm viewer facts hit called backend: calls=%d", users.calls)
}
users.users[2001] = domain.User{ID: 2001}
svc.InvalidateViewerFacts(2001)
if premium, err := svc.ViewerIsPremium(ctx, 2001); err != nil || premium {
t.Fatalf("invalidated ViewerIsPremium = %v, err=%v; want false", premium, err)
}
if users.calls != 2 {
t.Fatalf("invalidated viewer facts cold loads = %d, want 2", users.calls)
}
}
func TestMembershipReadModelCachesNegativeFactsAndInvalidatesPair(t *testing.T) {
ctx := context.Background()
rules := memory.NewPrivacyStore()
memberships := &countingMemberships{active: map[int64]map[int64]bool{
9001: {2001: true},
9002: {},
}}
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(nil, memberships)
if _, err := svc.SetRules(ctx, 1001, domain.PrivacyKeyChatInvite, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleAllowChatParticipants, ChatIDs: []int64{9001, 9002}},
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set participant rules: %v", err)
}
got, err := svc.CanSeeMatrix(
ctx,
[]int64{1001},
[]int64{2001, 2002},
[]domain.PrivacyKey{domain.PrivacyKeyChatInvite},
)
if err != nil {
t.Fatalf("CanSeeMatrix: %v", err)
}
if !got[1001][2001][domain.PrivacyKeyChatInvite] ||
got[1001][2002][domain.PrivacyKeyChatInvite] {
t.Fatalf("unexpected membership visibility matrix: %+v", got)
}
if memberships.calls != 2 {
t.Fatalf("membership cold loads = %d, want one batch per referenced chat", memberships.calls)
}
if allowed, err := svc.CanSee(ctx, 1001, 2002, domain.PrivacyKeyChatInvite); err != nil || allowed {
t.Fatalf("warm negative membership = %v, err=%v; want false", allowed, err)
}
if memberships.calls != 2 {
t.Fatalf("negative cache missed: calls=%d", memberships.calls)
}
memberships.active[9002][2002] = true
svc.InvalidateMembership(9002, 2002)
if allowed, err := svc.CanSee(ctx, 1001, 2002, domain.PrivacyKeyChatInvite); err != nil || !allowed {
t.Fatalf("invalidated membership = %v, err=%v; want true", allowed, err)
}
if memberships.calls != 3 {
t.Fatalf("pair invalidation reloads = %d, want 3", memberships.calls)
}
}

View file

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

View file

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

View file

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

View file

@ -166,4 +166,10 @@ func TestCreateCatalogBundleMaterializesPublishableCollectibleDocuments(t *testi
if !pattern.Attributes[1].TextColor {
t.Fatalf("pattern render attribute = %+v, want text_color", pattern.Attributes[1])
}
preview, found, err := svc.CollectiblePreviewSample(ctx, result.Catalog.Gift.ID)
if err != nil || !found || len(preview.Models) != 2 || len(preview.Patterns) != 2 || len(preview.Backdrops) != 2 ||
preview.Models[0].Animation == nil || len(preview.Models[0].Animation.JSON) != 0 ||
preview.Patterns[0].Animation == nil || len(preview.Patterns[0].Animation.JSON) != 0 {
t.Fatalf("collectible preview sample = found:%v err:%v value:%+v", found, err, preview)
}
}

View file

@ -442,10 +442,22 @@ func collectibleDocumentAttributes(kind domain.StarGiftCollectibleAttributeKind)
}
func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
return s.collectiblePreview(ctx, giftID, 0)
}
// CollectiblePreviewSample returns the small randomized working set consumed by official-client
// upgrade rollers. The complete published pool remains available through CollectiblePreview for
// payments.getStarGiftUpgradeAttributes and the admin editor.
func (s *Service) CollectiblePreviewSample(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
const attributesPerKind = 3
return s.collectiblePreview(ctx, giftID, attributesPerKind)
}
func (s *Service) collectiblePreview(ctx context.Context, giftID int64, samplePerKind int) (domain.StarGiftUpgradePreview, bool, error) {
if s == nil || s.store == nil || giftID <= 0 {
return domain.StarGiftUpgradePreview{}, false, nil
}
revision, ok, err := s.store.ActiveCollectibleRevision(ctx, giftID)
revision, ok, err := s.store.ActiveCollectibleProjection(ctx, giftID, samplePerKind)
if err != nil || !ok || !revision.Published {
return domain.StarGiftUpgradePreview{}, false, err
}
@ -740,6 +752,25 @@ func (s *Service) SetNotifications(ctx context.Context, userID, channelID int64,
return s.lifecycle.SetStarGiftNotifications(ctx, userID, channelID, enabled)
}
func (s *Service) NotificationsEnabled(ctx context.Context, userID, channelID int64) (bool, error) {
if s == nil {
return false, domain.ErrStarGiftUnavailable
}
if s.lifecycle == nil {
// Isolated memory/RPC adapters have no settings table; production's
// persisted default is enabled, so preserve that wire behavior.
return true, nil
}
return s.lifecycle.StarGiftNotificationsEnabled(ctx, userID, channelID)
}
func (s *Service) ResolveUserMessageRef(ctx context.Context, viewerUserID int64, msgID int) (domain.SavedStarGiftRef, bool, error) {
if s == nil || s.store == nil {
return domain.SavedStarGiftRef{}, false, nil
}
return s.store.ResolveUserMessageRef(ctx, viewerUserID, msgID)
}
func (s *Service) Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error) {
if s == nil || s.lifecycle == nil || s.withdrawal == nil {
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
@ -794,17 +825,15 @@ func (s *Service) TonBalance(ctx context.Context, userID int64) (int64, error) {
return s.lifecycle.TonBalance(ctx, userID)
}
func (s *Service) TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error) {
func (s *Service) TonTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
if s == nil || s.lifecycle == nil {
return domain.TonTransactionPage{}, nil
}
if len(offset) > domain.MaxStarsTransactionsOffsetBytes {
offset = ""
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.TonTransactionPage{}, err
}
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
limit = domain.MaxStarsTransactionsLimit
}
return s.lifecycle.TonTransactions(ctx, userID, offset, limit)
return s.lifecycle.TonTransactions(ctx, userID, query)
}
func (s *Service) ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error) {
@ -814,17 +843,15 @@ func (s *Service) ChannelStarsBalance(ctx context.Context, channelID int64) (int
return s.lifecycle.ChannelStarsBalance(ctx, channelID)
}
func (s *Service) ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error) {
func (s *Service) ChannelStarsTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
if s == nil || s.lifecycle == nil {
return domain.StarsTransactionPage{}, nil
}
if len(offset) > domain.MaxStarsTransactionsOffsetBytes {
offset = ""
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.StarsTransactionPage{}, err
}
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
limit = domain.MaxStarsTransactionsLimit
}
return s.lifecycle.ChannelStarsTransactions(ctx, channelID, offset, limit)
return s.lifecycle.ChannelStarsTransactions(ctx, channelID, query)
}
func (s *Service) ChannelTonBalance(ctx context.Context, channelID int64) (int64, error) {
@ -834,17 +861,15 @@ func (s *Service) ChannelTonBalance(ctx context.Context, channelID int64) (int64
return s.lifecycle.ChannelTonBalance(ctx, channelID)
}
func (s *Service) ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error) {
func (s *Service) ChannelTonTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
if s == nil || s.lifecycle == nil {
return domain.TonTransactionPage{}, nil
}
if len(offset) > domain.MaxStarsTransactionsOffsetBytes {
offset = ""
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.TonTransactionPage{}, err
}
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
limit = domain.MaxStarsTransactionsLimit
}
return s.lifecycle.ChannelTonTransactions(ctx, channelID, offset, limit)
return s.lifecycle.ChannelTonTransactions(ctx, channelID, query)
}
func (s *Service) SweepLifecycle(ctx context.Context, now, limit int) error {
@ -880,9 +905,11 @@ func (s *Service) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs
func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
if gift.UniqueGiftID == 0 && gift.PrepaidUpgradeStars == 0 && gift.PrepaidUpgradeHash == "" && s.store != nil {
if revision, ok, err := s.store.ActiveCollectibleRevision(ctx, gift.GiftID); err != nil {
availability, err := s.store.CollectibleAvailability(ctx, []int64{gift.GiftID})
if err != nil {
return 0, err
} else if ok && revision.Published && revision.Issued < revision.SupplyTotal {
}
if current, ok := availability[gift.GiftID]; ok && current.Issued < current.SupplyTotal {
var token [32]byte
if _, err := rand.Read(token[:]); err != nil {
return 0, fmt.Errorf("generate prepaid star gift upgrade hash: %w", err)

View file

@ -13,9 +13,10 @@ import (
// Service 是 Stars 账本应用服务。
type Service struct {
store store.StarsStore
grantAmount int64
now func() time.Time
store store.StarsStore
purchaseStore store.StarsPurchaseStore
grantAmount int64
now func() time.Time
}
// Option 配置 Service。
@ -26,6 +27,11 @@ func WithStartingGrant(amount int64) Option {
return func(s *Service) { s.grantAmount = amount }
}
// WithPurchaseStore enables the atomic fiat Stars checkout aggregate.
func WithPurchaseStore(st store.StarsPurchaseStore) Option {
return func(s *Service) { s.purchaseStore = st }
}
// WithClock 注入时钟(测试用)。
func WithClock(now func() time.Time) Option {
return func(s *Service) {
@ -78,16 +84,67 @@ func (s *Service) Debit(ctx context.Context, userID, amount int64, reason domain
return s.store.Debit(ctx, userID, amount, reason, peer, int(s.now().Unix()), title, desc)
}
// ListTransactions 按 keyset 分页返回流水 + 当前余额,首读时惰性授予。
func (s *Service) ListTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.StarsTransactionPage, error) {
if len(offset) > domain.MaxStarsTransactionsOffsetBytes {
offset = ""
}
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
limit = domain.MaxStarsTransactionsLimit
// ListTransactions 按方向与顺序做 keyset 分页,首读时惰性授予。
func (s *Service) ListTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.StarsTransactionPage{}, err
}
if _, err := s.ensureGranted(ctx, userID); err != nil {
return domain.StarsTransactionPage{}, err
}
return s.store.ListTransactions(ctx, userID, offset, limit)
return s.store.ListTransactions(ctx, userID, query)
}
// IssuePurchaseForm persists a short-lived, exact checkout intent.
func (s *Service) IssuePurchaseForm(ctx context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) {
if s.purchaseStore == nil || !validPurchaseForm(form) {
return domain.StarsPurchaseForm{}, domain.ErrStarsPurchaseFormInvalid
}
return s.purchaseStore.IssueStarsPurchaseForm(ctx, form)
}
// Purchase settles one exact persisted form. Package validation remains at
// the RPC boundary as well, while the store revalidates the persisted tuple
// under lock before performing any write.
func (s *Service) Purchase(ctx context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) {
if s.purchaseStore == nil || req.FormID == 0 || req.Date <= 0 || !validPurchaseCommand(req.StarsPurchaseForm) {
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
}
return s.purchaseStore.PurchaseStars(ctx, req)
}
// GetGiveawayInfo resolves one launch card from the same aggregate that
// persisted it. date is supplied by the RPC clock for deterministic tests.
func (s *Service) GetGiveawayInfo(ctx context.Context, viewerUserID, channelID int64, messageID, date int) (domain.StarsGiveawayInfo, error) {
reader, ok := s.purchaseStore.(store.StarsGiveawayStore)
if !ok || viewerUserID <= 0 || channelID <= 0 || messageID <= 0 || date <= 0 {
return domain.StarsGiveawayInfo{}, domain.ErrStarsPurchaseFormInvalid
}
return reader.GetStarsGiveawayInfo(ctx, viewerUserID, channelID, messageID, date)
}
func validPurchaseForm(form domain.StarsPurchaseForm) bool {
return validPurchaseCommand(form) && form.IssuedAt > 0 && form.ExpiresAt == form.IssuedAt+600
}
func validPurchaseCommand(form domain.StarsPurchaseForm) bool {
if !form.Kind.Valid() || form.BuyerUserID <= 0 || form.Stars <= 0 || form.Amount <= 0 || form.Currency == "" {
return false
}
switch form.Kind {
case domain.StarsPurchaseTopup:
return form.Giveaway == nil && form.RecipientUserID == 0 && ((form.SpendPurposePeer == domain.Peer{}) ||
((form.SpendPurposePeer.Type == domain.PeerTypeUser || form.SpendPurposePeer.Type == domain.PeerTypeChannel) && form.SpendPurposePeer.ID > 0))
case domain.StarsPurchaseGift:
return form.Giveaway == nil && form.RecipientUserID > 0 && form.BuyerUserID != form.RecipientUserID && form.SpendPurposePeer == (domain.Peer{})
case domain.StarsPurchaseGiveaway:
g := form.Giveaway
return form.RecipientUserID == 0 && form.SpendPurposePeer == (domain.Peer{}) && g != nil &&
g.BoostPeer.Type == domain.PeerTypeChannel && g.BoostPeer.ID > 0 && g.RandomID != 0 &&
g.UntilDate > 0 && g.Users > 0 && g.PerUserStars > 0 &&
int64(g.Users) <= form.Stars/g.PerUserStars && int64(g.Users)*g.PerUserStars == form.Stars
default:
return false
}
}

View file

@ -33,7 +33,7 @@ func TestStartingGrantOnce(t *testing.T) {
t.Fatalf("second balance = %d, want 1000 (no double grant)", bal2.Balance)
}
// 流水里应恰有一条 grant。
page, err := svc.ListTransactions(ctx, 7, "", 100)
page, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Limit: 100})
if err != nil {
t.Fatalf("ListTransactions: %v", err)
}
@ -106,7 +106,7 @@ func TestListTransactionsPagination(t *testing.T) {
t.Fatalf("Credit#%d: %v", i, err)
}
}
page1, err := svc.ListTransactions(ctx, 7, "", 2)
page1, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Limit: 2})
if err != nil {
t.Fatalf("page1: %v", err)
}
@ -117,14 +117,14 @@ func TestListTransactionsPagination(t *testing.T) {
if page1.Transactions[0].Amount != 14 {
t.Fatalf("page1[0].Amount = %d, want 14 (newest first)", page1.Transactions[0].Amount)
}
page2, err := svc.ListTransactions(ctx, 7, page1.NextOffset, 2)
page2, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Offset: page1.NextOffset, Limit: 2})
if err != nil {
t.Fatalf("page2: %v", err)
}
if len(page2.Transactions) != 2 {
t.Fatalf("page2 = %d txns, want 2", len(page2.Transactions))
}
page3, err := svc.ListTransactions(ctx, 7, page2.NextOffset, 2)
page3, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Offset: page2.NextOffset, Limit: 2})
if err != nil {
t.Fatalf("page3: %v", err)
}
@ -135,3 +135,75 @@ func TestListTransactionsPagination(t *testing.T) {
t.Fatalf("last page NextOffset = %q, want empty (no infinite paging)", page3.NextOffset)
}
}
func TestListTransactionsDirectionAndAscending(t *testing.T) {
svc := newTestService(0)
ctx := context.Background()
if _, err := svc.Credit(ctx, 7, 100, domain.StarsReasonTopup, domain.Peer{}, "", ""); err != nil {
t.Fatalf("credit 100: %v", err)
}
if _, err := svc.Debit(ctx, 7, 40, domain.StarsReasonGift, domain.Peer{}, "", ""); err != nil {
t.Fatalf("debit 40: %v", err)
}
if _, err := svc.Credit(ctx, 7, 20, domain.StarsReasonGift, domain.Peer{}, "", ""); err != nil {
t.Fatalf("credit 20: %v", err)
}
if _, err := svc.Debit(ctx, 7, 10, domain.StarsReasonReaction, domain.Peer{}, "", ""); err != nil {
t.Fatalf("debit 10: %v", err)
}
all, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Limit: 10})
if err != nil {
t.Fatalf("all transactions: %v", err)
}
assertStarsAmounts(t, all.Transactions, []int64{-10, 20, -40, 100})
if all.Balance != 70 {
t.Fatalf("all balance = %d, want 70", all.Balance)
}
incoming1, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{
Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("incoming page1: %v", err)
}
assertStarsAmounts(t, incoming1.Transactions, []int64{20})
if incoming1.NextOffset == "" {
t.Fatal("incoming page1 missing next offset")
}
incoming2, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{
Offset: incoming1.NextOffset, Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("incoming page2: %v", err)
}
assertStarsAmounts(t, incoming2.Transactions, []int64{100})
if incoming2.NextOffset != "" {
t.Fatalf("terminal incoming next offset = %q", incoming2.NextOffset)
}
outgoing, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{
Limit: 10, Direction: domain.StarsTransactionDirectionOutgoing, Ascending: true,
})
if err != nil {
t.Fatalf("ascending outgoing: %v", err)
}
assertStarsAmounts(t, outgoing.Transactions, []int64{-40, -10})
_, err = svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Direction: 99})
if !errors.Is(err, domain.ErrStarsTransactionQueryInvalid) {
t.Fatalf("invalid direction error = %v", err)
}
}
func assertStarsAmounts(t *testing.T, transactions []domain.StarsTransaction, want []int64) {
t.Helper()
if len(transactions) != len(want) {
t.Fatalf("transaction count = %d, want %d: %+v", len(transactions), len(want), transactions)
}
for i, amount := range want {
if transactions[i].Amount != amount {
t.Fatalf("transaction[%d].amount = %d, want %d", i, transactions[i].Amount, amount)
}
}
}

View file

@ -102,7 +102,14 @@ func (s *Service) Create(ctx context.Context, spec domain.ThemeSpec) (domain.The
func (s *Service) resolve(ctx context.Context, ref domain.ThemeRef) (domain.Theme, bool, error) {
if ref.ID != 0 {
return s.store.GetThemeByID(ctx, ref.ID)
t, ok, err := s.store.GetThemeByID(ctx, ref.ID)
if err != nil || !ok {
return domain.Theme{}, ok, err
}
if t.AccessHash != ref.AccessHash {
return domain.Theme{}, false, nil
}
return t, true, nil
}
if ref.Slug != "" {
return s.store.GetThemeBySlug(ctx, ref.Slug)

View file

@ -35,14 +35,14 @@ func TestServiceCreateAutoSlugAndCreatorGuard(t *testing.T) {
}
// 非创建者不能改。
if _, err := svc.Update(ctx, 2002, domain.ThemeRef{ID: a.ID}, domain.ThemeUpdate{Title: strptr("hacked")}); !errors.Is(err, domain.ErrThemeInvalid) {
if _, err := svc.Update(ctx, 2002, domain.ThemeRef{ID: a.ID, AccessHash: a.AccessHash}, domain.ThemeUpdate{Title: strptr("hacked")}); !errors.Is(err, domain.ErrThemeInvalid) {
t.Fatalf("non-creator update err = %v, want ErrThemeInvalid", err)
}
// 创建者可改 title + document。
newTitle := "A2"
newDoc := int64(99)
updated, err := svc.Update(ctx, owner, domain.ThemeRef{ID: a.ID}, domain.ThemeUpdate{Title: &newTitle, DocumentID: &newDoc})
updated, err := svc.Update(ctx, owner, domain.ThemeRef{ID: a.ID, AccessHash: a.AccessHash}, domain.ThemeUpdate{Title: &newTitle, DocumentID: &newDoc})
if err != nil {
t.Fatalf("creator update: %v", err)
}
@ -51,7 +51,7 @@ func TestServiceCreateAutoSlugAndCreatorGuard(t *testing.T) {
}
// install 计数 + 列表。
if err := svc.Install(ctx, owner, domain.ThemeRef{ID: a.ID}, true); err != nil {
if err := svc.Install(ctx, owner, domain.ThemeRef{ID: a.ID, AccessHash: a.AccessHash}, true); err != nil {
t.Fatalf("install: %v", err)
}
got, ok, _ := svc.Get(ctx, domain.ThemeRef{Slug: a.Slug})
@ -67,6 +67,20 @@ func TestServiceCreateAutoSlugAndCreatorGuard(t *testing.T) {
if err := svc.Install(ctx, owner, domain.ThemeRef{Slug: "nope"}, false); !errors.Is(err, domain.ErrThemeInvalid) {
t.Fatalf("install unknown err = %v, want ErrThemeInvalid", err)
}
forged := domain.ThemeRef{ID: a.ID, AccessHash: a.AccessHash + 1}
if _, ok, err := svc.Get(ctx, forged); err != nil || ok {
t.Fatalf("get forged access hash = ok %v err %v, want false/nil", ok, err)
}
if err := svc.Save(ctx, owner, forged); !errors.Is(err, domain.ErrThemeInvalid) {
t.Fatalf("save forged access hash err = %v, want ErrThemeInvalid", err)
}
if err := svc.Install(ctx, owner, forged, true); !errors.Is(err, domain.ErrThemeInvalid) {
t.Fatalf("install forged access hash err = %v, want ErrThemeInvalid", err)
}
if _, err := svc.Update(ctx, owner, forged, domain.ThemeUpdate{Title: strptr("forged")}); !errors.Is(err, domain.ErrThemeNotFound) {
t.Fatalf("update forged access hash err = %v, want ErrThemeNotFound", err)
}
}
func strptr(s string) *string { return &s }

View file

@ -400,31 +400,9 @@ func (s *Service) PublishNewMessage(ctx context.Context, userID int64, msg domai
}, true, 0, false)
}
// RecordMessageReactions records a durable marker for message reaction changes.
//
// updateMessageReactions has no pts fields in Layer 225, but TDesktop still
// needs getDifference to advance account pts and carry the latest reaction
// aggregate for offline devices.
func (s *Service) RecordMessageReactions(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) {
if userID == 0 {
userID = msg.OwnerUserID
}
date := msg.Date
if date == 0 {
date = int(time.Now().Unix())
}
return s.recordEventWithoutState(ctx, userID, domain.UpdateEvent{
Type: domain.UpdateEventMessageReactions,
Date: date,
Message: msg,
Peer: msg.Peer,
PtsCount: 1,
})
}
// RecordMessagePoll records a durable marker for message poll state changes
// (vote / close). updateMessagePoll has no pts fields in Layer 225 — same
// bookkeeping shape as RecordMessageReactions.
// historical bookkeeping shape pending its own audit.
func (s *Service) RecordMessagePoll(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) {
if userID == 0 {
userID = msg.OwnerUserID
@ -753,16 +731,6 @@ func (s *Service) RecordFolderPeers(ctx context.Context, stateAuthKeyID [8]byte,
}, true, excludeSessionID)
}
// RecordChannelAvailableMessages records a local channel history clear for multi-device sync.
func (s *Service) RecordChannelAvailableMessages(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, availableMinID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
return s.recordEvent(ctx, stateAuthKeyID, excludeAuthKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventChannelAvailable,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
MaxID: availableMinID,
PtsCount: 1,
}, true, excludeSessionID)
}
func (s *Service) recordEvent(ctx context.Context, stateAuthKeyID, excludeAuthKeyID [8]byte, userID int64, event domain.UpdateEvent, dispatch bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
return s.recordEventCore(ctx, stateAuthKeyID, excludeAuthKeyID, userID, event, dispatch, excludeSessionID, true)
}

View file

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

View file

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

View file

@ -1,8 +1,10 @@
package userprojection
import (
"container/list"
"context"
"fmt"
"sort"
"sync"
"time"
@ -18,7 +20,7 @@ const (
DefaultContactProjectionCacheTTL = 24 * time.Hour
contactSnapshotMaxViewers = 4096
contactReverseSnapshotOwnerCap = 16
contactReversePairMaxEntries = 262144
contactPersonalPhotoSnapshotCap = 4096
)
@ -34,11 +36,32 @@ type personalPhotoSnapshot struct {
expireAt time.Time
}
type reverseContactKey struct {
ownerUserID int64
contactUserID int64
}
type reverseContactSnapshot struct {
contact domain.Contact
found bool
expireAt time.Time
}
type reverseContactEntry struct {
key reverseContactKey
snapshot reverseContactSnapshot
}
type contactSnapshotLoadResult struct {
snap contactAccountSnapshot
stored bool
}
type reverseContactLoadResult struct {
contacts map[int64]domain.Contact
stored bool
}
type personalPhotoSnapshotLoadResult struct {
snap personalPhotoSnapshot
stored bool
@ -59,6 +82,10 @@ type CachedContactStore struct {
mu sync.RWMutex
contacts map[int64]contactAccountSnapshot
personalPhotos map[int64]personalPhotoSnapshot
reverse map[reverseContactKey]*list.Element
reverseLRU *list.List
reverseByOwner map[int64]map[int64]struct{}
reverseCap int
epoch uint64
sf singleflight.Group
}
@ -76,6 +103,10 @@ func NewCachedContactStore(inner store.ContactStore, ttl time.Duration) *CachedC
now: time.Now,
contacts: make(map[int64]contactAccountSnapshot, 1024),
personalPhotos: make(map[int64]personalPhotoSnapshot, 1024),
reverse: make(map[reverseContactKey]*list.Element, 4096),
reverseLRU: list.New(),
reverseByOwner: make(map[int64]map[int64]struct{}, 1024),
reverseCap: contactReversePairMaxEntries,
}
}
@ -131,24 +162,90 @@ func (c *CachedContactStore) GetReverseContacts(ctx context.Context, userID int6
if len(owners) == 0 {
return out, nil
}
if len(owners) > contactReverseSnapshotOwnerCap {
// Large fan-out should keep using the store's set query until a dedicated
// reverse-contact read model exists; loading hundreds of full contact
// lists would be worse than one batched SQL.
return c.inner.GetReverseContacts(ctx, userID, owners)
}
missing := make([]int64, 0, len(owners))
now := c.now()
for _, ownerID := range owners {
snap, err := c.contactSnapshot(ctx, ownerID)
if err != nil {
return nil, err
// Reuse a full owner snapshot when another hot path already loaded it.
// Do not cold-load one full list per owner: a large projection would turn
// into N SQL queries.
if snap, ok := c.lookupContactSnapshot(ownerID, now); ok {
if contact, found := snap.contacts[userID]; found {
out[ownerID] = cloneCachedContact(contact)
}
continue
}
if contact, ok := snap.contacts[userID]; ok {
if contact, found, cached := c.lookupReverseContact(ownerID, userID, now); cached {
if found {
out[ownerID] = contact
}
continue
}
missing = append(missing, ownerID)
}
if len(missing) == 0 {
return out, nil
}
loaded, err := c.loadReverseContacts(ctx, userID, missing)
if err != nil {
return nil, err
}
for ownerID, contact := range loaded {
if contact.User.ID != 0 {
out[ownerID] = cloneCachedContact(contact)
}
}
return out, nil
}
// loadReverseContacts performs at most one batched cold-store read for all
// missing owner→viewer pairs, then caches both hits and misses. Privacy
// projection therefore stays memory-only after warm-up instead of repeating a
// reverse-contact SQL query on every large user vector.
func (c *CachedContactStore) loadReverseContacts(ctx context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) {
owners := append([]int64(nil), ownerUserIDs...)
sort.Slice(owners, func(i, j int) bool { return owners[i] < owners[j] })
sfKey := fmt.Sprintf("contact-reverse:%d:%v", userID, owners)
for {
v, err, _ := c.sf.Do(sfKey, func() (any, error) {
loadEpoch := c.cacheEpoch()
contacts, err := c.inner.GetReverseContacts(ctx, userID, owners)
if err != nil {
return reverseContactLoadResult{}, err
}
now := c.now()
expireAt := now.Add(c.ttl)
c.mu.Lock()
stored := c.epoch == loadEpoch
if stored {
for _, ownerID := range owners {
key := reverseContactKey{ownerUserID: ownerID, contactUserID: userID}
contact, found := contacts[ownerID]
c.storeReverseContactLocked(key, reverseContactSnapshot{
contact: cloneCachedContact(contact),
found: found,
expireAt: expireAt,
})
}
}
c.mu.Unlock()
return reverseContactLoadResult{
contacts: cloneCachedContactMap(contacts),
stored: stored,
}, nil
})
if err != nil {
return nil, err
}
result := v.(reverseContactLoadResult)
if result.stored {
return result.contacts, nil
}
if err := ctx.Err(); err != nil {
return nil, err
}
}
}
func (c *CachedContactStore) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
contact, err := c.inner.Upsert(ctx, userID, input)
if err == nil {
@ -367,6 +464,59 @@ func (c *CachedContactStore) lookupPersonalPhotoSnapshot(userID int64, now time.
return snap, true
}
func (c *CachedContactStore) lookupReverseContact(ownerUserID, contactUserID int64, now time.Time) (domain.Contact, bool, bool) {
key := reverseContactKey{ownerUserID: ownerUserID, contactUserID: contactUserID}
c.mu.Lock()
element, ok := c.reverse[key]
if !ok {
c.mu.Unlock()
return domain.Contact{}, false, false
}
entry := element.Value.(*reverseContactEntry)
snap := entry.snapshot
if !snap.expireAt.After(now) {
c.removeReverseElementLocked(element)
c.mu.Unlock()
return domain.Contact{}, false, false
}
c.reverseLRU.MoveToFront(element)
c.mu.Unlock()
return cloneCachedContact(snap.contact), snap.found, true
}
func (c *CachedContactStore) storeReverseContactLocked(key reverseContactKey, snapshot reverseContactSnapshot) {
if element, ok := c.reverse[key]; ok {
entry := element.Value.(*reverseContactEntry)
entry.snapshot = snapshot
c.reverseLRU.MoveToFront(element)
return
}
element := c.reverseLRU.PushFront(&reverseContactEntry{key: key, snapshot: snapshot})
c.reverse[key] = element
if c.reverseByOwner[key.ownerUserID] == nil {
c.reverseByOwner[key.ownerUserID] = make(map[int64]struct{})
}
c.reverseByOwner[key.ownerUserID][key.contactUserID] = struct{}{}
for c.reverseLRU.Len() > c.reverseCap {
c.removeReverseElementLocked(c.reverseLRU.Back())
}
}
func (c *CachedContactStore) removeReverseElementLocked(element *list.Element) {
if element == nil {
return
}
entry := element.Value.(*reverseContactEntry)
delete(c.reverse, entry.key)
if viewers := c.reverseByOwner[entry.key.ownerUserID]; viewers != nil {
delete(viewers, entry.key.contactUserID)
if len(viewers) == 0 {
delete(c.reverseByOwner, entry.key.ownerUserID)
}
}
c.reverseLRU.Remove(element)
}
func (c *CachedContactStore) InvalidateViewers(ids ...int64) {
if c == nil || len(ids) == 0 {
return
@ -379,6 +529,11 @@ func (c *CachedContactStore) InvalidateViewers(ids ...int64) {
}
delete(c.contacts, id)
delete(c.personalPhotos, id)
for contactUserID := range c.reverseByOwner[id] {
if element, ok := c.reverse[reverseContactKey{ownerUserID: id, contactUserID: contactUserID}]; ok {
c.removeReverseElementLocked(element)
}
}
}
c.mu.Unlock()
}
@ -391,6 +546,9 @@ func (c *CachedContactStore) FlushReadModelCache() {
c.epoch++
c.contacts = make(map[int64]contactAccountSnapshot, 1024)
c.personalPhotos = make(map[int64]personalPhotoSnapshot, 1024)
c.reverse = make(map[reverseContactKey]*list.Element, 4096)
c.reverseLRU.Init()
c.reverseByOwner = make(map[int64]map[int64]struct{}, 1024)
c.mu.Unlock()
}
@ -415,6 +573,14 @@ func buildContactAccountSnapshot(list domain.ContactList, expireAt time.Time) co
return contactAccountSnapshot{contacts: contacts, ordered: ordered, hash: list.Hash, expireAt: expireAt}
}
func cloneCachedContactMap(in map[int64]domain.Contact) map[int64]domain.Contact {
out := make(map[int64]domain.Contact, len(in))
for id, contact := range in {
out[id] = cloneCachedContact(contact)
}
return out
}
func dedupContactIDs(ids []int64) []int64 {
seen := make(map[int64]struct{}, len(ids))
out := make([]int64, 0, len(ids))

View file

@ -162,6 +162,81 @@ func TestCachedContactStoreCachesProjectionReads(t *testing.T) {
}
}
func TestCachedContactStoreCachesLargeReverseContactBatch(t *testing.T) {
ctx := context.Background()
base := memory.NewContactStore()
owners := make([]int64, 32)
for i := range owners {
owners[i] = int64(i + 1)
if i%2 == 0 {
if _, err := base.Upsert(ctx, owners[i], domain.ContactInput{
ContactUserID: 9001,
FirstName: "Viewer",
}); err != nil {
t.Fatalf("seed owner %d: %v", owners[i], err)
}
}
}
counting := &countingContactStore{ContactStore: base}
cached := NewCachedContactStore(counting, 0)
first, err := cached.GetReverseContacts(ctx, 9001, owners)
if err != nil {
t.Fatalf("first reverse lookup: %v", err)
}
if len(first) != 16 || counting.reverseCalls != 1 || counting.listCalls != 0 {
t.Fatalf("first reverse hits=%d reverseCalls=%d listCalls=%d, want 16/1/0", len(first), counting.reverseCalls, counting.listCalls)
}
second, err := cached.GetReverseContacts(ctx, 9001, owners)
if err != nil {
t.Fatalf("second reverse lookup: %v", err)
}
if len(second) != 16 || counting.reverseCalls != 1 || counting.listCalls != 0 {
t.Fatalf("cached reverse hits=%d reverseCalls=%d listCalls=%d, want 16/1/0", len(second), counting.reverseCalls, counting.listCalls)
}
cached.InvalidateViewers(owners[0])
third, err := cached.GetReverseContacts(ctx, 9001, owners)
if err != nil {
t.Fatalf("reverse lookup after owner invalidation: %v", err)
}
if len(third) != 16 || counting.reverseCalls != 2 {
t.Fatalf("invalidated reverse hits=%d reverseCalls=%d, want 16/2", len(third), counting.reverseCalls)
}
}
func TestCachedContactStoreReversePairsUsePerEntryLRU(t *testing.T) {
ctx := context.Background()
base := memory.NewContactStore()
for ownerID := int64(1); ownerID <= 3; ownerID++ {
if _, err := base.Upsert(ctx, ownerID, domain.ContactInput{
ContactUserID: 9001,
FirstName: "Viewer",
}); err != nil {
t.Fatalf("seed owner %d: %v", ownerID, err)
}
}
counting := &countingContactStore{ContactStore: base}
cached := NewCachedContactStore(counting, 0)
cached.reverseCap = 2
for _, ownerID := range []int64{1, 2, 1, 3, 1, 2} {
got, err := cached.GetReverseContacts(ctx, 9001, []int64{ownerID})
if err != nil {
t.Fatalf("reverse owner %d: %v", ownerID, err)
}
if _, ok := got[ownerID]; !ok {
t.Fatalf("reverse owner %d missing", ownerID)
}
}
if counting.reverseCalls != 4 {
t.Fatalf("reverse calls = %d, want 4 (owner 1 touched, owner 2 evicted only)", counting.reverseCalls)
}
if len(cached.reverse) != 2 || cached.reverseLRU.Len() != 2 {
t.Fatalf("reverse cache map/list = %d/%d, want 2/2", len(cached.reverse), cached.reverseLRU.Len())
}
}
func TestCachedContactStoreInvalidatesAccountSnapshot(t *testing.T) {
ctx := context.Background()
base := memory.NewContactStore()

View file

@ -2,6 +2,7 @@ package userprojection
import (
"context"
"time"
"golang.org/x/sync/errgroup"
@ -209,7 +210,8 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
vis = matrix[u.ID][viewer]
}
var perr error
pj, perr = applyPrivacy(ctx, p.privacy, viewer, pj, found, vis, profileRefs, fallbackRefs, nil)
hasKnownContactPhone := found && contact.Phone != ""
pj, perr = applyPrivacy(ctx, p.privacy, viewer, pj, hasKnownContactPhone, vis, profileRefs, fallbackRefs, nil)
if perr != nil {
return nil, perr
}
@ -341,8 +343,9 @@ func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users [
}
// ForViewer applies the owner-specific user view that Telegram clients expect.
// In particular, phone is visible for self and contacts; non-contacts should not
// receive a phone field because TDesktop will prefer it over the public name.
// A contact relationship alone never grants phone visibility. A viewer may retain
// an owner-scoped phone it explicitly supplied, while the target account phone is
// governed by PhoneNumber privacy.
func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID int64, users []domain.User) ([]domain.User, error) {
users = sanitizeDeletedUsers(users)
if contacts == nil || viewerUserID == 0 || len(users) == 0 {
@ -489,8 +492,9 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
if viewerUserID != 0 && u.ID != viewerUserID && u.ID != domain.OfficialSystemUserID && !u.Bot {
contact, found := contactsByID[u.ID]
projected = applyContactProjection(projected, contact, found)
hasKnownContactPhone := found && contact.Phone != ""
var err error
projected, err = applyPrivacy(ctx, privacy, viewerUserID, projected, found, visibility[u.ID], profileRefs, fallbackRefs, personalRefs)
projected, err = applyPrivacy(ctx, privacy, viewerUserID, projected, hasKnownContactPhone, visibility[u.ID], profileRefs, fallbackRefs, personalRefs)
if err != nil {
return nil, err
}
@ -608,9 +612,11 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
user.CloseFriend = contact.CloseFriend || contact.User.CloseFriend
user.ContactNote = contact.Note
user.ContactNoteEntities = append([]domain.MessageEntity(nil), contact.NoteEntities...)
if contact.User.Phone != "" {
user.Phone = contact.User.Phone
} else {
// contact.Phone is an owner-local fact supplied by this viewer. It may differ
// from the target's current account phone and is safe to preserve because the
// viewer already knew it. An empty contact.Phone must not replace or authorize
// the target account phone carried by user.Phone.
if contact.Phone != "" {
user.Phone = contact.Phone
}
if contact.User.FirstName != "" || contact.User.LastName != "" {
@ -623,11 +629,16 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
return user
}
func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) {
func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, hasKnownContactPhone bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) {
if user.Deleted {
return user.DeletedTombstone(), nil
}
if privacy == nil {
// Missing privacy wiring must fail closed for an account phone. The only
// safe exception is an owner-scoped phone the viewer explicitly supplied.
if !hasKnownContactPhone {
user.Phone = ""
}
return user, nil
}
// vis 为批量预取结果projectBatch 一次 ListPrivacyRules+GetReverseContacts 算得);
@ -642,7 +653,7 @@ func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID in
if err != nil {
return domain.User{}, err
}
if !phoneAllowed {
if !phoneAllowed && !hasKnownContactPhone {
user.Phone = ""
}
statusAllowed, err := canSee(domain.PrivacyKeyStatusTimestamp)
@ -650,10 +661,8 @@ func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID in
return domain.User{}, err
}
if !statusAllowed {
user.Status = domain.ApproximateUserStatus(user.LastSeenAt, int(time.Now().Unix()))
user.LastSeenAt = 0
if user.Status.Kind == domain.UserStatusOnline || user.Status.Kind == domain.UserStatusOffline {
user.Status = domain.UserStatus{Kind: domain.UserStatusRecently}
}
}
if ref, ok := personalRefs[user.ID]; ok && ref.PhotoID != 0 {
ref.Personal = true

View file

@ -119,6 +119,51 @@ func TestProjectorUsesFallbackWhenProfilePhotoHidden(t *testing.T) {
}
}
func TestProjectorContactWithoutKnownPhoneCannotBypassPhonePrivacy(t *testing.T) {
ctx := context.Background()
const (
viewerID = int64(3101)
ownerID = int64(3102)
)
contacts := memory.NewContactStore()
if _, err := contacts.Upsert(ctx, viewerID, domain.ContactInput{
ContactUserID: ownerID,
FirstName: "Saved",
Phone: "",
}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
privacy := privacyapp.NewService(memory.NewPrivacyStore(), contacts)
projector := New(
WithContactStore(contacts),
WithPrivacyEvaluator(privacy),
)
users, err := projector.ForViewer(ctx, viewerID, []domain.User{{
ID: ownerID,
Phone: "15550003102",
FirstName: "Owner",
}})
if err != nil {
t.Fatalf("ForViewer: %v", err)
}
owner := projectionUser(t, users, ownerID)
if !owner.Contact || owner.Phone != "" {
t.Fatalf("owner projection = %+v, want contact=true with hidden phone", owner)
}
batch, err := projector.ForViewers(ctx, []int64{viewerID}, []domain.User{{
ID: ownerID,
Phone: "15550003102",
FirstName: "Owner",
}})
if err != nil {
t.Fatalf("ForViewers: %v", err)
}
batchOwner := projectionUser(t, batch[viewerID], ownerID)
if !batchOwner.Contact || batchOwner.Phone != "" {
t.Fatalf("batch owner projection = %+v, want contact=true with hidden phone", batchOwner)
}
}
func TestProjectorAccountFreezeIsViewerScopedAndReversible(t *testing.T) {
ctx := context.Background()
const (

View file

@ -33,6 +33,10 @@ type usernameAvailabilityStore interface {
CheckUsername(ctx context.Context, userID int64, username string) (bool, error)
}
type moderationFlagAudienceStore interface {
ModerationFlagAudience(ctx context.Context, userID int64, limit int) ([]int64, error)
}
// Option 调整用户服务可选依赖。
type Option func(*Service)
@ -138,6 +142,14 @@ func (s *Service) AdminUser(ctx context.Context, userID int64) (domain.User, boo
return s.loadBaseUserByID(ctx, userID)
}
// PrivacyBaseUsers returns viewer-independent bot/premium facts through the
// shared base-user read model. Privacy uses this as a batched cold loader behind
// its bounded process cache; no viewer projection is performed, avoiding a
// privacy -> users -> privacy recursion.
func (s *Service) PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]domain.User, error) {
return s.loadBaseUsersByIDs(ctx, userIDs)
}
// ByIDs 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。
func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) {
if currentUserID == 0 {
@ -392,6 +404,23 @@ func (s *Service) SetScamFake(ctx context.Context, userID int64, scam, fake bool
return updated, nil
}
// ModerationFlagAudience returns the bounded set of existing viewers that may
// need an immediate updateUser after SCAM/FAKE changes. This is an online
// accelerator only: it does not allocate PTS or create durable update events.
func (s *Service) ModerationFlagAudience(ctx context.Context, userID int64, limit int) ([]int64, error) {
if userID == 0 {
return nil, ErrNotAuthorized
}
if limit <= 0 || limit > 4096 {
limit = 4096
}
audience, ok := s.users.(moderationFlagAudienceStore)
if !ok {
return []int64{userID}, nil
}
return audience.ModerationFlagAudience(ctx, userID, limit)
}
// SetSupport 设置/取消用户的 support 标记(官方客服账号)。写后刷新基础缓存。
func (s *Service) SetSupport(ctx context.Context, userID int64, support bool) (domain.User, error) {
if userID == 0 {
@ -548,7 +577,11 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user
return domain.User{}, false, err
}
username = normalizeUsername(username)
if !validUsername(username) {
// Resolution covers both the editable username slot (5..32) and
// Fragment-style collectible usernames (4..32). Keep the stricter
// validUsername check on create/update paths; only lookup accepts the
// collectible lower bound.
if !domain.ValidCollectibleUsername(username) {
return domain.User{}, false, domain.ErrUsernameInvalid
}
u, found, err := s.users.ByUsername(ctx, username)
@ -563,7 +596,9 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user
return u, true, nil
}
// ResolvePhone 解析手机号到用户;当前阶段默认允许手机号深链解析,隐私规则后续接 account privacy。
// ResolvePhone resolves a phone number only when the target's AddedByPhone
// privacy allows the current viewer. The evaluator is backed by owner-level
// privacy/contact snapshots in production, so this adds no per-rule SQL query.
func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone string) (domain.User, bool, error) {
if _, err := s.loadSelf(ctx, currentUserID); err != nil {
return domain.User{}, false, err
@ -577,6 +612,28 @@ func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone s
return u, found, err
}
s.putCachedUsers(ctx, u)
if s.privacy != nil && u.ID != currentUserID {
allowed := false
var err error
if batch, ok := s.privacy.(userprojection.BatchPrivacyEvaluator); ok {
visibility, batchErr := batch.CanSeeBatch(
ctx,
[]int64{u.ID},
currentUserID,
[]domain.PrivacyKey{domain.PrivacyKeyAddedByPhone},
)
err = batchErr
allowed = visibility[u.ID][domain.PrivacyKeyAddedByPhone]
} else {
allowed, err = s.privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyAddedByPhone)
}
if err != nil {
return domain.User{}, false, err
}
if !allowed {
return domain.User{}, false, domain.ErrPhoneNotOccupied
}
}
u, err = s.projectOne(ctx, currentUserID, u)
if err != nil {
return domain.User{}, false, err

View file

@ -6,6 +6,7 @@ import (
"strings"
"testing"
privacyapp "telesrv/internal/app/privacy"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
@ -44,6 +45,34 @@ func TestServiceUsernameLifecycle(t *testing.T) {
if err != nil || !found || resolved.ID != owner.ID {
t.Fatalf("ResolveUsername = user %+v found %v err %v, want owner", resolved, found, err)
}
registry := memory.NewCollectibleUsernameStore()
store.AttachUsernameRegistry(registry)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
if _, err := registry.SetEditableUsername(ctx, peer, updated.Username); err != nil {
t.Fatalf("seed editable username registry: %v", err)
}
if _, created, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{
Username: "nft4",
Owner: peer,
Currency: domain.CollectibleCurrencyStars,
Amount: 1,
Actor: "test",
}); err != nil || !created {
t.Fatalf("mint four-character collectible: created=%v err=%v", created, err)
}
resolved, found, err = svc.ResolveUsername(ctx, other.ID, "@NFT4")
if err != nil || !found || resolved.ID != owner.ID {
t.Fatalf("ResolveUsername collectible = user %+v found %v err %v, want owner", resolved, found, err)
}
if _, err := svc.UpdateUsername(ctx, owner.ID, "nft4"); !errors.Is(err, domain.ErrUsernameInvalid) {
t.Fatalf("four-character editable username err = %v, want username invalid", err)
}
if changed, err := registry.SetUsernameActive(ctx, peer, "nft4", false); err != nil || !changed {
t.Fatalf("deactivate collectible: changed=%v err=%v", changed, err)
}
if _, found, err := svc.ResolveUsername(ctx, other.ID, "nft4"); err != nil || found {
t.Fatalf("inactive collectible found=%v err=%v, want hidden", found, err)
}
if _, err := svc.UpdateUsername(ctx, owner.ID, "TAKEN_NAME"); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("UpdateUsername duplicate err = %v, want username occupied", err)
}
@ -60,6 +89,42 @@ func TestServiceUsernameLifecycle(t *testing.T) {
}
}
func TestResolvePhoneHonorsAddedByPhone(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
contacts := memory.NewContactStore()
viewer, err := users.Create(ctx, domain.User{AccessHash: 1, Phone: "15550001001", FirstName: "Viewer"})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
target, err := users.Create(ctx, domain.User{AccessHash: 2, Phone: "15550001002", FirstName: "Target"})
if err != nil {
t.Fatalf("create target: %v", err)
}
privacy := privacyapp.NewService(memory.NewPrivacyStore(), contacts)
if _, err := privacy.SetRules(ctx, target.ID, domain.PrivacyKeyAddedByPhone, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
t.Fatalf("set AddedByPhone: %v", err)
}
svc := NewService(users,
WithContactStore(contacts),
WithPrivacyEvaluator(privacy),
)
if _, found, err := svc.ResolvePhone(ctx, viewer.ID, target.Phone); !errors.Is(err, domain.ErrPhoneNotOccupied) || found {
t.Fatalf("ResolvePhone stranger found=%v err=%v, want phone not occupied", found, err)
}
if _, err := contacts.Upsert(ctx, target.ID, domain.ContactInput{
ContactUserID: viewer.ID,
FirstName: viewer.FirstName,
}); err != nil {
t.Fatalf("target add viewer: %v", err)
}
got, found, err := svc.ResolvePhone(ctx, viewer.ID, target.Phone)
if err != nil || !found || got.ID != target.ID {
t.Fatalf("ResolvePhone contact = %+v found=%v err=%v, want target", got, found, err)
}
}
func TestServiceUpdateProfile(t *testing.T) {
ctx := context.Background()
store := memory.NewUserStore()

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,22 @@
package android
const clientType = "android"
// OfferInitialMessageReportOptions preserves DrKLO's official channel-report
// flow. DrKLO starts that flow with an empty message-id vector and only opens
// its message selector after a chosen option receives MESSAGE_ID_REQUIRED.
//
// This exception is deliberately limited to the non-mutating first request:
// a selected option, a comment, or any non-Android caller must still pass the
// normal messages.report message-id validation.
func OfferInitialMessageReportOptions(
client string,
messageIDCount int,
option []byte,
comment string,
) bool {
return client == clientType &&
messageIDCount == 0 &&
len(option) == 0 &&
comment == ""
}

View file

@ -0,0 +1,32 @@
package android
import "testing"
func TestOfferInitialMessageReportOptions(t *testing.T) {
tests := []struct {
name string
client string
messageIDCount int
option []byte
comment string
want bool
}{
{name: "android initial discovery", client: "android", want: true},
{name: "desktop keeps protocol error", client: "tdesktop"},
{name: "selected option requires messages", client: "android", option: []byte("spam")},
{name: "comment requires messages", client: "android", comment: "details"},
{name: "message ids use normal flow", client: "android", messageIDCount: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := OfferInitialMessageReportOptions(
test.client,
test.messageIDCount,
test.option,
test.comment,
); got != test.want {
t.Fatalf("OfferInitialMessageReportOptions() = %v, want %v", got, test.want)
}
})
}
}

View file

@ -0,0 +1,24 @@
package android
import "encoding/json"
// DirectInvoiceCurrencies are the Google Play billing currencies for which
// DrKLO's official client must use Telegram's invoice flow. telesrv does not
// publish or verify Google Play products, so this prevents Stars options with
// no store_product from entering the Play Billing branch once it is ready.
var directInvoiceCurrencies = []string{
"AED", "AUD", "BRL", "CAD", "CHF", "CLP", "CNY", "COP", "CZK", "DKK",
"EGP", "EUR", "GBP", "HKD", "HUF", "IDR", "ILS", "INR", "JPY", "KRW",
"KZT", "MXN", "MYR", "NGN", "NOK", "NZD", "PEN", "PHP", "PKR", "PLN",
"QAR", "RON", "RUB", "SAR", "SEK", "SGD", "THB", "TRY", "TWD", "UAH",
"USD", "VND", "ZAR",
}
func DirectInvoiceCurrencies() []string {
return append([]string(nil), directInvoiceCurrencies...)
}
func DirectInvoiceCurrenciesJSON() string {
body, _ := json.Marshal(directInvoiceCurrencies)
return string(body)
}

View file

@ -0,0 +1,30 @@
package android
import (
"encoding/json"
"testing"
)
func TestDirectInvoiceCurrenciesAreStableAndContainUSD(t *testing.T) {
values := DirectInvoiceCurrencies()
if len(values) == 0 {
t.Fatal("direct invoice currency list is empty")
}
foundUSD := false
for _, value := range values {
if value == "USD" {
foundUSD = true
}
}
if !foundUSD {
t.Fatalf("direct invoice currencies = %v, want USD", values)
}
values[0] = "MUTATED"
if DirectInvoiceCurrencies()[0] == "MUTATED" {
t.Fatal("DirectInvoiceCurrencies returned mutable package storage")
}
var decoded []string
if err := json.Unmarshal([]byte(DirectInvoiceCurrenciesJSON()), &decoded); err != nil || len(decoded) != len(values) {
t.Fatalf("currency JSON = %q decoded=%v err=%v", DirectInvoiceCurrenciesJSON(), decoded, err)
}
}

View file

@ -0,0 +1,46 @@
package ios
import "github.com/iamxvbaba/td/tg"
// ProjectThemes converts theme accent colors to the ARGB representation used by
// Telegram-iOS. Official chat-theme seed colors are RGB24 values, while iOS
// passes accent_color and outbox_accent_color to UIColor(argb:); a zero high
// byte would therefore make every accent-tinted control fully transparent.
//
// The projection is copy-on-write so the Android/TDesktop catalog remains
// byte-for-byte unchanged. message_colors and wallpaper colors intentionally
// stay RGB24, as required by the TL schema and all audited clients.
func ProjectThemes(themes []tg.Theme) []tg.Theme {
if len(themes) == 0 {
return nil
}
out := make([]tg.Theme, len(themes))
for i := range themes {
out[i] = themes[i]
settings, ok := themes[i].GetSettings()
if !ok {
continue
}
projected := make([]tg.ThemeSettings, len(settings))
for j := range settings {
projected[j] = settings[j]
projected[j].AccentColor = opaqueARGB(settings[j].AccentColor)
if color, ok := settings[j].GetOutboxAccentColor(); ok {
projected[j].SetOutboxAccentColor(opaqueARGB(color))
}
if colors, ok := settings[j].GetMessageColors(); ok {
projected[j].SetMessageColors(append([]int(nil), colors...))
}
}
out[i].SetSettings(projected)
}
return out
}
func opaqueARGB(color int) int {
value := uint32(int32(color))
if value>>24 == 0 {
value |= 0xff000000
}
return int(int32(value))
}

View file

@ -0,0 +1,75 @@
package ios
import (
"testing"
"github.com/iamxvbaba/td/tg"
)
func TestProjectThemesMakesOnlyIOSAccentColorsOpaque(t *testing.T) {
settings := tg.ThemeSettings{
BaseTheme: &tg.BaseThemeClassic{},
AccentColor: 0x29b071,
}
settings.SetOutboxAccentColor(0x4cb064)
settings.SetMessageColors([]int{0xd4f1ff, 0xb9e4ff})
wallpaper := &tg.WallPaperNoFile{}
settings.SetWallpaper(wallpaper)
theme := tg.Theme{ID: 1, Slug: "green", Title: "Green"}
theme.SetSettings([]tg.ThemeSettings{settings})
projected := ProjectThemes([]tg.Theme{theme})
if len(projected) != 1 {
t.Fatalf("ProjectThemes length = %d, want 1", len(projected))
}
got, ok := projected[0].GetSettings()
if !ok || len(got) != 1 {
t.Fatalf("projected settings = %#v ok=%v, want one setting", got, ok)
}
if color := uint32(int32(got[0].AccentColor)); color != 0xff29b071 {
t.Fatalf("accent color = %#08x, want opaque ARGB 0xff29b071", color)
}
if color, ok := got[0].GetOutboxAccentColor(); !ok || uint32(int32(color)) != 0xff4cb064 {
t.Fatalf("outbox accent = %#08x ok=%v, want opaque ARGB 0xff4cb064", uint32(int32(color)), ok)
}
colors, ok := got[0].GetMessageColors()
if !ok || len(colors) != 2 || colors[0] != 0xd4f1ff || colors[1] != 0xb9e4ff {
t.Fatalf("message colors = %#v ok=%v, want unchanged RGB24 values", colors, ok)
}
if got[0].Wallpaper != wallpaper {
t.Fatal("wallpaper changed during accent-only projection")
}
sourceSettings, _ := theme.GetSettings()
if sourceSettings[0].AccentColor != 0x29b071 {
t.Fatalf("source accent mutated to %#x", sourceSettings[0].AccentColor)
}
sourceOutbox, _ := sourceSettings[0].GetOutboxAccentColor()
if sourceOutbox != 0x4cb064 {
t.Fatalf("source outbox accent mutated to %#x", sourceOutbox)
}
colors[0] = 1
sourceColors, _ := sourceSettings[0].GetMessageColors()
if sourceColors[0] != 0xd4f1ff {
t.Fatalf("source message colors mutated through projection: %#v", sourceColors)
}
}
func TestProjectThemesPreservesExistingAlphaAndAbsentOutbox(t *testing.T) {
argb := uint32(0x80445566)
settings := tg.ThemeSettings{
BaseTheme: &tg.BaseThemeTinted{},
AccentColor: int(int32(argb)),
}
theme := tg.Theme{ID: 2}
theme.SetSettings([]tg.ThemeSettings{settings})
projected := ProjectThemes([]tg.Theme{theme})
got, _ := projected[0].GetSettings()
if color := uint32(int32(got[0].AccentColor)); color != argb {
t.Fatalf("existing ARGB color = %#08x, want %#08x", color, argb)
}
if _, ok := got[0].GetOutboxAccentColor(); ok {
t.Fatal("absent outbox accent became present")
}
}

View file

@ -38,9 +38,68 @@ func LookupWallPaper(input tg.InputWallPaperClass) (tg.WallPaperClass, bool) {
return DefaultWallPaper(wallpaper), true
}
}
// account.getThemes/account.getChatThemes also advertise wallpapers nested
// in ThemeSettings. The default getWallPapers export is a filtered list and
// does not contain every nested entry, so these identities must remain part
// of the same lookup boundary or Android can render a theme that it cannot
// subsequently install.
for _, theme := range catalog.ChatThemes {
for _, settings := range theme.Settings {
if inputWallPaperMatches(input, settings.Wallpaper) {
return DefaultWallPaper(settings.Wallpaper), true
}
}
}
// DrKLO normally installs a default theme with the nested wallpaper slug
// stored on ThemeAccent. During accent restoration it can instead fall back
// to ThemeInfo.slug, which is the slug of the exact Theme advertised by
// account.getThemes. Accept that server-issued alias only when every setting
// of the matched theme points at one unambiguous file wallpaper.
if in, ok := input.(*tg.InputWallPaperSlug); ok {
if wallpaper, ok := lookupChatThemeWallpaperAlias(catalog.ChatThemes, in.Slug); ok {
return DefaultWallPaper(wallpaper), true
}
}
return nil, false
}
func lookupChatThemeWallpaperAlias(themes []appearance.ChatTheme, slug string) (appearance.Wallpaper, bool) {
if slug == "" {
return appearance.Wallpaper{}, false
}
var resolved appearance.Wallpaper
found := false
for _, theme := range themes {
if theme.Slug != slug || len(theme.Settings) == 0 {
continue
}
var themeWallpaper appearance.Wallpaper
for i, settings := range theme.Settings {
wallpaper := settings.Wallpaper
if wallpaper.Slug == "" || wallpaper.ID == 0 {
return appearance.Wallpaper{}, false
}
if i == 0 {
themeWallpaper = wallpaper
continue
}
if !sameWallpaperIdentity(themeWallpaper, wallpaper) {
return appearance.Wallpaper{}, false
}
}
if found && !sameWallpaperIdentity(resolved, themeWallpaper) {
return appearance.Wallpaper{}, false
}
resolved = themeWallpaper
found = true
}
return resolved, found
}
func sameWallpaperIdentity(a, b appearance.Wallpaper) bool {
return a.ID == b.ID && a.AccessHash == b.AccessHash && a.Slug == b.Slug
}
// LookupWallPapers resolves multiple wallpapers from the Default seed catalog.
func LookupWallPapers(inputs []tg.InputWallPaperClass) ([]tg.WallPaperClass, bool) {
out := make([]tg.WallPaperClass, 0, len(inputs))

View file

@ -1,6 +1,7 @@
package tdesktop
import (
"net/netip"
"time"
"github.com/iamxvbaba/td/tg"
@ -13,18 +14,27 @@ import (
// 字段值取 Telegram 常见默认TDesktop 联调阶段按客户端实际需要微调
// (记录于 docs/compatibility-matrix.md
func BuildConfig(dc int, ip string, port int, now time.Time, publicBaseURL string) *tg.Config {
// TELESRV_ADVERTISE_IP is validated during config loading. Parse again here
// only to derive the wire ipv6 flag and to render IPv4-mapped addresses in
// their canonical form. Keeping the advertised route in help.getConfig is a
// protocol invariant: clients replace or persist this list for reconnects.
addr, err := netip.ParseAddr(ip)
if err == nil {
addr = addr.Unmap()
ip = addr.String()
}
meURLPrefix := links.NormalizeBaseURL(publicBaseURL) + "/"
config := &tg.Config{
Date: int(now.Unix()),
Expires: int(now.Add(time.Hour).Unix()),
TestMode: false,
ThisDC: dc,
// 不下发 DCOptions客户端TDesktop patch / drklo fork已写死 static DC
// 地址空列表会让客户端保留它——drklo ConnectionsManager.cpp 的 processConfig
// 在 dc_options 为空时整段跳过 replaceAddresses/saveConfig既不覆盖也不持久化。
// 服务端因此无需配置对外可达 IP换网络/部署只改客户端写死地址即可。ip/port
// 参数暂留,供未来需要显式 advertise 时改回。
DCOptions: nil,
DCOptions: []tg.DCOption{{
Ipv6: addr.Is6(),
ID: dc,
IPAddress: ip,
Port: port,
}},
ChatSizeMax: 200,
MegagroupSizeMax: 200000,
ForwardedCountMax: 100,
@ -58,13 +68,3 @@ func BuildConfig(dc int, ip string, port int, now time.Time, publicBaseURL strin
config.SetReactionsDefault(&tg.ReactionEmoji{Emoticon: DefaultReactionEmoticon})
return config
}
// NearestDC 构造 help.getNearestDc 返回值。
func NearestDC(dc int) *tg.NearestDC {
return &tg.NearestDC{
// 默认国家=中国DrKLO/TDesktop 登录页据此预选区号(+86)。
Country: "CN",
ThisDC: dc,
NearestDC: dc,
}
}

View file

@ -18,3 +18,31 @@ func TestBuildConfigIncludesDefaultReaction(t *testing.T) {
t.Fatalf("reactions_default = %#v, want %q emoji", reaction, DefaultReactionEmoticon)
}
}
func TestBuildConfigAdvertisesCanonicalPrimaryDC(t *testing.T) {
tests := []struct {
name string
ip string
want string
ipv6 bool
}{
{name: "ipv4", ip: "192.0.2.10", want: "192.0.2.10"},
{name: "ipv6", ip: "2001:0db8::1", want: "2001:db8::1", ipv6: true},
{name: "mapped ipv4", ip: "::ffff:192.0.2.10", want: "192.0.2.10"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := BuildConfig(2, tt.ip, 2398, time.Unix(1, 0), "https://telesrv.net")
if len(config.DCOptions) != 1 {
t.Fatalf("len(DCOptions) = %d, want 1", len(config.DCOptions))
}
option := config.DCOptions[0]
if option.ID != 2 || option.IPAddress != tt.want || option.Port != 2398 || option.Ipv6 != tt.ipv6 {
t.Fatalf("DCOptions[0] = %+v, want dc=2 ip=%q port=2398 ipv6=%v", option, tt.want, tt.ipv6)
}
if option.MediaOnly || option.CDN || option.TCPObfuscatedOnly || option.Static || option.ThisPortOnly {
t.Fatalf("DCOptions[0] has unexpected restrictive flags: %+v", option)
}
})
}
}

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