Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877
This commit is contained in:
commit
ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions
994
internal/admin/botverification.go
Normal file
994
internal/admin/botverification.go
Normal file
|
|
@ -0,0 +1,994 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Third-party bot verification for the operator (core.telegram.org/api/bots/verification).
|
||||
//
|
||||
// This is NOT the official platform badge. The official review lives in
|
||||
// verification.go behind the verification.* permissions and writes
|
||||
// verification_applications / the peer's `verified` flag; this file drives the
|
||||
// third-party mechanism, whose state is verification_icons,
|
||||
// bot_verifier_settings, custom_verifications and custom_verification_requests,
|
||||
// behind the botverification.* permissions. Neither section reads the other's
|
||||
// tables, and a third-party verifier must never be able to mint a platform
|
||||
// checkmark -- so nothing here ever touches domain.User.Verified.
|
||||
//
|
||||
// The operator surface has two halves, and they carry different rights:
|
||||
//
|
||||
// - Configuration (botverification.manage): who is a verifier at all, which
|
||||
// icons exist, and stripping a granted mark. These are the actions that
|
||||
// decide how much a mark is worth, so they are deliberately not implied by
|
||||
// the right to work the queue.
|
||||
// - Review (botverification.review): deciding the applications filed with a
|
||||
// verifier bot.
|
||||
//
|
||||
// Every mutation goes through runCommand, so a decision is journalled in
|
||||
// admin_commands / admin_audit_logs, replayable by command id and rehearsable
|
||||
// with a dry run. A dry run never mutates and never predicts an outcome stricter
|
||||
// than the real command's: where a check can only run inside the use-case layer
|
||||
// (the icon's custom emoji document is resolved against the document store) the
|
||||
// dry run says so in its details instead of guessing.
|
||||
|
||||
// BotVerificationService is the operator-facing slice of the third-party
|
||||
// verification use cases. It is the exact method set *app/botverification.Service
|
||||
// exposes for this surface, so the admin layer never reaches into the store.
|
||||
type BotVerificationService interface {
|
||||
// Icon catalogue.
|
||||
Icons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error)
|
||||
UpsertIcon(ctx context.Context, icon domain.VerificationIcon) (domain.VerificationIcon, error)
|
||||
SetIconActive(ctx context.Context, iconID int64, active bool) (domain.VerificationIcon, error)
|
||||
|
||||
// Verifier status.
|
||||
Verifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error)
|
||||
VerifierSettings(ctx context.Context, botID int64) (domain.BotVerifierSettings, error)
|
||||
GrantVerifier(ctx context.Context, settings domain.BotVerifierSettings) (domain.BotVerifierSettings, error)
|
||||
SetVerifierEnabled(ctx context.Context, botID int64, enabled bool) (domain.BotVerifierSettings, error)
|
||||
RevokeVerifier(ctx context.Context, botID int64) (bool, error)
|
||||
|
||||
// Granted marks.
|
||||
Marks(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error)
|
||||
RevokeMark(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error)
|
||||
|
||||
// Application queue.
|
||||
Requests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error)
|
||||
Request(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error)
|
||||
RequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error)
|
||||
Approve(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error)
|
||||
Reject(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error)
|
||||
RevokeRequest(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error)
|
||||
}
|
||||
|
||||
// botVerificationCatalogueScan bounds the catalogue page a dry run reads to
|
||||
// pre-check an icon. The catalogue is operator-curated and small; the check is
|
||||
// advisory precisely because this bound may not cover it (see grantIconPreflight).
|
||||
const botVerificationCatalogueScan = 200
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command payloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GrantBotVerifierRequest grants or reconfigures a bot's verifier status.
|
||||
//
|
||||
// Version is the optimistic-locking token: 0 means "this is a new grant" and a
|
||||
// positive value means "update the row I read", so two operators editing the same
|
||||
// verifier cannot clobber each other. Enabled is deliberately absent -- the kill
|
||||
// switch is its own action, so reconfiguring a switched-off verifier does not
|
||||
// quietly switch it back on.
|
||||
type GrantBotVerifierRequest struct {
|
||||
CommandMeta
|
||||
BotID int64 `json:"bot_id"`
|
||||
IconDocumentID int64 `json:"icon_document_id"`
|
||||
CompanyName string `json:"company_name"`
|
||||
DefaultDescription string `json:"default_description"`
|
||||
CanModifyCustomDescription bool `json:"can_modify_custom_description"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
// SetBotVerifierEnabledRequest flips the operator kill switch. The verifier keeps
|
||||
// its row and the marks it granted, so flipping it back restores exactly what was
|
||||
// there.
|
||||
type SetBotVerifierEnabledRequest struct {
|
||||
CommandMeta
|
||||
BotID int64 `json:"bot_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// RevokeBotVerifierRequest removes verifier status entirely. Its marks cascade
|
||||
// away with it in the store, because a mark whose verifier no longer exists has
|
||||
// nothing to render.
|
||||
type RevokeBotVerifierRequest struct {
|
||||
CommandMeta
|
||||
BotID int64 `json:"bot_id"`
|
||||
}
|
||||
|
||||
// UpsertVerificationIconRequest adds or updates a catalogue entry, keyed by custom
|
||||
// emoji document id. OwnerBotID is zero for a shared entry and a bot id when the
|
||||
// operator reserves the icon for one verifier.
|
||||
type UpsertVerificationIconRequest struct {
|
||||
CommandMeta
|
||||
DocumentID int64 `json:"document_id"`
|
||||
Name string `json:"name"`
|
||||
OwnerBotID int64 `json:"owner_bot_id,omitempty"`
|
||||
}
|
||||
|
||||
// SetVerificationIconActiveRequest retires or restores a catalogue entry. Marks
|
||||
// already granted with it keep rendering -- the icon id is denormalised onto the
|
||||
// mark -- so retiring an entry stops new grants without blanking existing badges.
|
||||
type SetVerificationIconActiveRequest struct {
|
||||
CommandMeta
|
||||
IconID int64 `json:"icon_id"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// RevokeCustomVerificationRequest strips one verifier's mark from a peer on the
|
||||
// operator's behalf. It addresses the (verifier, peer) pair rather than an
|
||||
// application, because the operator may have to strip a mark that no application
|
||||
// ever produced.
|
||||
type RevokeCustomVerificationRequest struct {
|
||||
CommandMeta
|
||||
VerifierBotID int64 `json:"verifier_bot_id"`
|
||||
PeerType domain.PeerType `json:"peer_type"`
|
||||
PeerID int64 `json:"peer_id"`
|
||||
}
|
||||
|
||||
// ApproveBotVerificationRequest grants the mark an application asked for. The
|
||||
// mark and the approved status commit together in the use-case layer, so an
|
||||
// approved application without its mark is not a reachable state.
|
||||
type ApproveBotVerificationRequest struct {
|
||||
CommandMeta
|
||||
RequestID int64 `json:"request_id"`
|
||||
Version int64 `json:"version"`
|
||||
// InternalNote is operator-only: it is journalled and stored on the
|
||||
// application, and it is never part of what the applicant is told.
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// RejectBotVerificationRequest closes an application against the applicant.
|
||||
// Reason is mandatory: it is the text the applicant receives.
|
||||
type RejectBotVerificationRequest struct {
|
||||
CommandMeta
|
||||
RequestID int64 `json:"request_id"`
|
||||
Version int64 `json:"version"`
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// RevokeBotVerificationRequest withdraws a granted mark through the application it
|
||||
// came from. The application stays as history -- revoked is reachable only from
|
||||
// approved, so it keeps meaning "was verified once". Reason is mandatory for the
|
||||
// same reason it is on rejection.
|
||||
type RevokeBotVerificationRequest struct {
|
||||
CommandMeta
|
||||
RequestID int64 `json:"request_id"`
|
||||
Version int64 `json:"version"`
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BotVerifiers lists verifier bots.
|
||||
func (s *Service) BotVerifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return nil, errBotVerificationNotConfigured
|
||||
}
|
||||
return s.botVerification.Verifiers(ctx, enabledOnly, limit)
|
||||
}
|
||||
|
||||
// BotVerifier resolves one verifier's status, enabled or not: the panel needs the
|
||||
// disabled row too, to render the kill switch.
|
||||
func (s *Service) BotVerifier(ctx context.Context, botID int64) (domain.BotVerifierSettings, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return domain.BotVerifierSettings{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if botID <= 0 {
|
||||
return domain.BotVerifierSettings{}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
return s.botVerification.VerifierSettings(ctx, botID)
|
||||
}
|
||||
|
||||
// VerificationIcons lists the icon catalogue, newest first.
|
||||
func (s *Service) VerificationIcons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return nil, errBotVerificationNotConfigured
|
||||
}
|
||||
return s.botVerification.Icons(ctx, activeOnly, limit)
|
||||
}
|
||||
|
||||
// CustomVerifications lists granted third-party marks with keyset paging.
|
||||
func (s *Service) CustomVerifications(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return nil, errBotVerificationNotConfigured
|
||||
}
|
||||
return s.botVerification.Marks(ctx, filter)
|
||||
}
|
||||
|
||||
// CustomVerificationRequests is the third-party review queue.
|
||||
func (s *Service) CustomVerificationRequests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return nil, errBotVerificationNotConfigured
|
||||
}
|
||||
return s.botVerification.Requests(ctx, filter)
|
||||
}
|
||||
|
||||
// CustomVerificationRequest resolves one application by identity.
|
||||
func (s *Service) CustomVerificationRequest(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return domain.CustomVerificationRequest{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if requestID <= 0 {
|
||||
return domain.CustomVerificationRequest{}, botVerificationCoded(domain.ErrCustomVerificationRequestNotFound)
|
||||
}
|
||||
return s.botVerification.Request(ctx, requestID)
|
||||
}
|
||||
|
||||
// CustomVerificationRequestCounts is the queue summary rendered above the list.
|
||||
func (s *Service) CustomVerificationRequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return nil, errBotVerificationNotConfigured
|
||||
}
|
||||
return s.botVerification.RequestCounts(ctx)
|
||||
}
|
||||
|
||||
// CustomVerificationMarkActive reports whether a (verifier, peer) pair currently
|
||||
// carries a mark. The request detail view needs it to tell "approved" apart from
|
||||
// "approved and since stripped by the operator".
|
||||
func (s *Service) CustomVerificationMarkActive(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return false, errBotVerificationNotConfigured
|
||||
}
|
||||
if verifierBotID <= 0 || peer.ID <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
marks, err := s.botVerification.Marks(ctx, domain.CustomVerificationFilter{
|
||||
VerifierBotID: verifierBotID,
|
||||
PeerType: peer.Type,
|
||||
PeerID: peer.ID,
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return false, botVerificationError(err)
|
||||
}
|
||||
return len(marks) > 0, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands: verifier status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GrantBotVerifier grants or reconfigures verifier status.
|
||||
//
|
||||
// The dry run predicts everything it can without writing: the payload shape, the
|
||||
// optimistic lock against the row as it is now, and -- when the icon is visible in
|
||||
// the catalogue page it reads -- that the icon is active and usable by this bot.
|
||||
// It deliberately does not fail an icon it cannot see: the use-case layer resolves
|
||||
// the icon's custom emoji document against the document store, and a dry run that
|
||||
// refused more than the real command would make a rehearsal useless.
|
||||
func (s *Service) GrantBotVerifier(ctx context.Context, req GrantBotVerifierRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if req.BotID <= 0 {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
if req.Version < 0 {
|
||||
return CommandResult{}, botVerifierInvalid("version must not be negative")
|
||||
}
|
||||
settings := domain.BotVerifierSettings{
|
||||
BotID: req.BotID,
|
||||
IconDocumentID: req.IconDocumentID,
|
||||
CompanyName: strings.TrimSpace(req.CompanyName),
|
||||
DefaultDescription: strings.TrimSpace(req.DefaultDescription),
|
||||
CanModifyCustomDescription: req.CanModifyCustomDescription,
|
||||
GrantedBy: strings.TrimSpace(req.Actor),
|
||||
GrantReason: strings.TrimSpace(req.Reason),
|
||||
Enabled: true,
|
||||
Version: req.Version,
|
||||
}
|
||||
if err := settings.Validate(); err != nil {
|
||||
return CommandResult{}, botVerificationError(err)
|
||||
}
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: req.BotID}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionGrantBotVerifier, req.BotID, target, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"bot_id": strconv.FormatInt(req.BotID, 10),
|
||||
"icon_document_id": strconv.FormatInt(req.IconDocumentID, 10),
|
||||
"company_name": settings.CompanyName,
|
||||
"can_modify_custom_description": req.CanModifyCustomDescription,
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
current, exists, err := s.botVerifierState(ctx, req.BotID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["created"] = !exists
|
||||
if exists {
|
||||
details["previous_version"] = strconv.FormatInt(current.Version, 10)
|
||||
details["previous_icon_document_id"] = strconv.FormatInt(current.IconDocumentID, 10)
|
||||
details["previous_enabled"] = current.Enabled
|
||||
// The kill switch is its own action, so reconfiguring a switched-off
|
||||
// verifier must not switch it back on.
|
||||
settings.Enabled = current.Enabled
|
||||
if req.Version != current.Version {
|
||||
return CommandResult{Details: details}, codedError(CodeCustomVerificationConflict, domain.ErrCustomVerificationVersionConflict)
|
||||
}
|
||||
} else if req.Version != 0 {
|
||||
// The operator is editing a row that is no longer there.
|
||||
return CommandResult{Details: details}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
details["enabled"] = settings.Enabled
|
||||
if err := s.grantIconPreflight(ctx, details, req.BotID, req.IconDocumentID); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if req.DryRun {
|
||||
message := "bot verifier grant validated"
|
||||
if exists {
|
||||
message = "bot verifier update validated"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
}
|
||||
stored, err := s.botVerification.GrantVerifier(ctx, settings)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["version"] = strconv.FormatInt(stored.Version, 10)
|
||||
details["enabled"] = stored.Enabled
|
||||
details["changed"] = true
|
||||
message := "bot verifier status granted"
|
||||
if exists {
|
||||
message = "bot verifier status updated"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetBotVerifierEnabled flips the operator kill switch.
|
||||
func (s *Service) SetBotVerifierEnabled(ctx context.Context, req SetBotVerifierEnabledRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if req.BotID <= 0 {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: req.BotID}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetBotVerifierEnabled, req.BotID, target, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"bot_id": strconv.FormatInt(req.BotID, 10),
|
||||
"enabled": req.Enabled,
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
current, exists, err := s.botVerifierState(ctx, req.BotID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if !exists {
|
||||
return CommandResult{Details: details}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
details["previous_enabled"] = current.Enabled
|
||||
details["previous_version"] = strconv.FormatInt(current.Version, 10)
|
||||
details["company_name"] = current.CompanyName
|
||||
// A no-op flip neither burns a version nor pushes an update, so the audit
|
||||
// trail does not claim a change that never happened.
|
||||
noop := current.Enabled == req.Enabled
|
||||
details["changed"] = !noop
|
||||
if req.DryRun {
|
||||
message := "bot verifier switch validated"
|
||||
if noop {
|
||||
message = "bot verifier switch validated; already in that state"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
}
|
||||
stored, err := s.botVerification.SetVerifierEnabled(ctx, req.BotID, req.Enabled)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["version"] = strconv.FormatInt(stored.Version, 10)
|
||||
details["enabled"] = stored.Enabled
|
||||
message := "bot verifier enabled"
|
||||
if !req.Enabled {
|
||||
message = "bot verifier disabled"
|
||||
}
|
||||
if noop {
|
||||
message = "bot verifier was already in that state"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeBotVerifier removes verifier status entirely.
|
||||
//
|
||||
// A missing row is not an error: the command answers changed=false, so a panel
|
||||
// retry after a lost response is harmless and the dry run never refuses what the
|
||||
// real command accepts.
|
||||
func (s *Service) RevokeBotVerifier(ctx context.Context, req RevokeBotVerifierRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if req.BotID <= 0 {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: req.BotID}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRevokeBotVerifier, req.BotID, target, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"bot_id": strconv.FormatInt(req.BotID, 10),
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
current, exists, err := s.botVerifierState(ctx, req.BotID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["present"] = exists
|
||||
if exists {
|
||||
details["previous_version"] = strconv.FormatInt(current.Version, 10)
|
||||
details["previous_enabled"] = current.Enabled
|
||||
details["company_name"] = current.CompanyName
|
||||
// The marks this verifier granted cascade away with the row, which is
|
||||
// the fact an operator most needs stated before confirming.
|
||||
details["mark_count"] = s.botVerifierMarkCount(ctx, req.BotID)
|
||||
}
|
||||
if req.DryRun {
|
||||
message := "bot verifier revoke validated"
|
||||
if !exists {
|
||||
message = "bot verifier revoke validated; the bot is not a verifier"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
}
|
||||
removed, err := s.botVerification.RevokeVerifier(ctx, req.BotID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["changed"] = removed
|
||||
message := "bot verifier status revoked"
|
||||
if !removed {
|
||||
message = "bot was not a verifier"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands: icon catalogue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UpsertVerificationIcon adds or updates a catalogue entry.
|
||||
//
|
||||
// The use-case layer resolves the custom emoji document before writing, because an
|
||||
// entry pointing at a document no client can fetch renders as *nothing*: the peer
|
||||
// looks unverified while the server insists it is marked. That probe needs the
|
||||
// document store, so it runs on execution only; the dry run validates the shape.
|
||||
func (s *Service) UpsertVerificationIcon(ctx context.Context, req UpsertVerificationIconRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
icon := domain.VerificationIcon{
|
||||
DocumentID: req.DocumentID,
|
||||
OwnerBotID: req.OwnerBotID,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Active: true,
|
||||
}
|
||||
if err := icon.Validate(); err != nil {
|
||||
return CommandResult{}, botVerificationError(err)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionUpsertVerificationIcon, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"document_id": strconv.FormatInt(req.DocumentID, 10),
|
||||
"owner_bot_id": strconv.FormatInt(req.OwnerBotID, 10),
|
||||
"name": icon.Name,
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "verification icon validated", Details: details}, nil
|
||||
}
|
||||
stored, err := s.botVerification.UpsertIcon(ctx, icon)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["icon_id"] = strconv.FormatInt(stored.ID, 10)
|
||||
details["active"] = stored.Active
|
||||
details["changed"] = true
|
||||
return CommandResult{Message: "verification icon stored", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetVerificationIconActive retires or restores a catalogue entry.
|
||||
func (s *Service) SetVerificationIconActive(ctx context.Context, req SetVerificationIconActiveRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if req.IconID <= 0 {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrVerificationIconNotFound)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetVerificationIconActive, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"icon_id": strconv.FormatInt(req.IconID, 10),
|
||||
"active": req.Active,
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "verification icon switch validated", Details: details}, nil
|
||||
}
|
||||
stored, err := s.botVerification.SetIconActive(ctx, req.IconID, req.Active)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["document_id"] = strconv.FormatInt(stored.DocumentID, 10)
|
||||
details["name"] = stored.Name
|
||||
details["active"] = stored.Active
|
||||
details["changed"] = true
|
||||
message := "verification icon activated"
|
||||
if !req.Active {
|
||||
// Marks already granted with it keep rendering: the icon id is
|
||||
// denormalised onto the mark.
|
||||
message = "verification icon retired"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands: granted marks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RevokeCustomVerification strips one verifier's mark from a peer.
|
||||
//
|
||||
// The peer is checked for shape only, never for existence: an operator must still
|
||||
// be able to strip a mark from a peer that has since been deleted or become
|
||||
// unresolvable, which is exactly when stripping it matters most.
|
||||
func (s *Service) RevokeCustomVerification(ctx context.Context, req RevokeCustomVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if req.VerifierBotID <= 0 {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
|
||||
}
|
||||
peer := domain.Peer{Type: req.PeerType, ID: req.PeerID}
|
||||
if !markableAdminPeer(peer) {
|
||||
return CommandResult{}, botVerificationCoded(domain.ErrCustomVerificationTargetInvalid)
|
||||
}
|
||||
targetUserID := int64(0)
|
||||
if peer.Type == domain.PeerTypeUser {
|
||||
targetUserID = peer.ID
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRevokeCustomVerification, targetUserID, peer, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"verifier_bot_id": strconv.FormatInt(req.VerifierBotID, 10),
|
||||
"peer_type": string(peer.Type),
|
||||
"peer_id": strconv.FormatInt(peer.ID, 10),
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
present, err := s.CustomVerificationMarkActive(ctx, req.VerifierBotID, peer)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["mark_present"] = present
|
||||
if req.DryRun {
|
||||
message := "custom verification revoke validated"
|
||||
if !present {
|
||||
message = "custom verification revoke validated; the peer carries no mark from this verifier"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
}
|
||||
removed, err := s.botVerification.RevokeMark(ctx, req.VerifierBotID, peer)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
details["changed"] = removed
|
||||
message := "custom verification revoked"
|
||||
if !removed {
|
||||
message = "custom verification was already absent"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands: application queue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ApproveBotVerification grants the mark an application asked for.
|
||||
func (s *Service) ApproveBotVerification(ctx context.Context, req ApproveBotVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if err := validateBotVerificationDecisionShape(req.RequestID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionApproveBotVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
current, details, err := s.botVerificationSubject(ctx, req.CommandMeta, req.RequestID, domain.CustomVerificationApproved, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := botVerificationTransition(current, req.Version, domain.CustomVerificationApproved); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
// An application that is already approved is a replay: the use-case layer
|
||||
// answers changed=false without re-running the verifier gate, so the dry run
|
||||
// must not re-run it either.
|
||||
replay := current.Status == domain.CustomVerificationApproved
|
||||
if !replay {
|
||||
if err := s.mergeVerifierDecisionDetails(ctx, details, current.VerifierBotID, true); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "bot verification approve validated", Details: details}, nil
|
||||
}
|
||||
stored, changed, err := s.botVerification.Approve(ctx, req.RequestID, req.Version,
|
||||
strings.TrimSpace(req.Actor), strings.TrimSpace(req.Reason), strings.TrimSpace(req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
mergeBotVerificationDetails(details, stored, changed)
|
||||
message := "bot verification application approved"
|
||||
if !changed {
|
||||
message = "bot verification application already approved"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RejectBotVerification closes an application against the applicant. The reason is
|
||||
// mandatory and is the text the applicant is shown; the internal note stays
|
||||
// operator-side.
|
||||
func (s *Service) RejectBotVerification(ctx context.Context, req RejectBotVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if err := validateBotVerificationDecisionShape(req.RequestID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
// Refused before the journal is touched: the audit trail must never contain a
|
||||
// decision nobody can explain.
|
||||
if strings.TrimSpace(req.Reason) == "" {
|
||||
return CommandResult{}, codedError(CodeCustomVerificationReasonRequired, domain.ErrVerificationReasonRequired)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRejectBotVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
current, details, err := s.botVerificationSubject(ctx, req.CommandMeta, req.RequestID, domain.CustomVerificationRejected, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := botVerificationTransition(current, req.Version, domain.CustomVerificationRejected); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "bot verification reject validated", Details: details}, nil
|
||||
}
|
||||
stored, changed, err := s.botVerification.Reject(ctx, req.RequestID, req.Version,
|
||||
strings.TrimSpace(req.Actor), strings.TrimSpace(req.Reason), strings.TrimSpace(req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
mergeBotVerificationDetails(details, stored, changed)
|
||||
message := "bot verification application rejected"
|
||||
if !changed {
|
||||
message = "bot verification application already rejected"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeBotVerification withdraws a granted mark through the application it came
|
||||
// from. A reason is mandatory for the same reason it is on rejection.
|
||||
func (s *Service) RevokeBotVerification(ctx context.Context, req RevokeBotVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.botVerification == nil {
|
||||
return CommandResult{}, errBotVerificationNotConfigured
|
||||
}
|
||||
if err := validateBotVerificationDecisionShape(req.RequestID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(req.Reason) == "" {
|
||||
return CommandResult{}, codedError(CodeCustomVerificationReasonRequired, domain.ErrVerificationReasonRequired)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRevokeBotVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
current, details, err := s.botVerificationSubject(ctx, req.CommandMeta, req.RequestID, domain.CustomVerificationRevoked, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := botVerificationTransition(current, req.Version, domain.CustomVerificationRevoked); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
// The mark may already be gone -- the operator can strip one directly -- and
|
||||
// that is not an error: the application still has to reach "revoked".
|
||||
present, err := s.CustomVerificationMarkActive(ctx, current.VerifierBotID, current.Peer)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["mark_present"] = present
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "bot verification revoke validated", Details: details}, nil
|
||||
}
|
||||
stored, changed, err := s.botVerification.RevokeRequest(ctx, req.RequestID, req.Version,
|
||||
strings.TrimSpace(req.Actor), strings.TrimSpace(req.Reason), strings.TrimSpace(req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, botVerificationError(err)
|
||||
}
|
||||
mergeBotVerificationDetails(details, stored, changed)
|
||||
message := "bot verification mark revoked"
|
||||
if !changed {
|
||||
message = "bot verification application already revoked"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var errBotVerificationNotConfigured = errors.New("admin bot verification dependency is not configured")
|
||||
|
||||
// botVerifierState loads a verifier row and reports whether it exists at all,
|
||||
// because "not a verifier" is a normal answer for several of these commands
|
||||
// rather than a failure.
|
||||
func (s *Service) botVerifierState(ctx context.Context, botID int64) (domain.BotVerifierSettings, bool, error) {
|
||||
settings, err := s.botVerification.VerifierSettings(ctx, botID)
|
||||
switch {
|
||||
case err == nil:
|
||||
return settings, settings.BotID == botID, nil
|
||||
case errors.Is(err, domain.ErrVerifierNotFound):
|
||||
return domain.BotVerifierSettings{}, false, nil
|
||||
default:
|
||||
return domain.BotVerifierSettings{}, false, botVerificationError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// botVerifierMarkCount reports how many marks a verifier holds, bounded by the
|
||||
// listing page. It is advisory audit detail, so a read failure is reported as an
|
||||
// unknown count rather than failing the command it is describing.
|
||||
func (s *Service) botVerifierMarkCount(ctx context.Context, botID int64) any {
|
||||
marks, err := s.botVerification.Marks(ctx, domain.CustomVerificationFilter{
|
||||
VerifierBotID: botID,
|
||||
Limit: botVerificationCatalogueScan,
|
||||
})
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
if len(marks) >= botVerificationCatalogueScan {
|
||||
return strconv.Itoa(len(marks)) + "+"
|
||||
}
|
||||
return strconv.Itoa(len(marks))
|
||||
}
|
||||
|
||||
// grantIconPreflight records what the catalogue says about the icon and refuses
|
||||
// the definite failures early.
|
||||
//
|
||||
// The check is deliberately one-sided. An icon found in the page and unusable is
|
||||
// a certain failure and is refused with its own code; an icon the page does not
|
||||
// cover is left to the use-case layer, which reads the entry by document id and
|
||||
// additionally resolves the custom emoji document. So this can only ever make the
|
||||
// dry run *more* informative, never stricter than the command it rehearses.
|
||||
func (s *Service) grantIconPreflight(ctx context.Context, details map[string]any, botID, documentID int64) error {
|
||||
icons, err := s.botVerification.Icons(ctx, false, botVerificationCatalogueScan)
|
||||
if err != nil {
|
||||
details["icon_catalogue_checked"] = false
|
||||
return nil
|
||||
}
|
||||
for _, icon := range icons {
|
||||
if icon.DocumentID != documentID {
|
||||
continue
|
||||
}
|
||||
details["icon_catalogue_checked"] = true
|
||||
details["icon_id"] = strconv.FormatInt(icon.ID, 10)
|
||||
details["icon_name"] = icon.Name
|
||||
details["icon_active"] = icon.Active
|
||||
details["icon_owner_bot_id"] = strconv.FormatInt(icon.OwnerBotID, 10)
|
||||
if !icon.Active {
|
||||
return botVerificationCoded(domain.ErrVerificationIconInactive)
|
||||
}
|
||||
if !icon.UsableBy(botID) {
|
||||
// A reserved entry belongs to one verifier; for anybody else it does
|
||||
// not exist, which is why this is "not found" and not "forbidden".
|
||||
return botVerificationCoded(domain.ErrVerificationIconNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
details["icon_catalogue_checked"] = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// botVerificationSubject loads the application under decision and seeds the
|
||||
// command details with everything the audit entry must state even when the command
|
||||
// then fails: which application, whose, which verifier, which peer, the status it
|
||||
// is leaving and the status it was asked to reach.
|
||||
func (s *Service) botVerificationSubject(
|
||||
ctx context.Context,
|
||||
meta CommandMeta,
|
||||
requestID int64,
|
||||
next domain.CustomVerificationRequestStatus,
|
||||
internalNote string,
|
||||
) (domain.CustomVerificationRequest, map[string]any, error) {
|
||||
details := map[string]any{
|
||||
"request_id": strconv.FormatInt(requestID, 10),
|
||||
"next_status": string(next),
|
||||
"correlation_id": strings.TrimSpace(meta.CommandID),
|
||||
}
|
||||
if note := strings.TrimSpace(internalNote); note != "" {
|
||||
details["internal_note"] = note
|
||||
}
|
||||
current, err := s.botVerification.Request(ctx, requestID)
|
||||
if err != nil {
|
||||
return domain.CustomVerificationRequest{}, details, botVerificationError(err)
|
||||
}
|
||||
if current.ID != requestID {
|
||||
return domain.CustomVerificationRequest{}, details, botVerificationCoded(domain.ErrCustomVerificationRequestNotFound)
|
||||
}
|
||||
details["verifier_bot_id"] = strconv.FormatInt(current.VerifierBotID, 10)
|
||||
details["applicant_user_id"] = strconv.FormatInt(current.ApplicantUserID, 10)
|
||||
details["peer_type"] = string(current.Peer.Type)
|
||||
details["peer_id"] = strconv.FormatInt(current.Peer.ID, 10)
|
||||
details["peer_username"] = current.PeerUsername
|
||||
details["previous_status"] = string(current.Status)
|
||||
details["previous_version"] = strconv.FormatInt(current.Version, 10)
|
||||
return current, details, nil
|
||||
}
|
||||
|
||||
// mergeVerifierDecisionDetails records the verifier behind an application and,
|
||||
// for a decision that would grant a mark, refuses a verifier that may not verify
|
||||
// right now.
|
||||
//
|
||||
// An application can sit in the queue for days: a verifier switched off in the
|
||||
// meantime must not be able to grant through the review path what the RPC path
|
||||
// would refuse. "No row" and "switched off" answer the same code on purpose --
|
||||
// that is the distinction BOTVERIFIER_FORBIDDEN deliberately hides.
|
||||
func (s *Service) mergeVerifierDecisionDetails(ctx context.Context, details map[string]any, botID int64, requireEnabled bool) error {
|
||||
settings, exists, err := s.botVerifierState(ctx, botID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
details["verifier_present"] = exists
|
||||
if exists {
|
||||
details["verifier_enabled"] = settings.Enabled
|
||||
details["verifier_company_name"] = settings.CompanyName
|
||||
details["verifier_icon_document_id"] = strconv.FormatInt(settings.IconDocumentID, 10)
|
||||
details["verifier_can_modify_custom_description"] = settings.CanModifyCustomDescription
|
||||
}
|
||||
if requireEnabled && (!exists || !settings.Enabled) {
|
||||
return botVerificationCoded(domain.ErrVerifierForbidden)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeBotVerificationDetails records the decided state.
|
||||
func mergeBotVerificationDetails(details map[string]any, req domain.CustomVerificationRequest, changed bool) {
|
||||
details["status"] = string(req.Status)
|
||||
details["version"] = strconv.FormatInt(req.Version, 10)
|
||||
details["changed"] = changed
|
||||
details["decided_by"] = req.DecidedBy
|
||||
if req.CorrelationID != "" {
|
||||
details["correlation_id"] = req.CorrelationID
|
||||
}
|
||||
if req.DecisionReason != "" {
|
||||
details["decision_reason"] = req.DecisionReason
|
||||
}
|
||||
}
|
||||
|
||||
// validateBotVerificationDecisionShape rejects a malformed decision before the
|
||||
// command journal is touched.
|
||||
func validateBotVerificationDecisionShape(requestID, version int64, internalNote string) error {
|
||||
if requestID <= 0 {
|
||||
return botVerificationCoded(domain.ErrCustomVerificationRequestNotFound)
|
||||
}
|
||||
if version <= 0 {
|
||||
// Without the version the reviewer never read the row, so the optimistic
|
||||
// lock could not protect a concurrent decision.
|
||||
return botVerificationInvalid("version is required")
|
||||
}
|
||||
if utf8.RuneCountInString(internalNote) > domain.MaxCustomVerificationNoteLength {
|
||||
return botVerificationInvalid("internal_note is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// botVerificationTransition checks the status machine and the optimistic lock in
|
||||
// the order the use-case layer does, so a dry run predicts the real outcome
|
||||
// exactly.
|
||||
//
|
||||
// A request already in the target status is a replay the service answers as a
|
||||
// no-op, so it passes without consulting the version: re-sending a decision whose
|
||||
// response was lost must not turn into a spurious conflict.
|
||||
func botVerificationTransition(req domain.CustomVerificationRequest, version int64, next domain.CustomVerificationRequestStatus) error {
|
||||
if req.Status == next {
|
||||
return nil
|
||||
}
|
||||
if !domain.CanTransitionCustomVerificationStatus(req.Status, next) {
|
||||
return codedError(CodeCustomVerificationStatusInvalid,
|
||||
fmt.Errorf("%w: %s -> %s", domain.ErrCustomVerificationRequestInvalid, req.Status, next))
|
||||
}
|
||||
if req.Version != version {
|
||||
return codedError(CodeCustomVerificationConflict, domain.ErrCustomVerificationVersionConflict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// markableAdminPeer mirrors the store's peer_type CHECK: only users (bots
|
||||
// included) and channels can carry a third-party mark.
|
||||
func markableAdminPeer(peer domain.Peer) bool {
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||||
return peer.ID > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// BotVerificationErrorCode maps a third-party verification failure onto the stable
|
||||
// code the admin panel switches on. An unmapped error returns "" so the caller can
|
||||
// report it verbatim instead of inventing a code.
|
||||
//
|
||||
// The codes are separate from the official verification ones by design: the two
|
||||
// mechanisms fail for different reasons and the panel renders them in different
|
||||
// sections, so sharing a token would make a message land in the wrong place.
|
||||
func BotVerificationErrorCode(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case errors.Is(err, domain.ErrVerifierNotFound):
|
||||
return CodeBotVerifierNotFound
|
||||
case errors.Is(err, domain.ErrVerifierForbidden):
|
||||
return CodeBotVerifierForbidden
|
||||
case errors.Is(err, domain.ErrVerifierDescriptionForbidden):
|
||||
return CodeBotVerifierDescriptionForbidden
|
||||
case errors.Is(err, domain.ErrVerifierSettingsInvalid):
|
||||
return CodeBotVerifierInvalid
|
||||
case errors.Is(err, domain.ErrVerificationIconNotFound):
|
||||
return CodeVerificationIconNotFound
|
||||
case errors.Is(err, domain.ErrVerificationIconInactive):
|
||||
return CodeVerificationIconInactive
|
||||
case errors.Is(err, domain.ErrVerificationIconInvalid):
|
||||
return CodeVerificationIconInvalid
|
||||
case errors.Is(err, domain.ErrCustomVerificationVersionConflict):
|
||||
return CodeCustomVerificationConflict
|
||||
case errors.Is(err, domain.ErrCustomVerificationLimit):
|
||||
return CodeCustomVerificationLimit
|
||||
case errors.Is(err, domain.ErrCustomVerificationNotFound):
|
||||
return CodeCustomVerificationNotFound
|
||||
case errors.Is(err, domain.ErrCustomVerificationRequestNotFound):
|
||||
return CodeCustomVerificationRequestNotFound
|
||||
case errors.Is(err, domain.ErrCustomVerificationRequestExists):
|
||||
return CodeCustomVerificationRequestExists
|
||||
case errors.Is(err, domain.ErrCustomVerificationTargetInvalid):
|
||||
return CodeCustomVerificationTargetInvalid
|
||||
case errors.Is(err, domain.ErrVerificationTargetSystem):
|
||||
return CodeCustomVerificationTargetSystem
|
||||
case errors.Is(err, domain.ErrVerificationReasonRequired):
|
||||
return CodeCustomVerificationReasonRequired
|
||||
case errors.Is(err, domain.ErrVerificationRateLimited):
|
||||
return CodeCustomVerificationRateLimited
|
||||
case errors.Is(err, domain.ErrBotNotFound):
|
||||
return CodeBotVerifierBotNotFound
|
||||
case errors.Is(err, domain.ErrCustomVerificationRequestInvalid):
|
||||
return CodeCustomVerificationInvalid
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// botVerificationError prefixes a recognised failure with its stable code, the way
|
||||
// verificationError does for the official review.
|
||||
func botVerificationError(err error) error {
|
||||
if code := BotVerificationErrorCode(err); code != "" {
|
||||
return codedError(code, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func botVerificationCoded(err error) error {
|
||||
return codedError(BotVerificationErrorCode(err), err)
|
||||
}
|
||||
|
||||
func botVerificationInvalid(message string) error {
|
||||
return codedError(CodeCustomVerificationInvalid, fmt.Errorf("%s: %w", message, domain.ErrCustomVerificationRequestInvalid))
|
||||
}
|
||||
|
||||
func botVerifierInvalid(message string) error {
|
||||
return codedError(CodeBotVerifierInvalid, fmt.Errorf("%s: %w", message, domain.ErrVerifierSettingsInvalid))
|
||||
}
|
||||
1045
internal/admin/botverification_test.go
Normal file
1045
internal/admin/botverification_test.go
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
529
internal/admin/verification.go
Normal file
529
internal/admin/verification.go
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Official platform verification review.
|
||||
//
|
||||
// The review actions live behind the same command journal as every other
|
||||
// operator write: a decision is auditable, replayable by command id and
|
||||
// rehearsable with a dry run. The application record itself is the durable audit
|
||||
// subject, so the details captured here name the application, its target and the
|
||||
// status it moved between, and they carry the correlation id that ties the
|
||||
// command journal entry to the immutable application event.
|
||||
|
||||
// VerificationService is the operator-facing slice of the official verification
|
||||
// use cases. It is the exact method set *app/verification.Service exposes for the
|
||||
// reviewer side, so the admin layer never reaches into the store.
|
||||
type VerificationService interface {
|
||||
List(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error)
|
||||
Counts(ctx context.Context) (domain.VerificationStatusCounts, error)
|
||||
Events(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error)
|
||||
Application(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
|
||||
TargetSnapshot(ctx context.Context, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationTarget, error)
|
||||
Claim(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, error)
|
||||
Approve(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error)
|
||||
Reject(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error)
|
||||
Revoke(ctx context.Context, req domain.VerificationRevocation) (domain.VerificationApplication, bool, error)
|
||||
}
|
||||
|
||||
// ClaimVerificationRequest assigns a reviewer to a submitted application.
|
||||
type ClaimVerificationRequest struct {
|
||||
CommandMeta
|
||||
ApplicationID int64 `json:"application_id"`
|
||||
// Version is the optimistic-locking token the reviewer read. Two reviewers
|
||||
// opening the same row submit the same version and exactly one wins.
|
||||
Version int64 `json:"version"`
|
||||
// InternalNote is optional on a claim: a reviewer picking a case up may want to
|
||||
// record why ("waiting on legal") without deciding it yet.
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// ApproveVerificationRequest grants the platform badge.
|
||||
type ApproveVerificationRequest struct {
|
||||
CommandMeta
|
||||
ApplicationID int64 `json:"application_id"`
|
||||
Version int64 `json:"version"`
|
||||
// InternalNote is operator-only. It is journalled and appended to the
|
||||
// application history, and it is never part of what the applicant is told.
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// RejectVerificationRequest closes an application against the applicant. Reason
|
||||
// is mandatory: it is the text the applicant receives.
|
||||
type RejectVerificationRequest struct {
|
||||
CommandMeta
|
||||
ApplicationID int64 `json:"application_id"`
|
||||
Version int64 `json:"version"`
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// RevokeVerificationRequest clears the badge of a previously approved target. It
|
||||
// addresses the target rather than an application, because a revocation is not a
|
||||
// decision on the application: the application stays approved as history.
|
||||
type RevokeVerificationRequest struct {
|
||||
CommandMeta
|
||||
TargetType domain.VerificationTargetType `json:"target_type"`
|
||||
TargetID int64 `json:"target_id"`
|
||||
InternalNote string `json:"internal_note,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// VerificationApplications is the review-queue listing. The filter is passed
|
||||
// through unchanged: the use-case layer owns normalisation and the page bound.
|
||||
func (s *Service) VerificationApplications(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return nil, errVerificationNotConfigured
|
||||
}
|
||||
return s.verification.List(ctx, filter)
|
||||
}
|
||||
|
||||
// VerificationCounts is the queue summary rendered above the list.
|
||||
func (s *Service) VerificationCounts(ctx context.Context) (domain.VerificationStatusCounts, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return nil, errVerificationNotConfigured
|
||||
}
|
||||
return s.verification.Counts(ctx)
|
||||
}
|
||||
|
||||
// VerificationApplication resolves one application by identity.
|
||||
func (s *Service) VerificationApplication(ctx context.Context, applicationID int64) (domain.VerificationApplication, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return domain.VerificationApplication{}, errVerificationNotConfigured
|
||||
}
|
||||
if applicationID <= 0 {
|
||||
return domain.VerificationApplication{}, verificationCoded(domain.ErrVerificationApplicationNotFound)
|
||||
}
|
||||
return s.verification.Application(ctx, applicationID)
|
||||
}
|
||||
|
||||
// VerificationApplicationEvents returns one application's immutable history.
|
||||
func (s *Service) VerificationApplicationEvents(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return nil, errVerificationNotConfigured
|
||||
}
|
||||
return s.verification.Events(ctx, applicationID, limit)
|
||||
}
|
||||
|
||||
// VerificationTargetSnapshot returns the target's state as it is now: title,
|
||||
// username, badge, and whether it would pass the eligibility checks today.
|
||||
func (s *Service) VerificationTargetSnapshot(ctx context.Context, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationTarget, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return domain.VerificationTarget{}, errVerificationNotConfigured
|
||||
}
|
||||
return s.verification.TargetSnapshot(ctx, targetType, targetID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ClaimVerification takes ownership of a submitted application.
|
||||
//
|
||||
// Claiming an application that somebody else already claimed is not idempotent:
|
||||
// the status machine has no in_review -> in_review edge, so the second reviewer
|
||||
// is told the row is taken instead of silently stealing it.
|
||||
func (s *Service) ClaimVerification(ctx context.Context, req ClaimVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return CommandResult{}, errVerificationNotConfigured
|
||||
}
|
||||
if err := validateVerificationDecisionShape(req.ApplicationID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionClaimVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
app, details, err := s.verificationSubject(ctx, req.CommandMeta, req.ApplicationID, domain.VerificationStatusInReview, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := verificationTransition(app, req.Version, domain.VerificationStatusInReview, false); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "verification claim validated", Details: details}, nil
|
||||
}
|
||||
claimed, err := s.verification.Claim(ctx, verificationDecision(req.CommandMeta, req.ApplicationID, req.Version, req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, verificationError(err)
|
||||
}
|
||||
mergeVerificationDetails(details, claimed, true)
|
||||
return CommandResult{Message: "verification application claimed", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ApproveVerification grants the platform badge.
|
||||
//
|
||||
// The dry run reloads the target snapshot and refuses in advance whatever the
|
||||
// real approval would refuse: the snapshot re-runs every eligibility check
|
||||
// except the ownership probe, and a missing ownership can only make the real run
|
||||
// stricter, never more permissive. So a passing dry run never turns into a
|
||||
// surprise, and a failing one names the reason before anything is written.
|
||||
func (s *Service) ApproveVerification(ctx context.Context, req ApproveVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return CommandResult{}, errVerificationNotConfigured
|
||||
}
|
||||
if err := validateVerificationDecisionShape(req.ApplicationID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionApproveVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
app, details, err := s.verificationSubject(ctx, req.CommandMeta, req.ApplicationID, domain.VerificationStatusApproved, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := verificationTransition(app, req.Version, domain.VerificationStatusApproved, true); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
// An application that is already approved is a replay: the use-case layer
|
||||
// returns it untouched without re-running the target checks, so the target
|
||||
// gate must not fire here either -- otherwise a retry would fail a dry run
|
||||
// that the real command answers as a no-op.
|
||||
replay := app.Status == domain.VerificationStatusApproved
|
||||
if err := s.mergeVerificationTargetDetails(ctx, details, app, !replay); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "verification approve validated", Details: details}, nil
|
||||
}
|
||||
approved, changed, err := s.verification.Approve(ctx, verificationDecision(req.CommandMeta, req.ApplicationID, req.Version, req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, verificationError(err)
|
||||
}
|
||||
mergeVerificationDetails(details, approved, changed)
|
||||
message := "verification application approved"
|
||||
if !changed {
|
||||
message = "verification application already approved"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RejectVerification closes an application against the applicant. The reason is
|
||||
// mandatory and is the text the applicant is shown; the internal note stays
|
||||
// operator-side.
|
||||
func (s *Service) RejectVerification(ctx context.Context, req RejectVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return CommandResult{}, errVerificationNotConfigured
|
||||
}
|
||||
if err := validateVerificationDecisionShape(req.ApplicationID, req.Version, req.InternalNote); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
// A rejection without a stated reason is refused before the journal is
|
||||
// touched: the audit trail must never contain a decision nobody can explain.
|
||||
if strings.TrimSpace(req.Reason) == "" {
|
||||
return CommandResult{}, codedError(CodeVerificationReasonRequired, domain.ErrVerificationReasonRequired)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRejectVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
app, details, err := s.verificationSubject(ctx, req.CommandMeta, req.ApplicationID, domain.VerificationStatusRejected, req.InternalNote)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := verificationTransition(app, req.Version, domain.VerificationStatusRejected, true); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "verification reject validated", Details: details}, nil
|
||||
}
|
||||
rejected, changed, err := s.verification.Reject(ctx, verificationDecision(req.CommandMeta, req.ApplicationID, req.Version, req.InternalNote))
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, verificationError(err)
|
||||
}
|
||||
mergeVerificationDetails(details, rejected, changed)
|
||||
message := "verification application rejected"
|
||||
if !changed {
|
||||
message = "verification application already rejected"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeVerification clears the platform badge from a target.
|
||||
//
|
||||
// It addresses the peer, not an application: the approved application stays
|
||||
// approved as history and the revocation is its own audit event. A reason is
|
||||
// mandatory for the same reason a rejection needs one.
|
||||
func (s *Service) RevokeVerification(ctx context.Context, req RevokeVerificationRequest) (CommandResult, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return CommandResult{}, errVerificationNotConfigured
|
||||
}
|
||||
if req.TargetID <= 0 || !req.TargetType.Valid() {
|
||||
return CommandResult{}, codedError(CodeVerificationTargetInvalid, domain.ErrVerificationTargetInvalid)
|
||||
}
|
||||
if utf8.RuneCountInString(req.InternalNote) > domain.MaxVerificationNoteLength {
|
||||
return CommandResult{}, verificationInvalid("internal_note is too long")
|
||||
}
|
||||
if strings.TrimSpace(req.Reason) == "" {
|
||||
return CommandResult{}, codedError(CodeVerificationReasonRequired, domain.ErrVerificationReasonRequired)
|
||||
}
|
||||
targetPeer := domain.Peer{Type: req.TargetType.PeerType(), ID: req.TargetID}
|
||||
targetUserID := int64(0)
|
||||
if targetPeer.Type == domain.PeerTypeUser {
|
||||
targetUserID = req.TargetID
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRevokeVerification, targetUserID, targetPeer, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"target_type": string(req.TargetType),
|
||||
"target_id": strconv.FormatInt(req.TargetID, 10),
|
||||
"correlation_id": strings.TrimSpace(req.CommandID),
|
||||
}
|
||||
if note := strings.TrimSpace(req.InternalNote); note != "" {
|
||||
details["internal_note"] = note
|
||||
}
|
||||
// A built-in system account carries its badge by construction, so revoking
|
||||
// it would desynchronise the seeded record from domain.SystemUserByID.
|
||||
if targetPeer.Type == domain.PeerTypeUser && domain.IsSystemUserID(req.TargetID) {
|
||||
return CommandResult{Details: details}, codedError(CodeVerificationTargetSystem, domain.ErrVerificationTargetSystem)
|
||||
}
|
||||
target, err := s.verification.TargetSnapshot(ctx, req.TargetType, req.TargetID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, verificationError(err)
|
||||
}
|
||||
details["target_title"] = target.Title
|
||||
details["target_username"] = target.Username
|
||||
details["target_verified"] = target.Verified
|
||||
if req.DryRun {
|
||||
message := "verification revoke validated"
|
||||
if !target.Verified {
|
||||
message = "verification revoke validated; target carries no badge"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
}
|
||||
app, changed, err := s.verification.Revoke(ctx, domain.VerificationRevocation{
|
||||
TargetType: req.TargetType,
|
||||
TargetID: req.TargetID,
|
||||
Reviewer: strings.TrimSpace(req.Actor),
|
||||
Reason: strings.TrimSpace(req.Reason),
|
||||
InternalNote: strings.TrimSpace(req.InternalNote),
|
||||
CorrelationID: strings.TrimSpace(req.CommandID),
|
||||
})
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, verificationError(err)
|
||||
}
|
||||
details["changed"] = changed
|
||||
details["target_verified"] = false
|
||||
if app.ID > 0 {
|
||||
// The revoked target usually has an approved application behind it; it
|
||||
// stays approved, which is why previous_status and status match here.
|
||||
details["application_id"] = strconv.FormatInt(app.ID, 10)
|
||||
details["applicant_user_id"] = strconv.FormatInt(app.ApplicantUserID, 10)
|
||||
details["previous_status"] = string(app.Status)
|
||||
details["status"] = string(app.Status)
|
||||
details["version"] = strconv.FormatInt(app.Version, 10)
|
||||
}
|
||||
message := "verification badge revoked"
|
||||
if !changed {
|
||||
message = "verification badge was already absent"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var errVerificationNotConfigured = errors.New("admin verification dependency is not configured")
|
||||
|
||||
// verificationSubject loads the application under decision and seeds the command
|
||||
// details with everything the audit entry must state even when the command then
|
||||
// fails: which application, whose, which target, the status it is leaving and the
|
||||
// status it was asked to reach.
|
||||
func (s *Service) verificationSubject(
|
||||
ctx context.Context,
|
||||
meta CommandMeta,
|
||||
applicationID int64,
|
||||
next domain.VerificationStatus,
|
||||
internalNote string,
|
||||
) (domain.VerificationApplication, map[string]any, error) {
|
||||
details := map[string]any{
|
||||
"application_id": strconv.FormatInt(applicationID, 10),
|
||||
"next_status": string(next),
|
||||
"correlation_id": strings.TrimSpace(meta.CommandID),
|
||||
}
|
||||
if note := strings.TrimSpace(internalNote); note != "" {
|
||||
details["internal_note"] = note
|
||||
}
|
||||
app, err := s.verification.Application(ctx, applicationID)
|
||||
if err != nil {
|
||||
return domain.VerificationApplication{}, details, verificationError(err)
|
||||
}
|
||||
details["applicant_user_id"] = strconv.FormatInt(app.ApplicantUserID, 10)
|
||||
details["target_type"] = string(app.TargetType)
|
||||
details["target_id"] = strconv.FormatInt(app.TargetID, 10)
|
||||
details["target_username"] = app.TargetUsername
|
||||
details["previous_status"] = string(app.Status)
|
||||
details["previous_version"] = strconv.FormatInt(app.Version, 10)
|
||||
return app, details, nil
|
||||
}
|
||||
|
||||
// mergeVerificationTargetDetails records the current target snapshot and, for a
|
||||
// decision that flips the badge, refuses a target the platform may not verify.
|
||||
func (s *Service) mergeVerificationTargetDetails(ctx context.Context, details map[string]any, app domain.VerificationApplication, requireEligible bool) error {
|
||||
target, err := s.verification.TargetSnapshot(ctx, app.TargetType, app.TargetID)
|
||||
if err != nil {
|
||||
return verificationError(err)
|
||||
}
|
||||
details["target_title"] = target.Title
|
||||
details["target_current_username"] = target.Username
|
||||
details["target_verified"] = target.Verified
|
||||
details["target_eligible"] = target.Eligible
|
||||
if target.Reason != "" {
|
||||
details["target_reason"] = target.Reason
|
||||
}
|
||||
if requireEligible && !target.Eligible {
|
||||
return verificationError(verificationTargetReasonError(target.Reason))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeVerificationDetails records the decided state.
|
||||
func mergeVerificationDetails(details map[string]any, app domain.VerificationApplication, changed bool) {
|
||||
details["status"] = string(app.Status)
|
||||
details["version"] = strconv.FormatInt(app.Version, 10)
|
||||
details["changed"] = changed
|
||||
details["reviewer_admin_id"] = app.ReviewerAdminID
|
||||
if app.CorrelationID != "" {
|
||||
details["correlation_id"] = app.CorrelationID
|
||||
}
|
||||
if app.DecisionReason != "" {
|
||||
details["decision_reason"] = app.DecisionReason
|
||||
}
|
||||
}
|
||||
|
||||
// verificationDecision builds the domain decision.
|
||||
//
|
||||
// The admin command id doubles as the correlation id, so one token links the
|
||||
// command journal entry, the immutable application event and the applicant
|
||||
// notification. The internal note travels in its own field and never in Reason,
|
||||
// which is what the applicant is shown.
|
||||
func verificationDecision(meta CommandMeta, applicationID, version int64, internalNote string) domain.VerificationDecision {
|
||||
return domain.VerificationDecision{
|
||||
ApplicationID: applicationID,
|
||||
Version: version,
|
||||
Reviewer: strings.TrimSpace(meta.Actor),
|
||||
Reason: strings.TrimSpace(meta.Reason),
|
||||
InternalNote: strings.TrimSpace(internalNote),
|
||||
CorrelationID: strings.TrimSpace(meta.CommandID),
|
||||
}
|
||||
}
|
||||
|
||||
// validateVerificationDecisionShape rejects a malformed decision before the
|
||||
// command journal is touched.
|
||||
func validateVerificationDecisionShape(applicationID, version int64, internalNote string) error {
|
||||
if applicationID <= 0 {
|
||||
return verificationCoded(domain.ErrVerificationApplicationNotFound)
|
||||
}
|
||||
if version <= 0 {
|
||||
// Without the version the reviewer never read the row, so the optimistic
|
||||
// lock could not protect a concurrent decision.
|
||||
return verificationInvalid("version is required")
|
||||
}
|
||||
if utf8.RuneCountInString(internalNote) > domain.MaxVerificationNoteLength {
|
||||
return verificationInvalid("internal_note is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verificationTransition checks the status machine and the optimistic lock in the
|
||||
// order the use-case layer does, so a dry run predicts the real outcome exactly.
|
||||
//
|
||||
// idempotent marks the decisions the service treats as a no-op on replay
|
||||
// (approve/reject of an already decided application). A claim is not among them:
|
||||
// there is no in_review -> in_review edge.
|
||||
func verificationTransition(app domain.VerificationApplication, version int64, next domain.VerificationStatus, idempotent bool) error {
|
||||
if idempotent && app.Status == next {
|
||||
return nil
|
||||
}
|
||||
if !domain.CanTransitionVerificationStatus(app.Status, next) {
|
||||
return codedError(CodeVerificationStatusInvalid, fmt.Errorf("%w: %s -> %s", domain.ErrVerificationStatusInvalid, app.Status, next))
|
||||
}
|
||||
if app.Version != version {
|
||||
return codedError(CodeVerificationConflict, domain.ErrVerificationVersionConflict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verificationTargetReasonError maps the snapshot's rendered ineligibility reason
|
||||
// back onto its domain sentinel. VerificationTarget.Reason is a string by
|
||||
// design -- it is rendered to applicants by the bot -- so the reverse lookup is
|
||||
// what lets the admin layer answer with a stable code instead of a bare message.
|
||||
func verificationTargetReasonError(reason string) error {
|
||||
for _, candidate := range []error{
|
||||
domain.ErrVerificationTargetAlreadyVerified,
|
||||
domain.ErrVerificationTargetRestricted,
|
||||
domain.ErrVerificationTargetNotPublic,
|
||||
domain.ErrVerificationTargetSystem,
|
||||
domain.ErrVerificationUserTargetsDisabled,
|
||||
domain.ErrVerificationTargetInvalid,
|
||||
} {
|
||||
if candidate.Error() == reason {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: %s", domain.ErrVerificationTargetInvalid, reason)
|
||||
}
|
||||
|
||||
// VerificationErrorCode maps a verification failure onto the stable code the
|
||||
// admin panel switches on. An unmapped error returns "" so the caller can report
|
||||
// it verbatim instead of inventing a code.
|
||||
func VerificationErrorCode(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case errors.Is(err, domain.ErrVerificationApplicationNotFound):
|
||||
return CodeVerificationNotFound
|
||||
case errors.Is(err, domain.ErrVerificationVersionConflict):
|
||||
return CodeVerificationConflict
|
||||
case errors.Is(err, domain.ErrVerificationApplicationExists):
|
||||
return CodeVerificationTargetOccupied
|
||||
case errors.Is(err, domain.ErrVerificationStatusInvalid):
|
||||
return CodeVerificationStatusInvalid
|
||||
case errors.Is(err, domain.ErrVerificationReasonRequired):
|
||||
return CodeVerificationReasonRequired
|
||||
case errors.Is(err, domain.ErrVerificationTargetAlreadyVerified):
|
||||
return CodeVerificationTargetVerified
|
||||
case errors.Is(err, domain.ErrVerificationTargetNotPublic):
|
||||
return CodeVerificationTargetNotPublic
|
||||
case errors.Is(err, domain.ErrVerificationTargetRestricted):
|
||||
return CodeVerificationTargetRestricted
|
||||
case errors.Is(err, domain.ErrVerificationTargetSystem):
|
||||
return CodeVerificationTargetSystem
|
||||
case errors.Is(err, domain.ErrVerificationNotOwner):
|
||||
return CodeVerificationNotOwner
|
||||
case errors.Is(err, domain.ErrVerificationUserTargetsDisabled):
|
||||
return CodeVerificationUserTargetsDisabled
|
||||
case errors.Is(err, domain.ErrVerificationTargetInvalid):
|
||||
return CodeVerificationTargetInvalid
|
||||
case errors.Is(err, domain.ErrVerificationURLInvalid),
|
||||
errors.Is(err, domain.ErrVerificationApplicationInvalid):
|
||||
return CodeVerificationInvalid
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// verificationError prefixes a recognised verification error with its stable
|
||||
// code, the way collectibleUsernameError does for the username registry.
|
||||
func verificationError(err error) error {
|
||||
if code := VerificationErrorCode(err); code != "" {
|
||||
return codedError(code, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func verificationCoded(err error) error {
|
||||
return codedError(VerificationErrorCode(err), err)
|
||||
}
|
||||
|
||||
func verificationInvalid(message string) error {
|
||||
return codedError(CodeVerificationInvalid, fmt.Errorf("%s: %w", message, domain.ErrVerificationApplicationInvalid))
|
||||
}
|
||||
629
internal/admin/verification_test.go
Normal file
629
internal/admin/verification_test.go
Normal file
|
|
@ -0,0 +1,629 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
verificationapp "telesrv/internal/app/verification"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Compile-time proof that the shipped use-case service satisfies the admin port.
|
||||
// cmd/telesrv wires *verification.Service into Dependencies.Verification
|
||||
// directly, so a drifting method set has to fail here rather than at integration
|
||||
// time.
|
||||
var _ VerificationService = (*verificationapp.Service)(nil)
|
||||
|
||||
type fakeVerificationService struct {
|
||||
app domain.VerificationApplication
|
||||
target domain.VerificationTarget
|
||||
events []domain.VerificationApplicationEvent
|
||||
counts domain.VerificationStatusCounts
|
||||
|
||||
appErr error
|
||||
targetErr error
|
||||
decideErr error
|
||||
|
||||
claimCalls int
|
||||
approveCalls int
|
||||
rejectCalls int
|
||||
revokeCalls int
|
||||
|
||||
claimed domain.VerificationDecision
|
||||
approved domain.VerificationDecision
|
||||
rejected domain.VerificationDecision
|
||||
revoked domain.VerificationRevocation
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) List(context.Context, domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
|
||||
return []domain.VerificationApplication{f.app}, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Counts(context.Context) (domain.VerificationStatusCounts, error) {
|
||||
return f.counts, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Events(context.Context, int64, int) ([]domain.VerificationApplicationEvent, error) {
|
||||
return f.events, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Application(_ context.Context, applicationID int64) (domain.VerificationApplication, error) {
|
||||
if f.appErr != nil {
|
||||
return domain.VerificationApplication{}, f.appErr
|
||||
}
|
||||
if f.app.ID != applicationID {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
||||
}
|
||||
return f.app, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) TargetSnapshot(context.Context, domain.VerificationTargetType, int64) (domain.VerificationTarget, error) {
|
||||
if f.targetErr != nil {
|
||||
return domain.VerificationTarget{}, f.targetErr
|
||||
}
|
||||
return f.target, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Claim(_ context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, error) {
|
||||
f.claimCalls++
|
||||
f.claimed = decision
|
||||
if f.decideErr != nil {
|
||||
return domain.VerificationApplication{}, f.decideErr
|
||||
}
|
||||
f.app.Status = domain.VerificationStatusInReview
|
||||
f.app.ReviewerAdminID = decision.Reviewer
|
||||
f.app.Version++
|
||||
f.app.CorrelationID = decision.CorrelationID
|
||||
return f.app, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Approve(_ context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error) {
|
||||
f.approveCalls++
|
||||
f.approved = decision
|
||||
if f.decideErr != nil {
|
||||
return domain.VerificationApplication{}, false, f.decideErr
|
||||
}
|
||||
f.app.Status = domain.VerificationStatusApproved
|
||||
f.app.ReviewerAdminID = decision.Reviewer
|
||||
f.app.InternalNote = decision.InternalNote
|
||||
f.app.Version++
|
||||
f.app.CorrelationID = decision.CorrelationID
|
||||
return f.app, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Reject(_ context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error) {
|
||||
f.rejectCalls++
|
||||
f.rejected = decision
|
||||
if f.decideErr != nil {
|
||||
return domain.VerificationApplication{}, false, f.decideErr
|
||||
}
|
||||
f.app.Status = domain.VerificationStatusRejected
|
||||
f.app.ReviewerAdminID = decision.Reviewer
|
||||
f.app.DecisionReason = decision.Reason
|
||||
f.app.InternalNote = decision.InternalNote
|
||||
f.app.Version++
|
||||
f.app.CorrelationID = decision.CorrelationID
|
||||
return f.app, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerificationService) Revoke(_ context.Context, req domain.VerificationRevocation) (domain.VerificationApplication, bool, error) {
|
||||
f.revokeCalls++
|
||||
f.revoked = req
|
||||
if f.decideErr != nil {
|
||||
return domain.VerificationApplication{}, false, f.decideErr
|
||||
}
|
||||
f.target.Verified = false
|
||||
return f.app, true, nil
|
||||
}
|
||||
|
||||
func submittedVerificationApplication() domain.VerificationApplication {
|
||||
return domain.VerificationApplication{
|
||||
ID: 77,
|
||||
ApplicantUserID: 1001,
|
||||
TargetType: domain.VerificationTargetChannel,
|
||||
TargetID: 5005,
|
||||
TargetTitle: "Example News",
|
||||
TargetUsername: "examplenews",
|
||||
Category: "media",
|
||||
Status: domain.VerificationStatusSubmitted,
|
||||
SubmittedAt: fixedNow(),
|
||||
Version: 3,
|
||||
}
|
||||
}
|
||||
|
||||
func newVerificationFixture() (*Service, *fakeVerificationService, *memoryCommandRepo) {
|
||||
repo := newMemoryCommandRepo()
|
||||
verification := &fakeVerificationService{
|
||||
app: submittedVerificationApplication(),
|
||||
target: domain.VerificationTarget{
|
||||
Type: domain.VerificationTargetChannel, ID: 5005,
|
||||
Title: "Example News", Username: "examplenews", Eligible: true,
|
||||
},
|
||||
}
|
||||
svc := NewService(Dependencies{Commands: repo, Verification: verification, Now: fixedNow})
|
||||
return svc, verification, repo
|
||||
}
|
||||
|
||||
func TestClaimVerificationDryRunExecuteAndIdempotentReplay(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, repo := newVerificationFixture()
|
||||
|
||||
dry, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-claim", Actor: "alice", Reason: "picking up the queue", DryRun: true},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run claim: %v", err)
|
||||
}
|
||||
if !dry.DryRun || dry.Status != string(domain.AdminCommandCompleted) || verification.claimCalls != 0 {
|
||||
t.Fatalf("dry-run result=%+v claimCalls=%d, want a completed dry run without mutation", dry, verification.claimCalls)
|
||||
}
|
||||
if dry.Details["previous_status"] != string(domain.VerificationStatusSubmitted) ||
|
||||
dry.Details["next_status"] != string(domain.VerificationStatusInReview) ||
|
||||
dry.Details["application_id"] != "77" || dry.Details["target_id"] != "5005" ||
|
||||
dry.Details["target_type"] != string(domain.VerificationTargetChannel) ||
|
||||
dry.Details["correlation_id"] != "dry-claim" {
|
||||
t.Fatalf("dry-run details=%+v, want the audit facts seeded before execution", dry.Details)
|
||||
}
|
||||
|
||||
execReq := ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-claim", Actor: "alice", Reason: "picking up the queue"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
}
|
||||
exec, err := svc.ClaimVerification(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("execute claim: %v", err)
|
||||
}
|
||||
if verification.claimCalls != 1 || exec.Status != string(domain.AdminCommandCompleted) {
|
||||
t.Fatalf("execute result=%+v claimCalls=%d", exec, verification.claimCalls)
|
||||
}
|
||||
if exec.Details["status"] != string(domain.VerificationStatusInReview) ||
|
||||
exec.Details["reviewer_admin_id"] != "alice" || exec.Details["version"] != "4" {
|
||||
t.Fatalf("execute details=%+v", exec.Details)
|
||||
}
|
||||
// The command id is the correlation id, so the journal entry and the
|
||||
// application event can be matched up afterwards.
|
||||
if verification.claimed.CorrelationID != "exec-claim" || verification.claimed.Reviewer != "alice" {
|
||||
t.Fatalf("claim decision=%+v", verification.claimed)
|
||||
}
|
||||
if _, ok := repo.items["exec-claim"]; !ok {
|
||||
t.Fatal("claim was not journalled")
|
||||
}
|
||||
|
||||
replay, err := svc.ClaimVerification(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("replay claim: %v", err)
|
||||
}
|
||||
if !replay.AlreadyExecuted || verification.claimCalls != 1 {
|
||||
t.Fatalf("replay result=%+v claimCalls=%d, want an idempotent replay", replay, verification.claimCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimVerificationRefusesAlreadyClaimedApplication(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusInReview
|
||||
|
||||
_, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "claim-taken", Actor: "bob", Reason: "second reviewer"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationStatusInvalid) {
|
||||
t.Fatalf("claim of a claimed application err=%v, want ErrVerificationStatusInvalid", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), CodeVerificationStatusInvalid) || verification.claimCalls != 0 {
|
||||
t.Fatalf("err=%v claimCalls=%d, want the stable code and no mutation", err, verification.claimCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveVerificationVersionConflictIsNotAMutation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusInReview
|
||||
|
||||
_, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-stale", Actor: "alice", Reason: "docs check out"},
|
||||
ApplicationID: 77,
|
||||
// The reviewer read version 3 but somebody else already advanced the row.
|
||||
Version: 2,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationVersionConflict) {
|
||||
t.Fatalf("stale approve err=%v, want ErrVerificationVersionConflict", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), CodeVerificationConflict) || verification.approveCalls != 0 {
|
||||
t.Fatalf("err=%v approveCalls=%d", err, verification.approveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveVerificationRefusesIneligibleTargetBeforeWriting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusInReview
|
||||
verification.target.Eligible = false
|
||||
verification.target.Verified = true
|
||||
verification.target.Reason = domain.ErrVerificationTargetAlreadyVerified.Error()
|
||||
|
||||
result, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-verified", Actor: "alice", Reason: "docs check out", DryRun: true},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationTargetAlreadyVerified) {
|
||||
t.Fatalf("approve of a verified target err=%v, want ErrVerificationTargetAlreadyVerified", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), CodeVerificationTargetVerified) {
|
||||
t.Fatalf("err=%v, want the stable code", err)
|
||||
}
|
||||
if result.Details["target_eligible"] != false || result.Details["target_verified"] != true {
|
||||
t.Fatalf("details=%+v, want the snapshot recorded on the failed command", result.Details)
|
||||
}
|
||||
if verification.approveCalls != 0 {
|
||||
t.Fatalf("approveCalls=%d, want the dry run to predict the refusal", verification.approveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveVerificationKeepsInternalNoteOutOfTheApplicantReason(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, repo := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusInReview
|
||||
|
||||
result, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-77", Actor: "alice", Reason: "press coverage verified"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
InternalNote: "contact reached us through the press office; do not quote",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("approve: %v", err)
|
||||
}
|
||||
if verification.approved.Reason != "press coverage verified" {
|
||||
t.Fatalf("applicant-facing reason=%q", verification.approved.Reason)
|
||||
}
|
||||
if verification.approved.InternalNote != "contact reached us through the press office; do not quote" {
|
||||
t.Fatalf("internal note=%q, want it carried in its own field", verification.approved.InternalNote)
|
||||
}
|
||||
if strings.Contains(verification.approved.Reason, "do not quote") {
|
||||
t.Fatal("internal note leaked into the applicant-facing reason")
|
||||
}
|
||||
// The note is operator-only but must still be auditable.
|
||||
if result.Details["internal_note"] != "contact reached us through the press office; do not quote" {
|
||||
t.Fatalf("details=%+v, want the internal note journalled", result.Details)
|
||||
}
|
||||
if stored := repo.items["approve-77"].ResultJSON; !strings.Contains(string(stored), "do not quote") {
|
||||
t.Fatalf("journalled result=%s, want the internal note persisted", stored)
|
||||
}
|
||||
}
|
||||
|
||||
// A retried approval of an already approved application is a no-op in the
|
||||
// use-case layer, so the dry run must not fail it on the "already verified"
|
||||
// target gate the first approval passed.
|
||||
func TestApproveVerificationReplayIsNotBlockedByTheTargetGate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusApproved
|
||||
verification.app.ReviewerAdminID = "alice"
|
||||
verification.target.Verified = true
|
||||
verification.target.Eligible = false
|
||||
verification.target.Reason = domain.ErrVerificationTargetAlreadyVerified.Error()
|
||||
|
||||
dry, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-replay-dry", Actor: "alice", Reason: "docs check out", DryRun: true},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run replay approve: %v", err)
|
||||
}
|
||||
if verification.approveCalls != 0 || dry.Details["target_verified"] != true {
|
||||
t.Fatalf("dry-run replay details=%+v approveCalls=%d", dry.Details, verification.approveCalls)
|
||||
}
|
||||
|
||||
exec, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-replay", Actor: "alice", Reason: "docs check out"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("replay approve: %v", err)
|
||||
}
|
||||
if exec.Details["changed"] != true {
|
||||
// The fake always reports a change; the point is that the command reached
|
||||
// the service instead of being refused by the pre-check.
|
||||
t.Fatalf("replay approve details=%+v", exec.Details)
|
||||
}
|
||||
if verification.approveCalls != 1 {
|
||||
t.Fatalf("approveCalls=%d, want the replay handed to the use-case layer", verification.approveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectVerificationRequiresReason(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, repo := newVerificationFixture()
|
||||
verification.app.Status = domain.VerificationStatusInReview
|
||||
|
||||
_, err := svc.RejectVerification(ctx, RejectVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "reject-no-reason", Actor: "alice", Reason: " "},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationReasonRequired) {
|
||||
t.Fatalf("reject without reason err=%v, want ErrVerificationReasonRequired", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), CodeVerificationReasonRequired) {
|
||||
t.Fatalf("err=%v, want the stable code", err)
|
||||
}
|
||||
if verification.rejectCalls != 0 || len(repo.items) != 0 {
|
||||
t.Fatalf("rejectCalls=%d journalled=%d, want a refusal before the journal is touched",
|
||||
verification.rejectCalls, len(repo.items))
|
||||
}
|
||||
|
||||
ok, err := svc.RejectVerification(ctx, RejectVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "reject-77", Actor: "alice", Reason: "press links are self-published"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reject: %v", err)
|
||||
}
|
||||
if verification.rejectCalls != 1 || verification.rejected.Reason != "press links are self-published" {
|
||||
t.Fatalf("rejectCalls=%d decision=%+v", verification.rejectCalls, verification.rejected)
|
||||
}
|
||||
if ok.Details["status"] != string(domain.VerificationStatusRejected) ||
|
||||
ok.Details["decision_reason"] != "press links are self-published" {
|
||||
t.Fatalf("details=%+v", ok.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeVerificationRequiresReasonAndTargetShape(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, repo := newVerificationFixture()
|
||||
verification.target.Verified = true
|
||||
|
||||
_, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "revoke-no-reason", Actor: "alice"},
|
||||
TargetType: domain.VerificationTargetChannel,
|
||||
TargetID: 5005,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationReasonRequired) {
|
||||
t.Fatalf("revoke without reason err=%v, want ErrVerificationReasonRequired", err)
|
||||
}
|
||||
|
||||
_, err = svc.RevokeVerification(ctx, RevokeVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "revoke-bad-target", Actor: "alice", Reason: "impersonation"},
|
||||
TargetType: domain.VerificationTargetType("group"),
|
||||
TargetID: 5005,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationTargetInvalid) {
|
||||
t.Fatalf("revoke of an unmodelled target err=%v, want ErrVerificationTargetInvalid", err)
|
||||
}
|
||||
if verification.revokeCalls != 0 || len(repo.items) != 0 {
|
||||
t.Fatalf("revokeCalls=%d journalled=%d, want refusals before the journal", verification.revokeCalls, len(repo.items))
|
||||
}
|
||||
|
||||
dry, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-revoke", Actor: "alice", Reason: "impersonation confirmed", DryRun: true},
|
||||
TargetType: domain.VerificationTargetChannel,
|
||||
TargetID: 5005,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run revoke: %v", err)
|
||||
}
|
||||
if verification.revokeCalls != 0 || dry.Details["target_verified"] != true {
|
||||
t.Fatalf("dry-run revoke details=%+v revokeCalls=%d", dry.Details, verification.revokeCalls)
|
||||
}
|
||||
|
||||
exec, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-revoke", Actor: "alice", Reason: "impersonation confirmed"},
|
||||
TargetType: domain.VerificationTargetChannel,
|
||||
TargetID: 5005,
|
||||
InternalNote: "legal asked for the takedown",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute revoke: %v", err)
|
||||
}
|
||||
if verification.revokeCalls != 1 || verification.revoked.Reason != "impersonation confirmed" ||
|
||||
verification.revoked.InternalNote != "legal asked for the takedown" ||
|
||||
verification.revoked.CorrelationID != "exec-revoke" {
|
||||
t.Fatalf("revocation=%+v", verification.revoked)
|
||||
}
|
||||
if exec.Details["target_verified"] != false || exec.Details["changed"] != true ||
|
||||
exec.Details["application_id"] != "77" {
|
||||
t.Fatalf("execute revoke details=%+v", exec.Details)
|
||||
}
|
||||
if exec.TargetPeer != (domain.Peer{Type: domain.PeerTypeChannel, ID: 5005}) {
|
||||
t.Fatalf("journalled target peer=%+v", exec.TargetPeer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeVerificationRefusesSystemAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
|
||||
result, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "revoke-system", Actor: "alice", Reason: "cleanup"},
|
||||
TargetType: domain.VerificationTargetBot,
|
||||
TargetID: domain.BotFatherUserID,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationTargetSystem) {
|
||||
t.Fatalf("revoke of a system account err=%v, want ErrVerificationTargetSystem", err)
|
||||
}
|
||||
if verification.revokeCalls != 0 || result.Details["target_id"] == nil {
|
||||
t.Fatalf("revokeCalls=%d details=%+v", verification.revokeCalls, result.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationDecisionShapeIsValidatedBeforeTheJournal(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, _, repo := newVerificationFixture()
|
||||
|
||||
if _, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "claim-no-id", Actor: "alice", Reason: "queue"},
|
||||
ApplicationID: 0,
|
||||
Version: 3,
|
||||
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
||||
t.Fatalf("claim without an application err=%v", err)
|
||||
}
|
||||
if _, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "claim-no-version", Actor: "alice", Reason: "queue"},
|
||||
ApplicationID: 77,
|
||||
Version: 0,
|
||||
}); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
|
||||
t.Fatalf("claim without a version err=%v", err)
|
||||
}
|
||||
if _, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "approve-long-note", Actor: "alice", Reason: "queue"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
InternalNote: strings.Repeat("x", domain.MaxVerificationNoteLength+1),
|
||||
}); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
|
||||
t.Fatalf("approve with an oversized note err=%v", err)
|
||||
}
|
||||
if len(repo.items) != 0 {
|
||||
t.Fatalf("journalled=%d, want malformed decisions refused before the journal", len(repo.items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationMissingApplicationIsJournalledAsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, repo := newVerificationFixture()
|
||||
verification.appErr = domain.ErrVerificationApplicationNotFound
|
||||
|
||||
result, err := svc.RejectVerification(ctx, RejectVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "reject-missing", Actor: "alice", Reason: "not eligible"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
||||
t.Fatalf("reject of a missing application err=%v", err)
|
||||
}
|
||||
if VerificationErrorCode(err) != CodeVerificationNotFound {
|
||||
t.Fatalf("code=%q", VerificationErrorCode(err))
|
||||
}
|
||||
if result.Status != string(domain.AdminCommandFailed) {
|
||||
t.Fatalf("result=%+v, want a failed command", result)
|
||||
}
|
||||
if cmd, ok := repo.items["reject-missing"]; !ok || cmd.Status != domain.AdminCommandFailed {
|
||||
t.Fatalf("journalled command=%+v ok=%v, want the failure recorded", cmd, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationReadsRequireTheDependency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Now: fixedNow})
|
||||
if _, err := svc.VerificationApplications(ctx, domain.VerificationApplicationFilter{}); err == nil {
|
||||
t.Fatal("listing without the dependency succeeded")
|
||||
}
|
||||
if _, err := svc.VerificationCounts(ctx); err == nil {
|
||||
t.Fatal("counting without the dependency succeeded")
|
||||
}
|
||||
if _, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{ApplicationID: 1, Version: 1}); err == nil {
|
||||
t.Fatal("claiming without the dependency succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationReadsPassThrough(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.counts = domain.VerificationStatusCounts{domain.VerificationStatusSubmitted: 3}
|
||||
verification.events = []domain.VerificationApplicationEvent{{
|
||||
ID: 9, ApplicationID: 77, Kind: domain.VerificationEventSubmitted,
|
||||
ToStatus: domain.VerificationStatusSubmitted, CreatedAt: fixedNow(),
|
||||
}}
|
||||
|
||||
items, err := svc.VerificationApplications(ctx, domain.VerificationApplicationFilter{Limit: 10})
|
||||
if err != nil || len(items) != 1 || items[0].ID != 77 {
|
||||
t.Fatalf("applications=%+v err=%v", items, err)
|
||||
}
|
||||
counts, err := svc.VerificationCounts(ctx)
|
||||
if err != nil || counts[domain.VerificationStatusSubmitted] != 3 {
|
||||
t.Fatalf("counts=%+v err=%v", counts, err)
|
||||
}
|
||||
events, err := svc.VerificationApplicationEvents(ctx, 77, 10)
|
||||
if err != nil || len(events) != 1 || events[0].ID != 9 {
|
||||
t.Fatalf("events=%+v err=%v", events, err)
|
||||
}
|
||||
app, err := svc.VerificationApplication(ctx, 77)
|
||||
if err != nil || app.Version != 3 {
|
||||
t.Fatalf("application=%+v err=%v", app, err)
|
||||
}
|
||||
if _, err := svc.VerificationApplication(ctx, 0); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
||||
t.Fatalf("application(0) err=%v", err)
|
||||
}
|
||||
target, err := svc.VerificationTargetSnapshot(ctx, domain.VerificationTargetChannel, 5005)
|
||||
if err != nil || target.ID != 5005 {
|
||||
t.Fatalf("target=%+v err=%v", target, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationErrorCodeCoversTheDomainSentinels(t *testing.T) {
|
||||
cases := map[error]string{
|
||||
domain.ErrVerificationApplicationNotFound: CodeVerificationNotFound,
|
||||
domain.ErrVerificationVersionConflict: CodeVerificationConflict,
|
||||
domain.ErrVerificationApplicationExists: CodeVerificationTargetOccupied,
|
||||
domain.ErrVerificationStatusInvalid: CodeVerificationStatusInvalid,
|
||||
domain.ErrVerificationReasonRequired: CodeVerificationReasonRequired,
|
||||
domain.ErrVerificationTargetAlreadyVerified: CodeVerificationTargetVerified,
|
||||
domain.ErrVerificationTargetNotPublic: CodeVerificationTargetNotPublic,
|
||||
domain.ErrVerificationTargetRestricted: CodeVerificationTargetRestricted,
|
||||
domain.ErrVerificationTargetSystem: CodeVerificationTargetSystem,
|
||||
domain.ErrVerificationNotOwner: CodeVerificationNotOwner,
|
||||
domain.ErrVerificationUserTargetsDisabled: CodeVerificationUserTargetsDisabled,
|
||||
domain.ErrVerificationTargetInvalid: CodeVerificationTargetInvalid,
|
||||
domain.ErrVerificationApplicationInvalid: CodeVerificationInvalid,
|
||||
errors.New("some transport failure nobody mapped"): "",
|
||||
}
|
||||
for err, want := range cases {
|
||||
if got := VerificationErrorCode(err); got != want {
|
||||
t.Fatalf("VerificationErrorCode(%v) = %q, want %q", err, got, want)
|
||||
}
|
||||
}
|
||||
if got := VerificationErrorCode(nil); got != "" {
|
||||
t.Fatalf("VerificationErrorCode(nil) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationTargetReasonErrorRoundTripsTheSnapshotString(t *testing.T) {
|
||||
for _, sentinel := range []error{
|
||||
domain.ErrVerificationTargetAlreadyVerified,
|
||||
domain.ErrVerificationTargetRestricted,
|
||||
domain.ErrVerificationTargetNotPublic,
|
||||
domain.ErrVerificationTargetSystem,
|
||||
domain.ErrVerificationUserTargetsDisabled,
|
||||
domain.ErrVerificationTargetInvalid,
|
||||
} {
|
||||
if err := verificationTargetReasonError(sentinel.Error()); !errors.Is(err, sentinel) {
|
||||
t.Fatalf("verificationTargetReasonError(%q) = %v", sentinel.Error(), err)
|
||||
}
|
||||
}
|
||||
// An unrecognised reason must still be a target failure rather than a panic or
|
||||
// a silent pass.
|
||||
if err := verificationTargetReasonError("something new"); !errors.Is(err, domain.ErrVerificationTargetInvalid) {
|
||||
t.Fatalf("unknown reason mapped to %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationClockIsNotRequiredForDetails(t *testing.T) {
|
||||
// The details are pure projections of the application, so a service without a
|
||||
// wall clock still produces a complete audit entry.
|
||||
svc, verification, _ := newVerificationFixture()
|
||||
verification.app.UpdatedAt = time.Time{}
|
||||
result, err := svc.ClaimVerification(context.Background(), ClaimVerificationRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "claim-clockless", Actor: "alice", Reason: "queue"},
|
||||
ApplicationID: 77,
|
||||
Version: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
for _, key := range []string{"application_id", "applicant_user_id", "target_type", "target_id", "previous_status", "status", "correlation_id"} {
|
||||
if _, ok := result.Details[key]; !ok {
|
||||
t.Fatalf("details %+v missing %q", result.Details, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
561
internal/adminapi/botverification.go
Normal file
561
internal/adminapi/botverification.go
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
package adminapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Third-party bot verification over the admin API
|
||||
// (core.telegram.org/api/bots/verification).
|
||||
//
|
||||
// These are the mirror routes of the panel's own /api/botverification endpoints:
|
||||
// the panel reads straight from PostgreSQL for speed, while an integration holding
|
||||
// a scoped token reads it here. Every mutation only ever travels this way, so the
|
||||
// command journal, the status machine and the optimistic lock are enforced in one
|
||||
// place.
|
||||
//
|
||||
// This is NOT the official platform verification surface in verification.go. The
|
||||
// two mechanisms own separate tables, separate permissions (botverification.* vs
|
||||
// verification.*) and separate routes, and neither reads the other's state: a
|
||||
// third-party verifier must never be able to mint a platform checkmark.
|
||||
//
|
||||
// Every int64 crosses the JSON boundary as a decimal string. Bot ids, peer ids,
|
||||
// custom emoji document ids and the optimistic-locking version all exceed the
|
||||
// range a JSON number holds exactly, and a rounded version would decide the wrong
|
||||
// revision of a row.
|
||||
|
||||
// handleBotVerifiers lists verifier bots.
|
||||
func (s *Server) handleBotVerifiers(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := s.svc.BotVerifiers(r.Context(), queryBool(query.Get("enabled_only")), limit)
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, botVerifierResponse(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
|
||||
}
|
||||
|
||||
// handleVerificationIcons lists the icon catalogue.
|
||||
func (s *Server) handleVerificationIcons(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := s.svc.VerificationIcons(r.Context(), queryBool(query.Get("active_only")), limit)
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, verificationIconResponse(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
|
||||
}
|
||||
|
||||
// handleCustomVerifications lists granted marks with keyset paging.
|
||||
func (s *Server) handleCustomVerifications(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
peerType, ok := botVerificationPeerType(w, query.Get("peer_type"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
verifierBotID, ok := optionalQueryInt64(w, query, "verifier_bot_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
beforeID, ok := optionalQueryInt64(w, query, "before_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := s.svc.CustomVerifications(r.Context(), domain.CustomVerificationFilter{
|
||||
VerifierBotID: verifierBotID,
|
||||
PeerType: peerType,
|
||||
Query: query.Get("q"),
|
||||
BeforeID: beforeID,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, customVerificationResponse(item))
|
||||
}
|
||||
// The page bound is the use-case layer's, so has_more is derived from what came
|
||||
// back rather than from the limit the caller asked for.
|
||||
hasMore := limit > 0 && len(items) >= limit
|
||||
nextBeforeID := ""
|
||||
if hasMore && len(items) > 0 {
|
||||
nextBeforeID = strconv.FormatInt(items[len(items)-1].ID, 10)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rows": rows,
|
||||
"has_more": hasMore,
|
||||
"next_before_id": nextBeforeID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleCustomVerificationRequests is the third-party review queue.
|
||||
func (s *Server) handleCustomVerificationRequests(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
peerType, ok := botVerificationPeerType(w, query.Get("peer_type"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter := domain.CustomVerificationRequestFilter{
|
||||
PeerType: peerType,
|
||||
Query: query.Get("q"),
|
||||
}
|
||||
// status accepts a comma-separated list, so a "pending,approved" view is one
|
||||
// request rather than two.
|
||||
for _, raw := range strings.Split(query.Get("status"), ",") {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
status := domain.CustomVerificationRequestStatus(raw)
|
||||
if !status.Valid() {
|
||||
writeCodedError(w, http.StatusBadRequest, admin.CodeCustomVerificationStatusInvalid, "invalid status "+raw)
|
||||
return
|
||||
}
|
||||
filter.Statuses = append(filter.Statuses, status)
|
||||
}
|
||||
verifierBotID, ok := optionalQueryInt64(w, query, "verifier_bot_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.VerifierBotID = verifierBotID
|
||||
beforeID, ok := optionalQueryInt64(w, query, "before_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.BeforeID = beforeID
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.Limit = limit
|
||||
items, err := s.svc.CustomVerificationRequests(r.Context(), filter)
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, customVerificationRequestResponse(item))
|
||||
}
|
||||
hasMore := limit > 0 && len(items) >= limit
|
||||
nextBeforeID := ""
|
||||
if hasMore && len(items) > 0 {
|
||||
nextBeforeID = strconv.FormatInt(items[len(items)-1].ID, 10)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rows": rows,
|
||||
"has_more": hasMore,
|
||||
"next_before_id": nextBeforeID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleCustomVerificationRequest is one application with the verifier behind it
|
||||
// and whether the mark is on the peer right now.
|
||||
func (s *Server) handleCustomVerificationRequest(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, err := s.svc.CustomVerificationRequest(r.Context(), id)
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
body := map[string]any{"request": customVerificationRequestResponse(req)}
|
||||
// The verifier row is advisory: it may have been revoked since the application
|
||||
// was filed, and that must not turn the audit record into a 500. An absent row
|
||||
// is reported as a verifier with only its id, so the reviewer can see which bot
|
||||
// it was.
|
||||
if settings, err := s.svc.BotVerifier(r.Context(), req.VerifierBotID); err == nil {
|
||||
body["verifier"] = botVerifierResponse(settings)
|
||||
} else if errors.Is(err, domain.ErrVerifierNotFound) {
|
||||
body["verifier"] = botVerifierResponse(domain.BotVerifierSettings{BotID: req.VerifierBotID})
|
||||
} else {
|
||||
body["verifier"] = botVerifierResponse(domain.BotVerifierSettings{BotID: req.VerifierBotID})
|
||||
body["verifier_error"] = err.Error()
|
||||
}
|
||||
// mark_active tells "approved" apart from "approved and since stripped by the
|
||||
// operator", which is the one thing the status alone cannot say.
|
||||
if active, err := s.svc.CustomVerificationMarkActive(r.Context(), req.VerifierBotID, req.Peer); err == nil {
|
||||
body["mark_active"] = active
|
||||
} else {
|
||||
body["mark_active"] = false
|
||||
body["mark_error"] = err.Error()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// handleCustomVerificationCounts is the queue summary.
|
||||
func (s *Server) handleCustomVerificationCounts(w http.ResponseWriter, r *http.Request) {
|
||||
counts, err := s.svc.CustomVerificationRequestCounts(r.Context())
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"counts": customVerificationCountsResponse(counts)})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleGrantBotVerifier(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.GrantBotVerifierRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.GrantBotVerifier(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetBotVerifierEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetBotVerifierEnabledRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.SetBotVerifierEnabled(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeBotVerifier(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.RevokeBotVerifierRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.RevokeBotVerifier(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpsertVerificationIcon(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.UpsertVerificationIconRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.UpsertVerificationIcon(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetVerificationIconActive(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetVerificationIconActiveRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.SetVerificationIconActive(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeCustomVerification(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.RevokeCustomVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.RevokeCustomVerification(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleApproveBotVerification(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req admin.ApproveBotVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
// The path is the authority on which application is decided: a body naming a
|
||||
// different one would make the URL lie to the audit trail.
|
||||
req.RequestID = id
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.ApproveBotVerification(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRejectBotVerification(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req admin.RejectBotVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.RequestID = id
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.RejectBotVerification(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeBotVerification(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req admin.RevokeBotVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.RequestID = id
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.RevokeBotVerification(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// botVerifierResponse renders one verifier bot. The keys are the panel's row
|
||||
// field names, so the same shape reaches the browser whether it came from here or
|
||||
// from the panel's direct read.
|
||||
func botVerifierResponse(settings domain.BotVerifierSettings) map[string]any {
|
||||
out := map[string]any{
|
||||
"BotID": strconv.FormatInt(settings.BotID, 10),
|
||||
"IconDocumentID": strconv.FormatInt(settings.IconDocumentID, 10),
|
||||
"CompanyName": settings.CompanyName,
|
||||
"DefaultDescription": settings.DefaultDescription,
|
||||
"CanModifyCustomDescription": settings.CanModifyCustomDescription,
|
||||
"Enabled": settings.Enabled,
|
||||
"GrantedBy": settings.GrantedBy,
|
||||
"GrantReason": settings.GrantReason,
|
||||
"Version": strconv.FormatInt(settings.Version, 10),
|
||||
}
|
||||
if !settings.CreatedAt.IsZero() {
|
||||
out["CreatedAt"] = settings.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !settings.UpdatedAt.IsZero() {
|
||||
out["UpdatedAt"] = settings.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func verificationIconResponse(icon domain.VerificationIcon) map[string]any {
|
||||
out := map[string]any{
|
||||
"ID": strconv.FormatInt(icon.ID, 10),
|
||||
"DocumentID": strconv.FormatInt(icon.DocumentID, 10),
|
||||
"OwnerBotID": strconv.FormatInt(icon.OwnerBotID, 10),
|
||||
"Name": icon.Name,
|
||||
"Active": icon.Active,
|
||||
}
|
||||
if !icon.CreatedAt.IsZero() {
|
||||
out["CreatedAt"] = icon.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !icon.UpdatedAt.IsZero() {
|
||||
out["UpdatedAt"] = icon.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func customVerificationResponse(mark domain.CustomVerification) map[string]any {
|
||||
out := map[string]any{
|
||||
"ID": strconv.FormatInt(mark.ID, 10),
|
||||
"VerifierBotID": strconv.FormatInt(mark.VerifierBotID, 10),
|
||||
"PeerType": string(mark.Peer.Type),
|
||||
"PeerID": strconv.FormatInt(mark.Peer.ID, 10),
|
||||
"IconDocumentID": strconv.FormatInt(mark.IconDocumentID, 10),
|
||||
"Description": mark.Description,
|
||||
"Version": strconv.FormatInt(mark.Version, 10),
|
||||
}
|
||||
if !mark.CreatedAt.IsZero() {
|
||||
out["CreatedAt"] = mark.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !mark.UpdatedAt.IsZero() {
|
||||
out["UpdatedAt"] = mark.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func customVerificationRequestResponse(req domain.CustomVerificationRequest) map[string]any {
|
||||
out := map[string]any{
|
||||
"ID": strconv.FormatInt(req.ID, 10),
|
||||
"VerifierBotID": strconv.FormatInt(req.VerifierBotID, 10),
|
||||
"ApplicantUserID": strconv.FormatInt(req.ApplicantUserID, 10),
|
||||
"PeerType": string(req.Peer.Type),
|
||||
"PeerID": strconv.FormatInt(req.Peer.ID, 10),
|
||||
"PeerTitle": req.PeerTitle,
|
||||
"PeerUsername": req.PeerUsername,
|
||||
"Reason": req.Reason,
|
||||
"RequestedDescription": req.RequestedDescription,
|
||||
"Status": string(req.Status),
|
||||
"DecidedBy": req.DecidedBy,
|
||||
"DecisionReason": req.DecisionReason,
|
||||
// InternalNote is operator-only. It is exposed here because every caller of
|
||||
// this route already holds botverification.review, and it is the reviewer's
|
||||
// own handover note; it is never part of the applicant-facing projection.
|
||||
"InternalNote": req.InternalNote,
|
||||
"CorrelationID": req.CorrelationID,
|
||||
"Version": strconv.FormatInt(req.Version, 10),
|
||||
}
|
||||
if !req.CreatedAt.IsZero() {
|
||||
out["CreatedAt"] = req.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !req.UpdatedAt.IsZero() {
|
||||
out["UpdatedAt"] = req.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !req.ApprovedAt.IsZero() {
|
||||
out["ApprovedAt"] = req.ApprovedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !req.RejectedAt.IsZero() {
|
||||
out["RejectedAt"] = req.RejectedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// customVerificationCountsResponse renders the queue summary with every modelled
|
||||
// status present, so the panel never has to distinguish "zero" from "absent". The
|
||||
// values are decimal strings for the same exactness reason as the ids.
|
||||
func customVerificationCountsResponse(counts map[domain.CustomVerificationRequestStatus]int64) map[string]string {
|
||||
out := make(map[string]string, len(customVerificationStatusOrder))
|
||||
for _, status := range customVerificationStatusOrder {
|
||||
out[string(status)] = strconv.FormatInt(counts[status], 10)
|
||||
}
|
||||
for status, count := range counts {
|
||||
if _, ok := out[string(status)]; !ok {
|
||||
out[string(status)] = strconv.FormatInt(count, 10)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// customVerificationStatusOrder is the closed status set, in lifecycle order.
|
||||
var customVerificationStatusOrder = []domain.CustomVerificationRequestStatus{
|
||||
domain.CustomVerificationPending,
|
||||
domain.CustomVerificationApproved,
|
||||
domain.CustomVerificationRejected,
|
||||
domain.CustomVerificationRevoked,
|
||||
}
|
||||
|
||||
// botVerificationPeerType validates the peer filter against the peer kinds a
|
||||
// third-party mark can sit on. An unmodelled value is a 400 rather than an empty
|
||||
// result, so a typo is reported instead of silently returning nothing.
|
||||
func botVerificationPeerType(w http.ResponseWriter, raw string) (domain.PeerType, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", true
|
||||
}
|
||||
peerType := domain.PeerType(raw)
|
||||
if peerType != domain.PeerTypeUser && peerType != domain.PeerTypeChannel {
|
||||
writeCodedError(w, http.StatusBadRequest, admin.CodeCustomVerificationTargetInvalid, "invalid peer_type")
|
||||
return "", false
|
||||
}
|
||||
return peerType, true
|
||||
}
|
||||
|
||||
// queryBool reads a boolean flag the way the panel writes it: an absent or empty
|
||||
// value is false, and "1"/"true"/"yes" are true.
|
||||
func queryBool(raw string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// botVerificationErrorStatus maps a third-party verification failure onto its HTTP
|
||||
// status.
|
||||
//
|
||||
// The version conflict is 409, not 400, because nothing about the request was
|
||||
// wrong -- another operator simply decided first, and the panel has to answer that
|
||||
// by reloading rather than by correcting input. The per-verifier bound is 409 for
|
||||
// the same reason: the request was well formed and the state refused it.
|
||||
func botVerificationErrorStatus(code string) int {
|
||||
switch code {
|
||||
case admin.CodeBotVerifierNotFound,
|
||||
admin.CodeBotVerifierBotNotFound,
|
||||
admin.CodeVerificationIconNotFound,
|
||||
admin.CodeCustomVerificationNotFound,
|
||||
admin.CodeCustomVerificationRequestNotFound:
|
||||
return http.StatusNotFound
|
||||
case admin.CodeCustomVerificationConflict,
|
||||
admin.CodeCustomVerificationLimit,
|
||||
admin.CodeCustomVerificationRequestExists:
|
||||
return http.StatusConflict
|
||||
case admin.CodeCustomVerificationRateLimited:
|
||||
return http.StatusTooManyRequests
|
||||
case admin.CodeBotVerifierForbidden,
|
||||
admin.CodeBotVerifierDescriptionForbidden,
|
||||
admin.CodeBotVerifierInvalid,
|
||||
admin.CodeVerificationIconInactive,
|
||||
admin.CodeVerificationIconInvalid,
|
||||
admin.CodeCustomVerificationStatusInvalid,
|
||||
admin.CodeCustomVerificationReasonRequired,
|
||||
admin.CodeCustomVerificationTargetInvalid,
|
||||
admin.CodeCustomVerificationTargetSystem,
|
||||
admin.CodeCustomVerificationInvalid:
|
||||
// BOTVERIFIER_FORBIDDEN is 400 rather than 403 on purpose: the caller is
|
||||
// authorised (403 is reserved for the permission gate), it is the *subject*
|
||||
// that may not verify, which the operator fixes by enabling the verifier.
|
||||
return http.StatusBadRequest
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
func writeBotVerificationError(w http.ResponseWriter, err error) {
|
||||
code := admin.BotVerificationErrorCode(err)
|
||||
writeCodedError(w, botVerificationErrorStatus(code), code, err.Error())
|
||||
}
|
||||
|
||||
// writeBotVerificationCommandResult answers a command.
|
||||
//
|
||||
// The body stays a CommandResult so the panel parses one shape for every operator
|
||||
// action, but the status is derived from the failure: a lost optimistic-locking
|
||||
// race must reach the browser as 409, because that is the one failure the panel
|
||||
// resolves by reloading the row instead of by asking the operator to fix the form.
|
||||
func writeBotVerificationCommandResult(w http.ResponseWriter, result admin.CommandResult, err error) {
|
||||
if err == nil {
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
code := admin.BotVerificationErrorCode(err)
|
||||
status := botVerificationErrorStatus(code)
|
||||
if status == http.StatusInternalServerError {
|
||||
// An unmapped command failure is a bad request, as everywhere else in this
|
||||
// API, rather than a server fault.
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
if result.CommandID == "" {
|
||||
result = admin.CommandResult{Status: "failed", Message: "command failed", Error: err.Error()}
|
||||
}
|
||||
if result.Error == "" {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
if code == admin.CodeCustomVerificationConflict {
|
||||
result.Message = "another operator changed this row first; reload it and try again"
|
||||
}
|
||||
writeJSON(w, status, result)
|
||||
}
|
||||
826
internal/adminapi/botverification_test.go
Normal file
826
internal/adminapi/botverification_test.go
Normal file
|
|
@ -0,0 +1,826 @@
|
|||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// fakeService gains the third-party verification surface here so the shared fake
|
||||
// keeps satisfying Service without touching the existing test files.
|
||||
|
||||
func (fakeService) GrantBotVerifier(_ context.Context, req admin.GrantBotVerifierRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetBotVerifierEnabled(_ context.Context, req admin.SetBotVerifierEnabledRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) RevokeBotVerifier(_ context.Context, req admin.RevokeBotVerifierRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) UpsertVerificationIcon(_ context.Context, req admin.UpsertVerificationIconRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetVerificationIconActive(_ context.Context, req admin.SetVerificationIconActiveRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) RevokeCustomVerification(_ context.Context, req admin.RevokeCustomVerificationRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) ApproveBotVerification(_ context.Context, req admin.ApproveBotVerificationRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) RejectBotVerification(_ context.Context, req admin.RejectBotVerificationRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) RevokeBotVerification(_ context.Context, req admin.RevokeBotVerificationRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) BotVerifiers(context.Context, bool, int) ([]domain.BotVerifierSettings, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) BotVerifier(context.Context, int64) (domain.BotVerifierSettings, error) {
|
||||
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
|
||||
}
|
||||
|
||||
func (fakeService) VerificationIcons(context.Context, bool, int) ([]domain.VerificationIcon, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) CustomVerifications(context.Context, domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) CustomVerificationRequests(context.Context, domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) CustomVerificationRequest(context.Context, int64) (domain.CustomVerificationRequest, error) {
|
||||
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
|
||||
}
|
||||
|
||||
func (fakeService) CustomVerificationRequestCounts(context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) CustomVerificationMarkActive(context.Context, int64, domain.Peer) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type captureBotVerificationService struct {
|
||||
fakeService
|
||||
verifier domain.BotVerifierSettings
|
||||
hasRow bool
|
||||
icons []domain.VerificationIcon
|
||||
marks []domain.CustomVerification
|
||||
request domain.CustomVerificationRequest
|
||||
counts map[domain.CustomVerificationRequestStatus]int64
|
||||
markActive bool
|
||||
|
||||
iconFilterActiveOnly bool
|
||||
verifierFilterEnabled bool
|
||||
verifierFilterLimit int
|
||||
markFilter domain.CustomVerificationFilter
|
||||
requestFilter domain.CustomVerificationRequestFilter
|
||||
grant admin.GrantBotVerifierRequest
|
||||
setEnabled admin.SetBotVerifierEnabledRequest
|
||||
revokeVerifier admin.RevokeBotVerifierRequest
|
||||
upsertIcon admin.UpsertVerificationIconRequest
|
||||
setIconActive admin.SetVerificationIconActiveRequest
|
||||
revokeMark admin.RevokeCustomVerificationRequest
|
||||
approve admin.ApproveBotVerificationRequest
|
||||
reject admin.RejectBotVerificationRequest
|
||||
revokeRequest admin.RevokeBotVerificationRequest
|
||||
commandErr error
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) BotVerifiers(_ context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error) {
|
||||
s.verifierFilterEnabled = enabledOnly
|
||||
s.verifierFilterLimit = limit
|
||||
if !s.hasRow {
|
||||
return nil, nil
|
||||
}
|
||||
return []domain.BotVerifierSettings{s.verifier}, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) BotVerifier(_ context.Context, botID int64) (domain.BotVerifierSettings, error) {
|
||||
if !s.hasRow || s.verifier.BotID != botID {
|
||||
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
|
||||
}
|
||||
return s.verifier, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) VerificationIcons(_ context.Context, activeOnly bool, _ int) ([]domain.VerificationIcon, error) {
|
||||
s.iconFilterActiveOnly = activeOnly
|
||||
return s.icons, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) CustomVerifications(_ context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
|
||||
s.markFilter = filter
|
||||
return s.marks, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) CustomVerificationRequests(_ context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
|
||||
s.requestFilter = filter
|
||||
return []domain.CustomVerificationRequest{s.request}, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) CustomVerificationRequest(_ context.Context, requestID int64) (domain.CustomVerificationRequest, error) {
|
||||
if s.request.ID != requestID {
|
||||
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
|
||||
}
|
||||
return s.request, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) CustomVerificationRequestCounts(context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
|
||||
return s.counts, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) CustomVerificationMarkActive(context.Context, int64, domain.Peer) (bool, error) {
|
||||
return s.markActive, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) commandResult(commandID string, dryRun bool) (admin.CommandResult, error) {
|
||||
if s.commandErr != nil {
|
||||
return admin.CommandResult{CommandID: commandID, Status: "failed", Error: s.commandErr.Error()}, s.commandErr
|
||||
}
|
||||
return admin.CommandResult{CommandID: commandID, Status: "completed", DryRun: dryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) GrantBotVerifier(_ context.Context, req admin.GrantBotVerifierRequest) (admin.CommandResult, error) {
|
||||
s.grant = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) SetBotVerifierEnabled(_ context.Context, req admin.SetBotVerifierEnabledRequest) (admin.CommandResult, error) {
|
||||
s.setEnabled = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) RevokeBotVerifier(_ context.Context, req admin.RevokeBotVerifierRequest) (admin.CommandResult, error) {
|
||||
s.revokeVerifier = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) UpsertVerificationIcon(_ context.Context, req admin.UpsertVerificationIconRequest) (admin.CommandResult, error) {
|
||||
s.upsertIcon = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) SetVerificationIconActive(_ context.Context, req admin.SetVerificationIconActiveRequest) (admin.CommandResult, error) {
|
||||
s.setIconActive = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) RevokeCustomVerification(_ context.Context, req admin.RevokeCustomVerificationRequest) (admin.CommandResult, error) {
|
||||
s.revokeMark = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) ApproveBotVerification(_ context.Context, req admin.ApproveBotVerificationRequest) (admin.CommandResult, error) {
|
||||
s.approve = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) RejectBotVerification(_ context.Context, req admin.RejectBotVerificationRequest) (admin.CommandResult, error) {
|
||||
s.reject = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) RevokeBotVerification(_ context.Context, req admin.RevokeBotVerificationRequest) (admin.CommandResult, error) {
|
||||
s.revokeRequest = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
// botVerificationServer is the deployment shape the permission model exists for:
|
||||
// one master token plus bounded tokens that can review, manage, or neither.
|
||||
func botVerificationServer(svc Service) *Server {
|
||||
return &Server{
|
||||
token: "master",
|
||||
scoped: []ScopedToken{
|
||||
{Name: "queue-bot", Token: "scoped-review", Permissions: []string{PermissionBotVerificationReview}},
|
||||
{Name: "trust-and-safety", Token: "scoped-manage", Permissions: []string{PermissionBotVerificationManage}},
|
||||
{Name: "both", Token: "scoped-both", Permissions: []string{
|
||||
PermissionBotVerificationReview, PermissionBotVerificationManage,
|
||||
}},
|
||||
// A token for the *official* review surface: it must not reach this one.
|
||||
{Name: "official-review", Token: "scoped-official", Permissions: []string{PermissionVerificationReview}},
|
||||
},
|
||||
svc: svc,
|
||||
}
|
||||
}
|
||||
|
||||
// botVerificationRoute is one route with a body the handler accepts, so an
|
||||
// authorisation test cannot pass by accident on a malformed payload.
|
||||
type botVerificationRoute struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}
|
||||
|
||||
const decisionBody = `{"command_id":"c1","actor":"ops","reason":"decided","version":2}`
|
||||
|
||||
var botVerificationReadRoutes = []botVerificationRoute{
|
||||
{http.MethodGet, "/v1/botverification/verifiers", ""},
|
||||
{http.MethodGet, "/v1/botverification/icons", ""},
|
||||
{http.MethodGet, "/v1/botverification/marks", ""},
|
||||
{http.MethodGet, "/v1/botverification/requests", ""},
|
||||
{http.MethodGet, "/v1/botverification/requests/7", ""},
|
||||
{http.MethodGet, "/v1/botverification/counts", ""},
|
||||
{http.MethodPost, "/v1/botverification/requests/7/approve", decisionBody},
|
||||
{http.MethodPost, "/v1/botverification/requests/7/reject", decisionBody},
|
||||
{http.MethodPost, "/v1/botverification/requests/7/revoke", decisionBody},
|
||||
}
|
||||
|
||||
var botVerificationManageRoutes = []botVerificationRoute{
|
||||
{http.MethodPost, "/v1/botverification/verifiers/grant",
|
||||
`{"command_id":"c1","actor":"ops","reason":"partner","bot_id":3003,"icon_document_id":900,"company_name":"Example Trust","version":4}`},
|
||||
{http.MethodPost, "/v1/botverification/verifiers/set-enabled",
|
||||
`{"command_id":"c1","actor":"ops","reason":"abuse","bot_id":3003,"enabled":false}`},
|
||||
{http.MethodPost, "/v1/botverification/verifiers/revoke",
|
||||
`{"command_id":"c1","actor":"ops","reason":"programme ended","bot_id":3003}`},
|
||||
{http.MethodPost, "/v1/botverification/icons/upsert",
|
||||
`{"command_id":"c1","actor":"ops","reason":"new icon","document_id":900,"name":"blue check"}`},
|
||||
{http.MethodPost, "/v1/botverification/icons/set-active",
|
||||
`{"command_id":"c1","actor":"ops","reason":"retired","icon_id":501,"active":false}`},
|
||||
{http.MethodPost, "/v1/botverification/marks/revoke",
|
||||
`{"command_id":"c1","actor":"ops","reason":"impersonation","verifier_bot_id":3003,"peer_type":"channel","peer_id":5005}`},
|
||||
}
|
||||
|
||||
// botVerificationRoutes is every route in the section.
|
||||
func botVerificationRoutes() []botVerificationRoute {
|
||||
out := make([]botVerificationRoute, 0, len(botVerificationReadRoutes)+len(botVerificationManageRoutes))
|
||||
out = append(out, botVerificationReadRoutes...)
|
||||
return append(out, botVerificationManageRoutes...)
|
||||
}
|
||||
|
||||
func TestBotVerificationRoutesRejectMissingAndUnknownTokens(t *testing.T) {
|
||||
srv := botVerificationServer(fakeService{})
|
||||
for _, item := range botVerificationRoutes() {
|
||||
for _, token := range []string{"", "not-a-configured-token"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s %s token=%q status=%d, want 401", item.method, item.path, token, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationRoutesRefuseScopedTokenWithoutThePermission(t *testing.T) {
|
||||
srv := botVerificationServer(fakeService{})
|
||||
// The official-verification token is the interesting negative: the two
|
||||
// mechanisms are separate, so verification.review must not open this surface.
|
||||
for _, token := range []string{"scoped-official", "scoped-manage"} {
|
||||
for _, item := range botVerificationReadRoutes {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s %s token=%q status=%d body=%s, want 403", item.method, item.path, token, rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode 403 body: %v", err)
|
||||
}
|
||||
if body["code"] != CodeForbidden || body["permission"] != PermissionBotVerificationReview {
|
||||
t.Fatalf("403 body=%+v, want botverification.review named", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the review right alone does not reach the configuration half.
|
||||
for _, token := range []string{"scoped-official", "scoped-review"} {
|
||||
for _, item := range botVerificationManageRoutes {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s %s token=%q status=%d body=%s, want 403", item.method, item.path, token, rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode 403 body: %v", err)
|
||||
}
|
||||
if body["permission"] != PermissionBotVerificationManage {
|
||||
t.Fatalf("403 body=%+v, want botverification.manage named", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A token holding the third-party rights must not reach the official queue either:
|
||||
// the separation is symmetric.
|
||||
func TestBotVerificationTokenCannotReachTheOfficialVerificationSurface(t *testing.T) {
|
||||
srv := botVerificationServer(fakeService{})
|
||||
for _, path := range []string{"/v1/verification/applications", "/v1/verification/counts"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, path, "scoped-both", ""))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s status=%d body=%s, want 403", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// Nor the legacy surface that predates permissions.
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/accounts/set-verified", "scoped-both",
|
||||
`{"command_id":"c1","actor":"ops","reason":"x","user_id":1001,"verified":true}`))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("legacy surface status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationScopedTokensReachTheirOwnHalf(t *testing.T) {
|
||||
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 7, Version: 2}}
|
||||
srv := botVerificationServer(svc)
|
||||
|
||||
for _, item := range botVerificationReadRoutes {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "scoped-review", item.body))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s %s status=%d body=%s", item.method, item.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
for _, item := range botVerificationManageRoutes {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "scoped-manage", item.body))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s %s status=%d body=%s", item.method, item.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasterTokenReachesTheBotVerificationSurface(t *testing.T) {
|
||||
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 7, Version: 2}}
|
||||
srv := botVerificationServer(svc)
|
||||
for _, item := range botVerificationRoutes() {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "master", item.body))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("master on %s %s status=%d body=%s", item.method, item.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerifierListRendersInt64AsDecimalStrings(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
svc := &captureBotVerificationService{
|
||||
hasRow: true,
|
||||
verifier: domain.BotVerifierSettings{
|
||||
BotID: maxInt64,
|
||||
IconDocumentID: maxInt64,
|
||||
CompanyName: "Example Trust",
|
||||
DefaultDescription: "verified by Example Trust",
|
||||
CanModifyCustomDescription: true,
|
||||
Enabled: true,
|
||||
GrantedBy: "alice",
|
||||
GrantReason: "partner programme",
|
||||
Version: maxInt64,
|
||||
CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
|
||||
UpdatedAt: time.Unix(1_700_000_000, 0).UTC(),
|
||||
},
|
||||
}
|
||||
srv := botVerificationServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
|
||||
"/v1/botverification/verifiers?enabled_only=1&limit=25", "scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !svc.verifierFilterEnabled || svc.verifierFilterLimit != 25 {
|
||||
t.Fatalf("enabledOnly=%v limit=%d, want the query honoured", svc.verifierFilterEnabled, svc.verifierFilterLimit)
|
||||
}
|
||||
var body struct {
|
||||
Rows []map[string]any `json:"rows"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode verifiers: %v", err)
|
||||
}
|
||||
if len(body.Rows) != 1 {
|
||||
t.Fatalf("rows=%+v", body.Rows)
|
||||
}
|
||||
for _, field := range []string{"BotID", "IconDocumentID", "Version"} {
|
||||
if body.Rows[0][field] != "9223372036854775807" {
|
||||
t.Fatalf("%s = %#v, want an exact decimal string", field, body.Rows[0][field])
|
||||
}
|
||||
}
|
||||
if body.Rows[0]["CanModifyCustomDescription"] != true || body.Rows[0]["Enabled"] != true {
|
||||
t.Fatalf("row=%+v, want the booleans as booleans", body.Rows[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationIconAndMarkListingsRenderInt64AsDecimalStrings(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
svc := &captureBotVerificationService{
|
||||
icons: []domain.VerificationIcon{{
|
||||
ID: maxInt64, DocumentID: maxInt64, OwnerBotID: maxInt64, Name: "blue check", Active: true,
|
||||
CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
|
||||
}},
|
||||
marks: []domain.CustomVerification{{
|
||||
ID: maxInt64, VerifierBotID: maxInt64,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: maxInt64},
|
||||
IconDocumentID: maxInt64, Description: "verified partner", Version: maxInt64,
|
||||
}},
|
||||
}
|
||||
srv := botVerificationServer(svc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
|
||||
"/v1/botverification/icons?active_only=true", "scoped-review", ""))
|
||||
if rec.Code != http.StatusOK || !svc.iconFilterActiveOnly {
|
||||
t.Fatalf("icons status=%d activeOnly=%v body=%s", rec.Code, svc.iconFilterActiveOnly, rec.Body.String())
|
||||
}
|
||||
var icons struct {
|
||||
Rows []map[string]any `json:"rows"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &icons); err != nil {
|
||||
t.Fatalf("decode icons: %v", err)
|
||||
}
|
||||
for _, field := range []string{"ID", "DocumentID", "OwnerBotID"} {
|
||||
if icons.Rows[0][field] != "9223372036854775807" {
|
||||
t.Fatalf("icon %s = %#v", field, icons.Rows[0][field])
|
||||
}
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
|
||||
"/v1/botverification/marks?verifier_bot_id=9223372036854775807&peer_type=channel&q=news&limit=1&before_id=99",
|
||||
"scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("marks status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if svc.markFilter.VerifierBotID != maxInt64 || svc.markFilter.PeerType != domain.PeerTypeChannel ||
|
||||
svc.markFilter.Query != "news" || svc.markFilter.Limit != 1 || svc.markFilter.BeforeID != 99 {
|
||||
t.Fatalf("mark filter=%+v", svc.markFilter)
|
||||
}
|
||||
var marks struct {
|
||||
Rows []map[string]any `json:"rows"`
|
||||
HasMore bool `json:"has_more"`
|
||||
NextBeforeID string `json:"next_before_id"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &marks); err != nil {
|
||||
t.Fatalf("decode marks: %v", err)
|
||||
}
|
||||
for _, field := range []string{"ID", "VerifierBotID", "PeerID", "IconDocumentID", "Version"} {
|
||||
if marks.Rows[0][field] != "9223372036854775807" {
|
||||
t.Fatalf("mark %s = %#v", field, marks.Rows[0][field])
|
||||
}
|
||||
}
|
||||
// A full page reports more, and the cursor is the last id as a decimal string.
|
||||
if !marks.HasMore || marks.NextBeforeID != "9223372036854775807" {
|
||||
t.Fatalf("paging hasMore=%v next=%q", marks.HasMore, marks.NextBeforeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationQueueFilterAndUnmodelledValues(t *testing.T) {
|
||||
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{
|
||||
ID: 88, VerifierBotID: 3003, ApplicantUserID: 1001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 5005},
|
||||
Status: domain.CustomVerificationPending, Version: 3,
|
||||
}}
|
||||
srv := botVerificationServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
|
||||
"/v1/botverification/requests?status=pending,approved&verifier_bot_id=3003&peer_type=channel&q=news&limit=25&before_id=99",
|
||||
"scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(svc.requestFilter.Statuses) != 2 ||
|
||||
svc.requestFilter.Statuses[0] != domain.CustomVerificationPending ||
|
||||
svc.requestFilter.Statuses[1] != domain.CustomVerificationApproved ||
|
||||
svc.requestFilter.VerifierBotID != 3003 || svc.requestFilter.PeerType != domain.PeerTypeChannel ||
|
||||
svc.requestFilter.Query != "news" || svc.requestFilter.Limit != 25 || svc.requestFilter.BeforeID != 99 {
|
||||
t.Fatalf("filter=%+v", svc.requestFilter)
|
||||
}
|
||||
|
||||
// An unmodelled status or peer type is a 400 rather than an empty result, so a
|
||||
// typo is reported instead of silently returning nothing.
|
||||
for _, query := range []string{"?status=in_review", "?peer_type=chat", "?verifier_bot_id=abc", "?before_id=-1"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests"+query, "scoped-review", ""))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s status=%d body=%s, want 400", query, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
for _, query := range []string{"?peer_type=chat"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/marks"+query, "scoped-review", ""))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("marks %s status=%d, want 400", query, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationRequestDetailAndCounts(t *testing.T) {
|
||||
svc := &captureBotVerificationService{
|
||||
hasRow: true,
|
||||
verifier: domain.BotVerifierSettings{
|
||||
BotID: 3003, IconDocumentID: 900, CompanyName: "Example Trust", Enabled: true, Version: 4,
|
||||
},
|
||||
request: domain.CustomVerificationRequest{
|
||||
ID: 88, VerifierBotID: 3003, ApplicantUserID: 1001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 5005},
|
||||
PeerTitle: "Example News",
|
||||
PeerUsername: "examplenews",
|
||||
InternalNote: "operator only",
|
||||
Status: domain.CustomVerificationApproved, Version: 5,
|
||||
ApprovedAt: time.Unix(1_700_000_000, 0).UTC(),
|
||||
},
|
||||
markActive: true,
|
||||
counts: map[domain.CustomVerificationRequestStatus]int64{domain.CustomVerificationPending: 3, domain.CustomVerificationApproved: 1},
|
||||
}
|
||||
srv := botVerificationServer(svc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests/88", "scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("detail status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var detail struct {
|
||||
Request map[string]any `json:"request"`
|
||||
Verifier map[string]any `json:"verifier"`
|
||||
MarkActive bool `json:"mark_active"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &detail); err != nil {
|
||||
t.Fatalf("decode detail: %v", err)
|
||||
}
|
||||
if detail.Request["ID"] != "88" || detail.Request["PeerID"] != "5005" || detail.Request["Version"] != "5" ||
|
||||
detail.Request["InternalNote"] != "operator only" || detail.Request["ApprovedAt"] == nil {
|
||||
t.Fatalf("request=%+v", detail.Request)
|
||||
}
|
||||
if detail.Verifier["BotID"] != "3003" || detail.Verifier["CompanyName"] != "Example Trust" || !detail.MarkActive {
|
||||
t.Fatalf("verifier=%+v markActive=%v", detail.Verifier, detail.MarkActive)
|
||||
}
|
||||
|
||||
// A verifier revoked since the application was filed must not turn the audit
|
||||
// record into a 500: the row is reported with only its id.
|
||||
svc.hasRow = false
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests/88", "scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("detail without a verifier status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &detail); err != nil {
|
||||
t.Fatalf("decode detail: %v", err)
|
||||
}
|
||||
if detail.Verifier["BotID"] != "3003" || detail.Verifier["Enabled"] != false {
|
||||
t.Fatalf("verifier=%+v, want the bot named and no status claimed", detail.Verifier)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests/89", "scoped-review", ""))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing application status=%d body=%s, want 404", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/counts", "scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("counts status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var counts struct {
|
||||
Counts map[string]string `json:"counts"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &counts); err != nil {
|
||||
t.Fatalf("decode counts: %v", err)
|
||||
}
|
||||
// Every modelled status is present so the panel never tells "zero" from
|
||||
// "absent", and the values are decimal strings.
|
||||
if counts.Counts["pending"] != "3" || counts.Counts["approved"] != "1" ||
|
||||
counts.Counts["rejected"] != "0" || counts.Counts["revoked"] != "0" || len(counts.Counts) != 4 {
|
||||
t.Fatalf("counts=%+v", counts.Counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationDecisionTakesTheRequestIDFromThePath(t *testing.T) {
|
||||
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 88, Version: 3}}
|
||||
srv := botVerificationServer(svc)
|
||||
// The body names a different application on purpose: the path has to win, or
|
||||
// the URL would lie to the audit trail.
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
|
||||
`{"command_id":"c1","actor":"alice","reason":"verified","request_id":99,"version":3,"internal_note":"handover"}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if svc.approve.RequestID != 88 || svc.approve.Version != 3 ||
|
||||
svc.approve.InternalNote != "handover" || svc.approve.Actor != "alice" {
|
||||
t.Fatalf("forwarded approval=%+v", svc.approve)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationDryRunIsForwardedAndEchoed(t *testing.T) {
|
||||
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 88, Version: 3}}
|
||||
srv := botVerificationServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/reject", "scoped-review",
|
||||
`{"command_id":"dry-1","actor":"alice","reason":"not an outlet","dry_run":true,"version":3}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !svc.reject.DryRun || svc.reject.Reason != "not an outlet" {
|
||||
t.Fatalf("forwarded rejection=%+v", svc.reject)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"dry_run":true`) {
|
||||
t.Fatalf("body=%s, want the dry run echoed", rec.Body.String())
|
||||
}
|
||||
|
||||
// Also on the manage half: appointing a verifier is rehearsable too.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/grant", "scoped-manage",
|
||||
`{"command_id":"dry-2","actor":"alice","reason":"partner","dry_run":true,
|
||||
"bot_id":3003,"icon_document_id":900,"company_name":"Example Trust","version":4}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("grant status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !svc.grant.DryRun || svc.grant.BotID != 3003 || svc.grant.IconDocumentID != 900 || svc.grant.Version != 4 {
|
||||
t.Fatalf("forwarded grant=%+v, want the exact int64s from decimal strings", svc.grant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationVersionConflictIsAnswered409(t *testing.T) {
|
||||
svc := &captureBotVerificationService{
|
||||
request: domain.CustomVerificationRequest{ID: 88, Version: 5},
|
||||
// The shape admin.codedError produces for a lost race.
|
||||
commandErr: fmt.Errorf("%s: %w", admin.CodeCustomVerificationConflict, domain.ErrCustomVerificationVersionConflict),
|
||||
}
|
||||
srv := botVerificationServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
|
||||
`{"command_id":"c1","actor":"alice","reason":"verified","version":4}`))
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status=%d body=%s, want 409 for a lost optimistic-locking race", rec.Code, rec.Body.String())
|
||||
}
|
||||
var result admin.CommandResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("decode conflict: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.Error, admin.CodeCustomVerificationConflict) {
|
||||
t.Fatalf("result=%+v, want the stable conflict code", result)
|
||||
}
|
||||
if !strings.Contains(result.Message, "reload") {
|
||||
t.Fatalf("result message=%q, want an actionable message", result.Message)
|
||||
}
|
||||
|
||||
// The same on the manage half, where two operators can race a verifier row.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/grant", "scoped-manage",
|
||||
`{"command_id":"c2","actor":"alice","reason":"partner","bot_id":3003,"icon_document_id":900,"company_name":"x","version":3}`))
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("grant conflict status=%d body=%s, want 409", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationErrorStatusMapping(t *testing.T) {
|
||||
cases := map[string]int{
|
||||
admin.CodeBotVerifierNotFound: http.StatusNotFound,
|
||||
admin.CodeBotVerifierBotNotFound: http.StatusNotFound,
|
||||
admin.CodeVerificationIconNotFound: http.StatusNotFound,
|
||||
admin.CodeCustomVerificationNotFound: http.StatusNotFound,
|
||||
admin.CodeCustomVerificationRequestNotFound: http.StatusNotFound,
|
||||
admin.CodeCustomVerificationConflict: http.StatusConflict,
|
||||
admin.CodeCustomVerificationLimit: http.StatusConflict,
|
||||
admin.CodeCustomVerificationRequestExists: http.StatusConflict,
|
||||
admin.CodeCustomVerificationRateLimited: http.StatusTooManyRequests,
|
||||
admin.CodeBotVerifierForbidden: http.StatusBadRequest,
|
||||
admin.CodeBotVerifierInvalid: http.StatusBadRequest,
|
||||
admin.CodeVerificationIconInactive: http.StatusBadRequest,
|
||||
admin.CodeVerificationIconInvalid: http.StatusBadRequest,
|
||||
admin.CodeCustomVerificationStatusInvalid: http.StatusBadRequest,
|
||||
admin.CodeCustomVerificationReasonRequired: http.StatusBadRequest,
|
||||
admin.CodeCustomVerificationTargetInvalid: http.StatusBadRequest,
|
||||
admin.CodeCustomVerificationInvalid: http.StatusBadRequest,
|
||||
"": http.StatusInternalServerError,
|
||||
}
|
||||
for code, want := range cases {
|
||||
if got := botVerificationErrorStatus(code); got != want {
|
||||
t.Fatalf("botVerificationErrorStatus(%q) = %d, want %d", code, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationActionsForwardTheirPayloads(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
svc := &captureBotVerificationService{}
|
||||
srv := botVerificationServer(svc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/set-enabled", "scoped-manage",
|
||||
`{"command_id":"c1","actor":"ops","reason":"abuse","bot_id":9223372036854775807,"enabled":false}`))
|
||||
if rec.Code != http.StatusOK || svc.setEnabled.BotID != maxInt64 || svc.setEnabled.Enabled {
|
||||
t.Fatalf("set-enabled status=%d req=%+v", rec.Code, svc.setEnabled)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/revoke", "scoped-manage",
|
||||
`{"command_id":"c2","actor":"ops","reason":"programme ended","bot_id":3003}`))
|
||||
if rec.Code != http.StatusOK || svc.revokeVerifier.BotID != 3003 {
|
||||
t.Fatalf("revoke-verifier status=%d req=%+v", rec.Code, svc.revokeVerifier)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/icons/upsert", "scoped-manage",
|
||||
`{"command_id":"c3","actor":"ops","reason":"new icon","document_id":9223372036854775807,"name":"blue check","owner_bot_id":3003}`))
|
||||
if rec.Code != http.StatusOK || svc.upsertIcon.DocumentID != maxInt64 ||
|
||||
svc.upsertIcon.Name != "blue check" || svc.upsertIcon.OwnerBotID != 3003 {
|
||||
t.Fatalf("upsert-icon status=%d req=%+v", rec.Code, svc.upsertIcon)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/icons/set-active", "scoped-manage",
|
||||
`{"command_id":"c4","actor":"ops","reason":"retired","icon_id":501,"active":false}`))
|
||||
if rec.Code != http.StatusOK || svc.setIconActive.IconID != 501 || svc.setIconActive.Active {
|
||||
t.Fatalf("set-icon-active status=%d req=%+v", rec.Code, svc.setIconActive)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/marks/revoke", "scoped-manage",
|
||||
`{"command_id":"c5","actor":"ops","reason":"impersonation","verifier_bot_id":3003,"peer_type":"channel","peer_id":9223372036854775807}`))
|
||||
if rec.Code != http.StatusOK || svc.revokeMark.VerifierBotID != 3003 ||
|
||||
svc.revokeMark.PeerType != domain.PeerTypeChannel || svc.revokeMark.PeerID != maxInt64 {
|
||||
t.Fatalf("revoke-mark status=%d req=%+v", rec.Code, svc.revokeMark)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/revoke", "scoped-review",
|
||||
`{"command_id":"c6","actor":"ops","reason":"licence withdrawn","version":9223372036854775807}`))
|
||||
if rec.Code != http.StatusOK || svc.revokeRequest.RequestID != 88 || svc.revokeRequest.Version != maxInt64 {
|
||||
t.Fatalf("revoke-request status=%d req=%+v", rec.Code, svc.revokeRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationScopedTokenNameBecomesTheAuditActor(t *testing.T) {
|
||||
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 88, Version: 3}}
|
||||
srv := botVerificationServer(svc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
|
||||
`{"command_id":"c1","reason":"queue sweep","version":3}`))
|
||||
if rec.Code != http.StatusOK || svc.approve.Actor != "queue-bot" {
|
||||
t.Fatalf("status=%d actor=%q, want the scoped token name", rec.Code, svc.approve.Actor)
|
||||
}
|
||||
|
||||
// A stated actor is never overwritten, which is how the panel attributes an
|
||||
// action to the signed-in operator.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
|
||||
`{"command_id":"c2","actor":"alice","reason":"queue sweep","version":3}`))
|
||||
if rec.Code != http.StatusOK || svc.approve.Actor != "alice" {
|
||||
t.Fatalf("status=%d actor=%q", rec.Code, svc.approve.Actor)
|
||||
}
|
||||
|
||||
// The master token has no name, so the caller keeps having to say who acts.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "master",
|
||||
`{"command_id":"c3","reason":"queue sweep","version":3}`))
|
||||
if rec.Code != http.StatusOK || svc.approve.Actor != "" {
|
||||
t.Fatalf("master token status=%d actor=%q, want no invented identity", rec.Code, svc.approve.Actor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationCommandsRejectUnknownFields(t *testing.T) {
|
||||
srv := botVerificationServer(&captureBotVerificationService{})
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/grant", "scoped-manage",
|
||||
`{"command_id":"c1","actor":"ops","reason":"x","bot_id":3003,"icon_document_id":900,"company_name":"y","enabled":true}`))
|
||||
// enabled is not part of the grant payload: the kill switch is its own action,
|
||||
// and a silently ignored field would hide that from the operator.
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "enabled") {
|
||||
t.Fatalf("status=%d body=%s, want 400 naming the unknown field", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationPermissionNamesAreDistinctFromTheOfficialOnes(t *testing.T) {
|
||||
// The permission model's whole point here: appointing verifiers is not implied
|
||||
// by reviewing the official queue, in either direction.
|
||||
bounded := newPermissionSet([]string{PermissionBotVerificationReview})
|
||||
if !bounded.Has(PermissionBotVerificationReview) {
|
||||
t.Fatal("bounded set dropped its own permission")
|
||||
}
|
||||
if bounded.Has(PermissionBotVerificationManage) || bounded.Has(PermissionVerificationReview) {
|
||||
t.Fatalf("botverification.review leaked into another right")
|
||||
}
|
||||
manage := newPermissionSet([]string{PermissionBotVerificationManage})
|
||||
if manage.Has(PermissionBotVerificationReview) {
|
||||
t.Fatal("botverification.manage implied the review right")
|
||||
}
|
||||
all := newPermissionSet([]string{PermissionAll})
|
||||
if !all.Has(PermissionBotVerificationReview) || !all.Has(PermissionBotVerificationManage) {
|
||||
t.Fatal("the wildcard refused a third-party permission")
|
||||
}
|
||||
}
|
||||
196
internal/adminapi/rbac.go
Normal file
196
internal/adminapi/rbac.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Admin API authorisation.
|
||||
//
|
||||
// Every request arrives with a bearer token, and the token decides which
|
||||
// permissions the request carries:
|
||||
//
|
||||
// - TELESRV_ADMIN_API_TOKEN is the master token and carries every permission.
|
||||
// This is what keeps the existing surface working unchanged: all the routes
|
||||
// that predate permissions stay mounted through authenticated(), which is
|
||||
// defined as "requires every permission", so the master token reaches them
|
||||
// exactly as before.
|
||||
// - A scoped token from TELESRV_ADMIN_SCOPED_TOKENS carries only the
|
||||
// permissions its entry lists. A scoped token is therefore *not* a weaker
|
||||
// master token: it authenticates successfully and is then refused with 403 on
|
||||
// anything outside its list, including every legacy route. Widening a scoped
|
||||
// token to the legacy surface would be a silent privilege escalation, so the
|
||||
// wildcard has to be spelled out in configuration to get it.
|
||||
//
|
||||
// The two-step answer matters for diagnosis: 401 means "I do not know this
|
||||
// token", 403 means "I know you and you may not do this".
|
||||
|
||||
// Permission names. They are the same strings the operator writes into
|
||||
// TELESRV_ADMIN_UI_PERMISSIONS / TELESRV_ADMIN_SCOPED_TOKENS.
|
||||
const (
|
||||
// PermissionAll is the wildcard: a principal carrying it passes every check.
|
||||
PermissionAll = "*"
|
||||
// PermissionVerificationReview guards the whole official-verification review
|
||||
// surface: the queue, one application, the counters, and the claim/approve/
|
||||
// reject decisions.
|
||||
PermissionVerificationReview = "verification.review"
|
||||
// PermissionVerificationRevoke is required *in addition* to
|
||||
// PermissionVerificationReview to clear a badge that was already granted.
|
||||
// Taking a badge away is visible to every client of a public peer, so it is
|
||||
// deliberately not implied by the right to review new applications.
|
||||
PermissionVerificationRevoke = "verification.revoke"
|
||||
// PermissionBotVerificationReview guards the third-party verification read
|
||||
// surface -- verifiers, icons, granted marks, the queue and its counters -- plus
|
||||
// the decisions on the applications filed with a verifier bot.
|
||||
//
|
||||
// This is NOT verification.review. Third-party verification is a separate
|
||||
// mechanism over separate tables (verification_icons, bot_verifier_settings,
|
||||
// custom_verifications, custom_verification_requests), so a token trusted to
|
||||
// work one queue is not thereby trusted with the other: neither permission
|
||||
// implies the other.
|
||||
PermissionBotVerificationReview = "botverification.review"
|
||||
// PermissionBotVerificationManage guards the configuration half: granting,
|
||||
// switching and revoking verifier status, the icon catalogue, and stripping a
|
||||
// granted mark.
|
||||
//
|
||||
// It is separate from the review right because these are the actions that
|
||||
// decide how much a third-party mark is worth. Handing out the queue is
|
||||
// routine; handing out the ability to appoint verifiers is not.
|
||||
PermissionBotVerificationManage = "botverification.manage"
|
||||
)
|
||||
|
||||
// CodeForbidden is the stable code for a permission failure, so the panel can
|
||||
// tell an authorisation refusal apart from a domain refusal.
|
||||
const CodeForbidden = "FORBIDDEN"
|
||||
|
||||
// ScopedToken is one bearer token restricted to a permission set. It mirrors
|
||||
// config.AdminScopedToken; the adminapi package keeps its own shape so it does
|
||||
// not depend on the configuration loader.
|
||||
type ScopedToken struct {
|
||||
// Name is the audit identity of actions performed with this token.
|
||||
Name string
|
||||
Token string
|
||||
Permissions []string
|
||||
}
|
||||
|
||||
// permissionSet is a resolved permission list.
|
||||
type permissionSet struct {
|
||||
all bool
|
||||
names map[string]struct{}
|
||||
}
|
||||
|
||||
func newPermissionSet(permissions []string) permissionSet {
|
||||
set := permissionSet{names: make(map[string]struct{}, len(permissions))}
|
||||
for _, permission := range permissions {
|
||||
permission = strings.TrimSpace(permission)
|
||||
if permission == "" {
|
||||
continue
|
||||
}
|
||||
if permission == PermissionAll {
|
||||
set.all = true
|
||||
continue
|
||||
}
|
||||
set.names[permission] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// Has reports whether the set grants the permission.
|
||||
func (p permissionSet) Has(permission string) bool {
|
||||
if p.all {
|
||||
return true
|
||||
}
|
||||
_, ok := p.names[permission]
|
||||
return ok
|
||||
}
|
||||
|
||||
// principal is the authenticated caller.
|
||||
type principal struct {
|
||||
// name is the scoped token's audit identity, or "" for the master token,
|
||||
// whose actions are attributed by the actor the caller states in the body.
|
||||
name string
|
||||
permissions permissionSet
|
||||
}
|
||||
|
||||
type principalKey struct{}
|
||||
|
||||
// principalName returns the scoped-token identity behind the request, or "" when
|
||||
// the request came in on the master token.
|
||||
func principalName(ctx context.Context) string {
|
||||
if p, ok := ctx.Value(principalKey{}).(principal); ok {
|
||||
return p.name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// principalFor resolves the bearer token to a principal.
|
||||
//
|
||||
// Every configured token is compared, and every comparison is constant time and
|
||||
// unconditional: returning as soon as one matches would leak, through timing,
|
||||
// which token position a guess collided with.
|
||||
func (s *Server) principalFor(r *http.Request) (principal, bool) {
|
||||
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
if got == "" {
|
||||
return principal{}, false
|
||||
}
|
||||
matched := false
|
||||
resolved := principal{}
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) == 1 && s.token != "" {
|
||||
matched = true
|
||||
resolved = principal{permissions: permissionSet{all: true}}
|
||||
}
|
||||
for _, scoped := range s.scoped {
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(scoped.Token)) == 1 && scoped.Token != "" && !matched {
|
||||
matched = true
|
||||
resolved = principal{name: scoped.Name, permissions: newPermissionSet(scoped.Permissions)}
|
||||
}
|
||||
}
|
||||
return resolved, matched
|
||||
}
|
||||
|
||||
// authenticated guards a route that requires unrestricted rights.
|
||||
//
|
||||
// This is every route that predates the permission model. Keeping them here is
|
||||
// the documented behaviour: the master token carries every permission, so nothing
|
||||
// about the existing surface changes, while a bounded scoped token cannot use one
|
||||
// of them as a side door.
|
||||
func (s *Server) authenticated(next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.authorized(PermissionAll, next)
|
||||
}
|
||||
|
||||
// authorized guards a route behind one permission.
|
||||
func (s *Server) authorized(permission string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.authorizedAll([]string{permission}, next)
|
||||
}
|
||||
|
||||
// authorizedAll guards a route behind every listed permission. Revocation uses it
|
||||
// to require the review right and the revoke right together, so the revoke right
|
||||
// alone cannot be handed out as a way into the review surface.
|
||||
func (s *Server) authorizedAll(permissions []string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := s.principalFor(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
for _, permission := range permissions {
|
||||
if !caller.permissions.Has(permission) {
|
||||
writeForbidden(w, permission)
|
||||
return
|
||||
}
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), principalKey{}, caller)))
|
||||
}
|
||||
}
|
||||
|
||||
// writeForbidden names the missing permission, so an operator configuring a
|
||||
// scoped token is told what to add instead of having to guess.
|
||||
func writeForbidden(w http.ResponseWriter, permission string) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"error": "permission " + permission + " is required",
|
||||
"code": CodeForbidden,
|
||||
"permission": permission,
|
||||
})
|
||||
}
|
||||
|
|
@ -2,12 +2,13 @@ package adminapi
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
361
internal/adminapi/verification.go
Normal file
361
internal/adminapi/verification.go
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
package adminapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Official platform verification review over the admin API.
|
||||
//
|
||||
// These are the mirror routes of the panel's own endpoints: the panel reads the
|
||||
// queue straight from PostgreSQL for speed, while an integration holding a scoped
|
||||
// token reads it here. Decisions only ever travel this way, so the command
|
||||
// journal and the status machine are enforced in one place.
|
||||
|
||||
// handleVerificationApplications is the review queue.
|
||||
func (s *Server) handleVerificationApplications(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
filter := domain.VerificationApplicationFilter{
|
||||
TargetType: domain.VerificationTargetType(strings.TrimSpace(query.Get("target_type"))),
|
||||
Reviewer: strings.TrimSpace(query.Get("reviewer")),
|
||||
Query: query.Get("q"),
|
||||
}
|
||||
if filter.TargetType != "" && !filter.TargetType.Valid() {
|
||||
writeCodedError(w, http.StatusBadRequest, admin.CodeVerificationTargetInvalid, "invalid target_type")
|
||||
return
|
||||
}
|
||||
// status accepts a comma-separated list, so the queue view ("submitted,
|
||||
// in_review") is one request rather than two.
|
||||
for _, raw := range strings.Split(query.Get("status"), ",") {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
status := domain.VerificationStatus(raw)
|
||||
if !status.Valid() {
|
||||
writeCodedError(w, http.StatusBadRequest, admin.CodeVerificationStatusInvalid, "invalid status "+raw)
|
||||
return
|
||||
}
|
||||
filter.Statuses = append(filter.Statuses, status)
|
||||
}
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.Limit = limit
|
||||
beforeID, ok := optionalQueryInt64(w, query, "before_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.BeforeID = beforeID
|
||||
items, err := s.svc.VerificationApplications(r.Context(), filter)
|
||||
if err != nil {
|
||||
writeVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
applications := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
applications = append(applications, verificationApplicationResponse(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"applications": applications})
|
||||
}
|
||||
|
||||
// handleVerificationApplication is one application with its history and the
|
||||
// target as it looks right now.
|
||||
func (s *Server) handleVerificationApplication(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
app, err := s.svc.VerificationApplication(r.Context(), id)
|
||||
if err != nil {
|
||||
writeVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
limit, ok := optionalQueryInt(w, r.URL.Query(), "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
events, err := s.svc.VerificationApplicationEvents(r.Context(), app.ID, limit)
|
||||
if err != nil {
|
||||
writeVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
history := make([]map[string]any, 0, len(events))
|
||||
for _, event := range events {
|
||||
history = append(history, verificationEventResponse(event))
|
||||
}
|
||||
body := map[string]any{
|
||||
"application": verificationApplicationResponse(app),
|
||||
"events": history,
|
||||
}
|
||||
// The snapshot is advisory: a target that vanished must not turn the audit
|
||||
// record into a 500, so a snapshot failure is reported next to the record
|
||||
// instead of replacing it.
|
||||
if target, err := s.svc.VerificationTargetSnapshot(r.Context(), app.TargetType, app.TargetID); err == nil {
|
||||
body["target"] = verificationTargetResponse(target)
|
||||
} else {
|
||||
body["target_error"] = err.Error()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// handleVerificationCounts is the queue summary.
|
||||
func (s *Server) handleVerificationCounts(w http.ResponseWriter, r *http.Request) {
|
||||
counts, err := s.svc.VerificationCounts(r.Context())
|
||||
if err != nil {
|
||||
writeVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"counts": verificationCountsResponse(counts)})
|
||||
}
|
||||
|
||||
func (s *Server) handleClaimVerification(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req admin.ClaimVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
// The path is the authority on which application is decided: a body naming a
|
||||
// different one would make the URL lie to the audit trail.
|
||||
req.ApplicationID = id
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.ClaimVerification(r.Context(), req)
|
||||
writeVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleApproveVerification(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req admin.ApproveVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.ApplicationID = id
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.ApproveVerification(r.Context(), req)
|
||||
writeVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRejectVerification(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req admin.RejectVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.ApplicationID = id
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.RejectVerification(r.Context(), req)
|
||||
writeVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeVerification(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.RevokeVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.RevokeVerification(r.Context(), req)
|
||||
writeVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
// applyVerificationPrincipal fills in the audit actor for a scoped token that did
|
||||
// not state one.
|
||||
//
|
||||
// A scoped token's configured name *is* its audit identity, so an integration
|
||||
// does not have to invent an actor string. The master token has no name, so a
|
||||
// caller using it keeps having to state who is acting -- which is what the panel
|
||||
// does with the signed-in operator.
|
||||
func (s *Server) applyVerificationPrincipal(r *http.Request, meta *admin.CommandMeta) {
|
||||
if strings.TrimSpace(meta.Actor) != "" {
|
||||
return
|
||||
}
|
||||
if name := principalName(r.Context()); name != "" {
|
||||
meta.Actor = name
|
||||
}
|
||||
}
|
||||
|
||||
// verificationApplicationResponse renders one application. Every int64 crosses
|
||||
// the JSON boundary as a decimal string: application ids, peer ids and the
|
||||
// optimistic-locking version exceed the range a JSON number holds exactly, and a
|
||||
// rounded id would decide the wrong application.
|
||||
func verificationApplicationResponse(app domain.VerificationApplication) map[string]any {
|
||||
out := map[string]any{
|
||||
"id": strconv.FormatInt(app.ID, 10),
|
||||
"applicant_user_id": strconv.FormatInt(app.ApplicantUserID, 10),
|
||||
"target_type": string(app.TargetType),
|
||||
"target_id": strconv.FormatInt(app.TargetID, 10),
|
||||
"target_title": app.TargetTitle,
|
||||
"target_username": app.TargetUsername,
|
||||
"category": app.Category,
|
||||
"description": app.Description,
|
||||
"official_website": app.OfficialWebsite,
|
||||
"social_links": stringList(app.SocialLinks),
|
||||
"press_links": stringList(app.PressLinks),
|
||||
"additional_note": app.AdditionalNote,
|
||||
"status": string(app.Status),
|
||||
"reviewer_admin_id": app.ReviewerAdminID,
|
||||
"decision_reason": app.DecisionReason,
|
||||
// internal_note is operator-only. It is exposed here because every caller
|
||||
// of this route already holds verification.review, and it is the reviewer's
|
||||
// own handover note; it is never part of the applicant-facing projection.
|
||||
"internal_note": app.InternalNote,
|
||||
"correlation_id": app.CorrelationID,
|
||||
"version": strconv.FormatInt(app.Version, 10),
|
||||
}
|
||||
if !app.CreatedAt.IsZero() {
|
||||
out["created_at"] = app.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !app.UpdatedAt.IsZero() {
|
||||
out["updated_at"] = app.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !app.SubmittedAt.IsZero() {
|
||||
out["submitted_at"] = app.SubmittedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !app.ReviewedAt.IsZero() {
|
||||
out["reviewed_at"] = app.ReviewedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func verificationEventResponse(event domain.VerificationApplicationEvent) map[string]any {
|
||||
out := map[string]any{
|
||||
"id": strconv.FormatInt(event.ID, 10),
|
||||
"application_id": strconv.FormatInt(event.ApplicationID, 10),
|
||||
"kind": string(event.Kind),
|
||||
"from_status": string(event.FromStatus),
|
||||
"to_status": string(event.ToStatus),
|
||||
"actor": event.Actor,
|
||||
"reason": event.Reason,
|
||||
"note": event.Note,
|
||||
"correlation_id": event.CorrelationID,
|
||||
}
|
||||
if !event.CreatedAt.IsZero() {
|
||||
out["created_at"] = event.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func verificationTargetResponse(target domain.VerificationTarget) map[string]any {
|
||||
return map[string]any{
|
||||
"type": string(target.Type),
|
||||
"id": strconv.FormatInt(target.ID, 10),
|
||||
"title": target.Title,
|
||||
"username": target.Username,
|
||||
"verified": target.Verified,
|
||||
"eligible": target.Eligible,
|
||||
"reason": target.Reason,
|
||||
}
|
||||
}
|
||||
|
||||
// verificationCountsResponse renders the queue summary with every modelled status
|
||||
// present, so the panel never has to distinguish "zero" from "absent". The values
|
||||
// are decimal strings for the same exactness reason as the ids.
|
||||
func verificationCountsResponse(counts domain.VerificationStatusCounts) map[string]string {
|
||||
out := make(map[string]string, len(verificationStatusOrder))
|
||||
for _, status := range verificationStatusOrder {
|
||||
out[string(status)] = strconv.FormatInt(counts[status], 10)
|
||||
}
|
||||
for status, count := range counts {
|
||||
if _, ok := out[string(status)]; !ok {
|
||||
out[string(status)] = strconv.FormatInt(count, 10)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// verificationStatusOrder is the closed status set, in lifecycle order.
|
||||
var verificationStatusOrder = []domain.VerificationStatus{
|
||||
domain.VerificationStatusDraft,
|
||||
domain.VerificationStatusSubmitted,
|
||||
domain.VerificationStatusInReview,
|
||||
domain.VerificationStatusApproved,
|
||||
domain.VerificationStatusRejected,
|
||||
domain.VerificationStatusCancelled,
|
||||
}
|
||||
|
||||
// stringList normalises a nil slice to an empty JSON array, so the panel can
|
||||
// iterate without a null check.
|
||||
func stringList(items []string) []string {
|
||||
if items == nil {
|
||||
return []string{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// verificationErrorStatus maps a verification failure onto its HTTP status.
|
||||
//
|
||||
// The version conflict is the interesting one: it is 409, not 400, because
|
||||
// nothing about the request was wrong -- another reviewer simply decided first,
|
||||
// and the panel has to answer that by reloading rather than by correcting input.
|
||||
func verificationErrorStatus(code string) int {
|
||||
switch code {
|
||||
case admin.CodeVerificationNotFound:
|
||||
return http.StatusNotFound
|
||||
case admin.CodeVerificationConflict,
|
||||
admin.CodeVerificationTargetOccupied,
|
||||
admin.CodeVerificationTargetVerified:
|
||||
return http.StatusConflict
|
||||
case admin.CodeVerificationStatusInvalid,
|
||||
admin.CodeVerificationReasonRequired,
|
||||
admin.CodeVerificationTargetInvalid,
|
||||
admin.CodeVerificationTargetNotPublic,
|
||||
admin.CodeVerificationTargetRestricted,
|
||||
admin.CodeVerificationTargetSystem,
|
||||
admin.CodeVerificationNotOwner,
|
||||
admin.CodeVerificationUserTargetsDisabled,
|
||||
admin.CodeVerificationInvalid:
|
||||
return http.StatusBadRequest
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
func writeVerificationError(w http.ResponseWriter, err error) {
|
||||
code := admin.VerificationErrorCode(err)
|
||||
writeCodedError(w, verificationErrorStatus(code), code, err.Error())
|
||||
}
|
||||
|
||||
// writeVerificationCommandResult answers a decision.
|
||||
//
|
||||
// The body stays a CommandResult so the panel parses one shape for every
|
||||
// operator action, but the status is derived from the failure: a lost
|
||||
// optimistic-locking race must reach the browser as 409, because that is the one
|
||||
// failure the panel resolves by reloading the application instead of by asking
|
||||
// the operator to fix the form.
|
||||
func writeVerificationCommandResult(w http.ResponseWriter, result admin.CommandResult, err error) {
|
||||
if err == nil {
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
code := admin.VerificationErrorCode(err)
|
||||
status := verificationErrorStatus(code)
|
||||
if status == http.StatusInternalServerError {
|
||||
// An unmapped command failure is a bad request, as everywhere else in this
|
||||
// API, rather than a server fault.
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
if result.CommandID == "" {
|
||||
result = admin.CommandResult{Status: "failed", Message: "command failed", Error: err.Error()}
|
||||
}
|
||||
if result.Error == "" {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
if code == admin.CodeVerificationConflict {
|
||||
result.Message = "another reviewer changed this application first; reload it and decide again"
|
||||
}
|
||||
writeJSON(w, status, result)
|
||||
}
|
||||
546
internal/adminapi/verification_test.go
Normal file
546
internal/adminapi/verification_test.go
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// fakeService gains the verification surface here so the shared fake keeps
|
||||
// satisfying Service without touching the existing test file.
|
||||
|
||||
func (fakeService) ClaimVerification(_ context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) ApproveVerification(_ context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) RejectVerification(_ context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) RevokeVerification(_ context.Context, req admin.RevokeVerificationRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) VerificationApplications(context.Context, domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) VerificationApplication(context.Context, int64) (domain.VerificationApplication, error) {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
||||
}
|
||||
|
||||
func (fakeService) VerificationApplicationEvents(context.Context, int64, int) ([]domain.VerificationApplicationEvent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) VerificationCounts(context.Context) (domain.VerificationStatusCounts, error) {
|
||||
return domain.VerificationStatusCounts{}, nil
|
||||
}
|
||||
|
||||
func (fakeService) VerificationTargetSnapshot(context.Context, domain.VerificationTargetType, int64) (domain.VerificationTarget, error) {
|
||||
return domain.VerificationTarget{}, nil
|
||||
}
|
||||
|
||||
type captureVerificationService struct {
|
||||
fakeService
|
||||
filter domain.VerificationApplicationFilter
|
||||
claim admin.ClaimVerificationRequest
|
||||
approve admin.ApproveVerificationRequest
|
||||
reject admin.RejectVerificationRequest
|
||||
revoke admin.RevokeVerificationRequest
|
||||
app domain.VerificationApplication
|
||||
events []domain.VerificationApplicationEvent
|
||||
counts domain.VerificationStatusCounts
|
||||
target domain.VerificationTarget
|
||||
decideOn error
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) VerificationApplications(_ context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
|
||||
s.filter = filter
|
||||
return []domain.VerificationApplication{s.app}, nil
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) VerificationApplication(_ context.Context, applicationID int64) (domain.VerificationApplication, error) {
|
||||
if s.app.ID != applicationID {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
||||
}
|
||||
return s.app, nil
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) VerificationApplicationEvents(context.Context, int64, int) ([]domain.VerificationApplicationEvent, error) {
|
||||
return s.events, nil
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) VerificationCounts(context.Context) (domain.VerificationStatusCounts, error) {
|
||||
return s.counts, nil
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) VerificationTargetSnapshot(context.Context, domain.VerificationTargetType, int64) (domain.VerificationTarget, error) {
|
||||
return s.target, nil
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) ClaimVerification(_ context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error) {
|
||||
s.claim = req
|
||||
if s.decideOn != nil {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "failed", Error: s.decideOn.Error()}, s.decideOn
|
||||
}
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) ApproveVerification(_ context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error) {
|
||||
s.approve = req
|
||||
if s.decideOn != nil {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "failed", Error: s.decideOn.Error()}, s.decideOn
|
||||
}
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) RejectVerification(_ context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error) {
|
||||
s.reject = req
|
||||
if s.decideOn != nil {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "failed", Error: s.decideOn.Error()}, s.decideOn
|
||||
}
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) RevokeVerification(_ context.Context, req admin.RevokeVerificationRequest) (admin.CommandResult, error) {
|
||||
s.revoke = req
|
||||
if s.decideOn != nil {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "failed", Error: s.decideOn.Error()}, s.decideOn
|
||||
}
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
// reviewOnlyServer is the deployment shape the permission model exists for: one
|
||||
// unrestricted master token plus two bounded tokens, one able to review and one
|
||||
// able to review and revoke.
|
||||
func reviewOnlyServer(svc Service) *Server {
|
||||
return &Server{
|
||||
token: "master",
|
||||
scoped: []ScopedToken{
|
||||
{Name: "queue-bot", Token: "scoped-review", Permissions: []string{PermissionVerificationReview}},
|
||||
{Name: "trust-and-safety", Token: "scoped-revoke", Permissions: []string{
|
||||
PermissionVerificationReview, PermissionVerificationRevoke,
|
||||
}},
|
||||
{Name: "gift-importer", Token: "scoped-other", Permissions: []string{"gifts.import"}},
|
||||
},
|
||||
svc: svc,
|
||||
}
|
||||
}
|
||||
|
||||
func verificationRequest(method, path, token, body string) *http.Request {
|
||||
var req *http.Request
|
||||
if body == "" {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestVerificationRoutesRejectMissingAndUnknownTokens(t *testing.T) {
|
||||
srv := reviewOnlyServer(fakeService{})
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{http.MethodGet, "/v1/verification/applications", ""},
|
||||
{http.MethodGet, "/v1/verification/applications/7", ""},
|
||||
{http.MethodGet, "/v1/verification/counts", ""},
|
||||
{http.MethodPost, "/v1/verification/applications/7/claim", `{}`},
|
||||
{http.MethodPost, "/v1/verification/applications/7/approve", `{}`},
|
||||
{http.MethodPost, "/v1/verification/applications/7/reject", `{}`},
|
||||
{http.MethodPost, "/v1/verification/revoke", `{}`},
|
||||
}
|
||||
for _, item := range cases {
|
||||
for _, token := range []string{"", "not-a-configured-token"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s %s token=%q status=%d, want 401", item.method, item.path, token, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationRoutesRefuseScopedTokenWithoutThePermission(t *testing.T) {
|
||||
srv := reviewOnlyServer(fakeService{})
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{http.MethodGet, "/v1/verification/applications", ""},
|
||||
{http.MethodGet, "/v1/verification/counts", ""},
|
||||
{http.MethodPost, "/v1/verification/applications/7/claim", `{}`},
|
||||
}
|
||||
for _, item := range cases {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "scoped-other", item.body))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s %s status=%d body=%s, want 403", item.method, item.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode 403 body: %v", err)
|
||||
}
|
||||
if body["code"] != CodeForbidden || body["permission"] != PermissionVerificationReview {
|
||||
t.Fatalf("403 body=%+v, want the missing permission named", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationRevokeNeedsTheRevokePermissionOnTopOfReview(t *testing.T) {
|
||||
svc := &captureVerificationService{}
|
||||
srv := reviewOnlyServer(svc)
|
||||
|
||||
// A review-only token reaches the queue but not the revocation.
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/counts", "scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("review token on counts status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/revoke", "scoped-review",
|
||||
`{"command_id":"c1","actor":"ops","reason":"impersonation","target_type":"channel","target_id":5005}`))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("review token on revoke status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode 403 body: %v", err)
|
||||
}
|
||||
if body["permission"] != PermissionVerificationRevoke {
|
||||
t.Fatalf("403 body=%+v, want verification.revoke named", body)
|
||||
}
|
||||
|
||||
// The token that carries both rights gets through.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/revoke", "scoped-revoke",
|
||||
`{"command_id":"c2","actor":"ops","reason":"impersonation","target_type":"channel","target_id":5005}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("revoke token status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if svc.revoke.TargetType != domain.VerificationTargetChannel || svc.revoke.TargetID != 5005 ||
|
||||
svc.revoke.Reason != "impersonation" {
|
||||
t.Fatalf("forwarded revocation=%+v", svc.revoke)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasterTokenKeepsEveryPermissionIncludingTheLegacySurface(t *testing.T) {
|
||||
svc := &captureVerificationService{app: domain.VerificationApplication{ID: 7, Version: 2}}
|
||||
srv := reviewOnlyServer(svc)
|
||||
|
||||
// The new permissioned routes.
|
||||
for _, path := range []string{"/v1/verification/applications", "/v1/verification/counts"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, path, "master", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("master token on %s status=%d body=%s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/revoke", "master",
|
||||
`{"command_id":"c1","actor":"ops","reason":"impersonation","target_type":"bot","target_id":2002}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("master token on revoke status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// And every route that predates permissions.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/accounts/set-verified", "master",
|
||||
`{"command_id":"c2","actor":"ops","reason":"official","dry_run":true,"user_id":1001,"verified":true}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("master token on the legacy surface status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A bounded token must not inherit the routes that predate the permission model:
|
||||
// that would turn "give the queue bot the review right" into "give it everything".
|
||||
func TestScopedTokenCannotUseTheLegacySurfaceAsASideDoor(t *testing.T) {
|
||||
srv := reviewOnlyServer(fakeService{})
|
||||
for _, path := range []string{"/v1/accounts/set-verified", "/v1/accounts/set-frozen", "/v1/bots/delete"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, path, "scoped-review",
|
||||
`{"command_id":"c1","actor":"ops","reason":"x","user_id":1001,"verified":true}`))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("scoped token on %s status=%d body=%s, want 403", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// A scoped token that spells out the wildcard is the operator's explicit
|
||||
// choice and does reach it.
|
||||
wide := &Server{
|
||||
token: "master",
|
||||
scoped: []ScopedToken{{Name: "everything", Token: "scoped-all", Permissions: []string{PermissionAll}}},
|
||||
svc: fakeService{},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
wide.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/accounts/set-verified", "scoped-all",
|
||||
`{"command_id":"c1","actor":"ops","reason":"x","dry_run":true,"user_id":1001,"verified":true}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("wildcard scoped token status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationQueueFilterAndInt64Rendering(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
svc := &captureVerificationService{app: domain.VerificationApplication{
|
||||
ID: maxInt64,
|
||||
ApplicantUserID: maxInt64,
|
||||
TargetType: domain.VerificationTargetChannel,
|
||||
TargetID: maxInt64,
|
||||
TargetTitle: "Example News",
|
||||
TargetUsername: "examplenews",
|
||||
Category: "media",
|
||||
Status: domain.VerificationStatusSubmitted,
|
||||
SocialLinks: []string{"https://example.test/social"},
|
||||
Version: maxInt64,
|
||||
CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
|
||||
}}
|
||||
srv := reviewOnlyServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(
|
||||
http.MethodGet,
|
||||
"/v1/verification/applications?status=submitted,in_review&target_type=channel&reviewer=alice&q=examplenews&limit=25&before_id=99",
|
||||
"scoped-review", "",
|
||||
))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(svc.filter.Statuses) != 2 ||
|
||||
svc.filter.Statuses[0] != domain.VerificationStatusSubmitted ||
|
||||
svc.filter.Statuses[1] != domain.VerificationStatusInReview ||
|
||||
svc.filter.TargetType != domain.VerificationTargetChannel ||
|
||||
svc.filter.Reviewer != "alice" || svc.filter.Query != "examplenews" ||
|
||||
svc.filter.Limit != 25 || svc.filter.BeforeID != 99 {
|
||||
t.Fatalf("filter=%+v", svc.filter)
|
||||
}
|
||||
var body struct {
|
||||
Applications []map[string]any `json:"applications"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode queue: %v", err)
|
||||
}
|
||||
if len(body.Applications) != 1 {
|
||||
t.Fatalf("applications=%+v", body.Applications)
|
||||
}
|
||||
for _, field := range []string{"id", "applicant_user_id", "target_id", "version"} {
|
||||
if body.Applications[0][field] != "9223372036854775807" {
|
||||
t.Fatalf("%s = %#v, want an exact decimal string", field, body.Applications[0][field])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationQueueRejectsUnmodelledFilters(t *testing.T) {
|
||||
srv := reviewOnlyServer(fakeService{})
|
||||
for _, query := range []string{"?status=pending", "?target_type=group"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/applications"+query, "scoped-review", ""))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s status=%d body=%s, want 400", query, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationApplicationDetailAndCounts(t *testing.T) {
|
||||
svc := &captureVerificationService{
|
||||
app: domain.VerificationApplication{ID: 7, TargetType: domain.VerificationTargetBot, TargetID: 2002, Version: 4},
|
||||
events: []domain.VerificationApplicationEvent{{
|
||||
ID: 11, ApplicationID: 7, Kind: domain.VerificationEventSubmitted,
|
||||
ToStatus: domain.VerificationStatusSubmitted, CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
|
||||
}},
|
||||
target: domain.VerificationTarget{Type: domain.VerificationTargetBot, ID: 2002, Verified: true, Eligible: false, Reason: "already verified"},
|
||||
counts: domain.VerificationStatusCounts{domain.VerificationStatusSubmitted: 3},
|
||||
}
|
||||
srv := reviewOnlyServer(svc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/applications/7", "scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("detail status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var detail struct {
|
||||
Application map[string]any `json:"application"`
|
||||
Events []map[string]any `json:"events"`
|
||||
Target map[string]any `json:"target"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &detail); err != nil {
|
||||
t.Fatalf("decode detail: %v", err)
|
||||
}
|
||||
if detail.Application["id"] != "7" || len(detail.Events) != 1 || detail.Events[0]["id"] != "11" {
|
||||
t.Fatalf("detail=%+v", detail)
|
||||
}
|
||||
if detail.Target["verified"] != true || detail.Target["eligible"] != false {
|
||||
t.Fatalf("target=%+v, want the live snapshot alongside the record", detail.Target)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/applications/8", "scoped-review", ""))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing application status=%d body=%s, want 404", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/counts", "scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("counts status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var counts struct {
|
||||
Counts map[string]string `json:"counts"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &counts); err != nil {
|
||||
t.Fatalf("decode counts: %v", err)
|
||||
}
|
||||
// Every modelled status is present so the panel never tells "zero" from
|
||||
// "absent", and the values are decimal strings.
|
||||
if counts.Counts["submitted"] != "3" || counts.Counts["draft"] != "0" ||
|
||||
counts.Counts["cancelled"] != "0" || len(counts.Counts) != 6 {
|
||||
t.Fatalf("counts=%+v", counts.Counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationDecisionTakesTheApplicationIDFromThePath(t *testing.T) {
|
||||
svc := &captureVerificationService{app: domain.VerificationApplication{ID: 7, Version: 4}}
|
||||
srv := reviewOnlyServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
// The body names a different application on purpose: the path has to win, or
|
||||
// the URL would lie to the audit trail.
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/approve", "scoped-review",
|
||||
`{"command_id":"c1","actor":"alice","reason":"verified","application_id":99,"version":4,"internal_note":"handover"}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if svc.approve.ApplicationID != 7 || svc.approve.Version != 4 ||
|
||||
svc.approve.InternalNote != "handover" || svc.approve.Actor != "alice" {
|
||||
t.Fatalf("forwarded approval=%+v", svc.approve)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationDecisionDryRunIsForwarded(t *testing.T) {
|
||||
svc := &captureVerificationService{app: domain.VerificationApplication{ID: 7, Version: 4}}
|
||||
srv := reviewOnlyServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/reject", "scoped-review",
|
||||
`{"command_id":"dry-1","actor":"alice","reason":"press links are self-published","dry_run":true,"version":4}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !svc.reject.DryRun || svc.reject.Reason != "press links are self-published" {
|
||||
t.Fatalf("forwarded rejection=%+v", svc.reject)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"dry_run":true`) {
|
||||
t.Fatalf("body=%s, want the dry run echoed", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationVersionConflictIsAnswered409(t *testing.T) {
|
||||
svc := &captureVerificationService{
|
||||
app: domain.VerificationApplication{ID: 7, Version: 5},
|
||||
// The shape admin.codedError produces for a lost race.
|
||||
decideOn: fmt.Errorf("%s: %w", admin.CodeVerificationConflict, domain.ErrVerificationVersionConflict),
|
||||
}
|
||||
srv := reviewOnlyServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/approve", "scoped-review",
|
||||
`{"command_id":"c1","actor":"alice","reason":"verified","version":4}`))
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status=%d body=%s, want 409 for a lost optimistic-locking race", rec.Code, rec.Body.String())
|
||||
}
|
||||
var result admin.CommandResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("decode conflict: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.Error, admin.CodeVerificationConflict) {
|
||||
t.Fatalf("result=%+v, want the stable conflict code", result)
|
||||
}
|
||||
if !strings.Contains(result.Message, "reload") {
|
||||
t.Fatalf("result message=%q, want an actionable message", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationErrorStatusMapping(t *testing.T) {
|
||||
cases := map[string]int{
|
||||
admin.CodeVerificationNotFound: http.StatusNotFound,
|
||||
admin.CodeVerificationConflict: http.StatusConflict,
|
||||
admin.CodeVerificationTargetOccupied: http.StatusConflict,
|
||||
admin.CodeVerificationTargetVerified: http.StatusConflict,
|
||||
admin.CodeVerificationStatusInvalid: http.StatusBadRequest,
|
||||
admin.CodeVerificationReasonRequired: http.StatusBadRequest,
|
||||
admin.CodeVerificationTargetInvalid: http.StatusBadRequest,
|
||||
admin.CodeVerificationTargetRestricted: http.StatusBadRequest,
|
||||
admin.CodeVerificationTargetSystem: http.StatusBadRequest,
|
||||
admin.CodeVerificationNotOwner: http.StatusBadRequest,
|
||||
admin.CodeVerificationInvalid: http.StatusBadRequest,
|
||||
"": http.StatusInternalServerError,
|
||||
}
|
||||
for code, want := range cases {
|
||||
if got := verificationErrorStatus(code); got != want {
|
||||
t.Fatalf("verificationErrorStatus(%q) = %d, want %d", code, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedTokenNameBecomesTheAuditActorWhenNoneIsStated(t *testing.T) {
|
||||
svc := &captureVerificationService{app: domain.VerificationApplication{ID: 7, Version: 4}}
|
||||
srv := reviewOnlyServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/claim", "scoped-review",
|
||||
`{"command_id":"c1","reason":"queue sweep","version":4}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// The scoped token's configured name is its audit identity.
|
||||
if svc.claim.Actor != "queue-bot" {
|
||||
t.Fatalf("actor=%q, want the scoped token name", svc.claim.Actor)
|
||||
}
|
||||
|
||||
// A stated actor is never overwritten, which is how the panel attributes an
|
||||
// action to the signed-in operator.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/claim", "scoped-review",
|
||||
`{"command_id":"c2","actor":"alice","reason":"queue sweep","version":4}`))
|
||||
if rec.Code != http.StatusOK || svc.claim.Actor != "alice" {
|
||||
t.Fatalf("status=%d actor=%q", rec.Code, svc.claim.Actor)
|
||||
}
|
||||
|
||||
// The master token has no name, so the caller keeps having to say who acts.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/claim", "master",
|
||||
`{"command_id":"c3","reason":"queue sweep","version":4}`))
|
||||
if rec.Code != http.StatusOK || svc.claim.Actor != "" {
|
||||
t.Fatalf("master token status=%d actor=%q, want no invented identity", rec.Code, svc.claim.Actor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionSetWildcardAndMembership(t *testing.T) {
|
||||
all := newPermissionSet([]string{PermissionAll})
|
||||
if !all.Has(PermissionVerificationReview) || !all.Has("anything.at.all") {
|
||||
t.Fatal("wildcard set refused a permission")
|
||||
}
|
||||
bounded := newPermissionSet([]string{" verification.review ", ""})
|
||||
if !bounded.Has(PermissionVerificationReview) {
|
||||
t.Fatal("bounded set dropped a padded permission")
|
||||
}
|
||||
if bounded.Has(PermissionVerificationRevoke) {
|
||||
t.Fatal("bounded set granted an unlisted permission")
|
||||
}
|
||||
if newPermissionSet(nil).Has(PermissionVerificationReview) {
|
||||
t.Fatal("empty set granted a permission")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import (
|
|||
|
||||
const (
|
||||
passwordHashSize = 256
|
||||
recoveryCodeTTL = 15 * 60
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
53
internal/app/authdiagnostics/service.go
Normal file
53
internal/app/authdiagnostics/service.go
Normal 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)
|
||||
}
|
||||
81
internal/app/authdiagnostics/service_test.go
Normal file
81
internal/app/authdiagnostics/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -74,15 +74,26 @@ You can control me by sending these commands:
|
|||
/cancel - cancel the current operation
|
||||
/help - show this message`
|
||||
|
||||
// botReply 是 BotFather 的一条回复。
|
||||
// botReply 是内置 service bot 的一条回复。ReplyMarkup 为可选 inline keyboard
|
||||
// 快照(@verifybot 的按钮式对话使用);落库前经 domain.ValidateReplyMarkup 校验。
|
||||
type botReply struct {
|
||||
Text string
|
||||
Entities []domain.MessageEntity
|
||||
Text string
|
||||
Entities []domain.MessageEntity
|
||||
ReplyMarkup *domain.MessageReplyMarkup
|
||||
}
|
||||
|
||||
// HandlesBot 报告该收件人是否为内置应答 bot(messages.BotResponder 实现)。
|
||||
func (s *Service) HandlesBot(botUserID int64) bool {
|
||||
return s != nil && (botUserID == domain.BotFatherUserID || botUserID == domain.StickersBotUserID || botUserID == domain.ChatBotUserID)
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
switch botUserID {
|
||||
case domain.BotFatherUserID, domain.StickersBotUserID, domain.ChatBotUserID,
|
||||
domain.VerifyBotUserID, domain.VerifierBotUserID:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// OnPrivateMessage 处理投递给内置 bot 的私聊消息(messages.BotResponder 实现)。
|
||||
|
|
@ -103,6 +114,10 @@ func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg dom
|
|||
go s.respondAsStickers(userID, msg)
|
||||
case domain.ChatBotUserID:
|
||||
go s.respondAsChatBot(userID, msg)
|
||||
case domain.VerifyBotUserID:
|
||||
go s.respondAsVerify(userID, msg)
|
||||
case domain.VerifierBotUserID:
|
||||
go s.respondAsVerifier(userID, msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -145,12 +160,24 @@ func (s *Service) sendServiceBotReplyResult(ctx context.Context, botUserID, user
|
|||
if s == nil || s.messages == nil || reply.Text == "" {
|
||||
return domain.SendPrivateTextResult{}, false
|
||||
}
|
||||
markup := reply.ReplyMarkup
|
||||
if err := domain.ValidateReplyMarkup(markup); err != nil {
|
||||
// 键盘校验必须先于落库(I9):结构非法的 markup 绝不写库,但正文仍然发出
|
||||
// ——用户至少收到提示文本,不会因为一颗坏按钮而完全失联。
|
||||
s.log.Error("service bot: invalid reply markup",
|
||||
zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
|
||||
markup = nil
|
||||
}
|
||||
if markup.IsZero() {
|
||||
markup = nil
|
||||
}
|
||||
res, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: botUserID,
|
||||
RecipientUserID: userID,
|
||||
RandomID: s.botReplyRandomID(),
|
||||
Message: reply.Text,
|
||||
Entities: serviceBotReplyEntities(reply.Text, reply.Entities),
|
||||
ReplyMarkup: markup,
|
||||
Date: int(s.now().Unix()),
|
||||
RecipientBlocked: s.serviceBotRecipientBlocked(ctx, botUserID, userID),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -48,6 +48,30 @@ type aiChatGenerator interface {
|
|||
GenerateTextStream(ctx context.Context, req domain.AITextGenerationRequest, emit func(domain.AIComposeText) error) (domain.AIComposeText, error)
|
||||
}
|
||||
|
||||
// verificationApplications is the applicant-side surface of official platform
|
||||
// verification used by the built-in @verifybot (app/verification.Service
|
||||
// satisfies it as-is).
|
||||
//
|
||||
// It is declared as a narrow port rather than taken as a concrete service for the
|
||||
// usual reason plus one specific to this feature: every verification rule --
|
||||
// ownership, public username, restrictions, already-verified, cooldown, rate
|
||||
// limit, the status machine -- belongs to that service, and the bot must not be
|
||||
// able to reach past it. Nothing here can write a peer's verified flag.
|
||||
type verificationApplications interface {
|
||||
EligibleTargets(ctx context.Context, applicantUserID int64) ([]domain.VerificationTarget, error)
|
||||
StartDraft(ctx context.Context, req domain.SubmitVerificationApplicationRequest) (domain.VerificationApplication, bool, error)
|
||||
SaveDraft(ctx context.Context, applicantUserID, applicationID, version int64, draft domain.VerificationDraftInput) (domain.VerificationApplication, error)
|
||||
Submit(ctx context.Context, applicantUserID, applicationID, version int64) (domain.VerificationApplication, error)
|
||||
Cancel(ctx context.Context, applicantUserID, applicationID, version int64, reason string) (domain.VerificationApplication, error)
|
||||
Draft(ctx context.Context, applicantUserID int64) (domain.VerificationApplication, error)
|
||||
ApplicantApplications(ctx context.Context, applicantUserID int64, limit int) ([]domain.VerificationApplication, error)
|
||||
Application(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
|
||||
}
|
||||
|
||||
// The third-party verification ports live in verifierbot.go
|
||||
// (customVerifications, verifierBotTargets): they are the built-in @verifierbot's
|
||||
// only way to reach the feature, and are kept next to the dialog that uses them.
|
||||
|
||||
// RouterHooks 是 rpc 层回调(router 创建后经 SetRouterHooks 延迟注入,打破
|
||||
// router↔bots 的构造循环;这些能力都依赖 TL/连接层边界,不能在 app 层实现):
|
||||
// - RevokeBotSessions:token revoke 后撤销 bot 的全部已登录 session(删
|
||||
|
|
@ -83,6 +107,9 @@ type Service struct {
|
|||
stickers stickerSetCreator
|
||||
installer userStickerSetInstaller
|
||||
aiChat aiChatGenerator
|
||||
verification verificationApplications
|
||||
customVerification customVerifications
|
||||
verifierTargets verifierBotTargets
|
||||
telegramLogin *telegramloginapp.Service
|
||||
hooks RouterHooks
|
||||
textDrafts TextDraftPusher
|
||||
|
|
@ -92,6 +119,13 @@ type Service struct {
|
|||
now func() time.Time
|
||||
chatBotStreamThrottle time.Duration
|
||||
publicBaseURL string
|
||||
// dialogLimiter bounds how often one applicant can drive a service-bot dialog.
|
||||
// The verification service already rate-limits application creation; this is the
|
||||
// separate bound on dialog traffic itself, so a script cannot spin the state
|
||||
// machine (and its writes) even without ever submitting anything.
|
||||
dialogLimiter store.RateLimiter
|
||||
dialogRateLimit int
|
||||
dialogRateWindow time.Duration
|
||||
// replySeq 是回复 randomID 在 crypto/rand 失败时的兜底单调序列。
|
||||
replySeq atomic.Int64
|
||||
replyLocks [replyLockStripes]sync.Mutex
|
||||
|
|
@ -177,6 +211,56 @@ func WithAIChatGenerator(g aiChatGenerator) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithVerification injects the official verification service used by the
|
||||
// built-in @verifybot. Without it the bot still answers, but every command
|
||||
// reports that verification is unavailable rather than half-running the dialog.
|
||||
func WithVerification(v verificationApplications) Option {
|
||||
return func(s *Service) {
|
||||
if v != nil {
|
||||
s.verification = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithCustomVerification injects the third-party verification service used by the
|
||||
// built-in @verifierbot. Without it the bot still answers, but every command
|
||||
// reports that third-party verification is unavailable rather than half-running the
|
||||
// dialog.
|
||||
func WithCustomVerification(v customVerifications) Option {
|
||||
return func(s *Service) {
|
||||
if v != nil {
|
||||
s.customVerification = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithVerifierTargets injects the directory of an applicant's own peers used by
|
||||
// @verifierbot's subject picker. It is optional: with nothing injected the bot
|
||||
// falls back to the official verification service's EligibleTargets, which
|
||||
// enumerates exactly the same peers (only its eligibility verdicts, which answer a
|
||||
// different question, are ignored).
|
||||
func WithVerifierTargets(t verifierBotTargets) Option {
|
||||
return func(s *Service) {
|
||||
if t != nil {
|
||||
s.verifierTargets = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithDialogRateLimiter bounds service-bot dialog traffic per user. A zero limit
|
||||
// or a nil limiter disables the bound, which is what a deployment without Redis
|
||||
// gets.
|
||||
func WithDialogRateLimiter(limiter store.RateLimiter, limit int, window time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
if limiter == nil || limit <= 0 || window <= 0 {
|
||||
return
|
||||
}
|
||||
s.dialogLimiter = limiter
|
||||
s.dialogRateLimit = limit
|
||||
s.dialogRateWindow = window
|
||||
}
|
||||
}
|
||||
|
||||
// WithTelegramLogin injects the OIDC application service used by BotFather.
|
||||
// BotFather never writes the login tables directly.
|
||||
func WithTelegramLogin(login *telegramloginapp.Service) Option {
|
||||
|
|
@ -262,6 +346,33 @@ func (s *Service) SetAIChatGenerator(g aiChatGenerator) {
|
|||
}
|
||||
}
|
||||
|
||||
// SetVerification injects the official verification service after construction.
|
||||
// The bots service is built before the peer directories that service depends on,
|
||||
// so in the shipped process this is the wiring order that actually exists (same
|
||||
// deferred-injection pattern as SetRouterHooks).
|
||||
func (s *Service) SetVerification(v verificationApplications) {
|
||||
if s != nil && v != nil {
|
||||
s.verification = v
|
||||
}
|
||||
}
|
||||
|
||||
// SetCustomVerification injects the third-party verification service after
|
||||
// construction. The bots service is built before the stores and directories that
|
||||
// service depends on, so in the shipped process this is the wiring order that
|
||||
// actually exists (same deferred-injection pattern as SetVerification).
|
||||
func (s *Service) SetCustomVerification(v customVerifications) {
|
||||
if s != nil && v != nil {
|
||||
s.customVerification = v
|
||||
}
|
||||
}
|
||||
|
||||
// SetVerifierTargets injects @verifierbot's subject directory after construction.
|
||||
func (s *Service) SetVerifierTargets(t verifierBotTargets) {
|
||||
if s != nil && t != nil {
|
||||
s.verifierTargets = t
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建 bots 服务。
|
||||
func NewService(users store.UserStore, bots store.BotStore, messages store.MessageStore, opts ...Option) *Service {
|
||||
s := &Service{
|
||||
|
|
|
|||
1626
internal/app/bots/verifierbot.go
Normal file
1626
internal/app/bots/verifierbot.go
Normal file
File diff suppressed because it is too large
Load diff
1012
internal/app/bots/verifierbot_test.go
Normal file
1012
internal/app/bots/verifierbot_test.go
Normal file
File diff suppressed because it is too large
Load diff
1493
internal/app/bots/verifybot.go
Normal file
1493
internal/app/bots/verifybot.go
Normal file
File diff suppressed because it is too large
Load diff
984
internal/app/bots/verifybot_test.go
Normal file
984
internal/app/bots/verifybot_test.go
Normal file
|
|
@ -0,0 +1,984 @@
|
|||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
verificationapp "telesrv/internal/app/verification"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake verification service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// fakeVerification is an in-memory stand-in for app/verification.Service. It
|
||||
// keeps the properties the bot dialog actually leans on: one draft per applicant,
|
||||
// StartDraft resuming instead of duplicating, optimistic-locking versions, and
|
||||
// the domain validation of the payload.
|
||||
type fakeVerification struct {
|
||||
targets []domain.VerificationTarget
|
||||
apps map[int64]domain.VerificationApplication
|
||||
nextID int64
|
||||
starts int
|
||||
submits int
|
||||
targetsErr error
|
||||
startErr error
|
||||
}
|
||||
|
||||
func newFakeVerification(targets ...domain.VerificationTarget) *fakeVerification {
|
||||
return &fakeVerification{
|
||||
targets: targets,
|
||||
apps: make(map[int64]domain.VerificationApplication),
|
||||
nextID: 100,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeVerification) EligibleTargets(_ context.Context, applicantUserID int64) ([]domain.VerificationTarget, error) {
|
||||
if f.targetsErr != nil {
|
||||
return nil, f.targetsErr
|
||||
}
|
||||
if applicantUserID <= 0 {
|
||||
return nil, domain.ErrVerificationApplicationInvalid
|
||||
}
|
||||
return append([]domain.VerificationTarget(nil), f.targets...), nil
|
||||
}
|
||||
|
||||
func (f *fakeVerification) draftFor(applicantUserID int64) (domain.VerificationApplication, bool) {
|
||||
for _, app := range f.apps {
|
||||
if app.ApplicantUserID == applicantUserID && app.Status == domain.VerificationStatusDraft {
|
||||
return app, true
|
||||
}
|
||||
}
|
||||
return domain.VerificationApplication{}, false
|
||||
}
|
||||
|
||||
func (f *fakeVerification) StartDraft(_ context.Context, req domain.SubmitVerificationApplicationRequest) (domain.VerificationApplication, bool, error) {
|
||||
f.starts++
|
||||
if app, found := f.draftFor(req.ApplicantUserID); found {
|
||||
return app, false, nil
|
||||
}
|
||||
if f.startErr != nil {
|
||||
return domain.VerificationApplication{}, false, f.startErr
|
||||
}
|
||||
var target domain.VerificationTarget
|
||||
for _, candidate := range f.targets {
|
||||
if candidate.Type == req.TargetType && candidate.ID == req.TargetID {
|
||||
target = candidate
|
||||
}
|
||||
}
|
||||
if target.ID == 0 {
|
||||
return domain.VerificationApplication{}, false, domain.ErrVerificationTargetInvalid
|
||||
}
|
||||
if !target.Eligible {
|
||||
return domain.VerificationApplication{}, false, domain.ErrVerificationTargetAlreadyVerified
|
||||
}
|
||||
f.nextID++
|
||||
app := domain.VerificationApplication{
|
||||
ID: f.nextID,
|
||||
ApplicantUserID: req.ApplicantUserID,
|
||||
TargetType: target.Type,
|
||||
TargetID: target.ID,
|
||||
TargetTitle: target.Title,
|
||||
TargetUsername: target.Username,
|
||||
Status: domain.VerificationStatusDraft,
|
||||
CreatedAt: time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC),
|
||||
Version: 1,
|
||||
}
|
||||
f.apps[app.ID] = app
|
||||
return app, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerification) SaveDraft(_ context.Context, applicantUserID, applicationID, version int64, draft domain.VerificationDraftInput) (domain.VerificationApplication, error) {
|
||||
app, found := f.apps[applicationID]
|
||||
if !found || app.ApplicantUserID != applicantUserID {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
||||
}
|
||||
if app.Version != version {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
|
||||
}
|
||||
if app.Status != domain.VerificationStatusDraft {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
|
||||
}
|
||||
if err := draft.ValidateDraft(); err != nil {
|
||||
return domain.VerificationApplication{}, err
|
||||
}
|
||||
draft = draft.Normalize()
|
||||
app.Category = draft.Category
|
||||
app.Description = draft.Description
|
||||
app.OfficialWebsite = draft.OfficialWebsite
|
||||
app.SocialLinks = draft.SocialLinks
|
||||
app.PressLinks = draft.PressLinks
|
||||
app.AdditionalNote = draft.AdditionalNote
|
||||
app.Version++
|
||||
f.apps[applicationID] = app
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerification) Submit(_ context.Context, applicantUserID, applicationID, version int64) (domain.VerificationApplication, error) {
|
||||
app, found := f.apps[applicationID]
|
||||
if !found || app.ApplicantUserID != applicantUserID {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
||||
}
|
||||
if app.Version != version {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
|
||||
}
|
||||
if !domain.CanTransitionVerificationStatus(app.Status, domain.VerificationStatusSubmitted) {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
|
||||
}
|
||||
if err := (domain.VerificationDraftInput{
|
||||
Category: app.Category,
|
||||
Description: app.Description,
|
||||
OfficialWebsite: app.OfficialWebsite,
|
||||
SocialLinks: app.SocialLinks,
|
||||
PressLinks: app.PressLinks,
|
||||
AdditionalNote: app.AdditionalNote,
|
||||
}).ValidateForSubmission(); err != nil {
|
||||
return domain.VerificationApplication{}, err
|
||||
}
|
||||
f.submits++
|
||||
app.Status = domain.VerificationStatusSubmitted
|
||||
app.SubmittedAt = time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
|
||||
app.Version++
|
||||
f.apps[applicationID] = app
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerification) Cancel(_ context.Context, applicantUserID, applicationID, version int64, reason string) (domain.VerificationApplication, error) {
|
||||
app, found := f.apps[applicationID]
|
||||
if !found || app.ApplicantUserID != applicantUserID {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
||||
}
|
||||
if app.Version != version {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
|
||||
}
|
||||
if !domain.CanTransitionVerificationStatus(app.Status, domain.VerificationStatusCancelled) {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
|
||||
}
|
||||
app.Status = domain.VerificationStatusCancelled
|
||||
app.DecisionReason = reason
|
||||
app.Version++
|
||||
f.apps[applicationID] = app
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerification) Draft(_ context.Context, applicantUserID int64) (domain.VerificationApplication, error) {
|
||||
if app, found := f.draftFor(applicantUserID); found {
|
||||
return app, nil
|
||||
}
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
||||
}
|
||||
|
||||
func (f *fakeVerification) ApplicantApplications(_ context.Context, applicantUserID int64, limit int) ([]domain.VerificationApplication, error) {
|
||||
out := make([]domain.VerificationApplication, 0, len(f.apps))
|
||||
for _, app := range f.apps {
|
||||
if app.ApplicantUserID == applicantUserID {
|
||||
out = append(out, app)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
if limit > 0 && len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeVerification) Application(_ context.Context, applicationID int64) (domain.VerificationApplication, error) {
|
||||
if app, found := f.apps[applicationID]; found {
|
||||
return app, nil
|
||||
}
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
||||
}
|
||||
|
||||
var _ verificationApplications = (*fakeVerification)(nil)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func verifyChannelTarget() domain.VerificationTarget {
|
||||
return domain.VerificationTarget{
|
||||
Type: domain.VerificationTargetChannel, ID: 7001,
|
||||
Title: "Example News", Username: "examplenews", AccessHash: 42, Eligible: true,
|
||||
}
|
||||
}
|
||||
|
||||
func verifyBotTarget() domain.VerificationTarget {
|
||||
return domain.VerificationTarget{
|
||||
Type: domain.VerificationTargetBot, ID: 8002,
|
||||
Title: "Example Bot", Username: "examplebot", Eligible: true,
|
||||
}
|
||||
}
|
||||
|
||||
func newVerifyBotTestService(t *testing.T, verification verificationApplications, opts ...Option) (*Service, *memory.UserStore, *memory.MessageStore) {
|
||||
t.Helper()
|
||||
users := memory.NewUserStore()
|
||||
bots := memory.NewBotStore(users)
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
all := append([]Option{WithVerification(verification)}, opts...)
|
||||
return NewService(users, bots, messages, all...), users, messages
|
||||
}
|
||||
|
||||
// verifyBotReplies returns every @verifybot message in the user's box, oldest
|
||||
// first.
|
||||
func verifyBotReplies(t *testing.T, messages *memory.MessageStore, userID int64) []domain.Message {
|
||||
t.Helper()
|
||||
list, err := messages.ListByUser(context.Background(), userID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.VerifyBotUserID},
|
||||
Limit: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list @verifybot history: %v", err)
|
||||
}
|
||||
out := make([]domain.Message, 0, len(list.Messages))
|
||||
for _, msg := range list.Messages {
|
||||
if msg.From.ID == domain.VerifyBotUserID {
|
||||
out = append(out, msg)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
func latestVerifyReply(t *testing.T, messages *memory.MessageStore, userID int64) domain.Message {
|
||||
t.Helper()
|
||||
replies := verifyBotReplies(t, messages, userID)
|
||||
if len(replies) == 0 {
|
||||
t.Fatal("no @verifybot reply")
|
||||
}
|
||||
latest := replies[len(replies)-1]
|
||||
// Every keyboard the bot renders must be a valid, persistable markup: the send
|
||||
// path validates before storing, so an invalid one would silently vanish.
|
||||
if err := domain.ValidateReplyMarkup(latest.ReplyMarkup); err != nil {
|
||||
t.Fatalf("reply markup invalid: %v (%+v)", err, latest.ReplyMarkup)
|
||||
}
|
||||
for _, row := range verifyInlineRows(latest) {
|
||||
for _, button := range row {
|
||||
if len(button.Data) > domain.MaxCallbackDataLen {
|
||||
t.Fatalf("callback data %q is %d bytes, limit is %d", button.Data, len(button.Data), domain.MaxCallbackDataLen)
|
||||
}
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func verifyInlineRows(msg domain.Message) [][]domain.MarkupButton {
|
||||
if msg.ReplyMarkup == nil {
|
||||
return nil
|
||||
}
|
||||
return msg.ReplyMarkup.Inline
|
||||
}
|
||||
|
||||
// sendToVerifyBot drives the responder synchronously, bypassing the
|
||||
// OnPrivateMessage goroutine dispatch for determinism (the same shortcut the
|
||||
// BotFather and @Stickers tests take).
|
||||
func sendToVerifyBot(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64, text string) domain.Message {
|
||||
t.Helper()
|
||||
svc.respondAsVerify(userID, domain.Message{
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.VerifyBotUserID},
|
||||
Body: text,
|
||||
})
|
||||
return latestVerifyReply(t, messages, userID)
|
||||
}
|
||||
|
||||
func verifyButtonData(msg domain.Message, label string) ([]byte, bool) {
|
||||
for _, row := range verifyInlineRows(msg) {
|
||||
for _, button := range row {
|
||||
if button.Type == domain.MarkupButtonCallback && strings.Contains(button.Text, label) {
|
||||
return append([]byte(nil), button.Data...), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// pressVerifyCallbackData drives the internal callback path with raw data, the
|
||||
// way rpc.Router does once it has validated the click.
|
||||
func pressVerifyCallbackData(t *testing.T, svc *Service, userID int64, msg domain.Message, data []byte) domain.BotCallbackAnswer {
|
||||
t.Helper()
|
||||
if len(data) > domain.MaxCallbackDataLen {
|
||||
t.Fatalf("callback data too long: %d bytes", len(data))
|
||||
}
|
||||
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
|
||||
ID: 1,
|
||||
BotUserID: domain.VerifyBotUserID,
|
||||
UserID: userID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
|
||||
MessageID: msg.ID,
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("callback query: %v", err)
|
||||
}
|
||||
if !handled {
|
||||
t.Fatal("callback query reported unhandled for @verifybot")
|
||||
}
|
||||
return answer
|
||||
}
|
||||
|
||||
func pressVerifyButton(t *testing.T, svc *Service, userID int64, msg domain.Message, label string) domain.BotCallbackAnswer {
|
||||
t.Helper()
|
||||
data, found := verifyButtonData(msg, label)
|
||||
if !found {
|
||||
t.Fatalf("button %q is not in the keyboard of message %d: %+v", label, msg.ID, msg.ReplyMarkup)
|
||||
}
|
||||
return pressVerifyCallbackData(t, svc, userID, msg, data)
|
||||
}
|
||||
|
||||
const (
|
||||
verifyTestDescription = "Example News is the daily newsroom of the Example Foundation, publishing since 2015."
|
||||
verifyTestWebsite = "https://news.example.com"
|
||||
verifyTestPressLinks = "https://press.example.org/story-one\nhttps://media.example.net/story-two"
|
||||
)
|
||||
|
||||
// runVerifyApplication walks the whole dialog up to (but not including) Submit and
|
||||
// returns the summary message.
|
||||
func runVerifyApplication(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64) domain.Message {
|
||||
t.Helper()
|
||||
intro := sendToVerifyBot(t, svc, messages, userID, "/start")
|
||||
pressVerifyButton(t, svc, userID, intro, verifyApplyButtonText)
|
||||
picker := latestVerifyReply(t, messages, userID)
|
||||
|
||||
pressVerifyButton(t, svc, userID, picker, "@examplenews")
|
||||
categories := latestVerifyReply(t, messages, userID)
|
||||
|
||||
pressVerifyButton(t, svc, userID, categories, "Media outlet")
|
||||
if got := latestVerifyReply(t, messages, userID); !strings.Contains(got.Body, "describe the subject") {
|
||||
t.Fatalf("after category, reply = %q", got.Body)
|
||||
}
|
||||
|
||||
sendToVerifyBot(t, svc, messages, userID, verifyTestDescription)
|
||||
social := sendToVerifyBot(t, svc, messages, userID, verifyTestWebsite)
|
||||
if !strings.Contains(social.Body, "social media") {
|
||||
t.Fatalf("after website, reply = %q", social.Body)
|
||||
}
|
||||
|
||||
pressVerifyButton(t, svc, userID, social, verifySkipButtonText)
|
||||
if got := latestVerifyReply(t, messages, userID); !strings.Contains(got.Body, "press coverage") {
|
||||
t.Fatalf("after skipping social links, reply = %q", got.Body)
|
||||
}
|
||||
|
||||
note := sendToVerifyBot(t, svc, messages, userID, verifyTestPressLinks)
|
||||
pressVerifyButton(t, svc, userID, note, verifySkipButtonText)
|
||||
return latestVerifyReply(t, messages, userID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestVerifyBotStartExplainsAndOffersApplyButton(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7100")
|
||||
|
||||
if !svc.HandlesBot(domain.VerifyBotUserID) {
|
||||
t.Fatal("service should handle @verifybot")
|
||||
}
|
||||
reply := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
for _, want := range []string{"official", "public @username", "/new", "/help"} {
|
||||
if !strings.Contains(reply.Body, want) {
|
||||
t.Fatalf("/start reply missing %q: %q", want, reply.Body)
|
||||
}
|
||||
}
|
||||
data, found := verifyButtonData(reply, verifyApplyButtonText)
|
||||
if !found {
|
||||
t.Fatalf("/start reply has no apply button: %+v", reply.ReplyMarkup)
|
||||
}
|
||||
if !strings.HasPrefix(string(data), verifyCallbackDataPrefix) {
|
||||
t.Fatalf("callback data %q is not a @verifybot token", data)
|
||||
}
|
||||
if fake.starts != 0 {
|
||||
t.Fatalf("StartDraft called %d times on /start, want 0", fake.starts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotFullApplicationFlowFilesExactlyOneApplication(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget(), verifyBotTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7101")
|
||||
|
||||
summary := runVerifyApplication(t, svc, messages, owner.ID)
|
||||
for _, want := range []string{"Example News", "Media outlet", verifyTestWebsite, "press.example.org/story-one", verifySubmitButtonText} {
|
||||
if !strings.Contains(summary.Body, want) {
|
||||
t.Fatalf("summary missing %q: %q", want, summary.Body)
|
||||
}
|
||||
}
|
||||
|
||||
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
|
||||
filed := latestVerifyReply(t, messages, owner.ID)
|
||||
if !strings.Contains(filed.Body, "#101") || !strings.Contains(filed.Body, "/status") {
|
||||
t.Fatalf("submitted reply = %q", filed.Body)
|
||||
}
|
||||
if fake.submits != 1 || len(fake.apps) != 1 {
|
||||
t.Fatalf("submits=%d applications=%d, want exactly one of each", fake.submits, len(fake.apps))
|
||||
}
|
||||
app := fake.apps[101]
|
||||
if app.Status != domain.VerificationStatusSubmitted {
|
||||
t.Fatalf("application status = %q", app.Status)
|
||||
}
|
||||
if app.Category != "media" || app.OfficialWebsite != verifyTestWebsite || len(app.PressLinks) != 2 {
|
||||
t.Fatalf("stored application = %+v", app)
|
||||
}
|
||||
if app.TargetID != 7001 || app.TargetType != domain.VerificationTargetChannel {
|
||||
t.Fatalf("stored target = %s/%d", app.TargetType, app.TargetID)
|
||||
}
|
||||
}
|
||||
|
||||
// The target buttons must not leak the peer they stand for: the whole point of the
|
||||
// token table is that a click cannot name a peer at all.
|
||||
func TestVerifyBotCallbackDataCarriesNoTargetIdentity(t *testing.T) {
|
||||
target := verifyChannelTarget()
|
||||
fake := newFakeVerification(target)
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7102")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
picker := latestVerifyReply(t, messages, owner.ID)
|
||||
|
||||
buttons := 0
|
||||
for _, row := range verifyInlineRows(picker) {
|
||||
for _, button := range row {
|
||||
buttons++
|
||||
data := string(button.Data)
|
||||
// Structural assertion rather than a substring hunt: the data is the
|
||||
// prefix plus an opaque hex token and nothing else, so it is incapable of
|
||||
// encoding a peer id, an access hash, a username or a peer type.
|
||||
token, ok := strings.CutPrefix(data, verifyCallbackDataPrefix)
|
||||
if !ok || len(token) != 2*verifyOptionTokenBytes {
|
||||
t.Fatalf("callback data %q is not <prefix><token>", data)
|
||||
}
|
||||
for _, c := range token {
|
||||
if !strings.ContainsRune("0123456789abcdef", c) {
|
||||
t.Fatalf("callback data %q carries non-token bytes", data)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{target.Username, string(target.Type)} {
|
||||
if strings.Contains(data, forbidden) {
|
||||
t.Fatalf("callback data %q leaks %q", data, forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if buttons == 0 {
|
||||
t.Fatal("target picker has no buttons")
|
||||
}
|
||||
|
||||
// The token is minted per render, so the same target never has a stable,
|
||||
// guessable identifier on the wire.
|
||||
firstData, _ := verifyButtonData(picker, "@"+target.Username)
|
||||
sendToVerifyBot(t, svc, messages, owner.ID, "/new")
|
||||
secondData, found := verifyButtonData(latestVerifyReply(t, messages, owner.ID), "@"+target.Username)
|
||||
if !found {
|
||||
t.Fatal("re-rendered picker has no target button")
|
||||
}
|
||||
if string(firstData) == string(secondData) {
|
||||
t.Fatalf("token %q is stable across renders", firstData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotRepeatedButtonPressIsIdempotent(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7103")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
picker := latestVerifyReply(t, messages, owner.ID)
|
||||
|
||||
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
|
||||
first := latestVerifyReply(t, messages, owner.ID)
|
||||
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
|
||||
second := latestVerifyReply(t, messages, owner.ID)
|
||||
|
||||
if first.Body != second.Body {
|
||||
t.Fatalf("repeat target press changed the answer:\nfirst = %q\nsecond = %q", first.Body, second.Body)
|
||||
}
|
||||
if len(fake.apps) != 1 {
|
||||
t.Fatalf("applications = %d after pressing the same target twice, want 1", len(fake.apps))
|
||||
}
|
||||
}
|
||||
|
||||
// The same must hold for the terminal action: a double-tapped Submit files one
|
||||
// application and repeats the same confirmation.
|
||||
func TestVerifyBotRepeatedSubmitFilesOneApplication(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7121")
|
||||
|
||||
summary := runVerifyApplication(t, svc, messages, owner.ID)
|
||||
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
|
||||
firstFiled := latestVerifyReply(t, messages, owner.ID)
|
||||
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
|
||||
secondFiled := latestVerifyReply(t, messages, owner.ID)
|
||||
|
||||
if firstFiled.Body != secondFiled.Body {
|
||||
t.Fatalf("repeat submit changed the answer:\nfirst = %q\nsecond = %q", firstFiled.Body, secondFiled.Body)
|
||||
}
|
||||
if fake.submits != 1 || len(fake.apps) != 1 {
|
||||
t.Fatalf("submits=%d applications=%d after double submit, want 1/1", fake.submits, len(fake.apps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotForgedCallbackTokenIsRefused(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7104")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
before := len(verifyBotReplies(t, messages, owner.ID))
|
||||
|
||||
// A token that was never minted for this user, and a plausible-looking
|
||||
// hand-written one: both resolve only through the user's own state, so both are
|
||||
// refused without any side effect.
|
||||
for _, data := range [][]byte{
|
||||
[]byte(verifyCallbackDataPrefix + "deadbeefcafe"),
|
||||
[]byte("tgt:channel:7001"),
|
||||
[]byte(verifyCallbackDataPrefix),
|
||||
} {
|
||||
answer := pressVerifyCallbackData(t, svc, owner.ID, intro, data)
|
||||
if !answer.Alert || !strings.Contains(answer.Message, "no longer active") {
|
||||
t.Fatalf("forged data %q answered %+v, want an explaining alert", data, answer)
|
||||
}
|
||||
}
|
||||
if got := len(verifyBotReplies(t, messages, owner.ID)); got != before {
|
||||
t.Fatalf("forged callbacks produced %d new messages", got-before)
|
||||
}
|
||||
if fake.starts != 0 || len(fake.apps) != 0 {
|
||||
t.Fatalf("forged callbacks touched the service: starts=%d apps=%d", fake.starts, len(fake.apps))
|
||||
}
|
||||
}
|
||||
|
||||
// A token minted for one applicant must be meaningless for another: resolution
|
||||
// goes through the clicking user's own chat state only.
|
||||
func TestVerifyBotTokenFromAnotherUserIsRefused(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
victim := newOwner(t, users, "+7105")
|
||||
attacker := newOwner(t, users, "+7106")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, victim.ID, "/start")
|
||||
pressVerifyButton(t, svc, victim.ID, intro, verifyApplyButtonText)
|
||||
picker := latestVerifyReply(t, messages, victim.ID)
|
||||
stolen, found := verifyButtonData(picker, "@examplenews")
|
||||
if !found {
|
||||
t.Fatal("victim picker has no target button")
|
||||
}
|
||||
|
||||
sendToVerifyBot(t, svc, messages, attacker.ID, "/start")
|
||||
attackerIntro := latestVerifyReply(t, messages, attacker.ID)
|
||||
answer := pressVerifyCallbackData(t, svc, attacker.ID, attackerIntro, stolen)
|
||||
if !answer.Alert {
|
||||
t.Fatalf("stolen token answered %+v, want an alert", answer)
|
||||
}
|
||||
for _, app := range fake.apps {
|
||||
if app.ApplicantUserID == attacker.ID {
|
||||
t.Fatalf("stolen token created an application for the attacker: %+v", app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotPressLinkMinimumIsEnforced(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7107")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
|
||||
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
|
||||
social := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestWebsite)
|
||||
pressVerifyButton(t, svc, owner.ID, social, verifySkipButtonText)
|
||||
|
||||
tooFew := sendToVerifyBot(t, svc, messages, owner.ID, "https://press.example.org/story-one")
|
||||
if !strings.Contains(tooFew.Body, strconv.Itoa(domain.MinVerificationPressLinks)) {
|
||||
t.Fatalf("single press link accepted or unexplained: %q", tooFew.Body)
|
||||
}
|
||||
if len(fake.apps[101].PressLinks) != 0 {
|
||||
t.Fatalf("press links stored despite refusal: %+v", fake.apps[101].PressLinks)
|
||||
}
|
||||
|
||||
accepted := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestPressLinks)
|
||||
if !strings.Contains(accepted.Body, "reviewers should know") {
|
||||
t.Fatalf("two press links did not advance the dialog: %q", accepted.Body)
|
||||
}
|
||||
if len(fake.apps[101].PressLinks) != 2 {
|
||||
t.Fatalf("press links = %+v, want two stored", fake.apps[101].PressLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotRejectsInvalidLinksWithAReason(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7108")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
|
||||
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
|
||||
|
||||
// Not a URL, a non-web scheme, and an address the domain refuses as
|
||||
// non-public (which is also what keeps a submitted link from becoming an SSRF
|
||||
// probe).
|
||||
for _, bad := range []string{"my site", "ftp://example.com", "http://127.0.0.1/admin", "https://localhost/x"} {
|
||||
reply := sendToVerifyBot(t, svc, messages, owner.ID, bad)
|
||||
if !strings.Contains(reply.Body, "http:// or https://") {
|
||||
t.Fatalf("website %q answered %q, want the link rules", bad, reply.Body)
|
||||
}
|
||||
if fake.apps[101].OfficialWebsite != "" {
|
||||
t.Fatalf("website %q was stored", bad)
|
||||
}
|
||||
}
|
||||
// A short description is refused with the actual bar, not a generic error.
|
||||
shortDesc := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestWebsite)
|
||||
if !strings.Contains(shortDesc.Body, "social media") {
|
||||
t.Fatalf("valid website not accepted: %q", shortDesc.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotDescriptionMinimumIsExplained(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7109")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
|
||||
|
||||
reply := sendToVerifyBot(t, svc, messages, owner.ID, "a newsroom")
|
||||
if !strings.Contains(reply.Body, strconv.Itoa(domain.MinVerificationDescriptionLength)) {
|
||||
t.Fatalf("short description answered %q, want the minimum length", reply.Body)
|
||||
}
|
||||
if fake.apps[101].Description != "" {
|
||||
t.Fatalf("short description was stored: %q", fake.apps[101].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotGlobalCommandsWorkMidStep(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7110")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
|
||||
|
||||
// /help in the middle of the description step answers help and keeps the step.
|
||||
help := sendToVerifyBot(t, svc, messages, owner.ID, "/help")
|
||||
if help.Body != verifyBotHelpText {
|
||||
t.Fatalf("/help mid-step = %q", help.Body)
|
||||
}
|
||||
status := sendToVerifyBot(t, svc, messages, owner.ID, "/status")
|
||||
if !strings.Contains(status.Body, "#101") {
|
||||
t.Fatalf("/status mid-step = %q", status.Body)
|
||||
}
|
||||
resumed := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
|
||||
if !strings.Contains(resumed.Body, "official website") {
|
||||
t.Fatalf("description not accepted after global commands: %q", resumed.Body)
|
||||
}
|
||||
if fake.apps[101].Description != verifyTestDescription {
|
||||
t.Fatalf("description = %q, want the step to have survived", fake.apps[101].Description)
|
||||
}
|
||||
// An unknown command is never swallowed as a field value.
|
||||
unknown := sendToVerifyBot(t, svc, messages, owner.ID, "/nope")
|
||||
if !strings.Contains(unknown.Body, "do not know that command") {
|
||||
t.Fatalf("unknown command = %q", unknown.Body)
|
||||
}
|
||||
if fake.apps[101].OfficialWebsite != "" {
|
||||
t.Fatalf("unknown command stored as a website: %q", fake.apps[101].OfficialWebsite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotStatusListsApplicationsWithoutInternalNotes(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7111")
|
||||
|
||||
if empty := sendToVerifyBot(t, svc, messages, owner.ID, "/status"); empty.Body != verifyNoApplicationsText {
|
||||
t.Fatalf("/status without applications = %q", empty.Body)
|
||||
}
|
||||
|
||||
fake.apps[500] = domain.VerificationApplication{
|
||||
ID: 500, ApplicantUserID: owner.ID,
|
||||
TargetType: domain.VerificationTargetChannel, TargetID: 7001,
|
||||
TargetTitle: "Example News", TargetUsername: "examplenews",
|
||||
Status: domain.VerificationStatusRejected,
|
||||
DecisionReason: "the linked coverage does not mention the channel",
|
||||
InternalNote: "applicant argued with the reviewer",
|
||||
ReviewedAt: time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC),
|
||||
Version: 4,
|
||||
}
|
||||
fake.apps[501] = domain.VerificationApplication{
|
||||
ID: 501, ApplicantUserID: owner.ID,
|
||||
TargetType: domain.VerificationTargetBot, TargetID: 8002, TargetUsername: "examplebot",
|
||||
Status: domain.VerificationStatusSubmitted,
|
||||
SubmittedAt: time.Date(2026, 7, 25, 9, 0, 0, 0, time.UTC),
|
||||
Version: 2,
|
||||
}
|
||||
|
||||
reply := sendToVerifyBot(t, svc, messages, owner.ID, "/status")
|
||||
for _, want := range []string{"#500", "#501", "@examplebot", "does not mention the channel", "2026-07-20"} {
|
||||
if !strings.Contains(reply.Body, want) {
|
||||
t.Fatalf("/status missing %q: %q", want, reply.Body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(reply.Body, "argued with the reviewer") {
|
||||
t.Fatalf("/status leaked the internal note: %q", reply.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotCancelWithdrawsTheOpenApplication(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7112")
|
||||
|
||||
if nothing := sendToVerifyBot(t, svc, messages, owner.ID, "/cancel"); nothing.Body != verifyNothingToCancelText {
|
||||
t.Fatalf("/cancel with nothing open = %q", nothing.Body)
|
||||
}
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
|
||||
|
||||
cancelled := sendToVerifyBot(t, svc, messages, owner.ID, "/cancel")
|
||||
if !strings.Contains(cancelled.Body, "#101") || !strings.Contains(cancelled.Body, "withdrawn") {
|
||||
t.Fatalf("/cancel = %q", cancelled.Body)
|
||||
}
|
||||
if fake.apps[101].Status != domain.VerificationStatusCancelled {
|
||||
t.Fatalf("application status = %q after /cancel", fake.apps[101].Status)
|
||||
}
|
||||
// The dialog is gone with it, so a stale button cannot revive it.
|
||||
idle := sendToVerifyBot(t, svc, messages, owner.ID, "still here?")
|
||||
if idle.Body != verifyBotIdleText {
|
||||
t.Fatalf("after /cancel, plain text = %q", idle.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotCancelButtonWithdrawsFromInsideTheForm(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7113")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
|
||||
categories := latestVerifyReply(t, messages, owner.ID)
|
||||
|
||||
pressVerifyButton(t, svc, owner.ID, categories, verifyCancelButtonText)
|
||||
if reply := latestVerifyReply(t, messages, owner.ID); !strings.Contains(reply.Body, "withdrawn") {
|
||||
t.Fatalf("cancel button = %q", reply.Body)
|
||||
}
|
||||
if fake.apps[101].Status != domain.VerificationStatusCancelled {
|
||||
t.Fatalf("application status = %q after the cancel button", fake.apps[101].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotHelpAndIdleText(t *testing.T) {
|
||||
fake := newFakeVerification()
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7114")
|
||||
|
||||
help := sendToVerifyBot(t, svc, messages, owner.ID, "/help")
|
||||
for _, want := range []string{"/new", "/status", "/cancel", "/help"} {
|
||||
if !strings.Contains(help.Body, want) {
|
||||
t.Fatalf("/help missing %q: %q", want, help.Body)
|
||||
}
|
||||
}
|
||||
assertReplyEntityText(t, help, domain.MessageEntityBotCommand, "/new")
|
||||
|
||||
// Nothing to verify: the requirement is stated instead of an empty picker.
|
||||
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyNoTargetsText {
|
||||
t.Fatalf("/new with no candidates = %q", reply.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotShowsIneligibleTargetsWithTheirReason(t *testing.T) {
|
||||
verified := verifyChannelTarget()
|
||||
verified.Eligible = false
|
||||
verified.Verified = true
|
||||
verified.Reason = domain.ErrVerificationTargetAlreadyVerified.Error()
|
||||
fake := newFakeVerification(verified)
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7115")
|
||||
|
||||
picker := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
|
||||
if !strings.Contains(picker.Body, "cannot be filed") && !strings.Contains(picker.Body, verifyNoEligibleText) {
|
||||
t.Fatalf("picker with only ineligible candidates = %q", picker.Body)
|
||||
}
|
||||
answer := pressVerifyButton(t, svc, owner.ID, picker, "unavailable")
|
||||
if !answer.Alert || !strings.Contains(answer.Message, "already verified") {
|
||||
t.Fatalf("ineligible button answered %+v, want the reason", answer)
|
||||
}
|
||||
if fake.starts != 0 || len(fake.apps) != 0 {
|
||||
t.Fatalf("ineligible button reached the service: starts=%d apps=%d", fake.starts, len(fake.apps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotNewResumesTheOpenDraft(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7116")
|
||||
|
||||
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
|
||||
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
|
||||
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
|
||||
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
|
||||
|
||||
resumed := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
|
||||
if !strings.Contains(resumed.Body, "#101") || !strings.Contains(resumed.Body, "official website") {
|
||||
t.Fatalf("/new mid-draft = %q, want a resume at the website step", resumed.Body)
|
||||
}
|
||||
if len(fake.apps) != 1 {
|
||||
t.Fatalf("applications = %d after /new mid-draft, want 1", len(fake.apps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotWithoutServiceReportsUnavailable(t *testing.T) {
|
||||
svc, users, messages := newVerifyBotTestService(t, nil)
|
||||
owner := newOwner(t, users, "+7117")
|
||||
|
||||
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyUnavailableText {
|
||||
t.Fatalf("/new without a verification service = %q", reply.Body)
|
||||
}
|
||||
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/help"); reply.Body != verifyBotHelpText {
|
||||
t.Fatalf("/help without a verification service = %q", reply.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotCallbackForForeignBotIsNotClaimed(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, _, _ := newVerifyBotTestService(t, fake)
|
||||
|
||||
if _, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
|
||||
BotUserID: 555111, UserID: 900, Data: []byte("vb:whatever"),
|
||||
}); handled || err != nil {
|
||||
t.Fatalf("foreign bot callback handled=%v err=%v, want (false, nil)", handled, err)
|
||||
}
|
||||
// A built-in bot with no keyboards is claimed but answered empty, so the click
|
||||
// cannot hang for the whole callback timeout.
|
||||
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
|
||||
BotUserID: domain.BotFatherUserID, UserID: 900, Data: []byte("x"),
|
||||
})
|
||||
if !handled || err != nil || answer.Message != "" {
|
||||
t.Fatalf("BotFather callback = (%+v, %v, %v)", answer, handled, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotSendVerificationNoticeNeverLeaksInternalNote(t *testing.T) {
|
||||
fake := newFakeVerification()
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7118")
|
||||
ctx := context.Background()
|
||||
|
||||
app := domain.VerificationApplication{
|
||||
ID: 4242, ApplicantUserID: owner.ID,
|
||||
TargetType: domain.VerificationTargetChannel, TargetID: 7001,
|
||||
TargetTitle: "Example News", TargetUsername: "examplenews",
|
||||
DecisionReason: "the coverage you linked does not mention the channel",
|
||||
InternalNote: "reviewer note: applicant is a repeat filer, escalate next time",
|
||||
}
|
||||
|
||||
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindApproved); err != nil {
|
||||
t.Fatalf("approved notice: %v", err)
|
||||
}
|
||||
approved := latestVerifyReply(t, messages, owner.ID)
|
||||
for _, want := range []string{"#4242", "Example News", "@examplenews", "approved"} {
|
||||
if !strings.Contains(approved.Body, want) {
|
||||
t.Fatalf("approved notice missing %q: %q", want, approved.Body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(approved.Body, "repeat filer") {
|
||||
t.Fatalf("approved notice leaked the internal note: %q", approved.Body)
|
||||
}
|
||||
|
||||
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindRejected); err != nil {
|
||||
t.Fatalf("rejected notice: %v", err)
|
||||
}
|
||||
rejected := latestVerifyReply(t, messages, owner.ID)
|
||||
if !strings.Contains(rejected.Body, "#4242") || !strings.Contains(rejected.Body, "does not mention the channel") {
|
||||
t.Fatalf("rejected notice = %q", rejected.Body)
|
||||
}
|
||||
if strings.Contains(rejected.Body, "repeat filer") || strings.Contains(rejected.Body, "escalate") {
|
||||
t.Fatalf("rejected notice leaked the internal note: %q", rejected.Body)
|
||||
}
|
||||
|
||||
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindRevoked); err != nil {
|
||||
t.Fatalf("revoked notice: %v", err)
|
||||
}
|
||||
revoked := latestVerifyReply(t, messages, owner.ID)
|
||||
if !strings.Contains(revoked.Body, "revoked") || strings.Contains(revoked.Body, "repeat filer") {
|
||||
t.Fatalf("revoked notice = %q", revoked.Body)
|
||||
}
|
||||
|
||||
// An unknown kind is reported rather than delivered as an empty message: the
|
||||
// outbox row must stay pending instead of being marked delivered.
|
||||
before := len(verifyBotReplies(t, messages, owner.ID))
|
||||
if err := svc.SendVerificationNotice(ctx, owner.ID, app, "teleported"); err == nil {
|
||||
t.Fatal("unknown notice kind reported success")
|
||||
}
|
||||
if got := len(verifyBotReplies(t, messages, owner.ID)); got != before {
|
||||
t.Fatalf("unknown notice kind sent %d messages", got-before)
|
||||
}
|
||||
if err := svc.SendVerificationNotice(ctx, 0, app, verificationapp.NoticeKindApproved); err == nil {
|
||||
t.Fatal("empty recipient reported success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotSubmitBouncesAnIncompleteApplication(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7119")
|
||||
|
||||
summary := runVerifyApplication(t, svc, messages, owner.ID)
|
||||
// Simulate a payload that lost a required field between rendering the summary
|
||||
// and the press: Submit must send the applicant back, not file a broken record.
|
||||
app := fake.apps[101]
|
||||
app.PressLinks = nil
|
||||
app.Version++
|
||||
fake.apps[101] = app
|
||||
|
||||
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
|
||||
bounced := latestVerifyReply(t, messages, owner.ID)
|
||||
if !strings.Contains(bounced.Body, "press coverage") {
|
||||
t.Fatalf("incomplete submit = %q, want the press step", bounced.Body)
|
||||
}
|
||||
if fake.submits != 0 {
|
||||
t.Fatalf("submits = %d for an incomplete application", fake.submits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBotPolicyRefusalsAreExplained(t *testing.T) {
|
||||
fake := newFakeVerification(verifyChannelTarget())
|
||||
fake.startErr = domain.ErrVerificationRateLimited
|
||||
svc, users, messages := newVerifyBotTestService(t, fake)
|
||||
owner := newOwner(t, users, "+7120")
|
||||
|
||||
picker := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
|
||||
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
|
||||
if reply := latestVerifyReply(t, messages, owner.ID); !strings.Contains(reply.Body, "limit on open applications") {
|
||||
t.Fatalf("rate-limited StartDraft = %q", reply.Body)
|
||||
}
|
||||
|
||||
fake.targetsErr = verificationapp.ErrDisabled
|
||||
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyUnavailableText {
|
||||
t.Fatalf("disabled verification = %q", reply.Body)
|
||||
}
|
||||
if !errors.Is(fake.targetsErr, verificationapp.ErrDisabled) {
|
||||
t.Fatal("test setup lost the sentinel")
|
||||
}
|
||||
}
|
||||
1527
internal/app/botverification/service.go
Normal file
1527
internal/app/botverification/service.go
Normal file
File diff suppressed because it is too large
Load diff
1539
internal/app/botverification/service_test.go
Normal file
1539
internal/app/botverification/service_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 自愈)。
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
31
internal/app/clienttelemetry/service.go
Normal file
31
internal/app/clienttelemetry/service.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
575
internal/app/files/premium_promo_seed.go
Normal file
575
internal/app/files/premium_promo_seed.go
Normal 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
|
||||
}
|
||||
292
internal/app/files/premium_promo_seed_test.go
Normal file
292
internal/app/files/premium_promo_seed_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 服务的可选能力。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
512
internal/app/moderation/actions.go
Normal file
512
internal/app/moderation/actions.go
Normal 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
|
||||
}
|
||||
325
internal/app/moderation/actions_test.go
Normal file
325
internal/app/moderation/actions_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
83
internal/app/moderation/appeal_links.go
Normal file
83
internal/app/moderation/appeal_links.go
Normal 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
|
||||
}
|
||||
178
internal/app/moderation/appeal_links_test.go
Normal file
178
internal/app/moderation/appeal_links_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
180
internal/app/moderation/cases.go
Normal file
180
internal/app/moderation/cases.go
Normal 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
|
||||
}
|
||||
43
internal/app/moderation/cases_test.go
Normal file
43
internal/app/moderation/cases_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
739
internal/app/moderation/evidence.go
Normal file
739
internal/app/moderation/evidence.go
Normal 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
|
||||
}
|
||||
106
internal/app/moderation/legacy.go
Normal file
106
internal/app/moderation/legacy.go
Normal 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
|
||||
}
|
||||
}
|
||||
88
internal/app/moderation/legacy_test.go
Normal file
88
internal/app/moderation/legacy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
102
internal/app/moderation/registry_reports.go
Normal file
102
internal/app/moderation/registry_reports.go
Normal 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)
|
||||
}
|
||||
98
internal/app/moderation/registry_reports_test.go
Normal file
98
internal/app/moderation/registry_reports_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
89
internal/app/moderation/service.go
Normal file
89
internal/app/moderation/service.go
Normal 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)
|
||||
}
|
||||
36
internal/app/moderation/service_test.go
Normal file
36
internal/app/moderation/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 的完整规则集。调用方必须先
|
||||
// InvalidateOwners;epoch 保证预热期间若又发生失效,不会把旧快照写回。
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
245
internal/app/privacy/facts.go
Normal file
245
internal/app/privacy/facts.go
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
454
internal/app/rating/service.go
Normal file
454
internal/app/rating/service.go
Normal 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
|
||||
}
|
||||
719
internal/app/rating/service_test.go
Normal file
719
internal/app/rating/service_test.go
Normal file
|
|
@ -0,0 +1,719 @@
|
|||
package rating
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
var testNow = time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// fakeRatingStore is an in-memory AccountRatingStore with the same optimistic
|
||||
// concurrency contract as PostgreSQL: a save whose version does not follow the
|
||||
// stored one reports changed=false and returns the row that won.
|
||||
type fakeRatingStore struct {
|
||||
signals map[int64]domain.AccountRatingSignals
|
||||
ratings map[int64]domain.AccountRating
|
||||
manual map[int64]int64
|
||||
events map[int64][]domain.AccountRatingEvent
|
||||
keys map[string]domain.AccountRatingEvent
|
||||
|
||||
stale []int64
|
||||
staleOlderThan int64
|
||||
staleLimit int
|
||||
|
||||
unrated []int64
|
||||
unratedLimit int
|
||||
unratedCalls int
|
||||
unratedErr error
|
||||
|
||||
saves []domain.AccountRating
|
||||
forceConflicts int
|
||||
signalsErr error
|
||||
}
|
||||
|
||||
func newFakeRatingStore() *fakeRatingStore {
|
||||
return &fakeRatingStore{
|
||||
signals: map[int64]domain.AccountRatingSignals{},
|
||||
ratings: map[int64]domain.AccountRating{},
|
||||
manual: map[int64]int64{},
|
||||
events: map[int64][]domain.AccountRatingEvent{},
|
||||
keys: map[string]domain.AccountRatingEvent{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
rating, ok := f.ratings[userID]
|
||||
if !ok {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AccountRatingBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
|
||||
out := make(map[int64]domain.AccountRating, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if rating, ok := f.ratings[userID]; ok {
|
||||
out[userID] = rating
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) SaveAccountRating(_ context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
|
||||
f.saves = append(f.saves, rating)
|
||||
current := f.ratings[rating.UserID]
|
||||
if f.forceConflicts > 0 {
|
||||
f.forceConflicts--
|
||||
return current, false, nil
|
||||
}
|
||||
if rating.Version != current.Version+1 {
|
||||
return current, false, nil
|
||||
}
|
||||
f.ratings[rating.UserID] = rating
|
||||
return rating, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AccountRatingSignals(_ context.Context, userID int64) (domain.AccountRatingSignals, error) {
|
||||
if f.signalsErr != nil {
|
||||
return domain.AccountRatingSignals{}, f.signalsErr
|
||||
}
|
||||
signals := f.signals[userID]
|
||||
signals.UserID = userID
|
||||
signals.Manual = f.manual[userID]
|
||||
return signals, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AdjustAccountRating(_ context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
|
||||
if req.CommandKey != "" {
|
||||
if event, ok := f.keys[req.CommandKey]; ok {
|
||||
return event, false, nil
|
||||
}
|
||||
}
|
||||
event := domain.AccountRatingEvent{
|
||||
ID: int64(len(f.events[req.UserID]) + 1), UserID: req.UserID, Kind: domain.AccountRatingEventManual,
|
||||
Amount: req.Amount, Reason: req.Reason, Actor: req.Actor, CommandKey: req.CommandKey, CreatedAt: testNow,
|
||||
}
|
||||
f.events[req.UserID] = append(f.events[req.UserID], event)
|
||||
f.manual[req.UserID] += req.Amount
|
||||
if req.CommandKey != "" {
|
||||
f.keys[req.CommandKey] = event
|
||||
}
|
||||
return event, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) ListAccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
out := make([]domain.AccountRating, 0, len(f.ratings))
|
||||
for _, rating := range f.ratings {
|
||||
if rating.Level >= filter.MinLevel {
|
||||
out = append(out, rating)
|
||||
}
|
||||
if len(out) >= filter.Limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AccountRatingEvents(_ context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
|
||||
events := f.events[userID]
|
||||
if len(events) > limit {
|
||||
events = events[:limit]
|
||||
}
|
||||
return append([]domain.AccountRatingEvent(nil), events...), nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) StaleAccountRatings(_ context.Context, olderThanUnix int64, limit int) ([]int64, error) {
|
||||
f.staleOlderThan = olderThanUnix
|
||||
f.staleLimit = limit
|
||||
if len(f.stale) > limit {
|
||||
return append([]int64(nil), f.stale[:limit]...), nil
|
||||
}
|
||||
return append([]int64(nil), f.stale...), nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) UnratedAccounts(_ context.Context, limit int) ([]int64, error) {
|
||||
f.unratedCalls++
|
||||
f.unratedLimit = limit
|
||||
if f.unratedErr != nil {
|
||||
return nil, f.unratedErr
|
||||
}
|
||||
out := make([]int64, 0, len(f.unrated))
|
||||
for _, userID := range f.unrated {
|
||||
if _, rated := f.ratings[userID]; rated {
|
||||
continue
|
||||
}
|
||||
out = append(out, userID)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func newTestService(st *fakeRatingStore, opts ...Option) *Service {
|
||||
base := []Option{WithStore(st), WithClock(func() time.Time { return testNow })}
|
||||
return NewService(append(base, opts...)...)
|
||||
}
|
||||
|
||||
func TestRecomputeAppliesConfiguredWeights(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{
|
||||
StarsReceived: 1000, StarsSpent: 400, MessagesSent: 30, AccountAgeDays: 10,
|
||||
GiftsReceived: 2, ModerationCases: 1,
|
||||
}
|
||||
service := newTestService(st)
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
weights := domain.DefaultAccountRatingWeights()
|
||||
want := domain.ComputeAccountRating(domain.AccountRatingSignals{
|
||||
UserID: 7, StarsReceived: 1000, StarsSpent: 400, MessagesSent: 30, AccountAgeDays: 10,
|
||||
GiftsReceived: 2, ModerationCases: 1,
|
||||
}, weights, testNow)
|
||||
if rating.Stars != want.Stars || rating.Level != want.Level ||
|
||||
rating.StarsComponent != want.StarsComponent || rating.ActivityComponent != want.ActivityComponent ||
|
||||
rating.PenaltyComponent != want.PenaltyComponent {
|
||||
t.Fatalf("rating = %#v, want the domain formula result %#v", rating, want)
|
||||
}
|
||||
if rating.Version != 1 {
|
||||
t.Fatalf("first stored version = %d, want 1", rating.Version)
|
||||
}
|
||||
if !rating.ComputedAt.Equal(testNow) {
|
||||
t.Fatalf("ComputedAt = %v, want the injected clock %v", rating.ComputedAt, testNow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputePendingPolicy(t *testing.T) {
|
||||
t.Run("increase is parked", func(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Level: 1, Version: 4}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
|
||||
service := newTestService(st, WithPendingDelay(24*time.Hour))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if rating.Stars != 100 {
|
||||
t.Fatalf("visible stars = %d, want the previous 100 while the increase is pending", rating.Stars)
|
||||
}
|
||||
if rating.PendingStars != 400 {
|
||||
t.Fatalf("pending stars = %d, want 400", rating.PendingStars)
|
||||
}
|
||||
if want := testNow.Add(24 * time.Hour); !rating.PendingDate.Equal(want) {
|
||||
t.Fatalf("pending date = %v, want %v", rating.PendingDate, want)
|
||||
}
|
||||
if rating.Version != 5 {
|
||||
t.Fatalf("version = %d, want 5", rating.Version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decrease applies immediately", func(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 500, Level: 2, Version: 1}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500, Scam: true}
|
||||
service := newTestService(st, WithPendingDelay(24*time.Hour))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if rating.Stars != 0 || rating.PendingStars != 0 {
|
||||
t.Fatalf("rating = %d stars / %d pending, want a penalty applied at once", rating.Stars, rating.PendingStars)
|
||||
}
|
||||
if rating.PenaltyComponent != domain.DefaultAccountRatingWeights().ScamPenalty {
|
||||
t.Fatalf("penalty = %d, want the scam penalty", rating.PenaltyComponent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired parking is folded into the visible rating", func(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{
|
||||
UserID: 7, Stars: 100, Level: 1, Version: 2,
|
||||
PendingStars: 400, PendingDate: testNow.Add(-time.Hour),
|
||||
}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
|
||||
service := newTestService(st, WithPendingDelay(24*time.Hour))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if rating.Stars != 500 || rating.PendingStars != 0 || !rating.PendingDate.IsZero() {
|
||||
t.Fatalf("rating = %#v, want the parked delta applied and cleared", rating)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero delay never parks", func(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Version: 1}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
|
||||
service := newTestService(st, WithPendingDelay(0))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if rating.Stars != 500 || rating.PendingStars != 0 {
|
||||
t.Fatalf("rating = %d stars / %d pending, want an immediate apply", rating.Stars, rating.PendingStars)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecomputeRetriesOnceOnVersionConflict(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Version: 3}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 200}
|
||||
st.forceConflicts = 1
|
||||
service := newTestService(st, WithPendingDelay(0))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if len(st.saves) != 2 {
|
||||
t.Fatalf("saves = %d, want exactly one retry", len(st.saves))
|
||||
}
|
||||
if rating.Version != 4 || rating.Stars != 200 {
|
||||
t.Fatalf("rating = %#v, want version 4 with 200 stars", rating)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeFailsAfterPersistentConflict(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 200}
|
||||
st.forceConflicts = 2
|
||||
service := newTestService(st)
|
||||
|
||||
if _, err := service.Recompute(context.Background(), 7); err == nil {
|
||||
t.Fatal("Recompute reported success while every save lost the version race")
|
||||
}
|
||||
if len(st.saves) != 2 {
|
||||
t.Fatalf("saves = %d, want the bounded single retry", len(st.saves))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustRecordsLedgerAndRecomputes(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 100}
|
||||
service := newTestService(st, WithPendingDelay(0))
|
||||
|
||||
rating, applied, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{
|
||||
UserID: 7, Amount: 300, Reason: "support compensation", Actor: "admin", CommandKey: "cmd-1",
|
||||
})
|
||||
if err != nil || !applied {
|
||||
t.Fatalf("Adjust = %v, %v", applied, err)
|
||||
}
|
||||
if rating.ManualComponent != 300 || rating.Stars != 400 {
|
||||
t.Fatalf("rating = %#v, want the manual component folded in", rating)
|
||||
}
|
||||
if len(st.events[7]) != 1 {
|
||||
t.Fatalf("ledger rows = %d, want 1", len(st.events[7]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustReplayByCommandKeyIsIdempotent(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 100}
|
||||
service := newTestService(st, WithPendingDelay(0))
|
||||
req := domain.AdjustAccountRatingRequest{UserID: 7, Amount: 300, Actor: "admin", CommandKey: "cmd-1"}
|
||||
|
||||
first, applied, err := service.Adjust(context.Background(), req)
|
||||
if err != nil || !applied {
|
||||
t.Fatalf("first Adjust = %v, %v", applied, err)
|
||||
}
|
||||
second, applied, err := service.Adjust(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("replayed Adjust: %v", err)
|
||||
}
|
||||
if applied {
|
||||
t.Fatal("replayed Adjust reported applied=true")
|
||||
}
|
||||
if len(st.events[7]) != 1 || st.manual[7] != 300 {
|
||||
t.Fatalf("ledger = %d rows / manual %d, want the replay recorded nothing", len(st.events[7]), st.manual[7])
|
||||
}
|
||||
if second.Stars != first.Stars || second.ManualComponent != first.ManualComponent {
|
||||
t.Fatalf("replayed rating = %#v, want the same score as %#v", second, first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustValidatesRequest(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
service := newTestService(st)
|
||||
tests := []domain.AdjustAccountRatingRequest{
|
||||
{UserID: 0, Amount: 10},
|
||||
{UserID: 7, Amount: 0},
|
||||
{UserID: 7, Amount: 10, Reason: string(make([]byte, domain.MaxAccountRatingReasonLength+1))},
|
||||
}
|
||||
for _, req := range tests {
|
||||
if _, _, err := service.Adjust(context.Background(), req); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("Adjust(%#v) error = %v, want ErrAccountRatingAdjustmentInvalid", req, err)
|
||||
}
|
||||
}
|
||||
if len(st.events) != 0 || len(st.saves) != 0 {
|
||||
t.Fatal("store was touched by an invalid adjustment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecomputeCycleProcessesTheBatch(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1, 2, 3}
|
||||
for _, userID := range st.stale {
|
||||
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 100 * userID}
|
||||
}
|
||||
service := newTestService(st, WithStaleAfter(6*time.Hour))
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if processed != 3 {
|
||||
t.Fatalf("processed = %d, want 3", processed)
|
||||
}
|
||||
if st.staleLimit != 10 {
|
||||
t.Fatalf("stale limit = %d, want the requested 10", st.staleLimit)
|
||||
}
|
||||
if want := testNow.Add(-6 * time.Hour).Unix(); st.staleOlderThan != want {
|
||||
t.Fatalf("stale horizon = %d, want %d", st.staleOlderThan, want)
|
||||
}
|
||||
for _, userID := range st.stale {
|
||||
if _, ok := st.ratings[userID]; !ok {
|
||||
t.Fatalf("user %d was not recomputed", userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecomputeCycleSkipsFailingUsers(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1, 0, 2}
|
||||
st.forceConflicts = 2 // both saves of the first user lose the race
|
||||
service := newTestService(st)
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("processed = %d, want the surviving user only", processed)
|
||||
}
|
||||
if st.staleLimit != defaultRecomputeBatch {
|
||||
t.Fatalf("stale limit = %d, want the default batch", st.staleLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPathsDegradeWhenDisabled(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 500, Level: 2, Version: 1}
|
||||
service := newTestService(st, WithEnabled(false))
|
||||
|
||||
if service.Enabled() || service.Ready() {
|
||||
t.Fatal("disabled service reported enabled/ready")
|
||||
}
|
||||
// The userFull projection omits both TL flags on this error, which is exactly
|
||||
// the pre-rating wire shape.
|
||||
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("Rating error = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
batch, err := service.RatingBatch(context.Background(), []int64{7})
|
||||
if err != nil || len(batch) != 0 {
|
||||
t.Fatalf("RatingBatch = %#v, %v; want empty", batch, err)
|
||||
}
|
||||
if _, err := service.Recompute(context.Background(), 7); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("Recompute error = %v, want ErrDisabled", err)
|
||||
}
|
||||
if _, _, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{UserID: 7, Amount: 5}); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("Adjust error = %v, want ErrDisabled", err)
|
||||
}
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
||||
if err != nil || processed != 0 {
|
||||
t.Fatalf("RunRecomputeCycle = %d, %v; want a no-op", processed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnconfiguredStoreReportsConfiguration(t *testing.T) {
|
||||
service := NewService()
|
||||
if service.Ready() {
|
||||
t.Fatal("Ready = true without a store")
|
||||
}
|
||||
if _, err := service.Rating(context.Background(), 7); err == nil {
|
||||
t.Fatal("Rating accepted a missing store")
|
||||
}
|
||||
if _, err := service.Recompute(context.Background(), 7); err == nil {
|
||||
t.Fatal("Recompute accepted a missing store")
|
||||
}
|
||||
if _, _, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{UserID: 7, Amount: 5}); err == nil {
|
||||
t.Fatal("Adjust accepted a missing store")
|
||||
}
|
||||
if _, err := service.RunRecomputeCycle(context.Background(), 10); err == nil {
|
||||
t.Fatal("RunRecomputeCycle accepted a missing store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilServiceIsSafe(t *testing.T) {
|
||||
var service *Service
|
||||
if service.Enabled() || service.Ready() {
|
||||
t.Fatal("nil service reported enabled/ready")
|
||||
}
|
||||
if got := service.Weights(); got != domain.DefaultAccountRatingWeights() {
|
||||
t.Fatalf("nil service weights = %#v, want the defaults", got)
|
||||
}
|
||||
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("nil service Rating error = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
if batch, err := service.RatingBatch(context.Background(), []int64{7}); err != nil || len(batch) != 0 {
|
||||
t.Fatalf("nil service RatingBatch = %#v, %v; want empty", batch, err)
|
||||
}
|
||||
if _, err := service.Recompute(context.Background(), 7); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("nil service Recompute error = %v, want ErrDisabled", err)
|
||||
}
|
||||
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
|
||||
t.Fatalf("nil service RunRecomputeCycle = %d, %v", processed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidWeightsFallBackToDefaults(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
service := newTestService(st, WithWeights(domain.AccountRatingWeights{StarsReceivedPermille: -1}))
|
||||
if got := service.Weights(); got != domain.DefaultAccountRatingWeights() {
|
||||
t.Fatalf("weights = %#v, want the defaults after rejecting a negative set", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAndEventsBoundThePage(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Level: 3, Version: 1}
|
||||
for i := range maxEventLimit + 10 {
|
||||
st.events[7] = append(st.events[7], domain.AccountRatingEvent{ID: int64(i + 1), UserID: 7, Amount: 1})
|
||||
}
|
||||
service := newTestService(st)
|
||||
|
||||
list, err := service.List(context.Background(), domain.AccountRatingFilter{MinLevel: -5, Limit: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("List = %d rows, want 1", len(list))
|
||||
}
|
||||
events, err := service.Events(context.Background(), 7, 100000)
|
||||
if err != nil {
|
||||
t.Fatalf("Events: %v", err)
|
||||
}
|
||||
if len(events) != maxEventLimit {
|
||||
t.Fatalf("Events = %d rows, want the %d cap", len(events), maxEventLimit)
|
||||
}
|
||||
if _, err := service.Events(context.Background(), 0, 10); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("Events accepted a zero user id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeWorkerRunsAndStops(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1}
|
||||
st.signals[1] = domain.AccountRatingSignals{StarsReceived: 100}
|
||||
service := newTestService(st)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
NewRecomputeWorker(service, nil, time.Hour, 10).Run(ctx)
|
||||
}()
|
||||
// The first cycle runs before the ticker, so cancelling immediately still
|
||||
// leaves exactly one recompute behind.
|
||||
<-time.After(20 * time.Millisecond)
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("worker did not stop on context cancellation")
|
||||
}
|
||||
if _, ok := st.ratings[1]; !ok {
|
||||
t.Fatal("worker did not recompute the stale user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeWorkerExitsWhenNotReady(t *testing.T) {
|
||||
worker := NewRecomputeWorker(newTestService(newFakeRatingStore(), WithEnabled(false)), nil, time.Millisecond, 0)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
worker.Run(context.Background())
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("disabled worker kept running")
|
||||
}
|
||||
if worker.batch != defaultRecomputeBatch {
|
||||
t.Fatalf("batch = %d, want the default fallback", worker.batch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunRecomputeCycleSeedsAccountsWithNoProjection is the report "the ratings tab
|
||||
// is empty and no client shows a rating". StaleAccountRatings reads account_rating,
|
||||
// so it can only ever refresh rows that already exist; without a seeding pass the
|
||||
// very first row for a user has to come from an operator recomputing that user by
|
||||
// hand, and the read model stays permanently empty.
|
||||
func TestRunRecomputeCycleSeedsAccountsWithNoProjection(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.unrated = []int64{11, 12, 13}
|
||||
for _, userID := range st.unrated {
|
||||
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 100 * userID}
|
||||
}
|
||||
service := newTestService(st)
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if processed != 3 {
|
||||
t.Fatalf("processed = %d, want the three seeded accounts", processed)
|
||||
}
|
||||
for _, userID := range st.unrated {
|
||||
if _, ok := st.ratings[userID]; !ok {
|
||||
t.Fatalf("account %d was not seeded", userID)
|
||||
}
|
||||
}
|
||||
// A second cycle has nothing left to seed, so seeding converges instead of
|
||||
// rewriting the same rows every interval.
|
||||
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
|
||||
t.Fatalf("second cycle = %d,%v, want 0,nil", processed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The batch bound belongs to the cycle, not to each pass: a backlog of stale rows
|
||||
// must not let one cycle do an unbounded amount of work.
|
||||
func TestRunRecomputeCycleSharesTheBatchBudget(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1, 2}
|
||||
st.unrated = []int64{11, 12, 13, 14}
|
||||
service := newTestService(st)
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if processed != 3 {
|
||||
t.Fatalf("processed = %d, want the batch bound of 3", processed)
|
||||
}
|
||||
if st.unratedLimit != 1 {
|
||||
t.Fatalf("seeding limit = %d, want the 1 left after two stale rows", st.unratedLimit)
|
||||
}
|
||||
|
||||
// A cycle whose stale pass already fills the batch does not query for seeds at
|
||||
// all: refreshing rows somebody is looking at comes first.
|
||||
full := newFakeRatingStore()
|
||||
full.stale = []int64{1, 2, 3}
|
||||
full.unrated = []int64{11}
|
||||
if _, err := newTestService(full).RunRecomputeCycle(context.Background(), 3); err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if full.unratedCalls != 0 {
|
||||
t.Fatalf("seeding was queried %d times, want none when the batch is already full", full.unratedCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Seeding extends the cycle; it is not its purpose. A store that cannot enumerate
|
||||
// accounts must not turn a successful stale pass into a failed cycle.
|
||||
func TestRunRecomputeCycleSurvivesSeedingFailure(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1}
|
||||
st.unratedErr = errors.New("no users table")
|
||||
service := newTestService(st)
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle = %v, want the stale pass to stand", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("processed = %d, want the one stale row", processed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureRatingMaterializesOnce covers an administrative immediate-read path:
|
||||
// when the worker has not reached an account yet, the first read materializes the
|
||||
// local projection and the second read must not write again.
|
||||
func TestEnsureRatingMaterializesOnce(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 900}
|
||||
service := newTestService(st)
|
||||
|
||||
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("Rating before materialising = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
rating, err := service.EnsureRating(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureRating: %v", err)
|
||||
}
|
||||
if rating.UserID != 7 || rating.Stars == 0 {
|
||||
t.Fatalf("materialised rating = %+v, want a computed rating for user 7", rating)
|
||||
}
|
||||
writes := len(st.saves)
|
||||
again, err := service.EnsureRating(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("second EnsureRating: %v", err)
|
||||
}
|
||||
if again.Version != rating.Version {
|
||||
t.Fatalf("second EnsureRating rewrote the row: version %d then %d", rating.Version, again.Version)
|
||||
}
|
||||
if len(st.saves) != writes {
|
||||
t.Fatalf("second EnsureRating issued %d extra saves, want none", len(st.saves)-writes)
|
||||
}
|
||||
}
|
||||
|
||||
// A disabled feature materialises nothing. Telegram wire fields remain unset
|
||||
// independently of this local feature flag.
|
||||
func TestEnsureRatingDisabledStaysEmpty(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 900}
|
||||
service := newTestService(st, WithEnabled(false))
|
||||
|
||||
if _, err := service.EnsureRating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("EnsureRating while disabled = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
if len(st.saves) != 0 {
|
||||
t.Fatalf("EnsureRating while disabled wrote %d rows, want none", len(st.saves))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecomputeRefusesServiceAccounts pins that the platform account and the
|
||||
// built-in bots carry no rating. The platform account is not flagged is_bot, so the
|
||||
// bot exclusion in the seeding query does not cover it -- which is how it acquired a
|
||||
// rating in the first place -- and an operator must not be able to create one by
|
||||
// hand either.
|
||||
func TestRecomputeRefusesServiceAccounts(t *testing.T) {
|
||||
for _, userID := range domain.SystemUserIDs() {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 5000}
|
||||
st.unrated = []int64{userID}
|
||||
service := newTestService(st)
|
||||
|
||||
if _, err := service.Recompute(context.Background(), userID); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("Recompute(%d) = %v, want ErrAccountRatingAdjustmentInvalid", userID, err)
|
||||
}
|
||||
if _, err := service.EnsureRating(context.Background(), userID); err == nil {
|
||||
t.Fatalf("EnsureRating(%d) succeeded, want a refusal", userID)
|
||||
}
|
||||
if len(st.ratings) != 0 {
|
||||
t.Fatalf("service account %d ended up with a projection: %#v", userID, st.ratings)
|
||||
}
|
||||
// A seeding pass that is somehow handed one skips it rather than failing the
|
||||
// whole cycle.
|
||||
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
|
||||
t.Fatalf("cycle over service account %d = %d,%v, want 0,nil", userID, processed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// An ordinary account is unaffected.
|
||||
st := newFakeRatingStore()
|
||||
st.signals[42] = domain.AccountRatingSignals{StarsReceived: 5000}
|
||||
if _, err := newTestService(st).Recompute(context.Background(), 42); err != nil {
|
||||
t.Fatalf("Recompute of an ordinary account: %v", err)
|
||||
}
|
||||
}
|
||||
91
internal/app/rating/worker.go
Normal file
91
internal/app/rating/worker.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package rating
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultRecomputeInterval matches the shipped
|
||||
// TELESRV_RATING_RECOMPUTE_INTERVAL default.
|
||||
defaultRecomputeInterval = 15 * time.Minute
|
||||
)
|
||||
|
||||
// RecomputeWorker keeps the rating read model fresh.
|
||||
//
|
||||
// The projection is derived from signals that change outside the rating write
|
||||
// path (Stars flow, message activity, moderation decisions, account age), so no
|
||||
// single writer can keep it current. This worker walks the stale projections in
|
||||
// bounded batches; it never recomputes the whole table in one pass, and a
|
||||
// cancelled context stops it between users rather than mid-write.
|
||||
type RecomputeWorker struct {
|
||||
service *Service
|
||||
logger *zap.Logger
|
||||
interval time.Duration
|
||||
batch int
|
||||
}
|
||||
|
||||
// NewRecomputeWorker creates the periodic recompute worker. Non-positive
|
||||
// interval/batch fall back to the shipped defaults, matching the retention
|
||||
// worker's contract.
|
||||
func NewRecomputeWorker(service *Service, logger *zap.Logger, interval time.Duration, batch int) *RecomputeWorker {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = defaultRecomputeInterval
|
||||
}
|
||||
if batch <= 0 {
|
||||
batch = defaultRecomputeBatch
|
||||
}
|
||||
return &RecomputeWorker{service: service, logger: logger, interval: interval, batch: batch}
|
||||
}
|
||||
|
||||
// Run recomputes one batch immediately and then on every tick until ctx is
|
||||
// done. A disabled or store-less service exits immediately with one explicit
|
||||
// log line instead of ticking forever over a no-op.
|
||||
func (w *RecomputeWorker) Run(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
if !w.service.Ready() {
|
||||
w.logger.Info("account rating recompute worker disabled",
|
||||
zap.Bool("enabled", w.service.Enabled()))
|
||||
return
|
||||
}
|
||||
w.runOnce(ctx)
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *RecomputeWorker) runOnce(ctx context.Context) {
|
||||
if w == nil || w.service == nil {
|
||||
return
|
||||
}
|
||||
processed, err := w.service.RunRecomputeCycle(ctx, w.batch)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
w.logger.Warn("account rating recompute cycle failed",
|
||||
zap.Int("processed", processed),
|
||||
zap.Int("batch", w.batch),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
if processed > 0 {
|
||||
w.logger.Info("account rating recompute cycle completed",
|
||||
zap.Int("processed", processed),
|
||||
zap.Int("batch", w.batch))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
552
internal/app/usernames/service.go
Normal file
552
internal/app/usernames/service.go
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
// Package usernames implements the collectible (Fragment-style) username
|
||||
// registry use cases: reading a peer's username vector, toggling and reordering
|
||||
// the collectible rows a client owns, and the operator lifecycle that mints,
|
||||
// transfers, revokes and burns the assets behind those rows.
|
||||
//
|
||||
// The service owns normalisation and validation. Every entry point normalises
|
||||
// the name through domain.NormalizeUsername and runs the domain Validate()
|
||||
// checks before the store is touched, so an RPC handler, the admin API and a
|
||||
// unit test all reject the same shapes with the same errors.
|
||||
package usernames
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultListLimit is the admin listing page size used when the caller does
|
||||
// not bound the query itself.
|
||||
defaultListLimit = 50
|
||||
// maxListLimit bounds one listing page regardless of the requested limit.
|
||||
maxListLimit = 200
|
||||
// defaultTransferLimit / maxTransferLimit bound the provenance log page.
|
||||
defaultTransferLimit = 50
|
||||
maxTransferLimit = 200
|
||||
// usernamePlaceholder is the substitution supported by the operator URL
|
||||
// template, e.g. https://example.org/nft/{username}.
|
||||
usernamePlaceholder = "{username}"
|
||||
// defaultCollectibleURLPath is the public-link route used when no operator
|
||||
// template is configured.
|
||||
defaultCollectibleURLPath = "nft/username"
|
||||
)
|
||||
|
||||
// ErrPeerInvalid rejects a registry mutation for a peer that cannot hold
|
||||
// usernames. Only users and channels have a username registry; anything else is
|
||||
// a caller bug rather than a client-visible protocol state.
|
||||
var ErrPeerInvalid = errors.New("username peer invalid")
|
||||
|
||||
// PeerUsernameNotifier is the domain-only edge hook invoked after a username
|
||||
// registry mutation. The RPC router implements it: it invalidates the cached
|
||||
// peer projections and pushes the username change to online clients, exactly
|
||||
// like the account.updateUsername path does for the editable slot. Keeping it an
|
||||
// injected port means this package never depends on the protocol edge.
|
||||
type PeerUsernameNotifier interface {
|
||||
NotifyPeerUsernamesChanged(ctx context.Context, peer domain.Peer) error
|
||||
}
|
||||
|
||||
// Service is the collectible username use-case layer.
|
||||
type Service struct {
|
||||
registry store.UsernameRegistryStore
|
||||
collectibles store.CollectibleUsernameStore
|
||||
notifier PeerUsernameNotifier
|
||||
|
||||
// urlTemplate is the operator-provided collectible landing URL template;
|
||||
// publicBaseURL is the fallback root the default route is built from.
|
||||
urlTemplate string
|
||||
publicBaseURL string
|
||||
|
||||
now func() time.Time
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// Option adjusts optional service dependencies.
|
||||
type Option func(*Service)
|
||||
|
||||
// WithRegistryStore injects the peer username registry reader/writer.
|
||||
func WithRegistryStore(registry store.UsernameRegistryStore) Option {
|
||||
return func(s *Service) { s.registry = registry }
|
||||
}
|
||||
|
||||
// WithCollectibleStore injects the collectible asset lifecycle store.
|
||||
func WithCollectibleStore(collectibles store.CollectibleUsernameStore) Option {
|
||||
return func(s *Service) { s.collectibles = collectibles }
|
||||
}
|
||||
|
||||
// WithNotifier injects the edge invalidation/update hook.
|
||||
func WithNotifier(notifier PeerUsernameNotifier) Option {
|
||||
return func(s *Service) { s.notifier = notifier }
|
||||
}
|
||||
|
||||
// WithURLTemplate configures the collectible asset landing URL template. An
|
||||
// empty template keeps the public-link default route.
|
||||
func WithURLTemplate(template string) Option {
|
||||
return func(s *Service) { s.urlTemplate = strings.TrimSpace(template) }
|
||||
}
|
||||
|
||||
// WithPublicBaseURL configures the public-link root the default collectible URL
|
||||
// route is derived from.
|
||||
func WithPublicBaseURL(baseURL string) Option {
|
||||
return func(s *Service) { s.publicBaseURL = strings.TrimSpace(baseURL) }
|
||||
}
|
||||
|
||||
// WithClock injects the clock (tests).
|
||||
func WithClock(now func() time.Time) Option {
|
||||
return func(s *Service) {
|
||||
if now != nil {
|
||||
s.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger injects the service logger.
|
||||
func WithLogger(log *zap.Logger) Option {
|
||||
return func(s *Service) {
|
||||
if log != nil {
|
||||
s.log = log
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewService creates the collectible username service. Every dependency is
|
||||
// optional: a service without stores answers with a configuration error instead
|
||||
// of panicking, which keeps partial deployments diagnosable.
|
||||
func NewService(opts ...Option) *Service {
|
||||
s := &Service{now: time.Now, log: zap.NewNop()}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(s)
|
||||
}
|
||||
}
|
||||
if s.now == nil {
|
||||
s.now = time.Now
|
||||
}
|
||||
if s.log == nil {
|
||||
s.log = zap.NewNop()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SetPeerUsernameNotifier injects the edge hook after construction. The RPC
|
||||
// router is built after the app services, so the notification port is bound
|
||||
// here rather than through NewService.
|
||||
func (s *Service) SetPeerUsernameNotifier(notifier PeerUsernameNotifier) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.notifier = notifier
|
||||
}
|
||||
|
||||
// Configured reports whether both registries are installed.
|
||||
func (s *Service) Configured() bool {
|
||||
return s != nil && s.registry != nil && s.collectibles != nil
|
||||
}
|
||||
|
||||
func (s *Service) registryStore() (store.UsernameRegistryStore, error) {
|
||||
if s == nil || s.registry == nil {
|
||||
return nil, fmt.Errorf("username registry store is not configured")
|
||||
}
|
||||
return s.registry, nil
|
||||
}
|
||||
|
||||
func (s *Service) collectibleStore() (store.CollectibleUsernameStore, error) {
|
||||
if s == nil || s.collectibles == nil {
|
||||
return nil, fmt.Errorf("collectible username store is not configured")
|
||||
}
|
||||
return s.collectibles, nil
|
||||
}
|
||||
|
||||
// PeerUsernames returns the peer's username vector in projection order.
|
||||
func (s *Service) PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error) {
|
||||
registry, err := s.registryStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validPeer(peer) {
|
||||
return nil, nil
|
||||
}
|
||||
list, err := registry.PeerUsernames(ctx, peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return domain.SortUsernames(list), nil
|
||||
}
|
||||
|
||||
// UsernamesBatch resolves several peers in one round trip. Peers holding no
|
||||
// usernames are absent from the result.
|
||||
func (s *Service) UsernamesBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
|
||||
registry, err := s.registryStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unique := make([]domain.Peer, 0, len(peers))
|
||||
seen := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
if !validPeer(peer) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[peer]; ok {
|
||||
continue
|
||||
}
|
||||
seen[peer] = struct{}{}
|
||||
unique = append(unique, peer)
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return map[domain.Peer][]domain.Username{}, nil
|
||||
}
|
||||
batch, err := registry.PeerUsernamesBatch(ctx, unique)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[domain.Peer][]domain.Username, len(batch))
|
||||
for peer, list := range batch {
|
||||
if len(list) == 0 {
|
||||
continue
|
||||
}
|
||||
out[peer] = domain.SortUsernames(list)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ToggleUsername activates or deactivates one collectible row. The editable
|
||||
// slot is never touched: it is owned by account/channels.updateUsername.
|
||||
func (s *Service) ToggleUsername(ctx context.Context, peer domain.Peer, username string, active bool) (bool, error) {
|
||||
registry, err := s.registryStore()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !validPeer(peer) {
|
||||
return false, ErrPeerInvalid
|
||||
}
|
||||
username = domain.NormalizeUsername(username)
|
||||
if username == "" {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
current, err := registry.PeerUsernames(ctx, peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := domain.ValidateUsernameToggle(current, username, active); err != nil {
|
||||
return false, err
|
||||
}
|
||||
changed, err := registry.SetUsernameActive(ctx, peer, username, active)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if changed {
|
||||
s.notifyPeers(ctx, peer)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// ReorderUsernames rewrites the collectible order. order must be a permutation
|
||||
// of the peer's collectible usernames; the editable slot always projects first.
|
||||
func (s *Service) ReorderUsernames(ctx context.Context, peer domain.Peer, order []string) (bool, error) {
|
||||
registry, err := s.registryStore()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !validPeer(peer) {
|
||||
return false, ErrPeerInvalid
|
||||
}
|
||||
normalized := make([]string, 0, len(order))
|
||||
for _, name := range order {
|
||||
normalized = append(normalized, domain.NormalizeUsername(name))
|
||||
}
|
||||
current, err := registry.PeerUsernames(ctx, peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := domain.ValidateUsernameReorder(current, normalized); err != nil {
|
||||
return false, err
|
||||
}
|
||||
changed, err := registry.ReorderUsernames(ctx, peer, normalized)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if changed {
|
||||
s.notifyPeers(ctx, peer)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// DeactivateAllUsernames clears the active flag on every collectible row.
|
||||
func (s *Service) DeactivateAllUsernames(ctx context.Context, peer domain.Peer) (bool, error) {
|
||||
registry, err := s.registryStore()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !validPeer(peer) {
|
||||
return false, ErrPeerInvalid
|
||||
}
|
||||
changed, err := registry.DeactivateAllUsernames(ctx, peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if changed {
|
||||
s.notifyPeers(ctx, peer)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// CollectibleInfo returns the fragment.collectibleInfo projection for a name.
|
||||
func (s *Service) CollectibleInfo(ctx context.Context, username string) (domain.CollectibleInfo, error) {
|
||||
asset, err := s.Collectible(ctx, username)
|
||||
if err != nil {
|
||||
return domain.CollectibleInfo{}, err
|
||||
}
|
||||
return asset.Info(), nil
|
||||
}
|
||||
|
||||
// Collectible looks up the asset behind a collectible username.
|
||||
func (s *Service) Collectible(ctx context.Context, username string) (domain.CollectibleUsername, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, err
|
||||
}
|
||||
username = domain.NormalizeUsername(username)
|
||||
if !domain.ValidCollectibleUsername(username) {
|
||||
return domain.CollectibleUsername{}, domain.ErrUsernameInvalid
|
||||
}
|
||||
return collectibles.CollectibleUsername(ctx, username)
|
||||
}
|
||||
|
||||
// Mint creates a collectible asset, optionally assigning it in the same
|
||||
// command. An empty URL is rendered from the configured template and an unset
|
||||
// purchase date is stamped with the service clock, so the stored provenance is
|
||||
// always complete and reproducible.
|
||||
func (s *Service) Mint(ctx context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
req.Actor = strings.TrimSpace(req.Actor)
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
||||
if strings.TrimSpace(req.URL) == "" {
|
||||
req.URL = s.CollectibleURL(req.Username)
|
||||
}
|
||||
if req.PurchaseDate.IsZero() {
|
||||
req.PurchaseDate = s.now().UTC()
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
asset, created, err := collectibles.MintCollectibleUsername(ctx, req)
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
if created {
|
||||
s.notifyPeers(ctx, req.Owner, asset.Owner)
|
||||
}
|
||||
return asset, created, nil
|
||||
}
|
||||
|
||||
// Transfer moves the asset to req.To, either out of the vault or from the
|
||||
// current holder. Both the previous and the new holder are invalidated.
|
||||
func (s *Service) Transfer(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
req.Actor = strings.TrimSpace(req.Actor)
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
|
||||
asset, changed, err := collectibles.TransferCollectibleUsername(ctx, req)
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
if changed {
|
||||
s.notifyPeers(ctx, previousOwner, req.To, asset.Owner)
|
||||
}
|
||||
return asset, changed, nil
|
||||
}
|
||||
|
||||
// Revoke returns the asset to the vault, or burns it when req.Burn is set.
|
||||
func (s *Service) Revoke(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
req.Actor = strings.TrimSpace(req.Actor)
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
|
||||
asset, changed, err := collectibles.RevokeCollectibleUsername(ctx, req)
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
if changed {
|
||||
s.notifyPeers(ctx, previousOwner, asset.Owner)
|
||||
}
|
||||
return asset, changed, nil
|
||||
}
|
||||
|
||||
// Delete removes an asset outright, releasing its name and discarding its
|
||||
// provenance. Revoke with Burn retires an asset but keeps the history; this is
|
||||
// the operator's escape hatch for an asset issued by mistake.
|
||||
//
|
||||
// The previous owner is notified exactly like a revoke: the peer's projection
|
||||
// still carries the username until it is invalidated.
|
||||
func (s *Service) Delete(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
req.Actor = strings.TrimSpace(req.Actor)
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
||||
if err := req.Validate(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
|
||||
deleted, err := collectibles.DeleteCollectibleUsername(ctx, req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if deleted {
|
||||
s.notifyPeers(ctx, previousOwner, domain.Peer{})
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// List is the admin listing query. The limit is always bounded, so an
|
||||
// unfiltered operator request can never ask the store for an unbounded scan.
|
||||
func (s *Service) List(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter.Status != "" && !filter.Status.Valid() {
|
||||
return nil, domain.ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
if filter.Owner.Type != "" && !validPeer(filter.Owner) {
|
||||
return nil, domain.ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
filter.Query = domain.NormalizeUsername(filter.Query)
|
||||
filter.Limit = clampLimit(filter.Limit, defaultListLimit, maxListLimit)
|
||||
return collectibles.ListCollectibleUsernames(ctx, filter)
|
||||
}
|
||||
|
||||
// Transfers returns the provenance log of one asset, newest first.
|
||||
func (s *Service) Transfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if collectibleID <= 0 {
|
||||
return nil, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
return collectibles.CollectibleUsernameTransfers(ctx, collectibleID, clampLimit(limit, defaultTransferLimit, maxTransferLimit))
|
||||
}
|
||||
|
||||
// CollectibleURL renders the asset landing URL for a name. The operator
|
||||
// template wins; {username} is substituted when present and appended as a path
|
||||
// segment when it is not. Without a template the public-link default route is
|
||||
// used, and without any configured root the URL stays empty rather than
|
||||
// pointing at an unrelated host.
|
||||
func (s *Service) CollectibleURL(username string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
username = domain.NormalizeUsername(username)
|
||||
if username == "" {
|
||||
return ""
|
||||
}
|
||||
template := strings.TrimSpace(s.urlTemplate)
|
||||
if template != "" {
|
||||
if strings.Contains(template, usernamePlaceholder) {
|
||||
return strings.ReplaceAll(template, usernamePlaceholder, username)
|
||||
}
|
||||
return strings.TrimRight(template, "/") + "/" + username
|
||||
}
|
||||
if strings.TrimSpace(s.publicBaseURL) == "" {
|
||||
return ""
|
||||
}
|
||||
return links.Build(s.publicBaseURL, defaultCollectibleURLPath+"/"+username, nil)
|
||||
}
|
||||
|
||||
// currentOwner reads the holder before a lifecycle mutation so the previous
|
||||
// peer's projection is invalidated too. It is best effort: a missing or
|
||||
// unreadable asset only means there is no extra peer to notify, and the
|
||||
// mutation itself remains the authority.
|
||||
func (s *Service) currentOwner(ctx context.Context, collectibles store.CollectibleUsernameStore, username string) domain.Peer {
|
||||
asset, err := collectibles.CollectibleUsername(ctx, username)
|
||||
if err != nil {
|
||||
if !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
|
||||
s.log.Debug("read collectible username owner before mutation",
|
||||
zap.String("username", username),
|
||||
zap.Error(err))
|
||||
}
|
||||
return domain.Peer{}
|
||||
}
|
||||
if !asset.Owned() {
|
||||
return domain.Peer{}
|
||||
}
|
||||
return asset.Owner
|
||||
}
|
||||
|
||||
// notifyPeers invalidates projections and pushes updates for every distinct
|
||||
// affected peer. Notification is best effort: the registry mutation already
|
||||
// committed, and a failed push converges through the client's next
|
||||
// authoritative peer read.
|
||||
func (s *Service) notifyPeers(ctx context.Context, peers ...domain.Peer) {
|
||||
if s == nil || s.notifier == nil {
|
||||
return
|
||||
}
|
||||
seen := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
if !validPeer(peer) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[peer]; ok {
|
||||
continue
|
||||
}
|
||||
seen[peer] = struct{}{}
|
||||
if err := s.notifier.NotifyPeerUsernamesChanged(ctx, peer); err != nil {
|
||||
s.log.Warn("notify collectible username change failed",
|
||||
zap.String("peer_type", string(peer.Type)),
|
||||
zap.Int64("peer_id", peer.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validPeer(peer domain.Peer) bool {
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||||
return peer.ID > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func clampLimit(limit, fallback, maximum int) int {
|
||||
if limit <= 0 {
|
||||
return fallback
|
||||
}
|
||||
if limit > maximum {
|
||||
return maximum
|
||||
}
|
||||
return limit
|
||||
}
|
||||
734
internal/app/usernames/service_test.go
Normal file
734
internal/app/usernames/service_test.go
Normal file
|
|
@ -0,0 +1,734 @@
|
|||
package usernames
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
testUser = domain.Peer{Type: domain.PeerTypeUser, ID: 42}
|
||||
testChannel = domain.Peer{Type: domain.PeerTypeChannel, ID: 77}
|
||||
testClock = time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC)
|
||||
)
|
||||
|
||||
type toggleCall struct {
|
||||
peer domain.Peer
|
||||
username string
|
||||
active bool
|
||||
}
|
||||
|
||||
// fakeRegistry is an in-memory domain.Username registry recording exactly what
|
||||
// the service asked it to do, so the tests can assert normalisation reached the
|
||||
// store and validation did not.
|
||||
type fakeRegistry struct {
|
||||
lists map[domain.Peer][]domain.Username
|
||||
toggles []toggleCall
|
||||
orders [][]string
|
||||
clears []domain.Peer
|
||||
changed bool
|
||||
batchErr error
|
||||
}
|
||||
|
||||
func newFakeRegistry() *fakeRegistry {
|
||||
return &fakeRegistry{lists: map[domain.Peer][]domain.Username{}, changed: true}
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) PeerUsernames(_ context.Context, peer domain.Peer) ([]domain.Username, error) {
|
||||
if f.batchErr != nil {
|
||||
return nil, f.batchErr
|
||||
}
|
||||
return append([]domain.Username(nil), f.lists[peer]...), nil
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) PeerUsernamesBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
|
||||
if f.batchErr != nil {
|
||||
return nil, f.batchErr
|
||||
}
|
||||
out := make(map[domain.Peer][]domain.Username, len(peers))
|
||||
for _, peer := range peers {
|
||||
if list, ok := f.lists[peer]; ok {
|
||||
out[peer] = append([]domain.Username(nil), list...)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) SetUsernameActive(_ context.Context, peer domain.Peer, username string, active bool) (bool, error) {
|
||||
f.toggles = append(f.toggles, toggleCall{peer: peer, username: username, active: active})
|
||||
return f.changed, nil
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) ReorderUsernames(_ context.Context, _ domain.Peer, order []string) (bool, error) {
|
||||
f.orders = append(f.orders, append([]string(nil), order...))
|
||||
return f.changed, nil
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) DeactivateAllUsernames(_ context.Context, peer domain.Peer) (bool, error) {
|
||||
f.clears = append(f.clears, peer)
|
||||
return f.changed, nil
|
||||
}
|
||||
|
||||
// fakeCollectibles records the lifecycle commands and serves stored assets.
|
||||
type fakeCollectibles struct {
|
||||
assets map[string]domain.CollectibleUsername
|
||||
mints []domain.MintCollectibleUsernameRequest
|
||||
transfers []domain.TransferCollectibleUsernameRequest
|
||||
revokes []domain.RevokeCollectibleUsernameRequest
|
||||
deletes []domain.DeleteCollectibleUsernameRequest
|
||||
filters []domain.CollectibleUsernameFilter
|
||||
logLimits []int
|
||||
created bool
|
||||
changed bool
|
||||
}
|
||||
|
||||
func newFakeCollectibles() *fakeCollectibles {
|
||||
return &fakeCollectibles{assets: map[string]domain.CollectibleUsername{}, created: true, changed: true}
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) MintCollectibleUsername(_ context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.mints = append(f.mints, req)
|
||||
asset := domain.CollectibleUsername{
|
||||
ID: int64(len(f.mints)), Username: req.Username, Status: domain.CollectibleUsernameStatusVault,
|
||||
PurchaseDate: req.PurchaseDate, Currency: req.Currency, Amount: req.Amount, URL: req.URL,
|
||||
}
|
||||
if req.Owner.Type != "" {
|
||||
asset.Status = domain.CollectibleUsernameStatusOwned
|
||||
asset.Owner = req.Owner
|
||||
asset.OriginalOwner = req.Owner
|
||||
}
|
||||
f.assets[strings.ToLower(req.Username)] = asset
|
||||
return asset, f.created, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) TransferCollectibleUsername(_ context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.transfers = append(f.transfers, req)
|
||||
asset := f.assets[strings.ToLower(req.Username)]
|
||||
asset.Username = req.Username
|
||||
asset.Status = domain.CollectibleUsernameStatusOwned
|
||||
asset.Owner = req.To
|
||||
asset.TransferCount++
|
||||
f.assets[strings.ToLower(req.Username)] = asset
|
||||
return asset, f.changed, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) RevokeCollectibleUsername(_ context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.revokes = append(f.revokes, req)
|
||||
asset := f.assets[strings.ToLower(req.Username)]
|
||||
asset.Username = req.Username
|
||||
asset.Owner = domain.Peer{}
|
||||
asset.Status = domain.CollectibleUsernameStatusVault
|
||||
if req.Burn {
|
||||
asset.Status = domain.CollectibleUsernameStatusBurned
|
||||
}
|
||||
f.assets[strings.ToLower(req.Username)] = asset
|
||||
return asset, f.changed, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) DeleteCollectibleUsername(_ context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
|
||||
f.deletes = append(f.deletes, req)
|
||||
key := strings.ToLower(req.Username)
|
||||
if _, ok := f.assets[key]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(f.assets, key)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) CollectibleUsername(_ context.Context, username string) (domain.CollectibleUsername, error) {
|
||||
asset, ok := f.assets[strings.ToLower(username)]
|
||||
if !ok {
|
||||
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) CollectibleUsernameByID(_ context.Context, id int64) (domain.CollectibleUsername, error) {
|
||||
for _, asset := range f.assets {
|
||||
if asset.ID == id {
|
||||
return asset, nil
|
||||
}
|
||||
}
|
||||
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) ListCollectibleUsernames(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
|
||||
f.filters = append(f.filters, filter)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) CollectibleUsernameTransfers(_ context.Context, _ int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
f.logLimits = append(f.logLimits, limit)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// recordingNotifier captures the peers whose projections were invalidated.
|
||||
type recordingNotifier struct {
|
||||
peers []domain.Peer
|
||||
err error
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) NotifyPeerUsernamesChanged(_ context.Context, peer domain.Peer) error {
|
||||
n.peers = append(n.peers, peer)
|
||||
return n.err
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T, registry *fakeRegistry, collectibles *fakeCollectibles, opts ...Option) (*Service, *recordingNotifier) {
|
||||
t.Helper()
|
||||
notifier := &recordingNotifier{}
|
||||
base := []Option{
|
||||
WithRegistryStore(registry),
|
||||
WithCollectibleStore(collectibles),
|
||||
WithNotifier(notifier),
|
||||
WithClock(func() time.Time { return testClock }),
|
||||
}
|
||||
return NewService(append(base, opts...)...), notifier
|
||||
}
|
||||
|
||||
func TestPeerUsernamesProjectsStoredOrder(t *testing.T) {
|
||||
// Legacy numbering: the editable slot and the first collectible both carry
|
||||
// sort_order 0, and the editable slot wins that tie, so a peer that never
|
||||
// reordered anything projects its own username first.
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "zeta", Active: true, SortOrder: 1, CollectibleID: 2},
|
||||
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1},
|
||||
{Username: "editable", Active: true, Editable: true, SortOrder: 0},
|
||||
}
|
||||
service, _ := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
if got := projectedNames(t, service); got != "editable,alpha,zeta" {
|
||||
t.Fatalf("projection order = %v, want editable,alpha,zeta", got)
|
||||
}
|
||||
|
||||
// After a reorder that made a collectible primary, stored order decides and
|
||||
// the editable slot is no longer first: clients show usernames[0] as primary.
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "zeta", Active: true, SortOrder: 2, CollectibleID: 2},
|
||||
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1},
|
||||
{Username: "editable", Active: true, Editable: true, SortOrder: 1},
|
||||
}
|
||||
if got := projectedNames(t, service); got != "alpha,editable,zeta" {
|
||||
t.Fatalf("reordered projection = %v, want alpha,editable,zeta", got)
|
||||
}
|
||||
}
|
||||
|
||||
func projectedNames(t *testing.T, service *Service) string {
|
||||
t.Helper()
|
||||
list, err := service.PeerUsernames(context.Background(), testUser)
|
||||
if err != nil {
|
||||
t.Fatalf("PeerUsernames: %v", err)
|
||||
}
|
||||
got := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
got = append(got, item.Username)
|
||||
}
|
||||
return strings.Join(got, ",")
|
||||
}
|
||||
|
||||
func TestUsernamesBatchSkipsInvalidAndEmptyPeers(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{{Username: "alpha", Active: true, CollectibleID: 1}}
|
||||
registry.lists[testChannel] = nil
|
||||
service, _ := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
batch, err := service.UsernamesBatch(context.Background(), []domain.Peer{testUser, testUser, testChannel, {}, {Type: domain.PeerTypeUser}})
|
||||
if err != nil {
|
||||
t.Fatalf("UsernamesBatch: %v", err)
|
||||
}
|
||||
if len(batch) != 1 || len(batch[testUser]) != 1 {
|
||||
t.Fatalf("batch = %#v, want only the peer holding usernames", batch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleUsernameNormalizesBeforeStore(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "editable", Active: true, Editable: true},
|
||||
{Username: "Nft_One", Active: false, CollectibleID: 1},
|
||||
}
|
||||
service, notifier := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
changed, err := service.ToggleUsername(context.Background(), testUser, " @Nft_One ", true)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("ToggleUsername = %v, %v", changed, err)
|
||||
}
|
||||
if len(registry.toggles) != 1 || registry.toggles[0].username != "Nft_One" || !registry.toggles[0].active {
|
||||
t.Fatalf("store toggles = %#v, want normalized Nft_One", registry.toggles)
|
||||
}
|
||||
if len(notifier.peers) != 1 || notifier.peers[0] != testUser {
|
||||
t.Fatalf("notified peers = %#v, want the toggled peer", notifier.peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleUsernameValidatesBeforeStore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
list []domain.Username
|
||||
username string
|
||||
active bool
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "editable slot is not collectible",
|
||||
list: []domain.Username{{Username: "editable", Active: true, Editable: true}},
|
||||
username: "editable",
|
||||
wantErr: domain.ErrUsernameNotCollectible,
|
||||
},
|
||||
{
|
||||
name: "unknown username",
|
||||
list: []domain.Username{{Username: "alpha", Active: true, CollectibleID: 1}},
|
||||
username: "missing",
|
||||
wantErr: domain.ErrUsernameNotOccupied,
|
||||
},
|
||||
{
|
||||
name: "empty username",
|
||||
list: []domain.Username{{Username: "alpha", Active: true, CollectibleID: 1}},
|
||||
username: "@",
|
||||
wantErr: domain.ErrUsernameInvalid,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = test.list
|
||||
service, notifier := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
_, err := service.ToggleUsername(context.Background(), testUser, test.username, test.active)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("ToggleUsername error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if len(registry.toggles) != 0 {
|
||||
t.Fatalf("store was called with invalid input: %#v", registry.toggles)
|
||||
}
|
||||
if len(notifier.peers) != 0 {
|
||||
t.Fatalf("notifier ran for a rejected toggle: %#v", notifier.peers)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderUsernamesNormalizesPermutation(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "editable", Active: true, Editable: true},
|
||||
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1},
|
||||
{Username: "zeta", Active: true, SortOrder: 1, CollectibleID: 2},
|
||||
}
|
||||
service, notifier := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
changed, err := service.ReorderUsernames(context.Background(), testUser, []string{"@zeta", " alpha ", "editable"})
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("ReorderUsernames = %v, %v", changed, err)
|
||||
}
|
||||
if len(registry.orders) != 1 || strings.Join(registry.orders[0], ",") != "zeta,alpha,editable" {
|
||||
t.Fatalf("store order = %#v, want normalized zeta,alpha,editable", registry.orders)
|
||||
}
|
||||
if len(notifier.peers) != 1 {
|
||||
t.Fatalf("notified peers = %#v, want one", notifier.peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderUsernamesRejectsIncompletePermutation(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "alpha", Active: true, CollectibleID: 1},
|
||||
{Username: "zeta", Active: true, CollectibleID: 2},
|
||||
}
|
||||
service, _ := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
if _, err := service.ReorderUsernames(context.Background(), testUser, []string{"alpha"}); !errors.Is(err, domain.ErrUsernameOrderInvalid) {
|
||||
t.Fatalf("ReorderUsernames error = %v, want ErrUsernameOrderInvalid", err)
|
||||
}
|
||||
if len(registry.orders) != 0 {
|
||||
t.Fatalf("store was called with a non-permutation: %#v", registry.orders)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReorderUsernamesAcceptsTheEditableSlot is the report "channels.reorderUsernames
|
||||
// answers USERNAME_INVALID": Telegram Desktop sends the whole visible list, and
|
||||
// core.telegram.org/api/fragment requires exactly that ("all currently active
|
||||
// usernames must be specified"), so the editable slot is a legitimate member of
|
||||
// the order -- including as its first entry, and including when it is the only
|
||||
// username the peer has.
|
||||
func TestReorderUsernamesAcceptsTheEditableSlot(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testChannel] = []domain.Username{
|
||||
{Username: "chan_slot", Active: true, Editable: true},
|
||||
}
|
||||
service, _ := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
if _, err := service.ReorderUsernames(context.Background(), testChannel, []string{"chan_slot"}); err != nil {
|
||||
t.Fatalf("editable-only reorder: %v", err)
|
||||
}
|
||||
if len(registry.orders) != 1 || strings.Join(registry.orders[0], ",") != "chan_slot" {
|
||||
t.Fatalf("store order = %#v, want chan_slot", registry.orders)
|
||||
}
|
||||
|
||||
// An inactive collectible does not have to be listed, and listing an unknown
|
||||
// name is still rejected.
|
||||
registry.lists[testChannel] = []domain.Username{
|
||||
{Username: "chan_slot", Active: true, Editable: true},
|
||||
{Username: "hidden", Active: false, CollectibleID: 7},
|
||||
}
|
||||
if _, err := service.ReorderUsernames(context.Background(), testChannel, []string{"chan_slot"}); err != nil {
|
||||
t.Fatalf("reorder omitting an inactive collectible: %v", err)
|
||||
}
|
||||
if _, err := service.ReorderUsernames(context.Background(), testChannel, []string{"chan_slot", "nothere"}); !errors.Is(err, domain.ErrUsernameOrderInvalid) {
|
||||
t.Fatalf("reorder with an unknown name = %v, want ErrUsernameOrderInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeactivateAllUsernamesNotifiesPeer(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
service, notifier := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
changed, err := service.DeactivateAllUsernames(context.Background(), testChannel)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("DeactivateAllUsernames = %v, %v", changed, err)
|
||||
}
|
||||
if len(registry.clears) != 1 || registry.clears[0] != testChannel {
|
||||
t.Fatalf("store clears = %#v", registry.clears)
|
||||
}
|
||||
if len(notifier.peers) != 1 || notifier.peers[0] != testChannel {
|
||||
t.Fatalf("notified peers = %#v", notifier.peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintRendersCollectibleURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts []Option
|
||||
url string
|
||||
wantURL string
|
||||
username string
|
||||
}{
|
||||
{
|
||||
name: "public-link default route",
|
||||
opts: []Option{WithPublicBaseURL("https://example.test")},
|
||||
username: "alpha",
|
||||
wantURL: "https://example.test/nft/username/alpha",
|
||||
},
|
||||
{
|
||||
name: "template placeholder",
|
||||
opts: []Option{WithURLTemplate("https://frag.example/u/{username}?ref=1"), WithPublicBaseURL("https://example.test")},
|
||||
username: "alpha",
|
||||
wantURL: "https://frag.example/u/alpha?ref=1",
|
||||
},
|
||||
{
|
||||
name: "template without placeholder appends the name",
|
||||
opts: []Option{WithURLTemplate("https://frag.example/u/")},
|
||||
username: "alpha",
|
||||
wantURL: "https://frag.example/u/alpha",
|
||||
},
|
||||
{
|
||||
name: "explicit request URL wins",
|
||||
opts: []Option{WithURLTemplate("https://frag.example/u/{username}")},
|
||||
username: "alpha",
|
||||
url: "https://operator.example/custom",
|
||||
wantURL: "https://operator.example/custom",
|
||||
},
|
||||
{
|
||||
name: "no template and no base URL keeps the URL empty",
|
||||
username: "alpha",
|
||||
wantURL: "",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
service, _ := newTestService(t, newFakeRegistry(), collectibles, test.opts...)
|
||||
|
||||
asset, created, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{
|
||||
Username: "@" + test.username, Currency: domain.CollectibleCurrencyStars, Amount: 1000, URL: test.url,
|
||||
})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("Mint = %v, %v", created, err)
|
||||
}
|
||||
if asset.URL != test.wantURL {
|
||||
t.Fatalf("asset URL = %q, want %q", asset.URL, test.wantURL)
|
||||
}
|
||||
if len(collectibles.mints) != 1 {
|
||||
t.Fatalf("mints = %d, want 1", len(collectibles.mints))
|
||||
}
|
||||
if got := collectibles.mints[0].Username; got != test.username {
|
||||
t.Fatalf("stored username = %q, want normalized %q", got, test.username)
|
||||
}
|
||||
if !collectibles.mints[0].PurchaseDate.Equal(testClock) {
|
||||
t.Fatalf("purchase date = %v, want the service clock %v", collectibles.mints[0].PurchaseDate, testClock)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintValidatesBeforeStore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req domain.MintCollectibleUsernameRequest
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "username too short",
|
||||
req: domain.MintCollectibleUsernameRequest{Username: "ab", Currency: domain.CollectibleCurrencyStars},
|
||||
wantErr: domain.ErrUsernameInvalid,
|
||||
},
|
||||
{
|
||||
name: "unsupported currency",
|
||||
req: domain.MintCollectibleUsernameRequest{Username: "alpha", Currency: "EUR"},
|
||||
wantErr: domain.ErrCollectibleCurrencyInvalid,
|
||||
},
|
||||
{
|
||||
name: "crypto amount without currency",
|
||||
req: domain.MintCollectibleUsernameRequest{Username: "alpha", Currency: domain.CollectibleCurrencyStars, CryptoAmount: 5},
|
||||
wantErr: domain.ErrCollectibleCurrencyInvalid,
|
||||
},
|
||||
{
|
||||
name: "owner peer is not a username holder",
|
||||
req: domain.MintCollectibleUsernameRequest{
|
||||
Username: "alpha", Currency: domain.CollectibleCurrencyStars,
|
||||
Owner: domain.Peer{Type: domain.PeerTypeCommunity, ID: 5},
|
||||
},
|
||||
wantErr: domain.ErrCollectibleUsernameStateInvalid,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
service, notifier := newTestService(t, newFakeRegistry(), collectibles)
|
||||
|
||||
if _, _, err := service.Mint(context.Background(), test.req); !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Mint error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if len(collectibles.mints) != 0 {
|
||||
t.Fatalf("store was called with invalid input: %#v", collectibles.mints)
|
||||
}
|
||||
if len(notifier.peers) != 0 {
|
||||
t.Fatalf("notifier ran for a rejected mint: %#v", notifier.peers)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintNotifiesOwnerOnly(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
service, notifier := newTestService(t, newFakeRegistry(), collectibles, WithPublicBaseURL("https://example.test"))
|
||||
|
||||
if _, _, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{
|
||||
Username: "vaulted", Currency: domain.CollectibleCurrencyStars,
|
||||
}); err != nil {
|
||||
t.Fatalf("Mint vault: %v", err)
|
||||
}
|
||||
if len(notifier.peers) != 0 {
|
||||
t.Fatalf("vault mint notified %#v, want nothing", notifier.peers)
|
||||
}
|
||||
|
||||
if _, _, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{
|
||||
Username: "assigned", Currency: domain.CollectibleCurrencyStars, Owner: testUser,
|
||||
}); err != nil {
|
||||
t.Fatalf("Mint assigned: %v", err)
|
||||
}
|
||||
if len(notifier.peers) != 1 || notifier.peers[0] != testUser {
|
||||
t.Fatalf("notified peers = %#v, want the assigned owner", notifier.peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferNotifiesPreviousAndNewOwner(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
collectibles.assets["alpha"] = domain.CollectibleUsername{
|
||||
ID: 1, Username: "alpha", Status: domain.CollectibleUsernameStatusOwned, Owner: testUser,
|
||||
}
|
||||
service, notifier := newTestService(t, newFakeRegistry(), collectibles)
|
||||
|
||||
_, changed, err := service.Transfer(context.Background(), domain.TransferCollectibleUsernameRequest{
|
||||
Username: "@Alpha", To: testChannel, Actor: "admin", CommandKey: "cmd-1",
|
||||
})
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("Transfer = %v, %v", changed, err)
|
||||
}
|
||||
if len(collectibles.transfers) != 1 || collectibles.transfers[0].Username != "Alpha" {
|
||||
t.Fatalf("stored transfer = %#v, want normalized username", collectibles.transfers)
|
||||
}
|
||||
if len(notifier.peers) != 2 {
|
||||
t.Fatalf("notified peers = %#v, want previous and new owner", notifier.peers)
|
||||
}
|
||||
seen := map[domain.Peer]bool{notifier.peers[0]: true, notifier.peers[1]: true}
|
||||
if !seen[testUser] || !seen[testChannel] {
|
||||
t.Fatalf("notified peers = %#v, want %v and %v", notifier.peers, testUser, testChannel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeNotifiesPreviousOwner(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
collectibles.assets["alpha"] = domain.CollectibleUsername{
|
||||
ID: 1, Username: "alpha", Status: domain.CollectibleUsernameStatusOwned, Owner: testUser,
|
||||
}
|
||||
service, notifier := newTestService(t, newFakeRegistry(), collectibles)
|
||||
|
||||
asset, changed, err := service.Revoke(context.Background(), domain.RevokeCollectibleUsernameRequest{
|
||||
Username: "alpha", Burn: true, Actor: "admin",
|
||||
})
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("Revoke = %v, %v", changed, err)
|
||||
}
|
||||
if asset.Status != domain.CollectibleUsernameStatusBurned {
|
||||
t.Fatalf("asset status = %q, want burned", asset.Status)
|
||||
}
|
||||
if len(notifier.peers) != 1 || notifier.peers[0] != testUser {
|
||||
t.Fatalf("notified peers = %#v, want the previous owner", notifier.peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleInfoProjectsPurchaseRecord(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
collectibles.assets["alpha"] = domain.CollectibleUsername{
|
||||
ID: 1, Username: "alpha", Status: domain.CollectibleUsernameStatusOwned, Owner: testUser,
|
||||
PurchaseDate: testClock, Currency: domain.CollectibleCurrencyStars, Amount: 2500,
|
||||
URL: "https://example.test/nft/username/alpha",
|
||||
}
|
||||
service, _ := newTestService(t, newFakeRegistry(), collectibles)
|
||||
|
||||
info, err := service.CollectibleInfo(context.Background(), "@ALPHA")
|
||||
if err != nil {
|
||||
t.Fatalf("CollectibleInfo: %v", err)
|
||||
}
|
||||
if info.PurchaseDate != int(testClock.Unix()) || info.Amount != 2500 || info.Currency != domain.CollectibleCurrencyStars {
|
||||
t.Fatalf("collectible info = %#v", info)
|
||||
}
|
||||
if _, err := service.CollectibleInfo(context.Background(), "ab"); !errors.Is(err, domain.ErrUsernameInvalid) {
|
||||
t.Fatalf("CollectibleInfo short name error = %v, want ErrUsernameInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAndTransfersBoundThePage(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
service, _ := newTestService(t, newFakeRegistry(), collectibles)
|
||||
|
||||
if _, err := service.List(context.Background(), domain.CollectibleUsernameFilter{Query: " @Alpha ", Limit: 0}); err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if _, err := service.List(context.Background(), domain.CollectibleUsernameFilter{Limit: 100000}); err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(collectibles.filters) != 2 ||
|
||||
collectibles.filters[0].Limit != defaultListLimit || collectibles.filters[0].Query != "Alpha" ||
|
||||
collectibles.filters[1].Limit != maxListLimit {
|
||||
t.Fatalf("filters = %#v", collectibles.filters)
|
||||
}
|
||||
if _, err := service.List(context.Background(), domain.CollectibleUsernameFilter{Status: "sold"}); !errors.Is(err, domain.ErrCollectibleUsernameStateInvalid) {
|
||||
t.Fatalf("List accepted an unmodelled status")
|
||||
}
|
||||
|
||||
if _, err := service.Transfers(context.Background(), 7, 0); err != nil {
|
||||
t.Fatalf("Transfers: %v", err)
|
||||
}
|
||||
if len(collectibles.logLimits) != 1 || collectibles.logLimits[0] != defaultTransferLimit {
|
||||
t.Fatalf("transfer log limits = %#v", collectibles.logLimits)
|
||||
}
|
||||
if _, err := service.Transfers(context.Background(), 0, 10); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
|
||||
t.Fatalf("Transfers accepted a zero collectible id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceWithoutStoresReportsConfiguration(t *testing.T) {
|
||||
service := NewService()
|
||||
if service.Configured() {
|
||||
t.Fatal("Configured = true without stores")
|
||||
}
|
||||
if _, err := service.PeerUsernames(context.Background(), testUser); err == nil {
|
||||
t.Fatal("PeerUsernames accepted a missing registry store")
|
||||
}
|
||||
if _, err := service.ToggleUsername(context.Background(), testUser, "alpha", true); err == nil {
|
||||
t.Fatal("ToggleUsername accepted a missing registry store")
|
||||
}
|
||||
if _, _, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{Username: "alpha"}); err == nil {
|
||||
t.Fatal("Mint accepted a missing collectible store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilServiceIsSafe(t *testing.T) {
|
||||
var service *Service
|
||||
service.SetPeerUsernameNotifier(&recordingNotifier{})
|
||||
if service.Configured() {
|
||||
t.Fatal("nil service reported configured")
|
||||
}
|
||||
if url := service.CollectibleURL("alpha"); url != "" {
|
||||
t.Fatalf("nil service URL = %q", url)
|
||||
}
|
||||
if _, err := service.PeerUsernames(context.Background(), testUser); err == nil {
|
||||
t.Fatal("nil service PeerUsernames returned no error")
|
||||
}
|
||||
if _, _, err := service.Transfer(context.Background(), domain.TransferCollectibleUsernameRequest{Username: "alpha", To: testUser}); err == nil {
|
||||
t.Fatal("nil service Transfer returned no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierFailureDoesNotFailTheMutation(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "editable", Active: true, Editable: true},
|
||||
{Username: "alpha", Active: true, CollectibleID: 1},
|
||||
}
|
||||
notifier := &recordingNotifier{err: errors.New("push failed")}
|
||||
service := NewService(
|
||||
WithRegistryStore(registry),
|
||||
WithCollectibleStore(newFakeCollectibles()),
|
||||
WithNotifier(notifier),
|
||||
)
|
||||
|
||||
changed, err := service.ToggleUsername(context.Background(), testUser, "alpha", false)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("ToggleUsername = %v, %v; committed mutation must survive a failed push", changed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceDeleteNotifiesPreviousOwner covers the hard delete: the request is
|
||||
// normalised and validated before the store is touched, and the peer that held
|
||||
// the asset is invalidated so its projection stops advertising the username.
|
||||
func TestServiceDeleteNotifiesPreviousOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
registry := newFakeRegistry()
|
||||
collectibles := newFakeCollectibles()
|
||||
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 501}
|
||||
collectibles.assets["gone"] = domain.CollectibleUsername{
|
||||
ID: 9, Username: "Gone", Status: domain.CollectibleUsernameStatusOwned, Owner: holder,
|
||||
}
|
||||
svc, notifier := newTestService(t, registry, collectibles)
|
||||
|
||||
deleted, err := svc.Delete(ctx, domain.DeleteCollectibleUsernameRequest{
|
||||
Username: " @Gone ", Actor: "admin", Reason: "issued by mistake",
|
||||
})
|
||||
if err != nil || !deleted {
|
||||
t.Fatalf("delete: deleted=%v err=%v", deleted, err)
|
||||
}
|
||||
if len(collectibles.deletes) != 1 || collectibles.deletes[0].Username != "Gone" {
|
||||
t.Fatalf("store received %+v, want the normalised name", collectibles.deletes)
|
||||
}
|
||||
if len(notifier.peers) != 1 || notifier.peers[0] != holder {
|
||||
t.Fatalf("notified peers = %#v, want the previous owner %+v", notifier.peers, holder)
|
||||
}
|
||||
|
||||
// An invalid name never reaches the store.
|
||||
before := len(collectibles.deletes)
|
||||
if _, err := svc.Delete(ctx, domain.DeleteCollectibleUsernameRequest{Username: "no"}); err == nil {
|
||||
t.Fatalf("delete of a too-short name = nil error, want rejection")
|
||||
}
|
||||
if len(collectibles.deletes) != before {
|
||||
t.Fatalf("store was called with an invalid request: %+v", collectibles.deletes)
|
||||
}
|
||||
|
||||
// Nothing live left is not an error, and nothing is notified.
|
||||
notifier.peers = nil
|
||||
deleted, err = svc.Delete(ctx, domain.DeleteCollectibleUsernameRequest{
|
||||
Username: "absentname", Actor: "admin", Reason: "again",
|
||||
})
|
||||
if err != nil || deleted {
|
||||
t.Fatalf("delete of unknown name = %v err=%v, want (false, nil)", deleted, err)
|
||||
}
|
||||
if len(notifier.peers) != 0 {
|
||||
t.Fatalf("no-op delete notified %+v", notifier.peers)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
1422
internal/app/verification/service.go
Normal file
1422
internal/app/verification/service.go
Normal file
File diff suppressed because it is too large
Load diff
1416
internal/app/verification/service_test.go
Normal file
1416
internal/app/verification/service_test.go
Normal file
File diff suppressed because it is too large
Load diff
88
internal/app/verification/worker.go
Normal file
88
internal/app/verification/worker.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package verification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// defaultNotifyInterval matches the shipped
|
||||
// TELESRV_VERIFICATION_NOTIFY_INTERVAL default.
|
||||
const defaultNotifyInterval = 15 * time.Second
|
||||
|
||||
// NotificationWorker drains the applicant-notification outbox.
|
||||
//
|
||||
// A decision is committed together with its outbox row, never with a message
|
||||
// send: @verifybot may be blocked, the applicant may be deleted, and the panel
|
||||
// must not wait on either. Delivery is therefore a separate, retrying cycle over
|
||||
// durable rows, and this worker is only its cadence.
|
||||
type NotificationWorker struct {
|
||||
service *Service
|
||||
logger *zap.Logger
|
||||
interval time.Duration
|
||||
batch int
|
||||
}
|
||||
|
||||
// NewNotificationWorker creates the periodic delivery worker. Non-positive
|
||||
// interval/batch fall back to the shipped defaults, matching the rating
|
||||
// recompute worker's contract.
|
||||
func NewNotificationWorker(service *Service, logger *zap.Logger, interval time.Duration, batch int) *NotificationWorker {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = defaultNotifyInterval
|
||||
}
|
||||
if batch <= 0 {
|
||||
batch = defaultNotifyBatch
|
||||
}
|
||||
return &NotificationWorker{service: service, logger: logger, interval: interval, batch: batch}
|
||||
}
|
||||
|
||||
// Run delivers one batch immediately and then on every tick until ctx is done. A
|
||||
// disabled or store-less service exits immediately with one explicit log line
|
||||
// instead of ticking forever over a no-op.
|
||||
func (w *NotificationWorker) Run(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
if !w.service.Ready() {
|
||||
w.logger.Info("verification notification worker disabled",
|
||||
zap.Bool("enabled", w.service.Enabled()))
|
||||
return
|
||||
}
|
||||
w.runOnce(ctx)
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *NotificationWorker) runOnce(ctx context.Context) {
|
||||
if w == nil || w.service == nil {
|
||||
return
|
||||
}
|
||||
delivered, err := w.service.RunNotificationCycle(ctx, w.batch)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
w.logger.Warn("verification notification cycle failed",
|
||||
zap.Int("delivered", delivered),
|
||||
zap.Int("batch", w.batch),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
if delivered > 0 {
|
||||
w.logger.Info("verification notification cycle completed",
|
||||
zap.Int("delivered", delivered),
|
||||
zap.Int("batch", w.batch))
|
||||
}
|
||||
}
|
||||
22
internal/compat/android/report.go
Normal file
22
internal/compat/android/report.go
Normal 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 == ""
|
||||
}
|
||||
32
internal/compat/android/report_test.go
Normal file
32
internal/compat/android/report_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
24
internal/compat/android/stars.go
Normal file
24
internal/compat/android/stars.go
Normal 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)
|
||||
}
|
||||
30
internal/compat/android/stars_test.go
Normal file
30
internal/compat/android/stars_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
46
internal/compat/ios/theme_colors.go
Normal file
46
internal/compat/ios/theme_colors.go
Normal 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))
|
||||
}
|
||||
75
internal/compat/ios/theme_colors_test.go
Normal file
75
internal/compat/ios/theme_colors_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue