forked from molecule-ai/molecule-core
The platform's GET /workspaces/:id/transcript proxy was constructing the
outbound request without an Authorization header. The workspace's /transcript
endpoint (hardened in #287/#328) fails-closed when the header is absent,
so every transcript call in production returned 401 from the workspace.
Fix: after WorkspaceAuth validates the incoming bearer token, the handler
now forwards it verbatim via req.Header.Set("Authorization", ...).
Forwarding is safe — the token has already been validated by the middleware.
Tests:
- TestTranscript_ForwardsAuthHeader: was t.Skip'd as a bug marker; now
active. Verifies the Authorization header reaches the workspace stub.
- TestTranscript_NoAuthHeader_PassesThrough: new. Verifies that a missing
header produces no synthetic Authorization on the upstream call, and the
workspace 401 is faithfully relayed.
Identified by QA audit 2026-04-16.
Co-authored-by: QA Engineer <qa-engineer@molecule-ai.internal>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1ff544eba8
commit
04c3911c0e
@ -93,6 +93,15 @@ func (h *TranscriptHandler) Get(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Forward the caller's bearer token so the workspace /transcript handler
|
||||
// (secured by #287/#328) can authenticate the proxied request.
|
||||
// WorkspaceAuth has already validated the token above — forwarding is safe.
|
||||
// Without this the workspace fails-closed (401) and the transcript feature
|
||||
// is non-functional in production. Fixes the QA-2026-04-16 finding.
|
||||
if auth := c.GetHeader("Authorization"); auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
|
||||
resp, err := h.httpClient.Do(req)
|
||||
if err != nil {
|
||||
// Log the real error server-side (includes the target URL), but
|
||||
|
||||
@ -241,3 +241,104 @@ func TestTranscript_UnreachableWorkspaceReturns502(t *testing.T) {
|
||||
t.Errorf("expected 502, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscript_ForwardsAuthHeader is a regression guard for the fix where
|
||||
// TranscriptHandler.Get was not forwarding the Authorization header to the
|
||||
// workspace's /transcript endpoint (QA finding 2026-04-16).
|
||||
//
|
||||
// The workspace's /transcript endpoint (secured by #287/#328) requires a valid
|
||||
// `Authorization: Bearer <token>` header — it fails-closed when the header
|
||||
// is absent. The platform's WorkspaceAuth middleware validates the token before
|
||||
// the handler runs; forwarding it to the workspace is correct and safe.
|
||||
//
|
||||
// Fix applied: after constructing the outbound request, the handler now calls
|
||||
// req.Header.Set("Authorization", c.GetHeader("Authorization"))
|
||||
// This test verifies the fix and acts as a regression guard.
|
||||
func TestTranscript_ForwardsAuthHeader(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
h := NewTranscriptHandler()
|
||||
|
||||
const testToken = "Bearer test-workspace-token-abc123"
|
||||
|
||||
var receivedAuth string
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedAuth = r.Header.Get("Authorization")
|
||||
// Simulate the workspace's #328 fail-closed behaviour: reject missing auth.
|
||||
if receivedAuth == "" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"runtime":"claude-code","supported":true,"lines":[],"cursor":0,"more":false}`))
|
||||
}))
|
||||
defer stub.Close()
|
||||
|
||||
wsID := expectWorkspaceURLLookup(mock, stub.URL)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: wsID}}
|
||||
// Simulate a request that has already passed WorkspaceAuth middleware —
|
||||
// the bearer token is present and valid on the incoming request.
|
||||
req := httptest.NewRequest("GET", "/workspaces/"+wsID+"/transcript", nil)
|
||||
req.Header.Set("Authorization", testToken)
|
||||
c.Request = req
|
||||
h.Get(c)
|
||||
|
||||
// The proxy must forward the bearer token so the workspace accepts the call.
|
||||
if receivedAuth == "" {
|
||||
t.Error("TranscriptHandler did not forward Authorization header — workspace would return 401")
|
||||
}
|
||||
if receivedAuth != testToken {
|
||||
t.Errorf("Authorization header mismatch: forwarded %q, want %q", receivedAuth, testToken)
|
||||
}
|
||||
if w.Code == http.StatusUnauthorized {
|
||||
t.Errorf("workspace returned 401: transcript proxy did not authenticate; auth forwarded: %q", receivedAuth)
|
||||
}
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscript_NoAuthHeader_PassesThrough verifies that a request with no
|
||||
// Authorization header (e.g. unauthenticated local-dev call that somehow
|
||||
// bypassed WorkspaceAuth) results in no Authorization header on the upstream
|
||||
// request. The workspace will return 401 in this case, which the proxy
|
||||
// faithfully relays — no silent upgrade of privilege.
|
||||
func TestTranscript_NoAuthHeader_PassesThrough(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
h := NewTranscriptHandler()
|
||||
|
||||
var receivedAuth string
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedAuth = r.Header.Get("Authorization")
|
||||
if receivedAuth == "" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"runtime":"claude-code","supported":true,"lines":[]}`))
|
||||
}))
|
||||
defer stub.Close()
|
||||
|
||||
wsID := expectWorkspaceURLLookup(mock, stub.URL)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: wsID}}
|
||||
// No Authorization header on the request.
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/"+wsID+"/transcript", nil)
|
||||
h.Get(c)
|
||||
|
||||
// Without a token the workspace returns 401; the proxy must relay it faithfully.
|
||||
if receivedAuth != "" {
|
||||
t.Errorf("expected no Authorization forwarded to workspace, got %q", receivedAuth)
|
||||
}
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected proxy to relay workspace 401, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user