diff --git a/platform/internal/router/admin_test_token_route_test.go b/platform/internal/router/admin_test_token_route_test.go new file mode 100644 index 00000000..bf288b35 --- /dev/null +++ b/platform/internal/router/admin_test_token_route_test.go @@ -0,0 +1,101 @@ +package router + +import ( + "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/handlers" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/middleware" + "github.com/gin-gonic/gin" +) + +// buildTestTokenEngine builds a minimal Gin engine containing only the +// test-token route with AdminAuth middleware — the same registration that +// router.go now uses. Allows us to verify the auth gate is enforced at the +// HTTP layer without spinning up the full Setup() dependency graph. +func buildTestTokenEngine(t *testing.T) gin.IRouter { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + tokh := handlers.NewAdminTestTokenHandler() + r.GET("/admin/workspaces/:id/test-token", middleware.AdminAuth(db.DB), tokh.GetTestToken) + return r +} + +// setupRouterTestDB initialises db.DB with a sqlmock connection and returns +// the mock controller. Restores db.DB on test cleanup. +func setupRouterTestDB(t *testing.T) sqlmock.Sqlmock { + t.Helper() + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + prev := db.DB + db.DB = mockDB + t.Cleanup(func() { + db.DB = prev + mockDB.Close() + }) + return mock +} + +// TestTestTokenRoute_RequiresAdminAuth_WhenTokensExist verifies that once the +// platform has at least one live token, the test-token endpoint returns 401 +// for callers that provide no Authorization header. This is the core security +// property added by the fix — without AdminAuth in the router the request +// would reach the handler and mint a new bearer for any workspace UUID. +func TestTestTokenRoute_RequiresAdminAuth_WhenTokensExist(t *testing.T) { + t.Setenv("MOLECULE_ENV", "development") // enable the handler itself + mock := setupRouterTestDB(t) + + // HasAnyLiveTokenGlobal: platform has one enrolled workspace. + mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + r := buildTestTokenEngine(t) + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/admin/workspaces/ws-target/test-token", nil) + // No Authorization header — should be rejected by AdminAuth. + r.(http.Handler).ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 when tokens exist and no auth header, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) + } +} + +// TestTestTokenRoute_FailOpenOnFreshInstall verifies that AdminAuth is +// fail-open on a fresh install (HasAnyLiveTokenGlobal == 0), so the test-token +// bootstrap path still works before the first workspace has registered. +func TestTestTokenRoute_FailOpenOnFreshInstall(t *testing.T) { + t.Setenv("MOLECULE_ENV", "development") + mock := setupRouterTestDB(t) + + // HasAnyLiveTokenGlobal: no tokens yet — fresh install. + mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + + // Handler's own DB queries: workspace existence check + token insert. + mock.ExpectQuery("SELECT id FROM workspaces WHERE id ="). + WithArgs("ws-bootstrap"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-bootstrap")) + mock.ExpectExec("INSERT INTO workspace_auth_tokens"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + r := buildTestTokenEngine(t) + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/admin/workspaces/ws-bootstrap/test-token", nil) + r.(http.Handler).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 on fresh install (fail-open), got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) + } +} diff --git a/platform/internal/router/router.go b/platform/internal/router/router.go index 0b08d513..a2fbbf59 100644 --- a/platform/internal/router/router.go +++ b/platform/internal/router/router.go @@ -297,11 +297,13 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi } // 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. + // AdminAuth is a second defence-in-depth layer: on a fresh install with no tokens yet, + // AdminAuth is fail-open (HasAnyLiveTokenGlobal == 0), so the bootstrap still works. + // Once any token exists, callers must present a valid bearer — unauthenticated workspace- + // UUID enumeration is blocked even on non-production instances. { tokh := handlers.NewAdminTestTokenHandler() - r.GET("/admin/workspaces/:id/test-token", tokh.GetTestToken) + r.GET("/admin/workspaces/:id/test-token", middleware.AdminAuth(db.DB), tokh.GetTestToken) } // Admin — GitHub App installation token refresh (issue #547).