feat: add NFT usernames and bot verification (#22)
Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review. The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation. Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9 Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
This commit is contained in:
parent
b0fd3976f1
commit
fff8de783a
169 changed files with 55769 additions and 282 deletions
690
cmd/telesrv-admin/verification_test.go
Normal file
690
cmd/telesrv-admin/verification_test.go
Normal file
|
|
@ -0,0 +1,690 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
)
|
||||
|
||||
const testSessionKey = "01234567890123456789012345678901"
|
||||
|
||||
// panelServer builds a BFF whose sessions carry the given permissions.
|
||||
func panelServer(t *testing.T, permissions ...string) *server {
|
||||
t.Helper()
|
||||
srv, err := newServer(uiConfig{
|
||||
SessionKey: []byte(testSessionKey),
|
||||
Password: "letmein",
|
||||
Permissions: permissions,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newServer: %v", err)
|
||||
}
|
||||
return srv
|
||||
}
|
||||
|
||||
// signIn performs a real login against the routed server and returns the cookies
|
||||
// plus the CSRF token the panel would echo, so the tests exercise the same pairing
|
||||
// the browser gets.
|
||||
func signIn(t *testing.T, srv *server) ([]*http.Cookie, string) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"secret":"letmein"}`))
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Actor string `json:"actor"`
|
||||
Permissions []string `json:"permissions"`
|
||||
CSRFToken string `json:"csrf_token"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode login: %v", err)
|
||||
}
|
||||
if body.CSRFToken == "" {
|
||||
t.Fatal("login did not mint a csrf token")
|
||||
}
|
||||
cookies := rec.Result().Cookies()
|
||||
var sawCSRFCookie bool
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name != csrfCookieName {
|
||||
continue
|
||||
}
|
||||
sawCSRFCookie = true
|
||||
if cookie.HttpOnly {
|
||||
t.Fatal("csrf cookie is HttpOnly; the panel could not read it back")
|
||||
}
|
||||
if cookie.Value != body.CSRFToken || cookie.Path != "/" || cookie.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("csrf cookie=%+v", cookie)
|
||||
}
|
||||
}
|
||||
if !sawCSRFCookie {
|
||||
t.Fatal("login did not set the csrf cookie")
|
||||
}
|
||||
return cookies, body.CSRFToken
|
||||
}
|
||||
|
||||
func withCookies(req *http.Request, cookies []*http.Cookie) *http.Request {
|
||||
for _, cookie := range cookies {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestPanelSessionReportsPermissions(t *testing.T) {
|
||||
srv := panelServer(t, permissionVerificationReview)
|
||||
cookies, _ := signIn(t, srv)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(httptest.NewRequest(http.MethodGet, "/api/session", nil), cookies))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("session status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Actor string `json:"actor"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode session: %v", err)
|
||||
}
|
||||
if body.Actor != "admin" || len(body.Permissions) != 1 || body.Permissions[0] != permissionVerificationReview {
|
||||
t.Fatalf("session=%+v, want the granted permissions reported to the panel", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanelSessionReportsTheWildcardDefault(t *testing.T) {
|
||||
// The shipped default is the wildcard, so an operator upgrading into the
|
||||
// permission model keeps every section.
|
||||
srv := panelServer(t, permissionAll)
|
||||
cookies, _ := signIn(t, srv)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(httptest.NewRequest(http.MethodGet, "/api/session", nil), cookies))
|
||||
if !strings.Contains(rec.Body.String(), `"*"`) {
|
||||
t.Fatalf("session body=%s, want the wildcard reported", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutatingRequestsRequireTheCSRFHeader(t *testing.T) {
|
||||
srv := panelServer(t, permissionAll)
|
||||
cookies, token := signIn(t, srv)
|
||||
const path = "/api/actions/set-verified"
|
||||
const payload = `{"reason":"official","confirm":false,"user_id":1001,"verified":true}`
|
||||
|
||||
// No header at all.
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload)), cookies))
|
||||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), csrfHeaderName) {
|
||||
t.Fatalf("missing header status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// A header that does not match the cookie.
|
||||
rec = httptest.NewRecorder()
|
||||
req := withCookies(httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload)), cookies)
|
||||
req.Header.Set(csrfHeaderName, token+"-tampered")
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("mismatched header status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// A matching header from a different session's token: it agrees with the
|
||||
// cookie the attacker planted but not with the signed session.
|
||||
otherSrv := panelServer(t, permissionAll)
|
||||
_, otherToken := signIn(t, otherSrv)
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload))
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == sessionCookieName {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
}
|
||||
req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: otherToken})
|
||||
req.Header.Set(csrfHeaderName, otherToken)
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "not bound to this session") {
|
||||
t.Fatalf("foreign token status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// A session minted before the CSRF token existed is refused rather than left
|
||||
// half protected.
|
||||
legacy, err := signSession([]byte(testSessionKey), sessionClaims{
|
||||
Actor: "admin", Exp: time.Now().Add(time.Hour).Unix(), Nonce: "n",
|
||||
Permissions: []string{permissionAll},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("signSession: %v", err)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload))
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: legacy})
|
||||
req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "anything"})
|
||||
req.Header.Set(csrfHeaderName, "anything")
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("pre-CSRF session status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFProtectionCoversEveryExistingMutatingRoute(t *testing.T) {
|
||||
srv := panelServer(t, permissionAll)
|
||||
cookies, _ := signIn(t, srv)
|
||||
// A representative slice of the routes that predate CSRF: they must all be
|
||||
// closed, not just the new ones.
|
||||
for _, path := range []string{
|
||||
"/api/logout",
|
||||
"/api/actions/set-frozen",
|
||||
"/api/actions/grant-stars",
|
||||
"/api/actions/delete-bot",
|
||||
"/api/actions/revoke-collectible-username",
|
||||
"/api/actions/adjust-account-rating",
|
||||
"/api/moderation/cases/7/claim",
|
||||
"/api/verification/applications/7/approve",
|
||||
"/api/actions/revoke-verification",
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)), cookies))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s status=%d body=%s, want 403 without a csrf header", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRequestsDoNotNeedTheCSRFHeader(t *testing.T) {
|
||||
srv := panelServer(t, permissionAll)
|
||||
cookies, _ := signIn(t, srv)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(httptest.NewRequest(http.MethodGet, "/api/session", nil), cookies))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status=%d body=%s, want a token-free read", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestForeignOriginIsRefusedEvenWithAValidToken(t *testing.T) {
|
||||
srv := panelServer(t, permissionAll)
|
||||
cookies, token := signIn(t, srv)
|
||||
req := withCookies(httptest.NewRequest(http.MethodPost, "/api/actions/set-verified", strings.NewReader(
|
||||
`{"reason":"official","confirm":false,"user_id":1001,"verified":true}`)), cookies)
|
||||
req.Header.Set(csrfHeaderName, token)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "origin") {
|
||||
t.Fatalf("foreign origin status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// The panel's own origin is accepted.
|
||||
if !sameOriginRequest(originRequest("https://panel.example", "panel.example")) {
|
||||
t.Fatal("same origin refused")
|
||||
}
|
||||
// A missing Origin is accepted: browsers omit it and non-browser callers never
|
||||
// send it, and the token check still applies.
|
||||
if !sameOriginRequest(originRequest("", "panel.example")) {
|
||||
t.Fatal("absent origin refused")
|
||||
}
|
||||
// An opaque origin is not this host.
|
||||
if sameOriginRequest(originRequest("null", "panel.example")) {
|
||||
t.Fatal("opaque origin accepted")
|
||||
}
|
||||
if sameOriginRequest(originRequest("not a url", "panel.example")) {
|
||||
t.Fatal("unparsable origin accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func originRequest(origin, host string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/set-verified", nil)
|
||||
req.Host = host
|
||||
if origin != "" {
|
||||
req.Header.Set("Origin", origin)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestLoginRefusesAForeignOrigin(t *testing.T) {
|
||||
srv := panelServer(t, permissionAll)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"secret":"letmein"}`))
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-origin login status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationRoutesRefuseASessionWithoutTheReviewRight(t *testing.T) {
|
||||
srv := panelServer(t, "gifts.import")
|
||||
cookies, token := signIn(t, srv)
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{http.MethodGet, "/api/verification/applications", ""},
|
||||
{http.MethodGet, "/api/verification/applications/7", ""},
|
||||
{http.MethodGet, "/api/verification/counts", ""},
|
||||
{http.MethodPost, "/api/verification/applications/7/claim", `{}`},
|
||||
{http.MethodPost, "/api/verification/applications/7/approve", `{}`},
|
||||
{http.MethodPost, "/api/verification/applications/7/reject", `{}`},
|
||||
{http.MethodPost, "/api/actions/revoke-verification", `{}`},
|
||||
}
|
||||
for _, item := range cases {
|
||||
var req *http.Request
|
||||
if item.body == "" {
|
||||
req = httptest.NewRequest(item.method, item.path, nil)
|
||||
} else {
|
||||
req = httptest.NewRequest(item.method, item.path, strings.NewReader(item.body))
|
||||
req.Header.Set(csrfHeaderName, token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(req, cookies))
|
||||
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]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode 403 body: %v", err)
|
||||
}
|
||||
if body["code"] != "FORBIDDEN" || body["permission"] != permissionVerificationReview {
|
||||
t.Fatalf("%s 403 body=%+v, want the missing permission named", item.path, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeVerificationNeedsTheRevokeRightOnTopOfReview(t *testing.T) {
|
||||
srv := panelServer(t, permissionVerificationReview)
|
||||
cookies, token := signIn(t, srv)
|
||||
req := withCookies(httptest.NewRequest(http.MethodPost, "/api/actions/revoke-verification", strings.NewReader(
|
||||
`{"reason":"impersonation","confirm":true,"target_type":"channel","target_id":5005}`)), cookies)
|
||||
req.Header.Set(csrfHeaderName, token)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationRoutesRequireASession(t *testing.T) {
|
||||
srv := panelServer(t, permissionAll)
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/api/verification/applications"},
|
||||
{http.MethodGet, "/api/verification/applications/7"},
|
||||
{http.MethodGet, "/api/verification/counts"},
|
||||
{http.MethodPost, "/api/verification/applications/7/claim"},
|
||||
{http.MethodPost, "/api/verification/applications/7/approve"},
|
||||
{http.MethodPost, "/api/verification/applications/7/reject"},
|
||||
{http.MethodPost, "/api/actions/revoke-verification"},
|
||||
}
|
||||
for _, item := range cases {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, httptest.NewRequest(item.method, item.path, strings.NewReader(`{}`)))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s %s status=%d, want 401", item.method, item.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// verificationUpstream stands in for the admin API and records what the BFF sent.
|
||||
type verificationUpstream struct {
|
||||
path string
|
||||
raw []byte
|
||||
status int
|
||||
body any
|
||||
}
|
||||
|
||||
func (u *verificationUpstream) handler(t *testing.T) http.HandlerFunc {
|
||||
t.Helper()
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer api-secret" {
|
||||
t.Fatalf("upstream authorization=%q", r.Header.Get("Authorization"))
|
||||
}
|
||||
u.path = r.URL.Path
|
||||
defer r.Body.Close()
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read upstream body: %v", err)
|
||||
}
|
||||
u.raw = raw
|
||||
status := u.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(u.body)
|
||||
}
|
||||
}
|
||||
|
||||
// requestWithActor stands in for the session middleware, which is what puts the
|
||||
// signed-in operator into the request context.
|
||||
func requestWithActor(r *http.Request, actor string) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), actorKey{}, actor))
|
||||
}
|
||||
|
||||
func TestApproveVerificationBFFForwardsActorVersionAndNote(t *testing.T) {
|
||||
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed"}}
|
||||
api := httptest.NewServer(upstream.handler(t))
|
||||
defer api.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/approve", strings.NewReader(`{
|
||||
"reason":"press coverage verified","confirm":true,"version":"9223372036854775807",
|
||||
"internal_note":"contact came through the press office"
|
||||
}`))
|
||||
req.SetPathValue("id", "77")
|
||||
req = requestWithActor(req, "operator")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleApproveVerificationAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if upstream.path != "/v1/verification/applications/77/approve" {
|
||||
t.Fatalf("upstream path=%q", upstream.path)
|
||||
}
|
||||
var got admin.ApproveVerificationRequest
|
||||
if err := json.Unmarshal(upstream.raw, &got); err != nil {
|
||||
t.Fatalf("decode forwarded approval: %v (%s)", err, upstream.raw)
|
||||
}
|
||||
if got.Actor != "operator" {
|
||||
t.Fatalf("actor=%q, want the signed-in operator", got.Actor)
|
||||
}
|
||||
if got.ApplicationID != 77 || got.Version != 9223372036854775807 {
|
||||
t.Fatalf("forwarded approval=%+v, want the exact int64 version", got)
|
||||
}
|
||||
if got.InternalNote != "contact came through the press office" || got.DryRun {
|
||||
t.Fatalf("forwarded approval=%+v", got)
|
||||
}
|
||||
if got.CommandID == "" {
|
||||
t.Fatal("no command id was minted for the idempotency key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimVerificationBFFDefaultsToADryRun(t *testing.T) {
|
||||
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed", DryRun: true}}
|
||||
api := httptest.NewServer(upstream.handler(t))
|
||||
defer api.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/claim", strings.NewReader(
|
||||
`{"reason":"queue sweep","confirm":false,"version":3}`))
|
||||
req.SetPathValue("id", "77")
|
||||
req = requestWithActor(req, "operator")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleClaimVerificationAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got admin.ClaimVerificationRequest
|
||||
if err := json.Unmarshal(upstream.raw, &got); err != nil {
|
||||
t.Fatalf("decode forwarded claim: %v", err)
|
||||
}
|
||||
// confirm=false is a rehearsal: nothing may be written until the operator
|
||||
// confirms.
|
||||
if !got.DryRun || got.Version != 3 || got.ApplicationID != 77 {
|
||||
t.Fatalf("forwarded claim=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeVerificationBFFForwardsTargetAndRejectsBadShapes(t *testing.T) {
|
||||
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed"}}
|
||||
api := httptest.NewServer(upstream.handler(t))
|
||||
defer api.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
|
||||
req := requestWithActor(httptest.NewRequest(http.MethodPost, "/api/actions/revoke-verification", strings.NewReader(`{
|
||||
"reason":"impersonation confirmed","confirm":true,"target_type":"channel",
|
||||
"target_id":"9223372036854775807","internal_note":"legal asked for it"
|
||||
}`)), "operator")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleRevokeVerificationAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if upstream.path != "/v1/verification/revoke" {
|
||||
t.Fatalf("upstream path=%q", upstream.path)
|
||||
}
|
||||
var got admin.RevokeVerificationRequest
|
||||
if err := json.Unmarshal(upstream.raw, &got); err != nil {
|
||||
t.Fatalf("decode forwarded revocation: %v", err)
|
||||
}
|
||||
if got.TargetID != 9223372036854775807 || got.TargetType != "channel" ||
|
||||
got.Actor != "operator" || got.InternalNote != "legal asked for it" || got.DryRun {
|
||||
t.Fatalf("forwarded revocation=%+v", got)
|
||||
}
|
||||
|
||||
for _, payload := range []string{
|
||||
`{"reason":"x","confirm":true,"target_type":"group","target_id":5}`,
|
||||
`{"reason":"x","confirm":true,"target_type":"channel","target_id":0}`,
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleRevokeVerificationAPI(rec, requestWithActor(
|
||||
httptest.NewRequest(http.MethodPost, "/api/actions/revoke-verification", strings.NewReader(payload)), "operator"))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("payload %s status=%d body=%s, want 400", payload, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationDecisionRejectsUnknownFields(t *testing.T) {
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: "http://127.0.0.1:1", AdminAPIToken: "api-secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/approve", strings.NewReader(
|
||||
`{"reason":"ok","confirm":true,"version":3,"actor":"attacker"}`))
|
||||
req.SetPathValue("id", "77")
|
||||
req = requestWithActor(req, "operator")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleApproveVerificationAPI(rec, req)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "actor") {
|
||||
t.Fatalf("status=%d body=%s, want 400 rejecting the injected actor", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationVersionConflictReachesThePanelAs409(t *testing.T) {
|
||||
upstream := &verificationUpstream{
|
||||
status: http.StatusConflict,
|
||||
body: admin.CommandResult{
|
||||
CommandID: "c1", Status: "failed",
|
||||
Error: admin.CodeVerificationConflict + ": verification application changed concurrently",
|
||||
Message: "another reviewer changed this application first; reload it and decide again",
|
||||
},
|
||||
}
|
||||
api := httptest.NewServer(upstream.handler(t))
|
||||
defer api.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/approve", strings.NewReader(
|
||||
`{"reason":"ok","confirm":true,"version":3}`))
|
||||
req.SetPathValue("id", "77")
|
||||
req = requestWithActor(req, "operator")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleApproveVerificationAPI(rec, req)
|
||||
// A flattened 502 would hide the one failure the panel resolves by reloading.
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status=%d body=%s, want 409", 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) || !strings.Contains(result.Message, "reload") {
|
||||
t.Fatalf("relayed result=%+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationUnreachableAdminAPIIsABadGateway(t *testing.T) {
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: "http://127.0.0.1:1", AdminAPIToken: "api-secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/reject", strings.NewReader(
|
||||
`{"reason":"press links are self-published","confirm":true,"version":3}`))
|
||||
req.SetPathValue("id", "77")
|
||||
req = requestWithActor(req, "operator")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleRejectVerificationAPI(rec, req)
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status=%d body=%s, want 502", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationRowsJSONPreserveInt64AsDecimalStrings(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
raw, err := json.Marshal(VerificationApplicationRow{
|
||||
ID: maxInt64, ApplicantUserID: maxInt64, TargetID: maxInt64, Version: maxInt64,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal verification row: %v", err)
|
||||
}
|
||||
var application map[string]any
|
||||
if err := json.Unmarshal(raw, &application); err != nil {
|
||||
t.Fatalf("unmarshal verification row: %v", err)
|
||||
}
|
||||
for _, field := range []string{"ID", "ApplicantUserID", "TargetID", "Version"} {
|
||||
if application[field] != "9223372036854775807" {
|
||||
t.Fatalf("application %s = %#v, want an exact decimal string", field, application[field])
|
||||
}
|
||||
}
|
||||
|
||||
raw, err = json.Marshal(VerificationEventRow{ID: maxInt64})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal verification event row: %v", err)
|
||||
}
|
||||
var event map[string]any
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
t.Fatalf("unmarshal verification event row: %v", err)
|
||||
}
|
||||
if event["ID"] != "9223372036854775807" {
|
||||
t.Fatalf("event ID = %#v, want an exact decimal string", event["ID"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationQueryValidationRejectsUnmodelledFilters(t *testing.T) {
|
||||
srv := panelServer(t, permissionVerificationReview)
|
||||
cookies, _ := signIn(t, srv)
|
||||
for _, query := range []string{"?status=pending", "?target_type=group", "?before_id=-1", "?limit=abc"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(
|
||||
httptest.NewRequest(http.MethodGet, "/api/verification/applications"+query, nil), cookies))
|
||||
// The read store is absent in this fixture, so a rejected filter is a 400
|
||||
// and an accepted one would be a 503: either way the validation is proven.
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s status=%d body=%s, want 400", query, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(
|
||||
httptest.NewRequest(http.MethodGet, "/api/verification/applications?status=submitted&target_type=channel", nil), cookies))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("valid filter status=%d body=%s, want the read store to be reached", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanelPermissionsWildcardAndMembership(t *testing.T) {
|
||||
all := newPanelPermissions([]string{permissionAll})
|
||||
if !all.Has(permissionVerificationReview) || !all.Has(permissionVerificationRevoke) {
|
||||
t.Fatal("wildcard session refused a permission")
|
||||
}
|
||||
bounded := newPanelPermissions([]string{" verification.review ", "", "verification.review"})
|
||||
if !bounded.Has(permissionVerificationReview) || bounded.Has(permissionVerificationRevoke) {
|
||||
t.Fatalf("bounded session = %+v", bounded.List())
|
||||
}
|
||||
if len(bounded.List()) != 1 {
|
||||
t.Fatalf("bounded list=%+v, want the duplicate collapsed", bounded.List())
|
||||
}
|
||||
if got := newPanelPermissions(nil).List(); got == nil || len(got) != 0 {
|
||||
t.Fatalf("empty list=%#v, want an empty array rather than null", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The CSRF gate must let a correctly-tokened request through -- including on the
|
||||
// routes that predate it -- or the panel is simply broken rather than protected.
|
||||
func TestExistingMutatingRoutesStillWorkWithAValidToken(t *testing.T) {
|
||||
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed", DryRun: true}}
|
||||
api := httptest.NewServer(upstream.handler(t))
|
||||
defer api.Close()
|
||||
|
||||
srv := panelServer(t, permissionAll)
|
||||
srv.cfg.AdminAPIURL = api.URL
|
||||
srv.cfg.AdminAPIToken = "api-secret"
|
||||
cookies, token := signIn(t, srv)
|
||||
|
||||
req := withCookies(httptest.NewRequest(http.MethodPost, "/api/actions/set-verified", strings.NewReader(
|
||||
`{"reason":"official","confirm":false,"user_id":1001,"verified":true}`)), cookies)
|
||||
req.Header.Set(csrfHeaderName, token)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("tokened legacy action status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if upstream.path != "/v1/accounts/set-verified" {
|
||||
t.Fatalf("upstream path=%q", upstream.path)
|
||||
}
|
||||
|
||||
// And logout, which is now behind the same gate.
|
||||
req = withCookies(httptest.NewRequest(http.MethodPost, "/api/logout", nil), cookies)
|
||||
req.Header.Set(csrfHeaderName, token)
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("tokened logout status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Both cookies are cleared, so the browser cannot keep replaying either half.
|
||||
cleared := map[string]bool{}
|
||||
for _, cookie := range rec.Result().Cookies() {
|
||||
if cookie.MaxAge < 0 {
|
||||
cleared[cookie.Name] = true
|
||||
}
|
||||
}
|
||||
if !cleared[sessionCookieName] || !cleared[csrfCookieName] {
|
||||
t.Fatalf("logout cleared=%+v, want both cookies expired", cleared)
|
||||
}
|
||||
}
|
||||
|
||||
// The panel drives claim, approve and reject from one form, so a claim carrying an
|
||||
// internal note must not be rejected by the strict decoder.
|
||||
func TestClaimVerificationAcceptsAnOptionalInternalNote(t *testing.T) {
|
||||
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed"}}
|
||||
api := httptest.NewServer(upstream.handler(t))
|
||||
defer api.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/claim", strings.NewReader(
|
||||
`{"reason":"queue sweep","confirm":true,"version":3,"internal_note":"waiting on legal"}`))
|
||||
req.SetPathValue("id", "77")
|
||||
req = requestWithActor(req, "operator")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleClaimVerificationAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got admin.ClaimVerificationRequest
|
||||
if err := json.Unmarshal(upstream.raw, &got); err != nil {
|
||||
t.Fatalf("decode forwarded claim: %v", err)
|
||||
}
|
||||
if got.InternalNote != "waiting on legal" {
|
||||
t.Fatalf("forwarded claim=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutatingMethodClassification(t *testing.T) {
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions, "get"} {
|
||||
if mutatingMethod(method) {
|
||||
t.Fatalf("%s classified as mutating", method)
|
||||
}
|
||||
}
|
||||
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
|
||||
if !mutatingMethod(method) {
|
||||
t.Fatalf("%s classified as safe", method)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue