Files
Claude Opus 4.8 953f016549 feat: workspace migrate-provider + migration-status CLI commands
Add cross-cloud compute-provider migration to the CLI, closing the gap
where the canvas can migrate a workspace's compute box across clouds
(AWS <-> Hetzner <-> GCP) but the CLI could not (molecule-mcp-server#64).

- 'molecule workspace migrate-provider <id> --to <p> [--from <p>] --confirm'
  POSTs to the CP-admin POST /api/v1/admin/workspaces/:id/migrate-provider
  endpoint via cpAdminClient() (CP-admin bearer + MOLECULE_CP_URL — the
  tenant Org API Key has no standing on the control plane). Validates the
  provider enum + from!=to client-side, requires --from-instance-id for
  non-AWS sources, and refuses without --confirm (a real migration mutates
  two clouds — never auto-confirmed).
- 'molecule workspace migration-status <id>' GETs the same path and prints
  the {migration:{state,from_provider,to_provider,detail,...}, terminal}
  record.

New client methods MigrateProvider / GetMigrationStatus + MigrateProviderRequest
mirror the existing CP-admin org methods. Tests cover the URL/method/body/auth
(client httptest capture) and the provider validation helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:52:27 +00:00

482 lines
16 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")
}
}
// TestPathSegmentEscaping proves caller-supplied IDs are url.PathEscape'd into
// their path segment, so an ID containing '/', '?' or '#' cannot alter the
// endpoint, slip into the query, or open a fragment. Covers the platform.go +
// management.go methods that interpolate IDs.
func TestPathSegmentEscaping(t *testing.T) {
// An ID engineered to break naive concatenation: a slash to escape the
// segment, a '?' to start a bogus query, a '#' to start a fragment.
const evil = "ws/../admin?x=1#frag"
cases := []struct {
name string
reply string
call func(p *Platform) error
wantPath string // decoded path the server must see (single segment intact)
}{
{"GetWorkspace", `{}`, func(p *Platform) error { _, e := p.GetWorkspace(evil); return e }, "/workspaces/" + evil},
{"RestartWorkspace", `{}`, func(p *Platform) error { return p.RestartWorkspace(evil) }, "/workspaces/" + evil + "/restart"},
{"ListWorkspaceAgents", `[]`, func(p *Platform) error { _, e := p.ListWorkspaceAgents(evil); return e }, "/workspaces/" + evil + "/agents"},
{"GetAgent", `{}`, func(p *Platform) error { _, e := p.GetAgent(evil); return e }, "/agents/" + evil},
{"GetPeers", `[]`, func(p *Platform) error { _, e := p.GetPeers(evil); return e }, "/registry/" + evil + "/peers"},
{"GetDelegations", `[]`, func(p *Platform) error { _, e := p.GetDelegations(evil); return e }, "/workspaces/" + evil + "/delegations"},
{"PauseWorkspace", `{}`, func(p *Platform) error { return p.PauseWorkspace(evil) }, "/workspaces/" + evil + "/pause"},
}
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)
}
// net/http decodes the escaped path back to the original segment,
// so the server sees the full ID as one intact path — not split by
// '/', and with '?'/'#' NOT promoted to query/fragment.
if cap.path != tc.wantPath {
t.Errorf("decoded path = %q, want %q", cap.path, tc.wantPath)
}
if cap.query != "" {
t.Errorf("query = %q, want empty (ID '?' must not leak into the query)", cap.query)
}
})
}
// DeleteWorkspace appends its own ?confirm=true; the escaped ID must not
// inject extra query params.
t.Run("DeleteWorkspace", func(t *testing.T) {
var cap capture
srv := newCaptureServer(t, &cap, `{}`)
defer srv.Close()
p := NewWithAuth(srv.URL, "k", "o")
if err := p.DeleteWorkspace(evil); err != nil {
t.Fatalf("DeleteWorkspace: %v", err)
}
if cap.path != "/workspaces/"+evil {
t.Errorf("decoded path = %q, want %q", cap.path, "/workspaces/"+evil)
}
if cap.query != "confirm=true" {
t.Errorf("query = %q, want exactly confirm=true (ID must not inject params)", cap.query)
}
})
}
// 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}`,
},
{
// Body must be an empty JSON object ({}), never the literal `null`
// that a nil body would marshal to (a struct/map handler can reject
// null). {} matches sibling tooling.
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",
wantBody: `{}`,
},
{
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)
}
})
}
}
// TestMigrateProvider proves MigrateProvider POSTs {from,to,confirm,...} to the
// CP-admin migrate-provider route with the bearer, and that the workspace id is
// PathEscape'd into its own segment.
func TestMigrateProvider(t *testing.T) {
var cap capture
srv := newCaptureServer(t, &cap, `{"status":"migration_started","from":"aws","to":"hetzner"}`)
defer srv.Close()
p := NewWithAuth(srv.URL, "cp-admin-bearer", "")
raw, err := p.MigrateProvider("ws/1", MigrateProviderRequest{
From: "aws", To: "hetzner", Confirm: true,
})
if err != nil {
t.Fatalf("MigrateProvider: %v", err)
}
if cap.method != "POST" {
t.Errorf("method = %q, want POST", cap.method)
}
// cap.path is the DECODED r.URL.Path (same convention as
// TestPathSegmentEscaping): the id's '/' stays inside its segment rather
// than breaking the route, which proves it was PathEscape'd on the wire.
if cap.path != "/api/v1/admin/workspaces/ws/1/migrate-provider" {
t.Errorf("path = %q (id must be a single escaped segment)", cap.path)
}
if cap.auth != "Bearer cp-admin-bearer" {
t.Errorf("Authorization = %q", cap.auth)
}
var sent map[string]any
if err := json.Unmarshal([]byte(cap.body), &sent); err != nil {
t.Fatalf("body not json: %v (%s)", err, cap.body)
}
if sent["from"] != "aws" || sent["to"] != "hetzner" || sent["confirm"] != true {
t.Errorf("body = %s, want from=aws to=hetzner confirm=true", cap.body)
}
// omitempty: no from_instance_id/org_id/runtime sent when unset.
if _, ok := sent["from_instance_id"]; ok {
t.Errorf("from_instance_id should be omitted when empty (body=%s)", cap.body)
}
var resp map[string]any
if err := json.Unmarshal(raw, &resp); err != nil || resp["status"] != "migration_started" {
t.Errorf("response not propagated: %s", raw)
}
}
// TestMigrateProviderForwardsFromInstanceID proves from_instance_id is sent when
// provided (required for non-AWS sources).
func TestMigrateProviderForwardsFromInstanceID(t *testing.T) {
var cap capture
srv := newCaptureServer(t, &cap, `{"status":"migration_started"}`)
defer srv.Close()
p := NewWithAuth(srv.URL, "k", "")
if _, err := p.MigrateProvider("ws1", MigrateProviderRequest{
From: "gcp", To: "aws", Confirm: true, FromInstanceID: "gcp-box-9",
}); err != nil {
t.Fatalf("MigrateProvider: %v", err)
}
var sent map[string]any
_ = json.Unmarshal([]byte(cap.body), &sent)
if sent["from_instance_id"] != "gcp-box-9" {
t.Errorf("from_instance_id = %v, want gcp-box-9 (body=%s)", sent["from_instance_id"], cap.body)
}
}
// TestGetMigrationStatus proves GetMigrationStatus GETs the migrate-provider
// route and propagates the record body.
func TestGetMigrationStatus(t *testing.T) {
var cap capture
srv := newCaptureServer(t, &cap, `{"migration":{"state":"provisioning_target"},"terminal":false}`)
defer srv.Close()
p := NewWithAuth(srv.URL, "k", "")
raw, err := p.GetMigrationStatus("ws1")
if err != nil {
t.Fatalf("GetMigrationStatus: %v", err)
}
if cap.method != "GET" {
t.Errorf("method = %q, want GET", cap.method)
}
if cap.path != "/api/v1/admin/workspaces/ws1/migrate-provider" {
t.Errorf("path = %q", cap.path)
}
var resp struct {
Migration map[string]any `json:"migration"`
Terminal bool `json:"terminal"`
}
if err := json.Unmarshal(raw, &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Migration["state"] != "provisioning_target" {
t.Errorf("state not propagated: %s", raw)
}
}