Files
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

134 lines
4.1 KiB
Go

package cmd
import (
"os"
"path/filepath"
"testing"
)
// TestBudgetFlagMapping verifies the budget flag→limits translation:
// unset flags are omitted, negative clears (nil), non-negative sets a pointer.
// Exercises the SHARED budgetLimitsFromFlags helper so prod and test agree.
func TestBudgetFlagMapping(t *testing.T) {
// All unset → empty map (show, not set).
if got := budgetLimitsFromFlags(budgetUnset, budgetUnset, budgetUnset, budgetUnset); len(got) != 0 {
t.Errorf("all-unset: want empty map, got %v", got)
}
// monthly=50000 set, daily=-1 clear, others unset.
got := budgetLimitsFromFlags(budgetUnset, -1, budgetUnset, 50000)
if len(got) != 2 {
t.Fatalf("want 2 entries, got %d (%v)", len(got), got)
}
if v, ok := got["daily"]; !ok || v != nil {
t.Errorf("daily: want present+nil (clear), got ok=%v v=%v", ok, v)
}
if v, ok := got["monthly"]; !ok || v == nil || *v != 50000 {
t.Errorf("monthly: want 50000, got ok=%v v=%v", ok, v)
}
if _, ok := got["hourly"]; ok {
t.Errorf("hourly should be absent when unset")
}
}
// TestBillingModeValidation walks the billing-mode arg validation branches via
// the SHARED resolveBillingMode helper (same code prod runs).
func TestBillingModeValidation(t *testing.T) {
cases := []struct {
in string
wantMode string // resolved mode passed to the client
wantErr bool
}{
{"platform_managed", "platform_managed", false},
{"byok", "byok", false},
{"disabled", "disabled", false},
{"clear", "", false},
{"null", "", false},
{"", "", false},
{"bogus", "", true},
}
for _, tc := range cases {
mode, err := resolveBillingMode(tc.in)
if (err != nil) != tc.wantErr {
t.Errorf("%q: err=%v want %v", tc.in, err, tc.wantErr)
}
if !tc.wantErr && mode != tc.wantMode {
t.Errorf("%q: resolved mode=%q want %q", tc.in, mode, tc.wantMode)
}
}
}
// TestReadFileMappings verifies template --file relpath=localpath parsing +
// file reads, including the error branches.
func TestReadFileMappings(t *testing.T) {
dir := t.TempDir()
good := filepath.Join(dir, "org.yaml")
if err := os.WriteFile(good, []byte("name: x\n"), 0o600); err != nil {
t.Fatal(err)
}
// Valid mapping.
out, err := readFileMappings([]string{"org.yaml=" + good})
if err != nil {
t.Fatalf("valid mapping: %v", err)
}
if out["org.yaml"] != "name: x\n" {
t.Errorf("contents = %q", out["org.yaml"])
}
// Missing '='.
if _, err := readFileMappings([]string{"justakey"}); err == nil {
t.Errorf("missing '=' should error")
}
// Empty relpath.
if _, err := readFileMappings([]string{"=" + good}); err == nil {
t.Errorf("empty relpath should error")
}
// Nonexistent local file.
if _, err := readFileMappings([]string{"a.yaml=" + filepath.Join(dir, "nope")}); err == nil {
t.Errorf("nonexistent local file should error")
}
}
// TestJSONFlagResolution confirms --json sets outputFormat=json via the
// persistent pre-run hook.
func TestJSONFlagResolution(t *testing.T) {
origFmt, origJSON := outputFormat, jsonOutput
defer func() { outputFormat, jsonOutput = origFmt, origJSON }()
outputFormat = "table"
jsonOutput = true
rootCmd.PersistentPreRun(rootCmd, nil)
if outputFormat != "json" {
t.Errorf("--json should set outputFormat=json, got %q", outputFormat)
}
}
// TestAuthHelpers confirms the credential loaders read the documented env vars.
func TestAuthHelpers(t *testing.T) {
t.Setenv("MOLECULE_API_KEY", "k1")
t.Setenv("MOLECULE_ORG_ID", "o1")
if authToken() != "k1" {
t.Errorf("authToken() = %q, want k1", authToken())
}
if orgID() != "o1" {
t.Errorf("orgID() = %q, want o1", orgID())
}
}
// TestCPURLFallback confirms cpURL falls back to apiURL when MOLECULE_CP_URL
// is unset, and prefers MOLECULE_CP_URL when set.
func TestCPURLFallback(t *testing.T) {
origAPI := apiURL
defer func() { apiURL = origAPI }()
apiURL = "https://tenant.example"
t.Setenv("MOLECULE_CP_URL", "")
if got := cpURL(); got != "https://tenant.example" {
t.Errorf("cpURL fallback = %q, want tenant url", got)
}
t.Setenv("MOLECULE_CP_URL", "https://api.moleculesai.app")
if got := cpURL(); got != "https://api.moleculesai.app" {
t.Errorf("cpURL = %q, want CP url", got)
}
}