Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a981a472a | |||
| 3d0d9b1818 | |||
| 1c61db9042 |
@@ -206,29 +206,6 @@ CANDIDATES=$(jq -r --arg author "$PR_AUTHOR" --arg head "$PR_HEAD_SHA" "$JQ_FILT
|
||||
debug "candidate non-author approvers: $(echo "$CANDIDATES" | tr '\n' ' ')"
|
||||
|
||||
if [ -z "$CANDIDATES" ]; then
|
||||
# --- Guardrail (internal#503): explain the most common false
|
||||
# "no candidates" red. Gitea's review event enum is EXACTLY
|
||||
# APPROVED/REQUEST_CHANGES/COMMENT/PENDING. A wrong value ("APPROVE",
|
||||
# lowercase, ...) is silently accepted (HTTP 200) and stored as
|
||||
# state=PENDING. A correctly-started draft review has an EMPTY body;
|
||||
# a NON-empty body + state==PENDING by a non-author == an intended
|
||||
# verdict mis-filed by a wrong event string. Surface it actionably.
|
||||
# This does NOT change the gate result (still fail-closed below) — it
|
||||
# only converts a mystery red into a named, self-fixing error.
|
||||
MISFILED_FILTER='.[]
|
||||
| select(.state == "PENDING")
|
||||
| select(.dismissed != true)
|
||||
| select(.user.login != $author)
|
||||
| select(((.body // "") | gsub("^\\s+|\\s+$";"") | length) > 0)
|
||||
| "\(.id)\t\(.user.login)"'
|
||||
MISFILED=$(jq -r --arg author "$PR_AUTHOR" "$MISFILED_FILTER" "$REVIEWS_JSON" 2>/dev/null || true)
|
||||
if [ -n "$MISFILED" ]; then
|
||||
echo "::error::${TEAM}-review: non-author review(s) were SUBMITTED but stored as PENDING — almost certainly the wrong Gitea review event string (internal#503)."
|
||||
echo "::error::Gitea accepts ONLY the exact enum APPROVED / REQUEST_CHANGES / COMMENT. 'APPROVE' or lowercase is silently (HTTP 200) filed as PENDING and is invisible to this gate."
|
||||
printf '%s\n' "$MISFILED" | while IFS="$(printf '\t')" read -r _rid _rl; do
|
||||
[ -n "${_rid:-}" ] && echo "::error:: review id=${_rid} by '${_rl}': RE-SUBMIT via POST ${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews with {\"event\":\"APPROVED\"} (correct enum) — do NOT edit the DB."
|
||||
done
|
||||
fi
|
||||
echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (no candidates yet)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -538,13 +538,11 @@ jobs:
|
||||
all-required:
|
||||
# Aggregator sentinel — RFC internal#219 §2 (Phase 4 — closes internal#286).
|
||||
#
|
||||
# Emits `CI / all-required (<event>)` where <event> is the workflow trigger
|
||||
# (e.g. `CI / all-required (pull_request)`, `CI / all-required (push)`).
|
||||
# Branch protection MUST be updated to require the event-suffixed name —
|
||||
# requiring `CI / all-required` (bare, no suffix) silently blocks all merges
|
||||
# because Gitea treats absent status contexts as pending (not skipped), and
|
||||
# no workflow emits the bare name. Fixed: BP now requires
|
||||
# `CI / all-required (pull_request)` per issue #1473.
|
||||
# Single stable required-status name that branch protection points at;
|
||||
# CI churns underneath in `needs:` without any protection edits. Mirrors
|
||||
# the molecule-controlplane Phase 2a impl shipped in CP PR#112 and
|
||||
# referenced by `internal#286` ("Phase 4 is a single small PR... mirrors
|
||||
# CP's existing one").
|
||||
#
|
||||
# Closes the failure mode where status_check_contexts on molecule-core/main
|
||||
# only listed `Secret scan` + `sop-tier-check` (the 2 meta-gates), so real
|
||||
|
||||
@@ -52,9 +52,5 @@ jobs:
|
||||
# explicitly instead of the combined state avoids false-pause when
|
||||
# non-blocking jobs (continue-on-error: true) have failed — those
|
||||
# failures pollute combined state but do not gate merges.
|
||||
# NOTE: the event-suffixed context name is intentional — branch protection
|
||||
# MUST require `CI / all-required (pull_request)` (with suffix), NOT the
|
||||
# bare `CI / all-required`. Gitea treats absent contexts as pending, not
|
||||
# skipped; requiring the bare name silently blocks all merges (issue #1473).
|
||||
PUSH_REQUIRED_CONTEXTS: CI / all-required (push)
|
||||
run: python3 .gitea/scripts/gitea-merge-queue.py
|
||||
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Compute next version from PyPI latest and existing tags
|
||||
- name: Compute next version from PyPI latest
|
||||
id: bump
|
||||
run: |
|
||||
set -eu
|
||||
@@ -112,24 +112,9 @@ jobs:
|
||||
| python -c "import sys,json; print(json.load(sys.stdin)['info']['version'])")
|
||||
MAJOR=$(echo "$LATEST" | cut -d. -f1)
|
||||
MINOR=$(echo "$LATEST" | cut -d. -f2)
|
||||
TAG_LATEST=$(git tag --list "runtime-v${MAJOR}.${MINOR}.*" \
|
||||
| sed -E 's/^runtime-v//' \
|
||||
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| sort -V \
|
||||
| tail -1 || true)
|
||||
VERSION=$(PYPI_LATEST="$LATEST" TAG_LATEST="$TAG_LATEST" python - <<'PY'
|
||||
import os
|
||||
|
||||
def parse(v):
|
||||
return tuple(int(part) for part in v.split("."))
|
||||
|
||||
pypi = os.environ["PYPI_LATEST"]
|
||||
tag = os.environ.get("TAG_LATEST") or pypi
|
||||
base = max(parse(pypi), parse(tag))
|
||||
print(f"{base[0]}.{base[1]}.{base[2] + 1}")
|
||||
PY
|
||||
)
|
||||
echo "PyPI latest=$LATEST, latest runtime tag=${TAG_LATEST:-none} -> next=$VERSION"
|
||||
PATCH=$(echo "$LATEST" | cut -d. -f3)
|
||||
VERSION="${MAJOR}.${MINOR}.$((PATCH+1))"
|
||||
echo "PyPI latest=$LATEST -> next=$VERSION"
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error::computed version $VERSION does not match PEP 440 X.Y.Z"
|
||||
exit 1
|
||||
|
||||
@@ -30,11 +30,6 @@ jobs:
|
||||
scan:
|
||||
name: Scan diff for credential-shaped strings
|
||||
runs-on: ubuntu-latest
|
||||
# Hard CI gate — must complete or the PR is unmergable. 10-minute ceiling
|
||||
# is generous for a diff-scan against a single SHA. If this times out, the
|
||||
# runner is frozen and holding a slot — the step timeout triggers clean
|
||||
# failure, releasing the runner for the next job.
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -138,14 +133,6 @@ jobs:
|
||||
[ -z "$f" ] && continue
|
||||
[ "$f" = "$SELF_GITHUB" ] && continue
|
||||
[ "$f" = "$SELF_GITEA" ] && continue
|
||||
# Test-fixture exclude (internal#425): the secrets-detector's OWN
|
||||
# unit-test corpus deliberately embeds credential-SHAPED example
|
||||
# strings to exercise the detector. Verified 2026-05-18 synthetic
|
||||
# (fabricated ghp_* fixtures, not real). Without this the scanner
|
||||
# self-trips on its own fixtures and fail-closes every deploy.
|
||||
# Same rationale as the SELF_* excludes above; gate NOT weakened
|
||||
# (all other paths still fully scanned).
|
||||
[ "$f" = "workspace-server/internal/secrets/patterns_test.go" ] && continue
|
||||
if [ -n "$DIFF_RANGE" ]; then
|
||||
ADDED=$(git diff --no-color --unified=0 "$BASE" "$HEAD" -- "$f" 2>/dev/null | grep -E '^\+[^+]' || true)
|
||||
else
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,6 @@ TOP_LEVEL_MODULES = {
|
||||
"a2a_response",
|
||||
"a2a_tools",
|
||||
"a2a_tools_delegation",
|
||||
"a2a_tools_identity",
|
||||
"a2a_tools_inbox",
|
||||
"a2a_tools_memory",
|
||||
"a2a_tools_messaging",
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package handlers
|
||||
|
||||
// Unit tests for plugins_listing.go:
|
||||
// - parseManifestYAML: full YAML, missing fields, empty YAML
|
||||
// - listRegistryFiltered: empty/missing dir, no yaml, valid yaml, runtime filter
|
||||
// - ListRegistry (GET /plugins): no filter, with runtime filter
|
||||
// - ListAvailableForWorkspace (GET /workspaces/:id/plugins/available): runtimeLookup stub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// -------- parseManifestYAML --------
|
||||
|
||||
func TestParseManifestYAML_FullPlugin(t *testing.T) {
|
||||
data := []byte(`
|
||||
name: molecule-audit
|
||||
version: 1.2.3
|
||||
description: Security audit plugin for Claude Code
|
||||
author: Molecule AI
|
||||
tags:
|
||||
- security
|
||||
- audit
|
||||
skills:
|
||||
- security-scan
|
||||
- compliance-check
|
||||
runtimes:
|
||||
- claude_code
|
||||
- hermes
|
||||
`)
|
||||
info := parseManifestYAML("fallback-name", data)
|
||||
if info.Name != "fallback-name" {
|
||||
t.Errorf("Name = %q; want fallback-name", info.Name)
|
||||
}
|
||||
if info.Version != "1.2.3" {
|
||||
t.Errorf("Version = %q; want 1.2.3", info.Version)
|
||||
}
|
||||
if info.Description != "Security audit plugin for Claude Code" {
|
||||
t.Errorf("Description = %q; want full description", info.Description)
|
||||
}
|
||||
if info.Author != "Molecule AI" {
|
||||
t.Errorf("Author = %q; want Molecule AI", info.Author)
|
||||
}
|
||||
if len(info.Tags) != 2 || info.Tags[0] != "security" || info.Tags[1] != "audit" {
|
||||
t.Errorf("Tags = %v; want [security audit]", info.Tags)
|
||||
}
|
||||
if len(info.Skills) != 2 || info.Skills[0] != "security-scan" || info.Skills[1] != "compliance-check" {
|
||||
t.Errorf("Skills = %v; want [security-scan compliance-check]", info.Skills)
|
||||
}
|
||||
if len(info.Runtimes) != 2 || info.Runtimes[0] != "claude_code" || info.Runtimes[1] != "hermes" {
|
||||
t.Errorf("Runtimes = %v; want [claude_code hermes]", info.Runtimes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifestYAML_MinimalFields(t *testing.T) {
|
||||
// Only name field; all others should be zero-value.
|
||||
data := []byte(`name: minimal-plugin`)
|
||||
info := parseManifestYAML("fallback", data)
|
||||
if info.Name != "fallback" {
|
||||
t.Errorf("Name = %q; want fallback", info.Name)
|
||||
}
|
||||
if info.Version != "" {
|
||||
t.Errorf("Version = %q; want empty", info.Version)
|
||||
}
|
||||
if info.Description != "" {
|
||||
t.Errorf("Description = %q; want empty", info.Description)
|
||||
}
|
||||
if len(info.Tags) != 0 {
|
||||
t.Errorf("Tags = %v; want []", info.Tags)
|
||||
}
|
||||
if len(info.Skills) != 0 {
|
||||
t.Errorf("Skills = %v; want []", info.Skills)
|
||||
}
|
||||
if len(info.Runtimes) != 0 {
|
||||
t.Errorf("Runtimes = %v; want []", info.Runtimes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifestYAML_MissingPluginYAML(t *testing.T) {
|
||||
// No plugin.yaml present → returns fallback name only.
|
||||
info := parseManifestYAML("no-file", nil)
|
||||
if info.Name != "no-file" {
|
||||
t.Errorf("Name = %q; want no-file", info.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifestYAML_BadYAML(t *testing.T) {
|
||||
// Malformed YAML → returns fallback name only (no panic).
|
||||
info := parseManifestYAML("bad-yaml", []byte("not: [yaml: at all"))
|
||||
if info.Name != "bad-yaml" {
|
||||
t.Errorf("Name = %q; want bad-yaml", info.Name)
|
||||
}
|
||||
if info.Version != "" {
|
||||
t.Errorf("Version = %q; want empty after bad YAML", info.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifestYAML_PartialFields(t *testing.T) {
|
||||
// Present tags/skills/runtimes that are not []interface{} (e.g. wrong type)
|
||||
// should not panic and should leave the field empty.
|
||||
data := []byte(`
|
||||
name: partial
|
||||
tags: "not-an-array"
|
||||
skills: 123
|
||||
runtimes: true
|
||||
`)
|
||||
info := parseManifestYAML("partial", data)
|
||||
if info.Name != "partial" {
|
||||
t.Errorf("Name = %q; want partial", info.Name)
|
||||
}
|
||||
if len(info.Tags) != 0 {
|
||||
t.Errorf("Tags = %v; want [] (wrong type)", info.Tags)
|
||||
}
|
||||
if len(info.Skills) != 0 {
|
||||
t.Errorf("Skills = %v; want [] (wrong type)", info.Skills)
|
||||
}
|
||||
if len(info.Runtimes) != 0 {
|
||||
t.Errorf("Runtimes = %v; want [] (wrong type)", info.Runtimes)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- listRegistryFiltered --------
|
||||
|
||||
func makeTestHandler(t *testing.T, pluginsDir string) *PluginsHandler {
|
||||
// Construct a minimal PluginsHandler with a nil docker client
|
||||
// (filesystem paths are tested directly; container-dependent paths are
|
||||
// tested separately or skipped in this file).
|
||||
h := &PluginsHandler{pluginsDir: pluginsDir}
|
||||
return h
|
||||
}
|
||||
|
||||
func writePluginYAML(t *testing.T, dir, name, content string) {
|
||||
path := filepath.Join(dir, name, "plugin.yaml")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("writeFile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_EmptyDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
h := makeTestHandler(t, dir)
|
||||
got := h.listRegistryFiltered("")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty list for empty dir; got %d plugins", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_NonExistentDir(t *testing.T) {
|
||||
h := makeTestHandler(t, "/does/not/exist")
|
||||
got := h.listRegistryFiltered("")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty list for nonexistent dir; got %d plugins", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_NoPluginYAML(t *testing.T) {
|
||||
// Plugin directory exists but has no plugin.yaml → fallback name only.
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "no-manifest-plugin", "")
|
||||
h := makeTestHandler(t, dir)
|
||||
got := h.listRegistryFiltered("")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 plugin; got %d", len(got))
|
||||
}
|
||||
if got[0].Name != "no-manifest-plugin" {
|
||||
t.Errorf("Name = %q; want no-manifest-plugin", got[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_ValidPlugin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "molecule-audit", `
|
||||
name: molecule-audit
|
||||
version: 1.0.0
|
||||
description: Security audit plugin
|
||||
author: Molecule AI
|
||||
tags:
|
||||
- security
|
||||
skills:
|
||||
- audit
|
||||
runtimes:
|
||||
- hermes
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
got := h.listRegistryFiltered("")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 plugin; got %d", len(got))
|
||||
}
|
||||
if got[0].Name != "molecule-audit" {
|
||||
t.Errorf("Name = %q; want molecule-audit", got[0].Name)
|
||||
}
|
||||
if got[0].Version != "1.0.0" {
|
||||
t.Errorf("Version = %q; want 1.0.0", got[0].Version)
|
||||
}
|
||||
if len(got[0].Tags) != 1 || got[0].Tags[0] != "security" {
|
||||
t.Errorf("Tags = %v; want [security]", got[0].Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_FilesIgnored(t *testing.T) {
|
||||
// Regular files in pluginsDir are skipped (only directories are scanned).
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "real-plugin", `
|
||||
name: real-plugin
|
||||
version: 1.0.0
|
||||
`)
|
||||
f, err := os.Create(filepath.Join(dir, "not-a-plugin.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
h := makeTestHandler(t, dir)
|
||||
got := h.listRegistryFiltered("")
|
||||
if len(got) != 1 || got[0].Name != "real-plugin" {
|
||||
t.Errorf("expected only real-plugin; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_RuntimeFilterMatches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "cc-plugin", `
|
||||
name: cc-plugin
|
||||
runtimes: [claude_code]
|
||||
`)
|
||||
writePluginYAML(t, dir, "hermes-plugin", `
|
||||
name: hermes-plugin
|
||||
runtimes: [hermes]
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
|
||||
// With hermes filter → only hermes-plugin returned.
|
||||
got := h.listRegistryFiltered("hermes")
|
||||
if len(got) != 1 || got[0].Name != "hermes-plugin" {
|
||||
t.Errorf("expected [hermes-plugin]; got %v", got)
|
||||
}
|
||||
|
||||
// With claude-code filter → hyphen normalises to underscore → cc-plugin returned.
|
||||
got2 := h.listRegistryFiltered("claude-code")
|
||||
if len(got2) != 1 || got2[0].Name != "cc-plugin" {
|
||||
t.Errorf("expected [cc-plugin] with claude-code filter; got %v", got2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_RuntimeFilterExcludes(t *testing.T) {
|
||||
// Plugin declares hermes; query asks for claude-code → plugin excluded.
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "hermes-only", `
|
||||
name: hermes-only
|
||||
runtimes: [hermes]
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
got := h.listRegistryFiltered("claude_code")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty list for mismatched runtime; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_UnspecifiedRuntimeIncluded(t *testing.T) {
|
||||
// Plugin with no runtimes field is included in any filtered query
|
||||
// ("unspecified = try it" contract).
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "universal-plugin", `
|
||||
name: universal-plugin
|
||||
runtimes: []
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
got := h.listRegistryFiltered("any-runtime")
|
||||
if len(got) != 1 || got[0].Name != "universal-plugin" {
|
||||
t.Errorf("expected [universal-plugin] with any runtime filter; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_MultipleMatching(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"plugin-a", "plugin-b", "plugin-c"} {
|
||||
writePluginYAML(t, dir, name, `name: `+name+`
|
||||
runtimes: [hermes, claude_code]
|
||||
`)
|
||||
}
|
||||
h := makeTestHandler(t, dir)
|
||||
got := h.listRegistryFiltered("hermes")
|
||||
if len(got) != 3 {
|
||||
t.Errorf("expected 3 plugins; got %d: %v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- ListRegistry (GET /plugins) --------
|
||||
|
||||
func listRegistryReq(runtime string) (*http.Request, *httptest.ResponseRecorder, *gin.Context) {
|
||||
url := "/plugins"
|
||||
if runtime != "" {
|
||||
url += "?runtime=" + runtime
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", url, nil)
|
||||
return c.Request, w, c
|
||||
}
|
||||
|
||||
func TestListRegistry_NoFilter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "test-plugin", `
|
||||
name: test-plugin
|
||||
version: 0.1.0
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
_, w, c := listRegistryReq("")
|
||||
h.ListRegistry(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("unmarshal: %v", err)
|
||||
}
|
||||
if len(resp) != 1 || resp[0]["name"] != "test-plugin" {
|
||||
t.Errorf("unexpected response: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistry_WithRuntimeFilter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "hermes-plugin", `
|
||||
name: hermes-plugin
|
||||
runtimes: [hermes]
|
||||
`)
|
||||
writePluginYAML(t, dir, "cc-plugin", `
|
||||
name: cc-plugin
|
||||
runtimes: [claude_code]
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
_, w, c := listRegistryReq("hermes")
|
||||
h.ListRegistry(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200; got %d", w.Code)
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp) != 1 || resp[0]["name"] != "hermes-plugin" {
|
||||
t.Errorf("expected [hermes-plugin]; got %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistry_EmptyOnNoMatches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "cc-plugin", `name: cc-plugin
|
||||
runtimes: [claude_code]
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
_, w, c := listRegistryReq("nonexistent")
|
||||
h.ListRegistry(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200; got %d", w.Code)
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp) != 0 {
|
||||
t.Errorf("expected empty list; got %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- ListAvailableForWorkspace (GET /workspaces/:id/plugins/available) --------
|
||||
|
||||
func listAvailableReq(workspaceID string) (*http.Request, *httptest.ResponseRecorder, *gin.Context) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: workspaceID}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/"+workspaceID+"/plugins/available", nil)
|
||||
return c.Request, w, c
|
||||
}
|
||||
|
||||
func TestListAvailableForWorkspace_RuntimeLookupReturnsRuntime(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "hermes-plugin", `
|
||||
name: hermes-plugin
|
||||
runtimes: [hermes]
|
||||
`)
|
||||
writePluginYAML(t, dir, "cc-plugin", `
|
||||
name: cc-plugin
|
||||
runtimes: [claude_code]
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
h.runtimeLookup = func(workspaceID string) (string, error) {
|
||||
return "hermes", nil
|
||||
}
|
||||
_, w, c := listAvailableReq("00000000-0000-0000-0000-000000000001")
|
||||
h.ListAvailableForWorkspace(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200; got %d", w.Code)
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp) != 1 || resp[0]["name"] != "hermes-plugin" {
|
||||
t.Errorf("expected [hermes-plugin]; got %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAvailableForWorkspace_RuntimeLookupErrors(t *testing.T) {
|
||||
// runtimeLookup error → runtime="" → full registry returned.
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "plugin-a", `name: plugin-a
|
||||
runtimes: [hermes]
|
||||
`)
|
||||
writePluginYAML(t, dir, "plugin-b", `name: plugin-b
|
||||
runtimes: [claude_code]
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
h.runtimeLookup = func(workspaceID string) (string, error) {
|
||||
return "", errors.New("runtime lookup failed")
|
||||
}
|
||||
_, w, c := listAvailableReq("00000000-0000-0000-0000-000000000002")
|
||||
h.ListAvailableForWorkspace(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200; got %d", w.Code)
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp) != 2 {
|
||||
t.Errorf("expected 2 plugins (full registry fallback); got %d: %v", len(resp), resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAvailableForWorkspace_NoRuntimeLookup(t *testing.T) {
|
||||
// runtimeLookup nil → full registry (no filter).
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "plugin-x", `name: plugin-x`)
|
||||
h := makeTestHandler(t, dir)
|
||||
// runtimeLookup is nil by default from makeTestHandler.
|
||||
_, w, c := listAvailableReq("00000000-0000-0000-0000-000000000003")
|
||||
h.ListAvailableForWorkspace(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200; got %d", w.Code)
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp) != 1 || resp[0]["name"] != "plugin-x" {
|
||||
t.Errorf("expected [plugin-x]; got %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAvailableForWorkspace_UnspecifiedRuntimePluginsAlwaysIncluded(t *testing.T) {
|
||||
// Plugins with empty runtimes list should always be included
|
||||
// regardless of workspace runtime.
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "universal", `name: universal
|
||||
runtimes: []
|
||||
`)
|
||||
writePluginYAML(t, dir, "cc-only", `name: cc-only
|
||||
runtimes: [claude_code]
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
h.runtimeLookup = func(id string) (string, error) { return "hermes", nil }
|
||||
_, w, c := listAvailableReq("ws-001")
|
||||
h.ListAvailableForWorkspace(c)
|
||||
var resp []map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
// "universal" has no runtimes (try-it); "cc-only" doesn't support hermes.
|
||||
if len(resp) != 1 || resp[0]["name"] != "universal" {
|
||||
t.Errorf("expected [universal]; got %v", resp)
|
||||
}
|
||||
}
|
||||
@@ -198,17 +198,6 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
// back to its compiled-in Anthropic default and 401s when the user's
|
||||
// key is for a different provider. Non-hermes runtimes are unaffected
|
||||
// (the server still passes model through, they just don't use it).
|
||||
// runtimeExplicitlyRequested is true when the caller expressed intent for
|
||||
// a SPECIFIC runtime — either by passing `runtime` directly, or by naming
|
||||
// a `template` (a template encodes a runtime). When true, we must NOT
|
||||
// silently fall back to langgraph if that intent can't be honored: that
|
||||
// is the molecule-controlplane#188 / #184 contract violation (caller asks
|
||||
// for codex/claude-code, gets a langgraph workspace, 201, no error — a
|
||||
// false success). #188 mandates fail-closed (error+notify) on mismatch,
|
||||
// not an advisory degrade. The legitimate "no template, no runtime →
|
||||
// langgraph default" path (bare {"name":...}) is unaffected.
|
||||
runtimeExplicitlyRequested := payload.Runtime != "" || payload.Template != ""
|
||||
templateRuntimeResolved := payload.Runtime != ""
|
||||
if payload.Template != "" && (payload.Runtime == "" || payload.Model == "") {
|
||||
// #226: payload.Template is attacker-controllable. resolveInsideRoot
|
||||
// rejects absolute paths and any ".." that escapes configsDir so the
|
||||
@@ -241,9 +230,6 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
switch {
|
||||
case payload.Runtime == "" && !indented && strings.HasPrefix(stripped, "runtime:") && !strings.HasPrefix(stripped, "runtime_config"):
|
||||
payload.Runtime = strings.TrimSpace(strings.TrimPrefix(stripped, "runtime:"))
|
||||
if payload.Runtime != "" {
|
||||
templateRuntimeResolved = true
|
||||
}
|
||||
case payload.Model == "" && !indented && strings.HasPrefix(stripped, "model:"):
|
||||
// Legacy top-level `model:` — pre-runtime_config templates.
|
||||
payload.Model = strings.Trim(strings.TrimSpace(strings.TrimPrefix(stripped, "model:")), `"'`)
|
||||
@@ -256,27 +242,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fail-closed (molecule-controlplane#188 / #184): if the caller expressed
|
||||
// intent for a specific runtime (passed `runtime`, or named a `template`)
|
||||
// but we could NOT resolve a concrete runtime from it (template's
|
||||
// config.yaml unreadable, or it has no `runtime:` key), DO NOT silently
|
||||
// substitute langgraph and return 201 — that is the silent contract
|
||||
// violation that produced 5/5 wrong workspaces and a false codex E2E pass.
|
||||
// Return 422 so the caller learns the requested runtime was not honored.
|
||||
// The platform-side CP fix (controlplane#188) is the sibling gate; this
|
||||
// closes the ws-server `Create` boundary the product UI actually hits.
|
||||
if payload.Runtime == "" && runtimeExplicitlyRequested && !templateRuntimeResolved {
|
||||
log.Printf("Create: FAIL-CLOSED (controlplane#188) — template=%q requested but runtime could not be resolved; refusing silent langgraph fallback", payload.Template)
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{
|
||||
"error": "runtime could not be resolved from the requested template; refusing to silently provision langgraph (controlplane#188). Pass an explicit \"runtime\", or use a template whose config.yaml declares one.",
|
||||
"template": payload.Template,
|
||||
"code": "RUNTIME_UNRESOLVED",
|
||||
})
|
||||
return
|
||||
}
|
||||
if payload.Runtime == "" {
|
||||
// Legitimate default path: no template AND no runtime requested
|
||||
// (bare {"name":...}) — langgraph is the intended default here.
|
||||
payload.Runtime = "langgraph"
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -718,7 +718,7 @@ func TestWorkspaceList_Empty(t *testing.T) {
|
||||
"parent_id", "active_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled",
|
||||
"broadcast_enabled", "talk_to_user_enabled",
|
||||
}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -1770,147 +1770,3 @@ runtime_config:
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== #188 fail-closed: template/runtime contract ====================
|
||||
//
|
||||
// molecule-controlplane#188 / #184: if a caller names a `template` (intent
|
||||
// for a specific runtime) but the runtime cannot be resolved from it, the
|
||||
// server MUST NOT silently provision langgraph and return 201 — that false
|
||||
// success produced 5/5 wrong workspaces and a bogus codex E2E pass. These
|
||||
// tests pin the fail-closed boundary at the ws-server `Create` handler (the
|
||||
// path the product UI hits), and guard the legitimate default path against
|
||||
// regression.
|
||||
|
||||
// Template requested but its dir/config.yaml is absent → 422, not silent
|
||||
// langgraph 201.
|
||||
func TestWorkspaceCreate_188_TemplateMissingRuntime_FailsClosed(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
// configsDir is an empty temp dir → resolveInsideRoot succeeds (the path
|
||||
// is inside root) but config.yaml read fails → runtime cannot be resolved.
|
||||
configsDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(configsDir, "ghost-template"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", configsDir)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"Ghost","template":"ghost-template"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("expected 422 (fail-closed, controlplane#188), 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("parse: %v", err)
|
||||
}
|
||||
if resp["code"] != "RUNTIME_UNRESOLVED" {
|
||||
t.Errorf("expected code RUNTIME_UNRESOLVED, got %v", resp["code"])
|
||||
}
|
||||
}
|
||||
|
||||
// Template config.yaml has no `runtime:` key → 422, not silent langgraph.
|
||||
func TestWorkspaceCreate_188_TemplateConfigNoRuntimeKey_FailsClosed(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
configsDir := t.TempDir()
|
||||
tdir := filepath.Join(configsDir, "noruntime-template")
|
||||
if err := os.MkdirAll(tdir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
// config.yaml exists but declares no runtime.
|
||||
if err := os.WriteFile(filepath.Join(tdir, "config.yaml"), []byte("name: noruntime\n"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", configsDir)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"NoRuntime","template":"noruntime-template"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("expected 422 (fail-closed), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Regression guard: the legitimate default path (no template, no runtime —
|
||||
// bare {"name":...}) MUST still default to langgraph and return 201. The
|
||||
// #188 fix must not break this.
|
||||
func TestWorkspaceCreate_188_NoTemplateNoRuntime_StillDefaultsLanggraph(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Plain Default", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
WithArgs(sqlmock.AnyArg(), float64(0), float64(0)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"Plain Default"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 (legitimate default path), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit runtime, no template → honored, 201 (no template resolution
|
||||
// needed; runtimeExplicitlyRequested true but already resolved).
|
||||
func TestWorkspaceCreate_188_ExplicitRuntimeNoTemplate_OK(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Explicit Codex", nil, 3, "codex", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
WithArgs(sqlmock.AnyArg(), float64(0), float64(0)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"Explicit Codex","runtime":"codex"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,11 +81,11 @@ func TestPositiveMatches(t *testing.T) {
|
||||
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"},
|
||||
{"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"},
|
||||
@@ -156,7 +156,7 @@ func TestNegativeShapes(t *testing.T) {
|
||||
// makes ScanString do its own thing (e.g. accidentally normalise
|
||||
// case) would diverge silently.
|
||||
func TestScanString_NoOp(t *testing.T) {
|
||||
in := "ghp_" + "EXAMPLE111122223333444455556666777788889999"
|
||||
in := "ghp_EXAMPLE111122223333444455556666777788889999"
|
||||
m1, err1 := ScanBytes([]byte(in))
|
||||
if err1 != nil {
|
||||
t.Fatalf("ScanBytes errored: %v", err1)
|
||||
|
||||
@@ -172,12 +172,6 @@ async def handle_tool_call(name: str, arguments: dict) -> str:
|
||||
arguments.get("message", ""),
|
||||
workspace_id=arguments.get("workspace_id") or None,
|
||||
)
|
||||
elif name == "get_runtime_identity":
|
||||
return await tool_get_runtime_identity()
|
||||
elif name == "update_agent_card":
|
||||
return await tool_update_agent_card(
|
||||
arguments.get("card"),
|
||||
)
|
||||
return f"Unknown tool: {name}"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user