a002b412e9
Fix the auth bug FIRST: internal/cmd/http.go runHTTP (and the
internal/client Platform HTTP helpers, which had the same gap) sent NO
Authorization header, so management calls (workspace create/delete,
secrets, tokens, …) 401'd a hardened tenant. Now every request attaches
`Authorization: Bearer $MOLECULE_API_KEY` and, when set,
`X-Molecule-Org-Id: $MOLECULE_ORG_ID` (the tenant TenantGuard routing
gate). Headers are omitted when the env vars are unset so fresh
self-host/dev tenants keep working. Regression test
TestRunHTTP_SetsAuthHeader asserts the header is set and is proven
load-bearing (fails with `Authorization header = ""` when the fix is
reverted).
Add the management verbs (PLATFORM-MANAGEMENT-API.md §5(b)), each wired
to the OpenAPI-documented endpoint at the correct auth tier (verified
against the live workspace-server router.go + handlers and controlplane
orgs handler, since the parallel feat/openapi-management-spec branch
does not exist in molecule-core — reconciled to the actual handler
source instead):
org list|get|create --slug/--name|create --template|export
token list|create|revoke | allowlist
workspace list|get|create|delete|restart|pause|resume
budget|billing-mode|token mint
secret ws list|set|delete
org list|set|delete
template list|import|refresh
bundle export|import
events
approvals
Org-lifecycle verbs target the control plane (MOLECULE_CP_URL, default
= api-url); tenant verbs target the tenant host with the Org API Key.
All verbs honor --json (and existing -o table|json|yaml). Request/
response shapes match the handler structs (budget USD-cents
budget_limits; billing-mode {mode}; org import {dir,mode}; secrets
{key,value}; template import {name,files}; etc.).
Tests: table-driven request-construction tests (method/path/body/auth)
for all 30 management methods against an httptest mock, plus
cmd-layer branch tests (budget flag→limits, billing-mode validation,
template file mapping, --json resolution, CP-url fallback). Existing
workspace/agent/platform commands switched to the authenticated client.
go build ./..., go vet ./..., go test ./... all green; gofmt clean on
edited files. Binary smoke-tested end-to-end: auth headers reach the
server and --json output renders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
88 lines
2.8 KiB
Go
88 lines
2.8 KiB
Go
package cmd
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// TestRunHTTP_SetsAuthHeader is the regression test for the auth bug: before
|
|
// the fix, runHTTP sent NO Authorization header and management calls 401'd a
|
|
// hardened tenant. It must now send `Authorization: Bearer $MOLECULE_API_KEY`
|
|
// and `X-Molecule-Org-Id: $MOLECULE_ORG_ID`.
|
|
func TestRunHTTP_SetsAuthHeader(t *testing.T) {
|
|
t.Setenv("MOLECULE_API_KEY", "test-key-123")
|
|
t.Setenv("MOLECULE_ORG_ID", "org_abc")
|
|
|
|
var gotAuth, gotOrg, gotCT string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
gotOrg = r.Header.Get("X-Molecule-Org-Id")
|
|
gotCT = r.Header.Get("Content-Type")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if _, err := runHTTP("POST", srv.URL+"/x", []byte(`{"a":1}`)); err != nil {
|
|
t.Fatalf("runHTTP: %v", err)
|
|
}
|
|
if want := "Bearer test-key-123"; gotAuth != want {
|
|
t.Errorf("Authorization header = %q, want %q", gotAuth, want)
|
|
}
|
|
if want := "org_abc"; gotOrg != want {
|
|
t.Errorf("X-Molecule-Org-Id header = %q, want %q", gotOrg, want)
|
|
}
|
|
if want := "application/json"; gotCT != want {
|
|
t.Errorf("Content-Type = %q, want %q", gotCT, want)
|
|
}
|
|
}
|
|
|
|
// TestRunHTTP_NoOrgIDWhenUnset confirms the org header is omitted when
|
|
// MOLECULE_ORG_ID is unset (so single-tenant/dev hosts that don't gate on it
|
|
// keep working) — but the bearer is still set.
|
|
func TestRunHTTP_NoOrgIDWhenUnset(t *testing.T) {
|
|
t.Setenv("MOLECULE_API_KEY", "k")
|
|
t.Setenv("MOLECULE_ORG_ID", "")
|
|
|
|
var hadOrg bool
|
|
var gotAuth string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, hadOrg = r.Header["X-Molecule-Org-Id"]
|
|
gotAuth = r.Header.Get("Authorization")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if _, err := runHTTP("GET", srv.URL+"/x", nil); err != nil {
|
|
t.Fatalf("runHTTP: %v", err)
|
|
}
|
|
if hadOrg {
|
|
t.Errorf("X-Molecule-Org-Id should be absent when MOLECULE_ORG_ID is unset")
|
|
}
|
|
if gotAuth != "Bearer k" {
|
|
t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer k")
|
|
}
|
|
}
|
|
|
|
// TestRunHTTP_NoAuthHeaderWhenKeyUnset confirms no empty bearer is sent when
|
|
// MOLECULE_API_KEY is unset (preserves the dev/self-host fail-open path).
|
|
func TestRunHTTP_NoAuthHeaderWhenKeyUnset(t *testing.T) {
|
|
t.Setenv("MOLECULE_API_KEY", "")
|
|
t.Setenv("MOLECULE_ORG_ID", "")
|
|
|
|
var hadAuth bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, hadAuth = r.Header["Authorization"]
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if _, err := runHTTP("GET", srv.URL+"/x", nil); err != nil {
|
|
t.Fatalf("runHTTP: %v", err)
|
|
}
|
|
if hadAuth {
|
|
t.Errorf("Authorization header should be absent when MOLECULE_API_KEY is unset")
|
|
}
|
|
}
|