Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877
This commit is contained in:
commit
ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions
561
internal/adminapi/botverification.go
Normal file
561
internal/adminapi/botverification.go
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
package adminapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Third-party bot verification over the admin API
|
||||
// (core.telegram.org/api/bots/verification).
|
||||
//
|
||||
// These are the mirror routes of the panel's own /api/botverification endpoints:
|
||||
// the panel reads straight from PostgreSQL for speed, while an integration holding
|
||||
// a scoped token reads it here. Every mutation only ever travels this way, so the
|
||||
// command journal, the status machine and the optimistic lock are enforced in one
|
||||
// place.
|
||||
//
|
||||
// This is NOT the official platform verification surface in verification.go. The
|
||||
// two mechanisms own separate tables, separate permissions (botverification.* vs
|
||||
// verification.*) and separate routes, and neither reads the other's state: a
|
||||
// third-party verifier must never be able to mint a platform checkmark.
|
||||
//
|
||||
// Every int64 crosses the JSON boundary as a decimal string. Bot ids, peer ids,
|
||||
// custom emoji document ids and the optimistic-locking version all exceed the
|
||||
// range a JSON number holds exactly, and a rounded version would decide the wrong
|
||||
// revision of a row.
|
||||
|
||||
// handleBotVerifiers lists verifier bots.
|
||||
func (s *Server) handleBotVerifiers(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := s.svc.BotVerifiers(r.Context(), queryBool(query.Get("enabled_only")), limit)
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, botVerifierResponse(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
|
||||
}
|
||||
|
||||
// handleVerificationIcons lists the icon catalogue.
|
||||
func (s *Server) handleVerificationIcons(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := s.svc.VerificationIcons(r.Context(), queryBool(query.Get("active_only")), limit)
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, verificationIconResponse(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
|
||||
}
|
||||
|
||||
// handleCustomVerifications lists granted marks with keyset paging.
|
||||
func (s *Server) handleCustomVerifications(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
peerType, ok := botVerificationPeerType(w, query.Get("peer_type"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
verifierBotID, ok := optionalQueryInt64(w, query, "verifier_bot_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
beforeID, ok := optionalQueryInt64(w, query, "before_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := s.svc.CustomVerifications(r.Context(), domain.CustomVerificationFilter{
|
||||
VerifierBotID: verifierBotID,
|
||||
PeerType: peerType,
|
||||
Query: query.Get("q"),
|
||||
BeforeID: beforeID,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, customVerificationResponse(item))
|
||||
}
|
||||
// The page bound is the use-case layer's, so has_more is derived from what came
|
||||
// back rather than from the limit the caller asked for.
|
||||
hasMore := limit > 0 && len(items) >= limit
|
||||
nextBeforeID := ""
|
||||
if hasMore && len(items) > 0 {
|
||||
nextBeforeID = strconv.FormatInt(items[len(items)-1].ID, 10)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rows": rows,
|
||||
"has_more": hasMore,
|
||||
"next_before_id": nextBeforeID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleCustomVerificationRequests is the third-party review queue.
|
||||
func (s *Server) handleCustomVerificationRequests(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
peerType, ok := botVerificationPeerType(w, query.Get("peer_type"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter := domain.CustomVerificationRequestFilter{
|
||||
PeerType: peerType,
|
||||
Query: query.Get("q"),
|
||||
}
|
||||
// status accepts a comma-separated list, so a "pending,approved" view is one
|
||||
// request rather than two.
|
||||
for _, raw := range strings.Split(query.Get("status"), ",") {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
status := domain.CustomVerificationRequestStatus(raw)
|
||||
if !status.Valid() {
|
||||
writeCodedError(w, http.StatusBadRequest, admin.CodeCustomVerificationStatusInvalid, "invalid status "+raw)
|
||||
return
|
||||
}
|
||||
filter.Statuses = append(filter.Statuses, status)
|
||||
}
|
||||
verifierBotID, ok := optionalQueryInt64(w, query, "verifier_bot_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.VerifierBotID = verifierBotID
|
||||
beforeID, ok := optionalQueryInt64(w, query, "before_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.BeforeID = beforeID
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.Limit = limit
|
||||
items, err := s.svc.CustomVerificationRequests(r.Context(), filter)
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, customVerificationRequestResponse(item))
|
||||
}
|
||||
hasMore := limit > 0 && len(items) >= limit
|
||||
nextBeforeID := ""
|
||||
if hasMore && len(items) > 0 {
|
||||
nextBeforeID = strconv.FormatInt(items[len(items)-1].ID, 10)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rows": rows,
|
||||
"has_more": hasMore,
|
||||
"next_before_id": nextBeforeID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleCustomVerificationRequest is one application with the verifier behind it
|
||||
// and whether the mark is on the peer right now.
|
||||
func (s *Server) handleCustomVerificationRequest(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, err := s.svc.CustomVerificationRequest(r.Context(), id)
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
body := map[string]any{"request": customVerificationRequestResponse(req)}
|
||||
// The verifier row is advisory: it may have been revoked since the application
|
||||
// was filed, and that must not turn the audit record into a 500. An absent row
|
||||
// is reported as a verifier with only its id, so the reviewer can see which bot
|
||||
// it was.
|
||||
if settings, err := s.svc.BotVerifier(r.Context(), req.VerifierBotID); err == nil {
|
||||
body["verifier"] = botVerifierResponse(settings)
|
||||
} else if errors.Is(err, domain.ErrVerifierNotFound) {
|
||||
body["verifier"] = botVerifierResponse(domain.BotVerifierSettings{BotID: req.VerifierBotID})
|
||||
} else {
|
||||
body["verifier"] = botVerifierResponse(domain.BotVerifierSettings{BotID: req.VerifierBotID})
|
||||
body["verifier_error"] = err.Error()
|
||||
}
|
||||
// mark_active tells "approved" apart from "approved and since stripped by the
|
||||
// operator", which is the one thing the status alone cannot say.
|
||||
if active, err := s.svc.CustomVerificationMarkActive(r.Context(), req.VerifierBotID, req.Peer); err == nil {
|
||||
body["mark_active"] = active
|
||||
} else {
|
||||
body["mark_active"] = false
|
||||
body["mark_error"] = err.Error()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// handleCustomVerificationCounts is the queue summary.
|
||||
func (s *Server) handleCustomVerificationCounts(w http.ResponseWriter, r *http.Request) {
|
||||
counts, err := s.svc.CustomVerificationRequestCounts(r.Context())
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"counts": customVerificationCountsResponse(counts)})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleGrantBotVerifier(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.GrantBotVerifierRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.GrantBotVerifier(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetBotVerifierEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetBotVerifierEnabledRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.SetBotVerifierEnabled(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeBotVerifier(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.RevokeBotVerifierRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.RevokeBotVerifier(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpsertVerificationIcon(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.UpsertVerificationIconRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.UpsertVerificationIcon(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetVerificationIconActive(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetVerificationIconActiveRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.SetVerificationIconActive(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeCustomVerification(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.RevokeCustomVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.RevokeCustomVerification(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleApproveBotVerification(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req admin.ApproveBotVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
// The path is the authority on which application is decided: a body naming a
|
||||
// different one would make the URL lie to the audit trail.
|
||||
req.RequestID = id
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.ApproveBotVerification(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRejectBotVerification(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req admin.RejectBotVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.RequestID = id
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.RejectBotVerification(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeBotVerification(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req admin.RevokeBotVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.RequestID = id
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.RevokeBotVerification(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// botVerifierResponse renders one verifier bot. The keys are the panel's row
|
||||
// field names, so the same shape reaches the browser whether it came from here or
|
||||
// from the panel's direct read.
|
||||
func botVerifierResponse(settings domain.BotVerifierSettings) map[string]any {
|
||||
out := map[string]any{
|
||||
"BotID": strconv.FormatInt(settings.BotID, 10),
|
||||
"IconDocumentID": strconv.FormatInt(settings.IconDocumentID, 10),
|
||||
"CompanyName": settings.CompanyName,
|
||||
"DefaultDescription": settings.DefaultDescription,
|
||||
"CanModifyCustomDescription": settings.CanModifyCustomDescription,
|
||||
"Enabled": settings.Enabled,
|
||||
"GrantedBy": settings.GrantedBy,
|
||||
"GrantReason": settings.GrantReason,
|
||||
"Version": strconv.FormatInt(settings.Version, 10),
|
||||
}
|
||||
if !settings.CreatedAt.IsZero() {
|
||||
out["CreatedAt"] = settings.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !settings.UpdatedAt.IsZero() {
|
||||
out["UpdatedAt"] = settings.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func verificationIconResponse(icon domain.VerificationIcon) map[string]any {
|
||||
out := map[string]any{
|
||||
"ID": strconv.FormatInt(icon.ID, 10),
|
||||
"DocumentID": strconv.FormatInt(icon.DocumentID, 10),
|
||||
"OwnerBotID": strconv.FormatInt(icon.OwnerBotID, 10),
|
||||
"Name": icon.Name,
|
||||
"Active": icon.Active,
|
||||
}
|
||||
if !icon.CreatedAt.IsZero() {
|
||||
out["CreatedAt"] = icon.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !icon.UpdatedAt.IsZero() {
|
||||
out["UpdatedAt"] = icon.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func customVerificationResponse(mark domain.CustomVerification) map[string]any {
|
||||
out := map[string]any{
|
||||
"ID": strconv.FormatInt(mark.ID, 10),
|
||||
"VerifierBotID": strconv.FormatInt(mark.VerifierBotID, 10),
|
||||
"PeerType": string(mark.Peer.Type),
|
||||
"PeerID": strconv.FormatInt(mark.Peer.ID, 10),
|
||||
"IconDocumentID": strconv.FormatInt(mark.IconDocumentID, 10),
|
||||
"Description": mark.Description,
|
||||
"Version": strconv.FormatInt(mark.Version, 10),
|
||||
}
|
||||
if !mark.CreatedAt.IsZero() {
|
||||
out["CreatedAt"] = mark.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !mark.UpdatedAt.IsZero() {
|
||||
out["UpdatedAt"] = mark.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func customVerificationRequestResponse(req domain.CustomVerificationRequest) map[string]any {
|
||||
out := map[string]any{
|
||||
"ID": strconv.FormatInt(req.ID, 10),
|
||||
"VerifierBotID": strconv.FormatInt(req.VerifierBotID, 10),
|
||||
"ApplicantUserID": strconv.FormatInt(req.ApplicantUserID, 10),
|
||||
"PeerType": string(req.Peer.Type),
|
||||
"PeerID": strconv.FormatInt(req.Peer.ID, 10),
|
||||
"PeerTitle": req.PeerTitle,
|
||||
"PeerUsername": req.PeerUsername,
|
||||
"Reason": req.Reason,
|
||||
"RequestedDescription": req.RequestedDescription,
|
||||
"Status": string(req.Status),
|
||||
"DecidedBy": req.DecidedBy,
|
||||
"DecisionReason": req.DecisionReason,
|
||||
// InternalNote is operator-only. It is exposed here because every caller of
|
||||
// this route already holds botverification.review, and it is the reviewer's
|
||||
// own handover note; it is never part of the applicant-facing projection.
|
||||
"InternalNote": req.InternalNote,
|
||||
"CorrelationID": req.CorrelationID,
|
||||
"Version": strconv.FormatInt(req.Version, 10),
|
||||
}
|
||||
if !req.CreatedAt.IsZero() {
|
||||
out["CreatedAt"] = req.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !req.UpdatedAt.IsZero() {
|
||||
out["UpdatedAt"] = req.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !req.ApprovedAt.IsZero() {
|
||||
out["ApprovedAt"] = req.ApprovedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !req.RejectedAt.IsZero() {
|
||||
out["RejectedAt"] = req.RejectedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// customVerificationCountsResponse renders the queue summary with every modelled
|
||||
// status present, so the panel never has to distinguish "zero" from "absent". The
|
||||
// values are decimal strings for the same exactness reason as the ids.
|
||||
func customVerificationCountsResponse(counts map[domain.CustomVerificationRequestStatus]int64) map[string]string {
|
||||
out := make(map[string]string, len(customVerificationStatusOrder))
|
||||
for _, status := range customVerificationStatusOrder {
|
||||
out[string(status)] = strconv.FormatInt(counts[status], 10)
|
||||
}
|
||||
for status, count := range counts {
|
||||
if _, ok := out[string(status)]; !ok {
|
||||
out[string(status)] = strconv.FormatInt(count, 10)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// customVerificationStatusOrder is the closed status set, in lifecycle order.
|
||||
var customVerificationStatusOrder = []domain.CustomVerificationRequestStatus{
|
||||
domain.CustomVerificationPending,
|
||||
domain.CustomVerificationApproved,
|
||||
domain.CustomVerificationRejected,
|
||||
domain.CustomVerificationRevoked,
|
||||
}
|
||||
|
||||
// botVerificationPeerType validates the peer filter against the peer kinds a
|
||||
// third-party mark can sit on. An unmodelled value is a 400 rather than an empty
|
||||
// result, so a typo is reported instead of silently returning nothing.
|
||||
func botVerificationPeerType(w http.ResponseWriter, raw string) (domain.PeerType, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", true
|
||||
}
|
||||
peerType := domain.PeerType(raw)
|
||||
if peerType != domain.PeerTypeUser && peerType != domain.PeerTypeChannel {
|
||||
writeCodedError(w, http.StatusBadRequest, admin.CodeCustomVerificationTargetInvalid, "invalid peer_type")
|
||||
return "", false
|
||||
}
|
||||
return peerType, true
|
||||
}
|
||||
|
||||
// queryBool reads a boolean flag the way the panel writes it: an absent or empty
|
||||
// value is false, and "1"/"true"/"yes" are true.
|
||||
func queryBool(raw string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// botVerificationErrorStatus maps a third-party verification failure onto its HTTP
|
||||
// status.
|
||||
//
|
||||
// The version conflict is 409, not 400, because nothing about the request was
|
||||
// wrong -- another operator simply decided first, and the panel has to answer that
|
||||
// by reloading rather than by correcting input. The per-verifier bound is 409 for
|
||||
// the same reason: the request was well formed and the state refused it.
|
||||
func botVerificationErrorStatus(code string) int {
|
||||
switch code {
|
||||
case admin.CodeBotVerifierNotFound,
|
||||
admin.CodeBotVerifierBotNotFound,
|
||||
admin.CodeVerificationIconNotFound,
|
||||
admin.CodeCustomVerificationNotFound,
|
||||
admin.CodeCustomVerificationRequestNotFound:
|
||||
return http.StatusNotFound
|
||||
case admin.CodeCustomVerificationConflict,
|
||||
admin.CodeCustomVerificationLimit,
|
||||
admin.CodeCustomVerificationRequestExists:
|
||||
return http.StatusConflict
|
||||
case admin.CodeCustomVerificationRateLimited:
|
||||
return http.StatusTooManyRequests
|
||||
case admin.CodeBotVerifierForbidden,
|
||||
admin.CodeBotVerifierDescriptionForbidden,
|
||||
admin.CodeBotVerifierInvalid,
|
||||
admin.CodeVerificationIconInactive,
|
||||
admin.CodeVerificationIconInvalid,
|
||||
admin.CodeCustomVerificationStatusInvalid,
|
||||
admin.CodeCustomVerificationReasonRequired,
|
||||
admin.CodeCustomVerificationTargetInvalid,
|
||||
admin.CodeCustomVerificationTargetSystem,
|
||||
admin.CodeCustomVerificationInvalid:
|
||||
// BOTVERIFIER_FORBIDDEN is 400 rather than 403 on purpose: the caller is
|
||||
// authorised (403 is reserved for the permission gate), it is the *subject*
|
||||
// that may not verify, which the operator fixes by enabling the verifier.
|
||||
return http.StatusBadRequest
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
func writeBotVerificationError(w http.ResponseWriter, err error) {
|
||||
code := admin.BotVerificationErrorCode(err)
|
||||
writeCodedError(w, botVerificationErrorStatus(code), code, err.Error())
|
||||
}
|
||||
|
||||
// writeBotVerificationCommandResult answers a command.
|
||||
//
|
||||
// The body stays a CommandResult so the panel parses one shape for every operator
|
||||
// action, but the status is derived from the failure: a lost optimistic-locking
|
||||
// race must reach the browser as 409, because that is the one failure the panel
|
||||
// resolves by reloading the row instead of by asking the operator to fix the form.
|
||||
func writeBotVerificationCommandResult(w http.ResponseWriter, result admin.CommandResult, err error) {
|
||||
if err == nil {
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
code := admin.BotVerificationErrorCode(err)
|
||||
status := botVerificationErrorStatus(code)
|
||||
if status == http.StatusInternalServerError {
|
||||
// An unmapped command failure is a bad request, as everywhere else in this
|
||||
// API, rather than a server fault.
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
if result.CommandID == "" {
|
||||
result = admin.CommandResult{Status: "failed", Message: "command failed", Error: err.Error()}
|
||||
}
|
||||
if result.Error == "" {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
if code == admin.CodeCustomVerificationConflict {
|
||||
result.Message = "another operator changed this row first; reload it and try again"
|
||||
}
|
||||
writeJSON(w, status, result)
|
||||
}
|
||||
826
internal/adminapi/botverification_test.go
Normal file
826
internal/adminapi/botverification_test.go
Normal file
|
|
@ -0,0 +1,826 @@
|
|||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// fakeService gains the third-party verification surface here so the shared fake
|
||||
// keeps satisfying Service without touching the existing test files.
|
||||
|
||||
func (fakeService) GrantBotVerifier(_ context.Context, req admin.GrantBotVerifierRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetBotVerifierEnabled(_ context.Context, req admin.SetBotVerifierEnabledRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) RevokeBotVerifier(_ context.Context, req admin.RevokeBotVerifierRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) UpsertVerificationIcon(_ context.Context, req admin.UpsertVerificationIconRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetVerificationIconActive(_ context.Context, req admin.SetVerificationIconActiveRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) RevokeCustomVerification(_ context.Context, req admin.RevokeCustomVerificationRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) ApproveBotVerification(_ context.Context, req admin.ApproveBotVerificationRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) RejectBotVerification(_ context.Context, req admin.RejectBotVerificationRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) RevokeBotVerification(_ context.Context, req admin.RevokeBotVerificationRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) BotVerifiers(context.Context, bool, int) ([]domain.BotVerifierSettings, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) BotVerifier(context.Context, int64) (domain.BotVerifierSettings, error) {
|
||||
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
|
||||
}
|
||||
|
||||
func (fakeService) VerificationIcons(context.Context, bool, int) ([]domain.VerificationIcon, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) CustomVerifications(context.Context, domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) CustomVerificationRequests(context.Context, domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) CustomVerificationRequest(context.Context, int64) (domain.CustomVerificationRequest, error) {
|
||||
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
|
||||
}
|
||||
|
||||
func (fakeService) CustomVerificationRequestCounts(context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) CustomVerificationMarkActive(context.Context, int64, domain.Peer) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type captureBotVerificationService struct {
|
||||
fakeService
|
||||
verifier domain.BotVerifierSettings
|
||||
hasRow bool
|
||||
icons []domain.VerificationIcon
|
||||
marks []domain.CustomVerification
|
||||
request domain.CustomVerificationRequest
|
||||
counts map[domain.CustomVerificationRequestStatus]int64
|
||||
markActive bool
|
||||
|
||||
iconFilterActiveOnly bool
|
||||
verifierFilterEnabled bool
|
||||
verifierFilterLimit int
|
||||
markFilter domain.CustomVerificationFilter
|
||||
requestFilter domain.CustomVerificationRequestFilter
|
||||
grant admin.GrantBotVerifierRequest
|
||||
setEnabled admin.SetBotVerifierEnabledRequest
|
||||
revokeVerifier admin.RevokeBotVerifierRequest
|
||||
upsertIcon admin.UpsertVerificationIconRequest
|
||||
setIconActive admin.SetVerificationIconActiveRequest
|
||||
revokeMark admin.RevokeCustomVerificationRequest
|
||||
approve admin.ApproveBotVerificationRequest
|
||||
reject admin.RejectBotVerificationRequest
|
||||
revokeRequest admin.RevokeBotVerificationRequest
|
||||
commandErr error
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) BotVerifiers(_ context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error) {
|
||||
s.verifierFilterEnabled = enabledOnly
|
||||
s.verifierFilterLimit = limit
|
||||
if !s.hasRow {
|
||||
return nil, nil
|
||||
}
|
||||
return []domain.BotVerifierSettings{s.verifier}, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) BotVerifier(_ context.Context, botID int64) (domain.BotVerifierSettings, error) {
|
||||
if !s.hasRow || s.verifier.BotID != botID {
|
||||
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
|
||||
}
|
||||
return s.verifier, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) VerificationIcons(_ context.Context, activeOnly bool, _ int) ([]domain.VerificationIcon, error) {
|
||||
s.iconFilterActiveOnly = activeOnly
|
||||
return s.icons, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) CustomVerifications(_ context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
|
||||
s.markFilter = filter
|
||||
return s.marks, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) CustomVerificationRequests(_ context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
|
||||
s.requestFilter = filter
|
||||
return []domain.CustomVerificationRequest{s.request}, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) CustomVerificationRequest(_ context.Context, requestID int64) (domain.CustomVerificationRequest, error) {
|
||||
if s.request.ID != requestID {
|
||||
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
|
||||
}
|
||||
return s.request, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) CustomVerificationRequestCounts(context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
|
||||
return s.counts, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) CustomVerificationMarkActive(context.Context, int64, domain.Peer) (bool, error) {
|
||||
return s.markActive, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) commandResult(commandID string, dryRun bool) (admin.CommandResult, error) {
|
||||
if s.commandErr != nil {
|
||||
return admin.CommandResult{CommandID: commandID, Status: "failed", Error: s.commandErr.Error()}, s.commandErr
|
||||
}
|
||||
return admin.CommandResult{CommandID: commandID, Status: "completed", DryRun: dryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) GrantBotVerifier(_ context.Context, req admin.GrantBotVerifierRequest) (admin.CommandResult, error) {
|
||||
s.grant = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) SetBotVerifierEnabled(_ context.Context, req admin.SetBotVerifierEnabledRequest) (admin.CommandResult, error) {
|
||||
s.setEnabled = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) RevokeBotVerifier(_ context.Context, req admin.RevokeBotVerifierRequest) (admin.CommandResult, error) {
|
||||
s.revokeVerifier = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) UpsertVerificationIcon(_ context.Context, req admin.UpsertVerificationIconRequest) (admin.CommandResult, error) {
|
||||
s.upsertIcon = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) SetVerificationIconActive(_ context.Context, req admin.SetVerificationIconActiveRequest) (admin.CommandResult, error) {
|
||||
s.setIconActive = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) RevokeCustomVerification(_ context.Context, req admin.RevokeCustomVerificationRequest) (admin.CommandResult, error) {
|
||||
s.revokeMark = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) ApproveBotVerification(_ context.Context, req admin.ApproveBotVerificationRequest) (admin.CommandResult, error) {
|
||||
s.approve = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) RejectBotVerification(_ context.Context, req admin.RejectBotVerificationRequest) (admin.CommandResult, error) {
|
||||
s.reject = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
func (s *captureBotVerificationService) RevokeBotVerification(_ context.Context, req admin.RevokeBotVerificationRequest) (admin.CommandResult, error) {
|
||||
s.revokeRequest = req
|
||||
return s.commandResult(req.CommandID, req.DryRun)
|
||||
}
|
||||
|
||||
// botVerificationServer is the deployment shape the permission model exists for:
|
||||
// one master token plus bounded tokens that can review, manage, or neither.
|
||||
func botVerificationServer(svc Service) *Server {
|
||||
return &Server{
|
||||
token: "master",
|
||||
scoped: []ScopedToken{
|
||||
{Name: "queue-bot", Token: "scoped-review", Permissions: []string{PermissionBotVerificationReview}},
|
||||
{Name: "trust-and-safety", Token: "scoped-manage", Permissions: []string{PermissionBotVerificationManage}},
|
||||
{Name: "both", Token: "scoped-both", Permissions: []string{
|
||||
PermissionBotVerificationReview, PermissionBotVerificationManage,
|
||||
}},
|
||||
// A token for the *official* review surface: it must not reach this one.
|
||||
{Name: "official-review", Token: "scoped-official", Permissions: []string{PermissionVerificationReview}},
|
||||
},
|
||||
svc: svc,
|
||||
}
|
||||
}
|
||||
|
||||
// botVerificationRoute is one route with a body the handler accepts, so an
|
||||
// authorisation test cannot pass by accident on a malformed payload.
|
||||
type botVerificationRoute struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}
|
||||
|
||||
const decisionBody = `{"command_id":"c1","actor":"ops","reason":"decided","version":2}`
|
||||
|
||||
var botVerificationReadRoutes = []botVerificationRoute{
|
||||
{http.MethodGet, "/v1/botverification/verifiers", ""},
|
||||
{http.MethodGet, "/v1/botverification/icons", ""},
|
||||
{http.MethodGet, "/v1/botverification/marks", ""},
|
||||
{http.MethodGet, "/v1/botverification/requests", ""},
|
||||
{http.MethodGet, "/v1/botverification/requests/7", ""},
|
||||
{http.MethodGet, "/v1/botverification/counts", ""},
|
||||
{http.MethodPost, "/v1/botverification/requests/7/approve", decisionBody},
|
||||
{http.MethodPost, "/v1/botverification/requests/7/reject", decisionBody},
|
||||
{http.MethodPost, "/v1/botverification/requests/7/revoke", decisionBody},
|
||||
}
|
||||
|
||||
var botVerificationManageRoutes = []botVerificationRoute{
|
||||
{http.MethodPost, "/v1/botverification/verifiers/grant",
|
||||
`{"command_id":"c1","actor":"ops","reason":"partner","bot_id":3003,"icon_document_id":900,"company_name":"Example Trust","version":4}`},
|
||||
{http.MethodPost, "/v1/botverification/verifiers/set-enabled",
|
||||
`{"command_id":"c1","actor":"ops","reason":"abuse","bot_id":3003,"enabled":false}`},
|
||||
{http.MethodPost, "/v1/botverification/verifiers/revoke",
|
||||
`{"command_id":"c1","actor":"ops","reason":"programme ended","bot_id":3003}`},
|
||||
{http.MethodPost, "/v1/botverification/icons/upsert",
|
||||
`{"command_id":"c1","actor":"ops","reason":"new icon","document_id":900,"name":"blue check"}`},
|
||||
{http.MethodPost, "/v1/botverification/icons/set-active",
|
||||
`{"command_id":"c1","actor":"ops","reason":"retired","icon_id":501,"active":false}`},
|
||||
{http.MethodPost, "/v1/botverification/marks/revoke",
|
||||
`{"command_id":"c1","actor":"ops","reason":"impersonation","verifier_bot_id":3003,"peer_type":"channel","peer_id":5005}`},
|
||||
}
|
||||
|
||||
// botVerificationRoutes is every route in the section.
|
||||
func botVerificationRoutes() []botVerificationRoute {
|
||||
out := make([]botVerificationRoute, 0, len(botVerificationReadRoutes)+len(botVerificationManageRoutes))
|
||||
out = append(out, botVerificationReadRoutes...)
|
||||
return append(out, botVerificationManageRoutes...)
|
||||
}
|
||||
|
||||
func TestBotVerificationRoutesRejectMissingAndUnknownTokens(t *testing.T) {
|
||||
srv := botVerificationServer(fakeService{})
|
||||
for _, item := range botVerificationRoutes() {
|
||||
for _, token := range []string{"", "not-a-configured-token"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s %s token=%q status=%d, want 401", item.method, item.path, token, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationRoutesRefuseScopedTokenWithoutThePermission(t *testing.T) {
|
||||
srv := botVerificationServer(fakeService{})
|
||||
// The official-verification token is the interesting negative: the two
|
||||
// mechanisms are separate, so verification.review must not open this surface.
|
||||
for _, token := range []string{"scoped-official", "scoped-manage"} {
|
||||
for _, item := range botVerificationReadRoutes {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s %s token=%q status=%d body=%s, want 403", item.method, item.path, token, rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode 403 body: %v", err)
|
||||
}
|
||||
if body["code"] != CodeForbidden || body["permission"] != PermissionBotVerificationReview {
|
||||
t.Fatalf("403 body=%+v, want botverification.review named", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the review right alone does not reach the configuration half.
|
||||
for _, token := range []string{"scoped-official", "scoped-review"} {
|
||||
for _, item := range botVerificationManageRoutes {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s %s token=%q status=%d body=%s, want 403", item.method, item.path, token, rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode 403 body: %v", err)
|
||||
}
|
||||
if body["permission"] != PermissionBotVerificationManage {
|
||||
t.Fatalf("403 body=%+v, want botverification.manage named", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A token holding the third-party rights must not reach the official queue either:
|
||||
// the separation is symmetric.
|
||||
func TestBotVerificationTokenCannotReachTheOfficialVerificationSurface(t *testing.T) {
|
||||
srv := botVerificationServer(fakeService{})
|
||||
for _, path := range []string{"/v1/verification/applications", "/v1/verification/counts"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, path, "scoped-both", ""))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s status=%d body=%s, want 403", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// Nor the legacy surface that predates permissions.
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/accounts/set-verified", "scoped-both",
|
||||
`{"command_id":"c1","actor":"ops","reason":"x","user_id":1001,"verified":true}`))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("legacy surface status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationScopedTokensReachTheirOwnHalf(t *testing.T) {
|
||||
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 7, Version: 2}}
|
||||
srv := botVerificationServer(svc)
|
||||
|
||||
for _, item := range botVerificationReadRoutes {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "scoped-review", item.body))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s %s status=%d body=%s", item.method, item.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
for _, item := range botVerificationManageRoutes {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "scoped-manage", item.body))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s %s status=%d body=%s", item.method, item.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasterTokenReachesTheBotVerificationSurface(t *testing.T) {
|
||||
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 7, Version: 2}}
|
||||
srv := botVerificationServer(svc)
|
||||
for _, item := range botVerificationRoutes() {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "master", item.body))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("master on %s %s status=%d body=%s", item.method, item.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerifierListRendersInt64AsDecimalStrings(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
svc := &captureBotVerificationService{
|
||||
hasRow: true,
|
||||
verifier: domain.BotVerifierSettings{
|
||||
BotID: maxInt64,
|
||||
IconDocumentID: maxInt64,
|
||||
CompanyName: "Example Trust",
|
||||
DefaultDescription: "verified by Example Trust",
|
||||
CanModifyCustomDescription: true,
|
||||
Enabled: true,
|
||||
GrantedBy: "alice",
|
||||
GrantReason: "partner programme",
|
||||
Version: maxInt64,
|
||||
CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
|
||||
UpdatedAt: time.Unix(1_700_000_000, 0).UTC(),
|
||||
},
|
||||
}
|
||||
srv := botVerificationServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
|
||||
"/v1/botverification/verifiers?enabled_only=1&limit=25", "scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !svc.verifierFilterEnabled || svc.verifierFilterLimit != 25 {
|
||||
t.Fatalf("enabledOnly=%v limit=%d, want the query honoured", svc.verifierFilterEnabled, svc.verifierFilterLimit)
|
||||
}
|
||||
var body struct {
|
||||
Rows []map[string]any `json:"rows"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode verifiers: %v", err)
|
||||
}
|
||||
if len(body.Rows) != 1 {
|
||||
t.Fatalf("rows=%+v", body.Rows)
|
||||
}
|
||||
for _, field := range []string{"BotID", "IconDocumentID", "Version"} {
|
||||
if body.Rows[0][field] != "9223372036854775807" {
|
||||
t.Fatalf("%s = %#v, want an exact decimal string", field, body.Rows[0][field])
|
||||
}
|
||||
}
|
||||
if body.Rows[0]["CanModifyCustomDescription"] != true || body.Rows[0]["Enabled"] != true {
|
||||
t.Fatalf("row=%+v, want the booleans as booleans", body.Rows[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationIconAndMarkListingsRenderInt64AsDecimalStrings(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
svc := &captureBotVerificationService{
|
||||
icons: []domain.VerificationIcon{{
|
||||
ID: maxInt64, DocumentID: maxInt64, OwnerBotID: maxInt64, Name: "blue check", Active: true,
|
||||
CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
|
||||
}},
|
||||
marks: []domain.CustomVerification{{
|
||||
ID: maxInt64, VerifierBotID: maxInt64,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: maxInt64},
|
||||
IconDocumentID: maxInt64, Description: "verified partner", Version: maxInt64,
|
||||
}},
|
||||
}
|
||||
srv := botVerificationServer(svc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
|
||||
"/v1/botverification/icons?active_only=true", "scoped-review", ""))
|
||||
if rec.Code != http.StatusOK || !svc.iconFilterActiveOnly {
|
||||
t.Fatalf("icons status=%d activeOnly=%v body=%s", rec.Code, svc.iconFilterActiveOnly, rec.Body.String())
|
||||
}
|
||||
var icons struct {
|
||||
Rows []map[string]any `json:"rows"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &icons); err != nil {
|
||||
t.Fatalf("decode icons: %v", err)
|
||||
}
|
||||
for _, field := range []string{"ID", "DocumentID", "OwnerBotID"} {
|
||||
if icons.Rows[0][field] != "9223372036854775807" {
|
||||
t.Fatalf("icon %s = %#v", field, icons.Rows[0][field])
|
||||
}
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
|
||||
"/v1/botverification/marks?verifier_bot_id=9223372036854775807&peer_type=channel&q=news&limit=1&before_id=99",
|
||||
"scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("marks status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if svc.markFilter.VerifierBotID != maxInt64 || svc.markFilter.PeerType != domain.PeerTypeChannel ||
|
||||
svc.markFilter.Query != "news" || svc.markFilter.Limit != 1 || svc.markFilter.BeforeID != 99 {
|
||||
t.Fatalf("mark filter=%+v", svc.markFilter)
|
||||
}
|
||||
var marks struct {
|
||||
Rows []map[string]any `json:"rows"`
|
||||
HasMore bool `json:"has_more"`
|
||||
NextBeforeID string `json:"next_before_id"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &marks); err != nil {
|
||||
t.Fatalf("decode marks: %v", err)
|
||||
}
|
||||
for _, field := range []string{"ID", "VerifierBotID", "PeerID", "IconDocumentID", "Version"} {
|
||||
if marks.Rows[0][field] != "9223372036854775807" {
|
||||
t.Fatalf("mark %s = %#v", field, marks.Rows[0][field])
|
||||
}
|
||||
}
|
||||
// A full page reports more, and the cursor is the last id as a decimal string.
|
||||
if !marks.HasMore || marks.NextBeforeID != "9223372036854775807" {
|
||||
t.Fatalf("paging hasMore=%v next=%q", marks.HasMore, marks.NextBeforeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationQueueFilterAndUnmodelledValues(t *testing.T) {
|
||||
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{
|
||||
ID: 88, VerifierBotID: 3003, ApplicantUserID: 1001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 5005},
|
||||
Status: domain.CustomVerificationPending, Version: 3,
|
||||
}}
|
||||
srv := botVerificationServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
|
||||
"/v1/botverification/requests?status=pending,approved&verifier_bot_id=3003&peer_type=channel&q=news&limit=25&before_id=99",
|
||||
"scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(svc.requestFilter.Statuses) != 2 ||
|
||||
svc.requestFilter.Statuses[0] != domain.CustomVerificationPending ||
|
||||
svc.requestFilter.Statuses[1] != domain.CustomVerificationApproved ||
|
||||
svc.requestFilter.VerifierBotID != 3003 || svc.requestFilter.PeerType != domain.PeerTypeChannel ||
|
||||
svc.requestFilter.Query != "news" || svc.requestFilter.Limit != 25 || svc.requestFilter.BeforeID != 99 {
|
||||
t.Fatalf("filter=%+v", svc.requestFilter)
|
||||
}
|
||||
|
||||
// An unmodelled status or peer type is a 400 rather than an empty result, so a
|
||||
// typo is reported instead of silently returning nothing.
|
||||
for _, query := range []string{"?status=in_review", "?peer_type=chat", "?verifier_bot_id=abc", "?before_id=-1"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests"+query, "scoped-review", ""))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s status=%d body=%s, want 400", query, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
for _, query := range []string{"?peer_type=chat"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/marks"+query, "scoped-review", ""))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("marks %s status=%d, want 400", query, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationRequestDetailAndCounts(t *testing.T) {
|
||||
svc := &captureBotVerificationService{
|
||||
hasRow: true,
|
||||
verifier: domain.BotVerifierSettings{
|
||||
BotID: 3003, IconDocumentID: 900, CompanyName: "Example Trust", Enabled: true, Version: 4,
|
||||
},
|
||||
request: domain.CustomVerificationRequest{
|
||||
ID: 88, VerifierBotID: 3003, ApplicantUserID: 1001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 5005},
|
||||
PeerTitle: "Example News",
|
||||
PeerUsername: "examplenews",
|
||||
InternalNote: "operator only",
|
||||
Status: domain.CustomVerificationApproved, Version: 5,
|
||||
ApprovedAt: time.Unix(1_700_000_000, 0).UTC(),
|
||||
},
|
||||
markActive: true,
|
||||
counts: map[domain.CustomVerificationRequestStatus]int64{domain.CustomVerificationPending: 3, domain.CustomVerificationApproved: 1},
|
||||
}
|
||||
srv := botVerificationServer(svc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests/88", "scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("detail status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var detail struct {
|
||||
Request map[string]any `json:"request"`
|
||||
Verifier map[string]any `json:"verifier"`
|
||||
MarkActive bool `json:"mark_active"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &detail); err != nil {
|
||||
t.Fatalf("decode detail: %v", err)
|
||||
}
|
||||
if detail.Request["ID"] != "88" || detail.Request["PeerID"] != "5005" || detail.Request["Version"] != "5" ||
|
||||
detail.Request["InternalNote"] != "operator only" || detail.Request["ApprovedAt"] == nil {
|
||||
t.Fatalf("request=%+v", detail.Request)
|
||||
}
|
||||
if detail.Verifier["BotID"] != "3003" || detail.Verifier["CompanyName"] != "Example Trust" || !detail.MarkActive {
|
||||
t.Fatalf("verifier=%+v markActive=%v", detail.Verifier, detail.MarkActive)
|
||||
}
|
||||
|
||||
// A verifier revoked since the application was filed must not turn the audit
|
||||
// record into a 500: the row is reported with only its id.
|
||||
svc.hasRow = false
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests/88", "scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("detail without a verifier status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &detail); err != nil {
|
||||
t.Fatalf("decode detail: %v", err)
|
||||
}
|
||||
if detail.Verifier["BotID"] != "3003" || detail.Verifier["Enabled"] != false {
|
||||
t.Fatalf("verifier=%+v, want the bot named and no status claimed", detail.Verifier)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests/89", "scoped-review", ""))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing application status=%d body=%s, want 404", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/counts", "scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("counts status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var counts struct {
|
||||
Counts map[string]string `json:"counts"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &counts); err != nil {
|
||||
t.Fatalf("decode counts: %v", err)
|
||||
}
|
||||
// Every modelled status is present so the panel never tells "zero" from
|
||||
// "absent", and the values are decimal strings.
|
||||
if counts.Counts["pending"] != "3" || counts.Counts["approved"] != "1" ||
|
||||
counts.Counts["rejected"] != "0" || counts.Counts["revoked"] != "0" || len(counts.Counts) != 4 {
|
||||
t.Fatalf("counts=%+v", counts.Counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationDecisionTakesTheRequestIDFromThePath(t *testing.T) {
|
||||
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 88, Version: 3}}
|
||||
srv := botVerificationServer(svc)
|
||||
// The body names a different application on purpose: the path has to win, or
|
||||
// the URL would lie to the audit trail.
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
|
||||
`{"command_id":"c1","actor":"alice","reason":"verified","request_id":99,"version":3,"internal_note":"handover"}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if svc.approve.RequestID != 88 || svc.approve.Version != 3 ||
|
||||
svc.approve.InternalNote != "handover" || svc.approve.Actor != "alice" {
|
||||
t.Fatalf("forwarded approval=%+v", svc.approve)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationDryRunIsForwardedAndEchoed(t *testing.T) {
|
||||
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 88, Version: 3}}
|
||||
srv := botVerificationServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/reject", "scoped-review",
|
||||
`{"command_id":"dry-1","actor":"alice","reason":"not an outlet","dry_run":true,"version":3}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !svc.reject.DryRun || svc.reject.Reason != "not an outlet" {
|
||||
t.Fatalf("forwarded rejection=%+v", svc.reject)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"dry_run":true`) {
|
||||
t.Fatalf("body=%s, want the dry run echoed", rec.Body.String())
|
||||
}
|
||||
|
||||
// Also on the manage half: appointing a verifier is rehearsable too.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/grant", "scoped-manage",
|
||||
`{"command_id":"dry-2","actor":"alice","reason":"partner","dry_run":true,
|
||||
"bot_id":3003,"icon_document_id":900,"company_name":"Example Trust","version":4}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("grant status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !svc.grant.DryRun || svc.grant.BotID != 3003 || svc.grant.IconDocumentID != 900 || svc.grant.Version != 4 {
|
||||
t.Fatalf("forwarded grant=%+v, want the exact int64s from decimal strings", svc.grant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationVersionConflictIsAnswered409(t *testing.T) {
|
||||
svc := &captureBotVerificationService{
|
||||
request: domain.CustomVerificationRequest{ID: 88, Version: 5},
|
||||
// The shape admin.codedError produces for a lost race.
|
||||
commandErr: fmt.Errorf("%s: %w", admin.CodeCustomVerificationConflict, domain.ErrCustomVerificationVersionConflict),
|
||||
}
|
||||
srv := botVerificationServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
|
||||
`{"command_id":"c1","actor":"alice","reason":"verified","version":4}`))
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status=%d body=%s, want 409 for a lost optimistic-locking race", rec.Code, rec.Body.String())
|
||||
}
|
||||
var result admin.CommandResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("decode conflict: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.Error, admin.CodeCustomVerificationConflict) {
|
||||
t.Fatalf("result=%+v, want the stable conflict code", result)
|
||||
}
|
||||
if !strings.Contains(result.Message, "reload") {
|
||||
t.Fatalf("result message=%q, want an actionable message", result.Message)
|
||||
}
|
||||
|
||||
// The same on the manage half, where two operators can race a verifier row.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/grant", "scoped-manage",
|
||||
`{"command_id":"c2","actor":"alice","reason":"partner","bot_id":3003,"icon_document_id":900,"company_name":"x","version":3}`))
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("grant conflict status=%d body=%s, want 409", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationErrorStatusMapping(t *testing.T) {
|
||||
cases := map[string]int{
|
||||
admin.CodeBotVerifierNotFound: http.StatusNotFound,
|
||||
admin.CodeBotVerifierBotNotFound: http.StatusNotFound,
|
||||
admin.CodeVerificationIconNotFound: http.StatusNotFound,
|
||||
admin.CodeCustomVerificationNotFound: http.StatusNotFound,
|
||||
admin.CodeCustomVerificationRequestNotFound: http.StatusNotFound,
|
||||
admin.CodeCustomVerificationConflict: http.StatusConflict,
|
||||
admin.CodeCustomVerificationLimit: http.StatusConflict,
|
||||
admin.CodeCustomVerificationRequestExists: http.StatusConflict,
|
||||
admin.CodeCustomVerificationRateLimited: http.StatusTooManyRequests,
|
||||
admin.CodeBotVerifierForbidden: http.StatusBadRequest,
|
||||
admin.CodeBotVerifierInvalid: http.StatusBadRequest,
|
||||
admin.CodeVerificationIconInactive: http.StatusBadRequest,
|
||||
admin.CodeVerificationIconInvalid: http.StatusBadRequest,
|
||||
admin.CodeCustomVerificationStatusInvalid: http.StatusBadRequest,
|
||||
admin.CodeCustomVerificationReasonRequired: http.StatusBadRequest,
|
||||
admin.CodeCustomVerificationTargetInvalid: http.StatusBadRequest,
|
||||
admin.CodeCustomVerificationInvalid: http.StatusBadRequest,
|
||||
"": http.StatusInternalServerError,
|
||||
}
|
||||
for code, want := range cases {
|
||||
if got := botVerificationErrorStatus(code); got != want {
|
||||
t.Fatalf("botVerificationErrorStatus(%q) = %d, want %d", code, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationActionsForwardTheirPayloads(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
svc := &captureBotVerificationService{}
|
||||
srv := botVerificationServer(svc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/set-enabled", "scoped-manage",
|
||||
`{"command_id":"c1","actor":"ops","reason":"abuse","bot_id":9223372036854775807,"enabled":false}`))
|
||||
if rec.Code != http.StatusOK || svc.setEnabled.BotID != maxInt64 || svc.setEnabled.Enabled {
|
||||
t.Fatalf("set-enabled status=%d req=%+v", rec.Code, svc.setEnabled)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/revoke", "scoped-manage",
|
||||
`{"command_id":"c2","actor":"ops","reason":"programme ended","bot_id":3003}`))
|
||||
if rec.Code != http.StatusOK || svc.revokeVerifier.BotID != 3003 {
|
||||
t.Fatalf("revoke-verifier status=%d req=%+v", rec.Code, svc.revokeVerifier)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/icons/upsert", "scoped-manage",
|
||||
`{"command_id":"c3","actor":"ops","reason":"new icon","document_id":9223372036854775807,"name":"blue check","owner_bot_id":3003}`))
|
||||
if rec.Code != http.StatusOK || svc.upsertIcon.DocumentID != maxInt64 ||
|
||||
svc.upsertIcon.Name != "blue check" || svc.upsertIcon.OwnerBotID != 3003 {
|
||||
t.Fatalf("upsert-icon status=%d req=%+v", rec.Code, svc.upsertIcon)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/icons/set-active", "scoped-manage",
|
||||
`{"command_id":"c4","actor":"ops","reason":"retired","icon_id":501,"active":false}`))
|
||||
if rec.Code != http.StatusOK || svc.setIconActive.IconID != 501 || svc.setIconActive.Active {
|
||||
t.Fatalf("set-icon-active status=%d req=%+v", rec.Code, svc.setIconActive)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/marks/revoke", "scoped-manage",
|
||||
`{"command_id":"c5","actor":"ops","reason":"impersonation","verifier_bot_id":3003,"peer_type":"channel","peer_id":9223372036854775807}`))
|
||||
if rec.Code != http.StatusOK || svc.revokeMark.VerifierBotID != 3003 ||
|
||||
svc.revokeMark.PeerType != domain.PeerTypeChannel || svc.revokeMark.PeerID != maxInt64 {
|
||||
t.Fatalf("revoke-mark status=%d req=%+v", rec.Code, svc.revokeMark)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/revoke", "scoped-review",
|
||||
`{"command_id":"c6","actor":"ops","reason":"licence withdrawn","version":9223372036854775807}`))
|
||||
if rec.Code != http.StatusOK || svc.revokeRequest.RequestID != 88 || svc.revokeRequest.Version != maxInt64 {
|
||||
t.Fatalf("revoke-request status=%d req=%+v", rec.Code, svc.revokeRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationScopedTokenNameBecomesTheAuditActor(t *testing.T) {
|
||||
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 88, Version: 3}}
|
||||
srv := botVerificationServer(svc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
|
||||
`{"command_id":"c1","reason":"queue sweep","version":3}`))
|
||||
if rec.Code != http.StatusOK || svc.approve.Actor != "queue-bot" {
|
||||
t.Fatalf("status=%d actor=%q, want the scoped token name", rec.Code, svc.approve.Actor)
|
||||
}
|
||||
|
||||
// A stated actor is never overwritten, which is how the panel attributes an
|
||||
// action to the signed-in operator.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
|
||||
`{"command_id":"c2","actor":"alice","reason":"queue sweep","version":3}`))
|
||||
if rec.Code != http.StatusOK || svc.approve.Actor != "alice" {
|
||||
t.Fatalf("status=%d actor=%q", rec.Code, svc.approve.Actor)
|
||||
}
|
||||
|
||||
// The master token has no name, so the caller keeps having to say who acts.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "master",
|
||||
`{"command_id":"c3","reason":"queue sweep","version":3}`))
|
||||
if rec.Code != http.StatusOK || svc.approve.Actor != "" {
|
||||
t.Fatalf("master token status=%d actor=%q, want no invented identity", rec.Code, svc.approve.Actor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationCommandsRejectUnknownFields(t *testing.T) {
|
||||
srv := botVerificationServer(&captureBotVerificationService{})
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/grant", "scoped-manage",
|
||||
`{"command_id":"c1","actor":"ops","reason":"x","bot_id":3003,"icon_document_id":900,"company_name":"y","enabled":true}`))
|
||||
// enabled is not part of the grant payload: the kill switch is its own action,
|
||||
// and a silently ignored field would hide that from the operator.
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "enabled") {
|
||||
t.Fatalf("status=%d body=%s, want 400 naming the unknown field", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationPermissionNamesAreDistinctFromTheOfficialOnes(t *testing.T) {
|
||||
// The permission model's whole point here: appointing verifiers is not implied
|
||||
// by reviewing the official queue, in either direction.
|
||||
bounded := newPermissionSet([]string{PermissionBotVerificationReview})
|
||||
if !bounded.Has(PermissionBotVerificationReview) {
|
||||
t.Fatal("bounded set dropped its own permission")
|
||||
}
|
||||
if bounded.Has(PermissionBotVerificationManage) || bounded.Has(PermissionVerificationReview) {
|
||||
t.Fatalf("botverification.review leaked into another right")
|
||||
}
|
||||
manage := newPermissionSet([]string{PermissionBotVerificationManage})
|
||||
if manage.Has(PermissionBotVerificationReview) {
|
||||
t.Fatal("botverification.manage implied the review right")
|
||||
}
|
||||
all := newPermissionSet([]string{PermissionAll})
|
||||
if !all.Has(PermissionBotVerificationReview) || !all.Has(PermissionBotVerificationManage) {
|
||||
t.Fatal("the wildcard refused a third-party permission")
|
||||
}
|
||||
}
|
||||
196
internal/adminapi/rbac.go
Normal file
196
internal/adminapi/rbac.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Admin API authorisation.
|
||||
//
|
||||
// Every request arrives with a bearer token, and the token decides which
|
||||
// permissions the request carries:
|
||||
//
|
||||
// - TELESRV_ADMIN_API_TOKEN is the master token and carries every permission.
|
||||
// This is what keeps the existing surface working unchanged: all the routes
|
||||
// that predate permissions stay mounted through authenticated(), which is
|
||||
// defined as "requires every permission", so the master token reaches them
|
||||
// exactly as before.
|
||||
// - A scoped token from TELESRV_ADMIN_SCOPED_TOKENS carries only the
|
||||
// permissions its entry lists. A scoped token is therefore *not* a weaker
|
||||
// master token: it authenticates successfully and is then refused with 403 on
|
||||
// anything outside its list, including every legacy route. Widening a scoped
|
||||
// token to the legacy surface would be a silent privilege escalation, so the
|
||||
// wildcard has to be spelled out in configuration to get it.
|
||||
//
|
||||
// The two-step answer matters for diagnosis: 401 means "I do not know this
|
||||
// token", 403 means "I know you and you may not do this".
|
||||
|
||||
// Permission names. They are the same strings the operator writes into
|
||||
// TELESRV_ADMIN_UI_PERMISSIONS / TELESRV_ADMIN_SCOPED_TOKENS.
|
||||
const (
|
||||
// PermissionAll is the wildcard: a principal carrying it passes every check.
|
||||
PermissionAll = "*"
|
||||
// PermissionVerificationReview guards the whole official-verification review
|
||||
// surface: the queue, one application, the counters, and the claim/approve/
|
||||
// reject decisions.
|
||||
PermissionVerificationReview = "verification.review"
|
||||
// PermissionVerificationRevoke is required *in addition* to
|
||||
// PermissionVerificationReview to clear a badge that was already granted.
|
||||
// Taking a badge away is visible to every client of a public peer, so it is
|
||||
// deliberately not implied by the right to review new applications.
|
||||
PermissionVerificationRevoke = "verification.revoke"
|
||||
// PermissionBotVerificationReview guards the third-party verification read
|
||||
// surface -- verifiers, icons, granted marks, the queue and its counters -- plus
|
||||
// the decisions on the applications filed with a verifier bot.
|
||||
//
|
||||
// This is NOT verification.review. Third-party verification is a separate
|
||||
// mechanism over separate tables (verification_icons, bot_verifier_settings,
|
||||
// custom_verifications, custom_verification_requests), so a token trusted to
|
||||
// work one queue is not thereby trusted with the other: neither permission
|
||||
// implies the other.
|
||||
PermissionBotVerificationReview = "botverification.review"
|
||||
// PermissionBotVerificationManage guards the configuration half: granting,
|
||||
// switching and revoking verifier status, the icon catalogue, and stripping a
|
||||
// granted mark.
|
||||
//
|
||||
// It is separate from the review right because these are the actions that
|
||||
// decide how much a third-party mark is worth. Handing out the queue is
|
||||
// routine; handing out the ability to appoint verifiers is not.
|
||||
PermissionBotVerificationManage = "botverification.manage"
|
||||
)
|
||||
|
||||
// CodeForbidden is the stable code for a permission failure, so the panel can
|
||||
// tell an authorisation refusal apart from a domain refusal.
|
||||
const CodeForbidden = "FORBIDDEN"
|
||||
|
||||
// ScopedToken is one bearer token restricted to a permission set. It mirrors
|
||||
// config.AdminScopedToken; the adminapi package keeps its own shape so it does
|
||||
// not depend on the configuration loader.
|
||||
type ScopedToken struct {
|
||||
// Name is the audit identity of actions performed with this token.
|
||||
Name string
|
||||
Token string
|
||||
Permissions []string
|
||||
}
|
||||
|
||||
// permissionSet is a resolved permission list.
|
||||
type permissionSet struct {
|
||||
all bool
|
||||
names map[string]struct{}
|
||||
}
|
||||
|
||||
func newPermissionSet(permissions []string) permissionSet {
|
||||
set := permissionSet{names: make(map[string]struct{}, len(permissions))}
|
||||
for _, permission := range permissions {
|
||||
permission = strings.TrimSpace(permission)
|
||||
if permission == "" {
|
||||
continue
|
||||
}
|
||||
if permission == PermissionAll {
|
||||
set.all = true
|
||||
continue
|
||||
}
|
||||
set.names[permission] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// Has reports whether the set grants the permission.
|
||||
func (p permissionSet) Has(permission string) bool {
|
||||
if p.all {
|
||||
return true
|
||||
}
|
||||
_, ok := p.names[permission]
|
||||
return ok
|
||||
}
|
||||
|
||||
// principal is the authenticated caller.
|
||||
type principal struct {
|
||||
// name is the scoped token's audit identity, or "" for the master token,
|
||||
// whose actions are attributed by the actor the caller states in the body.
|
||||
name string
|
||||
permissions permissionSet
|
||||
}
|
||||
|
||||
type principalKey struct{}
|
||||
|
||||
// principalName returns the scoped-token identity behind the request, or "" when
|
||||
// the request came in on the master token.
|
||||
func principalName(ctx context.Context) string {
|
||||
if p, ok := ctx.Value(principalKey{}).(principal); ok {
|
||||
return p.name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// principalFor resolves the bearer token to a principal.
|
||||
//
|
||||
// Every configured token is compared, and every comparison is constant time and
|
||||
// unconditional: returning as soon as one matches would leak, through timing,
|
||||
// which token position a guess collided with.
|
||||
func (s *Server) principalFor(r *http.Request) (principal, bool) {
|
||||
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
if got == "" {
|
||||
return principal{}, false
|
||||
}
|
||||
matched := false
|
||||
resolved := principal{}
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) == 1 && s.token != "" {
|
||||
matched = true
|
||||
resolved = principal{permissions: permissionSet{all: true}}
|
||||
}
|
||||
for _, scoped := range s.scoped {
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(scoped.Token)) == 1 && scoped.Token != "" && !matched {
|
||||
matched = true
|
||||
resolved = principal{name: scoped.Name, permissions: newPermissionSet(scoped.Permissions)}
|
||||
}
|
||||
}
|
||||
return resolved, matched
|
||||
}
|
||||
|
||||
// authenticated guards a route that requires unrestricted rights.
|
||||
//
|
||||
// This is every route that predates the permission model. Keeping them here is
|
||||
// the documented behaviour: the master token carries every permission, so nothing
|
||||
// about the existing surface changes, while a bounded scoped token cannot use one
|
||||
// of them as a side door.
|
||||
func (s *Server) authenticated(next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.authorized(PermissionAll, next)
|
||||
}
|
||||
|
||||
// authorized guards a route behind one permission.
|
||||
func (s *Server) authorized(permission string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.authorizedAll([]string{permission}, next)
|
||||
}
|
||||
|
||||
// authorizedAll guards a route behind every listed permission. Revocation uses it
|
||||
// to require the review right and the revoke right together, so the revoke right
|
||||
// alone cannot be handed out as a way into the review surface.
|
||||
func (s *Server) authorizedAll(permissions []string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := s.principalFor(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
for _, permission := range permissions {
|
||||
if !caller.permissions.Has(permission) {
|
||||
writeForbidden(w, permission)
|
||||
return
|
||||
}
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), principalKey{}, caller)))
|
||||
}
|
||||
}
|
||||
|
||||
// writeForbidden names the missing permission, so an operator configuring a
|
||||
// scoped token is told what to add instead of having to guess.
|
||||
func writeForbidden(w http.ResponseWriter, permission string) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"error": "permission " + permission + " is required",
|
||||
"code": CodeForbidden,
|
||||
"permission": permission,
|
||||
})
|
||||
}
|
||||
|
|
@ -2,12 +2,13 @@ package adminapi
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -23,6 +24,20 @@ import (
|
|||
type Config struct {
|
||||
Addr string
|
||||
Token string
|
||||
// ScopedTokens are additional bearer tokens with a bounded permission set
|
||||
// each. Token stays the unrestricted master token, so a deployment that
|
||||
// configures no scoped token behaves exactly as it did before.
|
||||
//
|
||||
// The shape mirrors config.AdminScopedToken without importing the loader --
|
||||
// only the main packages depend on internal/config -- so the caller converts:
|
||||
//
|
||||
// scoped := make([]adminapi.ScopedToken, 0, len(cfg.AdminScopedTokens))
|
||||
// for _, item := range cfg.AdminScopedTokens {
|
||||
// scoped = append(scoped, adminapi.ScopedToken{
|
||||
// Name: item.Name, Token: item.Token, Permissions: item.Permissions,
|
||||
// })
|
||||
// }
|
||||
ScopedTokens []ScopedToken
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
|
|
@ -72,6 +87,54 @@ type Service interface {
|
|||
EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error)
|
||||
StarGiftCollectibles(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
|
||||
StarGiftCollectibleAnimation(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
|
||||
ModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error)
|
||||
ModerationCase(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error)
|
||||
ModerationReport(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error)
|
||||
ClaimModerationCase(ctx context.Context, caseID, expectedVersion int64, actor string) (domain.ModerationCase, error)
|
||||
DecideModerationCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
|
||||
SubmitModerationAppeal(ctx context.Context, caseID, appellantUserID int64, text string) (domain.ModerationAppeal, bool, error)
|
||||
ReviewModerationAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
|
||||
MintCollectibleUsername(ctx context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error)
|
||||
TransferCollectibleUsername(ctx context.Context, req admin.TransferCollectibleUsernameRequest) (admin.CommandResult, error)
|
||||
RevokeCollectibleUsername(ctx context.Context, req admin.RevokeCollectibleUsernameRequest) (admin.CommandResult, error)
|
||||
DeleteCollectibleUsername(ctx context.Context, req admin.DeleteCollectibleUsernameRequest) (admin.CommandResult, error)
|
||||
CollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
|
||||
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
|
||||
CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
|
||||
RecomputeAccountRating(ctx context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error)
|
||||
AdjustAccountRating(ctx context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error)
|
||||
AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error)
|
||||
AccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error)
|
||||
AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error)
|
||||
ClaimVerification(ctx context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error)
|
||||
ApproveVerification(ctx context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error)
|
||||
RejectVerification(ctx context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error)
|
||||
RevokeVerification(ctx context.Context, req admin.RevokeVerificationRequest) (admin.CommandResult, error)
|
||||
VerificationApplications(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error)
|
||||
VerificationApplication(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
|
||||
VerificationApplicationEvents(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error)
|
||||
VerificationCounts(ctx context.Context) (domain.VerificationStatusCounts, error)
|
||||
VerificationTargetSnapshot(ctx context.Context, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationTarget, error)
|
||||
// Third-party bot verification. A separate mechanism from the official
|
||||
// verification methods above, over separate tables and separate permissions;
|
||||
// see botverification.go.
|
||||
GrantBotVerifier(ctx context.Context, req admin.GrantBotVerifierRequest) (admin.CommandResult, error)
|
||||
SetBotVerifierEnabled(ctx context.Context, req admin.SetBotVerifierEnabledRequest) (admin.CommandResult, error)
|
||||
RevokeBotVerifier(ctx context.Context, req admin.RevokeBotVerifierRequest) (admin.CommandResult, error)
|
||||
UpsertVerificationIcon(ctx context.Context, req admin.UpsertVerificationIconRequest) (admin.CommandResult, error)
|
||||
SetVerificationIconActive(ctx context.Context, req admin.SetVerificationIconActiveRequest) (admin.CommandResult, error)
|
||||
RevokeCustomVerification(ctx context.Context, req admin.RevokeCustomVerificationRequest) (admin.CommandResult, error)
|
||||
ApproveBotVerification(ctx context.Context, req admin.ApproveBotVerificationRequest) (admin.CommandResult, error)
|
||||
RejectBotVerification(ctx context.Context, req admin.RejectBotVerificationRequest) (admin.CommandResult, error)
|
||||
RevokeBotVerification(ctx context.Context, req admin.RevokeBotVerificationRequest) (admin.CommandResult, error)
|
||||
BotVerifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error)
|
||||
BotVerifier(ctx context.Context, botID int64) (domain.BotVerifierSettings, error)
|
||||
VerificationIcons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error)
|
||||
CustomVerifications(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error)
|
||||
CustomVerificationRequests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error)
|
||||
CustomVerificationRequest(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error)
|
||||
CustomVerificationRequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error)
|
||||
CustomVerificationMarkActive(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error)
|
||||
}
|
||||
|
||||
func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http.Server, error) {
|
||||
|
|
@ -88,7 +151,7 @@ func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http
|
|||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
server := &Server{token: cfg.Token, svc: svc, log: log}
|
||||
server := &Server{token: cfg.Token, scoped: cfg.ScopedTokens, svc: svc, log: log}
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: server.routes(),
|
||||
|
|
@ -110,9 +173,10 @@ func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http
|
|||
}
|
||||
|
||||
type Server struct {
|
||||
token string
|
||||
svc Service
|
||||
log *zap.Logger
|
||||
token string
|
||||
scoped []ScopedToken
|
||||
svc Service
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func (s *Server) routes() http.Handler {
|
||||
|
|
@ -166,20 +230,57 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("GET /v1/emoji/{id}/animation", s.authenticated(s.handleEmojiAnimation))
|
||||
mux.HandleFunc("GET /v1/gifts/{id}/collectibles", s.authenticated(s.handleStarGiftCollectibles))
|
||||
mux.HandleFunc("GET /v1/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.authenticated(s.handleStarGiftCollectibleAnimation))
|
||||
mux.HandleFunc("GET /v1/moderation/cases", s.authenticated(s.handleModerationCases))
|
||||
mux.HandleFunc("GET /v1/moderation/cases/{id}", s.authenticated(s.handleModerationCase))
|
||||
mux.HandleFunc("GET /v1/moderation/reports/{id}", s.authenticated(s.handleModerationReport))
|
||||
mux.HandleFunc("POST /v1/moderation/cases/{id}/claim", s.authenticated(s.handleClaimModerationCase))
|
||||
mux.HandleFunc("POST /v1/moderation/cases/{id}/decide", s.authenticated(s.handleDecideModerationCase))
|
||||
mux.HandleFunc("POST /v1/moderation/cases/{id}/appeals", s.authenticated(s.handleSubmitModerationAppeal))
|
||||
mux.HandleFunc("POST /v1/moderation/cases/{id}/appeals/{appeal_id}/review", s.authenticated(s.handleReviewModerationAppeal))
|
||||
mux.HandleFunc("POST /v1/collectible-usernames/mint", s.authenticated(s.handleMintCollectibleUsername))
|
||||
mux.HandleFunc("POST /v1/collectible-usernames/transfer", s.authenticated(s.handleTransferCollectibleUsername))
|
||||
mux.HandleFunc("POST /v1/collectible-usernames/revoke", s.authenticated(s.handleRevokeCollectibleUsername))
|
||||
mux.HandleFunc("POST /v1/collectible-usernames/delete", s.authenticated(s.handleDeleteCollectibleUsername))
|
||||
mux.HandleFunc("GET /v1/collectible-usernames", s.authenticated(s.handleCollectibleUsernames))
|
||||
mux.HandleFunc("GET /v1/collectible-usernames/{id}", s.authenticated(s.handleCollectibleUsername))
|
||||
mux.HandleFunc("POST /v1/account-ratings/recompute", s.authenticated(s.handleRecomputeAccountRating))
|
||||
mux.HandleFunc("POST /v1/account-ratings/adjust", s.authenticated(s.handleAdjustAccountRating))
|
||||
mux.HandleFunc("GET /v1/account-ratings", s.authenticated(s.handleAccountRatings))
|
||||
mux.HandleFunc("GET /v1/account-ratings/{id}", s.authenticated(s.handleAccountRating))
|
||||
// Official platform verification. Unlike every route above, these carry a
|
||||
// named permission, so a scoped token can be given the review surface and
|
||||
// nothing else. Revocation additionally requires verification.revoke.
|
||||
mux.HandleFunc("GET /v1/verification/applications", s.authorized(PermissionVerificationReview, s.handleVerificationApplications))
|
||||
mux.HandleFunc("GET /v1/verification/applications/{id}", s.authorized(PermissionVerificationReview, s.handleVerificationApplication))
|
||||
mux.HandleFunc("GET /v1/verification/counts", s.authorized(PermissionVerificationReview, s.handleVerificationCounts))
|
||||
mux.HandleFunc("POST /v1/verification/applications/{id}/claim", s.authorized(PermissionVerificationReview, s.handleClaimVerification))
|
||||
mux.HandleFunc("POST /v1/verification/applications/{id}/approve", s.authorized(PermissionVerificationReview, s.handleApproveVerification))
|
||||
mux.HandleFunc("POST /v1/verification/applications/{id}/reject", s.authorized(PermissionVerificationReview, s.handleRejectVerification))
|
||||
mux.HandleFunc("POST /v1/verification/revoke", s.authorizedAll(
|
||||
[]string{PermissionVerificationReview, PermissionVerificationRevoke}, s.handleRevokeVerification))
|
||||
// Third-party bot verification. Separate routes, separate permissions and
|
||||
// separate tables from the official verification block above -- the two
|
||||
// mechanisms never read each other's state. Reads and queue decisions need
|
||||
// botverification.review; appointing verifiers, curating icons and stripping a
|
||||
// granted mark need botverification.manage.
|
||||
mux.HandleFunc("GET /v1/botverification/verifiers", s.authorized(PermissionBotVerificationReview, s.handleBotVerifiers))
|
||||
mux.HandleFunc("GET /v1/botverification/icons", s.authorized(PermissionBotVerificationReview, s.handleVerificationIcons))
|
||||
mux.HandleFunc("GET /v1/botverification/marks", s.authorized(PermissionBotVerificationReview, s.handleCustomVerifications))
|
||||
mux.HandleFunc("GET /v1/botverification/requests", s.authorized(PermissionBotVerificationReview, s.handleCustomVerificationRequests))
|
||||
mux.HandleFunc("GET /v1/botverification/requests/{id}", s.authorized(PermissionBotVerificationReview, s.handleCustomVerificationRequest))
|
||||
mux.HandleFunc("GET /v1/botverification/counts", s.authorized(PermissionBotVerificationReview, s.handleCustomVerificationCounts))
|
||||
mux.HandleFunc("POST /v1/botverification/requests/{id}/approve", s.authorized(PermissionBotVerificationReview, s.handleApproveBotVerification))
|
||||
mux.HandleFunc("POST /v1/botverification/requests/{id}/reject", s.authorized(PermissionBotVerificationReview, s.handleRejectBotVerification))
|
||||
mux.HandleFunc("POST /v1/botverification/requests/{id}/revoke", s.authorized(PermissionBotVerificationReview, s.handleRevokeBotVerification))
|
||||
mux.HandleFunc("POST /v1/botverification/verifiers/grant", s.authorized(PermissionBotVerificationManage, s.handleGrantBotVerifier))
|
||||
mux.HandleFunc("POST /v1/botverification/verifiers/set-enabled", s.authorized(PermissionBotVerificationManage, s.handleSetBotVerifierEnabled))
|
||||
mux.HandleFunc("POST /v1/botverification/verifiers/revoke", s.authorized(PermissionBotVerificationManage, s.handleRevokeBotVerifier))
|
||||
mux.HandleFunc("POST /v1/botverification/icons/upsert", s.authorized(PermissionBotVerificationManage, s.handleUpsertVerificationIcon))
|
||||
mux.HandleFunc("POST /v1/botverification/icons/set-active", s.authorized(PermissionBotVerificationManage, s.handleSetVerificationIconActive))
|
||||
mux.HandleFunc("POST /v1/botverification/marks/revoke", s.authorized(PermissionBotVerificationManage, s.handleRevokeCustomVerification))
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) authenticated(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) != 1 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || userID <= 0 {
|
||||
|
|
@ -872,6 +973,662 @@ func (s *Server) handleStarGiftCollectibleAnimation(w http.ResponseWriter, r *ht
|
|||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
type moderationClaimRequest struct {
|
||||
ExpectedVersion int64 `json:"expected_version"`
|
||||
Actor string `json:"actor"`
|
||||
}
|
||||
|
||||
type moderationActionRequest struct {
|
||||
Kind domain.ModerationActionKind `json:"kind"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
type moderationDecisionRequest struct {
|
||||
ExpectedVersion int64 `json:"expected_version"`
|
||||
Actor string `json:"actor"`
|
||||
Reason string `json:"reason"`
|
||||
CommandID string `json:"command_id"`
|
||||
Kind domain.ModerationDecisionKind `json:"kind"`
|
||||
Actions []moderationActionRequest `json:"actions"`
|
||||
}
|
||||
|
||||
type moderationAppealRequest struct {
|
||||
AppellantUserID int64 `json:"appellant_user_id"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type moderationAppealReviewRequest struct {
|
||||
ExpectedVersion int64 `json:"expected_version"`
|
||||
Actor string `json:"actor"`
|
||||
Reason string `json:"reason"`
|
||||
CommandID string `json:"command_id"`
|
||||
Granted bool `json:"granted"`
|
||||
Actions []moderationActionRequest `json:"actions"`
|
||||
}
|
||||
|
||||
func (s *Server) handleModerationCases(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
limit := 50
|
||||
if raw := query.Get("limit"); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
filter := domain.ModerationCaseFilter{
|
||||
AssignedTo: query.Get("assigned_to"),
|
||||
Limit: limit,
|
||||
}
|
||||
if raw := query.Get("statuses"); raw != "" {
|
||||
for _, status := range strings.Split(raw, ",") {
|
||||
if status = strings.TrimSpace(status); status != "" {
|
||||
filter.Statuses = append(filter.Statuses, domain.ModerationCaseStatus(status))
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw := query.Get("target_id"); raw != "" {
|
||||
id, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid target id")
|
||||
return
|
||||
}
|
||||
filter.Target = domain.Peer{
|
||||
Type: domain.PeerType(query.Get("target_type")), ID: id,
|
||||
}
|
||||
}
|
||||
if raw := query.Get("before_updated_at"); raw != "" {
|
||||
parsed, err := time.Parse(time.RFC3339Nano, raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid before_updated_at")
|
||||
return
|
||||
}
|
||||
filter.BeforeUpdate = parsed
|
||||
filter.BeforeID, _ = strconv.ParseInt(query.Get("before_id"), 10, 64)
|
||||
}
|
||||
items, err := s.svc.ModerationCases(r.Context(), filter)
|
||||
if err != nil {
|
||||
writeModerationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"cases": moderationCasesResponse(items),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleModerationCase(w http.ResponseWriter, r *http.Request) {
|
||||
caseID, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
detail, found, err := s.svc.ModerationCase(r.Context(), caseID)
|
||||
if err != nil {
|
||||
writeModerationError(w, err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "moderation case not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, moderationCaseDetailResponse(detail))
|
||||
}
|
||||
|
||||
func (s *Server) handleModerationReport(w http.ResponseWriter, r *http.Request) {
|
||||
reportID, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
report, found, err := s.svc.ModerationReport(r.Context(), reportID)
|
||||
if err != nil {
|
||||
writeModerationError(w, err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "moderation report not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, moderationReportResponse(report))
|
||||
}
|
||||
|
||||
func (s *Server) handleClaimModerationCase(w http.ResponseWriter, r *http.Request) {
|
||||
caseID, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request moderationClaimRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
item, err := s.svc.ClaimModerationCase(
|
||||
r.Context(), caseID, request.ExpectedVersion, request.Actor,
|
||||
)
|
||||
if err != nil {
|
||||
writeModerationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleDecideModerationCase(w http.ResponseWriter, r *http.Request) {
|
||||
caseID, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request moderationDecisionRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
detail, created, err := s.svc.DecideModerationCase(
|
||||
r.Context(), moderationDecisionDomain(caseID, 0, request),
|
||||
)
|
||||
if err != nil {
|
||||
writeModerationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"created": created, "case": moderationCaseDetailResponse(detail),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSubmitModerationAppeal(w http.ResponseWriter, r *http.Request) {
|
||||
caseID, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request moderationAppealRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
appeal, created, err := s.svc.SubmitModerationAppeal(
|
||||
r.Context(), caseID, request.AppellantUserID, request.Text,
|
||||
)
|
||||
if err != nil {
|
||||
writeModerationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"created": created, "appeal": appeal,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleReviewModerationAppeal(w http.ResponseWriter, r *http.Request) {
|
||||
caseID, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
appealID, ok := moderationPathID(w, r, "appeal_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request moderationAppealReviewRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
kind := domain.ModerationDecisionAppealDeny
|
||||
if request.Granted {
|
||||
kind = domain.ModerationDecisionAppealGrant
|
||||
}
|
||||
decision := moderationDecisionRequest{
|
||||
ExpectedVersion: request.ExpectedVersion, Actor: request.Actor,
|
||||
Reason: request.Reason, CommandID: request.CommandID,
|
||||
Kind: kind, Actions: request.Actions,
|
||||
}
|
||||
detail, created, err := s.svc.ReviewModerationAppeal(
|
||||
r.Context(), moderationDecisionDomain(caseID, appealID, decision),
|
||||
)
|
||||
if err != nil {
|
||||
writeModerationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"created": created, "case": moderationCaseDetailResponse(detail),
|
||||
})
|
||||
}
|
||||
|
||||
func moderationCasesResponse(items []domain.ModerationCase) []domain.ModerationCase {
|
||||
if items == nil {
|
||||
return []domain.ModerationCase{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func moderationCaseDetailResponse(detail domain.ModerationCaseDetail) domain.ModerationCaseDetail {
|
||||
if detail.Decisions == nil {
|
||||
detail.Decisions = []domain.ModerationDecision{}
|
||||
}
|
||||
if detail.Actions == nil {
|
||||
detail.Actions = []domain.ModerationAction{}
|
||||
}
|
||||
if detail.Appeals == nil {
|
||||
detail.Appeals = []domain.ModerationAppeal{}
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func moderationReportResponse(report domain.ModerationReport) domain.ModerationReport {
|
||||
if report.MediaHolds == nil {
|
||||
report.MediaHolds = []domain.ModerationMediaHold{}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func moderationDecisionDomain(caseID, appealID int64, request moderationDecisionRequest) domain.ModerationDecisionRequest {
|
||||
actions := make([]domain.ModerationActionDraft, 0, len(request.Actions))
|
||||
for _, action := range request.Actions {
|
||||
payload := action.Payload
|
||||
if len(payload) == 0 {
|
||||
payload = json.RawMessage(`{}`)
|
||||
}
|
||||
actions = append(actions, domain.ModerationActionDraft{
|
||||
Kind: action.Kind, Payload: payload,
|
||||
})
|
||||
}
|
||||
return domain.ModerationDecisionRequest{
|
||||
CaseID: caseID, AppealID: appealID,
|
||||
ExpectedVersion: request.ExpectedVersion, Actor: request.Actor,
|
||||
Reason: request.Reason, CommandID: request.CommandID,
|
||||
Kind: request.Kind, Actions: actions,
|
||||
}
|
||||
}
|
||||
|
||||
func moderationPathID(w http.ResponseWriter, r *http.Request, name string) (int64, bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue(name), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid "+name)
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func writeModerationError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrModerationCaseNotFound),
|
||||
errors.Is(err, domain.ErrModerationReportNotFound),
|
||||
errors.Is(err, domain.ErrModerationEvidenceNotFound):
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, domain.ErrModerationPermissionDenied):
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
case errors.Is(err, domain.ErrModerationCaseConflict),
|
||||
errors.Is(err, domain.ErrModerationActionConflict):
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, domain.ErrModerationRateLimited):
|
||||
writeError(w, http.StatusTooManyRequests, err.Error())
|
||||
case errors.Is(err, domain.ErrModerationCaseInvalid),
|
||||
errors.Is(err, domain.ErrModerationActionInvalid),
|
||||
errors.Is(err, domain.ErrModerationReportInvalid):
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleMintCollectibleUsername(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.MintCollectibleUsernameRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.MintCollectibleUsername(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleTransferCollectibleUsername(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.TransferCollectibleUsernameRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.TransferCollectibleUsername(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeCollectibleUsername(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.RevokeCollectibleUsernameRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.RevokeCollectibleUsername(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteCollectibleUsername(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.DeleteCollectibleUsernameRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.DeleteCollectibleUsername(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRecomputeAccountRating(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.RecomputeAccountRatingRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.RecomputeAccountRating(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdjustAccountRating(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.AdjustAccountRatingRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.AdjustAccountRating(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleCollectibleUsernames(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
filter := domain.CollectibleUsernameFilter{
|
||||
Status: domain.CollectibleUsernameStatus(strings.TrimSpace(query.Get("status"))),
|
||||
Query: query.Get("q"),
|
||||
}
|
||||
if filter.Status != "" && !filter.Status.Valid() {
|
||||
writeCodedError(w, http.StatusBadRequest, admin.CodeCollectibleStateInvalid, "invalid status")
|
||||
return
|
||||
}
|
||||
owner, ok := collectibleOwnerFilter(w, query)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.Owner = owner
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.Limit = limit
|
||||
beforeID, ok := optionalQueryInt64(w, query, "before_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.BeforeID = beforeID
|
||||
items, err := s.svc.CollectibleUsernames(r.Context(), filter)
|
||||
if err != nil {
|
||||
writeCollectibleUsernameError(w, err)
|
||||
return
|
||||
}
|
||||
assets := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
assets = append(assets, collectibleUsernameResponse(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"assets": assets})
|
||||
}
|
||||
|
||||
func (s *Server) handleCollectibleUsername(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
asset, err := s.svc.CollectibleUsernameByID(r.Context(), id)
|
||||
if err != nil {
|
||||
writeCollectibleUsernameError(w, err)
|
||||
return
|
||||
}
|
||||
limit, ok := optionalQueryInt(w, r.URL.Query(), "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
transfers, err := s.svc.CollectibleUsernameTransfers(r.Context(), asset.ID, limit)
|
||||
if err != nil {
|
||||
writeCollectibleUsernameError(w, err)
|
||||
return
|
||||
}
|
||||
log := make([]map[string]any, 0, len(transfers))
|
||||
for _, item := range transfers {
|
||||
log = append(log, collectibleUsernameTransferResponse(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"asset": collectibleUsernameResponse(asset), "transfers": log,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountRatings(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
minLevel, ok := optionalQueryInt(w, query, "min_level")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := optionalQueryInt64(w, query, "user_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
beforeID, ok := optionalQueryInt64(w, query, "before_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := s.svc.AccountRatings(r.Context(), domain.AccountRatingFilter{
|
||||
MinLevel: minLevel, UserID: userID, BeforeID: beforeID, Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
writeAccountRatingError(w, err)
|
||||
return
|
||||
}
|
||||
ratings := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
ratings = append(ratings, accountRatingResponse(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ratings": ratings})
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountRating(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rating, err := s.svc.AccountRating(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeAccountRatingError(w, err)
|
||||
return
|
||||
}
|
||||
limit, ok := optionalQueryInt(w, r.URL.Query(), "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
events, err := s.svc.AccountRatingEvents(r.Context(), userID, limit)
|
||||
if err != nil {
|
||||
writeAccountRatingError(w, err)
|
||||
return
|
||||
}
|
||||
ledger := make([]map[string]any, 0, len(events))
|
||||
for _, item := range events {
|
||||
ledger = append(ledger, accountRatingEventResponse(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rating": accountRatingResponse(rating), "events": ledger,
|
||||
})
|
||||
}
|
||||
|
||||
// collectibleOwnerFilter reads the optional owner filter. At most one of the two
|
||||
// identifiers may be present, mirroring the mint/transfer request shape.
|
||||
func collectibleOwnerFilter(w http.ResponseWriter, query url.Values) (domain.Peer, bool) {
|
||||
userID, ok := optionalQueryInt64(w, query, "owner_user_id")
|
||||
if !ok {
|
||||
return domain.Peer{}, false
|
||||
}
|
||||
channelID, ok := optionalQueryInt64(w, query, "owner_channel_id")
|
||||
if !ok {
|
||||
return domain.Peer{}, false
|
||||
}
|
||||
switch {
|
||||
case userID > 0 && channelID > 0:
|
||||
writeError(w, http.StatusBadRequest, "at most one owner filter is allowed")
|
||||
return domain.Peer{}, false
|
||||
case userID > 0:
|
||||
return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, true
|
||||
case channelID > 0:
|
||||
return domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, true
|
||||
default:
|
||||
return domain.Peer{}, true
|
||||
}
|
||||
}
|
||||
|
||||
func optionalQueryInt64(w http.ResponseWriter, query url.Values, name string) (int64, bool) {
|
||||
raw := strings.TrimSpace(query.Get(name))
|
||||
if raw == "" {
|
||||
return 0, true
|
||||
}
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || value < 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid "+name)
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func optionalQueryInt(w http.ResponseWriter, query url.Values, name string) (int, bool) {
|
||||
value, ok := optionalQueryInt64(w, query, name)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
if value > math.MaxInt32 {
|
||||
writeError(w, http.StatusBadRequest, "invalid "+name)
|
||||
return 0, false
|
||||
}
|
||||
return int(value), true
|
||||
}
|
||||
|
||||
// collectibleUsernameResponse renders one asset. Every int64 crosses the JSON
|
||||
// boundary as a decimal string: asset ids and nanoton amounts exceed the exact
|
||||
// range of a JSON number, and a rounded id would address the wrong asset.
|
||||
func collectibleUsernameResponse(asset domain.CollectibleUsername) map[string]any {
|
||||
out := map[string]any{
|
||||
"id": strconv.FormatInt(asset.ID, 10),
|
||||
"username": asset.Username,
|
||||
"status": string(asset.Status),
|
||||
"owner_type": string(asset.Owner.Type),
|
||||
"owner_id": strconv.FormatInt(asset.Owner.ID, 10),
|
||||
"purchase_date": asset.Info().PurchaseDate,
|
||||
"currency": asset.Currency,
|
||||
"amount": strconv.FormatInt(asset.Amount, 10),
|
||||
"crypto_currency": asset.CryptoCurrency,
|
||||
"crypto_amount": strconv.FormatInt(asset.CryptoAmount, 10),
|
||||
"url": asset.URL,
|
||||
"original_owner_type": string(asset.OriginalOwner.Type),
|
||||
"original_owner_id": strconv.FormatInt(asset.OriginalOwner.ID, 10),
|
||||
"transfer_count": asset.TransferCount,
|
||||
"version": strconv.FormatInt(asset.Version, 10),
|
||||
}
|
||||
if !asset.CreatedAt.IsZero() {
|
||||
out["created_at"] = asset.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !asset.UpdatedAt.IsZero() {
|
||||
out["updated_at"] = asset.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func collectibleUsernameTransferResponse(item domain.CollectibleUsernameTransfer) map[string]any {
|
||||
out := map[string]any{
|
||||
"id": strconv.FormatInt(item.ID, 10),
|
||||
"collectible_id": strconv.FormatInt(item.CollectibleID, 10),
|
||||
"kind": string(item.Kind),
|
||||
"from_type": string(item.From.Type),
|
||||
"from_id": strconv.FormatInt(item.From.ID, 10),
|
||||
"to_type": string(item.To.Type),
|
||||
"to_id": strconv.FormatInt(item.To.ID, 10),
|
||||
"currency": item.Currency,
|
||||
"amount": strconv.FormatInt(item.Amount, 10),
|
||||
"actor": item.Actor,
|
||||
"reason": item.Reason,
|
||||
"command_key": item.CommandKey,
|
||||
}
|
||||
if !item.CreatedAt.IsZero() {
|
||||
out["created_at"] = item.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// accountRatingResponse renders one composite rating. The score and every
|
||||
// component stay decimal strings for the same exactness reason as the asset ids.
|
||||
func accountRatingResponse(rating domain.AccountRating) map[string]any {
|
||||
out := map[string]any{
|
||||
"user_id": strconv.FormatInt(rating.UserID, 10),
|
||||
"level": rating.Level,
|
||||
"stars": strconv.FormatInt(rating.Stars, 10),
|
||||
"current_level_stars": strconv.FormatInt(rating.CurrentLevelStars, 10),
|
||||
"has_next_level": rating.HasNextLevel,
|
||||
"stars_component": strconv.FormatInt(rating.StarsComponent, 10),
|
||||
"activity_component": strconv.FormatInt(rating.ActivityComponent, 10),
|
||||
"penalty_component": strconv.FormatInt(rating.PenaltyComponent, 10),
|
||||
"manual_component": strconv.FormatInt(rating.ManualComponent, 10),
|
||||
"pending_stars": strconv.FormatInt(rating.PendingStars, 10),
|
||||
"version": strconv.FormatInt(rating.Version, 10),
|
||||
}
|
||||
if rating.HasNextLevel {
|
||||
out["next_level_stars"] = strconv.FormatInt(rating.NextLevelStars, 10)
|
||||
}
|
||||
if !rating.PendingDate.IsZero() {
|
||||
out["pending_date"] = rating.PendingDate.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !rating.ComputedAt.IsZero() {
|
||||
out["computed_at"] = rating.ComputedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !rating.UpdatedAt.IsZero() {
|
||||
out["updated_at"] = rating.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func accountRatingEventResponse(event domain.AccountRatingEvent) map[string]any {
|
||||
out := map[string]any{
|
||||
"id": strconv.FormatInt(event.ID, 10),
|
||||
"user_id": strconv.FormatInt(event.UserID, 10),
|
||||
"kind": string(event.Kind),
|
||||
"amount": strconv.FormatInt(event.Amount, 10),
|
||||
"reason": event.Reason,
|
||||
"actor": event.Actor,
|
||||
"command_key": event.CommandKey,
|
||||
}
|
||||
if !event.CreatedAt.IsZero() {
|
||||
out["created_at"] = event.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeCollectibleUsernameError maps a collectible-username failure onto its
|
||||
// stable admin code and the matching HTTP status, the way writeModerationError
|
||||
// does for moderation. An unmapped failure stays a 500 with its own text rather
|
||||
// than being dressed up as a client error.
|
||||
func writeCollectibleUsernameError(w http.ResponseWriter, err error) {
|
||||
code := admin.CollectibleUsernameErrorCode(err)
|
||||
status := http.StatusInternalServerError
|
||||
switch code {
|
||||
case admin.CodeCollectibleNotFound:
|
||||
status = http.StatusNotFound
|
||||
case admin.CodeUsernameOccupied, admin.CodeCollectibleBurned,
|
||||
admin.CodeCollectiblePeerLimit, admin.CodeCollectibleNotOwned:
|
||||
status = http.StatusConflict
|
||||
case admin.CodeUsernameInvalid, admin.CodeUsernameNotCollectible,
|
||||
admin.CodeCollectibleCurrencyInvalid, admin.CodeCollectibleStateInvalid:
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeCodedError(w, status, code, err.Error())
|
||||
}
|
||||
|
||||
func writeAccountRatingError(w http.ResponseWriter, err error) {
|
||||
code := admin.AccountRatingErrorCode(err)
|
||||
status := http.StatusInternalServerError
|
||||
switch code {
|
||||
case admin.CodeRatingNotFound:
|
||||
status = http.StatusNotFound
|
||||
case admin.CodeRatingAdjustmentInvalid, admin.CodeRatingWeightsInvalid:
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeCodedError(w, status, code, err.Error())
|
||||
}
|
||||
|
||||
func writeCodedError(w http.ResponseWriter, status int, code, msg string) {
|
||||
body := map[string]string{"error": msg}
|
||||
if code != "" {
|
||||
body["code"] = code
|
||||
}
|
||||
writeJSON(w, status, body)
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package adminapi
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
|
@ -43,6 +44,226 @@ func TestAdminAPISetAccountFrozen(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
type captureModerationService struct {
|
||||
fakeService
|
||||
filter domain.ModerationCaseFilter
|
||||
decision domain.ModerationDecisionRequest
|
||||
appealReview domain.ModerationDecisionRequest
|
||||
}
|
||||
|
||||
func (s *captureModerationService) ModerationCases(_ context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
|
||||
s.filter = filter
|
||||
return []domain.ModerationCase{{ID: 7}}, nil
|
||||
}
|
||||
|
||||
func (s *captureModerationService) DecideModerationCase(_ context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||
s.decision = request
|
||||
return domain.ModerationCaseDetail{Case: domain.ModerationCase{ID: request.CaseID}}, true, nil
|
||||
}
|
||||
|
||||
func (s *captureModerationService) ReviewModerationAppeal(_ context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||
s.appealReview = request
|
||||
return domain.ModerationCaseDetail{Case: domain.ModerationCase{ID: request.CaseID}}, true, nil
|
||||
}
|
||||
|
||||
func TestAdminAPIModerationQueueDecisionAndAppealReview(t *testing.T) {
|
||||
svc := &captureModerationService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
listRequest := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/v1/moderation/cases?statuses=open,action_failed&assigned_to=alice&target_type=user&target_id=99&limit=25",
|
||||
nil,
|
||||
)
|
||||
listRequest.Header.Set("Authorization", "Bearer secret")
|
||||
list := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(list, listRequest)
|
||||
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"ID":7`) {
|
||||
t.Fatalf("list status=%d body=%s", list.Code, list.Body.String())
|
||||
}
|
||||
if len(svc.filter.Statuses) != 2 ||
|
||||
svc.filter.Statuses[0] != domain.ModerationCaseOpen ||
|
||||
svc.filter.Statuses[1] != domain.ModerationCaseActionFailed ||
|
||||
svc.filter.AssignedTo != "alice" ||
|
||||
svc.filter.Target != (domain.Peer{Type: domain.PeerTypeUser, ID: 99}) ||
|
||||
svc.filter.Limit != 25 {
|
||||
t.Fatalf("filter=%+v", svc.filter)
|
||||
}
|
||||
|
||||
decisionRequest := httptest.NewRequest(
|
||||
http.MethodPost, "/v1/moderation/cases/7/decide",
|
||||
strings.NewReader(`{"expected_version":3,"actor":"alice","reason":"confirmed","command_id":"decision-7","kind":"violation","actions":[{"kind":"mark_scam","payload":{}}]}`),
|
||||
)
|
||||
decisionRequest.Header.Set("Authorization", "Bearer secret")
|
||||
decision := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(decision, decisionRequest)
|
||||
if decision.Code != http.StatusOK ||
|
||||
!strings.Contains(decision.Body.String(), `"created":true`) ||
|
||||
svc.decision.CaseID != 7 || svc.decision.ExpectedVersion != 3 ||
|
||||
svc.decision.Kind != domain.ModerationDecisionViolation ||
|
||||
len(svc.decision.Actions) != 1 ||
|
||||
svc.decision.Actions[0].Kind != domain.ModerationActionMarkScam {
|
||||
t.Fatalf("decision status=%d request=%+v body=%s",
|
||||
decision.Code, svc.decision, decision.Body.String())
|
||||
}
|
||||
|
||||
reviewRequest := httptest.NewRequest(
|
||||
http.MethodPost, "/v1/moderation/cases/7/appeals/8/review",
|
||||
strings.NewReader(`{"expected_version":5,"actor":"bob","reason":"appeal accepted","command_id":"appeal-8","granted":true,"actions":[{"kind":"clear_peer_flags","payload":{}}]}`),
|
||||
)
|
||||
reviewRequest.Header.Set("Authorization", "Bearer secret")
|
||||
review := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(review, reviewRequest)
|
||||
if review.Code != http.StatusOK ||
|
||||
svc.appealReview.CaseID != 7 || svc.appealReview.AppealID != 8 ||
|
||||
svc.appealReview.Kind != domain.ModerationDecisionAppealGrant ||
|
||||
len(svc.appealReview.Actions) != 1 ||
|
||||
svc.appealReview.Actions[0].Kind != domain.ModerationActionClearPeerFlags {
|
||||
t.Fatalf("review status=%d request=%+v body=%s",
|
||||
review.Code, svc.appealReview, review.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type emptyModerationCollectionsService struct {
|
||||
fakeService
|
||||
}
|
||||
|
||||
func (emptyModerationCollectionsService) ModerationCases(
|
||||
context.Context,
|
||||
domain.ModerationCaseFilter,
|
||||
) ([]domain.ModerationCase, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (emptyModerationCollectionsService) ModerationCase(
|
||||
_ context.Context,
|
||||
caseID int64,
|
||||
) (domain.ModerationCaseDetail, bool, error) {
|
||||
return domain.ModerationCaseDetail{
|
||||
Case: domain.ModerationCase{ID: caseID},
|
||||
ReportIDs: []int64{9},
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (emptyModerationCollectionsService) ModerationReport(
|
||||
_ context.Context,
|
||||
reportID int64,
|
||||
) (domain.ModerationReport, bool, error) {
|
||||
return domain.ModerationReport{
|
||||
ID: reportID,
|
||||
Items: []domain.ModerationReportItem{{ItemID: 10}},
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (emptyModerationCollectionsService) DecideModerationCase(
|
||||
_ context.Context,
|
||||
request domain.ModerationDecisionRequest,
|
||||
) (domain.ModerationCaseDetail, bool, error) {
|
||||
return domain.ModerationCaseDetail{
|
||||
Case: domain.ModerationCase{ID: request.CaseID},
|
||||
ReportIDs: []int64{9},
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (emptyModerationCollectionsService) ReviewModerationAppeal(
|
||||
_ context.Context,
|
||||
request domain.ModerationDecisionRequest,
|
||||
) (domain.ModerationCaseDetail, bool, error) {
|
||||
return domain.ModerationCaseDetail{
|
||||
Case: domain.ModerationCase{ID: request.CaseID},
|
||||
ReportIDs: []int64{9},
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func TestAdminAPIModerationCollectionsAreJSONArrays(t *testing.T) {
|
||||
srv := &Server{token: "secret", svc: emptyModerationCollectionsService{}}
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
keys []string
|
||||
nonEmptyKeys []string
|
||||
nested string
|
||||
}{
|
||||
{
|
||||
name: "empty queue", method: http.MethodGet,
|
||||
path: "/v1/moderation/cases", keys: []string{"cases"},
|
||||
},
|
||||
{
|
||||
name: "fresh case", method: http.MethodGet,
|
||||
path: "/v1/moderation/cases/7",
|
||||
keys: []string{"Decisions", "Actions", "Appeals"},
|
||||
nonEmptyKeys: []string{"ReportIDs"},
|
||||
},
|
||||
{
|
||||
name: "report without media holds", method: http.MethodGet,
|
||||
path: "/v1/moderation/reports/9",
|
||||
keys: []string{"MediaHolds"},
|
||||
nonEmptyKeys: []string{"Items"},
|
||||
},
|
||||
{
|
||||
name: "decision response", method: http.MethodPost,
|
||||
path: "/v1/moderation/cases/7/decide", body: `{}`,
|
||||
nested: "case",
|
||||
keys: []string{"Decisions", "Actions", "Appeals"},
|
||||
nonEmptyKeys: []string{"ReportIDs"},
|
||||
},
|
||||
{
|
||||
name: "appeal review response", method: http.MethodPost,
|
||||
path: "/v1/moderation/cases/7/appeals/8/review", body: `{}`,
|
||||
nested: "case",
|
||||
keys: []string{"Decisions", "Actions", "Appeals"},
|
||||
nonEmptyKeys: []string{"ReportIDs"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if tt.nested != "" {
|
||||
nestedValue := response[tt.nested]
|
||||
var ok bool
|
||||
response, ok = nestedValue.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%s=%T, want object; body=%s",
|
||||
tt.nested, nestedValue, rec.Body.String())
|
||||
}
|
||||
}
|
||||
for _, key := range tt.keys {
|
||||
value, ok := response[key]
|
||||
if !ok {
|
||||
t.Fatalf("%s missing; body=%s", key, rec.Body.String())
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok || len(items) != 0 {
|
||||
t.Fatalf("%s=%#v, want empty JSON array; body=%s",
|
||||
key, value, rec.Body.String())
|
||||
}
|
||||
}
|
||||
for _, key := range tt.nonEmptyKeys {
|
||||
value, ok := response[key]
|
||||
if !ok {
|
||||
t.Fatalf("%s missing; body=%s", key, rec.Body.String())
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok || len(items) == 0 {
|
||||
t.Fatalf("%s=%#v, want non-empty JSON array; body=%s",
|
||||
key, value, rec.Body.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPISetVerified(t *testing.T) {
|
||||
srv := &Server{token: "secret", svc: fakeService{}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/set-verified", strings.NewReader(`{"command_id":"c2","actor":"ops","reason":"official","dry_run":true,"user_id":1001,"verified":true}`))
|
||||
|
|
@ -397,3 +618,397 @@ func (fakeService) StarGiftCollectibles(context.Context, int64) (domain.StarGift
|
|||
func (fakeService) StarGiftCollectibleAnimation(context.Context, int64, domain.StarGiftCollectibleAttributeKind, int64) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
|
||||
}
|
||||
|
||||
func (fakeService) ModerationCases(context.Context, domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) ModerationCase(context.Context, int64) (domain.ModerationCaseDetail, bool, error) {
|
||||
return domain.ModerationCaseDetail{}, false, nil
|
||||
}
|
||||
|
||||
func (fakeService) ModerationReport(context.Context, int64) (domain.ModerationReport, bool, error) {
|
||||
return domain.ModerationReport{}, false, nil
|
||||
}
|
||||
|
||||
func (fakeService) ClaimModerationCase(context.Context, int64, int64, string) (domain.ModerationCase, error) {
|
||||
return domain.ModerationCase{}, nil
|
||||
}
|
||||
|
||||
func (fakeService) DecideModerationCase(context.Context, domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||
return domain.ModerationCaseDetail{}, true, nil
|
||||
}
|
||||
|
||||
func (fakeService) SubmitModerationAppeal(context.Context, int64, int64, string) (domain.ModerationAppeal, bool, error) {
|
||||
return domain.ModerationAppeal{}, true, nil
|
||||
}
|
||||
|
||||
func (fakeService) ReviewModerationAppeal(context.Context, domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||
return domain.ModerationCaseDetail{}, true, nil
|
||||
}
|
||||
|
||||
type captureCollectibleUsernameService struct {
|
||||
fakeService
|
||||
mint admin.MintCollectibleUsernameRequest
|
||||
transfer admin.TransferCollectibleUsernameRequest
|
||||
revoke admin.RevokeCollectibleUsernameRequest
|
||||
del admin.DeleteCollectibleUsernameRequest
|
||||
filter domain.CollectibleUsernameFilter
|
||||
assetID int64
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) MintCollectibleUsername(_ context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error) {
|
||||
s.mint = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) TransferCollectibleUsername(_ context.Context, req admin.TransferCollectibleUsernameRequest) (admin.CommandResult, error) {
|
||||
s.transfer = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) RevokeCollectibleUsername(_ context.Context, req admin.RevokeCollectibleUsernameRequest) (admin.CommandResult, error) {
|
||||
s.revoke = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) DeleteCollectibleUsername(_ context.Context, req admin.DeleteCollectibleUsernameRequest) (admin.CommandResult, error) {
|
||||
s.del = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) CollectibleUsernames(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
|
||||
s.filter = filter
|
||||
return []domain.CollectibleUsername{maxInt64Collectible()}, nil
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) CollectibleUsernameByID(_ context.Context, id int64) (domain.CollectibleUsername, error) {
|
||||
s.assetID = id
|
||||
asset := maxInt64Collectible()
|
||||
asset.ID = id
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) CollectibleUsernameTransfers(_ context.Context, collectibleID int64, _ int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
return []domain.CollectibleUsernameTransfer{{
|
||||
ID: 9223372036854775807,
|
||||
CollectibleID: collectibleID,
|
||||
Kind: domain.CollectibleUsernameKindMint,
|
||||
To: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
|
||||
Currency: domain.CollectibleCurrencyTON,
|
||||
Amount: 9223372036854775807,
|
||||
Actor: "ops",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func maxInt64Collectible() domain.CollectibleUsername {
|
||||
return domain.CollectibleUsername{
|
||||
ID: 9223372036854775807,
|
||||
Username: "durov",
|
||||
Status: domain.CollectibleUsernameStatusOwned,
|
||||
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
|
||||
Currency: domain.CollectibleCurrencyTON,
|
||||
Amount: 9223372036854775807,
|
||||
CryptoCurrency: domain.CollectibleCryptoCurrencyTON,
|
||||
CryptoAmount: 9223372036854775807,
|
||||
Version: 9223372036854775807,
|
||||
}
|
||||
}
|
||||
|
||||
type captureAccountRatingService struct {
|
||||
fakeService
|
||||
recompute admin.RecomputeAccountRatingRequest
|
||||
adjust admin.AdjustAccountRatingRequest
|
||||
filter domain.AccountRatingFilter
|
||||
}
|
||||
|
||||
func (s *captureAccountRatingService) RecomputeAccountRating(_ context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error) {
|
||||
s.recompute = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureAccountRatingService) AdjustAccountRating(_ context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error) {
|
||||
s.adjust = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureAccountRatingService) AccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
s.filter = filter
|
||||
return []domain.AccountRating{maxInt64Rating()}, nil
|
||||
}
|
||||
|
||||
func (s *captureAccountRatingService) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
rating := maxInt64Rating()
|
||||
rating.UserID = userID
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func (s *captureAccountRatingService) AccountRatingEvents(_ context.Context, userID int64, _ int) ([]domain.AccountRatingEvent, error) {
|
||||
return []domain.AccountRatingEvent{{
|
||||
ID: 9223372036854775807, UserID: userID,
|
||||
Kind: domain.AccountRatingEventManual, Amount: -9223372036854775807,
|
||||
Actor: "ops", Reason: "abuse",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func maxInt64Rating() domain.AccountRating {
|
||||
return domain.AccountRating{
|
||||
UserID: 1001,
|
||||
Level: 7,
|
||||
Stars: 9223372036854775807,
|
||||
CurrentLevelStars: 4900,
|
||||
NextLevelStars: 6400,
|
||||
HasNextLevel: true,
|
||||
StarsComponent: 9223372036854775807,
|
||||
ManualComponent: -1500,
|
||||
Version: 9223372036854775807,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPICollectibleUsernameCommandsRequireToken(t *testing.T) {
|
||||
srv := &Server{token: "secret", svc: fakeService{}}
|
||||
for _, path := range []string{
|
||||
"/v1/collectible-usernames/mint",
|
||||
"/v1/collectible-usernames/transfer",
|
||||
"/v1/collectible-usernames/revoke",
|
||||
"/v1/account-ratings/recompute",
|
||||
"/v1/account-ratings/adjust",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s status=%d, want 401", path, rec.Code)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{
|
||||
"/v1/collectible-usernames",
|
||||
"/v1/collectible-usernames/7",
|
||||
"/v1/account-ratings",
|
||||
"/v1/account-ratings/7",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s status=%d, want 401", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIMintCollectibleUsernameForwardsExactInt64AndDryRun(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
svc := &captureCollectibleUsernameService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/collectible-usernames/mint", strings.NewReader(`{
|
||||
"command_id":"mint-1","actor":"ops","reason":"fragment import","dry_run":true,
|
||||
"username":"durov","owner_user_id":"1001","currency":"TON","amount":"9223372036854775807",
|
||||
"crypto_currency":"TON","crypto_amount":"250000000000",
|
||||
"url":"https://fragment.example/durov","purchase_date":1700000000
|
||||
}`))
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"command_id":"mint-1"`) {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"dry_run":true`) {
|
||||
t.Fatalf("dry-run was not propagated: %s", rec.Body.String())
|
||||
}
|
||||
if svc.mint.Username != "durov" || svc.mint.OwnerUserID != 1001 || svc.mint.Amount != maxInt64 ||
|
||||
svc.mint.CryptoAmount != 250000000000 || svc.mint.PurchaseDate != 1700000000 || !svc.mint.DryRun {
|
||||
t.Fatalf("decoded mint request = %+v", svc.mint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPITransferAndRevokeCollectibleUsername(t *testing.T) {
|
||||
svc := &captureCollectibleUsernameService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
transfer := httptest.NewRequest(http.MethodPost, "/v1/collectible-usernames/transfer", strings.NewReader(
|
||||
`{"command_id":"t-1","actor":"ops","reason":"sold","username":"durov","to_channel_id":"2002"}`))
|
||||
transfer.Header.Set("Authorization", "Bearer secret")
|
||||
transferRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(transferRec, transfer)
|
||||
if transferRec.Code != http.StatusOK || svc.transfer.ToChannelID != 2002 || svc.transfer.Username != "durov" {
|
||||
t.Fatalf("transfer status=%d request=%+v", transferRec.Code, svc.transfer)
|
||||
}
|
||||
|
||||
revoke := httptest.NewRequest(http.MethodPost, "/v1/collectible-usernames/revoke", strings.NewReader(
|
||||
`{"command_id":"r-1","actor":"ops","reason":"fraud","username":"durov","burn":true}`))
|
||||
revoke.Header.Set("Authorization", "Bearer secret")
|
||||
revokeRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(revokeRec, revoke)
|
||||
if revokeRec.Code != http.StatusOK || !svc.revoke.Burn || svc.revoke.CommandID != "r-1" {
|
||||
t.Fatalf("revoke status=%d request=%+v", revokeRec.Code, svc.revoke)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIAccountRatingCommands(t *testing.T) {
|
||||
svc := &captureAccountRatingService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
recompute := httptest.NewRequest(http.MethodPost, "/v1/account-ratings/recompute", strings.NewReader(
|
||||
`{"command_id":"rc-1","actor":"ops","reason":"support ticket","dry_run":true,"user_id":"1001"}`))
|
||||
recompute.Header.Set("Authorization", "Bearer secret")
|
||||
recomputeRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(recomputeRec, recompute)
|
||||
if recomputeRec.Code != http.StatusOK || svc.recompute.UserID != 1001 || !svc.recompute.DryRun {
|
||||
t.Fatalf("recompute status=%d request=%+v body=%s", recomputeRec.Code, svc.recompute, recomputeRec.Body.String())
|
||||
}
|
||||
|
||||
adjust := httptest.NewRequest(http.MethodPost, "/v1/account-ratings/adjust", strings.NewReader(
|
||||
`{"command_id":"adj-1","actor":"ops","reason":"manual penalty","user_id":"1001","amount":"-2500"}`))
|
||||
adjust.Header.Set("Authorization", "Bearer secret")
|
||||
adjustRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(adjustRec, adjust)
|
||||
if adjustRec.Code != http.StatusOK || svc.adjust.Amount != -2500 || svc.adjust.DryRun {
|
||||
t.Fatalf("adjust status=%d request=%+v body=%s", adjustRec.Code, svc.adjust, adjustRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPICollectibleUsernameReadsUseDecimalStrings(t *testing.T) {
|
||||
svc := &captureCollectibleUsernameService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
list := httptest.NewRequest(http.MethodGet,
|
||||
"/v1/collectible-usernames?status=owned&owner_user_id=1001&q=%40Durov&limit=25&before_id=42", nil)
|
||||
list.Header.Set("Authorization", "Bearer secret")
|
||||
listRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(listRec, list)
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String())
|
||||
}
|
||||
if svc.filter.Status != domain.CollectibleUsernameStatusOwned ||
|
||||
svc.filter.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: 1001}) ||
|
||||
svc.filter.Query != "@Durov" || svc.filter.Limit != 25 || svc.filter.BeforeID != 42 {
|
||||
t.Fatalf("collectible filter = %+v", svc.filter)
|
||||
}
|
||||
if !strings.Contains(listRec.Body.String(), `"id":"9223372036854775807"`) ||
|
||||
!strings.Contains(listRec.Body.String(), `"amount":"9223372036854775807"`) {
|
||||
t.Fatalf("list body lost int64 precision: %s", listRec.Body.String())
|
||||
}
|
||||
|
||||
detail := httptest.NewRequest(http.MethodGet, "/v1/collectible-usernames/77", nil)
|
||||
detail.Header.Set("Authorization", "Bearer secret")
|
||||
detailRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(detailRec, detail)
|
||||
if detailRec.Code != http.StatusOK || svc.assetID != 77 {
|
||||
t.Fatalf("detail status=%d assetID=%d body=%s", detailRec.Code, svc.assetID, detailRec.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Asset map[string]any `json:"asset"`
|
||||
Transfers []map[string]any `json:"transfers"`
|
||||
}
|
||||
if err := json.Unmarshal(detailRec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode detail: %v", err)
|
||||
}
|
||||
if payload.Asset["id"] != "77" || len(payload.Transfers) != 1 ||
|
||||
payload.Transfers[0]["amount"] != "9223372036854775807" ||
|
||||
payload.Transfers[0]["collectible_id"] != "77" {
|
||||
t.Fatalf("detail payload = %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIAccountRatingReadsUseDecimalStrings(t *testing.T) {
|
||||
svc := &captureAccountRatingService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
list := httptest.NewRequest(http.MethodGet, "/v1/account-ratings?min_level=3&user_id=1001&limit=10&before_id=99", nil)
|
||||
list.Header.Set("Authorization", "Bearer secret")
|
||||
listRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(listRec, list)
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String())
|
||||
}
|
||||
if svc.filter.MinLevel != 3 || svc.filter.UserID != 1001 || svc.filter.Limit != 10 || svc.filter.BeforeID != 99 {
|
||||
t.Fatalf("rating filter = %+v", svc.filter)
|
||||
}
|
||||
if !strings.Contains(listRec.Body.String(), `"stars":"9223372036854775807"`) {
|
||||
t.Fatalf("rating list lost int64 precision: %s", listRec.Body.String())
|
||||
}
|
||||
|
||||
detail := httptest.NewRequest(http.MethodGet, "/v1/account-ratings/1001", nil)
|
||||
detail.Header.Set("Authorization", "Bearer secret")
|
||||
detailRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(detailRec, detail)
|
||||
if detailRec.Code != http.StatusOK {
|
||||
t.Fatalf("detail status=%d body=%s", detailRec.Code, detailRec.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Rating map[string]any `json:"rating"`
|
||||
Events []map[string]any `json:"events"`
|
||||
}
|
||||
if err := json.Unmarshal(detailRec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode detail: %v", err)
|
||||
}
|
||||
if payload.Rating["user_id"] != "1001" || payload.Rating["stars"] != "9223372036854775807" ||
|
||||
len(payload.Events) != 1 || payload.Events[0]["amount"] != "-9223372036854775807" {
|
||||
t.Fatalf("rating detail payload = %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIMissingCollectibleAndRatingReportCodedErrors(t *testing.T) {
|
||||
srv := &Server{token: "secret", svc: fakeService{}}
|
||||
asset := httptest.NewRequest(http.MethodGet, "/v1/collectible-usernames/5", nil)
|
||||
asset.Header.Set("Authorization", "Bearer secret")
|
||||
assetRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(assetRec, asset)
|
||||
if assetRec.Code != http.StatusNotFound ||
|
||||
!strings.Contains(assetRec.Body.String(), `"code":"`+admin.CodeCollectibleNotFound+`"`) {
|
||||
t.Fatalf("missing asset status=%d body=%s", assetRec.Code, assetRec.Body.String())
|
||||
}
|
||||
|
||||
rating := httptest.NewRequest(http.MethodGet, "/v1/account-ratings/5", nil)
|
||||
rating.Header.Set("Authorization", "Bearer secret")
|
||||
ratingRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(ratingRec, rating)
|
||||
if ratingRec.Code != http.StatusNotFound ||
|
||||
!strings.Contains(ratingRec.Body.String(), `"code":"`+admin.CodeRatingNotFound+`"`) {
|
||||
t.Fatalf("missing rating status=%d body=%s", ratingRec.Code, ratingRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (fakeService) MintCollectibleUsername(_ context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) TransferCollectibleUsername(_ context.Context, req admin.TransferCollectibleUsernameRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) RevokeCollectibleUsername(_ context.Context, req admin.RevokeCollectibleUsernameRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) DeleteCollectibleUsername(_ context.Context, req admin.DeleteCollectibleUsernameRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) CollectibleUsernames(context.Context, domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) CollectibleUsernameByID(context.Context, int64) (domain.CollectibleUsername, error) {
|
||||
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
|
||||
func (fakeService) CollectibleUsernameTransfers(context.Context, int64, int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) RecomputeAccountRating(_ context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) AdjustAccountRating(_ context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) AccountRating(context.Context, int64) (domain.AccountRating, error) {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
|
||||
func (fakeService) AccountRatings(context.Context, domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) AccountRatingEvents(context.Context, int64, int) ([]domain.AccountRatingEvent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
|
|||
361
internal/adminapi/verification.go
Normal file
361
internal/adminapi/verification.go
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
package adminapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Official platform verification review over the admin API.
|
||||
//
|
||||
// These are the mirror routes of the panel's own endpoints: the panel reads the
|
||||
// queue straight from PostgreSQL for speed, while an integration holding a scoped
|
||||
// token reads it here. Decisions only ever travel this way, so the command
|
||||
// journal and the status machine are enforced in one place.
|
||||
|
||||
// handleVerificationApplications is the review queue.
|
||||
func (s *Server) handleVerificationApplications(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
filter := domain.VerificationApplicationFilter{
|
||||
TargetType: domain.VerificationTargetType(strings.TrimSpace(query.Get("target_type"))),
|
||||
Reviewer: strings.TrimSpace(query.Get("reviewer")),
|
||||
Query: query.Get("q"),
|
||||
}
|
||||
if filter.TargetType != "" && !filter.TargetType.Valid() {
|
||||
writeCodedError(w, http.StatusBadRequest, admin.CodeVerificationTargetInvalid, "invalid target_type")
|
||||
return
|
||||
}
|
||||
// status accepts a comma-separated list, so the queue view ("submitted,
|
||||
// in_review") is one request rather than two.
|
||||
for _, raw := range strings.Split(query.Get("status"), ",") {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
status := domain.VerificationStatus(raw)
|
||||
if !status.Valid() {
|
||||
writeCodedError(w, http.StatusBadRequest, admin.CodeVerificationStatusInvalid, "invalid status "+raw)
|
||||
return
|
||||
}
|
||||
filter.Statuses = append(filter.Statuses, status)
|
||||
}
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.Limit = limit
|
||||
beforeID, ok := optionalQueryInt64(w, query, "before_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.BeforeID = beforeID
|
||||
items, err := s.svc.VerificationApplications(r.Context(), filter)
|
||||
if err != nil {
|
||||
writeVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
applications := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
applications = append(applications, verificationApplicationResponse(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"applications": applications})
|
||||
}
|
||||
|
||||
// handleVerificationApplication is one application with its history and the
|
||||
// target as it looks right now.
|
||||
func (s *Server) handleVerificationApplication(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
app, err := s.svc.VerificationApplication(r.Context(), id)
|
||||
if err != nil {
|
||||
writeVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
limit, ok := optionalQueryInt(w, r.URL.Query(), "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
events, err := s.svc.VerificationApplicationEvents(r.Context(), app.ID, limit)
|
||||
if err != nil {
|
||||
writeVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
history := make([]map[string]any, 0, len(events))
|
||||
for _, event := range events {
|
||||
history = append(history, verificationEventResponse(event))
|
||||
}
|
||||
body := map[string]any{
|
||||
"application": verificationApplicationResponse(app),
|
||||
"events": history,
|
||||
}
|
||||
// The snapshot is advisory: a target that vanished must not turn the audit
|
||||
// record into a 500, so a snapshot failure is reported next to the record
|
||||
// instead of replacing it.
|
||||
if target, err := s.svc.VerificationTargetSnapshot(r.Context(), app.TargetType, app.TargetID); err == nil {
|
||||
body["target"] = verificationTargetResponse(target)
|
||||
} else {
|
||||
body["target_error"] = err.Error()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// handleVerificationCounts is the queue summary.
|
||||
func (s *Server) handleVerificationCounts(w http.ResponseWriter, r *http.Request) {
|
||||
counts, err := s.svc.VerificationCounts(r.Context())
|
||||
if err != nil {
|
||||
writeVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"counts": verificationCountsResponse(counts)})
|
||||
}
|
||||
|
||||
func (s *Server) handleClaimVerification(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req admin.ClaimVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
// The path is the authority on which application is decided: a body naming a
|
||||
// different one would make the URL lie to the audit trail.
|
||||
req.ApplicationID = id
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.ClaimVerification(r.Context(), req)
|
||||
writeVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleApproveVerification(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req admin.ApproveVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.ApplicationID = id
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.ApproveVerification(r.Context(), req)
|
||||
writeVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRejectVerification(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req admin.RejectVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.ApplicationID = id
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.RejectVerification(r.Context(), req)
|
||||
writeVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeVerification(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.RevokeVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.RevokeVerification(r.Context(), req)
|
||||
writeVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
// applyVerificationPrincipal fills in the audit actor for a scoped token that did
|
||||
// not state one.
|
||||
//
|
||||
// A scoped token's configured name *is* its audit identity, so an integration
|
||||
// does not have to invent an actor string. The master token has no name, so a
|
||||
// caller using it keeps having to state who is acting -- which is what the panel
|
||||
// does with the signed-in operator.
|
||||
func (s *Server) applyVerificationPrincipal(r *http.Request, meta *admin.CommandMeta) {
|
||||
if strings.TrimSpace(meta.Actor) != "" {
|
||||
return
|
||||
}
|
||||
if name := principalName(r.Context()); name != "" {
|
||||
meta.Actor = name
|
||||
}
|
||||
}
|
||||
|
||||
// verificationApplicationResponse renders one application. Every int64 crosses
|
||||
// the JSON boundary as a decimal string: application ids, peer ids and the
|
||||
// optimistic-locking version exceed the range a JSON number holds exactly, and a
|
||||
// rounded id would decide the wrong application.
|
||||
func verificationApplicationResponse(app domain.VerificationApplication) map[string]any {
|
||||
out := map[string]any{
|
||||
"id": strconv.FormatInt(app.ID, 10),
|
||||
"applicant_user_id": strconv.FormatInt(app.ApplicantUserID, 10),
|
||||
"target_type": string(app.TargetType),
|
||||
"target_id": strconv.FormatInt(app.TargetID, 10),
|
||||
"target_title": app.TargetTitle,
|
||||
"target_username": app.TargetUsername,
|
||||
"category": app.Category,
|
||||
"description": app.Description,
|
||||
"official_website": app.OfficialWebsite,
|
||||
"social_links": stringList(app.SocialLinks),
|
||||
"press_links": stringList(app.PressLinks),
|
||||
"additional_note": app.AdditionalNote,
|
||||
"status": string(app.Status),
|
||||
"reviewer_admin_id": app.ReviewerAdminID,
|
||||
"decision_reason": app.DecisionReason,
|
||||
// internal_note is operator-only. It is exposed here because every caller
|
||||
// of this route already holds verification.review, and it is the reviewer's
|
||||
// own handover note; it is never part of the applicant-facing projection.
|
||||
"internal_note": app.InternalNote,
|
||||
"correlation_id": app.CorrelationID,
|
||||
"version": strconv.FormatInt(app.Version, 10),
|
||||
}
|
||||
if !app.CreatedAt.IsZero() {
|
||||
out["created_at"] = app.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !app.UpdatedAt.IsZero() {
|
||||
out["updated_at"] = app.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !app.SubmittedAt.IsZero() {
|
||||
out["submitted_at"] = app.SubmittedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !app.ReviewedAt.IsZero() {
|
||||
out["reviewed_at"] = app.ReviewedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func verificationEventResponse(event domain.VerificationApplicationEvent) map[string]any {
|
||||
out := map[string]any{
|
||||
"id": strconv.FormatInt(event.ID, 10),
|
||||
"application_id": strconv.FormatInt(event.ApplicationID, 10),
|
||||
"kind": string(event.Kind),
|
||||
"from_status": string(event.FromStatus),
|
||||
"to_status": string(event.ToStatus),
|
||||
"actor": event.Actor,
|
||||
"reason": event.Reason,
|
||||
"note": event.Note,
|
||||
"correlation_id": event.CorrelationID,
|
||||
}
|
||||
if !event.CreatedAt.IsZero() {
|
||||
out["created_at"] = event.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func verificationTargetResponse(target domain.VerificationTarget) map[string]any {
|
||||
return map[string]any{
|
||||
"type": string(target.Type),
|
||||
"id": strconv.FormatInt(target.ID, 10),
|
||||
"title": target.Title,
|
||||
"username": target.Username,
|
||||
"verified": target.Verified,
|
||||
"eligible": target.Eligible,
|
||||
"reason": target.Reason,
|
||||
}
|
||||
}
|
||||
|
||||
// verificationCountsResponse renders the queue summary with every modelled status
|
||||
// present, so the panel never has to distinguish "zero" from "absent". The values
|
||||
// are decimal strings for the same exactness reason as the ids.
|
||||
func verificationCountsResponse(counts domain.VerificationStatusCounts) map[string]string {
|
||||
out := make(map[string]string, len(verificationStatusOrder))
|
||||
for _, status := range verificationStatusOrder {
|
||||
out[string(status)] = strconv.FormatInt(counts[status], 10)
|
||||
}
|
||||
for status, count := range counts {
|
||||
if _, ok := out[string(status)]; !ok {
|
||||
out[string(status)] = strconv.FormatInt(count, 10)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// verificationStatusOrder is the closed status set, in lifecycle order.
|
||||
var verificationStatusOrder = []domain.VerificationStatus{
|
||||
domain.VerificationStatusDraft,
|
||||
domain.VerificationStatusSubmitted,
|
||||
domain.VerificationStatusInReview,
|
||||
domain.VerificationStatusApproved,
|
||||
domain.VerificationStatusRejected,
|
||||
domain.VerificationStatusCancelled,
|
||||
}
|
||||
|
||||
// stringList normalises a nil slice to an empty JSON array, so the panel can
|
||||
// iterate without a null check.
|
||||
func stringList(items []string) []string {
|
||||
if items == nil {
|
||||
return []string{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// verificationErrorStatus maps a verification failure onto its HTTP status.
|
||||
//
|
||||
// The version conflict is the interesting one: it is 409, not 400, because
|
||||
// nothing about the request was wrong -- another reviewer simply decided first,
|
||||
// and the panel has to answer that by reloading rather than by correcting input.
|
||||
func verificationErrorStatus(code string) int {
|
||||
switch code {
|
||||
case admin.CodeVerificationNotFound:
|
||||
return http.StatusNotFound
|
||||
case admin.CodeVerificationConflict,
|
||||
admin.CodeVerificationTargetOccupied,
|
||||
admin.CodeVerificationTargetVerified:
|
||||
return http.StatusConflict
|
||||
case admin.CodeVerificationStatusInvalid,
|
||||
admin.CodeVerificationReasonRequired,
|
||||
admin.CodeVerificationTargetInvalid,
|
||||
admin.CodeVerificationTargetNotPublic,
|
||||
admin.CodeVerificationTargetRestricted,
|
||||
admin.CodeVerificationTargetSystem,
|
||||
admin.CodeVerificationNotOwner,
|
||||
admin.CodeVerificationUserTargetsDisabled,
|
||||
admin.CodeVerificationInvalid:
|
||||
return http.StatusBadRequest
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
func writeVerificationError(w http.ResponseWriter, err error) {
|
||||
code := admin.VerificationErrorCode(err)
|
||||
writeCodedError(w, verificationErrorStatus(code), code, err.Error())
|
||||
}
|
||||
|
||||
// writeVerificationCommandResult answers a decision.
|
||||
//
|
||||
// The body stays a CommandResult so the panel parses one shape for every
|
||||
// operator action, but the status is derived from the failure: a lost
|
||||
// optimistic-locking race must reach the browser as 409, because that is the one
|
||||
// failure the panel resolves by reloading the application instead of by asking
|
||||
// the operator to fix the form.
|
||||
func writeVerificationCommandResult(w http.ResponseWriter, result admin.CommandResult, err error) {
|
||||
if err == nil {
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
code := admin.VerificationErrorCode(err)
|
||||
status := verificationErrorStatus(code)
|
||||
if status == http.StatusInternalServerError {
|
||||
// An unmapped command failure is a bad request, as everywhere else in this
|
||||
// API, rather than a server fault.
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
if result.CommandID == "" {
|
||||
result = admin.CommandResult{Status: "failed", Message: "command failed", Error: err.Error()}
|
||||
}
|
||||
if result.Error == "" {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
if code == admin.CodeVerificationConflict {
|
||||
result.Message = "another reviewer changed this application first; reload it and decide again"
|
||||
}
|
||||
writeJSON(w, status, result)
|
||||
}
|
||||
546
internal/adminapi/verification_test.go
Normal file
546
internal/adminapi/verification_test.go
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// fakeService gains the verification surface here so the shared fake keeps
|
||||
// satisfying Service without touching the existing test file.
|
||||
|
||||
func (fakeService) ClaimVerification(_ context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) ApproveVerification(_ context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) RejectVerification(_ context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) RevokeVerification(_ context.Context, req admin.RevokeVerificationRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) VerificationApplications(context.Context, domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) VerificationApplication(context.Context, int64) (domain.VerificationApplication, error) {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
||||
}
|
||||
|
||||
func (fakeService) VerificationApplicationEvents(context.Context, int64, int) ([]domain.VerificationApplicationEvent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) VerificationCounts(context.Context) (domain.VerificationStatusCounts, error) {
|
||||
return domain.VerificationStatusCounts{}, nil
|
||||
}
|
||||
|
||||
func (fakeService) VerificationTargetSnapshot(context.Context, domain.VerificationTargetType, int64) (domain.VerificationTarget, error) {
|
||||
return domain.VerificationTarget{}, nil
|
||||
}
|
||||
|
||||
type captureVerificationService struct {
|
||||
fakeService
|
||||
filter domain.VerificationApplicationFilter
|
||||
claim admin.ClaimVerificationRequest
|
||||
approve admin.ApproveVerificationRequest
|
||||
reject admin.RejectVerificationRequest
|
||||
revoke admin.RevokeVerificationRequest
|
||||
app domain.VerificationApplication
|
||||
events []domain.VerificationApplicationEvent
|
||||
counts domain.VerificationStatusCounts
|
||||
target domain.VerificationTarget
|
||||
decideOn error
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) VerificationApplications(_ context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
|
||||
s.filter = filter
|
||||
return []domain.VerificationApplication{s.app}, nil
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) VerificationApplication(_ context.Context, applicationID int64) (domain.VerificationApplication, error) {
|
||||
if s.app.ID != applicationID {
|
||||
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
||||
}
|
||||
return s.app, nil
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) VerificationApplicationEvents(context.Context, int64, int) ([]domain.VerificationApplicationEvent, error) {
|
||||
return s.events, nil
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) VerificationCounts(context.Context) (domain.VerificationStatusCounts, error) {
|
||||
return s.counts, nil
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) VerificationTargetSnapshot(context.Context, domain.VerificationTargetType, int64) (domain.VerificationTarget, error) {
|
||||
return s.target, nil
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) ClaimVerification(_ context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error) {
|
||||
s.claim = req
|
||||
if s.decideOn != nil {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "failed", Error: s.decideOn.Error()}, s.decideOn
|
||||
}
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) ApproveVerification(_ context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error) {
|
||||
s.approve = req
|
||||
if s.decideOn != nil {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "failed", Error: s.decideOn.Error()}, s.decideOn
|
||||
}
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) RejectVerification(_ context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error) {
|
||||
s.reject = req
|
||||
if s.decideOn != nil {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "failed", Error: s.decideOn.Error()}, s.decideOn
|
||||
}
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureVerificationService) RevokeVerification(_ context.Context, req admin.RevokeVerificationRequest) (admin.CommandResult, error) {
|
||||
s.revoke = req
|
||||
if s.decideOn != nil {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "failed", Error: s.decideOn.Error()}, s.decideOn
|
||||
}
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
// reviewOnlyServer is the deployment shape the permission model exists for: one
|
||||
// unrestricted master token plus two bounded tokens, one able to review and one
|
||||
// able to review and revoke.
|
||||
func reviewOnlyServer(svc Service) *Server {
|
||||
return &Server{
|
||||
token: "master",
|
||||
scoped: []ScopedToken{
|
||||
{Name: "queue-bot", Token: "scoped-review", Permissions: []string{PermissionVerificationReview}},
|
||||
{Name: "trust-and-safety", Token: "scoped-revoke", Permissions: []string{
|
||||
PermissionVerificationReview, PermissionVerificationRevoke,
|
||||
}},
|
||||
{Name: "gift-importer", Token: "scoped-other", Permissions: []string{"gifts.import"}},
|
||||
},
|
||||
svc: svc,
|
||||
}
|
||||
}
|
||||
|
||||
func verificationRequest(method, path, token, body string) *http.Request {
|
||||
var req *http.Request
|
||||
if body == "" {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestVerificationRoutesRejectMissingAndUnknownTokens(t *testing.T) {
|
||||
srv := reviewOnlyServer(fakeService{})
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{http.MethodGet, "/v1/verification/applications", ""},
|
||||
{http.MethodGet, "/v1/verification/applications/7", ""},
|
||||
{http.MethodGet, "/v1/verification/counts", ""},
|
||||
{http.MethodPost, "/v1/verification/applications/7/claim", `{}`},
|
||||
{http.MethodPost, "/v1/verification/applications/7/approve", `{}`},
|
||||
{http.MethodPost, "/v1/verification/applications/7/reject", `{}`},
|
||||
{http.MethodPost, "/v1/verification/revoke", `{}`},
|
||||
}
|
||||
for _, item := range cases {
|
||||
for _, token := range []string{"", "not-a-configured-token"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s %s token=%q status=%d, want 401", item.method, item.path, token, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationRoutesRefuseScopedTokenWithoutThePermission(t *testing.T) {
|
||||
srv := reviewOnlyServer(fakeService{})
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{http.MethodGet, "/v1/verification/applications", ""},
|
||||
{http.MethodGet, "/v1/verification/counts", ""},
|
||||
{http.MethodPost, "/v1/verification/applications/7/claim", `{}`},
|
||||
}
|
||||
for _, item := range cases {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "scoped-other", item.body))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s %s status=%d body=%s, want 403", item.method, item.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode 403 body: %v", err)
|
||||
}
|
||||
if body["code"] != CodeForbidden || body["permission"] != PermissionVerificationReview {
|
||||
t.Fatalf("403 body=%+v, want the missing permission named", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationRevokeNeedsTheRevokePermissionOnTopOfReview(t *testing.T) {
|
||||
svc := &captureVerificationService{}
|
||||
srv := reviewOnlyServer(svc)
|
||||
|
||||
// A review-only token reaches the queue but not the revocation.
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/counts", "scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("review token on counts status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/revoke", "scoped-review",
|
||||
`{"command_id":"c1","actor":"ops","reason":"impersonation","target_type":"channel","target_id":5005}`))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("review token on revoke status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode 403 body: %v", err)
|
||||
}
|
||||
if body["permission"] != PermissionVerificationRevoke {
|
||||
t.Fatalf("403 body=%+v, want verification.revoke named", body)
|
||||
}
|
||||
|
||||
// The token that carries both rights gets through.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/revoke", "scoped-revoke",
|
||||
`{"command_id":"c2","actor":"ops","reason":"impersonation","target_type":"channel","target_id":5005}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("revoke token status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if svc.revoke.TargetType != domain.VerificationTargetChannel || svc.revoke.TargetID != 5005 ||
|
||||
svc.revoke.Reason != "impersonation" {
|
||||
t.Fatalf("forwarded revocation=%+v", svc.revoke)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasterTokenKeepsEveryPermissionIncludingTheLegacySurface(t *testing.T) {
|
||||
svc := &captureVerificationService{app: domain.VerificationApplication{ID: 7, Version: 2}}
|
||||
srv := reviewOnlyServer(svc)
|
||||
|
||||
// The new permissioned routes.
|
||||
for _, path := range []string{"/v1/verification/applications", "/v1/verification/counts"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, path, "master", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("master token on %s status=%d body=%s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/revoke", "master",
|
||||
`{"command_id":"c1","actor":"ops","reason":"impersonation","target_type":"bot","target_id":2002}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("master token on revoke status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// And every route that predates permissions.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/accounts/set-verified", "master",
|
||||
`{"command_id":"c2","actor":"ops","reason":"official","dry_run":true,"user_id":1001,"verified":true}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("master token on the legacy surface status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A bounded token must not inherit the routes that predate the permission model:
|
||||
// that would turn "give the queue bot the review right" into "give it everything".
|
||||
func TestScopedTokenCannotUseTheLegacySurfaceAsASideDoor(t *testing.T) {
|
||||
srv := reviewOnlyServer(fakeService{})
|
||||
for _, path := range []string{"/v1/accounts/set-verified", "/v1/accounts/set-frozen", "/v1/bots/delete"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, path, "scoped-review",
|
||||
`{"command_id":"c1","actor":"ops","reason":"x","user_id":1001,"verified":true}`))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("scoped token on %s status=%d body=%s, want 403", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// A scoped token that spells out the wildcard is the operator's explicit
|
||||
// choice and does reach it.
|
||||
wide := &Server{
|
||||
token: "master",
|
||||
scoped: []ScopedToken{{Name: "everything", Token: "scoped-all", Permissions: []string{PermissionAll}}},
|
||||
svc: fakeService{},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
wide.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/accounts/set-verified", "scoped-all",
|
||||
`{"command_id":"c1","actor":"ops","reason":"x","dry_run":true,"user_id":1001,"verified":true}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("wildcard scoped token status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationQueueFilterAndInt64Rendering(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
svc := &captureVerificationService{app: domain.VerificationApplication{
|
||||
ID: maxInt64,
|
||||
ApplicantUserID: maxInt64,
|
||||
TargetType: domain.VerificationTargetChannel,
|
||||
TargetID: maxInt64,
|
||||
TargetTitle: "Example News",
|
||||
TargetUsername: "examplenews",
|
||||
Category: "media",
|
||||
Status: domain.VerificationStatusSubmitted,
|
||||
SocialLinks: []string{"https://example.test/social"},
|
||||
Version: maxInt64,
|
||||
CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
|
||||
}}
|
||||
srv := reviewOnlyServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(
|
||||
http.MethodGet,
|
||||
"/v1/verification/applications?status=submitted,in_review&target_type=channel&reviewer=alice&q=examplenews&limit=25&before_id=99",
|
||||
"scoped-review", "",
|
||||
))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(svc.filter.Statuses) != 2 ||
|
||||
svc.filter.Statuses[0] != domain.VerificationStatusSubmitted ||
|
||||
svc.filter.Statuses[1] != domain.VerificationStatusInReview ||
|
||||
svc.filter.TargetType != domain.VerificationTargetChannel ||
|
||||
svc.filter.Reviewer != "alice" || svc.filter.Query != "examplenews" ||
|
||||
svc.filter.Limit != 25 || svc.filter.BeforeID != 99 {
|
||||
t.Fatalf("filter=%+v", svc.filter)
|
||||
}
|
||||
var body struct {
|
||||
Applications []map[string]any `json:"applications"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode queue: %v", err)
|
||||
}
|
||||
if len(body.Applications) != 1 {
|
||||
t.Fatalf("applications=%+v", body.Applications)
|
||||
}
|
||||
for _, field := range []string{"id", "applicant_user_id", "target_id", "version"} {
|
||||
if body.Applications[0][field] != "9223372036854775807" {
|
||||
t.Fatalf("%s = %#v, want an exact decimal string", field, body.Applications[0][field])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationQueueRejectsUnmodelledFilters(t *testing.T) {
|
||||
srv := reviewOnlyServer(fakeService{})
|
||||
for _, query := range []string{"?status=pending", "?target_type=group"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/applications"+query, "scoped-review", ""))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s status=%d body=%s, want 400", query, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationApplicationDetailAndCounts(t *testing.T) {
|
||||
svc := &captureVerificationService{
|
||||
app: domain.VerificationApplication{ID: 7, TargetType: domain.VerificationTargetBot, TargetID: 2002, Version: 4},
|
||||
events: []domain.VerificationApplicationEvent{{
|
||||
ID: 11, ApplicationID: 7, Kind: domain.VerificationEventSubmitted,
|
||||
ToStatus: domain.VerificationStatusSubmitted, CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
|
||||
}},
|
||||
target: domain.VerificationTarget{Type: domain.VerificationTargetBot, ID: 2002, Verified: true, Eligible: false, Reason: "already verified"},
|
||||
counts: domain.VerificationStatusCounts{domain.VerificationStatusSubmitted: 3},
|
||||
}
|
||||
srv := reviewOnlyServer(svc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/applications/7", "scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("detail status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var detail struct {
|
||||
Application map[string]any `json:"application"`
|
||||
Events []map[string]any `json:"events"`
|
||||
Target map[string]any `json:"target"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &detail); err != nil {
|
||||
t.Fatalf("decode detail: %v", err)
|
||||
}
|
||||
if detail.Application["id"] != "7" || len(detail.Events) != 1 || detail.Events[0]["id"] != "11" {
|
||||
t.Fatalf("detail=%+v", detail)
|
||||
}
|
||||
if detail.Target["verified"] != true || detail.Target["eligible"] != false {
|
||||
t.Fatalf("target=%+v, want the live snapshot alongside the record", detail.Target)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/applications/8", "scoped-review", ""))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing application status=%d body=%s, want 404", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/counts", "scoped-review", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("counts status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var counts struct {
|
||||
Counts map[string]string `json:"counts"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &counts); err != nil {
|
||||
t.Fatalf("decode counts: %v", err)
|
||||
}
|
||||
// Every modelled status is present so the panel never tells "zero" from
|
||||
// "absent", and the values are decimal strings.
|
||||
if counts.Counts["submitted"] != "3" || counts.Counts["draft"] != "0" ||
|
||||
counts.Counts["cancelled"] != "0" || len(counts.Counts) != 6 {
|
||||
t.Fatalf("counts=%+v", counts.Counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationDecisionTakesTheApplicationIDFromThePath(t *testing.T) {
|
||||
svc := &captureVerificationService{app: domain.VerificationApplication{ID: 7, Version: 4}}
|
||||
srv := reviewOnlyServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
// The body names a different application on purpose: the path has to win, or
|
||||
// the URL would lie to the audit trail.
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/approve", "scoped-review",
|
||||
`{"command_id":"c1","actor":"alice","reason":"verified","application_id":99,"version":4,"internal_note":"handover"}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if svc.approve.ApplicationID != 7 || svc.approve.Version != 4 ||
|
||||
svc.approve.InternalNote != "handover" || svc.approve.Actor != "alice" {
|
||||
t.Fatalf("forwarded approval=%+v", svc.approve)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationDecisionDryRunIsForwarded(t *testing.T) {
|
||||
svc := &captureVerificationService{app: domain.VerificationApplication{ID: 7, Version: 4}}
|
||||
srv := reviewOnlyServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/reject", "scoped-review",
|
||||
`{"command_id":"dry-1","actor":"alice","reason":"press links are self-published","dry_run":true,"version":4}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !svc.reject.DryRun || svc.reject.Reason != "press links are self-published" {
|
||||
t.Fatalf("forwarded rejection=%+v", svc.reject)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"dry_run":true`) {
|
||||
t.Fatalf("body=%s, want the dry run echoed", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationVersionConflictIsAnswered409(t *testing.T) {
|
||||
svc := &captureVerificationService{
|
||||
app: domain.VerificationApplication{ID: 7, Version: 5},
|
||||
// The shape admin.codedError produces for a lost race.
|
||||
decideOn: fmt.Errorf("%s: %w", admin.CodeVerificationConflict, domain.ErrVerificationVersionConflict),
|
||||
}
|
||||
srv := reviewOnlyServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/approve", "scoped-review",
|
||||
`{"command_id":"c1","actor":"alice","reason":"verified","version":4}`))
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status=%d body=%s, want 409 for a lost optimistic-locking race", rec.Code, rec.Body.String())
|
||||
}
|
||||
var result admin.CommandResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("decode conflict: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.Error, admin.CodeVerificationConflict) {
|
||||
t.Fatalf("result=%+v, want the stable conflict code", result)
|
||||
}
|
||||
if !strings.Contains(result.Message, "reload") {
|
||||
t.Fatalf("result message=%q, want an actionable message", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationErrorStatusMapping(t *testing.T) {
|
||||
cases := map[string]int{
|
||||
admin.CodeVerificationNotFound: http.StatusNotFound,
|
||||
admin.CodeVerificationConflict: http.StatusConflict,
|
||||
admin.CodeVerificationTargetOccupied: http.StatusConflict,
|
||||
admin.CodeVerificationTargetVerified: http.StatusConflict,
|
||||
admin.CodeVerificationStatusInvalid: http.StatusBadRequest,
|
||||
admin.CodeVerificationReasonRequired: http.StatusBadRequest,
|
||||
admin.CodeVerificationTargetInvalid: http.StatusBadRequest,
|
||||
admin.CodeVerificationTargetRestricted: http.StatusBadRequest,
|
||||
admin.CodeVerificationTargetSystem: http.StatusBadRequest,
|
||||
admin.CodeVerificationNotOwner: http.StatusBadRequest,
|
||||
admin.CodeVerificationInvalid: http.StatusBadRequest,
|
||||
"": http.StatusInternalServerError,
|
||||
}
|
||||
for code, want := range cases {
|
||||
if got := verificationErrorStatus(code); got != want {
|
||||
t.Fatalf("verificationErrorStatus(%q) = %d, want %d", code, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedTokenNameBecomesTheAuditActorWhenNoneIsStated(t *testing.T) {
|
||||
svc := &captureVerificationService{app: domain.VerificationApplication{ID: 7, Version: 4}}
|
||||
srv := reviewOnlyServer(svc)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/claim", "scoped-review",
|
||||
`{"command_id":"c1","reason":"queue sweep","version":4}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// The scoped token's configured name is its audit identity.
|
||||
if svc.claim.Actor != "queue-bot" {
|
||||
t.Fatalf("actor=%q, want the scoped token name", svc.claim.Actor)
|
||||
}
|
||||
|
||||
// A stated actor is never overwritten, which is how the panel attributes an
|
||||
// action to the signed-in operator.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/claim", "scoped-review",
|
||||
`{"command_id":"c2","actor":"alice","reason":"queue sweep","version":4}`))
|
||||
if rec.Code != http.StatusOK || svc.claim.Actor != "alice" {
|
||||
t.Fatalf("status=%d actor=%q", rec.Code, svc.claim.Actor)
|
||||
}
|
||||
|
||||
// The master token has no name, so the caller keeps having to say who acts.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/claim", "master",
|
||||
`{"command_id":"c3","reason":"queue sweep","version":4}`))
|
||||
if rec.Code != http.StatusOK || svc.claim.Actor != "" {
|
||||
t.Fatalf("master token status=%d actor=%q, want no invented identity", rec.Code, svc.claim.Actor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionSetWildcardAndMembership(t *testing.T) {
|
||||
all := newPermissionSet([]string{PermissionAll})
|
||||
if !all.Has(PermissionVerificationReview) || !all.Has("anything.at.all") {
|
||||
t.Fatal("wildcard set refused a permission")
|
||||
}
|
||||
bounded := newPermissionSet([]string{" verification.review ", ""})
|
||||
if !bounded.Has(PermissionVerificationReview) {
|
||||
t.Fatal("bounded set dropped a padded permission")
|
||||
}
|
||||
if bounded.Has(PermissionVerificationRevoke) {
|
||||
t.Fatal("bounded set granted an unlisted permission")
|
||||
}
|
||||
if newPermissionSet(nil).Has(PermissionVerificationReview) {
|
||||
t.Fatal("empty set granted a permission")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue