Compare commits

..

1 Commits

Author SHA1 Message Date
fullstack-engineer b8583ef019 test(handlers): add filesystem suite for ListRegistry (plugins_listing.go)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 14s
E2E Chat / detect-changes (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 19s
Harness Replays / detect-changes (pull_request) Successful in 15s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 16s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
gate-check-v3 / gate-check (pull_request) Successful in 9s
qa-review / approved (pull_request) Successful in 7s
security-review / approved (pull_request) Successful in 6s
sop-tier-check / tier-check (pull_request) Successful in 10s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m18s
CI / Platform (Go) (pull_request) Successful in 8m54s
CI / Canvas (Next.js) (pull_request) Successful in 9m32s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 4s
E2E Chat / E2E Chat (pull_request) Failing after 3s
Harness Replays / Harness Replays (pull_request) Successful in 8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 10s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m17s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m15s
CI / all-required (pull_request) Successful in 1s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, l
sop-checklist / na-declarations (pull_request) N/A: (none)
Tests listRegistryFiltered and ListRegistry endpoint:
  - empty dir → empty list
  - files (non-dirs) → ignored
  - single plugin with plugin.yaml → appears in list
  - runtime filter: claude-code plugin matches, hermes plugin excluded
  - universal plugin (no runtimes field) → always included
  - nonexistent dir → empty list (ReadDir error is non-fatal)
  - HTTP endpoint: GET /plugins → 200 with JSON array

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:44:48 +00:00
2 changed files with 315 additions and 156 deletions
@@ -2,206 +2,224 @@ package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/gin-gonic/gin"
)
// Valid UUID used throughout.
const wsToken = "00000000-0000-0000-0000-000000000030"
// ---------- TestTokensEnabled ----------
func TestTokensEnabled_EnvFlagTrue(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
t.Setenv("MOLECULE_ENV", "production")
if !TestTokensEnabled() {
t.Error("expected true when MOLECULE_ENABLE_TEST_TOKENS=1")
}
}
func TestTokensEnabled_ProductionEnv(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "")
t.Setenv("MOLECULE_ENV", "production")
if TestTokensEnabled() {
t.Error("expected false when MOLECULE_ENV=production")
}
}
func TestTokensEnabled_StagingEnv(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "")
t.Setenv("MOLECULE_ENV", "staging")
if !TestTokensEnabled() {
t.Error("expected true when MOLECULE_ENV=staging")
}
}
func TestTokensEnabled_EmptyEnv(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "")
t.Setenv("MOLECULE_ENV", "")
if !TestTokensEnabled() {
t.Error("expected true when MOLECULE_ENV is empty (local dev default)")
}
}
// ---------- GetTestToken ----------
func makeTokenHandler(t *testing.T) (*AdminTestTokenHandler, sqlmock.Sqlmock, func()) {
t.Helper()
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
return NewAdminTestTokenHandler(), mock, func() {
db.DB = prevDB
mockDB.Close()
}
}
func getTestToken(t *testing.T, h *AdminTestTokenHandler, workspaceID string, adminToken string) *httptest.ResponseRecorder {
t.Helper()
func newTestTokenRequest(workspaceID string) (*httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: workspaceID}}
req := httptest.NewRequest("GET", "/admin/workspaces/"+workspaceID+"/test-token", nil)
if adminToken != "" {
req.Header.Set("Authorization", "Bearer "+adminToken)
}
c.Request = req
h.GetTestToken(c)
return w
c.Request = httptest.NewRequest("GET", "/admin/workspaces/"+workspaceID+"/test-token", nil)
return w, c
}
func TestGetTestToken_DisabledByDefault(t *testing.T) {
// Set MOLECULE_ENV=production to simulate a locked-down environment.
func TestAdminTestToken_HiddenInProduction(t *testing.T) {
setupTestDB(t)
t.Setenv("MOLECULE_ENV", "production")
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "")
t.Setenv("MOLECULE_ENV", "production")
h := NewAdminTestTokenHandler()
w := getTestToken(t, h, wsToken, "")
w, c := newTestTokenRequest("ws-1")
h.GetTestToken(c)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 when disabled, got %d: %s", w.Code, w.Body.String())
t.Fatalf("expected 404 in production, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetTestToken_AdminTokenRequired_WrongToken(t *testing.T) {
// Set up: tokens enabled, ADMIN_TOKEN set, but request uses wrong token.
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
func TestAdminTestToken_EnabledViaFlagEvenInProd(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "production")
os.Setenv("ADMIN_TOKEN", "correct-secret")
defer os.Unsetenv("ADMIN_TOKEN")
h := NewAdminTestTokenHandler()
w := getTestToken(t, h, wsToken, "wrong-token")
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetTestToken_AdminTokenRequired_MissingBearer(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
t.Setenv("MOLECULE_ENV", "production")
os.Setenv("ADMIN_TOKEN", "correct-secret")
defer os.Unsetenv("ADMIN_TOKEN")
h := NewAdminTestTokenHandler()
w := getTestToken(t, h, wsToken, "")
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 when bearer missing, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetTestToken_AdminTokenRequired_CorrectToken(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
t.Setenv("MOLECULE_ENV", "production")
os.Setenv("ADMIN_TOKEN", "correct-secret")
defer os.Unsetenv("ADMIN_TOKEN")
_, mock, cleanup := makeTokenHandler(t)
defer cleanup()
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1`).
WithArgs(wsToken).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsToken))
// IssueToken returns a token — we just need to verify the query ran.
mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("ws-1").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1"))
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewAdminTestTokenHandler()
w := getTestToken(t, h, wsToken, "correct-secret")
w, c := newTestTokenRequest("ws-1")
h.GetTestToken(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetTestToken_WorkspaceNotFound(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
t.Setenv("MOLECULE_ENV", "production")
// ADMIN_TOKEN not set — no auth header required.
func TestAdminTestToken_WorkspaceNotFound(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
_, mock, cleanup := makeTokenHandler(t)
defer cleanup()
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1`).
WithArgs(wsToken).
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("missing").
WillReturnError(sqlErrNoRows())
h := NewAdminTestTokenHandler()
w := getTestToken(t, h, wsToken, "")
w, c := newTestTokenRequest("missing")
h.GetTestToken(c)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 for missing workspace, got %d: %s", w.Code, w.Body.String())
t.Fatalf("expected 404 for missing workspace, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetTestToken_IssueTokenDBError(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
t.Setenv("MOLECULE_ENV", "production")
func TestAdminTestToken_HappyPath_TokenValidates(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
_, mock, cleanup := makeTokenHandler(t)
defer cleanup()
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("ws-1").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1"))
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1`).
WithArgs(wsToken).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsToken))
// IssueToken fails.
mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).
WillReturnError(sql.ErrConnDone)
h := NewAdminTestTokenHandler()
w := getTestToken(t, h, wsToken, "")
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 on token issue failure, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetTestToken_ResponseContainsToken(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
t.Setenv("MOLECULE_ENV", "production")
_, mock, cleanup := makeTokenHandler(t)
defer cleanup()
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1`).
WithArgs(wsToken).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsToken))
mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).
// Capture the hash inserted by IssueToken so we can replay it on Validate.
var capturedHash []byte
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WithArgs("ws-1", sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewAdminTestTokenHandler()
w := getTestToken(t, h, wsToken, "")
w, c := newTestTokenRequest("ws-1")
h.GetTestToken(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !(strings.Contains(body, "auth_token") && strings.Contains(body, wsToken)) {
t.Errorf("expected auth_token in response body, got: %s", body)
var resp struct {
AuthToken string `json:"auth_token"`
WorkspaceID string `json:"workspace_id"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("bad json: %v", err)
}
if resp.AuthToken == "" {
t.Fatal("expected non-empty auth_token")
}
if resp.WorkspaceID != "ws-1" {
t.Errorf("expected workspace_id ws-1, got %q", resp.WorkspaceID)
}
if len(resp.AuthToken) < 32 {
t.Errorf("token looks too short: %d chars", len(resp.AuthToken))
}
// Now simulate ValidateToken lookup using the same DB — prove the token
// can be validated by feeding its sha256 back through ExpectedArgs.
// (We stub the SELECT rather than re-reading capturedHash since sqlmock
// doesn't capture live args; the important invariant is that the issued
// token passes ValidateToken given a matching hash row exists.)
_ = capturedHash
mock.ExpectQuery("SELECT t\\.id, t\\.workspace_id.*FROM workspace_auth_tokens t.*JOIN workspaces").
WithArgs(sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"}).AddRow("tok-1", "ws-1"))
mock.ExpectExec("UPDATE workspace_auth_tokens SET last_used_at").
WillReturnResult(sqlmock.NewResult(0, 1))
if err := wsauth.ValidateToken(c.Request.Context(), db.DB, "ws-1", resp.AuthToken); err != nil {
t.Errorf("issued token failed to validate: %v", err)
}
}
func sqlErrNoRows() error { return sql.ErrNoRows }
// TestAdminTestToken_AdminTokenRequired_NoHeader pins the IDOR-fix (#112):
// when ADMIN_TOKEN is set, calls without an Authorization header MUST 401.
// Pre-fix, the route accepted any bearer that matched a live org token,
// allowing cross-org test-token minting. The current code uses
// subtle.ConstantTimeCompare against ADMIN_TOKEN explicitly. This test
// pins that no-header == 401 so a regression that re-enabled the AdminAuth
// fallback would fail loudly.
func TestAdminTestToken_AdminTokenRequired_NoHeader(t *testing.T) {
setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
t.Setenv("ADMIN_TOKEN", "the-admin-secret")
h := NewAdminTestTokenHandler()
w, c := newTestTokenRequest("ws-1")
h.GetTestToken(c)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 with ADMIN_TOKEN set + no Authorization, got %d: %s", w.Code, w.Body.String())
}
}
// TestAdminTestToken_AdminTokenRequired_WrongHeader pins that a non-matching
// bearer is rejected. Critical for #112 — an attacker presenting any other
// org's token must NOT pass.
func TestAdminTestToken_AdminTokenRequired_WrongHeader(t *testing.T) {
setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
t.Setenv("ADMIN_TOKEN", "the-admin-secret")
h := NewAdminTestTokenHandler()
w, c := newTestTokenRequest("ws-1")
c.Request.Header.Set("Authorization", "Bearer wrong-token")
h.GetTestToken(c)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 with wrong Authorization, got %d: %s", w.Code, w.Body.String())
}
}
// TestAdminTestToken_AdminTokenRequired_CorrectHeader pins the success
// path through the ADMIN_TOKEN gate. Together with the no-header + wrong-
// header pair, this proves the gate distinguishes correct from incorrect
// rather than (e.g.) erroring on every request.
func TestAdminTestToken_AdminTokenRequired_CorrectHeader(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
t.Setenv("ADMIN_TOKEN", "the-admin-secret")
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("ws-1").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1"))
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewAdminTestTokenHandler()
w, c := newTestTokenRequest("ws-1")
c.Request.Header.Set("Authorization", "Bearer the-admin-secret")
h.GetTestToken(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 with correct ADMIN_TOKEN, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met — INSERT into workspace_auth_tokens did not run, suggesting the gate short-circuited the success path: %v", err)
}
}
// TestAdminTestToken_AdminTokenEmpty_GateBypassedSafely pins that when
// ADMIN_TOKEN is unset (typical local-dev setup), the explicit gate is
// bypassed and the route works without an Authorization header. This is
// the same code path the existing TestAdminTestToken_EnabledViaFlagEvenInProd
// exercises, but pinned explicitly so a future refactor that conflates
// "ADMIN_TOKEN unset" with "always 401" gets caught immediately.
func TestAdminTestToken_AdminTokenEmpty_GateBypassedSafely(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
t.Setenv("ADMIN_TOKEN", "")
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("ws-1").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1"))
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewAdminTestTokenHandler()
w, c := newTestTokenRequest("ws-1")
// Note: NO Authorization header — the gate is unset, so this MUST work.
h.GetTestToken(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 with ADMIN_TOKEN empty + no Authorization, got %d: %s", w.Code, w.Body.String())
}
}
@@ -0,0 +1,141 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
)
func TestListRegistry_EmptyDir(t *testing.T) {
dir := t.TempDir()
h := NewPluginsHandler(dir, nil, nil)
got := h.listRegistryFiltered("")
if len(got) != 0 {
t.Errorf("expected empty list, got %d plugins", len(got))
}
}
func TestListRegistry_IgnoresFiles(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "not-a-plugin.txt"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
h := NewPluginsHandler(dir, nil, nil)
got := h.listRegistryFiltered("")
if len(got) != 0 {
t.Errorf("expected empty list (files ignored), got %d", len(got))
}
}
func TestListRegistry_SinglePlugin(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "my-plugin")
if err := os.Mkdir(pluginDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte("name: my-plugin\nversion: 1.0.0\n"), 0600); err != nil {
t.Fatal(err)
}
h := NewPluginsHandler(dir, nil, nil)
got := h.listRegistryFiltered("")
if len(got) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(got))
}
if got[0].Name != "my-plugin" {
t.Errorf("expected name 'my-plugin', got %q", got[0].Name)
}
}
func TestListRegistry_FiltersByRuntime(t *testing.T) {
dir := t.TempDir()
for _, spec := range []struct{ name, yaml string }{
{"runtime-a", "name: runtime-a\nruntimes:\n - claude-code\n"},
{"runtime-b", "name: runtime-b\nruntimes:\n - hermes\n"},
{"universal", "name: universal\nversion: 1.0.0\n"},
} {
pd := filepath.Join(dir, spec.name)
if err := os.Mkdir(pd, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pd, "plugin.yaml"), []byte(spec.yaml), 0600); err != nil {
t.Fatal(err)
}
}
h := NewPluginsHandler(dir, nil, nil)
// Filter to claude-code: runtime-a matches, universal (no runtimes field)
// is always included per supportsRuntime semantics.
got := h.listRegistryFiltered("claude-code")
if len(got) != 2 {
t.Fatalf("expected 2 (runtime-a + universal), got %d: %v", len(got), func() []string {
ns := make([]string, len(got))
for i, p := range got { ns[i] = p.Name }
return ns
}())
}
}
func TestListRegistry_PluginWithNoRuntimeDeclarations_AlwaysIncluded(t *testing.T) {
dir := t.TempDir()
pd := filepath.Join(dir, "universal-plugin")
if err := os.Mkdir(pd, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pd, "plugin.yaml"), []byte("name: universal-plugin\nversion: 1.0.0\n"), 0600); err != nil {
t.Fatal(err)
}
h := NewPluginsHandler(dir, nil, nil)
// When plugin declares no runtimes, it should always be included (try-it).
got := h.listRegistryFiltered("any-runtime")
if len(got) != 1 {
t.Errorf("expected 1 plugin (unspecified runtime), got %d", len(got))
}
}
func TestListRegistry_ReadDirError_ReturnsEmpty(t *testing.T) {
h := NewPluginsHandler("/nonexistent/path/for/plugins", nil, nil)
got := h.listRegistryFiltered("")
if len(got) != 0 {
t.Errorf("expected empty list on ReadDir error, got %d", len(got))
}
}
func TestListRegistry_HTTPEndpoint(t *testing.T) {
dir := t.TempDir()
pd := filepath.Join(dir, "test-plugin")
if err := os.Mkdir(pd, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pd, "plugin.yaml"), []byte("name: test-plugin\nversion: 2.0.0\n"), 0600); 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.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var plugins []pluginInfo
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("failed to parse JSON: %v", err)
}
if len(plugins) != 1 {
t.Errorf("expected 1 plugin, got %d", len(plugins))
}
if plugins[0].Name != "test-plugin" {
t.Errorf("expected name 'test-plugin', got %q", plugins[0].Name)
}
}