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

321 lines
9.6 KiB
Go

package client
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
// capture records what the test server received for one request.
type capture struct {
method string
path string
query string
auth string
org string
body string
}
// newCaptureServer returns an httptest server that records the first request
// into *cap and replies with replyJSON (status 200).
func newCaptureServer(t *testing.T, cap *capture, replyJSON string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
cap.method = r.Method
cap.path = r.URL.Path
cap.query = r.URL.RawQuery
cap.auth = r.Header.Get("Authorization")
cap.org = r.Header.Get("X-Molecule-Org-Id")
cap.body = string(b)
w.Header().Set("Content-Type", "application/json")
if replyJSON == "" {
replyJSON = "{}"
}
_, _ = io.WriteString(w, replyJSON)
}))
}
// TestClientAuthHeaders proves NewWithAuth attaches the bearer + org id on
// every verb (the broader half of the auth-bug fix — the client helpers used
// to send no Authorization header either).
func TestClientAuthHeaders(t *testing.T) {
var cap capture
srv := newCaptureServer(t, &cap, `[]`)
defer srv.Close()
p := NewWithAuth(srv.URL, "key-xyz", "org_1")
if _, err := p.ListWorkspaces(); err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if cap.auth != "Bearer key-xyz" {
t.Errorf("Authorization = %q, want %q", cap.auth, "Bearer key-xyz")
}
if cap.org != "org_1" {
t.Errorf("X-Molecule-Org-Id = %q, want %q", cap.org, "org_1")
}
}
// TestManagementRequestConstruction is the table-driven proof that each new
// management verb builds the right method, path, and body. The handler shapes
// are aligned to the live workspace-server / controlplane handlers.
func TestManagementRequestConstruction(t *testing.T) {
cases := []struct {
name string
reply string
call func(p *Platform) error
wantMethod string
wantPath string
wantBody string // exact JSON when non-empty; "" = don't assert body
}{
{
// org list now hits the CP ADMIN surface (the customer
// /api/v1/orgs is WorkOS-session-gated and 401s a bearer CLI).
name: "ListOrgs",
reply: `{"limit":100,"offset":0,"orgs":[]}`,
call: func(p *Platform) error { _, e := p.ListOrgs(); return e },
wantMethod: "GET",
wantPath: "/api/v1/admin/orgs",
},
{
// org create hits the CP ADMIN surface and carries the required
// owner_user_id (the admin route has no implicit session).
name: "CreateOrg",
reply: `{"slug":"acme","name":"Acme"}`,
call: func(p *Platform) error {
_, e := p.CreateOrg(CreateOrgRequest{Slug: "acme", Name: "Acme", OwnerUserID: "user_123"})
return e
},
wantMethod: "POST",
wantPath: "/api/v1/admin/orgs",
wantBody: `{"slug":"acme","name":"Acme","owner_user_id":"user_123"}`,
},
{
name: "CreateOrgFromTemplate",
reply: `[]`,
call: func(p *Platform) error {
_, e := p.CreateOrgFromTemplate(ImportOrgRequest{Dir: "tmpl", Mode: "reconcile"})
return e
},
wantMethod: "POST",
wantPath: "/org/import",
wantBody: `{"dir":"tmpl","mode":"reconcile"}`,
},
{
name: "GetAllowlist",
reply: `{"plugins":[]}`,
call: func(p *Platform) error { _, e := p.GetAllowlist("org_1"); return e },
wantMethod: "GET",
wantPath: "/orgs/org_1/plugins/allowlist",
},
{
name: "ListOrgTokens",
reply: `[]`,
call: func(p *Platform) error { _, e := p.ListOrgTokens(); return e },
wantMethod: "GET",
wantPath: "/org/tokens",
},
{
name: "CreateOrgToken",
reply: `{"id":"t1","auth_token":"secret"}`,
call: func(p *Platform) error { _, e := p.CreateOrgToken("ci"); return e },
wantMethod: "POST",
wantPath: "/org/tokens",
wantBody: `{"name":"ci"}`,
},
{
name: "RevokeOrgToken",
reply: ``,
call: func(p *Platform) error { return p.RevokeOrgToken("t1") },
wantMethod: "DELETE",
wantPath: "/org/tokens/t1",
},
{
name: "PauseWorkspace",
reply: ``,
call: func(p *Platform) error { return p.PauseWorkspace("ws_1") },
wantMethod: "POST",
wantPath: "/workspaces/ws_1/pause",
},
{
name: "ResumeWorkspace",
reply: ``,
call: func(p *Platform) error { return p.ResumeWorkspace("ws_1") },
wantMethod: "POST",
wantPath: "/workspaces/ws_1/resume",
},
{
name: "GetBudget",
reply: `{"budget_limits":{}}`,
call: func(p *Platform) error { _, e := p.GetBudget("ws_1"); return e },
wantMethod: "GET",
wantPath: "/workspaces/ws_1/budget",
},
{
name: "SetBudget",
reply: `{}`,
call: func(p *Platform) error {
v := int64(50000)
_, e := p.SetBudget("ws_1", map[string]*int64{"monthly": &v})
return e
},
wantMethod: "PATCH",
wantPath: "/workspaces/ws_1/budget",
wantBody: `{"budget_limits":{"monthly":50000}}`,
},
{
name: "SetBudget_clear",
reply: `{}`,
call: func(p *Platform) error { _, e := p.SetBudget("ws_1", map[string]*int64{"daily": nil}); return e },
wantMethod: "PATCH",
wantPath: "/workspaces/ws_1/budget",
wantBody: `{"budget_limits":{"daily":null}}`,
},
{
name: "SetBillingMode",
reply: `{}`,
call: func(p *Platform) error { _, e := p.SetBillingMode("ws_1", "byok"); return e },
wantMethod: "PUT",
wantPath: "/admin/workspaces/ws_1/llm-billing-mode",
wantBody: `{"mode":"byok"}`,
},
{
name: "SetBillingMode_clear",
reply: `{}`,
call: func(p *Platform) error { _, e := p.SetBillingMode("ws_1", ""); return e },
wantMethod: "PUT",
wantPath: "/admin/workspaces/ws_1/llm-billing-mode",
wantBody: `{"mode":null}`,
},
{
name: "MintWorkspaceToken",
reply: `{"auth_token":"x","workspace_id":"ws_1"}`,
call: func(p *Platform) error { _, e := p.MintWorkspaceToken("ws_1"); return e },
wantMethod: "POST",
wantPath: "/workspaces/ws_1/tokens",
},
{
name: "ListWorkspaceSecrets",
reply: `[]`,
call: func(p *Platform) error { _, e := p.ListWorkspaceSecrets("ws_1"); return e },
wantMethod: "GET",
wantPath: "/workspaces/ws_1/secrets",
},
{
name: "SetWorkspaceSecret",
reply: `{}`,
call: func(p *Platform) error { return p.SetWorkspaceSecret("ws_1", "K", "V") },
wantMethod: "POST",
wantPath: "/workspaces/ws_1/secrets",
wantBody: `{"key":"K","value":"V"}`,
},
{
name: "DeleteWorkspaceSecret",
reply: `{}`,
call: func(p *Platform) error { return p.DeleteWorkspaceSecret("ws_1", "K") },
wantMethod: "DELETE",
wantPath: "/workspaces/ws_1/secrets/K",
},
{
name: "ListOrgSecrets",
reply: `[]`,
call: func(p *Platform) error { _, e := p.ListOrgSecrets(); return e },
wantMethod: "GET",
wantPath: "/settings/secrets",
},
{
name: "SetOrgSecret",
reply: `{}`,
call: func(p *Platform) error { return p.SetOrgSecret("K", "V") },
wantMethod: "POST",
wantPath: "/settings/secrets",
wantBody: `{"key":"K","value":"V"}`,
},
{
name: "DeleteOrgSecret",
reply: `{}`,
call: func(p *Platform) error { return p.DeleteOrgSecret("K") },
wantMethod: "DELETE",
wantPath: "/settings/secrets/K",
},
{
name: "ListTemplates",
reply: `[]`,
call: func(p *Platform) error { _, e := p.ListTemplates(); return e },
wantMethod: "GET",
wantPath: "/templates",
},
{
name: "ImportTemplate",
reply: `{}`,
call: func(p *Platform) error { _, e := p.ImportTemplate("t", map[string]string{"org.yaml": "x"}); return e },
wantMethod: "POST",
wantPath: "/templates/import",
wantBody: `{"files":{"org.yaml":"x"},"name":"t"}`,
},
{
name: "RefreshTemplates",
reply: `{}`,
call: func(p *Platform) error { _, e := p.RefreshTemplates(); return e },
wantMethod: "POST",
wantPath: "/admin/templates/refresh",
},
{
name: "ExportBundle",
reply: `{"name":"b"}`,
call: func(p *Platform) error { _, e := p.ExportBundle("ws_1"); return e },
wantMethod: "GET",
wantPath: "/bundles/export/ws_1",
},
{
name: "ImportBundle",
reply: `{}`,
call: func(p *Platform) error { _, e := p.ImportBundle(json.RawMessage(`{"name":"b"}`)); return e },
wantMethod: "POST",
wantPath: "/bundles/import",
wantBody: `{"name":"b"}`,
},
{
name: "ListEvents",
reply: `[]`,
call: func(p *Platform) error { _, e := p.ListEvents(); return e },
wantMethod: "GET",
wantPath: "/events",
},
{
name: "ListPendingApprovals",
reply: `[]`,
call: func(p *Platform) error { _, e := p.ListPendingApprovals(); return e },
wantMethod: "GET",
wantPath: "/approvals/pending",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var cap capture
srv := newCaptureServer(t, &cap, tc.reply)
defer srv.Close()
p := NewWithAuth(srv.URL, "k", "o")
if err := tc.call(p); err != nil {
t.Fatalf("call: %v", err)
}
if cap.method != tc.wantMethod {
t.Errorf("method = %q, want %q", cap.method, tc.wantMethod)
}
if cap.path != tc.wantPath {
t.Errorf("path = %q, want %q", cap.path, tc.wantPath)
}
if cap.auth != "Bearer k" {
t.Errorf("Authorization = %q, want %q (auth must flow on every verb)", cap.auth, "Bearer k")
}
if tc.wantBody != "" && cap.body != tc.wantBody {
t.Errorf("body = %q, want %q", cap.body, tc.wantBody)
}
})
}
}