2026-08-09 22:47:43 +02:00
|
|
|
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"}`},
|
2026-08-17 09:33:07 +02:00
|
|
|
{name: "update_api_key", fn: s.handleUpdateAPIKey, body: `{"name":"renamed"}`},
|
2026-08-09 22:47:43 +02:00
|
|
|
{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)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|