Files
2026-08-17 09:33:07 +02:00

273 lines
9.2 KiB
Go

package httpapi
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/google/uuid"
)
func TestMemberForbiddenOnSensitiveMutations(t *testing.T) {
t.Parallel()
s := &Server{}
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "member")
cases := []struct {
name string
fn http.HandlerFunc
body string
}{
{name: "create_api_key", fn: s.handleCreateAPIKey, body: `{"name":"x"}`},
{name: "update_api_key", fn: s.handleUpdateAPIKey, body: `{"name":"renamed"}`},
{name: "revoke_api_key", fn: s.handleRevokeAPIKey, body: ""},
{name: "put_email", fn: s.handlePutEmailIntegration, body: `{}`},
{name: "verify_email", fn: s.handleVerifyEmailIntegration, body: ""},
{name: "test_email", fn: s.handleTestEmailIntegration, body: `{}`},
{name: "send_email", fn: s.handleSendEmail, body: `{}`},
{name: "put_ai", fn: s.handlePutAIIntegration, body: `{}`},
{name: "test_ai", fn: s.handleTestAIIntegration, body: ""},
{name: "update_woo", fn: s.handleUpdateWooConfig, body: `{}`},
{name: "update_woo_maps", fn: s.handleUpdateWooMaps, body: `{}`},
{name: "update_woo_schedule", fn: s.handleUpdateWooSchedule, body: `{}`},
{name: "update_shopify", fn: s.handleUpdateShopifyConfig, body: `{}`},
{name: "update_shopify_schedule", fn: s.handleUpdateShopifySchedule, body: `{}`},
{name: "stripe_checkout", fn: s.handleStripeCheckout, body: `{}`},
{name: "stripe_portal", fn: s.handleStripePortal, body: `{}`},
{name: "reset_products", fn: s.handleResetProducts, body: `{"product_ids":[],"kind":"raw"}`},
{name: "import_csv", fn: s.handleImportCSV, body: ""},
{name: "put_category_attributes", fn: s.handlePutCategoryAttributes, body: `{"attribute_ids":[]}`},
{name: "delete_category", fn: s.handleDeleteCategory, body: ""},
{name: "delete_attribute", fn: s.handleDeleteAttribute, body: ""},
{name: "delete_feed", fn: s.handleDeleteFeed, body: ""},
{name: "delete_export_feed", fn: s.handleDeleteExportFeed, body: ""},
{name: "rotate_export_feed_token", fn: s.handleRotateExportFeedPublicToken, body: ""},
{name: "delete_file", fn: s.handleDeleteFile, body: ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(tc.body))
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
tc.fn(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d body=%s, want 403", rec.Code, rec.Body.String())
}
})
}
}
func TestCompanyAdminAllowedRoles(t *testing.T) {
t.Parallel()
if !CompanyAdminAllowed(context.WithValue(context.Background(), ctxRole, "admin")) {
t.Fatal("admin role should be allowed")
}
if !CompanyAdminAllowed(context.WithValue(context.Background(), ctxRole, "api")) {
t.Fatal("api role should be allowed for catalog destructive ops")
}
if CompanyAdminAllowed(context.WithValue(context.Background(), ctxRole, "member")) {
t.Fatal("member role must not be allowed")
}
if CompanyAdminAllowed(context.Background()) {
t.Fatal("missing role must not be allowed")
}
}
func TestAPIKeyContextRole(t *testing.T) {
t.Parallel()
if got := apiKeyContextRole("admin"); got != "api" {
t.Fatalf("admin -> api, got %q", got)
}
if got := apiKeyContextRole("Admin"); got != "api" {
t.Fatalf("Admin -> api, got %q", got)
}
if got := apiKeyContextRole("member"); got != "member" {
t.Fatalf("member stays member, got %q", got)
}
if got := apiKeyContextRole(""); got != "member" {
t.Fatalf("empty normalizes to member, got %q", got)
}
if CompanyAdminAllowed(context.WithValue(context.Background(), ctxRole, apiKeyContextRole("member"))) {
t.Fatal("member-owned API key must not pass CompanyAdminAllowed")
}
if !CompanyAdminAllowed(context.WithValue(context.Background(), ctxRole, apiKeyContextRole("admin"))) {
t.Fatal("admin-owned API key must pass CompanyAdminAllowed")
}
}
func TestRequirePlatformAdminUnauthorized(t *testing.T) {
t.Parallel()
s := &Server{}
called := false
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/admin/users", 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")
}
}
func TestRequirePlatformAdminForbiddenAndAllow(t *testing.T) {
t.Parallel()
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
t.Run("forbidden", func(t *testing.T) {
t.Parallel()
s := &Server{
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
if got != uid {
t.Fatalf("userID = %s, want %s", got, uid)
}
return false, 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("handler must not run for non-admin")
}
})
t.Run("db_error_fail_closed", func(t *testing.T) {
t.Parallel()
s := &Server{
testPlatformAdmin: func(context.Context, uuid.UUID) (bool, error) {
return false, context.DeadlineExceeded
},
}
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/users", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 on lookup error", rec.Code)
}
})
t.Run("allow", func(t *testing.T) {
t.Parallel()
s := &Server{
testPlatformAdmin: func(context.Context, uuid.UUID) (bool, error) {
return true, 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.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
if !called {
t.Fatal("handler must run for platform admin")
}
})
t.Run("support_staff_forbidden", func(t *testing.T) {
t.Parallel()
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/plans", 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("support_staff must not reach full admin routes")
}
})
}
func TestRequireSupportDesk(t *testing.T) {
t.Parallel()
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
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, _ *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.StatusNoContent || !called {
t.Fatalf("status=%d called=%v", rec.Code, called)
}
})
t.Run("plain_user_forbidden", func(t *testing.T) {
t.Parallel()
s := &Server{
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
return auth.StaffAccess{}, nil
},
}
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)
}
})
}