From 5a28454ca4c17df901fcd308525170fb2234460b Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 19 Apr 2026 03:41:16 -0700 Subject: [PATCH] =?UTF-8?q?test(ws-server):=20cover=20CPProvisioner=20?= =?UTF-8?q?=E2=80=94=20auth,=20env=20fallback,=20error=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge audit flagged cp_provisioner.go as the only new file from the canary/C1 work without test coverage. Fills the gap: - NewCPProvisioner_RequiresOrgID — self-hosted without MOLECULE_ORG_ID refuses to construct (avoids silent phone-home to prod CP). - NewCPProvisioner_FallsBackToProvisionSharedSecret — the operator ergonomics of using one env-var name on both sides of the wire. - AuthHeader noop + happy path — bearer only set when secret is set. - Start_HappyPath — end-to-end POST to stubbed CP, bearer forwarded, instance_id parsed out of response. - Start_Non201ReturnsStructuredError — when CP returns structured {"error":"…"}, that message surfaces to the caller. - Start_NoStructuredErrorFallsBackToSize — regression gate for the anti-log-leak change from PR #980: raw upstream body must NOT appear in the error, only the byte count. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../provisioner/cp_provisioner_test.go | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 workspace-server/internal/provisioner/cp_provisioner_test.go diff --git a/workspace-server/internal/provisioner/cp_provisioner_test.go b/workspace-server/internal/provisioner/cp_provisioner_test.go new file mode 100644 index 00000000..ce49a352 --- /dev/null +++ b/workspace-server/internal/provisioner/cp_provisioner_test.go @@ -0,0 +1,150 @@ +package provisioner + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestNewCPProvisioner_RequiresOrgID — self-hosted deployments don't +// have a MOLECULE_ORG_ID, and the provisioner must refuse to construct +// rather than silently phone home to the prod CP with an empty tenant. +func TestNewCPProvisioner_RequiresOrgID(t *testing.T) { + t.Setenv("MOLECULE_ORG_ID", "") + if _, err := NewCPProvisioner(); err == nil { + t.Error("want error when MOLECULE_ORG_ID is unset, got nil") + } +} + +// TestNewCPProvisioner_FallsBackToProvisionSharedSecret — operators +// may set PROVISION_SHARED_SECRET on both sides of the wire with a +// single value; the tenant accepts that name as a fallback for +// MOLECULE_CP_SHARED_SECRET. The fallback is documented in +// NewCPProvisioner; this test is the regression gate. +func TestNewCPProvisioner_FallsBackToProvisionSharedSecret(t *testing.T) { + t.Setenv("MOLECULE_ORG_ID", "org-abc") + t.Setenv("MOLECULE_CP_SHARED_SECRET", "") + t.Setenv("PROVISION_SHARED_SECRET", "from-fallback") + + p, err := NewCPProvisioner() + if err != nil { + t.Fatalf("NewCPProvisioner: %v", err) + } + if p.sharedSecret != "from-fallback" { + t.Errorf("sharedSecret = %q, want %q", p.sharedSecret, "from-fallback") + } +} + +// TestAuthHeader_NoopWhenSecretEmpty — the self-hosted path that +// doesn't gate /cp/workspaces/* must not add a stray Authorization +// header (bearer-like content would be surprising to non-bearer +// intermediaries). +func TestAuthHeader_NoopWhenSecretEmpty(t *testing.T) { + p := &CPProvisioner{sharedSecret: ""} + req := httptest.NewRequest("GET", "http://x/", nil) + p.authHeader(req) + if got := req.Header.Get("Authorization"); got != "" { + t.Errorf("Authorization set to %q with empty secret; want unset", got) + } +} + +// TestAuthHeader_SetsBearerWhenSecretSet — happy path. +func TestAuthHeader_SetsBearerWhenSecretSet(t *testing.T) { + p := &CPProvisioner{sharedSecret: "the-secret"} + req := httptest.NewRequest("GET", "http://x/", nil) + p.authHeader(req) + if got := req.Header.Get("Authorization"); got != "Bearer the-secret" { + t.Errorf("Authorization = %q, want %q", got, "Bearer the-secret") + } +} + +// TestStart_HappyPath — Start posts to the stubbed CP, passes the +// bearer, and parses the returned instance_id. +func TestStart_HappyPath(t *testing.T) { + var sawBearer string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawBearer = r.Header.Get("Authorization") + if r.URL.Path != "/cp/workspaces/provision" { + t.Errorf("unexpected path %s", r.URL.Path) + } + // Verify the request body round-trips our fields + var body cpProvisionRequest + _ = json.NewDecoder(r.Body).Decode(&body) + if body.WorkspaceID != "ws-1" || body.Runtime != "python" { + t.Errorf("body mismatch: %+v", body) + } + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"instance_id":"i-abc123","state":"pending"}`) + })) + defer srv.Close() + + p := &CPProvisioner{ + baseURL: srv.URL, + orgID: "org-1", + sharedSecret: "s3cret", + httpClient: srv.Client(), + } + + id, err := p.Start(context.Background(), WorkspaceConfig{ + WorkspaceID: "ws-1", Runtime: "python", Tier: 1, PlatformURL: "http://tenant", + }) + if err != nil { + t.Fatalf("Start: %v", err) + } + if id != "i-abc123" { + t.Errorf("instance id = %q, want i-abc123", id) + } + if sawBearer != "Bearer s3cret" { + t.Errorf("server saw Authorization = %q, want Bearer s3cret", sawBearer) + } +} + +// TestStart_Non201ReturnsStructuredError — when CP returns 401 with a +// structured {"error":"..."} body, Start surfaces that error message. +// Verifies the defense against log-leaking raw upstream bodies. +func TestStart_Non201ReturnsStructuredError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"error":"invalid credentials"}`) + })) + defer srv.Close() + + p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()} + + _, err := p.Start(context.Background(), WorkspaceConfig{WorkspaceID: "ws-1", Runtime: "py"}) + if err == nil { + t.Fatal("expected error on 401, got nil") + } + if !strings.Contains(err.Error(), "invalid credentials") { + t.Errorf("error message %q should include upstream error field", err.Error()) + } +} + +// TestStart_NoStructuredErrorFallsBackToSize — the anti-leak path: +// when upstream returns non-JSON, we refuse to log the body and +// report only the byte count, preventing Authorization header echoes +// from landing in our logs. +func TestStart_NoStructuredErrorFallsBackToSize(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, "raw proxy error page, could contain echoed headers") + })) + defer srv.Close() + + p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()} + + _, err := p.Start(context.Background(), WorkspaceConfig{WorkspaceID: "ws-1", Runtime: "py"}) + if err == nil { + t.Fatal("expected error on 500, got nil") + } + if strings.Contains(err.Error(), "raw proxy error") { + t.Errorf("error leaked raw body: %q", err.Error()) + } + if !strings.Contains(err.Error(), "