442d1ebaf4
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>
172 lines
6.8 KiB
Go
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())
|
|
}
|
|
}
|