From 1c61db9042ef386c2f80dee39e1378324c92856a Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Mon, 18 May 2026 06:51:20 +0000 Subject: [PATCH 1/7] test: PatchAbilities handler + resolveWorkspaceName coverage (#1481) Co-authored-by: Molecule AI Fullstack Engineer Co-committed-by: Molecule AI Fullstack Engineer --- .../__tests__/resolveWorkspaceName.test.ts | 102 +++++++++ .../handlers/workspace_abilities_test.go | 193 ++++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 canvas/src/components/tabs/chat/__tests__/resolveWorkspaceName.test.ts create mode 100644 workspace-server/internal/handlers/workspace_abilities_test.go diff --git a/canvas/src/components/tabs/chat/__tests__/resolveWorkspaceName.test.ts b/canvas/src/components/tabs/chat/__tests__/resolveWorkspaceName.test.ts new file mode 100644 index 000000000..2f54f9e87 --- /dev/null +++ b/canvas/src/components/tabs/chat/__tests__/resolveWorkspaceName.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useCanvasStore } from "@/store/canvas"; +import { resolveWorkspaceName } from "../hooks/resolveWorkspaceName"; + +beforeEach(() => { + // Reset store to a clean slate between tests so node lookup is deterministic. + useCanvasStore.setState({ nodes: [] }); +}); + +describe("resolveWorkspaceName", () => { + it("returns the workspace name when a node with that ID exists", () => { + useCanvasStore.setState({ + nodes: [ + { + id: "ws-alpha-001", + type: "workspace", + data: { name: "Alpha Agent" }, + position: { x: 0, y: 0 }, + }, + ], + }); + + expect(resolveWorkspaceName("ws-alpha-001")).toBe("Alpha Agent"); + }); + + it("falls back to the first 8 chars of the ID when no matching node exists", () => { + expect(resolveWorkspaceName("ws-zzz-not-found")).toBe("ws-zzz-n"); + }); + + it("falls back to the first 8 chars when the node exists but has no name", () => { + useCanvasStore.setState({ + nodes: [ + { + id: "ws-no-name", + type: "workspace", + // data.name is deliberately absent + data: {}, + position: { x: 0, y: 0 }, + }, + ], + }); + + expect(resolveWorkspaceName("ws-no-name")).toBe("ws-no-na"); + }); + + it("returns the first 8 chars for a very short ID", () => { + expect(resolveWorkspaceName("ab")).toBe("ab"); + }); + + it("returns the first 8 chars when the ID is exactly 8 characters", () => { + // slice(0,8) of an 8-char string is the full string + const id = "12345678"; + expect(resolveWorkspaceName(id)).toBe(id); + }); + + it("picks the right node when multiple workspaces share a prefix", () => { + useCanvasStore.setState({ + nodes: [ + { + id: "00000000-0000-0000-0000-000000000001", + type: "workspace", + data: { name: "Backend Agent" }, + position: { x: 0, y: 0 }, + }, + { + id: "00000000-0000-0000-0000-000000000002", + type: "workspace", + data: { name: "Frontend Agent" }, + position: { x: 100, y: 0 }, + }, + ], + }); + + expect(resolveWorkspaceName("00000000-0000-0000-0000-000000000002")).toBe( + "Frontend Agent" + ); + expect(resolveWorkspaceName("00000000-0000-0000-0000-000000000001")).toBe( + "Backend Agent" + ); + }); + + it("does not mutate store state between calls", () => { + useCanvasStore.setState({ + nodes: [ + { + id: "stable-id", + type: "workspace", + data: { name: "Stable Workspace" }, + position: { x: 0, y: 0 }, + }, + ], + }); + + resolveWorkspaceName("stable-id"); + resolveWorkspaceName("unknown-id"); + + // Store nodes must be unchanged — resolveWorkspaceName is read-only. + const nodes = useCanvasStore.getState().nodes; + expect(nodes).toHaveLength(1); + expect((nodes[0] as { id: string }).id).toBe("stable-id"); + }); +}); diff --git a/workspace-server/internal/handlers/workspace_abilities_test.go b/workspace-server/internal/handlers/workspace_abilities_test.go new file mode 100644 index 000000000..ea912b389 --- /dev/null +++ b/workspace-server/internal/handlers/workspace_abilities_test.go @@ -0,0 +1,193 @@ +package handlers + +import ( + "bytes" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" +) + +// patchReq builds a gin context for a PATCH request to /workspaces/:id/abilities. +func patchReq(id, body string) (*http.Request, *httptest.ResponseRecorder, *gin.Context) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: id}} + c.Request = httptest.NewRequest("PATCH", "/workspaces/"+id+"/abilities", bytes.NewBufferString(body)) + c.Request.Header.Set("Content-Type", "application/json") + return c.Request, w, c +} + +func TestPatchAbilities_InvalidWorkspaceID(t *testing.T) { + setupTestDB(t) + + // "not-a-uuid" fails validateWorkspaceID + _, w, c := patchReq("not-a-uuid", `{"broadcast_enabled":true}`) + PatchAbilities(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPatchAbilities_EmptyBody(t *testing.T) { + setupTestDB(t) + id := "00000000-0000-0000-0000-000000000001" + + // Empty JSON object — no ability fields present + _, w, c := patchReq(id, `{}`) + PatchAbilities(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["error"] != "at least one ability field required" { + t.Errorf("expected 'at least one ability field required', got %v", resp["error"]) + } +} + +func TestPatchAbilities_WorkspaceNotFound(t *testing.T) { + mock := setupTestDB(t) + id := "00000000-0000-0000-0000-000000000002" + + // SELECT EXISTS returns false (workspace does not exist) + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + _, w, c := patchReq(id, `{"broadcast_enabled":true}`) + PatchAbilities(c) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPatchAbilities_SetBroadcastEnabledTrue(t *testing.T) { + mock := setupTestDB(t) + id := "00000000-0000-0000-0000-000000000003" + + // SELECT EXISTS → true + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + // UPDATE broadcast_enabled = true + mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(id, true). + WillReturnResult(sqlmock.NewResult(0, 1)) + + _, w, c := patchReq(id, `{"broadcast_enabled":true}`) + PatchAbilities(c) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["status"] != "updated" { + t.Errorf("expected status=updated, got %v", resp["status"]) + } +} + +func TestPatchAbilities_SetTalkToUserEnabledFalse(t *testing.T) { + mock := setupTestDB(t) + id := "00000000-0000-0000-0000-000000000004" + + // SELECT EXISTS → true + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + // UPDATE talk_to_user_enabled = false + mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(id, false). + WillReturnResult(sqlmock.NewResult(0, 1)) + + _, w, c := patchReq(id, `{"talk_to_user_enabled":false}`) + PatchAbilities(c) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPatchAbilities_BothFields(t *testing.T) { + mock := setupTestDB(t) + id := "00000000-0000-0000-0000-000000000005" + + // SELECT EXISTS → true + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + // UPDATE broadcast_enabled = false + mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(id, false). + WillReturnResult(sqlmock.NewResult(0, 1)) + + // UPDATE talk_to_user_enabled = true + mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(id, true). + WillReturnResult(sqlmock.NewResult(0, 1)) + + _, w, c := patchReq(id, `{"broadcast_enabled":false,"talk_to_user_enabled":true}`) + PatchAbilities(c) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPatchAbilities_BroadcastUpdateFails(t *testing.T) { + mock := setupTestDB(t) + id := "00000000-0000-0000-0000-000000000006" + + // SELECT EXISTS → true + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + // UPDATE fails + mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(id, true). + WillReturnError(sql.ErrConnDone) + + _, w, c := patchReq(id, `{"broadcast_enabled":true}`) + PatchAbilities(c) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPatchAbilities_TalkToUserUpdateFails(t *testing.T) { + mock := setupTestDB(t) + id := "00000000-0000-0000-0000-000000000007" + + // SELECT EXISTS → true + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + // UPDATE broadcast_enabled skipped (not in payload) + // UPDATE talk_to_user_enabled fails + mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(id, false). + WillReturnError(sql.ErrConnDone) + + _, w, c := patchReq(id, `{"talk_to_user_enabled":false}`) + PatchAbilities(c) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String()) + } +} -- 2.52.0 From 3d0d9b1818c22f943ba0232fe63aec840b1fe0d9 Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Mon, 18 May 2026 06:51:21 +0000 Subject: [PATCH 2/7] test(handlers): add Uninstall 503 coverage for plugins_install.go (closes #1377) (#1378) Co-authored-by: Molecule AI Fullstack Engineer Co-committed-by: Molecule AI Fullstack Engineer --- .../internal/handlers/plugins_install_test.go | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 workspace-server/internal/handlers/plugins_install_test.go diff --git a/workspace-server/internal/handlers/plugins_install_test.go b/workspace-server/internal/handlers/plugins_install_test.go new file mode 100644 index 000000000..06c908788 --- /dev/null +++ b/workspace-server/internal/handlers/plugins_install_test.go @@ -0,0 +1,53 @@ +package handlers + +// plugins_install_test.go — additional coverage for plugins_install.go. +// +// Gaps filled vs. existing test files: +// - plugins_install_external_test.go: Install + Uninstall 422 (external runtime) ✓ covered +// - plugins_test.go: Install 400 (missing source, invalid body, etc.) ✓ covered +// Uninstall 400 (invalid plugin name, empty name) ✓ covered +// Download auth gate ✓ covered +// - org_import_helpers_test.go: countWorkspaces, envRequirementKey, sanitizeEnvMembers, +// flattenAndSortRequirements, collectOrgEnv ✓ covered +// +// New test added here: +// - Uninstall 503: container not running, no SaaS dispatch. +// +// NOTE: validateWorkspaceID is not called inside the Install/Uninstall handlers. +// UUID validation is the responsibility of the WorkspaceAuth middleware, so no +// 400 test is needed here for UUID format. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// TestPluginUninstall_ContainerNotRunning_Returns503 exercises the 503 path +// where neither a local Docker container nor a SaaS instance-id dispatch +// resolves. The handler must return "workspace container not running" — NOT a +// generic 500 or a misleading 422 (external-runtime) message. +func TestPluginUninstall_ContainerNotRunning_Returns503(t *testing.T) { + // No docker client + no instance-id lookup → falls through to 503. + h := NewPluginsHandler(t.TempDir(), nil, nil) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}, + {Key: "name", Value: "some-plugin"}, + } + c.Request = httptest.NewRequest("DELETE", + "/workspaces/550e8400-e29b-41d4-a716-446655440000/plugins/some-plugin", nil) + + h.Uninstall(c) + + require.Equal(t, http.StatusServiceUnavailable, w.Code) + var body map[string]string + json.Unmarshal(w.Body.Bytes(), &body) + require.Equal(t, "workspace container not running", body["error"]) +} -- 2.52.0 From 376f78278dd9f8b811705d9315bc0a24f68ba787 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-QA Date: Mon, 18 May 2026 07:30:24 +0000 Subject: [PATCH 3/7] fix(ci): increase Go test timeouts for cold runner performance (#1175) Co-authored-by: Molecule AI Core-QA Co-committed-by: Molecule AI Core-QA --- .gitea/workflows/ci.yml | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6c98159e4..1d9ab7ffa 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -145,10 +145,10 @@ jobs: # the diagnostic step with its own continue-on-error: true (line 203). # Flip confirmed by CI / Platform (Go) status = success on main HEAD 363905d3. continue-on-error: false - # Job-level ceiling. The go test step below runs with a per-step 10m timeout; - # this cap catches any step that leaks past that. Set well above 10m so + # Job-level ceiling. The go test step below runs with a per-step 30m timeout; + # this cap catches any step that leaks past that. Set well above 30m so # the per-step timeout is the active constraint. - timeout-minutes: 15 + timeout-minutes: 35 defaults: run: working-directory: workspace-server @@ -176,12 +176,14 @@ jobs: name: Run golangci-lint run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./... - if: always() - name: Diagnostic — per-package verbose 60s + name: Diagnostic — per-package verbose (300s timeout) run: | set +e - go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log + # 300s allows handlers + pendinguploads packages to complete on cold + # runners with -race instrumentation (~60-120s each vs ~14s non-race). + go test -race -v -timeout 300s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log handlers_exit=$? - go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log + go test -race -v -timeout 300s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log pu_exit=$? echo "::group::handlers exit=$handlers_exit (last 100 lines)" tail -100 /tmp/test-handlers.log @@ -194,10 +196,10 @@ jobs: - if: always() name: Run tests with race detection and coverage # Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the - # full ./... suite with race detection + coverage. A 10m per-step timeout - # lets the suite complete on cold cache (~5-7m) while failing cleanly - # instead of OOM-killing. The job-level timeout (15m) is a backstop. - run: go test -race -timeout 10m -coverprofile=coverage.out ./... + # full ./... suite with race detection + coverage. A 30m per-step timeout + # lets the suite complete on cold cache (~13-25m) while failing cleanly + # instead of OOM-killing. The job-level timeout (35m) is a backstop. + run: go test -race -timeout 30m -coverprofile=coverage.out ./... - if: always() name: Per-file coverage report -- 2.52.0 From e84bf3a4c600381796287429a7ce743b9281fbe3 Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Mon, 18 May 2026 07:30:33 +0000 Subject: [PATCH 4/7] test(handlers+canvas): BroadcastHandler sqlmock suite + extractAgentText tests (#1475) Co-authored-by: Molecule AI Fullstack Engineer Co-committed-by: Molecule AI Fullstack Engineer --- .../chat/__tests__/message-parser.test.ts | 82 ++++++++ .../internal/handlers/workspace_broadcast.go | 6 +- .../handlers/workspace_broadcast_test.go | 184 ++++++++++++++---- 3 files changed, 235 insertions(+), 37 deletions(-) diff --git a/canvas/src/components/tabs/chat/__tests__/message-parser.test.ts b/canvas/src/components/tabs/chat/__tests__/message-parser.test.ts index 3a4748a7b..287800419 100644 --- a/canvas/src/components/tabs/chat/__tests__/message-parser.test.ts +++ b/canvas/src/components/tabs/chat/__tests__/message-parser.test.ts @@ -248,6 +248,88 @@ describe("extractResponseText", () => { }); }); +describe("extractAgentText", () => { + it("extracts text from top-level parts", () => { + const task = { + parts: [{ kind: "text", text: "Agent said hello" }], + }; + expect(extractAgentText(task)).toBe("Agent said hello"); + }); + + it("extracts from artifacts[0].parts when top-level parts absent", () => { + const task = { + artifacts: [ + { parts: [{ kind: "text", text: "From artifact block" }] }, + ], + }; + expect(extractAgentText(task)).toBe("From artifact block"); + }); + + it("extracts from status.message.parts as fallback", () => { + const task = { + status: { + message: { parts: [{ kind: "text", text: "Status text" }] }, + }, + }; + expect(extractAgentText(task)).toBe("Status text"); + }); + + it("prefers top-level parts over artifacts", () => { + const task = { + parts: [{ kind: "text", text: "top-level wins" }], + artifacts: [ + { parts: [{ kind: "text", text: "artifact text" }] }, + ], + }; + expect(extractAgentText(task)).toBe("top-level wins"); + }); + + it("prefers top-level parts over status.message", () => { + const task = { + parts: [{ kind: "text", text: "parts wins" }], + status: { + message: { parts: [{ kind: "text", text: "status text" }] }, + }, + }; + expect(extractAgentText(task)).toBe("parts wins"); + }); + + it("returns string identity when task itself is a string", () => { + expect(extractAgentText("plain string task" as unknown as Record)).toBe( + "plain string task", + ); + }); + + it("returns fallback when task is an empty object", () => { + expect(extractAgentText({})).toBe("(Could not extract response text)"); + }); + + it("returns fallback when task has no extractable text", () => { + expect( + extractAgentText({ status: "running", other: "fields" }), + ).toBe("(Could not extract response text)"); + }); + + it("tolerates malformed nested shapes without throwing", () => { + const task = { + parts: null, + artifacts: "not an array", + status: { message: 42 }, + }; + expect(extractAgentText(task)).toBe("(Could not extract response text)"); + }); + + it("joins multiple text parts with newline", () => { + const task = { + parts: [ + { kind: "text", text: "Line one" }, + { kind: "text", text: "Line two" }, + ], + }; + expect(extractAgentText(task)).toBe("Line one\nLine two"); + }); +}); + describe("extractTextsFromParts", () => { it("extracts text parts with kind=text", () => { const parts = [ diff --git a/workspace-server/internal/handlers/workspace_broadcast.go b/workspace-server/internal/handlers/workspace_broadcast.go index 668475661..e4b9e08f6 100644 --- a/workspace-server/internal/handlers/workspace_broadcast.go +++ b/workspace-server/internal/handlers/workspace_broadcast.go @@ -34,11 +34,13 @@ import ( // BroadcastHandler is constructed once and shared across requests. type BroadcastHandler struct { - broadcaster *events.Broadcaster + broadcaster events.EventEmitter } // NewBroadcastHandler creates a BroadcastHandler. -func NewBroadcastHandler(b *events.Broadcaster) *BroadcastHandler { +// The emitter is any EventEmitter — the concrete *Broadcaster in production, +// or a test double in unit tests. +func NewBroadcastHandler(b events.EventEmitter) *BroadcastHandler { return &BroadcastHandler{broadcaster: b} } diff --git a/workspace-server/internal/handlers/workspace_broadcast_test.go b/workspace-server/internal/handlers/workspace_broadcast_test.go index 506686433..c662f52a9 100644 --- a/workspace-server/internal/handlers/workspace_broadcast_test.go +++ b/workspace-server/internal/handlers/workspace_broadcast_test.go @@ -67,7 +67,6 @@ func TestBroadcast_OrgScopedRecipients(t *testing.T) { 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 response: %v", err) @@ -206,7 +205,7 @@ func TestBroadcast_Disabled(t *testing.T) { broadcaster := newTestBroadcaster() handler := NewBroadcastHandler(broadcaster) - senderID := "00000000-0000-0000-0000-000000000001" + senderID := "00000000-0000-0000-0000-000000000003" 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)) @@ -237,7 +236,7 @@ func TestBroadcast_EmptyOrg_NoRecipients(t *testing.T) { broadcaster := newTestBroadcaster() handler := NewBroadcastHandler(broadcaster) - senderID := "00000000-0000-0000-0000-000000000001" // org root, only workspace in org + senderID := "00000000-0000-0000-0000-000000000004" // org root, only workspace in org mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`). WithArgs(senderID). @@ -297,33 +296,12 @@ func TestBroadcast_InvalidWorkspaceID(t *testing.T) { } } -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" + senderID := "00000000-0000-0000-0000-000000000005" mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`). WithArgs(senderID). @@ -353,16 +331,13 @@ func TestBroadcast_OrgRootLookupFails(t *testing.T) { } } -// 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). func TestBroadcast_OrgScoped_SelfBroadcastExcluded(t *testing.T) { mock := setupTestDB(t) broadcaster := newTestBroadcaster() handler := NewBroadcastHandler(broadcaster) - senderID := "00000000-0000-0000-0000-000000000001" - peerID := "00000000-0000-0000-0000-000000000002" + senderID := "00000000-0000-0000-0000-000000000006" + peerID := "00000000-0000-0000-0000-000000000007" mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`). WithArgs(senderID). @@ -399,10 +374,145 @@ func TestBroadcast_OrgScoped_SelfBroadcastExcluded(t *testing.T) { } } +// TestBroadcast_RecipientActivityLogFails_SkipsAndContinues: if one recipient's +// activity_log insert fails, the handler logs the error and continues to the +// next recipient rather than aborting the whole broadcast. +func TestBroadcast_RecipientActivityLogFails_SkipsAndContinues(t *testing.T) { + mock := setupTestDB(t) + broadcaster := newTestBroadcaster() + handler := NewBroadcastHandler(broadcaster) + + senderID := "00000000-0000-0000-0000-000000000008" + peerA := "00000000-0000-0000-0000-000000000009" + peerB := "00000000-0000-0000-0000-00000000000a" + + mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`). + WithArgs(senderID). + WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Resilient Agent", true)) + + mock.ExpectQuery(`WITH RECURSIVE org_chain AS`). + WithArgs(senderID). + WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID)) + + mock.ExpectQuery(`WITH RECURSIVE org_chain AS`). + WithArgs(senderID, senderID). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(peerA).AddRow(peerB)) + + // Peer A fails — handler logs and continues + mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(peerA, senderID, sqlmock.AnyArg()). + WillReturnError(context.DeadlineExceeded) + // Peer B succeeds + mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(peerB, senderID, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(0, 1)) + // Sender log succeeds + 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":"partial delivery"}` + 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.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + // Only peerB was delivered + if int(resp["delivered"].(float64)) != 1 { + t.Errorf("expected delivered=1, got %v", resp["delivered"]) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestBroadcast_SenderActivityLogFails_StillReturns200: if the sender's own +// broadcast_sent activity_log insert fails, the handler still returns 200 +// so the caller doesn't retry a broadcast that already partially delivered. +func TestBroadcast_SenderActivityLogFails_StillReturns200(t *testing.T) { + mock := setupTestDB(t) + broadcaster := newTestBroadcaster() + handler := NewBroadcastHandler(broadcaster) + + senderID := "00000000-0000-0000-0000-00000000000b" + peerA := "00000000-0000-0000-0000-00000000000c" + + mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`). + WithArgs(senderID). + WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Log-Fail Agent", true)) + + mock.ExpectQuery(`WITH RECURSIVE org_chain AS`). + WithArgs(senderID). + WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID)) + + mock.ExpectQuery(`WITH RECURSIVE org_chain AS`). + WithArgs(senderID, senderID). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(peerA)) + + // Peer log succeeds + mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(peerA, senderID, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(0, 1)) + // Sender log FAILS + mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()). + WillReturnError(context.DeadlineExceeded) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: senderID}} + body := `{"message":"log fail 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.StatusOK { + t.Errorf("expected 200 even on sender log failure, 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-00000000000d"}} + c.Request = httptest.NewRequest("POST", "/workspaces/00000000-0000-0000-0000-00000000000d/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()) + } +} + +func TestBroadcast_MissingBody(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-00000000000e"}} + c.Request = httptest.NewRequest("POST", "/workspaces/00000000-0000-0000-0000-00000000000e/broadcast", nil) + // no Content-Type and no body + + handler.Broadcast(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + // 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 + "…"). +// character (U+2026) when len(msg) > max. The truncated output is max runes + "…". func TestBroadcast_Truncate(t *testing.T) { cases := []struct { msg string @@ -410,14 +520,18 @@ func TestBroadcast_Truncate(t *testing.T) { expect string }{ {"short", 120, "short"}, // under max — no truncation - // exactly120chars (15) + 105 ones = 120 chars; at max=120 → unchanged + // exactly 120 chars → unchanged {"exactly120chars1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", 120, "exactly120chars111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111…"}, - // "this is a longer mes" = 20 runes; + "…" = 21 chars + // 21 runes at max=20 → 20 + "…" = 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…"}, + // Unicode: 3-rune string at max=3 → unchanged + {"日本語", 3, "日本語"}, + // Empty string → unchanged + {"", 120, ""}, } for _, tc := range cases { result := broadcastTruncate(tc.msg, tc.max) -- 2.52.0 From bb61352c8649405adef7667322cbca09fce3521c Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Mon, 18 May 2026 09:21:15 +0000 Subject: [PATCH 5/7] feat(canvas): add theme-cookie + theme-provider test coverage Add 16 tests for theme-provider.tsx (previously uncovered): - applyResolvedTheme: sets data-theme on documentElement, idempotent, guards against undefined document (SSR safety) - ThemeProvider: initialTheme, resolvedTheme derivation, setTheme updates both theme+resolvedTheme, writes mol_theme cookie, calls applyResolvedTheme on mount and on change - useTheme: noop fallback when no provider in tree, setTheme no-op Extend theme-cookie.test.ts with 9 tests: - THEME_COOKIE constant: non-empty string, equals "mol_theme", stable - themeBootScript: non-empty, contains cookie name, system/light/dark, dataset.theme assignment, matchMedia call, IIFE wrapper, pure/stable Also export applyResolvedTheme from theme-provider.tsx so it is importable in tests (previously unexported internal function). No handler or component logic changed. 25 new tests pass. Closes #1466 Co-Authored-By: Claude Opus 4.7 --- canvas/src/lib/__tests__/theme-cookie.test.ts | 67 ++++- .../src/lib/__tests__/theme-provider.test.tsx | 277 ++++++++++++++++++ canvas/src/lib/theme-provider.tsx | 2 +- 3 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 canvas/src/lib/__tests__/theme-provider.test.tsx diff --git a/canvas/src/lib/__tests__/theme-cookie.test.ts b/canvas/src/lib/__tests__/theme-cookie.test.ts index 018e382ec..a9a01ad08 100644 --- a/canvas/src/lib/__tests__/theme-cookie.test.ts +++ b/canvas/src/lib/__tests__/theme-cookie.test.ts @@ -1,9 +1,12 @@ // @vitest-environment jsdom /** - * Tests for readThemeCookie — parses a cookie value into a ThemePreference. + * Tests for theme-cookie.ts: + * - THEME_COOKIE constant + * - readThemeCookie + * - themeBootScript */ import { describe, it, expect } from "vitest"; -import { readThemeCookie } from "../theme-cookie"; +import { readThemeCookie, THEME_COOKIE, themeBootScript } from "../theme-cookie"; describe("readThemeCookie", () => { it('returns "light" when cookie value is "light"', () => { @@ -45,3 +48,63 @@ describe("readThemeCookie", () => { } }); }); + +// ── THEME_COOKIE ──────────────────────────────────────────────────────────────── + +describe("THEME_COOKIE", () => { + it("is a non-empty string", () => { + expect(typeof THEME_COOKIE).toBe("string"); + expect(THEME_COOKIE.length).toBeGreaterThan(0); + }); + + it("equals 'mol_theme'", () => { + expect(THEME_COOKIE).toBe("mol_theme"); + }); + + it("is stable — constant is not reassigned", () => { + const first = THEME_COOKIE; + const second = THEME_COOKIE; + expect(first).toBe(second); + }); +}); + +// ── themeBootScript ───────────────────────────────────────────────────────────── + +describe("themeBootScript", () => { + it("is a non-empty string", () => { + expect(typeof themeBootScript).toBe("string"); + expect(themeBootScript.length).toBeGreaterThan(0); + }); + + it("contains THEME_COOKIE value in the cookie-regex pattern", () => { + // The script reads document.cookie looking for mol_theme=... + expect(themeBootScript).toContain(THEME_COOKIE); + }); + + it("contains 'system', 'light', 'dark' in the match pattern", () => { + expect(themeBootScript).toContain("system"); + expect(themeBootScript).toContain("light"); + expect(themeBootScript).toContain("dark"); + }); + + it("contains data-theme assignment on documentElement", () => { + // The script sets document.documentElement.dataset.theme = resolved + expect(themeBootScript).toContain("dataset.theme"); + expect(themeBootScript).toContain("document.documentElement"); + }); + + it("contains matchMedia call for OS preference fallback", () => { + expect(themeBootScript).toContain("matchMedia"); + expect(themeBootScript).toContain("prefers-color-scheme"); + }); + + it("wraps the entire body in an IIFE so it runs immediately", () => { + expect(themeBootScript).toMatch(/^\(\(\)=>/); + }); + + it("is pure — constant evaluated once, same value every time", () => { + const a = themeBootScript; + const b = themeBootScript; + expect(a).toBe(b); + }); +}); diff --git a/canvas/src/lib/__tests__/theme-provider.test.tsx b/canvas/src/lib/__tests__/theme-provider.test.tsx new file mode 100644 index 000000000..88c01a74f --- /dev/null +++ b/canvas/src/lib/__tests__/theme-provider.test.tsx @@ -0,0 +1,277 @@ +// @vitest-environment jsdom +"use client"; +/** + * Tests for theme-provider.tsx: + * - applyResolvedTheme — pure DOM side-effect function + * - ThemeProvider — context, setTheme, resolvedTheme derivation + * - useTheme — hook + noop fallback + * + * Coverage gaps filled vs theme-cookie.test.ts (which tests only readThemeCookie): + * applyResolvedTheme, ThemeProvider initialTheme, resolvedTheme derivation + * from system preference, writeThemeCookie integration, useTheme noop fallback. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import React from "react"; +import { render, screen, cleanup, act, waitFor } from "@testing-library/react"; +import { applyResolvedTheme, ThemeProvider, useTheme } from "../theme-provider"; + +// ─── applyResolvedTheme ──────────────────────────────────────────────────────── + +describe("applyResolvedTheme", () => { + beforeEach(() => { + if (typeof document !== "undefined") { + delete (document.documentElement as Record).dataset; + } + }); + + afterEach(() => { + cleanup(); + if (typeof document !== "undefined") { + delete (document.documentElement as Record).dataset; + } + }); + + it('sets data-theme="light" on document.documentElement', () => { + applyResolvedTheme("light"); + expect(document.documentElement.dataset.theme).toBe("light"); + }); + + it('sets data-theme="dark" on document.documentElement', () => { + applyResolvedTheme("dark"); + expect(document.documentElement.dataset.theme).toBe("dark"); + }); + + it("is idempotent — calling twice with same value keeps the same attribute", () => { + applyResolvedTheme("dark"); + applyResolvedTheme("dark"); + expect(document.documentElement.dataset.theme).toBe("dark"); + }); + + it("is a pure function for its DOM side-effect — no return value", () => { + expect(applyResolvedTheme("light")).toBeUndefined(); + }); + + it("guards against undefined document (SSR safety)", () => { + // In Node.js / SSR context document is undefined; the function returns + // early without throwing. We simulate this by temporarily deleting document. + const saved = globalThis.document; + // @ts-expect-error — intentionally undefined for SSR test + globalThis.document = undefined; + expect(() => applyResolvedTheme("dark")).not.toThrow(); + globalThis.document = saved; + }); +}); + +// ─── ThemeProvider ───────────────────────────────────────────────────────────── + +describe("ThemeProvider", () => { + beforeEach(() => { + // Stub matchMedia so ThemeProvider's system-preference useEffect works in jsdom. + // Default to light mode (matches=false) so resolvedTheme="light" when theme="system". + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, // light preference by default + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + }); + + afterEach(() => { + cleanup(); + if (typeof document !== "undefined") { + delete (document.documentElement as Record).dataset; + } + // Clear cookies set by writeThemeCookie. + if (typeof document !== "undefined") { + document.cookie = "mol_theme=; Max-Age=0"; + } + }); + + function ThemeChild() { + const { theme, resolvedTheme, setTheme } = useTheme(); + return ( +
+ {theme} + {resolvedTheme} + + +
+ ); + } + + it("renders children", () => { + render( + + Hello + , + ); + expect(screen.getByTestId("child")).toBeTruthy(); + }); + + it('initialTheme="light" sets theme=light', () => { + render( + + + , + ); + expect(screen.getByTestId("theme").textContent).toBe("light"); + }); + + it('initialTheme="dark" sets theme=dark', () => { + render( + + + , + ); + expect(screen.getByTestId("theme").textContent).toBe("dark"); + }); + + it('initialTheme="system" falls back to light (matchMedia stub)', () => { + // matchMedia is not stubbed in jsdom by default; the provider calls it + // and reads the OS preference. Without a stub, jsdom returns + // { matches: false } → "light". + render( + + + , + ); + // Resolved is "light" because jsdom matchMedia stub returns false for dark. + expect(screen.getByTestId("resolved").textContent).toBe("light"); + }); + + it("setTheme('dark') updates both theme and resolvedTheme", async () => { + render( + + + , + ); + expect(screen.getByTestId("theme").textContent).toBe("light"); + + await act(async () => { + screen.getByTestId("set-dark").click(); + }); + + expect(screen.getByTestId("theme").textContent).toBe("dark"); + // resolvedTheme tracks theme when not in system mode. + expect(screen.getByTestId("resolved").textContent).toBe("dark"); + }); + + it("setTheme('light') updates both theme and resolvedTheme", async () => { + render( + + + , + ); + await act(async () => { + screen.getByTestId("set-light").click(); + }); + expect(screen.getByTestId("theme").textContent).toBe("light"); + expect(screen.getByTestId("resolved").textContent).toBe("light"); + }); + + it("writes mol_theme cookie when setTheme is called", async () => { + render( + + + , + ); + await act(async () => { + screen.getByTestId("set-dark").click(); + }); + expect(document.cookie).toContain("mol_theme=dark"); + }); + + it("calls applyResolvedTheme on mount (data-theme set on )", () => { + render( + + hi + , + ); + expect(document.documentElement.dataset.theme).toBe("dark"); + }); + + it("calls applyResolvedTheme when resolvedTheme changes", async () => { + render( + + + , + ); + // Start at light. + expect(document.documentElement.dataset.theme).toBe("light"); + + await act(async () => { + screen.getByTestId("set-dark").click(); + }); + + expect(document.documentElement.dataset.theme).toBe("dark"); + }); +}); + +// ─── useTheme noop fallback ──────────────────────────────────────────────────── + +describe("useTheme without ThemeProvider", () => { + afterEach(() => { + cleanup(); + }); + + it("useTheme returns noopTheme when no provider is in the tree", () => { + function ShowTheme() { + const { theme, resolvedTheme, setTheme } = useTheme(); + return ( +
+ {theme} + {resolvedTheme} + {typeof setTheme} +
+ ); + } + render(); + // noopTheme defaults: theme="system", resolvedTheme="light", setTheme no-op. + expect(screen.getByTestId("theme").textContent).toBe("system"); + expect(screen.getByTestId("resolved").textContent).toBe("light"); + expect(screen.getByTestId("setTheme-type").textContent).toBe("function"); + }); + + it("setTheme is a no-op when no provider is present (no throw)", async () => { + let threw = false; + function ClickSetTheme() { + const { setTheme } = useTheme(); + return ( + + ); + } + render(); + await act(async () => { + screen.getByTestId("call-setTheme").click(); + }); + expect(threw).toBe(false); + }); +}); diff --git a/canvas/src/lib/theme-provider.tsx b/canvas/src/lib/theme-provider.tsx index 4ca783205..5baebe117 100644 --- a/canvas/src/lib/theme-provider.tsx +++ b/canvas/src/lib/theme-provider.tsx @@ -75,7 +75,7 @@ function writeThemeCookie(value: ThemePreference): void { document.cookie = parts.join("; "); } -function applyResolvedTheme(resolved: ResolvedTheme): void { +export function applyResolvedTheme(resolved: ResolvedTheme): void { if (typeof document === "undefined") return; document.documentElement.dataset.theme = resolved; } -- 2.52.0 From b97b06bd4a3275afc5c4e74956e700a892f8547c Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Mon, 18 May 2026 10:55:09 +0000 Subject: [PATCH 6/7] fix(ci): add secrets:read to qa-review/security-review/sop-checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEV-1 #1413: three CI workflows fail for ALL open PRs because Gitea Actions cannot substitute secret values without secrets:read permission. Without it, env vars are empty → every API call gets 401 → jobs exit 1 → merge-queue is blocked. Fix: add secrets:read to all three workflow permission blocks. sop-checklist.yml also cleans up stale comment boilerplate around statuses:write (already declared but undocumented). Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/qa-review.yml | 1 + .gitea/workflows/security-review.yml | 1 + .gitea/workflows/sop-checklist.yml | 5 +---- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/qa-review.yml b/.gitea/workflows/qa-review.yml index 13f610dc4..90a94c77e 100644 --- a/.gitea/workflows/qa-review.yml +++ b/.gitea/workflows/qa-review.yml @@ -89,6 +89,7 @@ on: permissions: contents: read pull-requests: read + secrets: read jobs: # bp-exempt: PR review bot signal; required merge state is enforced by CI / all-required. diff --git a/.gitea/workflows/security-review.yml b/.gitea/workflows/security-review.yml index b882a7427..e905a401e 100644 --- a/.gitea/workflows/security-review.yml +++ b/.gitea/workflows/security-review.yml @@ -16,6 +16,7 @@ on: permissions: contents: read pull-requests: read + secrets: read jobs: # bp-exempt: PR security review bot signal; required merge state is enforced by CI / all-required. diff --git a/.gitea/workflows/sop-checklist.yml b/.gitea/workflows/sop-checklist.yml index 85ebf50a1..3e45438cf 100644 --- a/.gitea/workflows/sop-checklist.yml +++ b/.gitea/workflows/sop-checklist.yml @@ -84,11 +84,8 @@ on: permissions: contents: read pull-requests: read - # NOTE: `statuses: write` is the GitHub-Actions name for POST /statuses. - # Gitea 1.22.6 may not gate on this permission key (it just checks the - # token), but listing it explicitly documents intent for the next - # platform-version upgrade. statuses: write + secrets: read jobs: all-items-acked: -- 2.52.0 From c6a3ffd6709f97d830becb910382a5d79202f28f Mon Sep 17 00:00:00 2001 From: Molecule AI Core-DevOps Date: Mon, 18 May 2026 11:05:25 +0000 Subject: [PATCH 7/7] chore: re-trigger workflows for SEV-1 fix verification Co-Authored-By: Claude Opus 4.7 -- 2.52.0