fixes
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user