Files
descrybe/apps/api/internal/httpapi/staff_authz_test.go
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

257 lines
8.4 KiB
Go

package httpapi
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/support"
"github.com/google/uuid"
)
func TestRequireSupportDeskForbiddenAndAllow(t *testing.T) {
t.Parallel()
uid := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
t.Run("unauthorized", func(t *testing.T) {
t.Parallel()
s := &Server{}
called := false
h := s.RequireSupportDesk(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/admin/support/tickets", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
if called {
t.Fatal("handler must not run without session user")
}
})
t.Run("member_forbidden", func(t *testing.T) {
t.Parallel()
s := &Server{
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
return auth.StaffAccess{}, nil
},
}
called := false
h := s.RequireSupportDesk(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodGet, "/api/admin/support/tickets", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if called {
t.Fatal("handler must not run for non-staff")
}
})
t.Run("support_staff_allowed", func(t *testing.T) {
t.Parallel()
s := &Server{
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
return auth.ResolveStaffAccess(false, auth.StaffRoleSupportStaff), nil
},
}
called := false
h := s.RequireSupportDesk(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
called = true
access, ok := StaffAccessFromContext(req.Context())
if !ok || !access.SupportDesk || !access.IsSupportOnly {
t.Fatalf("expected support-only access in context, got ok=%v %+v", ok, access)
}
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodGet, "/api/admin/support/tickets", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
if !called {
t.Fatal("handler must run for support_staff")
}
})
}
func TestSupportStaffForbiddenOnPlanFeatures(t *testing.T) {
t.Parallel()
uid := uuid.MustParse("cccccccc-cccc-cccc-cccc-cccccccccccc")
s := &Server{
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
return auth.ResolveStaffAccess(true, auth.StaffRoleSupportStaff), nil
},
}
cases := []struct {
name string
body string
}{
{name: "get_plan_features", body: ""},
{name: "put_plan_features", body: `{"features":{}}`},
{name: "enable_all", body: ""},
{name: "disable_all", body: ""},
{name: "get_gates", body: ""},
{name: "put_gates", body: `{"features":{}}`},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
called := false
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodPut, "/api/admin/plans/1/features", bytes.NewBufferString(tc.body)).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d body=%s, want 403", rec.Code, rec.Body.String())
}
if called {
t.Fatal("plan feature handler must not run for support_staff")
}
})
}
}
func TestMemberForbiddenOnAdminSupportAndPlanRoutes(t *testing.T) {
t.Parallel()
uid := uuid.MustParse("dddddddd-dddd-dddd-dddd-dddddddddddd")
s := &Server{
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
return auth.StaffAccess{}, nil
},
}
t.Run("support_desk", func(t *testing.T) {
t.Parallel()
h := s.RequireSupportDesk(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodGet, "/api/admin/support/tickets", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
})
t.Run("platform_admin", func(t *testing.T) {
t.Parallel()
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodGet, "/api/admin/plans/1/features", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
})
}
func TestUserReplyMassAssignmentRejected(t *testing.T) {
t.Parallel()
// UserReplyInput only accepts "body"; DisallowUnknownFields rejects status / is_internal_note.
var dst support.UserReplyInput
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"body":"hi","is_internal_note":true,"status":"closed"}`))
err := DecodeJSON(req, &dst)
if err == nil {
t.Fatal("expected DecodeJSON to reject mass-assignment fields on UserReplyInput")
}
}
func TestRedactForLog(t *testing.T) {
t.Parallel()
in := "smtp dial failed password=SuperSecret123 api_key=sk_live_abc token:xyz"
out := redactForLog(in)
if strings.Contains(out, "SuperSecret123") || strings.Contains(out, "sk_live_abc") || strings.Contains(out, ":xyz") {
t.Fatalf("secrets leaked in log: %s", out)
}
if !strings.Contains(out, "[REDACTED]") {
t.Fatalf("expected redaction markers, got %s", out)
}
}
func TestStaffMayAccessTicket(t *testing.T) {
t.Parallel()
s := &Server{}
actor := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
other := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
t.Run("full_admin_sees_all", func(t *testing.T) {
t.Parallel()
ctx := withStaffAccess(context.Background(), auth.ResolveStaffAccess(true, ""))
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
ticket := support.Ticket{AssigneeAdminUserID: &other}
if !s.staffMayAccessTicket(req, actor, ticket) {
t.Fatal("full admin must see assigned tickets")
}
})
t.Run("support_staff_own_or_unassigned", func(t *testing.T) {
t.Parallel()
ctx := withStaffAccess(context.Background(), auth.ResolveStaffAccess(false, auth.StaffRoleSupportStaff))
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
if !s.staffMayAccessTicket(req, actor, support.Ticket{Status: "open"}) {
t.Fatal("unassigned open must be visible")
}
if s.staffMayAccessTicket(req, actor, support.Ticket{Status: "resolved"}) {
t.Fatal("unassigned resolved must be hidden from claim queue")
}
own := actor
if !s.staffMayAccessTicket(req, actor, support.Ticket{AssigneeAdminUserID: &own}) {
t.Fatal("own assignment must be visible")
}
if s.staffMayAccessTicket(req, actor, support.Ticket{AssigneeAdminUserID: &other}) {
t.Fatal("other assignee must be hidden")
}
})
}
func TestRequirePlatformAdminExcludesSupportStaff(t *testing.T) {
t.Parallel()
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
return auth.ResolveStaffAccess(true, auth.StaffRoleSupportStaff), nil
},
}
called := false
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if called {
t.Fatal("full admin routes must reject support_staff even with is_platform_admin")
}
}