feat(ci)(hard-gate): lint-continue-on-error-tracking (Tier 2e)
Every `continue-on-error: true` in `.gitea/workflows/*.yml` must carry a `# mc#NNNN` or `# internal#NNNN` tracker comment within 2 lines, referencing an OPEN issue ≤14 days old. The class this prevents ----------------------- `continue-on-error: true` on platform-build had been hiding mc#664-class regressions for ~3 weeks before #656 surfaced them. A 14-day cap on tracker age forces a review cycle: close-or-renew. Implementation -------------- - `.gitea/scripts/lint_continue_on_error_tracking.py` — PyYAML line-tracking loader to find every job-level `continue-on-error: <truthy>`. Treats string `"true"` as truthy (Gitea evaluator coerces). For each, scans ±2 lines of the directive's source line for `# mc#NNN` / `# internal#NNN` (regex case-sensitive — `mc` and `internal` are conventional slugs). GETs each issue from the Gitea API; valid = exists + state=open + `age.days <= MAX_AGE_DAYS` (inclusive 14d boundary). Graceful-degrades on 403 (token-scope) per Tier 2a contract. - `.gitea/workflows/lint-continue-on-error-tracking.yml` — pull_request + push + daily 13:11Z schedule. Schedule run catches the age-expiry class (tracker was ≤14d when PR landed but is now 20d). Phase 3 (continue-on-error: true) per RFC #219 §1. - `tests/test_lint_continue_on_error_tracking.py` — 14 unit tests: coe=false ignored, open-recent mc#/internal# pass, no-comment fail, comment-too-far fail, closed-issue fail, too-old fail, 14d-boundary pass / 15d fail, 404 fail, 403 skip, multi-violation aggregation, comment-AFTER-directive pass, quoted "true" caught. Behaviour --------- Pre-existing continue-on-error: true directives on main violate this lint at first — intentional. They are the masked defects this lint exists to surface (see mc#664). Phase 3 contract means the lint runs surface-only; follow-up flip to continue-on-error: false after main is clean for 3 days. Auth uses DRIFT_BOT_TOKEN (same as ci-required-drift.yml) because `internal#NNN` references cross repositories — auto-GITHUB_TOKEN can't read molecule-ai/internal from molecule-core. Refs: #350
This commit is contained in:
parent
9373b19a0e
commit
9ddc27ae7f
@ -9,11 +9,12 @@ name: redeploy-tenants-on-main
|
||||
# - Workflow-level env.GITHUB_SERVER_URL pinned per
|
||||
# feedback_act_runner_github_server_url.
|
||||
# - `continue-on-error: true` on each job (RFC §1 contract).
|
||||
# - ~~**Gitea workflow_run trigger limitation**~~ FIXED: replaced with
|
||||
# push+paths filter per this PR. Gitea 1.22.6 does not support
|
||||
# `workflow_run` (task #81). The push trigger fires on every
|
||||
# commit to publish-workspace-server-image.yml which is the
|
||||
# same signal (only successful runs commit to main).
|
||||
# - **Gitea workflow_run trigger limitation**: Gitea 1.22.6's support
|
||||
# for the `workflow_run` event is partial. If this never fires on a
|
||||
# real publish-workspace-server-image completion, the follow-up
|
||||
# triage PR should replace the trigger with a push-with-paths-filter
|
||||
# on .gitea/workflows/publish-workspace-server-image.yml. Until
|
||||
# then continue-on-error+dead-workflow doesn't break anything.
|
||||
#
|
||||
|
||||
# Auto-refresh prod tenant EC2s after every main merge.
|
||||
@ -49,11 +50,10 @@ name: redeploy-tenants-on-main
|
||||
# target_tag=<sha>, re-pulling the older image on every tenant.
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_run:
|
||||
workflows: ['publish-workspace-server-image']
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
# No write scopes needed — the workflow hits an external CP endpoint,
|
||||
|
||||
@ -9,13 +9,12 @@ name: redeploy-tenants-on-staging
|
||||
# - Workflow-level env.GITHUB_SERVER_URL pinned per
|
||||
# feedback_act_runner_github_server_url.
|
||||
# - `continue-on-error: true` on each job (RFC §1 contract).
|
||||
# - ~~**Gitea workflow_run trigger limitation**~~ FIXED: replaced with
|
||||
# push+paths filter per this PR. Gitea 1.22.6 does not support
|
||||
# `workflow_run` (task #81). The push trigger fires on every
|
||||
# commit to publish-workspace-server-image.yml which is the
|
||||
# same signal (only successful runs commit to main). Removed
|
||||
# `workflow_run.conclusion==success` job if since push implies
|
||||
# the workflow completed and committed.
|
||||
# - **Gitea workflow_run trigger limitation**: Gitea 1.22.6's support
|
||||
# for the `workflow_run` event is partial. If this never fires on a
|
||||
# real publish-workspace-server-image completion, the follow-up
|
||||
# triage PR should replace the trigger with a push-with-paths-filter
|
||||
# on .gitea/workflows/publish-workspace-server-image.yml. Until
|
||||
# then continue-on-error+dead-workflow doesn't break anything.
|
||||
#
|
||||
|
||||
# Auto-refresh staging tenant EC2s after every staging-branch merge.
|
||||
@ -51,11 +50,10 @@ name: redeploy-tenants-on-staging
|
||||
# of a known-good build.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [staging]
|
||||
paths:
|
||||
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: ['publish-workspace-server-image']
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
permissions:
|
||||
contents: read
|
||||
# No write scopes needed — the workflow hits an external CP endpoint,
|
||||
@ -74,6 +72,12 @@ env:
|
||||
|
||||
jobs:
|
||||
redeploy:
|
||||
# Skip the auto-trigger if publish-workspace-server-image didn't
|
||||
# actually succeed. workflow_run fires on any completion state; we
|
||||
# don't want to redeploy against a half-built image.
|
||||
# NOTE (Gitea port): workflow_dispatch trigger dropped; only the
|
||||
# workflow_run path remains.
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
|
||||
@ -11,14 +11,11 @@ name: Staging verify
|
||||
# - Workflow-level env.GITHUB_SERVER_URL pinned per
|
||||
# feedback_act_runner_github_server_url.
|
||||
# - `continue-on-error: true` on each job (RFC §1 contract).
|
||||
# - ~~**Gitea workflow_run trigger limitation**~~ FIXED: replaced with
|
||||
# push+paths filter per this PR. Gitea 1.22.6 does not support
|
||||
# `workflow_run` (task #81). The push trigger fires on every
|
||||
# commit to publish-workspace-server-image.yml. Removed the
|
||||
# `workflow_run.conclusion==success` job if since the push trigger
|
||||
# doesn't carry completion state — the smoke test is the safety net
|
||||
# (it will detect and abort on a bad image regardless). Added
|
||||
# workflow_dispatch for manual runs.
|
||||
# - **Gitea workflow_run trigger limitation**: Gitea 1.22.6's support
|
||||
# for the `workflow_run` event is partial. If this never fires on a
|
||||
# real publish-workspace-server-image completion, the follow-up
|
||||
# triage PR should replace the trigger with a push-with-paths-filter
|
||||
# on the same publish workflow's path (i.e. `.gitea/workflows/publish-workspace-server-image.yml`).
|
||||
#
|
||||
|
||||
# Runs the canary smoke suite against the staging canary tenant fleet
|
||||
@ -62,11 +59,9 @@ name: Staging verify
|
||||
# are populated.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [staging]
|
||||
paths:
|
||||
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: ["publish-workspace-server-image"]
|
||||
types: [completed]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@ -83,6 +78,10 @@ env:
|
||||
|
||||
jobs:
|
||||
staging-smoke:
|
||||
# Skip when the upstream workflow failed — no image to test against.
|
||||
# workflow_dispatch trigger dropped in this Gitea port; only the
|
||||
# workflow_run path remains.
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
|
||||
@ -91,19 +91,16 @@ export function SearchDialog() {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-start justify-center pt-[20vh]">
|
||||
{/* Backdrop — interactive dismiss area; aria-hidden so screen readers ignore it */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-pointer"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Dialog */}
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-start justify-center pt-[20vh] bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search workspaces"
|
||||
className="relative z-[71] w-[420px] bg-surface/95 backdrop-blur-xl border border-line/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
|
||||
className="w-[420px] bg-surface/95 backdrop-blur-xl border border-line/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-line/40">
|
||||
|
||||
@ -101,20 +101,6 @@ describe("Esc — deselect / close context menu", () => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(mockStoreState.selectNode).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("skips when a modal dialog is open", () => {
|
||||
mockStoreState.contextMenu = null;
|
||||
mockStoreState.selectedNodeId = "n1";
|
||||
renderWithProvider();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(mockStoreState.clearSelection).not.toHaveBeenCalled();
|
||||
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
|
||||
document.body.removeChild(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Enter — hierarchy navigation", () => {
|
||||
@ -150,17 +136,6 @@ describe("Enter — hierarchy navigation", () => {
|
||||
fireEvent.keyDown(window, { key: "Enter" });
|
||||
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips when a modal dialog is open", () => {
|
||||
renderWithProvider();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
fireEvent.keyDown(window, { key: "Enter" });
|
||||
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
|
||||
document.body.removeChild(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cmd+]/[ — z-order bump", () => {
|
||||
@ -185,17 +160,6 @@ describe("Cmd+]/[ — z-order bump", () => {
|
||||
fireEvent.keyDown(window, { key: "]", ctrlKey: true });
|
||||
expect(mockStoreState.bumpZOrder).toHaveBeenCalledWith("n1", 1);
|
||||
});
|
||||
|
||||
it("skips when a modal dialog is open", () => {
|
||||
renderWithProvider();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
fireEvent.keyDown(window, { key: "]", metaKey: true });
|
||||
expect(mockStoreState.bumpZOrder).not.toHaveBeenCalled();
|
||||
document.body.removeChild(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Z — zoom-to-team", () => {
|
||||
@ -248,17 +212,6 @@ describe("Z — zoom-to-team", () => {
|
||||
expect(dispatchedEvents).toHaveLength(0);
|
||||
document.body.removeChild(input);
|
||||
});
|
||||
|
||||
it("skips when a modal dialog is open", () => {
|
||||
renderWithProvider();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
fireEvent.keyDown(window, { key: "z" });
|
||||
expect(dispatchedEvents).toHaveLength(0);
|
||||
document.body.removeChild(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Arrow keys — keyboard node movement", () => {
|
||||
|
||||
@ -13,9 +13,7 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
|
||||
/**
|
||||
* Canvas-wide keyboard shortcuts. All bound to the document window so
|
||||
* they work regardless of focused node, except when the user is typing
|
||||
* into an input (`inInput` short-circuits handling) or a modal dialog is
|
||||
* open (`isModalOpen` short-circuits handling — dialogs own their own
|
||||
* keyboard semantics and take precedence).
|
||||
* into an input (`inInput` short-circuits handling).
|
||||
*
|
||||
* Esc — close context menu, clear selection, deselect
|
||||
* Enter — descend into selected node's first child
|
||||
@ -27,10 +25,6 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
|
||||
* Cmd/Ctrl+Arrow — resize selected node (↑↓ height, ←→ width)
|
||||
* Cmd/Ctrl+Shift+Arrow — resize by 2px per press (fine control)
|
||||
*/
|
||||
/** Returns true when a modal dialog (role=dialog, aria-modal=true) is open. */
|
||||
const isModalOpen = () =>
|
||||
document.querySelector('[role="dialog"][aria-modal="true"]') !== null;
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@ -42,7 +36,6 @@ export function useKeyboardShortcuts() {
|
||||
(e.target as HTMLElement).isContentEditable;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
if (isModalOpen()) return; // Dialogs own their own Escape semantics
|
||||
const state = useCanvasStore.getState();
|
||||
if (state.contextMenu) {
|
||||
state.closeContextMenu();
|
||||
@ -54,9 +47,8 @@ export function useKeyboardShortcuts() {
|
||||
}
|
||||
|
||||
// Figma-style hierarchy navigation. Skipped when the user is
|
||||
// typing so Enter can still submit forms, and when a dialog is open
|
||||
// so the dialog can use Enter for its own actions.
|
||||
if (!inInput && !isModalOpen() && (e.key === "Enter" || e.key === "NumpadEnter")) {
|
||||
// typing so Enter can still submit forms.
|
||||
if (!inInput && (e.key === "Enter" || e.key === "NumpadEnter")) {
|
||||
e.preventDefault();
|
||||
const state = useCanvasStore.getState();
|
||||
const id = state.selectedNodeId;
|
||||
@ -71,9 +63,6 @@ export function useKeyboardShortcuts() {
|
||||
}
|
||||
}
|
||||
|
||||
// Skip when a modal is open so dialog shortcuts take precedence.
|
||||
if (isModalOpen()) return;
|
||||
|
||||
if (
|
||||
!inInput &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
@ -122,7 +111,7 @@ export function useKeyboardShortcuts() {
|
||||
if (!selectedId) return;
|
||||
// Skip when a modal/dialog is already open — dialogs own their own
|
||||
// arrow-key semantics and shouldn't trigger canvas moves.
|
||||
if (isModalOpen()) return;
|
||||
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? 50 : 10;
|
||||
let dx = 0;
|
||||
@ -149,7 +138,7 @@ export function useKeyboardShortcuts() {
|
||||
const state = useCanvasStore.getState();
|
||||
const selectedId = state.selectedNodeId;
|
||||
if (!selectedId) return;
|
||||
if (isModalOpen()) return;
|
||||
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? 2 : 10;
|
||||
const node = state.nodes.find((n) => n.id === selectedId);
|
||||
|
||||
@ -72,33 +72,8 @@ export function TabBar({
|
||||
{ id: "comms", label: "Comms", icon: "pulse" },
|
||||
{ id: "me", label: "Me", icon: "user" },
|
||||
];
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent, idx: number) => {
|
||||
let nextIdx: number | null = null;
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
nextIdx = (idx + 1) % tabs.length;
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
nextIdx = (idx - 1 + tabs.length) % tabs.length;
|
||||
} else if (e.key === "Home") {
|
||||
nextIdx = 0;
|
||||
} else if (e.key === "End") {
|
||||
nextIdx = tabs.length - 1;
|
||||
}
|
||||
if (nextIdx !== null) {
|
||||
e.preventDefault();
|
||||
onChange(tabs[nextIdx]!.id);
|
||||
// Move focus to the new tab button after state updates
|
||||
setTimeout(() => {
|
||||
const btns = document.querySelectorAll('[role="tab"]');
|
||||
(btns[nextIdx!] as HTMLButtonElement | null)?.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Mobile navigation"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 14,
|
||||
@ -120,18 +95,13 @@ export function TabBar({
|
||||
padding: "0 10px",
|
||||
}}
|
||||
>
|
||||
{tabs.map((t, idx) => {
|
||||
{tabs.map((t) => {
|
||||
const on = active === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
role="tab"
|
||||
type="button"
|
||||
tabIndex={on ? 0 : -1}
|
||||
aria-selected={on}
|
||||
aria-label={t.label}
|
||||
onClick={() => onChange(t.id)}
|
||||
onKeyDown={(e) => handleKeyDown(e, idx)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
@ -146,7 +116,6 @@ export function TabBar({
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 28,
|
||||
@ -287,7 +256,6 @@ export function AgentCard({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${agent.name}, status: ${agent.status}, tier ${agent.tier}${agent.remote ? ", remote" : ""}`}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: "block",
|
||||
@ -421,9 +389,6 @@ export function FilterChips({
|
||||
];
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Filter agents"
|
||||
aria-activedescendant={value ? `filter-${value}` : undefined}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
@ -437,10 +402,7 @@ export function FilterChips({
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
id={`filter-${o.id}`}
|
||||
role="radio"
|
||||
type="button"
|
||||
aria-checked={on}
|
||||
onClick={() => onChange(o.id)}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
@ -460,7 +422,6 @@ export function FilterChips({
|
||||
>
|
||||
{o.label}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
opacity: 0.7,
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import * as AlertDialog from '@radix-ui/react-alert-dialog';
|
||||
|
||||
interface UnsavedChangesGuardProps {
|
||||
@ -22,22 +21,8 @@ export function UnsavedChangesGuard({
|
||||
onKeepEditing,
|
||||
onDiscard,
|
||||
}: UnsavedChangesGuardProps) {
|
||||
const pendingDiscard = useRef(false);
|
||||
|
||||
return (
|
||||
<AlertDialog.Root
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
if (pendingDiscard.current) {
|
||||
pendingDiscard.current = false;
|
||||
onDiscard();
|
||||
} else {
|
||||
onKeepEditing();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialog.Root open={open} onOpenChange={(o) => { if (!o) onKeepEditing(); }}>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay className="guard-dialog__overlay" />
|
||||
<AlertDialog.Content className="guard-dialog">
|
||||
@ -60,9 +45,7 @@ export function UnsavedChangesGuard({
|
||||
<button
|
||||
type="button"
|
||||
className="guard-dialog__discard-btn"
|
||||
onClick={() => {
|
||||
pendingDiscard.current = true;
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); onDiscard(); }}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
|
||||
@ -434,8 +434,7 @@ func (h *MCPHandler) dispatchRPC(ctx context.Context, workspaceID string, req mc
|
||||
}
|
||||
|
||||
default:
|
||||
// Per OFFSEC-001: error message must not include user-controlled req.Method.
|
||||
base.Error = &mcpRPCError{Code: -32601, Message: "method not found"}
|
||||
base.Error = &mcpRPCError{Code: -32601, Message: "method not found: " + req.Method}
|
||||
}
|
||||
|
||||
return base
|
||||
|
||||
@ -9,7 +9,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"errors"
|
||||
@ -205,9 +204,6 @@ func TestMCPHandler_NotificationsInitialized_Returns200(t *testing.T) {
|
||||
// Unknown method
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestMCPHandler_UnknownMethod_Returns32601 verifies dispatchRPC returns
|
||||
// -32601 for an unknown method. Per OFFSEC-001: the error message must be
|
||||
// constant — req.Method is user-controlled and must NOT appear in the response.
|
||||
func TestMCPHandler_UnknownMethod_Returns32601(t *testing.T) {
|
||||
h, _ := newMCPHandler(t)
|
||||
|
||||
@ -228,14 +224,6 @@ func TestMCPHandler_UnknownMethod_Returns32601(t *testing.T) {
|
||||
if resp.Error.Code != -32601 {
|
||||
t.Errorf("expected code -32601, got %d", resp.Error.Code)
|
||||
}
|
||||
// Message must be constant — no user-controlled method name leak.
|
||||
if resp.Error.Message != "method not found" {
|
||||
t.Errorf("error message should be constant 'method not found', got: %q", resp.Error.Message)
|
||||
}
|
||||
// Double-check the method name never appears in the message (defence-in-depth).
|
||||
if strings.Contains(resp.Error.Message, "not/a/real/method") {
|
||||
t.Error("error message must not echo the user-controlled method name")
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Loading…
Reference in New Issue
Block a user