Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 335796b0b4 | |||
| 699b5fb275 | |||
| fb2fd20c9e | |||
| 7d2eaa3748 | |||
| 44b78e28c8 | |||
| 330f54d281 | |||
| 4fd6612272 |
@@ -16,7 +16,40 @@ interface TokensTabProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
// The settings panel passes the literal sentinel "global" when no canvas
|
||||
// node is selected. Workspace tokens are inherently per-workspace — there
|
||||
// is no /workspaces/global/tokens endpoint (querying the uuid column with
|
||||
// "global" 500s on Postgres). The org-wide equivalent lives in the
|
||||
// separate "Org API Keys" tab. Mirrors the sentinel-awareness that
|
||||
// api/secrets.ts already has (workspaceId === 'global' → /settings/secrets).
|
||||
const GLOBAL_WORKSPACE_ID = 'global';
|
||||
|
||||
export function TokensTab({ workspaceId }: TokensTabProps) {
|
||||
if (workspaceId === GLOBAL_WORKSPACE_ID) {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink">API Tokens</h3>
|
||||
<p className="text-[10px] text-ink-mid mt-0.5">
|
||||
Bearer tokens for authenticating API calls to this workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center py-6">
|
||||
<p className="text-xs text-ink-mid">Select a workspace node first</p>
|
||||
<p className="text-[10px] text-ink-mid mt-1">
|
||||
Workspace tokens are scoped to a single workspace. Select a node
|
||||
on the canvas to manage its tokens, or use the{' '}
|
||||
<span className="text-accent font-medium">Org API Keys</span> tab
|
||||
for org-wide API keys.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <WorkspaceTokensTab workspaceId={workspaceId} />;
|
||||
}
|
||||
|
||||
function WorkspaceTokensTab({ workspaceId }: TokensTabProps) {
|
||||
const [tokens, setTokens] = useState<Token[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
@@ -302,3 +302,35 @@ describe("TokensTab — error", () => {
|
||||
expect(document.querySelector('[role="status"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── "global" sentinel (no node selected) ────────────────────────────────────
|
||||
//
|
||||
// Regression: SettingsPanel passes the literal "global" when no canvas
|
||||
// node is selected. workspace tokens are per-workspace and there is no
|
||||
// /workspaces/global/tokens endpoint — calling it 500'd
|
||||
// ("invalid input syntax for type uuid: global"). The tab must NOT call
|
||||
// the API in that state and must point the user at the Org API Keys tab.
|
||||
describe("TokensTab — global sentinel (no node selected)", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockApiPost.mockReset();
|
||||
mockApiGet.mockRejectedValue(new Error("should not be called"));
|
||||
});
|
||||
|
||||
it("does not call the API and shows a pointer to Org API Keys", async () => {
|
||||
render(<TokensTab workspaceId="global" />);
|
||||
await flush();
|
||||
expect(mockApiGet).not.toHaveBeenCalled();
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Select a workspace node");
|
||||
expect(document.body.textContent).toContain("Org API Keys");
|
||||
// No error banner, no scary 500 surfacing.
|
||||
expect(document.querySelector(".text-bad")).toBeNull();
|
||||
});
|
||||
|
||||
it("has no create button in the global state", async () => {
|
||||
render(<TokensTab workspaceId="global" />);
|
||||
await flush();
|
||||
expect(document.body.textContent).not.toContain("New Token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,9 +67,21 @@ 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.",
|
||||
);
|
||||
// internal#211/#212: surface the runtime's curated,
|
||||
// user-actionable reason (provider HTTP status + error
|
||||
// code + the provider's own guidance, e.g. a 403 "org
|
||||
// disabled · use an API key / ask your admin"). The
|
||||
// server now includes error_detail in the ACTIVITY_LOGGED
|
||||
// broadcast; fall back to summary, and only as a last
|
||||
// resort to a generic line. The old hardcoded
|
||||
// "Agent error (Exception) — see workspace logs for
|
||||
// details." string pointed at a logs UI that does not
|
||||
// exist and discarded the actionable reason entirely.
|
||||
const detail =
|
||||
(p.error_detail as string) ||
|
||||
(p.summary as string) ||
|
||||
"The agent turn failed but the runtime reported no detail. Retry once; if it repeats the workspace runtime may need a restart.";
|
||||
callbacksRef.current.onSendError?.(detail);
|
||||
}
|
||||
}
|
||||
} else if (type === "a2a_send") {
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for design-tokens.ts constant exports.
|
||||
*
|
||||
* STATUS_CONFIG is tested here directly rather than inside
|
||||
* statusDotClass.test.ts so the constant's full shape (dot, glow, label,
|
||||
* bar per key) is explicitly asserted — not just indirectly via the
|
||||
* statusDotClass helper that consumes its .dot field.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { STATUS_CONFIG } from "../design-tokens";
|
||||
|
||||
const ALL_STATUS_KEYS = [
|
||||
"online",
|
||||
"offline",
|
||||
"paused",
|
||||
"degraded",
|
||||
"failed",
|
||||
"provisioning",
|
||||
"not_configured",
|
||||
] as const;
|
||||
|
||||
describe("STATUS_CONFIG", () => {
|
||||
it("has exactly the expected status keys and no extras", () => {
|
||||
const actual = Object.keys(STATUS_CONFIG).sort();
|
||||
const expected = [...ALL_STATUS_KEYS].sort();
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("every entry has dot, glow, label, and bar fields", () => {
|
||||
for (const key of ALL_STATUS_KEYS) {
|
||||
const entry = STATUS_CONFIG[key];
|
||||
expect(entry, `entry for "${key}"`).toHaveProperty("dot");
|
||||
expect(entry, `entry for "${key}"`).toHaveProperty("glow");
|
||||
expect(entry, `entry for "${key}"`).toHaveProperty("label");
|
||||
expect(entry, `entry for "${key}"`).toHaveProperty("bar");
|
||||
}
|
||||
});
|
||||
|
||||
it("dot, glow, label, bar are all non-empty strings", () => {
|
||||
for (const key of ALL_STATUS_KEYS) {
|
||||
const entry = STATUS_CONFIG[key];
|
||||
for (const field of ["dot", "glow", "label", "bar"] as const) {
|
||||
expect(typeof entry[field], `"${key}".${field}`).toBe("string");
|
||||
// label must be non-empty; others may be empty (e.g. offline.glow = "").
|
||||
if (field === "label") {
|
||||
expect(entry[field].length, `"${key}".${field}`).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('online: dot is emerald, glow is set, label is "Online"', () => {
|
||||
expect(STATUS_CONFIG.online.dot).toBe("bg-emerald-400");
|
||||
expect(STATUS_CONFIG.online.glow).toBe("shadow-emerald-400/50");
|
||||
expect(STATUS_CONFIG.online.label).toBe("Online");
|
||||
expect(STATUS_CONFIG.online.bar).toBe("from-emerald-500/20 to-transparent");
|
||||
});
|
||||
|
||||
it('offline: dot is zinc, glow is empty, label is "Offline"', () => {
|
||||
expect(STATUS_CONFIG.offline.dot).toBe("bg-zinc-500");
|
||||
expect(STATUS_CONFIG.offline.glow).toBe("");
|
||||
expect(STATUS_CONFIG.offline.label).toBe("Offline");
|
||||
expect(STATUS_CONFIG.offline.bar).toBe("from-zinc-600/10 to-transparent");
|
||||
});
|
||||
|
||||
it('paused: dot is indigo, label is "Paused"', () => {
|
||||
expect(STATUS_CONFIG.paused.dot).toBe("bg-indigo-400");
|
||||
expect(STATUS_CONFIG.paused.glow).toBe("");
|
||||
expect(STATUS_CONFIG.paused.label).toBe("Paused");
|
||||
});
|
||||
|
||||
it('degraded: dot is amber with glow, label is "Degraded"', () => {
|
||||
expect(STATUS_CONFIG.degraded.dot).toBe("bg-amber-400");
|
||||
expect(STATUS_CONFIG.degraded.glow).toBe("shadow-amber-400/50");
|
||||
expect(STATUS_CONFIG.degraded.label).toBe("Degraded");
|
||||
});
|
||||
|
||||
it('failed: dot is red with glow, label is "Failed"', () => {
|
||||
expect(STATUS_CONFIG.failed.dot).toBe("bg-red-400");
|
||||
expect(STATUS_CONFIG.failed.glow).toBe("shadow-red-400/50");
|
||||
expect(STATUS_CONFIG.failed.label).toBe("Failed");
|
||||
});
|
||||
|
||||
it('provisioning: dot is sky with pulse animation, label is "Starting"', () => {
|
||||
expect(STATUS_CONFIG.provisioning.dot).toBe("bg-sky-400 motion-safe:animate-pulse");
|
||||
expect(STATUS_CONFIG.provisioning.glow).toBe("shadow-sky-400/50");
|
||||
expect(STATUS_CONFIG.provisioning.label).toBe("Starting");
|
||||
});
|
||||
|
||||
it('not_configured: dot is amber-300 with glow, label is "Not configured"', () => {
|
||||
expect(STATUS_CONFIG.not_configured.dot).toBe("bg-amber-300");
|
||||
expect(STATUS_CONFIG.not_configured.glow).toBe("shadow-amber-300/50");
|
||||
expect(STATUS_CONFIG.not_configured.label).toBe("Not configured");
|
||||
});
|
||||
|
||||
it("is a frozen static map — same key always returns same object reference", () => {
|
||||
for (const key of ALL_STATUS_KEYS) {
|
||||
expect(STATUS_CONFIG[key]).toBe(STATUS_CONFIG[key]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for theme.ts — cssVar() function and ColorToken type.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { cssVar, type ColorToken } from "../theme";
|
||||
|
||||
describe("cssVar", () => {
|
||||
it("wraps each known token in a var() reference", () => {
|
||||
const tokens: ColorToken[] = [
|
||||
"surface",
|
||||
"surface-elevated",
|
||||
"surface-sunken",
|
||||
"surface-card",
|
||||
"line",
|
||||
"line-soft",
|
||||
"ink",
|
||||
"ink-mid",
|
||||
"ink-soft",
|
||||
"accent",
|
||||
"accent-strong",
|
||||
"warm",
|
||||
"good",
|
||||
"bad",
|
||||
"bg",
|
||||
"bg-elev",
|
||||
"bg-card",
|
||||
"line-strong",
|
||||
"ink-mute",
|
||||
"ink-dim",
|
||||
"accent-dim",
|
||||
"plasma",
|
||||
"warn",
|
||||
];
|
||||
for (const token of tokens) {
|
||||
expect(cssVar(token)).toBe(`var(--color-${token})`);
|
||||
}
|
||||
});
|
||||
|
||||
it("is a pure function — same token always returns same value", () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(cssVar("accent")).toBe("var(--color-accent)");
|
||||
expect(cssVar("surface")).toBe("var(--color-surface)");
|
||||
expect(cssVar("good")).toBe("var(--color-good)");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles hyphenated tokens correctly", () => {
|
||||
expect(cssVar("surface-elevated")).toBe("var(--color-surface-elevated)");
|
||||
expect(cssVar("line-soft")).toBe("var(--color-line-soft)");
|
||||
expect(cssVar("ink-mute")).toBe("var(--color-ink-mute)");
|
||||
});
|
||||
|
||||
it("produces a value usable as an inline style prop value", () => {
|
||||
const result = cssVar("accent");
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.startsWith("var(--color-")).toBe(true);
|
||||
expect(result.endsWith(")")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,7 @@ TOP_LEVEL_MODULES = {
|
||||
"a2a_tools_memory",
|
||||
"a2a_tools_messaging",
|
||||
"a2a_tools_rbac",
|
||||
"a2a_tools_identity",
|
||||
"adapter_base",
|
||||
"agent",
|
||||
"agents_md",
|
||||
|
||||
@@ -691,6 +691,19 @@ func logActivityExec(ctx context.Context, exec activityExecutor, broadcaster eve
|
||||
if respStr != nil {
|
||||
payload["response_body"] = json.RawMessage(respJSON)
|
||||
}
|
||||
// internal#211/#212: error_detail carries the runtime's curated,
|
||||
// user-actionable, secret-safe failure reason (provider HTTP
|
||||
// status + error code + the provider's own guidance, e.g. a 403
|
||||
// "org disabled · use an API key / ask your admin"). It is
|
||||
// already persisted to the DB column above and capped by the
|
||||
// runtime's report_activity helper (4096 chars). Previously it
|
||||
// was dropped from the LIVE broadcast, so the canvas had nothing
|
||||
// to render and fell back to a hardcoded opaque
|
||||
// "Agent error (Exception) — see workspace logs" string. Include
|
||||
// it so the chat bubble shows the real reason in real time.
|
||||
if params.ErrorDetail != nil && *params.ErrorDetail != "" {
|
||||
payload["error_detail"] = *params.ErrorDetail
|
||||
}
|
||||
}
|
||||
|
||||
return func() {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ListSources is the only exported function in plugins_sources.go.
|
||||
// It calls h.sources.Schemes() and returns the result verbatim,
|
||||
// so the test verifies the handler correctly serialises whatever
|
||||
// the real registry provides.
|
||||
func TestListSources_ReturnsSchemes(t *testing.T) {
|
||||
// Use a real handler — the registry is deterministic (local + github).
|
||||
h := NewPluginsHandler(t.TempDir(), nil, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/plugins/sources", nil)
|
||||
|
||||
h.ListSources(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Schemes []string `json:"schemes"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
// The default registry registers local + github resolvers.
|
||||
if len(body.Schemes) < 1 {
|
||||
t.Fatalf("expected at least 1 scheme, got %d: %v", len(body.Schemes), body.Schemes)
|
||||
}
|
||||
|
||||
// Verify stability — same call always returns same result.
|
||||
w2 := httptest.NewRecorder()
|
||||
c2, _ := gin.CreateTestContext(w2)
|
||||
c2.Request = httptest.NewRequest("GET", "/plugins/sources", nil)
|
||||
h.ListSources(c2)
|
||||
|
||||
var body2 struct {
|
||||
Schemes []string `json:"schemes"`
|
||||
}
|
||||
json.Unmarshal(w2.Body.Bytes(), &body2)
|
||||
if len(body.Schemes) != len(body2.Schemes) {
|
||||
t.Errorf("Schemes() is not stable: first=%v, second=%v", body.Schemes, body2.Schemes)
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,20 @@ import (
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// validWorkspaceID returns true when id is a syntactically valid UUID.
|
||||
// workspace_id is a `uuid` column; passing a non-UUID (e.g. the canvas
|
||||
// "global" sentinel sent when no node is selected) makes Postgres raise
|
||||
// `invalid input syntax for type uuid`, which previously leaked as an
|
||||
// opaque 500. Reject up front with a clean 400 instead. Mirrors the
|
||||
// uuid.Parse guard already used in handlers/activity.go.
|
||||
func validWorkspaceID(id string) bool {
|
||||
_, err := uuid.Parse(id)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// TokenHandler exposes user-facing token management for workspaces.
|
||||
// Routes: GET/POST/DELETE /workspaces/:id/tokens (behind WorkspaceAuth).
|
||||
type TokenHandler struct{}
|
||||
@@ -31,6 +43,10 @@ type tokenListItem struct {
|
||||
// never the plaintext or hash).
|
||||
func (h *TokenHandler) List(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
if !validWorkspaceID(workspaceID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if v := c.Query("limit"); v != "" {
|
||||
@@ -53,6 +69,7 @@ func (h *TokenHandler) List(c *gin.Context) {
|
||||
LIMIT $2 OFFSET $3
|
||||
`, workspaceID, limit, offset)
|
||||
if err != nil {
|
||||
log.Printf("tokens: list query failed for workspace %s: %v", workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tokens"})
|
||||
return
|
||||
}
|
||||
@@ -85,6 +102,10 @@ const maxTokensPerWorkspace = 50
|
||||
// exactly once in the response — it cannot be recovered afterwards.
|
||||
func (h *TokenHandler) Create(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
if !validWorkspaceID(workspaceID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limit: max active tokens per workspace
|
||||
var count int
|
||||
@@ -117,6 +138,10 @@ func (h *TokenHandler) Create(c *gin.Context) {
|
||||
func (h *TokenHandler) Revoke(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
tokenID := c.Param("tokenId")
|
||||
if !validWorkspaceID(workspaceID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := db.DB.ExecContext(c.Request.Context(), `
|
||||
UPDATE workspace_auth_tokens
|
||||
|
||||
@@ -41,6 +41,15 @@ import (
|
||||
|
||||
func init() { gin.SetMode(gin.TestMode) }
|
||||
|
||||
// Workspace IDs are validated as UUIDs up front (tokens.go validWorkspaceID),
|
||||
// so handler tests must pass syntactically valid UUIDs. Fixed values keep
|
||||
// sqlmock WithArgs assertions deterministic.
|
||||
const (
|
||||
wsUUID1 = "11111111-1111-1111-1111-111111111111"
|
||||
wsUUID2 = "22222222-2222-2222-2222-222222222222"
|
||||
wsUUID3 = "33333333-3333-3333-3333-333333333333"
|
||||
)
|
||||
|
||||
// withMockDB swaps `db.DB` for a sqlmock and returns the mock plus a
|
||||
// restore func. Tests use this in place of setupTokenTestDB which
|
||||
// skips on a missing real DB.
|
||||
@@ -81,13 +90,13 @@ func TestTokenHandler_List_HappyPath(t *testing.T) {
|
||||
created := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
|
||||
last := created.Add(time.Hour)
|
||||
mock.ExpectQuery(`SELECT id, prefix, created_at, last_used_at\s+FROM workspace_auth_tokens`).
|
||||
WithArgs("ws-1", 50, 0).
|
||||
WithArgs(wsUUID1, 50, 0).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "created_at", "last_used_at"}).
|
||||
AddRow("tok-1", "abc12345", created, last).
|
||||
AddRow("tok-2", "def67890", created, nil))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().List, "GET",
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -121,7 +130,7 @@ func TestTokenHandler_List_EmptyResult(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "created_at", "last_used_at"}))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().List, "GET",
|
||||
"/workspaces/ws-2/tokens", gin.Params{{Key: "id", Value: "ws-2"}})
|
||||
"/workspaces/ws-2/tokens", gin.Params{{Key: "id", Value: wsUUID2}})
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 on empty list, got %d", w.Code)
|
||||
@@ -146,7 +155,7 @@ func TestTokenHandler_List_QueryError(t *testing.T) {
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().List, "GET",
|
||||
"/workspaces/ws-3/tokens", gin.Params{{Key: "id", Value: "ws-3"}})
|
||||
"/workspaces/ws-3/tokens", gin.Params{{Key: "id", Value: wsUUID3}})
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("query error must surface as 500, got %d", w.Code)
|
||||
@@ -158,13 +167,13 @@ func TestTokenHandler_List_RespectsLimit(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`SELECT id, prefix, created_at, last_used_at`).
|
||||
WithArgs("ws-1", 10, 5).
|
||||
WithArgs(wsUUID1, 10, 5).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "created_at", "last_used_at"}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/tokens?limit=10&offset=5", nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
c.Params = gin.Params{{Key: "id", Value: wsUUID1}}
|
||||
NewTokenHandler().List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
@@ -186,7 +195,7 @@ func TestTokenHandler_List_ScanError(t *testing.T) {
|
||||
AddRow("tok-1", "abc", "not-a-timestamp", nil))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().List, "GET",
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("scan error must surface as 500, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -201,11 +210,11 @@ func TestTokenHandler_Create_RateLimited(t *testing.T) {
|
||||
|
||||
// Count query returns 50 (== max) → 429.
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
|
||||
WithArgs("ws-1").
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(50))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().Create, "POST",
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
|
||||
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("max active tokens should 429, got %d", w.Code)
|
||||
@@ -225,7 +234,7 @@ func TestTokenHandler_Create_IssueFails(t *testing.T) {
|
||||
WillReturnError(errors.New("disk full"))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().Create, "POST",
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("IssueToken DB error must 500, got %d", w.Code)
|
||||
@@ -242,7 +251,7 @@ func TestTokenHandler_Create_HappyPath(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().Create, "POST",
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -257,7 +266,7 @@ func TestTokenHandler_Create_HappyPath(t *testing.T) {
|
||||
if body.AuthToken == "" {
|
||||
t.Errorf("auth_token must be present and non-empty in response")
|
||||
}
|
||||
if body.WorkspaceID != "ws-1" {
|
||||
if body.WorkspaceID != wsUUID1 {
|
||||
t.Errorf("workspace_id mismatch: %q", body.WorkspaceID)
|
||||
}
|
||||
}
|
||||
@@ -269,12 +278,12 @@ func TestTokenHandler_Revoke_HappyPath(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens\s+SET revoked_at = now\(\)`).
|
||||
WithArgs("tok-1", "ws-1").
|
||||
WithArgs("tok-1", wsUUID1).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().Revoke, "DELETE",
|
||||
"/workspaces/ws-1/tokens/tok-1", gin.Params{
|
||||
{Key: "id", Value: "ws-1"},
|
||||
{Key: "id", Value: wsUUID1},
|
||||
{Key: "tokenId", Value: "tok-1"},
|
||||
})
|
||||
|
||||
@@ -289,12 +298,12 @@ func TestTokenHandler_Revoke_NotFound(t *testing.T) {
|
||||
|
||||
// 0 rows affected → token not found OR already revoked.
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens`).
|
||||
WithArgs("tok-ghost", "ws-1").
|
||||
WithArgs("tok-ghost", wsUUID1).
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().Revoke, "DELETE",
|
||||
"/workspaces/ws-1/tokens/tok-ghost", gin.Params{
|
||||
{Key: "id", Value: "ws-1"},
|
||||
{Key: "id", Value: wsUUID1},
|
||||
{Key: "tokenId", Value: "tok-ghost"},
|
||||
})
|
||||
|
||||
@@ -312,7 +321,7 @@ func TestTokenHandler_Revoke_DBError(t *testing.T) {
|
||||
|
||||
w := makeReq(t, NewTokenHandler().Revoke, "DELETE",
|
||||
"/workspaces/ws-1/tokens/tok-1", gin.Params{
|
||||
{Key: "id", Value: "ws-1"},
|
||||
{Key: "id", Value: wsUUID1},
|
||||
{Key: "tokenId", Value: "tok-1"},
|
||||
})
|
||||
|
||||
@@ -321,6 +330,59 @@ func TestTokenHandler_Revoke_DBError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- UUID validation (regression: "global" sentinel 500) ------------
|
||||
|
||||
// The canvas Settings → Workspace Tokens tab sent the literal sentinel
|
||||
// "global" as the workspace id when no node was selected. workspace_id
|
||||
// is a `uuid` column, so the query raised
|
||||
// `invalid input syntax for type uuid: "global"` which leaked as an
|
||||
// opaque 500. List/Create/Revoke now reject any non-UUID id with a
|
||||
// clean 400 before touching the DB. No DB expectation is set on the
|
||||
// mock — a DB hit would fail ExpectationsWereMet, proving short-circuit.
|
||||
func TestTokenHandler_RejectsNonUUIDWorkspaceID(t *testing.T) {
|
||||
h := NewTokenHandler()
|
||||
cases := []struct {
|
||||
name string
|
||||
run func(c *gin.Context)
|
||||
method string
|
||||
params gin.Params
|
||||
}{
|
||||
{"List", h.List, "GET", gin.Params{{Key: "id", Value: "global"}}},
|
||||
{"Create", h.Create, "POST", gin.Params{{Key: "id", Value: "global"}}},
|
||||
{"Revoke", h.Revoke, "DELETE", gin.Params{
|
||||
{Key: "id", Value: "global"},
|
||||
{Key: "tokenId", Value: "tok-1"},
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
w := makeReq(t, tc.run, tc.method,
|
||||
"/workspaces/global/tokens", tc.params)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s with non-UUID id must 400, got %d: %s",
|
||||
tc.name, w.Code, w.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body.Error != "invalid workspace id" {
|
||||
t.Errorf("%s: want error=%q, got %q",
|
||||
tc.name, "invalid workspace id", body.Error)
|
||||
}
|
||||
// No query/exec was expected → if the handler hit the DB
|
||||
// this fails, proving the guard short-circuits before SQL.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("%s leaked a DB call past the uuid guard: %v", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time noise removal: the imports list pulls in the sql /
|
||||
// driver packages and the silenced ctx so a future scenario that
|
||||
// needs them doesn't have to re-add the import. Documented here so
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func init() { gin.SetMode(gin.TestMode) }
|
||||
@@ -167,11 +168,14 @@ func TestTokenHandler_RevokeWrongWorkspace(t *testing.T) {
|
||||
|
||||
h := NewTokenHandler()
|
||||
|
||||
// Try to revoke with a different workspace ID — should 404
|
||||
// Try to revoke with a different (valid-UUID) workspace ID that does
|
||||
// not own the token — should 404. A valid UUID is required so this
|
||||
// exercises the ownership branch, not the up-front uuid-shape 400.
|
||||
otherWS := uuid.NewString()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "wrong-workspace-id"}, {Key: "tokenId", Value: tokenID}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/wrong/tokens/"+tokenID, nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: otherWS}, {Key: "tokenId", Value: tokenID}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/"+otherWS+"/tokens/"+tokenID, nil)
|
||||
h.Revoke(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func setupAbilitiesTest(t *testing.T) (sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prev := db.DB
|
||||
db.DB = mockDB
|
||||
return mock, func() {
|
||||
db.DB = prev
|
||||
mockDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAbilities_InvalidWorkspaceID_Returns400(t *testing.T) {
|
||||
_, cleanup := setupAbilitiesTest(t)
|
||||
defer cleanup()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "not-a-valid-uuid"}}
|
||||
c.Request = httptest.NewRequest("PATCH",
|
||||
"/workspaces/not-a-valid-uuid/abilities",
|
||||
bytes.NewBufferString(`{"broadcast_enabled":true}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
PatchAbilities(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["error"] != "invalid workspace ID" {
|
||||
t.Errorf("expected 'invalid workspace ID', got %q", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAbilities_EmptyBody_Returns400(t *testing.T) {
|
||||
_, cleanup := setupAbilitiesTest(t)
|
||||
defer cleanup()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
||||
c.Request = httptest.NewRequest("PATCH",
|
||||
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
|
||||
bytes.NewBufferString(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
PatchAbilities(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["error"] != "at least one ability field required" {
|
||||
t.Errorf("expected 'at least one ability field required', got %q", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAbilities_InvalidJSON_Returns400(t *testing.T) {
|
||||
_, cleanup := setupAbilitiesTest(t)
|
||||
defer cleanup()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
||||
c.Request = httptest.NewRequest("PATCH",
|
||||
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
|
||||
bytes.NewBufferString(`{invalid json}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
PatchAbilities(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["error"] != "invalid request body" {
|
||||
t.Errorf("expected 'invalid request body', got %q", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAbilities_WorkspaceNotFound_Returns404(t *testing.T) {
|
||||
mock, cleanup := setupAbilitiesTest(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("SELECT EXISTS").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
||||
c.Request = httptest.NewRequest("PATCH",
|
||||
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
|
||||
bytes.NewBufferString(`{"broadcast_enabled":true}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
PatchAbilities(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAbilities_WorkspaceDBError_Returns404(t *testing.T) {
|
||||
mock, cleanup := setupAbilitiesTest(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("SELECT EXISTS").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000").
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
||||
c.Request = httptest.NewRequest("PATCH",
|
||||
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
|
||||
bytes.NewBufferString(`{"broadcast_enabled":true}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
PatchAbilities(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAbilities_UpdateBroadcastEnabled_Returns200(t *testing.T) {
|
||||
mock, cleanup := setupAbilitiesTest(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("SELECT EXISTS").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec("UPDATE workspaces SET broadcast_enabled").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000", true).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
||||
c.Request = httptest.NewRequest("PATCH",
|
||||
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
|
||||
bytes.NewBufferString(`{"broadcast_enabled":true}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
PatchAbilities(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["status"] != "updated" {
|
||||
t.Errorf("expected status=updated, got %v", body)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAbilities_UpdateTalkToUserEnabled_Returns200(t *testing.T) {
|
||||
mock, cleanup := setupAbilitiesTest(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("SELECT EXISTS").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec("UPDATE workspaces SET talk_to_user_enabled").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000", true).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
||||
c.Request = httptest.NewRequest("PATCH",
|
||||
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
|
||||
bytes.NewBufferString(`{"talk_to_user_enabled":true}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
PatchAbilities(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["status"] != "updated" {
|
||||
t.Errorf("expected status=updated, got %v", body)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAbilities_UpdateBothAbilities_Returns200(t *testing.T) {
|
||||
mock, cleanup := setupAbilitiesTest(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("SELECT EXISTS").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec("UPDATE workspaces SET broadcast_enabled").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000", true).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE workspaces SET talk_to_user_enabled").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000", false).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
||||
c.Request = httptest.NewRequest("PATCH",
|
||||
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
|
||||
bytes.NewBufferString(`{"broadcast_enabled":true,"talk_to_user_enabled":false}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
PatchAbilities(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["status"] != "updated" {
|
||||
t.Errorf("expected status=updated, got %v", body)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAbilities_UpdateBroadcastDisabled_Returns200(t *testing.T) {
|
||||
mock, cleanup := setupAbilitiesTest(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("SELECT EXISTS").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec("UPDATE workspaces SET broadcast_enabled").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000", false).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
||||
c.Request = httptest.NewRequest("PATCH",
|
||||
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
|
||||
bytes.NewBufferString(`{"broadcast_enabled":false}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
PatchAbilities(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// broadcastTruncate
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestBroadcastTruncate_ShortString_ReturnsUnmodified(t *testing.T) {
|
||||
result := broadcastTruncate("hello", 10)
|
||||
if result != "hello" {
|
||||
t.Errorf("expected 'hello', got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastTruncate_ExactlyMaxLength_ReturnsUnmodified(t *testing.T) {
|
||||
result := broadcastTruncate("hello", 5)
|
||||
if result != "hello" {
|
||||
t.Errorf("expected 'hello', got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastTruncate_ExceedsMaxLength_TruncatesWithEllipsis(t *testing.T) {
|
||||
result := broadcastTruncate("hello world", 5)
|
||||
if result != "hello…" {
|
||||
t.Errorf("expected 'hello…', got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastTruncate_Unicode_TruncatesAtRuneBoundary(t *testing.T) {
|
||||
result := broadcastTruncate("日本語テスト", 2)
|
||||
if result != "日本…" {
|
||||
t.Errorf("expected '日本…', got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// BroadcastHandler
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func setupBroadcastTest(t *testing.T) (sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prev := db.DB
|
||||
db.DB = mockDB
|
||||
return mock, func() {
|
||||
db.DB = prev
|
||||
mockDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_InvalidWorkspaceID_Returns400(t *testing.T) {
|
||||
_, cleanup := setupBroadcastTest(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewBroadcastHandler(newTestBroadcaster())
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "not-a-uuid"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/not-a-uuid/broadcast",
|
||||
bytes.NewBufferString(`{"message":"hello"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
h.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["error"] != "invalid workspace ID" {
|
||||
t.Errorf("expected 'invalid workspace ID', got %q", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_MissingMessage_Returns400(t *testing.T) {
|
||||
_, cleanup := setupBroadcastTest(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewBroadcastHandler(newTestBroadcaster())
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
||||
c.Request = httptest.NewRequest("POST",
|
||||
"/workspaces/550e8400-e29b-41d4-a716-446655440000/broadcast",
|
||||
bytes.NewBufferString(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
h.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["error"] != "message is required" {
|
||||
t.Errorf("expected 'message is required', got %q", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_WorkspaceNotFound_Returns404(t *testing.T) {
|
||||
mock, cleanup := setupBroadcastTest(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
h := NewBroadcastHandler(newTestBroadcaster())
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
||||
c.Request = httptest.NewRequest("POST",
|
||||
"/workspaces/550e8400-e29b-41d4-a716-446655440000/broadcast",
|
||||
bytes.NewBufferString(`{"message":"hello"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
h.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_BroadcastDisabled_Returns403(t *testing.T) {
|
||||
mock, cleanup := setupBroadcastTest(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
|
||||
AddRow("test-agent", false))
|
||||
|
||||
h := NewBroadcastHandler(newTestBroadcaster())
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
||||
c.Request = httptest.NewRequest("POST",
|
||||
"/workspaces/550e8400-e29b-41d4-a716-446655440000/broadcast",
|
||||
bytes.NewBufferString(`{"message":"hello"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
h.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["error"] != "broadcast_disabled" {
|
||||
t.Errorf("expected error='broadcast_disabled', got %v", body)
|
||||
}
|
||||
if _, ok := body["hint"]; !ok {
|
||||
t.Errorf("expected hint field in 403 body, got %v", body)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_RecipientQueryFails_Returns500(t *testing.T) {
|
||||
mock, cleanup := setupBroadcastTest(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
|
||||
AddRow("test-agent", true))
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != ").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000").
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
|
||||
h := NewBroadcastHandler(newTestBroadcaster())
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
||||
c.Request = httptest.NewRequest("POST",
|
||||
"/workspaces/550e8400-e29b-41d4-a716-446655440000/broadcast",
|
||||
bytes.NewBufferString(`{"message":"hello"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
h.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_NoRecipients_Returns200(t *testing.T) {
|
||||
mock, cleanup := setupBroadcastTest(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
|
||||
AddRow("test-agent", true))
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != ").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000", "Broadcast sent to 0 workspace(s)").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewBroadcastHandler(newTestBroadcaster())
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
||||
c.Request = httptest.NewRequest("POST",
|
||||
"/workspaces/550e8400-e29b-41d4-a716-446655440000/broadcast",
|
||||
bytes.NewBufferString(`{"message":"hello"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
h.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["status"] != "sent" {
|
||||
t.Errorf("expected status=sent, got %v", body)
|
||||
}
|
||||
if int(body["delivered"].(float64)) != 0 {
|
||||
t.Errorf("expected delivered=0, got %v", body["delivered"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_DeliversToOneRecipient_Returns200(t *testing.T) {
|
||||
mock, cleanup := setupBroadcastTest(t)
|
||||
defer cleanup()
|
||||
|
||||
senderID := "550e8400-e29b-41d4-a716-446655440000"
|
||||
recipientID := "660e8400-e29b-41d4-a716-446655440001"
|
||||
senderName := "test-agent"
|
||||
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
|
||||
AddRow(senderName, true))
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != ").
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(recipientID))
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs(recipientID, senderID, "Broadcast from "+senderName+": hello").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs(senderID, "Broadcast sent to 1 workspace(s)").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewBroadcastHandler(newTestBroadcaster())
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
c.Request = httptest.NewRequest("POST",
|
||||
"/workspaces/"+senderID+"/broadcast",
|
||||
bytes.NewBufferString(`{"message":"hello"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
h.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if int(body["delivered"].(float64)) != 1 {
|
||||
t.Errorf("expected delivered=1, got %v", body["delivered"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_RecipientInsertFails_Continues_Returns200(t *testing.T) {
|
||||
mock, cleanup := setupBroadcastTest(t)
|
||||
defer cleanup()
|
||||
|
||||
senderID := "550e8400-e29b-41d4-a716-446655440000"
|
||||
recipientID := "660e8400-e29b-41d4-a716-446655440001"
|
||||
senderName := "test-agent"
|
||||
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
|
||||
AddRow(senderName, true))
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != ").
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(recipientID))
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs(recipientID, senderID, "Broadcast from "+senderName+": hello").
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs(senderID, "Broadcast sent to 0 workspace(s)").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewBroadcastHandler(newTestBroadcaster())
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
c.Request = httptest.NewRequest("POST",
|
||||
"/workspaces/"+senderID+"/broadcast",
|
||||
bytes.NewBufferString(`{"message":"hello"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
h.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if int(body["delivered"].(float64)) != 0 {
|
||||
t.Errorf("expected delivered=0 (failed inserts don't count), got %v", body["delivered"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_SenderLogFails_StillReturns200(t *testing.T) {
|
||||
mock, cleanup := setupBroadcastTest(t)
|
||||
defer cleanup()
|
||||
|
||||
senderID := "550e8400-e29b-41d4-a716-446655440000"
|
||||
recipientID := "660e8400-e29b-41d4-a716-446655440001"
|
||||
senderName := "test-agent"
|
||||
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
|
||||
AddRow(senderName, true))
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != ").
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(recipientID))
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs(recipientID, senderID, "Broadcast from "+senderName+": hello").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs(senderID, "Broadcast sent to 1 workspace(s)").
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
|
||||
h := NewBroadcastHandler(newTestBroadcaster())
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
c.Request = httptest.NewRequest("POST",
|
||||
"/workspaces/"+senderID+"/broadcast",
|
||||
bytes.NewBufferString(`{"message":"hello"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(context.Background())
|
||||
|
||||
h.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if int(body["delivered"].(float64)) != 1 {
|
||||
t.Errorf("expected delivered=1, got %v", body["delivered"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -599,6 +599,28 @@ def _sanitize_for_external(msg: str) -> str:
|
||||
import re as _re
|
||||
|
||||
msg = _re.sub(r"(?i)(?:bearer|token|api[_-]?key|sk-)[ :=]+[A-Za-z0-9_/.-]{20,}", "[REDACTED]", msg)
|
||||
# Bare provider key with NO separator after the prefix — a real
|
||||
# `sk-ant-api03-…` / `sk-…` key uses `-` (not `[ :=]`) so the rule
|
||||
# above misses it. Require ≥24 key-ish chars after the `sk-`/`sk-ant-`
|
||||
# prefix so curated examples like `sk-ant-EXAMPLE-SHORT` (13 chars
|
||||
# after `sk-ant-`) still pass through un-redacted.
|
||||
msg = _re.sub(r"(?i)\bsk-(?:ant-)?[A-Za-z0-9_-]{24,}", "[REDACTED]", msg)
|
||||
# JSON-quoted credential values: {"token": "…"} / {"apiKey": "…"} /
|
||||
# {"secret": "…"} / {"password": "…"}. Redact only the value, and only
|
||||
# when it is ≥24 chars so a short curated sample like
|
||||
# `"api_key": "sk-ant-EXAMPLE-SHORT"` (20-char value) still passes.
|
||||
msg = _re.sub(
|
||||
r'(?i)("(?:token|api[_-]?key|secret|password)"\s*:\s*")[^"]{24,}(")',
|
||||
r"\1[REDACTED]\2",
|
||||
msg,
|
||||
)
|
||||
# AWS secret access key in `aws_secret_access_key=…` form (env dumps,
|
||||
# boto tracebacks). The base64-ish value runs until whitespace/quote.
|
||||
msg = _re.sub(
|
||||
r"(?i)(aws_secret_access_key\s*[:=]\s*)\S+",
|
||||
r"\1[REDACTED]",
|
||||
msg,
|
||||
)
|
||||
# Absolute paths: /etc/shadow, /home/user/.aws/credentials, etc.
|
||||
msg = _re.sub(r"(?:/[^/\s]+){2,}", lambda m: m.group(0) if len(m.group(0)) < 60 else "[REDACTED_PATH]", msg)
|
||||
return msg
|
||||
@@ -608,6 +630,7 @@ def sanitize_agent_error(
|
||||
exc: BaseException | None = None,
|
||||
category: str | None = None,
|
||||
stderr: str | None = None,
|
||||
reason: str | None = None,
|
||||
) -> str:
|
||||
"""Render an agent-side failure into a user-safe error message.
|
||||
|
||||
@@ -615,6 +638,18 @@ def sanitize_agent_error(
|
||||
category string (e.g. from `classify_subprocess_error`). If both are
|
||||
given, `category` wins. If neither, the tag defaults to "unknown".
|
||||
|
||||
When ``reason`` is provided (internal#211/#212), it is a *pre-curated,
|
||||
user-actionable, secret-safe* explanation built by the caller from a
|
||||
provider-side failure — e.g. a 403 "Your organization has disabled
|
||||
Claude subscription access · Use an Anthropic API key instead, or ask
|
||||
your admin to enable access" with error code ``oauth_org_not_allowed``.
|
||||
This text is exactly what the user needs to self-serve, so it is
|
||||
surfaced VERBATIM as the message instead of being collapsed to the
|
||||
opaque exception class name. It still passes through the
|
||||
key/token/bearer/path scrubber as a belt-and-braces second pass so a
|
||||
buggy caller can't leak a credential that snuck into the reason.
|
||||
``reason`` wins over ``stderr``; both lose to neither being set.
|
||||
|
||||
When ``stderr`` is provided (e.g. the first ~1 KB of a subprocess stderr
|
||||
or HTTP error body), it is sanitized and appended to the output so the
|
||||
A2A caller gets actionable context without needing to dig through workspace
|
||||
@@ -629,6 +664,13 @@ def sanitize_agent_error(
|
||||
else:
|
||||
tag = "unknown"
|
||||
|
||||
if reason:
|
||||
# Curated, user-actionable reason — surface it as the message.
|
||||
# Still scrub: a 403/auth/quota message is safe, but the scrubber
|
||||
# is cheap insurance against a caller that didn't curate cleanly.
|
||||
clean = _sanitize_for_external(reason[:_MAX_STDERR_PREVIEW])
|
||||
return f"Agent error ({tag}): {clean}"
|
||||
|
||||
if stderr:
|
||||
# Truncate and sanitize before including — prevents DoS via
|
||||
# a malicious or buggy peer injecting a huge error body, and
|
||||
|
||||
@@ -788,6 +788,123 @@ def test_sanitize_agent_error_stderr_combined_with_existing_tests():
|
||||
assert "workspace logs" in out
|
||||
|
||||
|
||||
# ─── reason passthrough (internal#211/#212: surface actionable provider error) ───
|
||||
|
||||
|
||||
def test_sanitize_agent_error_reason_surfaced_verbatim():
|
||||
"""A curated provider reason is shown to the user, not collapsed to the
|
||||
exception class name. This is the internal#211 regression: a 403
|
||||
org-disabled message must reach the canvas."""
|
||||
reason = (
|
||||
"provider HTTP 403 — oauth_org_not_allowed — Your organization has "
|
||||
"disabled Claude subscription access for Claude Code · Use an "
|
||||
"Anthropic API key instead, or ask your admin to enable access"
|
||||
)
|
||||
|
||||
class _ResultErr(Exception):
|
||||
pass
|
||||
|
||||
out = sanitize_agent_error(exc=_ResultErr("opaque"), reason=reason)
|
||||
# The actionable provider guidance and status code must be visible.
|
||||
assert "403" in out
|
||||
assert "oauth_org_not_allowed" in out
|
||||
assert "disabled Claude subscription access" in out
|
||||
assert "ask your admin to enable access" in out
|
||||
# NOT the old opaque form.
|
||||
assert "see workspace logs" not in out
|
||||
|
||||
|
||||
def test_sanitize_agent_error_reason_still_scrubs_secrets():
|
||||
"""Even on the reason path the key/token scrubber runs — a buggy caller
|
||||
that lets a bearer token into the reason still gets it redacted."""
|
||||
leaky = (
|
||||
"provider HTTP 401 — auth failed — Authorization: Bearer "
|
||||
"PLACEHOLDER_LONG_TOKEN_0123456789abcdefghijklm please re-auth"
|
||||
)
|
||||
out = sanitize_agent_error(reason=leaky)
|
||||
assert "[REDACTED]" in out
|
||||
assert "PLACEHOLDER_LONG_TOKEN_0123456789abcdefghijklm" not in out
|
||||
# The non-secret guidance still survives the scrub.
|
||||
assert "401" in out
|
||||
assert "please re-auth" in out
|
||||
|
||||
|
||||
def test_sanitize_agent_error_reason_scrubs_all_secret_formats():
|
||||
"""The scrubber must redact every realistic credential shape — not just
|
||||
the `Bearer <tok>` form the original test happened to exercise
|
||||
(internal#212 review finding: bare `sk-ant-api03-…` keys, JSON-quoted
|
||||
"token"/"apiKey" values, and `aws_secret_access_key=` all leaked).
|
||||
All curated/actionable guidance must still survive the scrub.
|
||||
"""
|
||||
# 1. Bare sk-ant-api03 key — no `[ :=]` separator after the prefix
|
||||
# (a real Anthropic key uses `-`), so the legacy regex missed it.
|
||||
bare = (
|
||||
"provider HTTP 401 — auth failed — invalid key "
|
||||
"sk-FAKEPLACEHOLDERabcdefghijklmnopqrstuvwxy0123456789 "
|
||||
"please re-auth"
|
||||
)
|
||||
out = sanitize_agent_error(reason=bare)
|
||||
assert "sk-FAKEPLACEHOLDERabcdefghijklmnopqrstuvwxy0123456789" not in out
|
||||
assert "[REDACTED]" in out
|
||||
assert "401" in out # actionable status survives
|
||||
assert "please re-auth" in out # actionable guidance survives
|
||||
|
||||
# 2. JSON-quoted "token" / "apiKey" values.
|
||||
jblob = (
|
||||
'provider error — config dump {"token": '
|
||||
'"abcDEF0123456789ghIJKL0123456789mnopQRST", "apiKey": '
|
||||
'"anon_fakefakefakefakefakefakefakefakefakefake"} — '
|
||||
"use an API key instead"
|
||||
)
|
||||
out = sanitize_agent_error(reason=jblob)
|
||||
assert "abcDEF0123456789ghIJKL0123456789mnopQRST" not in out
|
||||
assert "anon_fakefakefakefakefakefakefakefakefakefake" not in out
|
||||
assert "[REDACTED]" in out
|
||||
assert "use an API key instead" in out # actionable guidance survives
|
||||
|
||||
# 3. aws_secret_access_key=… form.
|
||||
awsblob = (
|
||||
"provider HTTP 403 — boto credential error "
|
||||
"aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY — "
|
||||
"ask your admin to enable access"
|
||||
)
|
||||
out = sanitize_agent_error(reason=awsblob)
|
||||
assert "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" not in out
|
||||
assert "[REDACTED]" in out
|
||||
assert "403" in out # actionable status survives
|
||||
assert "ask your admin to enable access" in out # guidance survives
|
||||
|
||||
# 4. Regression: the original Bearer form still redacts.
|
||||
# Uses PLACEHOLDER_LONG_TOKEN (>=40 chars, no sk-ant- prefix) to avoid
|
||||
# triggering the secret-scan workflow pattern
|
||||
# `sk-ant-[A-Za-z0-9_-]{40,}`.
|
||||
bearer = (
|
||||
"provider HTTP 401 — Authorization: Bearer "
|
||||
"PLACEHOLDER_LONG_TOKEN_9876543210abcdefghij re-auth"
|
||||
)
|
||||
out = sanitize_agent_error(reason=bearer)
|
||||
assert "PLACEHOLDER_LONG_TOKEN_9876543210abcdefghij" not in out
|
||||
assert "[REDACTED]" in out
|
||||
assert "re-auth" in out
|
||||
|
||||
|
||||
def test_sanitize_agent_error_reason_wins_over_stderr():
|
||||
"""When both reason and stderr are passed, the curated reason wins."""
|
||||
out = sanitize_agent_error(
|
||||
reason="provider HTTP 403 — use an API key",
|
||||
stderr="raw subprocess noise that should not be shown",
|
||||
)
|
||||
assert "use an API key" in out
|
||||
assert "raw subprocess noise" not in out
|
||||
|
||||
|
||||
def test_sanitize_agent_error_no_reason_unchanged():
|
||||
"""Omitting reason preserves the original generic behavior."""
|
||||
out = sanitize_agent_error(exc=ValueError("boom"))
|
||||
assert "ValueError" in out
|
||||
assert "workspace logs" in out
|
||||
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# classify_subprocess_error
|
||||
|
||||
Reference in New Issue
Block a user