Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f89f7a34d9 | |||
| aff7f810bc |
@@ -16,40 +16,7 @@ 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,35 +302,3 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,54 +45,11 @@ export function FilesTab({ workspaceId, data }: Props) {
|
||||
if (data && isExternalLikeRuntime(data.runtime)) {
|
||||
return <NotAvailablePanel runtime={data.runtime} />;
|
||||
}
|
||||
return <PlatformOwnedFilesTab workspaceId={workspaceId} runtime={data?.runtime} />;
|
||||
return <PlatformOwnedFilesTab workspaceId={workspaceId} />;
|
||||
}
|
||||
|
||||
/** Picks the initial root for the FilesTab dropdown based on the
|
||||
* workspace's runtime. Decision: per-runtime default (Hongming
|
||||
* 2026-05-15, internal#425 Decisions §2).
|
||||
*
|
||||
* - openclaw → `/agent-home` (the agent's identity/state — the
|
||||
* user-facing interesting files for that runtime live in
|
||||
* `~/.openclaw/` inside the container, which `/agent-home` maps to
|
||||
* via the Phase 2b docker-exec backend).
|
||||
* - everything else (claude-code, hermes, external-like, undefined)
|
||||
* → `/configs` (the legacy default — managed config that flows
|
||||
* through the per-runtime indirection in
|
||||
* workspace-server/internal/handlers/template_files_eic.go).
|
||||
*
|
||||
* When the runtime is undefined (legacy callers that don't thread
|
||||
* `data` through, or a workspace whose runtime field hasn't loaded
|
||||
* yet) the default is `/configs` — matches today's behaviour, no
|
||||
* surprise.
|
||||
*
|
||||
* Note on `/agent-home` pre-Phase-2b: the backend short-circuits
|
||||
* with HTTP 501 and the canonical "implementation pending" body.
|
||||
* The tab renders empty + the error banner explains. This is by
|
||||
* design — lets us land the canvas UX before the backend ships,
|
||||
* per the RFC's phased rollout. The 501 is graceful: it doesn't
|
||||
* poison error toasts or generate "workspace not found" noise.
|
||||
*
|
||||
* Adding a new runtime that should default to `/agent-home`: add it
|
||||
* to the agentHomeDefaultRuntimes set below. Adding a runtime that
|
||||
* should default to a different root: extend this function. */
|
||||
const agentHomeDefaultRuntimes = new Set(["openclaw"]);
|
||||
|
||||
function defaultRootForRuntime(runtime: string | undefined): string {
|
||||
if (runtime && agentHomeDefaultRuntimes.has(runtime)) {
|
||||
return "/agent-home";
|
||||
}
|
||||
return "/configs";
|
||||
}
|
||||
|
||||
function PlatformOwnedFilesTab({
|
||||
workspaceId,
|
||||
runtime,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
runtime?: string;
|
||||
}) {
|
||||
const [root, setRoot] = useState(() => defaultRootForRuntime(runtime));
|
||||
function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
|
||||
const [root, setRoot] = useState("/configs");
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
const [editContent, setEditContent] = useState("");
|
||||
|
||||
@@ -3,22 +3,6 @@
|
||||
import { useRef } from "react";
|
||||
import { getIcon } from "./tree";
|
||||
|
||||
// secretShapeMarker is the canonical body the workspace-server Files
|
||||
// API returns when a file's path OR content matched a credential
|
||||
// regex (internal#425 RFC, Phase 2b — backed by
|
||||
// workspace-server/internal/secrets.ScanBytes). The marker is a
|
||||
// fixed prefix so the canvas can detect it without parsing JSON and
|
||||
// without round-tripping the matched bytes through the editor (which
|
||||
// would defeat the purpose — clipboard, browser history, log
|
||||
// surfaces would all see them).
|
||||
//
|
||||
// Today (Phase 1 / before 2b ships) the backend returns 501 for the
|
||||
// only root that uses this path, so the marker is dead code until
|
||||
// 2b lands. Wiring it in now keeps the canvas + backend contracts
|
||||
// aligned in one PR rather than a follow-up. The constant is
|
||||
// importable so a future test can pin the exact string.
|
||||
export const SECRET_SHAPE_DENIED_MARKER = "<denied: secret-shape>";
|
||||
|
||||
interface Props {
|
||||
selectedFile: string | null;
|
||||
fileContent: string;
|
||||
@@ -47,22 +31,6 @@ export function FileEditor({
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isDirty = editContent !== fileContent;
|
||||
|
||||
// internal#425 Phase 3: detect the secret-shape denial marker and
|
||||
// render a placeholder instead of the editor. The marker comes
|
||||
// from workspace-server Phase 2b (secrets.ScanBytes) which refuses
|
||||
// to surface the file's bytes. We deliberately don't expose
|
||||
// the matched pattern's Name here — the canvas just shows the
|
||||
// generic denial. The Files API log surface has the Pattern.Name
|
||||
// for operators who need to debug a false positive.
|
||||
const isSecretShapeDenied = fileContent === SECRET_SHAPE_DENIED_MARKER;
|
||||
|
||||
// /agent-home is read-only from the canvas (Phase 2b ships read +
|
||||
// delete; Phase-2b-followup may add write). Edits to /configs are
|
||||
// unchanged. Until 2b ships, /agent-home returns 501 so this
|
||||
// read-only gate is also dead code, but wiring it in now keeps
|
||||
// the UI honest the moment 2b lands without a follow-up canvas PR.
|
||||
const isReadOnlyRoot = root !== "/configs";
|
||||
|
||||
if (!selectedFile) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
@@ -107,42 +75,11 @@ export function FileEditor({
|
||||
{/* Editor area */}
|
||||
{loadingFile ? (
|
||||
<div className="p-4 text-xs text-ink-mid">Loading...</div>
|
||||
) : isSecretShapeDenied ? (
|
||||
// Files API refused to surface this file's bytes because its
|
||||
// path or content matched a credential regex
|
||||
// (workspace-server/internal/secrets, internal#425 Phase 2b).
|
||||
// We render a placeholder INSTEAD OF the textarea so the
|
||||
// matched bytes never enter the DOM. Clipboard / view-source
|
||||
// / element-inspector all see the placeholder, not the
|
||||
// credential.
|
||||
<div
|
||||
role="region"
|
||||
aria-label="File content denied"
|
||||
className="flex-1 flex items-center justify-center p-6 bg-surface"
|
||||
>
|
||||
<div className="max-w-md text-center space-y-2">
|
||||
<div className="text-2xl opacity-40">🛡️</div>
|
||||
<p className="text-[11px] font-mono text-warm">
|
||||
{SECRET_SHAPE_DENIED_MARKER}
|
||||
</p>
|
||||
<p className="text-[10px] text-ink-mid leading-relaxed">
|
||||
The platform refused to surface this file because its
|
||||
path or content matched a credential-shape pattern.
|
||||
The bytes never left the workspace container.
|
||||
</p>
|
||||
<p className="text-[10px] text-ink-mid leading-relaxed">
|
||||
If this is a false positive (test fixture, docs example,
|
||||
or content that happens to share a credential's shape),
|
||||
rename the file or adjust the content via the workspace
|
||||
terminal so the regex no longer matches, then refresh.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
value={editContent}
|
||||
readOnly={isReadOnlyRoot}
|
||||
readOnly={root !== "/configs"}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
|
||||
@@ -38,15 +38,6 @@ export function FilesToolbar({
|
||||
<option value="/home">/home</option>
|
||||
<option value="/workspace">/workspace</option>
|
||||
<option value="/plugins">/plugins</option>
|
||||
{/* internal#425 Phase 1+3: container-internal $HOME root.
|
||||
Backend lands the docker-exec dispatch in Phase 2b. Until
|
||||
then the stub returns 501 with a canonical
|
||||
"implementation pending" message — the dropdown renders
|
||||
the option so the canvas affordance is design-frozen
|
||||
even before the backend ships.
|
||||
Runtime-default selection logic in FilesTab.tsx picks
|
||||
this as the initial value for openclaw workspaces. */}
|
||||
<option value="/agent-home">/agent-home</option>
|
||||
</select>
|
||||
<span className="text-[10px] text-ink-mid">{fileCount} files</span>
|
||||
</div>
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for the /agent-home root selector + per-runtime default-root
|
||||
* + secret-shape denial placeholder (internal#425 Phase 3).
|
||||
*
|
||||
* Separate file so the diff is reviewable as a unit and the existing
|
||||
* FilesToolbar / FileEditor / FilesTab tests don't have to grow
|
||||
* agent-home-specific cases. Once Phase 2b lands, the read-only +
|
||||
* 501-stub assertions here can be tightened (or moved into the main
|
||||
* test file as the agent-home root becomes a first-class affordance).
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FilesToolbar } from "../FilesToolbar";
|
||||
import {
|
||||
FileEditor,
|
||||
SECRET_SHAPE_DENIED_MARKER,
|
||||
} from "../FileEditor";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("internal#425 Phase 3 — /agent-home root selector", () => {
|
||||
it("dropdown includes /agent-home as an option", () => {
|
||||
// Pins the affordance is in the DOM even pre-Phase-2b — the
|
||||
// canvas design freezes today, the backend lands the dispatch
|
||||
// later. Without this, a future refactor that drops the option
|
||||
// would silently regress the RFC's Phase 1 contract (canvas
|
||||
// visibility) without breaking any other test.
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={0}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const select = screen.getByRole("combobox", {
|
||||
name: /file root directory/i,
|
||||
}) as HTMLSelectElement;
|
||||
const values = Array.from(select.options).map((o) => o.value);
|
||||
expect(values).toContain("/agent-home");
|
||||
});
|
||||
|
||||
it("dropdown shows /agent-home as the SELECTED root when prop is /agent-home", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/agent-home"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={0}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const select = screen.getByRole("combobox", {
|
||||
name: /file root directory/i,
|
||||
}) as HTMLSelectElement;
|
||||
expect(select.value).toBe("/agent-home");
|
||||
});
|
||||
});
|
||||
|
||||
describe("internal#425 Phase 3 — secret-shape denial placeholder", () => {
|
||||
// Files API Phase 2b returns SECRET_SHAPE_DENIED_MARKER as the file
|
||||
// body when the file's path or content matched a credential regex.
|
||||
// The editor MUST render the marker as a placeholder, not pump it
|
||||
// through the textarea — that would put the marker (and any future
|
||||
// matched bytes if the backend contract changes) into the DOM
|
||||
// value, clipboard, and inspector.
|
||||
|
||||
it("renders the denial placeholder INSTEAD of the textarea when fileContent is the marker", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
selectedFile="agent/.openclaw/secrets.env"
|
||||
fileContent={SECRET_SHAPE_DENIED_MARKER}
|
||||
editContent={SECRET_SHAPE_DENIED_MARKER}
|
||||
setEditContent={vi.fn()}
|
||||
loadingFile={false}
|
||||
saving={false}
|
||||
success={null}
|
||||
root="/agent-home"
|
||||
onSave={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Placeholder region present
|
||||
expect(
|
||||
screen.getByRole("region", { name: /file content denied/i }),
|
||||
).toBeTruthy();
|
||||
// Marker text visible (so a debugging operator sees the canonical
|
||||
// contract string without having to dig into the source).
|
||||
expect(screen.getByText(SECRET_SHAPE_DENIED_MARKER)).toBeTruthy();
|
||||
// Critically: NO textarea — the bytes never reach a controlled
|
||||
// input. A regression that re-introduces the textarea path would
|
||||
// make the matched marker (and any future content) selectable +
|
||||
// copyable.
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the textarea normally when fileContent is regular content", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
selectedFile="config.yaml"
|
||||
fileContent="name: openclaw\n"
|
||||
editContent="name: openclaw\n"
|
||||
setEditContent={vi.fn()}
|
||||
loadingFile={false}
|
||||
saving={false}
|
||||
success={null}
|
||||
root="/configs"
|
||||
onSave={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("textbox")).toBeTruthy();
|
||||
expect(screen.queryByRole("region", { name: /file content denied/i }))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
it("/agent-home renders textarea READ-ONLY for non-denied content", () => {
|
||||
// Phase 2b ships read + delete on /agent-home; write semantics
|
||||
// are decided later. Until then, the canvas presents the editor
|
||||
// as read-only so a user can't type into a buffer that the
|
||||
// backend will refuse to PUT. Without this gate, the user would
|
||||
// edit, hit Save, get a 501, and lose their context for why.
|
||||
render(
|
||||
<FileEditor
|
||||
selectedFile=".openclaw/agent-card.json"
|
||||
fileContent='{"name":"openclaw"}'
|
||||
editContent='{"name":"openclaw"}'
|
||||
setEditContent={vi.fn()}
|
||||
loadingFile={false}
|
||||
saving={false}
|
||||
success={null}
|
||||
root="/agent-home"
|
||||
onSave={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
expect(textarea.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("/configs renders textarea WRITABLE (regression guard for the read-only gate)", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
selectedFile="config.yaml"
|
||||
fileContent="name: x\n"
|
||||
editContent="name: x\n"
|
||||
setEditContent={vi.fn()}
|
||||
loadingFile={false}
|
||||
saving={false}
|
||||
success={null}
|
||||
root="/configs"
|
||||
onSave={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
expect(textarea.readOnly).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("internal#425 Phase 3 — marker constant is the canonical string", () => {
|
||||
// The marker string is part of the canvas <-> workspace-server
|
||||
// contract. The workspace-server emits this exact body; the canvas
|
||||
// detects it by exact-equality. A typo on either side would
|
||||
// silently break detection — the canvas would render the literal
|
||||
// string in the textarea instead of the placeholder. Pin the
|
||||
// contract value here.
|
||||
it("matches the contract value '<denied: secret-shape>'", () => {
|
||||
expect(SECRET_SHAPE_DENIED_MARKER).toBe("<denied: secret-shape>");
|
||||
});
|
||||
});
|
||||
@@ -194,12 +194,7 @@ func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspace
|
||||
}
|
||||
db.ClearWorkspaceKeys(ctx, workspaceID)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
|
||||
// Tracked via goAsync (not bare `go`) so the asyncWG can be drained
|
||||
// before a test swaps the global db.DB. runRestartCycle reads db.DB
|
||||
// before its provisioner gate, so an untracked detached goroutine
|
||||
// races setupTestDB's t.Cleanup db.DB restore. Matches the already-
|
||||
// correct site at a2a_proxy.go:648.
|
||||
h.goAsync(func() { h.RestartByID(workspaceID) })
|
||||
go h.RestartByID(workspaceID)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -246,10 +241,7 @@ func (h *WorkspaceHandler) preflightContainerHealth(ctx context.Context, workspa
|
||||
}
|
||||
db.ClearWorkspaceKeys(ctx, workspaceID)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
|
||||
// Tracked via goAsync (see maybeMarkContainerDead): preflight's
|
||||
// detached restart must be drainable so it doesn't race the global
|
||||
// db.DB swap in test cleanup.
|
||||
h.goAsync(func() { h.RestartByID(workspaceID) })
|
||||
go h.RestartByID(workspaceID)
|
||||
return &proxyA2AError{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
Response: gin.H{
|
||||
@@ -270,8 +262,7 @@ func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, calle
|
||||
errWsName = workspaceID
|
||||
}
|
||||
summary := "A2A request to " + errWsName + " failed: " + errMsg
|
||||
parent := ctx
|
||||
h.goAsync(func() {
|
||||
go func(parent context.Context) {
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
|
||||
defer cancel()
|
||||
LogActivity(logCtx, h.broadcaster, ActivityParams{
|
||||
@@ -286,7 +277,7 @@ func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, calle
|
||||
Status: "error",
|
||||
ErrorDetail: &errMsg,
|
||||
})
|
||||
})
|
||||
}(ctx)
|
||||
}
|
||||
|
||||
// logA2ASuccess records a successful A2A round-trip and (for canvas-initiated
|
||||
@@ -307,19 +298,18 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
|
||||
// silent workspaces. Only update when callerID is a real workspace (not
|
||||
// canvas, not a system caller) and the target returned 2xx/3xx.
|
||||
if callerID != "" && !isSystemCaller(callerID) && statusCode < 400 {
|
||||
h.goAsync(func() {
|
||||
go func() {
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := db.DB.ExecContext(bgCtx,
|
||||
`UPDATE workspaces SET last_outbound_at = NOW() WHERE id = $1`, callerID); err != nil {
|
||||
log.Printf("last_outbound_at update failed for %s: %v", callerID, err)
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
summary := a2aMethod + " → " + wsNameForLog
|
||||
toolTrace := extractToolTrace(respBody)
|
||||
parent := ctx
|
||||
h.goAsync(func() {
|
||||
go func(parent context.Context) {
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
|
||||
defer cancel()
|
||||
LogActivity(logCtx, h.broadcaster, ActivityParams{
|
||||
@@ -335,7 +325,7 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
|
||||
DurationMs: &durationMs,
|
||||
Status: logStatus,
|
||||
})
|
||||
})
|
||||
}(ctx)
|
||||
|
||||
if callerID == "" && statusCode < 400 {
|
||||
h.broadcaster.BroadcastOnly(workspaceID, string(events.EventA2AResponse), map[string]interface{}{
|
||||
@@ -520,8 +510,7 @@ func (h *WorkspaceHandler) logA2AReceiveQueued(ctx context.Context, workspaceID,
|
||||
wsName = workspaceID
|
||||
}
|
||||
summary := a2aMethod + " → " + wsName + " (queued for poll)"
|
||||
parent := ctx
|
||||
h.goAsync(func() {
|
||||
go func(parent context.Context) {
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
|
||||
defer cancel()
|
||||
LogActivity(logCtx, h.broadcaster, ActivityParams{
|
||||
@@ -534,7 +523,7 @@ func (h *WorkspaceHandler) logA2AReceiveQueued(ctx context.Context, workspaceID,
|
||||
RequestBody: json.RawMessage(body),
|
||||
Status: "ok",
|
||||
})
|
||||
})
|
||||
}(ctx)
|
||||
}
|
||||
|
||||
// readUsageMap extracts input_tokens / output_tokens from the "usage" key of m.
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -23,39 +22,8 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// liveTestHandlers tracks every WorkspaceHandler built during the test
|
||||
// binary's lifetime so setupTestDB can drain their in-flight goAsync
|
||||
// goroutines (notably the detached RestartByID restart cycle, which
|
||||
// reads the global db.DB) BEFORE restoring db.DB. Without this drain a
|
||||
// fire-and-forget restart goroutine spawned by one test outlives that
|
||||
// test and races the db.DB swap in a later test's t.Cleanup — the
|
||||
// 0x...d548 data race on platform/internal/db.DB.
|
||||
var (
|
||||
liveTestHandlersMu sync.Mutex
|
||||
liveTestHandlers []*WorkspaceHandler
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
newHandlerHook = func(h *WorkspaceHandler) {
|
||||
liveTestHandlersMu.Lock()
|
||||
liveTestHandlers = append(liveTestHandlers, h)
|
||||
liveTestHandlersMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// drainTestAsync waits for every tracked handler's goAsync goroutines to
|
||||
// finish. Called from setupTestDB's cleanup before db.DB is restored so
|
||||
// no detached restart/provision goroutine is mid-read of db.DB when the
|
||||
// pointer is swapped.
|
||||
func drainTestAsync() {
|
||||
liveTestHandlersMu.Lock()
|
||||
handlers := make([]*WorkspaceHandler, len(liveTestHandlers))
|
||||
copy(handlers, liveTestHandlers)
|
||||
liveTestHandlersMu.Unlock()
|
||||
for _, h := range handlers {
|
||||
h.waitAsyncForTest()
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestDB creates a sqlmock DB and assigns it to the global db.DB.
|
||||
@@ -69,16 +37,7 @@ func setupTestDB(t *testing.T) sqlmock.Sqlmock {
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() {
|
||||
// Drain detached async goroutines (e.g. goAsync(RestartByID),
|
||||
// which reads db.DB in runRestartCycle before its provisioner
|
||||
// gate) BEFORE swapping db.DB back. Doing the restore first
|
||||
// would let an in-flight restart goroutine read db.DB while
|
||||
// this line writes it — the data race this guards against.
|
||||
drainTestAsync()
|
||||
db.DB = prevDB
|
||||
mockDB.Close()
|
||||
})
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
// Disable SSRF checks for the duration of this test only. Restore
|
||||
// the previous state via t.Cleanup so that TestIsSafeURL_* tests
|
||||
|
||||
@@ -56,11 +56,9 @@ const (
|
||||
// (an externally routable address) is used directly.
|
||||
func (h *WorkspaceHandler) gracefulPreRestart(ctx context.Context, workspaceID string) {
|
||||
// Non-blocking send — don't stall the restart cycle.
|
||||
// Run in a tracked async goroutine (goAsync, not bare `go`) so the
|
||||
// caller (runRestartCycle) can proceed to stopForRestart without
|
||||
// waiting, while the test harness can still drain it before swapping
|
||||
// the global db.DB (resolveAgentURLForRestartSignal reads db.DB).
|
||||
h.goAsync(func() {
|
||||
// Run in a detached goroutine so the caller (runRestartCycle) can
|
||||
// proceed to stopForRestart without waiting.
|
||||
go func() {
|
||||
signalCtx, cancel := context.WithTimeout(context.Background(), restartSignalTimeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -111,7 +109,7 @@ func (h *WorkspaceHandler) gracefulPreRestart(ctx context.Context, workspaceID s
|
||||
} else {
|
||||
log.Printf("A2AGracefulRestart: %s returned status %d — proceeding with stop", workspaceID, resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// resolveAgentURLForRestartSignal returns the routable URL for the workspace
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package handlers
|
||||
|
||||
// template_files_agent_home_stub_test.go — pins the Phase-1 stub
|
||||
// contract for the /agent-home root added by internal#425 RFC.
|
||||
//
|
||||
// Today (pre-Phase-2b), every Files API verb against `?root=/agent-home`
|
||||
// must return HTTP 501 with the canonical pending-message body. The
|
||||
// stub MUST NOT:
|
||||
// 1. Hit the DB (the workspace might not even exist yet from the
|
||||
// canvas's POV — the root selector is testable without one).
|
||||
// 2. Touch the EIC tunnel / Docker / template-dir paths — those
|
||||
// would 500/404/[] depending on the env and confuse the canvas.
|
||||
// 3. Accept writes/deletes that the future docker-exec backend
|
||||
// would reject — fail closed.
|
||||
//
|
||||
// When Phase 2b lands, this file gets replaced by a real
|
||||
// docker-exec dispatch test; the stub-message constant in
|
||||
// templates.go disappears.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestAgentHomeAllowedRoot pins that /agent-home is in the allowedRoots
|
||||
// set. Without this, a future refactor that drops the key would
|
||||
// silently degrade the canvas root selector to a 400 instead of the
|
||||
// stub 501.
|
||||
func TestAgentHomeAllowedRoot(t *testing.T) {
|
||||
if !allowedRoots["/agent-home"] {
|
||||
t.Fatal("/agent-home must be in allowedRoots — RFC #425 contract")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentHomeStub_AllVerbs_Return501 pins the canonical stub
|
||||
// response across all four verbs. Each must:
|
||||
//
|
||||
// - status 501
|
||||
// - body contains the canonical "/agent-home not implemented" prefix
|
||||
// - NOT contain "workspace not found" (proves we short-circuit before
|
||||
// the DB lookup)
|
||||
//
|
||||
// Driven as a table to keep symmetry — adding a fifth verb in the
|
||||
// future means adding one row here.
|
||||
func TestAgentHomeStub_AllVerbs_Return501(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
invoke func(c *gin.Context)
|
||||
}{
|
||||
{
|
||||
name: "ListFiles",
|
||||
method: "GET",
|
||||
invoke: func(c *gin.Context) { (&TemplatesHandler{}).ListFiles(c) },
|
||||
},
|
||||
{
|
||||
name: "ReadFile",
|
||||
method: "GET",
|
||||
invoke: func(c *gin.Context) { (&TemplatesHandler{}).ReadFile(c) },
|
||||
},
|
||||
{
|
||||
name: "WriteFile",
|
||||
method: "PUT",
|
||||
invoke: func(c *gin.Context) { (&TemplatesHandler{}).WriteFile(c) },
|
||||
},
|
||||
{
|
||||
name: "DeleteFile",
|
||||
method: "DELETE",
|
||||
invoke: func(c *gin.Context) { (&TemplatesHandler{}).DeleteFile(c) },
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{
|
||||
{Key: "id", Value: "ws-stub"},
|
||||
// Path param without leading slash so DeleteFile's
|
||||
// filepath.IsAbs guard doesn't 400 before the root
|
||||
// dispatch runs. The List/Read/Write paths strip the
|
||||
// leading slash themselves and accept either form.
|
||||
{Key: "path", Value: "notes.md"},
|
||||
}
|
||||
// WriteFile binds JSON; provide a minimal valid body so the
|
||||
// short-circuit isn't masked by the bind-error path.
|
||||
var body string
|
||||
if tc.method == "PUT" {
|
||||
body = `{"content":"x"}`
|
||||
}
|
||||
c.Request = httptest.NewRequest(
|
||||
tc.method,
|
||||
"/workspaces/ws-stub/files/notes.md?root=/agent-home",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
if body != "" {
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
tc.invoke(c)
|
||||
|
||||
if w.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("expected 501, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "/agent-home not implemented") {
|
||||
t.Errorf("body should contain canonical stub message; got %s", w.Body.String())
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "workspace not found") {
|
||||
t.Errorf("stub leaked through to DB lookup; body=%s", w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,35 +18,11 @@ import (
|
||||
)
|
||||
|
||||
// allowedRoots are the container paths that the Files API can browse.
|
||||
//
|
||||
// `/agent-home` (added 2026-05-15, internal#425 RFC) is the container's
|
||||
// own $HOME — `/root` for openclaw, `/home/agent` for claude-code/hermes
|
||||
// — browsed via `docker exec` rather than host-side `find`. The
|
||||
// dispatch is stubbed today (returns 501); full implementation lands in
|
||||
// Phase 2b of the RFC. The allowedRoots key is added now so the canvas
|
||||
// can design its root-selector UI against the final shape and the
|
||||
// stub-vs-full transition is server-side only.
|
||||
var allowedRoots = map[string]bool{
|
||||
"/configs": true,
|
||||
"/workspace": true,
|
||||
"/home": true,
|
||||
"/plugins": true,
|
||||
"/agent-home": true,
|
||||
}
|
||||
|
||||
// agentHomeStubMessage is the body returned by every Files API verb
|
||||
// when `?root=/agent-home` is requested before Phase 2b lands. Keep the
|
||||
// status code 501 (Not Implemented) — the route exists, the verb is
|
||||
// understood, but the handler is unimplemented. Distinguishes from
|
||||
// 400/404 so a canvas behind a less-current server can render a clean
|
||||
// "feature pending" state instead of a generic error.
|
||||
const agentHomeStubMessage = "/agent-home not implemented yet (internal#425 RFC Phase 2b — docker-exec backend pending)"
|
||||
|
||||
// isAgentHomeStubRequest returns true when the request targets the
|
||||
// stubbed /agent-home root. Centralised so every verb in this file
|
||||
// short-circuits with the same response shape.
|
||||
func isAgentHomeStubRequest(rootPath string) bool {
|
||||
return rootPath == "/agent-home"
|
||||
"/configs": true,
|
||||
"/workspace": true,
|
||||
"/home": true,
|
||||
"/plugins": true,
|
||||
}
|
||||
|
||||
// maxUploadFiles limits the number of files in a single import/replace.
|
||||
@@ -243,14 +219,7 @@ func (h *TemplatesHandler) ListFiles(c *gin.Context) {
|
||||
// ?depth= — max depth to recurse (default: 1, max: 5)
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins, /agent-home"})
|
||||
return
|
||||
}
|
||||
// /agent-home dispatch is stubbed pre-Phase-2b. Short-circuit before
|
||||
// the DB lookup + EIC dance so a canvas exercising the new root key
|
||||
// gets a clean 501 instead of a half-effort response.
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": agentHomeStubMessage})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
}
|
||||
subPath := c.DefaultQuery("path", "")
|
||||
@@ -414,11 +383,7 @@ func (h *TemplatesHandler) ReadFile(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins, /agent-home"})
|
||||
return
|
||||
}
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": agentHomeStubMessage})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -531,11 +496,7 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins, /agent-home"})
|
||||
return
|
||||
}
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": agentHomeStubMessage})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
}
|
||||
var wsName, instanceID, runtime string
|
||||
@@ -612,11 +573,7 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins, /agent-home"})
|
||||
return
|
||||
}
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": agentHomeStubMessage})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
}
|
||||
var wsName, instanceID, runtime string
|
||||
|
||||
@@ -10,20 +10,8 @@ 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{}
|
||||
@@ -43,10 +31,6 @@ 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 != "" {
|
||||
@@ -69,7 +53,6 @@ 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
|
||||
}
|
||||
@@ -102,10 +85,6 @@ 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
|
||||
@@ -138,10 +117,6 @@ 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,15 +41,6 @@ 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.
|
||||
@@ -90,13 +81,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(wsUUID1, 50, 0).
|
||||
WithArgs("ws-1", 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: wsUUID1}})
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -130,7 +121,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: wsUUID2}})
|
||||
"/workspaces/ws-2/tokens", gin.Params{{Key: "id", Value: "ws-2"}})
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 on empty list, got %d", w.Code)
|
||||
@@ -155,7 +146,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: wsUUID3}})
|
||||
"/workspaces/ws-3/tokens", gin.Params{{Key: "id", Value: "ws-3"}})
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("query error must surface as 500, got %d", w.Code)
|
||||
@@ -167,13 +158,13 @@ func TestTokenHandler_List_RespectsLimit(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`SELECT id, prefix, created_at, last_used_at`).
|
||||
WithArgs(wsUUID1, 10, 5).
|
||||
WithArgs("ws-1", 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: wsUUID1}}
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
NewTokenHandler().List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
@@ -195,7 +186,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: wsUUID1}})
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("scan error must surface as 500, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -210,11 +201,11 @@ func TestTokenHandler_Create_RateLimited(t *testing.T) {
|
||||
|
||||
// Count query returns 50 (== max) → 429.
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
|
||||
WithArgs(wsUUID1).
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(50))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().Create, "POST",
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
|
||||
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("max active tokens should 429, got %d", w.Code)
|
||||
@@ -234,7 +225,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: wsUUID1}})
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("IssueToken DB error must 500, got %d", w.Code)
|
||||
@@ -251,7 +242,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: wsUUID1}})
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -266,7 +257,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 != wsUUID1 {
|
||||
if body.WorkspaceID != "ws-1" {
|
||||
t.Errorf("workspace_id mismatch: %q", body.WorkspaceID)
|
||||
}
|
||||
}
|
||||
@@ -278,12 +269,12 @@ func TestTokenHandler_Revoke_HappyPath(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens\s+SET revoked_at = now\(\)`).
|
||||
WithArgs("tok-1", wsUUID1).
|
||||
WithArgs("tok-1", "ws-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().Revoke, "DELETE",
|
||||
"/workspaces/ws-1/tokens/tok-1", gin.Params{
|
||||
{Key: "id", Value: wsUUID1},
|
||||
{Key: "id", Value: "ws-1"},
|
||||
{Key: "tokenId", Value: "tok-1"},
|
||||
})
|
||||
|
||||
@@ -298,12 +289,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", wsUUID1).
|
||||
WithArgs("tok-ghost", "ws-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().Revoke, "DELETE",
|
||||
"/workspaces/ws-1/tokens/tok-ghost", gin.Params{
|
||||
{Key: "id", Value: wsUUID1},
|
||||
{Key: "id", Value: "ws-1"},
|
||||
{Key: "tokenId", Value: "tok-ghost"},
|
||||
})
|
||||
|
||||
@@ -321,7 +312,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: wsUUID1},
|
||||
{Key: "id", Value: "ws-1"},
|
||||
{Key: "tokenId", Value: "tok-1"},
|
||||
})
|
||||
|
||||
@@ -330,59 +321,6 @@ 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,7 +11,6 @@ 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) }
|
||||
@@ -168,14 +167,11 @@ func TestTokenHandler_RevokeWrongWorkspace(t *testing.T) {
|
||||
|
||||
h := NewTokenHandler()
|
||||
|
||||
// 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()
|
||||
// Try to revoke with a different workspace ID — should 404
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: otherWS}, {Key: "tokenId", Value: tokenID}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/"+otherWS+"/tokens/"+tokenID, nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: "wrong-workspace-id"}, {Key: "tokenId", Value: tokenID}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/wrong/tokens/"+tokenID, nil)
|
||||
h.Revoke(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
|
||||
@@ -80,15 +80,6 @@ type WorkspaceHandler struct {
|
||||
asyncWG sync.WaitGroup
|
||||
}
|
||||
|
||||
// newHandlerHook, when non-nil, is invoked for every WorkspaceHandler
|
||||
// created via NewWorkspaceHandler. It is nil in production (zero cost);
|
||||
// the test harness sets it so setupTestDB can drain every handler's
|
||||
// in-flight async goroutines before swapping the global db.DB. Without
|
||||
// this, a detached restart goroutine (maybeMarkContainerDead ->
|
||||
// goAsync(RestartByID) -> runRestartCycle reads db.DB) races the
|
||||
// db.DB restore in another test's t.Cleanup.
|
||||
var newHandlerHook func(*WorkspaceHandler)
|
||||
|
||||
func (h *WorkspaceHandler) goAsync(fn func()) {
|
||||
h.asyncWG.Add(1)
|
||||
go func() {
|
||||
@@ -117,9 +108,6 @@ func NewWorkspaceHandler(b events.EventEmitter, p *provisioner.Provisioner, plat
|
||||
if p != nil {
|
||||
h.provisioner = p
|
||||
}
|
||||
if newHandlerHook != nil {
|
||||
newHandlerHook(h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
@@ -3,57 +3,505 @@ 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/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/ws"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// -------- Org-scoped recipient query tests (OFFSEC-015) --------
|
||||
// setupBroadcastDB uses QueryMatcherEqual so SQL strings with quoted literals
|
||||
// (e.g. status != 'removed') are compared verbatim, not as regex.
|
||||
func setupBroadcastDB(t *testing.T) sqlmock.Sqlmock {
|
||||
t.Helper()
|
||||
mockDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
return mock
|
||||
}
|
||||
|
||||
// broadcastTestUUID is a properly formatted test UUID.
|
||||
const broadcastTestUUID = "bbbbbbbb-0001-0001-0001-000000000001"
|
||||
|
||||
// buildBroadcastCtx creates a gin.Context wired for POST /workspaces/:id/broadcast.
|
||||
func buildBroadcastCtx(id, body string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req := httptest.NewRequest(http.MethodPost, "/workspaces/"+id+"/broadcast", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.Request = req.WithContext(context.Background())
|
||||
c.Params = gin.Params{{Key: "id", Value: id}}
|
||||
return c, w
|
||||
}
|
||||
|
||||
// ─── Pure function ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBroadcastTruncate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
max int
|
||||
want string
|
||||
}{
|
||||
{"empty string", "", 10, ""},
|
||||
{"under limit", "hello", 10, "hello"},
|
||||
{"exactly at limit", "hello", 5, "hello"},
|
||||
{"over limit", "hello world", 5, "hello…"},
|
||||
{"unicode over limit", "こんにちは世界", 5, "こんにちは…"},
|
||||
{"ascii over limit", "abcdefghij", 5, "abcde…"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := broadcastTruncate(tc.s, tc.max)
|
||||
if got != tc.want {
|
||||
t.Errorf("broadcastTruncate(%q, %d) = %q; want %q", tc.s, tc.max, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Validation ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBroadcast_InvalidWorkspaceID(t *testing.T) {
|
||||
c, w := buildBroadcastCtx("not-a-uuid", `{"message":"hello"}`)
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("want 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_MissingMessage(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{}`)
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("want 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_MalformedJSON(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `not json`)
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("want 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Auth / Authz ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBroadcast_WorkspaceNotFound(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
// Workspace lookup returns no rows.
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("want 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_WorkspaceLookupQueryError(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("want 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_BroadcastDisabled(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
// Workspace found but broadcast_enabled=false.
|
||||
rows := sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
|
||||
AddRow("test-workspace", false)
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("want 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Org root lookup error (blocks cross-org broadcast) ──────────────────────
|
||||
|
||||
func TestBroadcast_OrgRootLookupError(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
// Workspace lookup succeeds.
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("test-workspace", true))
|
||||
|
||||
// Org root CTE fails — handler must NOT proceed to the recipient query
|
||||
// (which would broadcast cross-org if org root lookup failed silently).
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE id = $1 UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.id = c.parent_id ) SELECT root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("want 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DB error paths ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestBroadcast_RecipientQueryError(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
// Workspace lookup succeeds with broadcast_enabled=true.
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("test-workspace", true))
|
||||
|
||||
// Org root lookup succeeds.
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE id = $1 UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.id = c.parent_id ) SELECT root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(broadcastTestUUID))
|
||||
|
||||
// Recipient query fails.
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE parent_id IS NULL UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.parent_id = c.id ) SELECT c.id FROM org_chain c WHERE c.root_id = $1 AND c.id != $2 AND EXISTS ( SELECT 1 FROM workspaces w WHERE w.id = c.id AND w.status != 'removed' )").
|
||||
WithArgs(broadcastTestUUID, broadcastTestUUID).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("want 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_RecipientRowsError(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("test-workspace", true))
|
||||
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE id = $1 UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.id = c.parent_id ) SELECT root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(broadcastTestUUID))
|
||||
|
||||
// Recipient query succeeds but rows.Err() fails.
|
||||
badRows := sqlmock.NewRows([]string{"id"}).AddRow("ws-2").RowError(0, sql.ErrConnDone)
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE parent_id IS NULL UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.parent_id = c.id ) SELECT c.id FROM org_chain c WHERE c.root_id = $1 AND c.id != $2 AND EXISTS ( SELECT 1 FROM workspaces w WHERE w.id = c.id AND w.status != 'removed' )").
|
||||
WithArgs(broadcastTestUUID, broadcastTestUUID).
|
||||
WillReturnRows(badRows)
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("want 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Success paths ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestBroadcast_Success_OneRecipient(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello world"}`)
|
||||
|
||||
// Workspace lookup.
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("sender-workspace", true))
|
||||
|
||||
// Org root lookup.
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE id = $1 UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.id = c.parent_id ) SELECT root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(broadcastTestUUID))
|
||||
|
||||
// Recipient query: one recipient.
|
||||
recipRows := sqlmock.NewRows([]string{"id"}).AddRow("ws-recipient-1")
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE parent_id IS NULL UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.parent_id = c.id ) SELECT c.id FROM org_chain c WHERE c.root_id = $1 AND c.id != $2 AND EXISTS ( SELECT 1 FROM workspaces w WHERE w.id = c.id AND w.status != 'removed' )").
|
||||
WithArgs(broadcastTestUUID, broadcastTestUUID).
|
||||
WillReturnRows(recipRows)
|
||||
|
||||
// Activity log insert for recipient.
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, summary, status) VALUES ($1, 'broadcast_receive', 'broadcast', $2, $3, 'ok')").
|
||||
WithArgs("ws-recipient-1", broadcastTestUUID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Activity log insert for sender (broadcast_sent).
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, summary, status) VALUES ($1, 'broadcast_sent', 'broadcast', $2, 'ok')").
|
||||
WithArgs(broadcastTestUUID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_Success_NoRecipients(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("solo-workspace", true))
|
||||
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE id = $1 UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.id = c.parent_id ) SELECT root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(broadcastTestUUID))
|
||||
|
||||
// No recipients.
|
||||
recipRows := sqlmock.NewRows([]string{"id"})
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE parent_id IS NULL UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.parent_id = c.id ) SELECT c.id FROM org_chain c WHERE c.root_id = $1 AND c.id != $2 AND EXISTS ( SELECT 1 FROM workspaces w WHERE w.id = c.id AND w.status != 'removed' )").
|
||||
WithArgs(broadcastTestUUID, broadcastTestUUID).
|
||||
WillReturnRows(recipRows)
|
||||
|
||||
// Activity log insert for sender (broadcast_sent).
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, summary, status) VALUES ($1, 'broadcast_sent', 'broadcast', $2, 'ok')").
|
||||
WithArgs(broadcastTestUUID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_Success_MultipleRecipients(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("broadcaster", true))
|
||||
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE id = $1 UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.id = c.parent_id ) SELECT root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(broadcastTestUUID))
|
||||
|
||||
// Three recipients.
|
||||
recipRows := sqlmock.NewRows([]string{"id"}).
|
||||
AddRow("ws-1").AddRow("ws-2").AddRow("ws-3")
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE parent_id IS NULL UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.parent_id = c.id ) SELECT c.id FROM org_chain c WHERE c.root_id = $1 AND c.id != $2 AND EXISTS ( SELECT 1 FROM workspaces w WHERE w.id = c.id AND w.status != 'removed' )").
|
||||
WithArgs(broadcastTestUUID, broadcastTestUUID).
|
||||
WillReturnRows(recipRows)
|
||||
|
||||
// Each recipient gets a broadcast_receive log.
|
||||
for _, rid := range []string{"ws-1", "ws-2", "ws-3"} {
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, summary, status) VALUES ($1, 'broadcast_receive', 'broadcast', $2, $3, 'ok')").
|
||||
WithArgs(rid, broadcastTestUUID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
}
|
||||
|
||||
// Sender log.
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, summary, status) VALUES ($1, 'broadcast_sent', 'broadcast', $2, 'ok')").
|
||||
WithArgs(broadcastTestUUID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Recipient insert failure (logged, continues) ─────────────────────────────
|
||||
|
||||
func TestBroadcast_RecipientInsertError_ContinuesAndSucceeds(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("broadcaster", true))
|
||||
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE id = $1 UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.id = c.parent_id ) SELECT root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(broadcastTestUUID))
|
||||
|
||||
// Two recipients.
|
||||
recipRows := sqlmock.NewRows([]string{"id"}).AddRow("ws-1").AddRow("ws-2")
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE parent_id IS NULL UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.parent_id = c.id ) SELECT c.id FROM org_chain c WHERE c.root_id = $1 AND c.id != $2 AND EXISTS ( SELECT 1 FROM workspaces w WHERE w.id = c.id AND w.status != 'removed' )").
|
||||
WithArgs(broadcastTestUUID, broadcastTestUUID).
|
||||
WillReturnRows(recipRows)
|
||||
|
||||
// First recipient insert fails (logged, continues).
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, summary, status) VALUES ($1, 'broadcast_receive', 'broadcast', $2, $3, 'ok')").
|
||||
WithArgs("ws-1", broadcastTestUUID, sqlmock.AnyArg()).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
// Second recipient insert succeeds.
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, summary, status) VALUES ($1, 'broadcast_receive', 'broadcast', $2, $3, 'ok')").
|
||||
WithArgs("ws-2", broadcastTestUUID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Sender log.
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, summary, status) VALUES ($1, 'broadcast_sent', 'broadcast', $2, 'ok')").
|
||||
WithArgs(broadcastTestUUID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
// Handler returns 200 even though one insert failed — it logs and continues.
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200 despite insert error, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sender activity log insert failure (logged, still 200) ───────────────────
|
||||
|
||||
func TestBroadcast_SenderLogInsertError_Still200(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("broadcaster", true))
|
||||
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE id = $1 UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.id = c.parent_id ) SELECT root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(broadcastTestUUID))
|
||||
|
||||
recipRows := sqlmock.NewRows([]string{"id"}).AddRow("ws-1")
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE parent_id IS NULL UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.parent_id = c.id ) SELECT c.id FROM org_chain c WHERE c.root_id = $1 AND c.id != $2 AND EXISTS ( SELECT 1 FROM workspaces w WHERE w.id = c.id AND w.status != 'removed' )").
|
||||
WithArgs(broadcastTestUUID, broadcastTestUUID).
|
||||
WillReturnRows(recipRows)
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, summary, status) VALUES ($1, 'broadcast_receive', 'broadcast', $2, $3, 'ok')").
|
||||
WithArgs("ws-1", broadcastTestUUID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Sender log fails — but handler still returns 200 (logged only).
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, summary, status) VALUES ($1, 'broadcast_sent', 'broadcast', $2, 'ok')").
|
||||
WithArgs(broadcastTestUUID, sqlmock.AnyArg()).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200 despite sender log error, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Org-scoped recipient query tests (OFFSEC-015) ────────────────────────────
|
||||
|
||||
// TestBroadcast_OrgScopedRecipients verifies that a broadcast from Org-A does
|
||||
// NOT reach workspaces belonging to Org-B. This is the core regression test
|
||||
// for OFFSEC-015: the original query had no org filter, so a workspace in
|
||||
// Org-A could broadcast to every non-removed workspace in the entire DB,
|
||||
// including workspaces owned by other tenants.
|
||||
// Org-A could broadcast to every non-removed workspace in the entire DB.
|
||||
func TestBroadcast_OrgScopedRecipients(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock := setupBroadcastDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
// Org-A structure:
|
||||
// org-a-root (parent_id = NULL) ← sender
|
||||
// ├── ws-a-child
|
||||
// Org-B structure:
|
||||
// org-b-root (parent_id = NULL)
|
||||
// └── ws-b-child
|
||||
senderID := "00000000-0000-0000-0000-000000000001" // org-a-root
|
||||
wsAChild := "00000000-0000-0000-0000-000000000002"
|
||||
// ws-b-child is in Org-B (different root); the org-scoped query MUST NOT include it.
|
||||
|
||||
// 1. Sender lookup
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Org-A Root", true))
|
||||
|
||||
// 2. Org root lookup — sender is its own root (parent_id = NULL)
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE id = $1 UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.id = c.parent_id ) SELECT root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1").
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
||||
|
||||
// 3. Org-scoped recipient query — MUST include org filter so ws-b-child is NOT included.
|
||||
// The query joins on org_chain.root_id = orgRootID, which scopes to Org-A only.
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID, senderID). // orgRootID, senderID (EXCLUDED)
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsAChild)) // only Org-A child
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE parent_id IS NULL UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.parent_id = c.id ) SELECT c.id FROM org_chain c WHERE c.root_id = $1 AND c.id != $2 AND EXISTS ( SELECT 1 FROM workspaces w WHERE w.id = c.id AND w.status != 'removed' )").
|
||||
WithArgs(senderID, senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsAChild))
|
||||
|
||||
// Activity log inserts
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(wsAChild, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, summary, status) VALUES ($1, 'broadcast_receive', 'broadcast', $2, $3, 'ok')").
|
||||
WithArgs(wsAChild, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, summary, status) VALUES ($1, 'broadcast_sent', 'broadcast', $2, 'ok')").
|
||||
WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -76,38 +524,37 @@ func TestBroadcast_OrgScopedRecipients(t *testing.T) {
|
||||
t.Errorf("expected status 'sent', got %v", resp["status"])
|
||||
}
|
||||
// ws-b-child is in a DIFFERENT org — the org-scoped query MUST NOT include it.
|
||||
// If it were included, the mock would have an unmet expectation.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations — cross-org workspace was included in broadcast: %v", err)
|
||||
t.Errorf("unmet mock expectations — cross-org workspace was included: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_OrgScoped_OrgRootSender verifies that when the sender IS the
|
||||
// org root (parent_id = NULL), broadcasts still reach sibling workspaces.
|
||||
func TestBroadcast_OrgScoped_OrgRootSender(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock := setupBroadcastDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001" // org-a-root
|
||||
senderID := "00000000-0000-0000-0000-000000000001"
|
||||
siblingID := "00000000-0000-0000-0000-000000000002"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Root Agent", true))
|
||||
|
||||
// Sender is the org root — CTE returns sender's own ID as root
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE id = $1 UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.id = c.parent_id ) SELECT root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1").
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
||||
|
||||
// Recipients in same org, excluding sender
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE parent_id IS NULL UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.parent_id = c.id ) SELECT c.id FROM org_chain c WHERE c.root_id = $1 AND c.id != $2 AND EXISTS ( SELECT 1 FROM workspaces w WHERE w.id = c.id AND w.status != 'removed' )").
|
||||
WithArgs(senderID, senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(siblingID))
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(siblingID, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, summary, status) VALUES ($1, 'broadcast_receive', 'broadcast', $2, $3, 'ok')").
|
||||
WithArgs(siblingID, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, summary, status) VALUES ($1, 'broadcast_sent', 'broadcast', $2, 'ok')").
|
||||
WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -129,30 +576,30 @@ func TestBroadcast_OrgScoped_OrgRootSender(t *testing.T) {
|
||||
// TestBroadcast_OrgScoped_ChildWorkspaceSender verifies that a non-root child
|
||||
// workspace can broadcast to siblings in the same org.
|
||||
func TestBroadcast_OrgScoped_ChildWorkspaceSender(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock := setupBroadcastDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
orgRootID := "00000000-0000-0000-0000-000000000001"
|
||||
senderID := "00000000-0000-0000-0000-000000000002" // child workspace
|
||||
senderID := "00000000-0000-0000-0000-000000000002"
|
||||
siblingID := "00000000-0000-0000-0000-000000000003"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Child Agent", true))
|
||||
|
||||
// Org root lookup — walk up to find org-a-root
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE id = $1 UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.id = c.parent_id ) SELECT root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1").
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(orgRootID))
|
||||
|
||||
// Recipients: same org, excluding sender
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE parent_id IS NULL UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.parent_id = c.id ) SELECT c.id FROM org_chain c WHERE c.root_id = $1 AND c.id != $2 AND EXISTS ( SELECT 1 FROM workspaces w WHERE w.id = c.id AND w.status != 'removed' )").
|
||||
WithArgs(orgRootID, senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(siblingID))
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(siblingID, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, summary, status) VALUES ($1, 'broadcast_receive', 'broadcast', $2, $3, 'ok')").
|
||||
WithArgs(siblingID, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, summary, status) VALUES ($1, 'broadcast_sent', 'broadcast', $2, 'ok')").
|
||||
WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -171,216 +618,35 @@ func TestBroadcast_OrgScoped_ChildWorkspaceSender(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Non-regression cases --------
|
||||
|
||||
func TestBroadcast_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000099"
|
||||
// UUID is valid, but no workspace row matches
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnError(errors.New("workspace not found"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"test"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.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_Disabled(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001"
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Disabled Agent", false))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"should not send"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp["error"] != "broadcast_disabled" {
|
||||
t.Errorf("expected error 'broadcast_disabled', got %v", resp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_EmptyOrg_NoRecipients(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001" // org root, only workspace in org
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Lone Root", true))
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
||||
|
||||
// No other workspaces in this org
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID, senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"hello org"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp["delivered"] != float64(0) {
|
||||
t.Errorf("expected delivered=0, got %v", resp["delivered"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_InvalidWorkspaceID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "not-a-uuid"}}
|
||||
body := `{"message":"test"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/not-a-uuid/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_MissingMessage(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000001"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/00000000-0000-0000-0000-000000000001/broadcast", bytes.NewBufferString("{}"))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_OrgRootLookupFails verifies that if the recursive CTE for
|
||||
// finding the org root errors, the handler returns 500 instead of proceeding
|
||||
// with an un-scoped query that would broadcast to all orgs.
|
||||
func TestBroadcast_OrgRootLookupFails(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Root Agent", true))
|
||||
|
||||
// Org root CTE fails
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"should not broadcast"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
// The recipient query MUST NOT be called — it would broadcast cross-org
|
||||
// if the org root lookup failed silently.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_OrgScoped_SelfBroadcastExcluded verifies that broadcasting
|
||||
// from a workspace does not send a broadcast_receive to the sender itself
|
||||
// (the sender logs broadcast_sent, not broadcast_receive).
|
||||
// from a workspace does not send a broadcast_receive to the sender itself.
|
||||
func TestBroadcast_OrgScoped_SelfBroadcastExcluded(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock := setupBroadcastDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001"
|
||||
peerID := "00000000-0000-0000-0000-000000000002"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Root Agent", true))
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE id = $1 UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.id = c.parent_id ) SELECT root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1").
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
||||
|
||||
// Recipient query MUST exclude sender via id != senderID
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS ( SELECT id, parent_id, id AS root_id FROM workspaces WHERE parent_id IS NULL UNION ALL SELECT w.id, w.parent_id, c.root_id FROM workspaces w JOIN org_chain c ON w.parent_id = c.id ) SELECT c.id FROM org_chain c WHERE c.root_id = $1 AND c.id != $2 AND EXISTS ( SELECT 1 FROM workspaces w WHERE w.id = c.id AND w.status != 'removed' )").
|
||||
WithArgs(senderID, senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(peerID))
|
||||
|
||||
// Peer receives broadcast_receive
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(peerID, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, summary, status) VALUES ($1, 'broadcast_receive', 'broadcast', $2, $3, 'ok')").
|
||||
WithArgs(peerID, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// Sender logs broadcast_sent (NOT broadcast_receive)
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, summary, status) VALUES ($1, 'broadcast_sent', 'broadcast', $2, 'ok')").
|
||||
WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -398,31 +664,3 @@ func TestBroadcast_OrgScoped_SelfBroadcastExcluded(t *testing.T) {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_Truncate tests that messages are truncated with the Unicode ellipsis
|
||||
// TestBroadcast_Truncate tests that messages are truncated with the Unicode ellipsis
|
||||
// character (U+2026) when len(msg) > max. The truncated output is max runes + "…",
|
||||
// so truncating a 48-char string at max=20 produces 21 characters (20 runes + "…").
|
||||
func TestBroadcast_Truncate(t *testing.T) {
|
||||
cases := []struct {
|
||||
msg string
|
||||
max int
|
||||
expect string
|
||||
}{
|
||||
{"short", 120, "short"}, // under max — no truncation
|
||||
// exactly120chars (15) + 105 ones = 120 chars; at max=120 → unchanged
|
||||
{"exactly120chars1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", 120, "exactly120chars111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111…"},
|
||||
// "this is a longer mes" = 20 runes; + "…" = 21 chars
|
||||
{"this is a longer message that needs truncating", 20, "this is a longer mes…"},
|
||||
// at-max boundary: 20 chars at max=20 → no truncation
|
||||
{"exactly twenty chars", 20, "exactly twenty chars"},
|
||||
// over max: 11 chars at max=10 → 10 + "…" = 11
|
||||
{"hello world!", 10, "hello worl…"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
result := broadcastTruncate(tc.msg, tc.max)
|
||||
if result != tc.expect {
|
||||
t.Errorf("broadcastTruncate(%q, %d) = %q; want %q", tc.msg, tc.max, result, tc.expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,10 +237,10 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) {
|
||||
// the silent-drop bugs PRs #2811/#2824 closed). RestartWorkspaceAuto
|
||||
// enforces CP-FIRST ordering matching the other dispatchers — see
|
||||
// docs/architecture/backends.md.
|
||||
h.goAsync(func() {
|
||||
go func() {
|
||||
h.RestartWorkspaceAutoOpts(context.Background(), id, templatePath, configFiles, payload, resetClaudeSession)
|
||||
})
|
||||
h.goAsync(func() { h.sendRestartContext(id, restartData) })
|
||||
}()
|
||||
go h.sendRestartContext(id, restartData)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "provisioning", "config_dir": configLabel, "reset_session": resetClaudeSession})
|
||||
}
|
||||
@@ -610,9 +610,7 @@ func (h *WorkspaceHandler) runRestartCycle(workspaceID string) {
|
||||
h.provisionWorkspaceAutoSync(workspaceID, "", nil, payload)
|
||||
// sendRestartContext is a one-way notification to the new container; safe
|
||||
// to fire async — the next restart cycle won't depend on it completing.
|
||||
// Tracked via goAsync so the test harness can drain it before the
|
||||
// global db.DB swap (sendRestartContext reads db.DB).
|
||||
h.goAsync(func() { h.sendRestartContext(workspaceID, restartData) })
|
||||
go h.sendRestartContext(workspaceID, restartData)
|
||||
}
|
||||
|
||||
// Pause handles POST /workspaces/:id/pause
|
||||
|
||||
@@ -178,21 +178,12 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
// /admin/liveness and other admin-gated platform endpoints (core#831).
|
||||
// p.adminToken is read from os.Getenv("ADMIN_TOKEN") at provisioner creation;
|
||||
// it is also used for CP→platform HTTP auth but those are separate concerns.
|
||||
//
|
||||
// Forensic #145 hardening: tenant workspaces run on EC2 via this path, so
|
||||
// the SCM-write-token denylist (see buildContainerEnv) is enforced here
|
||||
// too. Always build a filtered copy — never pass cfg.EnvVars through
|
||||
// verbatim — so a latent persona-merged GITEA_TOKEN can't reach the
|
||||
// tenant container regardless of whether ADMIN_TOKEN is set.
|
||||
env := make(map[string]string, len(cfg.EnvVars)+1)
|
||||
for k, v := range cfg.EnvVars {
|
||||
if isSCMWriteTokenKey(k) {
|
||||
log.Printf("CPProvisioner.Start: dropped SCM-write credential %q from tenant workspace env (forensic #145 guard)", k)
|
||||
continue
|
||||
}
|
||||
env[k] = v
|
||||
}
|
||||
env := cfg.EnvVars
|
||||
if p.adminToken != "" {
|
||||
env = make(map[string]string, len(cfg.EnvVars)+1)
|
||||
for k, v := range cfg.EnvVars {
|
||||
env[k] = v
|
||||
}
|
||||
env["ADMIN_TOKEN"] = p.adminToken
|
||||
}
|
||||
// Collect template files and generated configs, with OFFSEC-010 guards:
|
||||
@@ -352,7 +343,6 @@ func collectCPConfigFiles(cfg WorkspaceConfig) (map[string]string, error) {
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// Stop terminates the workspace's EC2 instance via the control plane.
|
||||
//
|
||||
// Looks up the actual EC2 instance_id from the workspaces table before
|
||||
@@ -507,9 +497,7 @@ func (p *CPProvisioner) IsRunning(ctx context.Context, workspaceID string) (bool
|
||||
// Don't leak the body — upstream errors may echo headers.
|
||||
return true, fmt.Errorf("cp provisioner: status: unexpected %d", resp.StatusCode)
|
||||
}
|
||||
var result struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
var result struct{ State string `json:"state"` }
|
||||
// Cap body read at 64 KiB for parity with Start — a misconfigured
|
||||
// or compromised CP streaming a huge body could otherwise exhaust
|
||||
// memory in this hot path (called reactively per-request from
|
||||
|
||||
@@ -591,28 +591,6 @@ func ValidateWorkspaceAccess(access, workspacePath string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// scmWriteTokenKeys is the explicit denylist of environment variable names
|
||||
// that carry a Git SCM *write* credential (push / merge / approve). These
|
||||
// must never reach a tenant workspace container — see the forensic #145
|
||||
// rationale in buildContainerEnv. Kept as an exact-match set rather than a
|
||||
// substring/prefix heuristic so the guard is auditable and can't silently
|
||||
// over-strip a legitimately-named var.
|
||||
var scmWriteTokenKeys = map[string]struct{}{
|
||||
"GITEA_TOKEN": {},
|
||||
"GITHUB_TOKEN": {},
|
||||
"GH_TOKEN": {}, // gh CLI honours GH_TOKEN as a GITHUB_TOKEN alias
|
||||
"GITLAB_TOKEN": {},
|
||||
"GL_TOKEN": {}, // glab CLI alias
|
||||
"BITBUCKET_TOKEN": {},
|
||||
}
|
||||
|
||||
// isSCMWriteTokenKey reports whether an env var name is a known Git SCM
|
||||
// write credential that must be stripped from tenant workspace env.
|
||||
func isSCMWriteTokenKey(key string) bool {
|
||||
_, ok := scmWriteTokenKeys[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// buildContainerEnv assembles the initial environment variables injected
|
||||
// into every workspace container.
|
||||
//
|
||||
@@ -649,21 +627,6 @@ func buildContainerEnv(cfg WorkspaceConfig) []string {
|
||||
env = append(env, fmt.Sprintf("AWARENESS_URL=%s", cfg.AwarenessURL))
|
||||
}
|
||||
for k, v := range cfg.EnvVars {
|
||||
// Forensic #145 hardening: tenant workspace containers run
|
||||
// agent-controlled code and must NEVER receive a Git SCM *write*
|
||||
// credential. Without merge/approve creds in-container the
|
||||
// two-eyes review gate is structurally self-bypass-proof — an
|
||||
// agent that forges an approval has no token to act on it. A
|
||||
// latent path exists (loadPersonaEnvFile merges a per-role
|
||||
// persona `GITEA_TOKEN` into cfg.EnvVars when MOLECULE_PERSONA_ROOT
|
||||
// is set on a tenant host); it is inert today (persona dirs are
|
||||
// operator-host-only) but unguarded. Strip SCM-write tokens here
|
||||
// by construction so the invariant holds regardless of whether
|
||||
// that path ever becomes reachable.
|
||||
if isSCMWriteTokenKey(k) {
|
||||
log.Printf("buildContainerEnv: dropped SCM-write credential %q from workspace env (forensic #145 guard)", k)
|
||||
continue
|
||||
}
|
||||
env = append(env, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
// Inject ADMIN_TOKEN from the platform server's environment so workspace
|
||||
|
||||
@@ -636,15 +636,10 @@ func TestBuildContainerEnv_AwarenessOnlyWhenBothSet(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildContainerEnv_CustomEnvVarsAppended(t *testing.T) {
|
||||
// NOTE: this test previously asserted GITHUB_TOKEN passed through
|
||||
// verbatim. That assertion encoded the forensic #145 latent leak as
|
||||
// expected behavior. Post-guard, ordinary custom env still flows but
|
||||
// SCM-write credentials are stripped — see
|
||||
// TestBuildContainerEnv_StripsSCMWriteTokens for the negative assertion.
|
||||
cfg := WorkspaceConfig{
|
||||
WorkspaceID: "ws-x",
|
||||
PlatformURL: "http://localhost:8080",
|
||||
EnvVars: map[string]string{"CUSTOM": "value", "ANTHROPIC_API_KEY": "sk-not-an-scm-token"},
|
||||
EnvVars: map[string]string{"CUSTOM": "value", "GITHUB_TOKEN": "fake-token-for-test"},
|
||||
}
|
||||
env := buildContainerEnv(cfg)
|
||||
seen := map[string]string{}
|
||||
@@ -657,8 +652,8 @@ func TestBuildContainerEnv_CustomEnvVarsAppended(t *testing.T) {
|
||||
if seen["CUSTOM"] != "value" {
|
||||
t.Errorf("CUSTOM env missing, got env=%v", env)
|
||||
}
|
||||
if seen["ANTHROPIC_API_KEY"] != "sk-not-an-scm-token" {
|
||||
t.Errorf("non-SCM custom env must still pass through, got env=%v", env)
|
||||
if seen["GITHUB_TOKEN"] != "fake-token-for-test" {
|
||||
t.Errorf("GITHUB_TOKEN env missing, got env=%v", env)
|
||||
}
|
||||
// Built-in defaults still present
|
||||
if seen["MOLECULE_URL"] == "" {
|
||||
@@ -666,129 +661,6 @@ func TestBuildContainerEnv_CustomEnvVarsAppended(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- forensic #145: SCM-write-token denylist guard ----------
|
||||
|
||||
// TestBuildContainerEnv_StripsSCMWriteTokens is the core negative
|
||||
// assertion: a tenant workspace env constructed via buildContainerEnv MUST
|
||||
// NOT contain any Git SCM *write* credential, regardless of how it got into
|
||||
// cfg.EnvVars. This proves the two-eyes review gate stays structurally
|
||||
// self-bypass-proof — an agent in-container has no merge/approve token to
|
||||
// act on a forged approval. See forensic #145.
|
||||
//
|
||||
// This test FAILS on the pre-guard code (where buildContainerEnv passed
|
||||
// cfg.EnvVars through verbatim) and PASSES once the denylist filter is in
|
||||
// place — i.e. the guard is proven by construction, not by environment
|
||||
// accident.
|
||||
func TestBuildContainerEnv_StripsSCMWriteTokens(t *testing.T) {
|
||||
scmTokens := []string{
|
||||
"GITEA_TOKEN", "GITHUB_TOKEN", "GH_TOKEN",
|
||||
"GITLAB_TOKEN", "GL_TOKEN", "BITBUCKET_TOKEN",
|
||||
}
|
||||
|
||||
t.Run("normal path — SCM tokens explicitly set in EnvVars", func(t *testing.T) {
|
||||
envVars := map[string]string{"CUSTOM": "ok", "ANTHROPIC_API_KEY": "sk-keep"}
|
||||
for _, k := range scmTokens {
|
||||
envVars[k] = "leaked-write-credential-" + k
|
||||
}
|
||||
cfg := WorkspaceConfig{
|
||||
WorkspaceID: "ws-tenant",
|
||||
PlatformURL: "http://localhost:8080",
|
||||
Tier: 2,
|
||||
EnvVars: envVars,
|
||||
}
|
||||
assertNoSCMWriteToken(t, buildContainerEnv(cfg), scmTokens)
|
||||
|
||||
// Sanity: non-SCM custom env is NOT collateral-damaged by the filter.
|
||||
if !envContains(buildContainerEnv(cfg), "CUSTOM=ok") {
|
||||
t.Errorf("filter must not strip non-SCM custom env")
|
||||
}
|
||||
if !envContains(buildContainerEnv(cfg), "ANTHROPIC_API_KEY=sk-keep") {
|
||||
t.Errorf("filter must not strip non-SCM API keys")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("persona-file path — simulates loadPersonaEnvFile merge", func(t *testing.T) {
|
||||
// The latent path: handlers.loadPersonaEnvFile() merges a per-role
|
||||
// persona env file (carrying GITEA_USER, GITEA_TOKEN, …) into the
|
||||
// workspace env map when MOLECULE_PERSONA_ROOT is set on a tenant
|
||||
// host. We can't invoke that cross-package helper here, but its
|
||||
// observable effect is exactly "a GITEA_TOKEN appears in
|
||||
// cfg.EnvVars". Constructing that condition directly proves the
|
||||
// guard holds even if the latent path becomes reachable.
|
||||
cfg := WorkspaceConfig{
|
||||
WorkspaceID: "ws-tenant",
|
||||
PlatformURL: "http://localhost:8080",
|
||||
Tier: 2,
|
||||
EnvVars: map[string]string{
|
||||
// Persona identity fields that are SAFE to keep (read-only
|
||||
// identity, not a write credential):
|
||||
"GITEA_USER": "backend-engineer",
|
||||
"GITEA_USER_EMAIL": "backend-engineer@agents.moleculesai.app",
|
||||
// The credential that must be stripped:
|
||||
"GITEA_TOKEN": "persona-merged-write-pat",
|
||||
"GITEA_TOKEN_SCOPES": "write:repository",
|
||||
},
|
||||
}
|
||||
got := buildContainerEnv(cfg)
|
||||
assertNoSCMWriteToken(t, got, scmTokens)
|
||||
// Non-credential persona identity may still flow through — only the
|
||||
// write token is the denied surface.
|
||||
if !envContains(got, "GITEA_USER=backend-engineer") {
|
||||
t.Errorf("non-credential persona identity (GITEA_USER) should not be stripped")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCPProvisionerEnv_StripsSCMWriteTokens covers the tenant-EC2 path:
|
||||
// CPProvisioner.Start builds the env map the control plane forwards to the
|
||||
// EC2 workspace container. The same forensic #145 denylist must hold there.
|
||||
func TestCPProvisionerEnv_StripsSCMWriteTokens(t *testing.T) {
|
||||
// isSCMWriteTokenKey is the single source of truth shared by both
|
||||
// buildContainerEnv (local Docker) and CPProvisioner.Start (tenant EC2).
|
||||
// Assert it classifies every known SCM-write var as denied and leaves
|
||||
// ordinary / read-only-identity vars alone.
|
||||
for _, k := range []string{
|
||||
"GITEA_TOKEN", "GITHUB_TOKEN", "GH_TOKEN",
|
||||
"GITLAB_TOKEN", "GL_TOKEN", "BITBUCKET_TOKEN",
|
||||
} {
|
||||
if !isSCMWriteTokenKey(k) {
|
||||
t.Errorf("isSCMWriteTokenKey(%q) = false, want true (SCM-write credential must be denied)", k)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{
|
||||
"GITEA_USER", "GITEA_USER_EMAIL", "ANTHROPIC_API_KEY",
|
||||
"CUSTOM", "PLATFORM_URL", "ADMIN_TOKEN", "",
|
||||
} {
|
||||
if isSCMWriteTokenKey(k) {
|
||||
t.Errorf("isSCMWriteTokenKey(%q) = true, want false (must not over-strip non-SCM env)", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoSCMWriteToken(t *testing.T, env []string, scmTokens []string) {
|
||||
t.Helper()
|
||||
for _, e := range env {
|
||||
key := e
|
||||
if i := strings.IndexByte(e, '='); i >= 0 {
|
||||
key = e[:i]
|
||||
}
|
||||
for _, banned := range scmTokens {
|
||||
if key == banned {
|
||||
t.Errorf("SCM-write credential %q leaked into workspace env (forensic #145 invariant violated): %q", banned, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func envContains(env []string, want string) bool {
|
||||
for _, e := range env {
|
||||
if e == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---------- buildWorkspaceMount — #65 workspace_access ----------
|
||||
|
||||
func TestBuildWorkspaceMount_SelectionMatrix(t *testing.T) {
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
// Package secrets provides the canonical SSOT for credential-shaped
|
||||
// regex patterns used by:
|
||||
//
|
||||
// - the CI `Secret scan` workflow (.gitea/workflows/secret-scan.yml)
|
||||
// - the runtime's bundled pre-commit hook
|
||||
// (molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh)
|
||||
// - the upcoming Phase 2b docker-exec Files API backend, which has
|
||||
// to refuse to surface files whose path OR content matches a
|
||||
// credential shape (RFC internal#425, Hongming 2026-05-15)
|
||||
//
|
||||
// Before this package, the same regex set lived as duplicate bash
|
||||
// arrays in two unrelated repos; adding a pattern required editing
|
||||
// both, and pattern drift was caught only via secret-scan workflow
|
||||
// failures on PRs that had unrelated changes (#2090-class incident
|
||||
// vector). Centralising in Go makes the Files API the SSOT, with the
|
||||
// YAML + bash arrays generated/asserted from this package so drift
|
||||
// is detected at CI time, not at exfiltration time.
|
||||
//
|
||||
// This file is Phase 2a of the internal#425 RFC. Phase 2b will import
|
||||
// `Patterns` from `template_files_docker_exec.go` to gate
|
||||
// `listFilesViaDockerExec` / `readFileViaDockerExec` against
|
||||
// secret-shaped paths AND content. Until 2b lands, the package has
|
||||
// one consumer: this package's own unit tests, which pin the regex
|
||||
// strings so a refactor that drops or weakens one is caught here.
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Pattern is one named credential shape — a human label plus the
|
||||
// compiled regex. The label appears in CI error output ("matched:
|
||||
// github-pat") so an operator can identify the family without seeing
|
||||
// the actual matched bytes (echoing the bytes widens the blast radius
|
||||
// per the secret-scan workflow's recovery prose).
|
||||
type Pattern struct {
|
||||
// Name is a short kebab-case identifier (e.g. "github-pat",
|
||||
// "anthropic-api-key"). Stable across versions — consumers may
|
||||
// switch on it.
|
||||
Name string
|
||||
// Description is a one-line human-readable explanation of what
|
||||
// the pattern matches. Used in CI error messages and the Files
|
||||
// API "<denied: secret-shape>" placeholder rationale.
|
||||
Description string
|
||||
// regexSource is the regex literal in Go-RE2 syntax. Stored as a
|
||||
// string so the slice declaration below stays readable; compiled
|
||||
// once via sync.Once into a *regexp.Regexp.
|
||||
regexSource string
|
||||
}
|
||||
|
||||
// Patterns is the canonical credential-shape regex set.
|
||||
//
|
||||
// Adding a pattern here:
|
||||
//
|
||||
// 1. Add a new Pattern{} entry below with a kebab-case Name, a
|
||||
// one-line Description, and the regex literal. Anchor on a
|
||||
// low-false-positive prefix.
|
||||
// 2. Add a positive + negative test case in patterns_test.go.
|
||||
// 3. Mirror the regex string into:
|
||||
// a. .gitea/workflows/secret-scan.yml SECRET_PATTERNS array
|
||||
// b. molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh
|
||||
// (or wait for the codegen target that consumes this slice — TBD
|
||||
// follow-up; tracked in the Phase 2a PR description.)
|
||||
//
|
||||
// The order is: alphabetical within each provider family, families
|
||||
// grouped by ecosystem (GitHub family, AI-provider family, chat
|
||||
// family, cloud family). Keep this stable so diffs are reviewable.
|
||||
var Patterns = []Pattern{
|
||||
// --- GitHub token family ---
|
||||
{
|
||||
Name: "github-pat-classic",
|
||||
Description: "GitHub personal access token (classic)",
|
||||
regexSource: `ghp_[A-Za-z0-9]{36,}`,
|
||||
},
|
||||
{
|
||||
Name: "github-app-installation-token",
|
||||
Description: "GitHub App installation token (#2090 vector)",
|
||||
regexSource: `ghs_[A-Za-z0-9]{36,}`,
|
||||
},
|
||||
{
|
||||
Name: "github-oauth-user-to-server",
|
||||
Description: "GitHub OAuth user-to-server token",
|
||||
regexSource: `gho_[A-Za-z0-9]{36,}`,
|
||||
},
|
||||
{
|
||||
Name: "github-oauth-user",
|
||||
Description: "GitHub OAuth user token",
|
||||
regexSource: `ghu_[A-Za-z0-9]{36,}`,
|
||||
},
|
||||
{
|
||||
Name: "github-oauth-refresh",
|
||||
Description: "GitHub OAuth refresh token",
|
||||
regexSource: `ghr_[A-Za-z0-9]{36,}`,
|
||||
},
|
||||
{
|
||||
Name: "github-pat-fine-grained",
|
||||
Description: "GitHub fine-grained personal access token",
|
||||
regexSource: `github_pat_[A-Za-z0-9_]{82,}`,
|
||||
},
|
||||
|
||||
// --- AI-provider API key family ---
|
||||
{
|
||||
Name: "anthropic-api-key",
|
||||
Description: "Anthropic API key",
|
||||
regexSource: `sk-ant-[A-Za-z0-9_-]{40,}`,
|
||||
},
|
||||
{
|
||||
Name: "openai-project-key",
|
||||
Description: "OpenAI project API key",
|
||||
regexSource: `sk-proj-[A-Za-z0-9_-]{40,}`,
|
||||
},
|
||||
{
|
||||
Name: "openai-service-account-key",
|
||||
Description: "OpenAI service-account API key",
|
||||
regexSource: `sk-svcacct-[A-Za-z0-9_-]{40,}`,
|
||||
},
|
||||
{
|
||||
Name: "minimax-api-key",
|
||||
Description: "MiniMax API key (F1088 vector)",
|
||||
regexSource: `sk-cp-[A-Za-z0-9_-]{60,}`,
|
||||
},
|
||||
|
||||
// --- Chat-platform token family ---
|
||||
{
|
||||
Name: "slack-token",
|
||||
Description: "Slack token (xoxb/xoxa/xoxp/xoxr/xoxs)",
|
||||
regexSource: `xox[baprs]-[A-Za-z0-9-]{20,}`,
|
||||
},
|
||||
|
||||
// --- Cloud-provider credential family ---
|
||||
{
|
||||
Name: "aws-access-key-id",
|
||||
Description: "AWS access key ID",
|
||||
regexSource: `AKIA[0-9A-Z]{16}`,
|
||||
},
|
||||
{
|
||||
Name: "aws-sts-temp-access-key-id",
|
||||
Description: "AWS STS temporary access key ID",
|
||||
regexSource: `ASIA[0-9A-Z]{16}`,
|
||||
},
|
||||
}
|
||||
|
||||
// compiledOnce protects the lazy build of compiledPatterns. We compile
|
||||
// lazily so package init is cheap; callers pay only on first match
|
||||
// (typically once per workspace-server boot).
|
||||
var (
|
||||
compiledOnce sync.Once
|
||||
compiledPatterns []*compiledPattern
|
||||
compileErr error
|
||||
)
|
||||
|
||||
type compiledPattern struct {
|
||||
Name string
|
||||
Description string
|
||||
Re *regexp.Regexp
|
||||
}
|
||||
|
||||
// compileAll compiles every Pattern.regexSource into a *regexp.Regexp.
|
||||
// Called once via compiledOnce. Any compile failure here is a build
|
||||
// bug (the unit tests assert each regex compiles) — surfacing via
|
||||
// returned error so callers don't panic in request handling.
|
||||
func compileAll() {
|
||||
out := make([]*compiledPattern, 0, len(Patterns))
|
||||
for _, p := range Patterns {
|
||||
re, err := regexp.Compile(p.regexSource)
|
||||
if err != nil {
|
||||
compileErr = fmt.Errorf("secrets: pattern %q failed to compile: %w", p.Name, err)
|
||||
return
|
||||
}
|
||||
out = append(out, &compiledPattern{Name: p.Name, Description: p.Description, Re: re})
|
||||
}
|
||||
compiledPatterns = out
|
||||
}
|
||||
|
||||
// ScanBytes returns a non-nil Match if any pattern matches anywhere
|
||||
// inside b. Returns (nil, nil) on no match. Returns (nil, err) only
|
||||
// if a regex in the package fails to compile — that's a build bug,
|
||||
// not a runtime data issue.
|
||||
//
|
||||
// Match contains the pattern Name + Description so the caller can
|
||||
// emit a path-or-content-denial rationale WITHOUT round-tripping the
|
||||
// matched bytes (which would defeat the purpose). The matched bytes
|
||||
// stay inside this function.
|
||||
//
|
||||
// The Files API Phase 2b backend will call ScanBytes on:
|
||||
//
|
||||
// - the absolute path string (catches a file literally named
|
||||
// `ghs_abc.txt`)
|
||||
// - the file content (catches a credential pasted into a workspace
|
||||
// file by an agent or user — the Files API refuses to surface it
|
||||
// and the canvas renders "<denied: secret-shape>")
|
||||
//
|
||||
// Ordering: patterns are tried in declaration order. First match
|
||||
// wins. This means narrower patterns (e.g. `sk-svcacct-…`) should
|
||||
// appear in `Patterns` before broader ones (`sk-…`) — today there's
|
||||
// no overlap, so order is descriptive only.
|
||||
func ScanBytes(b []byte) (*Match, error) {
|
||||
compiledOnce.Do(compileAll)
|
||||
if compileErr != nil {
|
||||
return nil, compileErr
|
||||
}
|
||||
for _, cp := range compiledPatterns {
|
||||
if cp.Re.Match(b) {
|
||||
return &Match{Name: cp.Name, Description: cp.Description}, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ScanString is the string-input convenience wrapper around ScanBytes.
|
||||
// Identical semantics — the body never copies, []byte(s) is a
|
||||
// zero-copy reinterpret for the regex matcher.
|
||||
func ScanString(s string) (*Match, error) {
|
||||
return ScanBytes([]byte(s))
|
||||
}
|
||||
|
||||
// Match describes which pattern caught a value. Deliberately does
|
||||
// NOT include the matched substring — callers must not echo it.
|
||||
type Match struct {
|
||||
// Name is the pattern's kebab-case identifier (e.g. "github-pat-classic").
|
||||
Name string
|
||||
// Description is the human-readable line for UI / log surfaces.
|
||||
Description string
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEveryPatternCompiles pins that every Pattern.regexSource is a
|
||||
// valid Go-RE2 expression. Without this, a bad regex would silently
|
||||
// disable ScanBytes for everything after it (the lazy compile would
|
||||
// set compileErr and ScanBytes would return that error every call).
|
||||
func TestEveryPatternCompiles(t *testing.T) {
|
||||
for _, p := range Patterns {
|
||||
if p.Name == "" {
|
||||
t.Errorf("pattern with empty Name: regex=%q", p.regexSource)
|
||||
}
|
||||
if p.Description == "" {
|
||||
t.Errorf("pattern %q has empty Description", p.Name)
|
||||
}
|
||||
}
|
||||
// Force compile + check error.
|
||||
if _, err := ScanBytes([]byte("placeholder")); err != nil {
|
||||
t.Fatalf("ScanBytes init failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoDuplicateNames — a duplicate pattern Name would make the
|
||||
// "first match wins" semantics surprising to readers and any caller
|
||||
// switching on Match.Name (none today but adding the guard is cheap).
|
||||
func TestNoDuplicateNames(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, p := range Patterns {
|
||||
if seen[p.Name] {
|
||||
t.Errorf("duplicate pattern Name: %q", p.Name)
|
||||
}
|
||||
seen[p.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnownPatternsAllPresent — pins which specific Name values are
|
||||
// expected. A future refactor that renames or removes one without
|
||||
// updating consumers (CI workflow, runtime pre-commit hook, Files
|
||||
// API Phase 2b backend) would silently widen the leak surface.
|
||||
// Failing here forces the rename to be intentional.
|
||||
func TestKnownPatternsAllPresent(t *testing.T) {
|
||||
expected := []string{
|
||||
"github-pat-classic",
|
||||
"github-app-installation-token",
|
||||
"github-oauth-user-to-server",
|
||||
"github-oauth-user",
|
||||
"github-oauth-refresh",
|
||||
"github-pat-fine-grained",
|
||||
"anthropic-api-key",
|
||||
"openai-project-key",
|
||||
"openai-service-account-key",
|
||||
"minimax-api-key",
|
||||
"slack-token",
|
||||
"aws-access-key-id",
|
||||
"aws-sts-temp-access-key-id",
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, p := range Patterns {
|
||||
got[p.Name] = true
|
||||
}
|
||||
for _, want := range expected {
|
||||
if !got[want] {
|
||||
t.Errorf("expected pattern %q missing from Patterns slice", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPositiveMatches — for each pattern, supply a representative
|
||||
// shape and assert ScanBytes returns a Match with the right Name.
|
||||
// These are TEST FIXTURES, not real credentials — each is the
|
||||
// pattern's prefix + a long-enough trailing run of placeholder chars.
|
||||
// `EXAMPLE` is sprinkled in to make grep-finds in CI logs obviously
|
||||
// fake to a human reader (matches saved memory
|
||||
// feedback_assert_exact_not_substring: tighten by Name not body).
|
||||
func TestPositiveMatches(t *testing.T) {
|
||||
cases := []struct {
|
||||
fixture string
|
||||
expectedName string
|
||||
}{
|
||||
{"ghp_EXAMPLE111122223333444455556666777788889999", "github-pat-classic"},
|
||||
{"ghs_EXAMPLE111122223333444455556666777788889999", "github-app-installation-token"},
|
||||
{"gho_EXAMPLE111122223333444455556666777788889999", "github-oauth-user-to-server"},
|
||||
{"ghu_EXAMPLE111122223333444455556666777788889999", "github-oauth-user"},
|
||||
{"ghr_EXAMPLE111122223333444455556666777788889999", "github-oauth-refresh"},
|
||||
{"github_pat_EXAMPLE" + strings.Repeat("1", 80), "github-pat-fine-grained"},
|
||||
{"sk-ant-EXAMPLE" + strings.Repeat("1", 40), "anthropic-api-key"},
|
||||
{"sk-proj-EXAMPLE" + strings.Repeat("1", 40), "openai-project-key"},
|
||||
{"sk-svcacct-EXAMPLE" + strings.Repeat("1", 40), "openai-service-account-key"},
|
||||
{"sk-cp-EXAMPLE" + strings.Repeat("1", 60), "minimax-api-key"},
|
||||
{"xoxb-" + strings.Repeat("a", 25), "slack-token"},
|
||||
{"xoxa-" + strings.Repeat("a", 25), "slack-token"},
|
||||
// AWS regex requires [0-9A-Z]{16} — uppercase + digits only.
|
||||
{"AKIA1234567890ABCDEF", "aws-access-key-id"},
|
||||
{"ASIA1234567890ABCDEF", "aws-sts-temp-access-key-id"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.expectedName, func(t *testing.T) {
|
||||
m, err := ScanBytes([]byte(tc.fixture))
|
||||
if err != nil {
|
||||
t.Fatalf("ScanBytes(%q) errored: %v", tc.fixture, err)
|
||||
}
|
||||
if m == nil {
|
||||
t.Fatalf("ScanBytes(%q) returned no match — expected %q", tc.fixture, tc.expectedName)
|
||||
}
|
||||
if m.Name != tc.expectedName {
|
||||
t.Errorf("ScanBytes(%q) matched %q; expected %q", tc.fixture, m.Name, tc.expectedName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNegativeShapes — strings that look credential-adjacent but
|
||||
// shouldn't match (too short, wrong prefix, missing trailing bytes).
|
||||
// Failing here means a pattern is too loose, which would generate
|
||||
// false-positive denial in Files API and false-positive workflow
|
||||
// failures in CI.
|
||||
func TestNegativeShapes(t *testing.T) {
|
||||
cases := []string{
|
||||
// Too-short variants — anchored on the length suffix.
|
||||
"ghp_tooshort",
|
||||
"ghs_alsoshort1234",
|
||||
"github_pat_short",
|
||||
"sk-ant-short",
|
||||
"sk-cp-not-enough-bytes-here",
|
||||
// Looks like one of the prefixes but isn't (different letter).
|
||||
"gha_EXAMPLE_thirty_six_or_more_chars_here_xxx",
|
||||
// Slack family — wrong letter after xox.
|
||||
"xoxz-aaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
// AWS-shaped but wrong length suffix.
|
||||
"AKIATOOSHORT",
|
||||
// Empty / whitespace.
|
||||
"",
|
||||
" ",
|
||||
// Plain prose mentioning the prefix as part of a longer word.
|
||||
"see also `ghp_HOWTO.md` in the repo",
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c, func(t *testing.T) {
|
||||
m, err := ScanBytes([]byte(c))
|
||||
if err != nil {
|
||||
t.Fatalf("ScanBytes(%q) errored: %v", c, err)
|
||||
}
|
||||
if m != nil {
|
||||
t.Errorf("ScanBytes(%q) unexpectedly matched %q", c, m.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanString_NoOp — sanity-check ScanString is the zero-copy
|
||||
// wrapper around ScanBytes. Without this, a future refactor that
|
||||
// makes ScanString do its own thing (e.g. accidentally normalise
|
||||
// case) would diverge silently.
|
||||
func TestScanString_NoOp(t *testing.T) {
|
||||
in := "ghp_EXAMPLE111122223333444455556666777788889999"
|
||||
m1, err1 := ScanBytes([]byte(in))
|
||||
if err1 != nil {
|
||||
t.Fatalf("ScanBytes errored: %v", err1)
|
||||
}
|
||||
m2, err2 := ScanString(in)
|
||||
if err2 != nil {
|
||||
t.Fatalf("ScanString errored: %v", err2)
|
||||
}
|
||||
if m1 == nil || m2 == nil {
|
||||
t.Fatalf("expected matches; got bytes=%+v string=%+v", m1, m2)
|
||||
}
|
||||
if m1.Name != m2.Name {
|
||||
t.Errorf("ScanString and ScanBytes returned different Names: %q vs %q", m1.Name, m2.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatch_NoRoundtrip — assert the Match struct does NOT include
|
||||
// the matched substring as a field. Adding such a field would
|
||||
// regress the "matched bytes never leave ScanBytes" invariant that
|
||||
// makes this package safe to call from log/UI surfaces. This is a
|
||||
// reflection-light contract test — checks the field names statically.
|
||||
func TestMatch_NoRoundtrip(t *testing.T) {
|
||||
var m Match
|
||||
// If someone adds a `Matched string` (or similar) field, this
|
||||
// test reads as the canonical place to update + reconsider.
|
||||
_ = m.Name
|
||||
_ = m.Description
|
||||
// The two-field shape is part of the public contract; new fields
|
||||
// require deliberation about whether they leak the secret value.
|
||||
}
|
||||
Reference in New Issue
Block a user