owpengram-server/cmd/telesrv-admin/verification_test.go

688 lines
26 KiB
Go

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, 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(`{"username":"owpengram","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 != breakGlassUsername || 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/delete-bot",
"/api/actions/revoke-collectible-username",
"/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(`{"username":"owpengram","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)
}
}
}