feat(platform): GET /admin/workspaces/:id/test-token for E2E (#6)
Adds a gated admin endpoint that mints a fresh workspace bearer token on demand, eliminating the register-race currently used by test_comprehensive_e2e.sh (PR #5 follow-up). - New handler admin_test_token.go: returns 404 unless MOLECULE_ENV != production or MOLECULE_ENABLE_TEST_TOKENS=1. Hides route existence in prod (404 not 403). - Mints via wsauth.IssueToken; logs at INFO without the token itself. - Verifies workspace exists before minting (missing -> 404, never 500). - Tests cover prod-hidden, enable-flag-overrides-prod, missing workspace, and happy-path + token-validates round trip. - tests/e2e/_lib.sh gains e2e_mint_test_token helper for downstream adoption. - CLAUDE.md updated with route + env vars. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
65b01cef83
commit
496dee8e13
@ -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 |
|
||||
|
||||
91
platform/internal/handlers/admin_test_token.go
Normal file
91
platform/internal/handlers/admin_test_token.go
Normal 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,
|
||||
})
|
||||
}
|
||||
131
platform/internal/handlers/admin_test_token_test.go
Normal file
131
platform/internal/handlers/admin_test_token_test.go
Normal 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 }
|
||||
@ -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 {
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user