Merge pull request #53 from Molecule-AI/feat/issue-6-admin-test-token

feat(platform): GET /admin/workspaces/:id/test-token for E2E (#6)
This commit is contained in:
Hongming Wang 2026-04-14 10:33:59 -07:00 committed by GitHub
commit 1ab65ca736
5 changed files with 258 additions and 1 deletions

View File

@ -53,7 +53,7 @@ go run ./cmd/server # Run server (requires Postgres + Redis running)
go build -o molecli ./cmd/cli # Build TUI dashboard
./molecli # Run TUI dashboard (requires platform running)
```
Must run from `platform/` directory (not repo root). Env vars: `DATABASE_URL`, `REDIS_URL`, `PORT`, `PLATFORM_URL` (default `http://host.docker.internal:PORT` — passed to agent containers so they can reach the platform), `SECRETS_ENCRYPTION_KEY` (optional AES-256, 32 bytes), `CONFIGS_DIR` (auto-discovered), `PLUGINS_DIR` (deprecated — plugins are now installed per-workspace via API; the `plugins/` registry at repo root is auto-discovered), `ACTIVITY_RETENTION_DAYS` (default `7`), `ACTIVITY_CLEANUP_INTERVAL_HOURS` (default `6`), `CORS_ORIGINS` (comma-separated, default `http://localhost:3000,http://localhost:3001`), `RATE_LIMIT` (requests/min, default `600`), `WORKSPACE_DIR` (optional — global fallback host path for `/workspace` bind-mount; overridden by per-workspace `workspace_dir` column in DB; if neither is set, each workspace gets an isolated Docker named volume), `AWARENESS_URL` (optional — if set, injected into workspace containers along with a deterministic `AWARENESS_NAMESPACE` derived from workspace ID), `MOLECULE_IN_DOCKER` (optional — set to `1` when the platform itself runs inside Docker so the A2A proxy rewrites `127.0.0.1:<port>` URLs to container hostnames; auto-detected via `/.dockerenv`).
Must run from `platform/` directory (not repo root). Env vars: `DATABASE_URL`, `REDIS_URL`, `PORT`, `PLATFORM_URL` (default `http://host.docker.internal:PORT` — passed to agent containers so they can reach the platform), `SECRETS_ENCRYPTION_KEY` (optional AES-256, 32 bytes), `CONFIGS_DIR` (auto-discovered), `PLUGINS_DIR` (deprecated — plugins are now installed per-workspace via API; the `plugins/` registry at repo root is auto-discovered), `ACTIVITY_RETENTION_DAYS` (default `7`), `ACTIVITY_CLEANUP_INTERVAL_HOURS` (default `6`), `CORS_ORIGINS` (comma-separated, default `http://localhost:3000,http://localhost:3001`), `RATE_LIMIT` (requests/min, default `600`), `WORKSPACE_DIR` (optional — global fallback host path for `/workspace` bind-mount; overridden by per-workspace `workspace_dir` column in DB; if neither is set, each workspace gets an isolated Docker named volume), `AWARENESS_URL` (optional — if set, injected into workspace containers along with a deterministic `AWARENESS_NAMESPACE` derived from workspace ID), `MOLECULE_IN_DOCKER` (optional — set to `1` when the platform itself runs inside Docker so the A2A proxy rewrites `127.0.0.1:<port>` URLs to container hostnames; auto-detected via `/.dockerenv`), `MOLECULE_ENV` (optional — set to `production` to hide the `/admin/workspaces/:id/test-token` E2E helper endpoint; unset or any other value leaves it enabled), `MOLECULE_ENABLE_TEST_TOKENS` (optional — set to `1` to force-enable the test-token endpoint even when `MOLECULE_ENV=production`; intended for staging runs only).
**Plugin install safeguards** (bound the cost of a single `POST /workspaces/:id/plugins` install so a slow/malicious source can't tie up a handler):
- `PLUGIN_INSTALL_BODY_MAX_BYTES` — max request body size (default `65536` = 64 KiB)
@ -256,6 +256,7 @@ Agents can auto-execute a prompt on startup before any user interaction. Configu
| GET | /settings/secrets | secrets.go — list global secrets (keys only, values masked) |
| PUT/POST | /settings/secrets | secrets.go — set a global secret {key, value} |
| DELETE | /settings/secrets/:key | secrets.go — delete a global secret |
| GET | /admin/workspaces/:id/test-token | admin_test_token.go — mint a fresh bearer token for E2E scripts; 404 unless `MOLECULE_ENV != production` or `MOLECULE_ENABLE_TEST_TOKENS=1` |
| GET/POST/DELETE | /admin/secrets[/:key] | secrets.go — legacy aliases for /settings/secrets |
| WS | /workspaces/:id/terminal | terminal.go |
| POST | /workspaces/:id/expand | team.go |

View File

@ -0,0 +1,91 @@
// Package handlers — admin test-token endpoint (follow-up to PR #5, issue #6).
//
// GET /admin/workspaces/:id/test-token mints a fresh workspace auth token for
// E2E scripts, eliminating the register-race in test_comprehensive_e2e.sh.
// The endpoint is DELIBERATELY hidden in production: it returns 404 rather
// than 403 when disabled, so an attacker scanning for admin surfaces can't
// distinguish "route exists, forbidden" from "route doesn't exist."
//
// Enablement contract:
//
// - If MOLECULE_ENABLE_TEST_TOKENS=1 → enabled.
// - Else if MOLECULE_ENV is set and != "production" → enabled.
// - Else → disabled (404).
//
// The fallback to MOLECULE_ENV keeps local dev and CI "just work" without
// requiring every operator to set the enable flag, while forcing production
// deployments (which should set MOLECULE_ENV=production) to stay locked.
package handlers
import (
"database/sql"
"log"
"net/http"
"os"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/gin-gonic/gin"
)
// TestTokensEnabled reports whether the /admin/workspaces/:id/test-token
// route should respond with tokens. Exported so tests (and operator health
// checks) can share the exact same gating logic.
func TestTokensEnabled() bool {
if os.Getenv("MOLECULE_ENABLE_TEST_TOKENS") == "1" {
return true
}
// Empty MOLECULE_ENV defaults to enabled — local dev runs don't set it.
// Production deployments MUST set MOLECULE_ENV=production to lock this.
return os.Getenv("MOLECULE_ENV") != "production"
}
// AdminTestTokenHandler mints a fresh token for an existing workspace.
type AdminTestTokenHandler struct{}
func NewAdminTestTokenHandler() *AdminTestTokenHandler {
return &AdminTestTokenHandler{}
}
// GetTestToken handles GET /admin/workspaces/:id/test-token.
func (h *AdminTestTokenHandler) GetTestToken(c *gin.Context) {
if !TestTokensEnabled() {
// 404 (not 403) — hide the route's existence entirely in prod.
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
workspaceID := c.Param("id")
if workspaceID == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
// Confirm the workspace exists — a missing workspace also 404s so we
// can't be used to probe for arbitrary IDs.
var exists string
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT id FROM workspaces WHERE id = $1`, workspaceID).Scan(&exists)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "lookup failed"})
return
}
token, err := wsauth.IssueToken(c.Request.Context(), db.DB, workspaceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token issue failed"})
return
}
// INFO log — never include the token itself.
log.Printf("admin: issued test token for workspace %s", workspaceID)
c.JSON(http.StatusOK, gin.H{
"auth_token": token,
"workspace_id": workspaceID,
})
}

View File

@ -0,0 +1,131 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"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"
)
func newTestTokenRequest(workspaceID string) (*httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: workspaceID}}
c.Request = httptest.NewRequest("GET", "/admin/workspaces/"+workspaceID+"/test-token", nil)
return w, c
}
func TestAdminTestToken_HiddenInProduction(t *testing.T) {
setupTestDB(t)
t.Setenv("MOLECULE_ENV", "production")
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "")
h := NewAdminTestTokenHandler()
w, c := newTestTokenRequest("ws-1")
h.GetTestToken(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 in production, got %d: %s", w.Code, w.Body.String())
}
}
func TestAdminTestToken_EnabledViaFlagEvenInProd(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "production")
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
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")
h.GetTestToken(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestAdminTestToken_WorkspaceNotFound(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("missing").
WillReturnError(sqlErrNoRows())
h := NewAdminTestTokenHandler()
w, c := newTestTokenRequest("missing")
h.GetTestToken(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for missing workspace, got %d: %s", w.Code, w.Body.String())
}
}
func TestAdminTestToken_HappyPath_TokenValidates(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("ws-1").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1"))
// 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, c := newTestTokenRequest("ws-1")
h.GetTestToken(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
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 id, workspace_id\\s+FROM workspace_auth_tokens").
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 }

View File

@ -216,6 +216,14 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
adminAuth.DELETE("/admin/secrets/:key", sechGlobal.DeleteGlobal)
}
// Admin — test token minting (issue #6). Hidden in production via TestTokensEnabled().
// Registered at root (not inside AdminAuth) because it is itself the bootstrap for
// acquiring a token, and it's gated on MOLECULE_ENV / MOLECULE_ENABLE_TEST_TOKENS.
{
tokh := handlers.NewAdminTestTokenHandler()
r.GET("/admin/workspaces/:id/test-token", tokh.GetTestToken)
}
// Terminal — shares Docker client with provisioner
var dockerCli *client.Client
if prov != nil {

View File

@ -19,6 +19,32 @@ e2e_extract_token() {
# Delete every workspace currently on the platform. Use at the top of a
# script so count-based assertions are reproducible across runs.
# Mint a fresh workspace auth token via the admin endpoint (issue #6).
# Use this INSTEAD of racing /registry/register from the test harness —
# GET /admin/workspaces/:id/test-token is deterministic and gated by
# MOLECULE_ENV (off in production, on in dev / CI).
#
# Usage:
# TOKEN=$(e2e_mint_test_token "$workspace_id") || exit 1
e2e_mint_test_token() {
local wid="$1"
if [ -z "$wid" ]; then
echo "e2e_mint_test_token: workspace id required" >&2
return 2
fi
local body
body=$(curl -s -w "\n%{http_code}" "$BASE/admin/workspaces/$wid/test-token")
local code
code=$(printf '%s' "$body" | tail -n1)
local json
json=$(printf '%s' "$body" | sed '$d')
if [ "$code" != "200" ]; then
echo "e2e_mint_test_token: got HTTP $code (is MOLECULE_ENV!=production?)" >&2
return 1
fi
printf '%s' "$json" | python3 -c "import json,sys; print(json.load(sys.stdin)['auth_token'])"
}
e2e_cleanup_all_workspaces() {
for _wid in $(curl -s "$BASE/workspaces" | python3 -c "import json,sys
try: