This commit is contained in:
2026-08-16 11:37:42 +02:00
parent dc9ea628c1
commit 52f27e30fc
26 changed files with 1137 additions and 56 deletions
@@ -0,0 +1,108 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// handleAdminCloneCompanyCatalog copies operational catalog data from the URL
// company into the admin's sandbox company (home / explicit dest). Source is
// never mutated. Body: confirm=true required; optional dest_company_id.
func (s *Server) handleAdminCloneCompanyCatalog(w http.ResponseWriter, r *http.Request) {
if s.Catalog == nil {
Error(w, http.StatusServiceUnavailable, "catalog service unavailable")
return
}
srcID, err := uuid.Parse(strings.TrimSpace(chi.URLParam(r, "id")))
if err != nil {
Error(w, http.StatusBadRequest, "invalid company id")
return
}
var body struct {
Confirm bool `json:"confirm"`
DestCompanyID string `json:"dest_company_id"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if !body.Confirm {
Error(w, http.StatusBadRequest, "confirm must be true (destination catalog will be replaced)")
return
}
destID, err := s.resolveCloneDestCompany(r, body.DestCompanyID)
if err != nil {
Error(w, http.StatusBadRequest, err.Error())
return
}
if platformsettings.IsSystemCompany(srcID) || platformsettings.IsSystemCompany(destID) {
Error(w, http.StatusBadRequest, "cannot clone to or from the platform settings company")
return
}
var destLegacy, destName string
if err := s.Pool.QueryRow(r.Context(), `
SELECT COALESCE(legacy_company_id, ''), name FROM companies WHERE id = $1`, destID).
Scan(&destLegacy, &destName); err != nil {
Error(w, http.StatusBadRequest, "destination company not found")
return
}
if billing.IsA1CohortCompany(destLegacy, destName) {
Error(w, http.StatusBadRequest, "refusing to overwrite A1 cohort company; switch to your sandbox company")
return
}
res, err := s.Catalog.CloneCompanyCatalog(r.Context(), srcID, destID)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not clone catalog", err, catalog.ClientError)
return
}
JSON(w, http.StatusOK, map[string]any{
"status": "ok",
"source_company_id": res.SourceCompanyID,
"dest_company_id": res.DestCompanyID,
"counts": res.Counts,
})
}
// resolveCloneDestCompany prefers an explicit dest, then staff home (when acting
// in another tenant), else the session active company.
func (s *Server) resolveCloneDestCompany(r *http.Request, destRaw string) (uuid.UUID, error) {
if raw := strings.TrimSpace(destRaw); raw != "" {
id, err := uuid.Parse(raw)
if err != nil {
return uuid.Nil, errors.New("invalid dest_company_id")
}
return id, nil
}
if home := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionStaffHomeCompanyKey)); home != "" {
id, err := uuid.Parse(home)
if err != nil {
return uuid.Nil, errors.New("invalid staff home company")
}
return id, nil
}
if cid, ok := CompanyIDFromContext(r.Context()); ok && cid != uuid.Nil {
return cid, nil
}
if cur := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionCompanyIDKey)); cur != "" {
id, err := uuid.Parse(cur)
if err != nil {
return uuid.Nil, errors.New("invalid session company")
}
return id, nil
}
return uuid.Nil, errors.New("no destination company; select your sandbox company first")
}
@@ -0,0 +1,78 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/google/uuid"
)
func TestHandleAdminCloneCompanyCatalogNilCatalog(t *testing.T) {
t.Parallel()
s := &Server{}
req := httptest.NewRequest(http.MethodPost, "/api/admin/companies/"+uuid.NewString()+"/clone-catalog", strings.NewReader(`{"confirm":true}`))
rec := httptest.NewRecorder()
s.handleAdminCloneCompanyCatalog(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503 body=%s", rec.Code, rec.Body.String())
}
}
// TestRouterAdminCloneCatalogMounted locks POST /api/admin/companies/{id}/clone-catalog
// after session + CSRF + platform-admin (503 with nil Catalog), not chi 404.
func TestRouterAdminCloneCatalogMounted(t *testing.T) {
t.Parallel()
sm := scs.New()
sm.Cookie.Name = "descrybe_session"
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{
Config: config.Config{
CSRFCookieName: "descrybe_csrf",
WebOrigin: "http://localhost:5173",
},
Sessions: sm,
Auth: &auth.Service{},
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
return got == uid, nil
},
}
var token string
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
token = c.Value
}
}
if token == "" {
t.Fatal("expected session cookie from seed request")
}
h := s.Router()
path := "/api/admin/companies/" + uuid.NewString() + "/clone-catalog"
csrf := csrfCookieForSession(t, h, sm, token)
mounted := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"confirm":true}`))
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
req.AddCookie(csrf)
req.Header.Set("X-CSRF-Token", csrf.Value)
h.ServeHTTP(mounted, req)
if mounted.Code == http.StatusNotFound {
t.Fatalf("route not mounted: status=404 body=%s", mounted.Body.String())
}
if mounted.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503 (nil catalog) body=%s", mounted.Code, mounted.Body.String())
}
}
+4 -3
View File
@@ -387,9 +387,10 @@ func (s *Server) Router() http.Handler {
r.Get("/users", s.handleAdminListUsers)
r.Patch("/users/{id}/staff-role", s.handleAdminSetStaffRole)
r.Put("/support/agents/{id}", s.handleAdminSetSupportAgent)
r.Get("/staff", s.handleAdminListStaff)
r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword)
r.Get("/companies", s.handleAdminListCompanies)
r.Get("/staff", s.handleAdminListStaff)
r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword)
r.Get("/companies", s.handleAdminListCompanies)
r.Post("/companies/{id}/clone-catalog", s.handleAdminCloneCompanyCatalog)
r.Get("/readiness", s.handleAdminReadiness)
r.Get("/diagnostics", s.handleAdminDiagnostics)
r.Get("/analytics", s.handleAdminAnalytics)
@@ -45,6 +45,15 @@ func (s *Server) handleStripeCheckout(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusForbidden, "admin required")
return
}
platformAdmin, err := s.checkPlatformAdmin(r.Context(), uid)
if err != nil {
LogAndError(w, http.StatusInternalServerError, "failed to verify platform admin", err)
return
}
if err := s.stripeSvc().EnsureSelfServeCheckout(r.Context(), platformAdmin); err != nil {
ClientOrLog(w, http.StatusServiceUnavailable, "checkout unavailable", err, billing.ClientError)
return
}
var body billing.CheckoutRequest
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
@@ -100,6 +100,29 @@ func TestHandleStripeCheckoutMockRequiresAdmin(t *testing.T) {
}
}
func TestHandleStripeCheckoutMockBlocksNonPlatformAdmin(t *testing.T) {
t.Parallel()
s := &Server{
Config: config.Config{StripeMock: true},
Stripe: &billing.StripeService{Cfg: billing.StripeConfig{ForceMock: true}},
testPlatformAdmin: func(context.Context, uuid.UUID) (bool, error) {
return false, nil
},
}
cid := uuid.New()
uid := uuid.New()
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "admin")
req := httptest.NewRequest(http.MethodPost, "/api/billing/checkout", bytes.NewBufferString(`{"plan":"starter","term":"monthly"}`))
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
s.handleStripeCheckout(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d body=%s want 503", rec.Code, rec.Body.String())
}
}
func signHandlerStripePayload(t *testing.T, secret string, payload []byte) string {
t.Helper()
ts := time.Now().Unix()