Files
molecule-cli/internal/cmd/org_test.go
sdk-dev 442d1ebaf4
CI / Test / test (pull_request) Waiting to run
Release Go binaries / release (pull_request) Blocked by required conditions
Release Go binaries / test (pull_request) Waiting to run
fix(cli): repoint org create/list to CP-admin bearer; fail-fast get/export
Review fix for #13. The CP org verbs targeted /api/v1/orgs*, which is gated by
RequireSession() (WorkOS cookie-only) — a bearer-token CLI can't authenticate
and these 401 in prod; the tenant Org API Key has no standing on the CP at all.

- org create/list now target the CP ADMIN routes (POST/GET /api/v1/admin/orgs,
  AdminGate bearer), authenticated with a DISTINCT credential MOLECULE_CP_ADMIN_TOKEN
  (never the tenant MOLECULE_API_KEY). create now requires --owner-user-id, per
  controlplane adminCreateOrgRequest{slug,name,owner_user_id}. ListOrgs decodes
  the {limit,offset,orgs[]} admin-summary envelope. Two-credential split is
  documented in `org`/`org create` help text; the org key is never sent to the CP.
- org get/export have NO AdminGate-reachable route on the CP (session-only), so
  they fail fast with a clear "session-only, use the dashboard" error instead of
  shipping verbs that 401.
- cpAdminClient() fails fast with guidance when MOLECULE_CP_ADMIN_TOKEN is unset
  (wrong-credential path), rather than silently sending the org key to the CP.
- Wire Execute() through handleErr so SilenceErrors'd exitError messages actually
  print (they were previously swallowed by main's bare os.Exit(1)) — required for
  the fail-fast guidance to reach the user.
- Optional cleanup: extract resolveBillingMode()/budgetLimitsFromFlags() so prod
  and tests share one definition.
- Tests: client + cmd assert org verbs hit /api/v1/admin/orgs with the CP-admin
  bearer (no org-id header, no org-key leak), the missing-owner and
  missing-admin-token fail-fast paths, get/export fail-fast, and an e2e CLI test
  that `org list` without the admin token exits non-zero naming MOLECULE_CP_ADMIN_TOKEN.

Budget shape (budget_limits) left unchanged — confirmed correct; the OpenAPI
spec is the stale one (fixed separately on #2056).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:22 -07:00

172 lines
6.8 KiB
Go

package cmd
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// orgTestServer spins up an httptest server that records the path, method, and
// the two credential headers of the request it receives, then returns the
// supplied body. Used to assert the org-lifecycle verbs hit the CP ADMIN
// surface with the CP-admin bearer (and NOT the tenant org key/org-id header).
type capturedReq struct {
method, path, auth, orgID string
hadAuth bool
}
func orgTestServer(t *testing.T, body string) (*httptest.Server, *capturedReq) {
t.Helper()
got := &capturedReq{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got.method = r.Method
got.path = r.URL.Path
got.auth = r.Header.Get("Authorization")
got.orgID = r.Header.Get("X-Molecule-Org-Id")
_, got.hadAuth = r.Header["Authorization"]
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv, got
}
// TestOrgList_TargetsAdminSurfaceWithCPAdminBearer asserts `org list` hits the
// control-plane ADMIN route (/api/v1/admin/orgs) authenticated with the
// CP-admin bearer (MOLECULE_CP_ADMIN_TOKEN) — and critically does NOT leak the
// tenant Org API Key (MOLECULE_API_KEY) or send the X-Molecule-Org-Id header.
func TestOrgList_TargetsAdminSurfaceWithCPAdminBearer(t *testing.T) {
srv, got := orgTestServer(t, `{"limit":100,"offset":0,"orgs":[{"slug":"acme","name":"Acme","plan":"pro","instance_status":"running","member_count":3}]}`)
// Tenant org key is present in the env but must NOT be used for CP calls.
t.Setenv("MOLECULE_API_KEY", "org-key-SECRET")
t.Setenv("MOLECULE_ORG_ID", "org_should_not_leak")
t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "cp-admin-bearer-123")
t.Setenv("MOLECULE_CP_URL", srv.URL)
origFmt := outputFormat
defer func() { outputFormat = origFmt }()
outputFormat = "json"
if err := runOrgList(nil, nil); err != nil {
t.Fatalf("runOrgList: %v", err)
}
if got.method != "GET" || got.path != "/api/v1/admin/orgs" {
t.Errorf("target = %s %s, want GET /api/v1/admin/orgs", got.method, got.path)
}
if got.auth != "Bearer cp-admin-bearer-123" {
t.Errorf("Authorization = %q, want CP-admin bearer", got.auth)
}
if strings.Contains(got.auth, "org-key-SECRET") {
t.Errorf("tenant Org API Key leaked to the control plane: %q", got.auth)
}
if got.orgID != "" {
t.Errorf("X-Molecule-Org-Id should not be sent to the CP admin surface, got %q", got.orgID)
}
}
// TestOrgCreate_TargetsAdminSurfaceWithOwnerAndCPAdminBearer asserts `org
// create` POSTs to /api/v1/admin/orgs with the CP-admin bearer and the
// required owner_user_id, never the tenant org key.
func TestOrgCreate_TargetsAdminSurfaceWithOwnerAndCPAdminBearer(t *testing.T) {
srv, got := orgTestServer(t, `{"id":"o1","slug":"acme","name":"Acme","plan":"free","created_at":"now"}`)
t.Setenv("MOLECULE_API_KEY", "org-key-SECRET")
t.Setenv("MOLECULE_ORG_ID", "org_should_not_leak")
t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "cp-admin-bearer-123")
t.Setenv("MOLECULE_CP_URL", srv.URL)
origFmt := outputFormat
defer func() { outputFormat = origFmt }()
outputFormat = "json"
orgCreateFlags.slug = "acme"
orgCreateFlags.name = "Acme"
orgCreateFlags.ownerUserID = "user_123"
orgCreateFlags.template = ""
defer func() { orgCreateFlags.slug, orgCreateFlags.name, orgCreateFlags.ownerUserID = "", "", "" }()
if err := runOrgCreate(nil, nil); err != nil {
t.Fatalf("runOrgCreate: %v", err)
}
if got.method != "POST" || got.path != "/api/v1/admin/orgs" {
t.Errorf("target = %s %s, want POST /api/v1/admin/orgs", got.method, got.path)
}
if got.auth != "Bearer cp-admin-bearer-123" {
t.Errorf("Authorization = %q, want CP-admin bearer", got.auth)
}
if got.orgID != "" {
t.Errorf("X-Molecule-Org-Id should not be sent to the CP admin surface, got %q", got.orgID)
}
}
// TestOrgCreate_RequiresOwnerUserID confirms the admin create path fails fast
// (no network call) when --owner-user-id is missing.
func TestOrgCreate_RequiresOwnerUserID(t *testing.T) {
t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "cp-admin-bearer-123")
orgCreateFlags.slug = "acme"
orgCreateFlags.name = "Acme"
orgCreateFlags.ownerUserID = ""
orgCreateFlags.template = ""
defer func() { orgCreateFlags.slug, orgCreateFlags.name = "", "" }()
err := runOrgCreate(nil, nil)
if err == nil {
t.Fatal("expected error when --owner-user-id is missing")
}
if !strings.Contains(err.Error(), "owner-user-id") {
t.Errorf("error = %q, want it to mention owner-user-id", err.Error())
}
}
// TestOrgVerbs_FailFastWithoutCPAdminToken is the WRONG-CREDENTIAL path: when
// only the tenant Org API Key is set (no MOLECULE_CP_ADMIN_TOKEN), the
// CP-targeting verbs must fail fast with a clear message — NOT silently send
// the org key to the control plane and 401.
func TestOrgVerbs_FailFastWithoutCPAdminToken(t *testing.T) {
// Tenant key present, CP-admin token deliberately absent.
t.Setenv("MOLECULE_API_KEY", "org-key-SECRET")
t.Setenv("MOLECULE_ORG_ID", "org_abc")
t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "")
// Point CP at an unreachable host so any accidental network call is a
// hard, obvious failure rather than a hang.
t.Setenv("MOLECULE_CP_URL", "http://127.0.0.1:0")
// list
if err := runOrgList(nil, nil); err == nil {
t.Error("org list should fail fast without MOLECULE_CP_ADMIN_TOKEN")
} else if !strings.Contains(err.Error(), "MOLECULE_CP_ADMIN_TOKEN") {
t.Errorf("org list error = %q, want it to name MOLECULE_CP_ADMIN_TOKEN", err.Error())
}
// create
orgCreateFlags.slug = "acme"
orgCreateFlags.name = "Acme"
orgCreateFlags.ownerUserID = "user_123"
orgCreateFlags.template = ""
defer func() { orgCreateFlags.slug, orgCreateFlags.name, orgCreateFlags.ownerUserID = "", "", "" }()
if err := runOrgCreate(nil, nil); err == nil {
t.Error("org create should fail fast without MOLECULE_CP_ADMIN_TOKEN")
} else if !strings.Contains(err.Error(), "MOLECULE_CP_ADMIN_TOKEN") {
t.Errorf("org create error = %q, want it to name MOLECULE_CP_ADMIN_TOKEN", err.Error())
}
}
// TestOrgGetExport_FailFast confirms get/export do not pretend to work over
// token auth: they return a clear unavailable error instead of 401'ing the
// session-only CP routes.
func TestOrgGetExport_FailFast(t *testing.T) {
t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "cp-admin-bearer-123") // even WITH the admin token
if err := runOrgGet(nil, []string{"acme"}); err == nil {
t.Error("org get should fail fast (session-only on the CP)")
} else if !strings.Contains(err.Error(), "session") {
t.Errorf("org get error = %q, want it to explain the session gate", err.Error())
}
if err := runOrgExport(nil, []string{"acme"}); err == nil {
t.Error("org export should fail fast (session-only on the CP)")
} else if !strings.Contains(err.Error(), "session") {
t.Errorf("org export error = %q, want it to explain the session gate", err.Error())
}
}