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>
134 lines
4.1 KiB
Go
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)
|
|
}
|
|
}
|