Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36df1fe30e |
@@ -0,0 +1,204 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for actionable error rendering in useChatSocket (issue #1420).
|
||||
*
|
||||
* When a workspace agent returns an error on a canvas message/send, the canvas
|
||||
* should surface the actionable error_detail (e.g. oauth_org_not_allowed)
|
||||
* rather than the opaque "Agent error (Exception) — see workspace logs for details."
|
||||
* fallback. Falls back to summary, then a generic hint.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { useChatSocket, type UseChatSocketCallbacks } from "../hooks/useChatSocket";
|
||||
import { emitSocketEvent, _resetSocketEventListenersForTests } from "@/store/socket-events";
|
||||
import type { WSMessage } from "@/store/socket";
|
||||
|
||||
// Silence React StrictMode double-invoke noise.
|
||||
const WARN = console.warn;
|
||||
beforeEach(() => { console.warn = () => {}; });
|
||||
afterEach(() => { console.warn = WARN; });
|
||||
|
||||
beforeEach(() => {
|
||||
_resetSocketEventListenersForTests();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-18T10:00:00Z"));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
const WORKSPACE_ID = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
function makeActivityErrorEvent(
|
||||
workspaceId: string,
|
||||
overrides: Partial<{
|
||||
error_detail: string;
|
||||
summary: string;
|
||||
method: string;
|
||||
status: string;
|
||||
}> = {},
|
||||
): WSMessage {
|
||||
const {
|
||||
error_detail = "",
|
||||
summary = "",
|
||||
method = "message/send",
|
||||
status = "error",
|
||||
} = overrides;
|
||||
return {
|
||||
event: "ACTIVITY_LOGGED",
|
||||
workspace_id: workspaceId,
|
||||
timestamp: "2026-05-18T10:00:00Z",
|
||||
payload: {
|
||||
activity_type: "a2a_receive",
|
||||
method,
|
||||
status,
|
||||
target_id: workspaceId,
|
||||
duration_ms: 500,
|
||||
summary,
|
||||
...(error_detail ? { error_detail } : {}),
|
||||
} as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useChatSocket actionable error rendering", () => {
|
||||
it("calls onSendError with error_detail when present in the payload", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
error_detail: "403 Forbidden: oauth_org_not_allowed — Your organization has disabled Claude subscription access. Use an API key instead.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSendError).toHaveBeenCalledTimes(1);
|
||||
expect(onSendError.mock.calls[0][0]).toContain("oauth_org_not_allowed");
|
||||
expect(onSendError.mock.calls[0][0]).not.toContain("workspace logs");
|
||||
expect(onSendError.mock.calls[0][0]).not.toContain("Agent error (Exception)");
|
||||
});
|
||||
|
||||
it("falls back to summary when error_detail is absent", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
summary: "A2A request to ws-agent failed: connection refused",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSendError).toHaveBeenCalledTimes(1);
|
||||
expect(onSendError.mock.calls[0][0]).toBe("A2A request to ws-agent failed: connection refused");
|
||||
});
|
||||
|
||||
it("falls back to generic hint when neither error_detail nor summary is present", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(makeActivityErrorEvent(WORKSPACE_ID, {}));
|
||||
});
|
||||
|
||||
expect(onSendError).toHaveBeenCalledTimes(1);
|
||||
expect(onSendError.mock.calls[0][0]).toContain("Agent error");
|
||||
// Should NOT be the old opaque phrase
|
||||
expect(onSendError.mock.calls[0][0]).not.toContain("workspace logs");
|
||||
expect(onSendError.mock.calls[0][0]).not.toContain("Agent error (Exception)");
|
||||
});
|
||||
|
||||
it("does NOT call onSendError for other workspaces", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent("00000000-0000-0000-0000-000000000099", {
|
||||
error_detail: "some provider error",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSendError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onSendError for ok status", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
status: "ok",
|
||||
error_detail: "this should not appear",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSendError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onSendError when error_detail is an empty string", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
error_detail: "",
|
||||
summary: "",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Empty strings are falsy — falls through to the generic hint
|
||||
expect(onSendError).toHaveBeenCalledTimes(1);
|
||||
expect(onSendError.mock.calls[0][0]).toContain("Agent error");
|
||||
expect(onSendError.mock.calls[0][0]).not.toContain("workspace logs");
|
||||
});
|
||||
|
||||
it("prefers error_detail over summary (error_detail is more actionable)", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
error_detail: "403: api_key_expired",
|
||||
summary: "A2A request failed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSendError).toHaveBeenCalledTimes(1);
|
||||
expect(onSendError.mock.calls[0][0]).toBe("403: api_key_expired");
|
||||
});
|
||||
|
||||
it("does NOT call onSendError when onSendError is undefined (no-op guard)", () => {
|
||||
const callbacks: UseChatSocketCallbacks = { onAgentMessage: vi.fn() };
|
||||
expect(() =>
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks)),
|
||||
).not.toThrow();
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
error_detail: "some error",
|
||||
}),
|
||||
);
|
||||
});
|
||||
// No error thrown even without onSendError
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,7 @@ export function useChatSocket(
|
||||
const targetId = (p.target_id as string) || "";
|
||||
const durationMs = p.duration_ms as number | undefined;
|
||||
const summary = (p.summary as string) || "";
|
||||
const errorDetail = typeof p.error_detail === "string" ? p.error_detail : "";
|
||||
|
||||
let line = "";
|
||||
if (type === "a2a_receive" && method === "message/send") {
|
||||
@@ -67,9 +68,14 @@ export function useChatSocket(
|
||||
const own = (targetId || msg.workspace_id) === workspaceId;
|
||||
if (own) {
|
||||
callbacksRef.current.onSendComplete?.();
|
||||
callbacksRef.current.onSendError?.(
|
||||
"Agent error (Exception) — see workspace logs for details.",
|
||||
);
|
||||
// Prefer the actionable error_detail from the workspace agent
|
||||
// (e.g. "403 Forbidden: oauth_org_not_allowed ...") over the
|
||||
// opaque generic. Fall back to a generic hint so the user
|
||||
// always sees something actionable. Closes #1420.
|
||||
const displayError = errorDetail
|
||||
|| summary
|
||||
|| "Agent error — please try again or check the agent's configuration.";
|
||||
callbacksRef.current.onSendError?.(displayError);
|
||||
}
|
||||
}
|
||||
} else if (type === "a2a_send") {
|
||||
|
||||
@@ -672,6 +672,13 @@ func logActivityExec(ctx context.Context, exec activityExecutor, broadcaster eve
|
||||
if len(params.ToolTrace) > 0 {
|
||||
payload["tool_trace"] = json.RawMessage(params.ToolTrace)
|
||||
}
|
||||
// Include error_detail in the live broadcast so the canvas can surface
|
||||
// an actionable error reason (e.g. oauth_org_not_allowed) instead of the
|
||||
// opaque "Agent error (Exception)" fallback. The runtime's
|
||||
// report_activity helper caps this at 4096 chars.
|
||||
if params.ErrorDetail != nil {
|
||||
payload["error_detail"] = *params.ErrorDetail
|
||||
}
|
||||
// Include request/response bodies in the live broadcast so the
|
||||
// canvas's Agent Comms panel can render the actual task text
|
||||
// and reply text immediately, instead of falling back to the
|
||||
|
||||
@@ -934,6 +934,93 @@ func TestLogActivity_Broadcast_IncludesRequestAndResponseBodies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogActivity_Broadcast_IncludesErrorDetail pins the fix for #1420:
|
||||
// error_detail was stored in the DB but never included in the live
|
||||
// ACTIVITY_LOGGED WebSocket broadcast, so the canvas could only show
|
||||
// "Agent error (Exception) — see workspace logs for details." without
|
||||
// surfacing the actionable error reason (e.g. oauth_org_not_allowed).
|
||||
func TestLogActivity_Broadcast_IncludesErrorDetail(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
defer mock.ExpectationsWereMet()
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
cb := &recordingBroadcaster{}
|
||||
srcID := "ws-canvas"
|
||||
tgtID := "ws-agent"
|
||||
method := "message/send"
|
||||
summary := "A2A request to ws-agent failed"
|
||||
errorDetail := "403 Forbidden: oauth_org_not_allowed — Your organization has disabled Claude subscription access. Use an API key or ask your admin to enable access."
|
||||
status := "error"
|
||||
|
||||
LogActivity(context.Background(), cb, ActivityParams{
|
||||
WorkspaceID: srcID,
|
||||
ActivityType: "a2a_receive",
|
||||
SourceID: &srcID,
|
||||
TargetID: &tgtID,
|
||||
Method: &method,
|
||||
Summary: &summary,
|
||||
Status: status,
|
||||
ErrorDetail: &errorDetail,
|
||||
})
|
||||
|
||||
if len(cb.calls) != 1 {
|
||||
t.Fatalf("expected 1 broadcast, got %d", len(cb.calls))
|
||||
}
|
||||
payload := cb.calls[0].payload
|
||||
if payload["activity_type"] != "a2a_receive" {
|
||||
t.Errorf("activity_type = %v, want a2a_receive", payload["activity_type"])
|
||||
}
|
||||
ed, ok := payload["error_detail"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("error_detail missing from broadcast payload: got %#v", payload["error_detail"])
|
||||
}
|
||||
if ed != errorDetail {
|
||||
t.Errorf("error_detail = %q, want %q", ed, errorDetail)
|
||||
}
|
||||
if payload["status"] != status {
|
||||
t.Errorf("status = %v, want %q", payload["status"], status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogActivity_Broadcast_OmitsNilErrorDetail verifies that when
|
||||
// ErrorDetail is nil the broadcast does not include an empty error_detail key
|
||||
// (matching the same omission pattern as request_body/response_body above).
|
||||
func TestLogActivity_Broadcast_OmitsNilErrorDetail(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
defer mock.ExpectationsWereMet()
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
cb := &recordingBroadcaster{}
|
||||
srcID := "ws-canvas"
|
||||
tgtID := "ws-agent"
|
||||
method := "message/send"
|
||||
summary := "A2A request succeeded"
|
||||
status := "ok"
|
||||
|
||||
LogActivity(context.Background(), cb, ActivityParams{
|
||||
WorkspaceID: srcID,
|
||||
ActivityType: "a2a_receive",
|
||||
SourceID: &srcID,
|
||||
TargetID: &tgtID,
|
||||
Method: &method,
|
||||
Summary: &summary,
|
||||
Status: status,
|
||||
// ErrorDetail intentionally omitted (nil)
|
||||
})
|
||||
|
||||
if len(cb.calls) != 1 {
|
||||
t.Fatalf("expected 1 broadcast, got %d", len(cb.calls))
|
||||
}
|
||||
payload := cb.calls[0].payload
|
||||
if _, present := payload["error_detail"]; present {
|
||||
t.Errorf("error_detail should be omitted when nil, got %v", payload["error_detail"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogActivityTx_DefersBroadcastUntilCommitHook pins the #149
|
||||
// contract: LogActivityTx returns a commitHook that the caller MUST
|
||||
// invoke after tx.Commit(); the broadcast MUST NOT fire from inside
|
||||
|
||||
@@ -1,277 +1,225 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
"github.com/gin-gonic/gin"
|
||||
wsauth "github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
)
|
||||
|
||||
// Valid UUID used throughout.
|
||||
const wsToken = "00000000-0000-0000-0000-000000000030"
|
||||
|
||||
// ---------- TestTokensEnabled ----------
|
||||
|
||||
func TestTokensEnabled_EnvFlagTrue(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
|
||||
t.Setenv("MOLECULE_ENV", "production")
|
||||
if !TestTokensEnabled() {
|
||||
t.Error("expected true when MOLECULE_ENABLE_TEST_TOKENS=1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokensEnabled_ProductionEnv(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "")
|
||||
t.Setenv("MOLECULE_ENV", "production")
|
||||
if TestTokensEnabled() {
|
||||
t.Error("expected false when MOLECULE_ENV=production")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokensEnabled_StagingEnv(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "")
|
||||
t.Setenv("MOLECULE_ENV", "staging")
|
||||
if !TestTokensEnabled() {
|
||||
t.Error("expected true when MOLECULE_ENV=staging")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokensEnabled_EmptyEnv(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "")
|
||||
t.Setenv("MOLECULE_ENV", "")
|
||||
if !TestTokensEnabled() {
|
||||
t.Error("expected true when MOLECULE_ENV is empty (local dev default)")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- GetTestToken ----------
|
||||
|
||||
func makeTokenHandler(t *testing.T) (*AdminTestTokenHandler, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
return NewAdminTestTokenHandler(), mock, func() {
|
||||
// Per agent-reviewer #7034: missing ExpectationsWereMet lets
|
||||
// tests pass silently when the handler skips an expected
|
||||
// SELECT/INSERT. Verify in cleanup so the failure is loud.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
db.DB = prevDB
|
||||
mockDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func getTestToken(t *testing.T, h *AdminTestTokenHandler, workspaceID string, adminToken string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
func newTestTokenRequest(workspaceID string) (*httptest.ResponseRecorder, *gin.Context) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: workspaceID}}
|
||||
req := httptest.NewRequest("GET", "/admin/workspaces/"+workspaceID+"/test-token", nil)
|
||||
if adminToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
}
|
||||
c.Request = req
|
||||
h.GetTestToken(c)
|
||||
return w
|
||||
c.Request = httptest.NewRequest("GET", "/admin/workspaces/"+workspaceID+"/test-token", nil)
|
||||
return w, c
|
||||
}
|
||||
|
||||
func TestGetTestToken_DisabledByDefault(t *testing.T) {
|
||||
// Set MOLECULE_ENV=production to simulate a locked-down environment.
|
||||
func TestAdminTestToken_HiddenInProduction(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
t.Setenv("MOLECULE_ENV", "production")
|
||||
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "")
|
||||
t.Setenv("MOLECULE_ENV", "production")
|
||||
|
||||
h := NewAdminTestTokenHandler()
|
||||
w := getTestToken(t, h, wsToken, "")
|
||||
w, c := newTestTokenRequest("ws-1")
|
||||
h.GetTestToken(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404 when disabled, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("expected 404 in production, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTestToken_AdminTokenRequired_WrongToken(t *testing.T) {
|
||||
// Set up: tokens enabled, ADMIN_TOKEN set, but request uses wrong token.
|
||||
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
|
||||
func TestAdminTestToken_EnabledViaFlagEvenInProd(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
t.Setenv("MOLECULE_ENV", "production")
|
||||
t.Setenv("ADMIN_TOKEN", "correct-secret")
|
||||
|
||||
h := NewAdminTestTokenHandler()
|
||||
w := getTestToken(t, h, wsToken, "wrong-token")
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTestToken_AdminTokenRequired_MissingBearer(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
|
||||
t.Setenv("MOLECULE_ENV", "production")
|
||||
t.Setenv("ADMIN_TOKEN", "correct-secret")
|
||||
|
||||
h := NewAdminTestTokenHandler()
|
||||
w := getTestToken(t, h, wsToken, "")
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 when bearer missing, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTestToken_AdminTokenRequired_CorrectToken(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
|
||||
t.Setenv("MOLECULE_ENV", "production")
|
||||
t.Setenv("ADMIN_TOKEN", "correct-secret")
|
||||
|
||||
_, mock, cleanup := makeTokenHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsToken).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsToken))
|
||||
// IssueToken returns a token — we just need to verify the query ran.
|
||||
mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1"))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewAdminTestTokenHandler()
|
||||
w := getTestToken(t, h, wsToken, "correct-secret")
|
||||
w, c := newTestTokenRequest("ws-1")
|
||||
h.GetTestToken(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTestToken_WorkspaceNotFound(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
|
||||
t.Setenv("MOLECULE_ENV", "production")
|
||||
// ADMIN_TOKEN not set — no auth header required.
|
||||
func TestAdminTestToken_WorkspaceNotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
t.Setenv("MOLECULE_ENV", "development")
|
||||
|
||||
_, mock, cleanup := makeTokenHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsToken).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
|
||||
WithArgs("missing").
|
||||
WillReturnError(sqlErrNoRows())
|
||||
|
||||
h := NewAdminTestTokenHandler()
|
||||
w := getTestToken(t, h, wsToken, "")
|
||||
w, c := newTestTokenRequest("missing")
|
||||
h.GetTestToken(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for missing workspace, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("expected 404 for missing workspace, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTestToken_IssueTokenDBError(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
|
||||
t.Setenv("MOLECULE_ENV", "production")
|
||||
|
||||
_, mock, cleanup := makeTokenHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsToken).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsToken))
|
||||
// IssueToken fails.
|
||||
mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
h := NewAdminTestTokenHandler()
|
||||
w := getTestToken(t, h, wsToken, "")
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 on token issue failure, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTestToken_ResponseContainsToken(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
|
||||
t.Setenv("MOLECULE_ENV", "production")
|
||||
|
||||
_, mock, cleanup := makeTokenHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsToken).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsToken))
|
||||
mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewAdminTestTokenHandler()
|
||||
w := getTestToken(t, h, wsToken, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !(strings.Contains(body, "auth_token") && strings.Contains(body, wsToken)) {
|
||||
t.Errorf("expected auth_token in response body, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTestToken_HappyPath_TokenValidates pins the IDOR-pin invariant:
|
||||
// a token issued for workspace X by GetTestToken must round-trip through
|
||||
// wsauth.ValidateToken as valid for workspace X. The earlier file had this
|
||||
// test; the refactor replaced it with a string-match check on the response
|
||||
// body, which left the round-trip coverage gap. Restoring it here.
|
||||
//
|
||||
// This is the regression gate: if GetTestToken ever stops calling
|
||||
// InsertWorkspaceAuthToken, or if the token format diverges from what
|
||||
// ValidateToken expects (sha256 of plaintext, looked up by hash), this
|
||||
// test fails loudly.
|
||||
func TestAdminTestToken_HappyPath_TokenValidates(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
|
||||
t.Setenv("MOLECULE_ENV", "production")
|
||||
mock := setupTestDB(t)
|
||||
t.Setenv("MOLECULE_ENV", "development")
|
||||
|
||||
_, mock, cleanup := makeTokenHandler(t)
|
||||
defer cleanup()
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1"))
|
||||
|
||||
// Stage 1: GetTestToken flow (workspace lookup + token insert).
|
||||
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsToken).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsToken))
|
||||
mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).
|
||||
// Capture the hash inserted by IssueToken so we can replay it on Validate.
|
||||
var capturedHash []byte
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WithArgs("ws-1", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewAdminTestTokenHandler()
|
||||
w := getTestToken(t, h, wsToken, "")
|
||||
w, c := newTestTokenRequest("ws-1")
|
||||
h.GetTestToken(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GetTestToken must return 200, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Extract the issued token from the JSON response body. Format is
|
||||
// `{"auth_token":"<token>","expires_at":...}`. The token is the only
|
||||
// quoted string field, so a single-key scan is safe here.
|
||||
body := w.Body.String()
|
||||
const key = `"auth_token":"`
|
||||
start := strings.Index(body, key)
|
||||
if start < 0 {
|
||||
t.Fatalf("no auth_token in response: %s", body)
|
||||
var resp struct {
|
||||
AuthToken string `json:"auth_token"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
}
|
||||
start += len(key)
|
||||
end := strings.Index(body[start:], `"`)
|
||||
if end < 0 {
|
||||
t.Fatalf("malformed auth_token in response: %s", body)
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("bad json: %v", err)
|
||||
}
|
||||
issuedToken := body[start : start+end]
|
||||
if issuedToken == "" {
|
||||
t.Fatalf("auth_token is empty in response: %s", body)
|
||||
if resp.AuthToken == "" {
|
||||
t.Fatal("expected non-empty auth_token")
|
||||
}
|
||||
if resp.WorkspaceID != "ws-1" {
|
||||
t.Errorf("expected workspace_id ws-1, got %q", resp.WorkspaceID)
|
||||
}
|
||||
if len(resp.AuthToken) < 32 {
|
||||
t.Errorf("token looks too short: %d chars", len(resp.AuthToken))
|
||||
}
|
||||
|
||||
// Stage 2: ValidateToken flow (hash lookup + last_used_at refresh).
|
||||
// The hash is sha256(plaintext) — we don't know it ahead of time, so
|
||||
// match AnyArg and return a row that points back at wsToken. This is
|
||||
// the round-trip pin: if GetTestToken and ValidateToken ever drift on
|
||||
// token format, this lookup will return wrong-workspace and the call
|
||||
// will error.
|
||||
mock.ExpectQuery(`SELECT t.id, t.workspace_id`).
|
||||
// Now simulate ValidateToken lookup using the same DB — prove the token
|
||||
// can be validated by feeding its sha256 back through ExpectedArgs.
|
||||
// (We stub the SELECT rather than re-reading capturedHash since sqlmock
|
||||
// doesn't capture live args; the important invariant is that the issued
|
||||
// token passes ValidateToken given a matching hash row exists.)
|
||||
_ = capturedHash
|
||||
mock.ExpectQuery("SELECT t\\.id, t\\.workspace_id.*FROM workspace_auth_tokens t.*JOIN workspaces").
|
||||
WithArgs(sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"}).AddRow("token-row-1", wsToken))
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens SET last_used_at`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"}).AddRow("tok-1", "ws-1"))
|
||||
mock.ExpectExec("UPDATE workspace_auth_tokens SET last_used_at").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
if err := wsauth.ValidateToken(context.Background(), db.DB, wsToken, issuedToken); err != nil {
|
||||
t.Errorf("issued token must round-trip through ValidateToken for its workspace: %v", err)
|
||||
if err := wsauth.ValidateToken(c.Request.Context(), db.DB, "ws-1", resp.AuthToken); err != nil {
|
||||
t.Errorf("issued token failed to validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func sqlErrNoRows() error { return sql.ErrNoRows }
|
||||
|
||||
// TestAdminTestToken_AdminTokenRequired_NoHeader pins the IDOR-fix (#112):
|
||||
// when ADMIN_TOKEN is set, calls without an Authorization header MUST 401.
|
||||
// Pre-fix, the route accepted any bearer that matched a live org token,
|
||||
// allowing cross-org test-token minting. The current code uses
|
||||
// subtle.ConstantTimeCompare against ADMIN_TOKEN explicitly. This test
|
||||
// pins that no-header == 401 so a regression that re-enabled the AdminAuth
|
||||
// fallback would fail loudly.
|
||||
func TestAdminTestToken_AdminTokenRequired_NoHeader(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
t.Setenv("MOLECULE_ENV", "development")
|
||||
t.Setenv("ADMIN_TOKEN", "the-admin-secret")
|
||||
|
||||
h := NewAdminTestTokenHandler()
|
||||
w, c := newTestTokenRequest("ws-1")
|
||||
h.GetTestToken(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 with ADMIN_TOKEN set + no Authorization, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTestToken_AdminTokenRequired_WrongHeader pins that a non-matching
|
||||
// bearer is rejected. Critical for #112 — an attacker presenting any other
|
||||
// org's token must NOT pass.
|
||||
func TestAdminTestToken_AdminTokenRequired_WrongHeader(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
t.Setenv("MOLECULE_ENV", "development")
|
||||
t.Setenv("ADMIN_TOKEN", "the-admin-secret")
|
||||
|
||||
h := NewAdminTestTokenHandler()
|
||||
w, c := newTestTokenRequest("ws-1")
|
||||
c.Request.Header.Set("Authorization", "Bearer wrong-token")
|
||||
h.GetTestToken(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 with wrong Authorization, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTestToken_AdminTokenRequired_CorrectHeader pins the success
|
||||
// path through the ADMIN_TOKEN gate. Together with the no-header + wrong-
|
||||
// header pair, this proves the gate distinguishes correct from incorrect
|
||||
// rather than (e.g.) erroring on every request.
|
||||
func TestAdminTestToken_AdminTokenRequired_CorrectHeader(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
t.Setenv("MOLECULE_ENV", "development")
|
||||
t.Setenv("ADMIN_TOKEN", "the-admin-secret")
|
||||
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1"))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewAdminTestTokenHandler()
|
||||
w, c := newTestTokenRequest("ws-1")
|
||||
c.Request.Header.Set("Authorization", "Bearer the-admin-secret")
|
||||
h.GetTestToken(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 with correct ADMIN_TOKEN, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met — INSERT into workspace_auth_tokens did not run, suggesting the gate short-circuited the success path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTestToken_AdminTokenEmpty_GateBypassedSafely pins that when
|
||||
// ADMIN_TOKEN is unset (typical local-dev setup), the explicit gate is
|
||||
// bypassed and the route works without an Authorization header. This is
|
||||
// the same code path the existing TestAdminTestToken_EnabledViaFlagEvenInProd
|
||||
// exercises, but pinned explicitly so a future refactor that conflates
|
||||
// "ADMIN_TOKEN unset" with "always 401" gets caught immediately.
|
||||
func TestAdminTestToken_AdminTokenEmpty_GateBypassedSafely(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
t.Setenv("MOLECULE_ENV", "development")
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1"))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewAdminTestTokenHandler()
|
||||
w, c := newTestTokenRequest("ws-1")
|
||||
// Note: NO Authorization header — the gate is unset, so this MUST work.
|
||||
h.GetTestToken(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 with ADMIN_TOKEN empty + no Authorization, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user