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>
321 lines
9.6 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|