molecule-core/workspace-server/internal/handlers/plugins_test.go
Hongming Wang b4cd78729d
fix(platform-go-ci): align test mocks with schema drift + org_id context contract (#1755)
* fix(platform-go-ci): align test mocks with schema drift + org_id context contract

Reduces Platform (Go) CI failures from 12 to 2 (both remaining are pre-existing
on origin/main and unrelated to this PR's scope).

Schema drift fixes (sqlmock column counts misaligned with current prod Scans):
- `orgtoken/tokens_test.go`: Validate query gained `org_id` column post-migration
  036 — updated 3 TestValidate_* tests from 2-col to 3-col ExpectQuery.
- `handlers/handlers_test.go` + `_additional_test.go`: `scanWorkspaceRow` now
  has 21 cols (`max_concurrent_tasks` inserted between `active_tasks` and
  `last_error_rate`). Updated TestWorkspaceList, TestWorkspaceList_WithData,
  and TestWorkspaceGet_CurrentTask mocks.
- `handlers/handlers_test.go`: activity scan now has 14 cols (`tool_trace`
  between `response_body` and `duration_ms`). Updated 5 TestActivityHandler_*
  tests (List, ListByType, ListEmpty, ListCustomLimit, ListMaxLimit).

Middleware org_id contract (7 failing tests → passing, zero prod callers):
- `middleware/wsauth_middleware.go`: WorkspaceAuth and AdminAuth now set the
  `org_id` context key only when the token has a non-NULL org_id. This lets
  downstream handlers use `c.Get("org_id")` existence to distinguish anchored
  tokens from pre-migration/ADMIN_TOKEN bootstrap tokens. Grep confirmed no
  current prod callers read this key — tests were the sole spec.
- `middleware/wsauth_middleware_test.go` + `_org_id_test.go`: consolidated
  separate primary+secondary ExpectQuery blocks into a single 3-col mock
  per test, and dropped the now-unused `orgTokenOrgIDQuery` constant.

Other:
- `handlers/github_token_test.go`: TestGitHubToken_NoTokenProvider now asserts
  500 + "token refresh failed" (env-based fallback path added in #960/#1101).
  Added missing `strings` import.
- `handlers/handlers_additional_test.go`: TestRegister_ProvisionerURLPreserved
  URL changed from `http://agent:8000` to `http://localhost:8000` — `agent` is
  not DNS-resolvable in CI and is rejected by validateAgentURL's SSRF check;
  `localhost` is name-exempt. The contract under test is provisioner-URL
  precedence, not URL validation.

Methodology (per quality mandate):
- Baselined 12 failing tests on clean origin/main before any edit.
- For each fix: grep'd prod for semantic contract, made minimal edits,
  verified full-suite delta = zero regressions.
- Discovered +5 pre-existing failures previously masked by TestWorkspaceList
  panic (which killed the test binary on origin/main before downstream tests
  ran). 3 of these are in this PR's bug class and were fixed; 2 are unrelated
  (a panicking test with a missing Request and a missing template file) —
  deferred to a follow-up issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: trigger CI after base retarget to main

* fix(platform-go-ci): stop TestRequireCallerOwnsOrg_NotOrgTokenCaller panic + skip yaml-includes test

Reduces Platform (Go) CI failures from 2 to 1 on this branch.

- `TestRequireCallerOwnsOrg_NotOrgTokenCaller`: the test's comment says
  "set to a non-string type" but the code stored the string "something",
  which passed the `tokenID.(string)` assertion in requireCallerOwnsOrg
  and triggered a DB lookup on a bare gin test context (no Request) →
  nil-deref in c.Request.Context(). Fixed by storing an int (12345), which
  matches the stated intent of exercising the non-string-assertion branch.

- `TestResolveYAMLIncludes_RealMoleculeDev`: the in-tree copy at
  /org-templates/molecule-dev/ is being extracted to the standalone
  Molecule-AI/molecule-ai-org-template-molecule-dev repo. Until that
  extraction lands the in-tree copy is stale (teams/dev.yaml !include's
  core-platform.yaml etc. that don't exist). Skipped with a pointer to
  the extraction so this doesn't rot.

Remaining failure: `TestRequireCallerOwnsOrg_TokenHasMatchingOrgID` panics
with the same root cause (bare gin context + string org_token_id → DB
lookup → nil-deref). Fixing it by adding a Request would unmask ~25 other
pre-existing hidden failures (schema drift, DNS-dependent tests, mock
drift) that were being masked by the earlier panic killing the test
binary. Those belong to a dedicated cleanup PR; the panic-chain triage
is tracked separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(platform-go-ci): eliminate remaining 25 cascade failures + harden auth

Takes Platform (Go) CI from 1 remaining failure (post–first pass) to 0.
Fixing `TestRequireCallerOwnsOrg_NotOrgTokenCaller`'s panic unmasked ~25
pre-existing handler-package failures that were silently hidden because
the panic killed the test binary mid-run. All are now fixed.

## Prod change
`org_plugin_allowlist.go#requireOrgOwnership` now denies unanchored
org-tokens (org_id NULL in DB) instead of treating them as session/admin.
The stated contract in `requireCallerOwnsOrg`'s comment already said
"those callers get callerOrg="" and are denied"; the downstream check
was the gap. Distinguishes the two `callerOrg == ""` paths by reading
`c.Get("org_token_id")` — key present → unanchored token → deny;
absent → session/ADMIN_TOKEN → allow.

## Tests fixed by class

**Request-less test-context panic** (7 tests, `org_plugin_allowlist_test.go`):
added `httptest.NewRequest(...)` to each bare `gin.CreateTestContext` so
the DB path in `requireCallerOwnsOrg` can read `c.Request.Context()`
without nil-deref.

**Workspace scan drift — `max_concurrent_tasks` 21st column** (8 tests):
- `TestWorkspaceGet_Success`, `_FinancialFieldsStripped`, `_SensitiveFieldsStripped`
- `TestWorkspaceBudget_Get_NilLimit`, `_WithLimit` (+ shared `wsColumns`)
- `TestWorkspaceBudget_A2A_UnderLimitPassesThrough`, `_NilLimitPassesThrough`,
  `_DBErrorFailOpen` — each also needed `allowLoopbackForTest(t)` because
  the SSRF guard now blocks `httptest.NewServer`'s 127.0.0.1 URL.

**Org-token INSERT param drift — added `org_id` 5th param** (5 tests,
`org_tokens_test.go`): `TestOrgTokenHandler_Create_*` (4) get a 5th
`nil` `WithArgs` arg; `TestOrgTokenHandler_List_HappyPath` gets `org_id`
as the 4th column in its mock row.

**ReplaceFiles/WriteFile restart-cascade SELECT shape change** (3 tests,
`template_import_test.go` + `templates_test.go`): handler now selects
`name, instance_id, runtime` for the post-write restart cascade — tests
now pin the full 3-column shape instead of just `SELECT name`.

**GitHub webhook forwarding** (2 tests, `webhooks_test.go`): added
`allowLoopbackForTest(t)` — same SSRF-guard / loopback-server mismatch
as the budget A2A tests.

**DNS-dependent sentinel hostname** (2 tests): `TestIsSafeURL/public_*`
+ `TestValidateAgentURL/valid_public_*` used `agent.example.com` which
is NXDOMAIN on most resolvers; switched to `example.com` itself (RFC-2606,
resolves globally via Cloudflare Anycast).

**Register C18 hijack assertion** (`registry_test.go`): attacker URL
was `attacker.example.com` (NXDOMAIN) → `validateAgentURL` rejected
with 400 before the C18 auth gate could fire 401. Switched to
`example.com` so the test actually exercises the C18 gate.

**Plugin install error vocabulary** (`plugins_test.go`): handler now
returns generic "invalid plugin source" instead of leaking the internal
`ParseSource` "empty spec" string to the HTTP surface. Test assertion
updated; "empty spec" still covered at the unit level in `plugins/source_test.go`.

**seedInitialMemories tests tripping redactSecrets** (3 tests,
`workspace_provision_test.go`): content was `strings.Repeat("X", N)`
which matches the BASE64_BLOB redactor (33+ chars of `[A-Za-z0-9+/]`)
and got replaced with `[REDACTED:BASE64_BLOB]` before INSERT, making
the `WithArgs` assertion mismatch. Switched to a space-containing
`"hello world "` pattern that breaks the run. Also fixed an unrelated
pre-existing bug in `TestSeedInitialMemories_Truncation` where
`copy([]byte(largeContent), "X")` was a no-op (strings are immutable
in Go — the copy modified a throwaway slice).

Net: Platform (Go) handlers package is now fully green on `go test -race`.
Unblocks PRs #1738, #1743, and any future handlers-package work that was
inheriting the 12→25 baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Hongming Wang <hongmingwang.rabbit@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 07:14:33 +00:00

1370 lines
44 KiB
Go

package handlers
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
// ---------- ListRegistry: empty dir → 200 [] ----------
func TestPluginListRegistry_EmptyDir(t *testing.T) {
dir := t.TempDir()
h := NewPluginsHandler(dir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins", nil)
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var plugins []pluginInfo
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(plugins) != 0 {
t.Errorf("expected 0 plugins, got %d", len(plugins))
}
}
// ---------- ListRegistry: non-existent dir → 200 [] ----------
func TestPluginListRegistry_NonExistentDir(t *testing.T) {
h := NewPluginsHandler("/tmp/does-not-exist-plugins-xyz", nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins", nil)
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var plugins []pluginInfo
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(plugins) != 0 {
t.Errorf("expected 0 plugins, got %d", len(plugins))
}
}
// ---------- ListRegistry: with plugins → returns manifest data ----------
func TestPluginListRegistry_WithPlugins(t *testing.T) {
dir := t.TempDir()
// Create a plugin with manifest
pluginDir := filepath.Join(dir, "my-plugin")
if err := os.Mkdir(pluginDir, 0755); err != nil {
t.Fatal(err)
}
manifest := `name: my-plugin
version: "1.0.0"
description: A test plugin
author: tester
tags:
- test
- example
skills:
- greet
`
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(manifest), 0644); err != nil {
t.Fatal(err)
}
// Create a plugin without manifest (just a directory)
bareDir := filepath.Join(dir, "bare-plugin")
if err := os.Mkdir(bareDir, 0755); err != nil {
t.Fatal(err)
}
// Create a regular file (should be skipped — not a directory)
if err := os.WriteFile(filepath.Join(dir, "not-a-dir.txt"), []byte("hi"), 0644); err != nil {
t.Fatal(err)
}
h := NewPluginsHandler(dir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins", nil)
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var plugins []pluginInfo
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(plugins) != 2 {
t.Fatalf("expected 2 plugins, got %d", len(plugins))
}
// Find the manifest plugin (order depends on readdir)
var found bool
for _, p := range plugins {
if p.Name == "my-plugin" {
found = true
if p.Version != "1.0.0" {
t.Errorf("expected version 1.0.0, got %s", p.Version)
}
if p.Description != "A test plugin" {
t.Errorf("expected description 'A test plugin', got %s", p.Description)
}
if p.Author != "tester" {
t.Errorf("expected author 'tester', got %s", p.Author)
}
if len(p.Tags) != 2 || p.Tags[0] != "test" || p.Tags[1] != "example" {
t.Errorf("unexpected tags: %v", p.Tags)
}
if len(p.Skills) != 1 || p.Skills[0] != "greet" {
t.Errorf("unexpected skills: %v", p.Skills)
}
}
if p.Name == "bare-plugin" {
if p.Version != "" {
t.Errorf("bare plugin should have empty version, got %s", p.Version)
}
}
}
if !found {
t.Error("my-plugin not found in results")
}
}
// ---------- Install: missing source → 400 ----------
func TestPluginInstall_MissingSource(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-123"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-123/plugins", bytes.NewBufferString(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Install(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// ---------- Install: invalid name in local source (path traversal) → 400 ----------
func TestPluginInstall_InvalidName_PathTraversal(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-123"}}
body := `{"source":"local://../../../etc/passwd"}`
c.Request = httptest.NewRequest("POST", "/workspaces/ws-123/plugins", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Install(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// ---------- Install: plugin not found → 404 ----------
func TestPluginInstall_NotFound(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-123"}}
body := `{"source":"local://nonexistent-plugin"}`
c.Request = httptest.NewRequest("POST", "/workspaces/ws-123/plugins", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Install(c)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
// ---------- Uninstall: invalid name → 400 ----------
func TestPluginUninstall_InvalidName(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: "ws-123"},
{Key: "name", Value: "../escape"},
}
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-123/plugins/../escape", nil)
h.Uninstall(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// ---------- Uninstall: empty name → 400 ----------
func TestPluginUninstall_EmptyName(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: "ws-123"},
{Key: "name", Value: ""},
}
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-123/plugins/", nil)
h.Uninstall(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// ---------- validatePluginName: valid names pass ----------
func TestValidatePluginName_ValidNames(t *testing.T) {
valid := []string{
"my-plugin",
"plugin_v2",
"AwesomePlugin",
"plugin123",
"a",
}
for _, name := range valid {
if err := validatePluginName(name); err != nil {
t.Errorf("validatePluginName(%q) should pass, got: %v", name, err)
}
}
}
// ---------- validatePluginName: "/" rejected ----------
func TestValidatePluginName_SlashRejected(t *testing.T) {
names := []string{
"foo/bar",
"/leading",
"trailing/",
"a/b/c",
}
for _, name := range names {
if err := validatePluginName(name); err == nil {
t.Errorf("validatePluginName(%q) should fail for slash", name)
}
}
}
// ---------- validatePluginName: ".." rejected ----------
func TestValidatePluginName_DotDotRejected(t *testing.T) {
names := []string{
"..",
"..foo",
"foo..",
"a..b",
}
for _, name := range names {
if err := validatePluginName(name); err == nil {
t.Errorf("validatePluginName(%q) should fail for '..'", name)
}
}
}
// ---------- validatePluginName: backslash rejected ----------
func TestValidatePluginName_BackslashRejected(t *testing.T) {
if err := validatePluginName(`foo\bar`); err == nil {
t.Error(`validatePluginName("foo\\bar") should fail`)
}
}
// ---------- validatePluginName: empty rejected ----------
func TestValidatePluginName_EmptyRejected(t *testing.T) {
if err := validatePluginName(""); err == nil {
t.Error("validatePluginName(\"\") should fail")
}
}
// ---------- parseManifestYAML: valid yaml → correct pluginInfo ----------
func TestParseManifestYAML_ValidYAML(t *testing.T) {
yaml := []byte(`
name: test-plugin
version: "2.0.0"
description: "Does things"
author: "dev"
tags:
- utility
- automation
skills:
- summarize
- translate
`)
info := parseManifestYAML("fallback-name", yaml)
// Name should use fallbackName, not the yaml name field
if info.Name != "fallback-name" {
t.Errorf("expected name 'fallback-name', got %s", info.Name)
}
if info.Version != "2.0.0" {
t.Errorf("expected version 2.0.0, got %s", info.Version)
}
if info.Description != "Does things" {
t.Errorf("expected description 'Does things', got %s", info.Description)
}
if info.Author != "dev" {
t.Errorf("expected author 'dev', got %s", info.Author)
}
if len(info.Tags) != 2 || info.Tags[0] != "utility" || info.Tags[1] != "automation" {
t.Errorf("unexpected tags: %v", info.Tags)
}
if len(info.Skills) != 2 || info.Skills[0] != "summarize" || info.Skills[1] != "translate" {
t.Errorf("unexpected skills: %v", info.Skills)
}
}
// ---------- parseManifestYAML: invalid yaml → fallback ----------
func TestParseManifestYAML_InvalidYAML(t *testing.T) {
info := parseManifestYAML("safe-name", []byte(`{{{not valid yaml`))
if info.Name != "safe-name" {
t.Errorf("expected fallback name 'safe-name', got %s", info.Name)
}
if info.Version != "" {
t.Errorf("expected empty version on invalid yaml, got %s", info.Version)
}
}
// ---------- parseManifestYAML: minimal yaml (no tags/skills) ----------
func TestParseManifestYAML_MinimalYAML(t *testing.T) {
yaml := []byte(`version: "0.1"`)
info := parseManifestYAML("minimal", yaml)
if info.Name != "minimal" {
t.Errorf("expected name 'minimal', got %s", info.Name)
}
if info.Version != "0.1" {
t.Errorf("expected version '0.1', got %s", info.Version)
}
if info.Tags != nil {
t.Errorf("expected nil tags, got %v", info.Tags)
}
if info.Skills != nil {
t.Errorf("expected nil skills, got %v", info.Skills)
}
}
// ---------- Runtime filter on ListRegistry ----------
// writePlugin is a small helper for the runtime-filter tests.
func writePlugin(t *testing.T, dir, name, manifest string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(dir, name), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, name, "plugin.yaml"), []byte(manifest), 0644); err != nil {
t.Fatal(err)
}
}
func TestPluginListRegistry_FiltersByRuntime(t *testing.T) {
dir := t.TempDir()
writePlugin(t, dir, "p-cc", "name: p-cc\nruntimes: [claude_code]\n")
writePlugin(t, dir, "p-da", "name: p-da\nruntimes: [deepagents]\n")
writePlugin(t, dir, "p-both", "name: p-both\nruntimes: [claude_code, deepagents]\n")
writePlugin(t, dir, "p-legacy", "name: p-legacy\n") // no runtimes — always allowed
h := NewPluginsHandler(dir, nil, nil)
cases := []struct {
name string
runtime string
expected map[string]bool
}{
{"no filter returns all", "", map[string]bool{"p-cc": true, "p-da": true, "p-both": true, "p-legacy": true}},
{"claude_code filter", "claude_code", map[string]bool{"p-cc": true, "p-both": true, "p-legacy": true}},
{"deepagents filter", "deepagents", map[string]bool{"p-da": true, "p-both": true, "p-legacy": true}},
{"hyphen form normalized", "claude-code", map[string]bool{"p-cc": true, "p-both": true, "p-legacy": true}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
url := "/plugins"
if tc.runtime != "" {
url += "?runtime=" + tc.runtime
}
c.Request = httptest.NewRequest("GET", url, nil)
h.ListRegistry(c)
var plugins []pluginInfo
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("unmarshal: %v", err)
}
got := map[string]bool{}
for _, p := range plugins {
got[p.Name] = true
}
if len(got) != len(tc.expected) {
t.Errorf("runtime=%q: got %v, want %v", tc.runtime, got, tc.expected)
}
for name := range tc.expected {
if !got[name] {
t.Errorf("runtime=%q: missing %q", tc.runtime, name)
}
}
})
}
}
// ---------- ListAvailableForWorkspace ----------
func TestPluginListAvailableForWorkspace_UsesRuntimeLookup(t *testing.T) {
dir := t.TempDir()
writePlugin(t, dir, "only-deepagents", "name: only-deepagents\nruntimes: [deepagents]\n")
writePlugin(t, dir, "only-claude", "name: only-claude\nruntimes: [claude_code]\n")
// Workspace resolves to deepagents.
h := NewPluginsHandler(dir, nil, nil).WithRuntimeLookup(func(id string) (string, error) {
if id == "ws-da" {
return "deepagents", nil
}
return "claude_code", nil
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-da"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-da/plugins/available", nil)
h.ListAvailableForWorkspace(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var plugins []pluginInfo
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatal(err)
}
if len(plugins) != 1 || plugins[0].Name != "only-deepagents" {
t.Errorf("expected only-deepagents, got %+v", plugins)
}
}
func TestPluginListAvailableForWorkspace_NoLookupReturnsAll(t *testing.T) {
dir := t.TempDir()
writePlugin(t, dir, "only-deepagents", "name: only-deepagents\nruntimes: [deepagents]\n")
writePlugin(t, dir, "only-claude", "name: only-claude\nruntimes: [claude_code]\n")
// No runtime lookup wired → falls back to full registry.
h := NewPluginsHandler(dir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "anything"}}
c.Request = httptest.NewRequest("GET", "/workspaces/anything/plugins/available", nil)
h.ListAvailableForWorkspace(c)
var plugins []pluginInfo
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatal(err)
}
if len(plugins) != 2 {
t.Errorf("expected 2 plugins, got %d", len(plugins))
}
}
// ---------- Manifest parsing: runtimes field ----------
func TestParseManifestYAML_PicksUpRuntimes(t *testing.T) {
info := parseManifestYAML("demo", []byte("name: demo\nruntimes:\n - claude_code\n - deepagents\n"))
if len(info.Runtimes) != 2 || info.Runtimes[0] != "claude_code" || info.Runtimes[1] != "deepagents" {
t.Errorf("expected [claude_code, deepagents], got %v", info.Runtimes)
}
if !info.supportsRuntime("claude-code") {
t.Error("hyphen/underscore normalization broken")
}
if info.supportsRuntime("langgraph") {
t.Error("should not support langgraph")
}
}
func TestSupportsRuntime_EmptyMeansLegacy(t *testing.T) {
info := pluginInfo{Name: "legacy"}
if !info.supportsRuntime("anything") {
t.Error("legacy plugins (no runtimes field) must be treated as compatible")
}
}
// ---------- CheckRuntimeCompatibility ----------
func TestCheckRuntimeCompatibility_RejectsMissingRuntimeParam(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws/plugins/compatibility", nil)
h.CheckRuntimeCompatibility(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestCheckRuntimeCompatibility_TriviallyCompatibleWhenContainerMissing(t *testing.T) {
// No docker client + no container → treated as "nothing installed, all compatible".
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws/plugins/compatibility?runtime=deepagents", nil)
h.CheckRuntimeCompatibility(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["all_compatible"] != true {
t.Errorf("expected all_compatible=true, got %v", body["all_compatible"])
}
if body["target_runtime"] != "deepagents" {
t.Errorf("target_runtime mismatch: %v", body["target_runtime"])
}
}
// ---------- ListSources ----------
func TestPluginListSources_ReturnsRegisteredSchemes(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins/sources", nil)
h.ListSources(c)
if w.Code != http.StatusOK {
t.Fatalf("status=%d", w.Code)
}
var body map[string][]string
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
hasLocal, hasGithub := false, false
for _, s := range body["schemes"] {
if s == "local" {
hasLocal = true
}
if s == "github" {
hasGithub = true
}
}
if !hasLocal || !hasGithub {
t.Errorf("expected local+github by default, got %v", body["schemes"])
}
}
// ---------- Install — source routing ----------
func TestPluginInstall_RejectsEmptyBody(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws"}}
c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Install(c)
if w.Code != http.StatusBadRequest {
t.Errorf("want 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestPluginInstall_RejectsUnknownScheme(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws"}}
c.Request = httptest.NewRequest("POST", "/x",
bytes.NewBufferString(`{"source":"mystery://thing"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Install(c)
if w.Code != http.StatusBadRequest {
t.Errorf("want 400, got %d: %s", w.Code, w.Body.String())
}
if !bytes.Contains(w.Body.Bytes(), []byte("available_schemes")) {
t.Errorf("response should list available_schemes: %s", w.Body.String())
}
}
func TestPluginInstall_LocalSourceReachesContainerLookup(t *testing.T) {
base := t.TempDir()
pluginDir := filepath.Join(base, "demo")
_ = os.MkdirAll(pluginDir, 0o755)
_ = os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte("name: demo\n"), 0o644)
h := NewPluginsHandler(base, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws"}}
c.Request = httptest.NewRequest("POST", "/x",
bytes.NewBufferString(`{"source":"local://demo"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Install(c)
// No docker client configured → source resolves, stage succeeds, then
// 503 on container lookup. Proves the local dispatch + stage worked.
if w.Code != http.StatusServiceUnavailable {
t.Errorf("local:// should reach container lookup: got %d: %s", w.Code, w.Body.String())
}
}
func TestPluginInstall_InvalidSourceString(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws"}}
c.Request = httptest.NewRequest("POST", "/x",
bytes.NewBufferString(`{"source":" "}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Install(c)
if w.Code != http.StatusBadRequest {
t.Errorf("whitespace-only source should be rejected: got %d", w.Code)
}
}
func TestPluginInstall_RejectsOversizedBody(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil)
// Build a JSON body larger than the cap (default 64 KiB).
big := `{"source":"local://` + strings.Repeat("a", 70*1024) + `"}`
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws"}}
c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString(big))
c.Request.Header.Set("Content-Type", "application/json")
h.Install(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for oversized body, got %d", w.Code)
}
}
// Install 404 via the typed sentinel (replaces the old string-match test).
func TestPluginInstall_NotFoundUsesTypedSentinel(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-123"}}
c.Request = httptest.NewRequest("POST", "/x",
bytes.NewBufferString(`{"source":"local://nonexistent"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Install(c)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 via ErrPluginNotFound, got %d: %s", w.Code, w.Body.String())
}
}
// Install 502 for non-sentinel resolver errors (e.g. network / auth).
func TestPluginInstall_NonSentinelResolverErrorIs502(t *testing.T) {
// Register a stub resolver whose Fetch returns a plain (non-ErrPluginNotFound) error.
h := NewPluginsHandler(t.TempDir(), nil, nil).
WithSourceResolver(alwaysErrs("broken", errors.New("connection refused")))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws"}}
c.Request = httptest.NewRequest("POST", "/x",
bytes.NewBufferString(`{"source":"broken://whatever"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Install(c)
if w.Code != http.StatusBadGateway {
t.Errorf("non-sentinel resolver error should be 502, got %d: %s", w.Code, w.Body.String())
}
}
// Install returns 504 when fetch honours ctx.DeadlineExceeded.
func TestPluginInstall_DeadlineExceededIs504(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil).
WithSourceResolver(alwaysErrs("slow", context.DeadlineExceeded))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws"}}
c.Request = httptest.NewRequest("POST", "/x",
bytes.NewBufferString(`{"source":"slow://x"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Install(c)
if w.Code != http.StatusGatewayTimeout {
t.Errorf("deadline exceeded should be 504, got %d", w.Code)
}
}
// Install 413 when the fetched tree exceeds the configured cap.
func TestPluginInstall_OversizedStagedTreeIs413(t *testing.T) {
t.Setenv("PLUGIN_INSTALL_MAX_DIR_BYTES", "1024") // 1 KiB cap
h := NewPluginsHandler(t.TempDir(), nil, nil).
WithSourceResolver(writesBlob("big", 2048)) // 2 KiB blob
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws"}}
c.Request = httptest.NewRequest("POST", "/x",
bytes.NewBufferString(`{"source":"big://whatever"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Install(c)
if w.Code != http.StatusRequestEntityTooLarge {
t.Errorf("oversized staged tree should be 413, got %d: %s", w.Code, w.Body.String())
}
}
// envDuration / envInt64 moved to platform/internal/envx; see
// envx/envx_test.go for their tests.
func TestDirSize_ShortCircuitsOnCap(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a"), bytes.Repeat([]byte("x"), 2048), 0o644); err != nil {
t.Fatal(err)
}
_, err := dirSize(dir, 1024)
if err == nil {
t.Error("expected cap-exceeded error")
}
// Under the cap: no error.
if _, err := dirSize(dir, 1<<20); err != nil {
t.Errorf("1 MiB cap should accept 2 KiB: %v", err)
}
}
// ---- test-only resolver stub ----
// fakeResolver is a single hook-based SourceResolver replacing the
// previously-separate erroringResolver / bigBlobResolver /
// hostileResolver types. Tests pick the scheme and supply a fetchFn.
type fakeResolver struct {
scheme string
fetchFn func(ctx context.Context, spec, dst string) (string, error)
}
func (f *fakeResolver) Scheme() string { return f.scheme }
func (f *fakeResolver) Fetch(ctx context.Context, spec, dst string) (string, error) {
return f.fetchFn(ctx, spec, dst)
}
// alwaysErrs returns a fakeResolver that fails every fetch with err.
func alwaysErrs(scheme string, err error) *fakeResolver {
return &fakeResolver{
scheme: scheme,
fetchFn: func(context.Context, string, string) (string, error) { return "", err },
}
}
// writesBlob returns a fakeResolver that writes N bytes into dst
// before returning `scheme` as the plugin name.
func writesBlob(scheme string, size int) *fakeResolver {
return &fakeResolver{
scheme: scheme,
fetchFn: func(_ context.Context, _, dst string) (string, error) {
if err := os.WriteFile(filepath.Join(dst, "blob"), bytes.Repeat([]byte("a"), size), 0o644); err != nil {
return "", err
}
return scheme, nil
},
}
}
// emitsName returns a fakeResolver that writes a valid plugin.yaml
// but returns `returnedName` — used to probe post-fetch name
// validation (e.g. hostile traversal names).
func emitsName(scheme, returnedName string) *fakeResolver {
return &fakeResolver{
scheme: scheme,
fetchFn: func(_ context.Context, _, dst string) (string, error) {
_ = os.WriteFile(filepath.Join(dst, "plugin.yaml"), []byte("name: x\n"), 0o644)
return returnedName, nil
},
}
}
func TestPluginInstall_RejectsHostileResolverPluginName(t *testing.T) {
// Prove the post-fetch validatePluginName call catches a resolver
// that tries to smuggle a traversal name into /configs/plugins/.
h := NewPluginsHandler(t.TempDir(), nil, nil).
WithSourceResolver(emitsName("hostile", "../../../etc/passwd"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws"}}
c.Request = httptest.NewRequest("POST", "/x",
bytes.NewBufferString(`{"source":"hostile://anything"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Install(c)
if w.Code != http.StatusBadRequest {
t.Errorf("hostile plugin name must be 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestPluginInstall_EmptySpecAfterSchemeRejected(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws"}}
c.Request = httptest.NewRequest("POST", "/x",
bytes.NewBufferString(`{"source":"github://"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Install(c)
if w.Code != http.StatusBadRequest {
t.Errorf("want 400, got %d: %s", w.Code, w.Body.String())
}
// Handler now returns the generic "invalid plugin source" rather than
// propagating the internal ParseSource "empty spec" wording — intentional
// so the HTTP surface doesn't leak parser-internal vocabulary. Unit-level
// coverage of the "empty spec" wording lives in plugins/source_test.go.
if !bytes.Contains(w.Body.Bytes(), []byte("invalid plugin source")) {
t.Errorf("error should mention 'invalid plugin source': %s", w.Body.String())
}
}
// ---- resolveAndStage in isolation (now testable sans gin.Context) ----
func TestResolveAndStage_HappyPath_Local(t *testing.T) {
base := t.TempDir()
pluginDir := filepath.Join(base, "demo")
_ = os.MkdirAll(pluginDir, 0o755)
_ = os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte("name: demo\n"), 0o644)
h := NewPluginsHandler(base, nil, nil)
res, err := h.resolveAndStage(context.Background(), installRequest{Source: "local://demo"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
defer os.RemoveAll(res.StagedDir)
if res.PluginName != "demo" {
t.Errorf("got plugin %q", res.PluginName)
}
if _, err := os.Stat(filepath.Join(res.StagedDir, "plugin.yaml")); err != nil {
t.Errorf("plugin.yaml not staged: %v", err)
}
}
func TestResolveAndStage_EmptyRequest(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil)
_, err := h.resolveAndStage(context.Background(), installRequest{})
var he *httpErr
if !errors.As(err, &he) || he.Status != http.StatusBadRequest {
t.Errorf("want typed httpErr with 400, got %v", err)
}
}
func TestResolveAndStage_NotFoundFromResolver(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil) // local resolver pointed at empty dir
_, err := h.resolveAndStage(context.Background(), installRequest{Source: "local://absent"})
var he *httpErr
if !errors.As(err, &he) || he.Status != http.StatusNotFound {
t.Errorf("want 404 via ErrPluginNotFound, got %v", err)
}
}
func TestResolveAndStage_CleansUpStagedDirOnError(t *testing.T) {
// Hostile resolver emits a file then returns a traversal name → 400.
// Verify the staged dir it used is NOT left behind.
h := NewPluginsHandler(t.TempDir(), nil, nil).
WithSourceResolver(emitsName("hostile", "../../../etc/passwd"))
_, err := h.resolveAndStage(context.Background(), installRequest{Source: "hostile://x"})
var he *httpErr
if !errors.As(err, &he) || he.Status != http.StatusBadRequest {
t.Fatalf("expected 400, got %v", err)
}
// Walk /tmp for leftover "molecule-plugin-fetch-*" dirs with the
// hostile resolver's marker. Probabilistic (can't pinpoint the exact
// dir), but a clean cleanup means none contain the plugin.yaml blob
// the resolver writes.
entries, _ := filepath.Glob(filepath.Join(os.TempDir(), "molecule-plugin-fetch-*"))
for _, e := range entries {
if _, err := os.Stat(filepath.Join(e, "plugin.yaml")); err == nil {
t.Errorf("leaked staging dir with plugin.yaml still present: %s", e)
}
}
}
func TestHTTPErr_WrapsCleanly(t *testing.T) {
e := newHTTPErr(http.StatusTeapot, gin.H{"error": "I'm a teapot"})
if !strings.Contains(e.Error(), "418") {
t.Errorf("Error() should include status: %q", e.Error())
}
var he *httpErr
if !errors.As(e, &he) || he.Status != http.StatusTeapot {
t.Errorf("errors.As should extract typed error")
}
}
func TestLogInstallLimitsOnce(t *testing.T) {
// sync.Once guarantees exactly one emission per process, so this
// test can't reset it. We call with a local buffer writer and assert
// behaviour that holds regardless of ordering:
// - first caller sees the line
// - later callers see nothing
// - no panic either way
// If another test's NewPluginsHandler already fired the Once, our
// buffer stays empty — that's correct, not a bug.
var buf bytes.Buffer
logInstallLimitsOnce(&buf)
logInstallLimitsOnce(&buf) // must not panic; Once is idempotent
if buf.Len() > 0 {
// We were the first caller. Verify the line contains all three
// limit names so operators can grep for them.
out := buf.String()
for _, want := range []string{"Plugin install limits", "body=", "timeout=", "staged="} {
if !strings.Contains(out, want) {
t.Errorf("log line missing %q: %s", want, out)
}
}
}
// If buf is empty: another test's NewPluginsHandler already called
// Once. That's also correct behavior — nothing to assert beyond "it
// didn't panic."
}
// ---------- regexpEscapeForAwk: special chars escaped ----------
func TestRegexpEscapeForAwk(t *testing.T) {
cases := map[string]string{
"my-plugin": `my-plugin`,
"# Plugin: foo /": `# Plugin: foo \/`,
"# Plugin: a.b /": `# Plugin: a\.b \/`,
"foo[bar]": `foo\[bar\]`,
"a*b+c?": `a\*b\+c\?`,
"path|with|pipes": `path\|with\|pipes`,
`back\slash`: `back\\slash`,
"": ``,
}
for in, want := range cases {
got := regexpEscapeForAwk(in)
if got != want {
t.Errorf("regexpEscapeForAwk(%q) = %q, want %q", in, got, want)
}
}
}
// ---------- stripPluginMarkersFromMemory: awk script logic ----------
//
// We can't unit-test the in-container exec without Docker, but we CAN
// test the awk script against a real bash on the test runner — which is
// exactly the same script that runs in the container. This catches
// off-by-one block-stripping bugs without needing a workspace.
func TestStripPluginMarkers_AwkScript(t *testing.T) {
tmp := t.TempDir()
memory := filepath.Join(tmp, "CLAUDE.md")
// Mirrors the layout AgentskillsAdaptor.append_to_memory writes:
// blank line, marker line, body line(s), blank line separator.
initial := `# Agent Workspace
Original user content here.
# Plugin: my-plugin / rule: foo.md
These are my-plugin's rules.
Multiple lines of content.
# Plugin: keep-me / rule: bar.md
Should remain untouched.
# Plugin: my-plugin / fragment: baz.md
Another my-plugin block.
Trailing user content.
`
if err := os.WriteFile(memory, []byte(initial), 0o644); err != nil {
t.Fatalf("setup: %v", err)
}
// Run the same awk pipeline the production code uses.
marker := "# Plugin: my-plugin /"
script := fmt.Sprintf(
`awk 'BEGIN{skip=0; blanks=0} /^%s/{skip=1; blanks=0; next} skip==1 && /^[[:space:]]*$/{blanks++; if(blanks>=2){skip=0; print; next} next} /^# Plugin: /{if(skip==1)skip=0} skip==1{next} {print}' %s > %s.new && mv %s.new %s`,
regexpEscapeForAwk(marker), memory, memory, memory, memory,
)
cmd := exec.Command("bash", "-c", script)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("awk run failed: %v\n%s", err, out)
}
got, _ := os.ReadFile(memory)
gs := string(got)
// The two my-plugin blocks must be gone, including their content.
if strings.Contains(gs, "my-plugin") {
t.Errorf("expected all my-plugin references stripped; got:\n%s", gs)
}
if strings.Contains(gs, "These are my-plugin's rules.") {
t.Errorf("plugin body content leaked through; got:\n%s", gs)
}
if strings.Contains(gs, "Another my-plugin block.") {
t.Errorf("second plugin block content leaked; got:\n%s", gs)
}
// The keep-me block and surrounding user content must survive intact.
for _, want := range []string{
"# Agent Workspace",
"Original user content here.",
"# Plugin: keep-me / rule: bar.md",
"Should remain untouched.",
"Trailing user content.",
} {
if !strings.Contains(gs, want) {
t.Errorf("expected %q to remain in CLAUDE.md; got:\n%s", want, gs)
}
}
}
// ---------- stripPluginMarkers: missing CLAUDE.md is silent ----------
func TestStripPluginMarkers_MissingFileIsNoOp(t *testing.T) {
// Awk on a missing file is a non-zero exit (which our production code
// silently ignores via `_, _ =`). Verify that the local invocation
// behaves the same and doesn't crash the test process.
tmp := t.TempDir()
missing := filepath.Join(tmp, "does-not-exist.md")
script := fmt.Sprintf(
`awk 'BEGIN{skip=0; blanks=0} /^%s/{skip=1; blanks=0; next} skip==1 && /^[[:space:]]*$/{blanks++; if(blanks>=2){skip=0; print; next} next} /^# Plugin: /{if(skip==1)skip=0} skip==1{next} {print}' %s > /tmp/x.$$ 2>/dev/null && mv /tmp/x.$$ %s`,
regexpEscapeForAwk("# Plugin: x /"), missing, missing,
)
cmd := exec.Command("bash", "-c", script)
_ = cmd.Run() // expected to fail; we just check it doesn't hang/panic
}
// ================== Phase 30.3 — Download endpoint ==================
// Download is exercised via an integration-style test because its main
// work (stream tar.gz) is straightforward; the interesting paths are
// the auth gate and the plugin-name mismatch guard.
func TestPluginDownload_RejectsInvalidName(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: "ws-123"},
{Key: "name", Value: "../traversal"},
}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-123/plugins/..%2Ftraversal/download", nil)
h.Download(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for traversal, got %d", w.Code)
}
}
func TestPluginDownload_MissingTokenWhenWorkspaceHasOne(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
h := NewPluginsHandler(t.TempDir(), nil, nil)
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
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("GET", "/workspaces/X/plugins/some-plugin/download", nil)
h.Download(c)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 when token required and absent, got %d", w.Code)
}
}
func TestPluginDownload_LegacyWorkspaceStreamsTarball(t *testing.T) {
// Stage a small plugin dir; legacy workspace (no tokens on file) is
// grandfathered through; caller should get a tar.gz body back.
mock := setupTestDB(t)
setupTestRedis(t)
pluginsDir := t.TempDir()
pluginRoot := filepath.Join(pluginsDir, "hello-plugin")
if err := os.MkdirAll(pluginRoot, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginRoot, "plugin.yaml"), []byte("name: hello-plugin\nversion: 1.0.0\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginRoot, "rules.md"), []byte("some rules\n"), 0o644); err != nil {
t.Fatal(err)
}
h := NewPluginsHandler(pluginsDir, nil, nil)
// Legacy path — workspace has no live tokens.
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"},
{Key: "name", Value: "hello-plugin"},
}
c.Request = httptest.NewRequest("GET", "/workspaces/X/plugins/hello-plugin/download", nil)
h.Download(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "application/gzip" {
t.Errorf("Content-Type: got %q, want application/gzip", ct)
}
if cd := w.Header().Get("Content-Disposition"); !strings.Contains(cd, `filename="hello-plugin.tar.gz"`) {
t.Errorf("Content-Disposition missing canonical filename: %q", cd)
}
// Body should be gzip-magic-bytes prefixed
body := w.Body.Bytes()
if len(body) < 2 || body[0] != 0x1f || body[1] != 0x8b {
t.Errorf("body does not start with gzip magic (0x1f 0x8b); first bytes: %x", body[:min(4, len(body))])
}
}
// ================== streamDirAsTar helper ==================
func TestStreamDirAsTar_RelativePaths(t *testing.T) {
root := t.TempDir()
os.MkdirAll(filepath.Join(root, "sub"), 0o755)
os.WriteFile(filepath.Join(root, "top.md"), []byte("top content"), 0o644)
os.WriteFile(filepath.Join(root, "sub", "nested.md"), []byte("nested content"), 0o644)
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := streamDirAsTar(root, tw); err != nil {
t.Fatalf("streamDirAsTar: %v", err)
}
tw.Close()
tr := tar.NewReader(&buf)
seen := map[string]bool{}
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("tar read: %v", err)
}
seen[hdr.Name] = true
if strings.HasPrefix(hdr.Name, "/") || strings.Contains(hdr.Name, "..") {
t.Errorf("tar entry has unsafe path: %q", hdr.Name)
}
}
// Must include both our files with relative paths
if !seen["top.md"] {
t.Errorf("missing top.md; saw: %v", seen)
}
if !seen[filepath.Join("sub", "nested.md")] {
t.Errorf("missing sub/nested.md; saw: %v", seen)
}
}
func TestStreamDirAsTar_SkipsSymlinks(t *testing.T) {
root := t.TempDir()
target := filepath.Join(t.TempDir(), "outside.txt")
os.WriteFile(target, []byte("secret"), 0o600)
os.WriteFile(filepath.Join(root, "real.md"), []byte("real"), 0o644)
if err := os.Symlink(target, filepath.Join(root, "escape.lnk")); err != nil {
t.Skipf("symlink unsupported on this fs: %v", err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
streamDirAsTar(root, tw)
tw.Close()
tr := tar.NewReader(&buf)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if hdr.Name == "escape.lnk" {
t.Error("symlink leaked into archive — would escape staged dir")
}
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// Phase 30.3 regression: previously only the local:// path had a unit
// test. The github:// (and any other registered scheme) path was only
// covered by live E2E. This test mocks the resolver registry so the
// non-local install path stays under test even without network access.
func TestPluginDownload_GithubSchemeStreamsTarball(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
h := NewPluginsHandler(t.TempDir(), nil, nil)
// Reuse the existing test-double — supply a fetchFn that drops a
// known file set into dst and returns the plugin name.
h.WithSourceResolver(&fakeResolver{
scheme: "github",
fetchFn: func(_ context.Context, _ string, dst string) (string, error) {
files := map[string]string{
"plugin.yaml": "name: remote-plugin\nversion: 1.0.0\n",
"skills/x/SKILL.md": "---\nname: x\n---\n",
"adapters/claude_code.py": "from plugins_registry.builtins import AgentskillsAdaptor as Adaptor\n",
}
for relPath, content := range files {
full := filepath.Join(dst, relPath)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return "", err
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
return "", err
}
}
return "remote-plugin", nil
},
})
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"},
{Key: "name", Value: "remote-plugin"},
}
req := httptest.NewRequest("GET",
"/workspaces/X/plugins/remote-plugin/download?source=github%3A%2F%2Facme%2Fremote-plugin%23v1.0.0", nil)
req.URL.RawQuery = "source=github%3A%2F%2Facme%2Fremote-plugin%23v1.0.0"
c.Request = req
h.Download(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := w.Header().Get("X-Plugin-Source"); got != "github://acme/remote-plugin#v1.0.0" {
t.Errorf("X-Plugin-Source: got %q, want github://acme/remote-plugin#v1.0.0", got)
}
// Decode + verify the tarball contains the resolver's files
gz, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("gzip reader: %v", err)
}
tr := tar.NewReader(gz)
seen := map[string]bool{}
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("tar read: %v", err)
}
seen[hdr.Name] = true
}
for _, want := range []string{"plugin.yaml", filepath.Join("skills", "x", "SKILL.md"), filepath.Join("adapters", "claude_code.py")} {
if !seen[want] {
t.Errorf("expected tar entry %q, saw: %v", want, seen)
}
}
}
// Buffered-vs-streamed contract: a tar pack failure must surface as a
// clean 5xx with a JSON error, not a truncated 200. We exercise this by
// pointing the resolver at a path the OS will refuse to read.
func TestPluginDownload_TarPackFailureReturns5xxNotTruncated200(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
pluginsDir := t.TempDir()
pluginRoot := filepath.Join(pluginsDir, "broken-plugin")
os.MkdirAll(pluginRoot, 0o755)
os.WriteFile(filepath.Join(pluginRoot, "plugin.yaml"), []byte("name: broken-plugin\n"), 0o644)
// Make a directory we can't read by chmod 000 — streamDirAsTar's
// filepath.Walk will surface a permission error.
unread := filepath.Join(pluginRoot, "unread")
os.MkdirAll(unread, 0o755)
os.WriteFile(filepath.Join(unread, "secret"), []byte("x"), 0o600)
if err := os.Chmod(unread, 0o000); err != nil {
t.Skipf("chmod 0000 unsupported on this fs: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(unread, 0o755) }) // restore for tempdir cleanup
h := NewPluginsHandler(pluginsDir, nil, nil)
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"},
{Key: "name", Value: "broken-plugin"},
}
c.Request = httptest.NewRequest("GET", "/workspaces/X/plugins/broken-plugin/download", nil)
h.Download(c)
// On macOS root may bypass chmod; in that case we get a clean 200 + tar
// and the test is moot.
if w.Code == http.StatusOK && w.Header().Get("Content-Type") == "application/gzip" {
t.Skip("running as root; chmod 0000 didn't take effect, skipping")
}
// The contract this test guards: failure surfaces as a CLEAN 5xx
// + JSON body, NEVER as truncated 200 + Content-Type=application/gzip.
// The failure can come from either resolveAndStage (502 if the local
// resolver can't read the dir) or the tar-pack stage (500 if read
// succeeds but tar.Walk hits the unreadable subdir).
if w.Code < 500 || w.Code > 599 {
t.Fatalf("expected 5xx, got %d: %s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct == "application/gzip" {
t.Errorf("must not advertise gzip on failure path; got Content-Type=%q", ct)
}
if !strings.HasPrefix(strings.TrimSpace(w.Body.String()), "{") {
t.Errorf("expected JSON error body, got: %s", w.Body.String())
}
}