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

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

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

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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