forked from molecule-ai/molecule-core
Merge branch 'staging' into test/2026-04-23-regression-suite
This commit is contained in:
commit
10c4fcc7fe
@ -105,6 +105,183 @@ func TestTerminalConnect_KI005_RejectsUnauthorizedCrossWorkspace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestKI005_SelfAccess_AlwaysAllowed — when callerID equals the target workspace
|
||||
// ID the request always passes (self-access: workspace's own token reaches its
|
||||
// own terminal without needing the hierarchy check).
|
||||
func TestKI005_SelfAccess_AlwaysAllowed(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery("SELECT COALESCE").
|
||||
WithArgs("ws-self").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"instance_id"}).AddRow(""))
|
||||
|
||||
h := NewTerminalHandler(nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-self"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-self/terminal", nil)
|
||||
// Self-access: X-Workspace-ID matches the route param, no auth needed.
|
||||
c.Request.Header.Set("X-Workspace-ID", "ws-self")
|
||||
|
||||
h.HandleConnect(c)
|
||||
|
||||
// Self-access passes without any token check or CanCommunicate query.
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("self-access: expected 503 (Docker unavailable), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKI005_CanCommunicatePeer_Allowed — when the caller and target are siblings
|
||||
// (share a parent), CanCommunicate returns true and the terminal access is granted.
|
||||
func TestKI005_CanCommunicatePeer_Allowed(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
// DB: caller workspace row for token validation.
|
||||
mock.ExpectQuery("SELECT t.id, t.workspace_id").
|
||||
WithArgs(sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"}).
|
||||
AddRow("tok-caller", "ws-peer-a"))
|
||||
|
||||
// DB: caller and target are siblings → CanCommunicate queries both.
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id").
|
||||
WithArgs("ws-peer-a").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).
|
||||
AddRow("ws-peer-a", "org-lead"))
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id").
|
||||
WithArgs("ws-peer-b").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).
|
||||
AddRow("ws-peer-b", "org-lead"))
|
||||
|
||||
// DB: target workspace has no instance_id → local Docker path.
|
||||
mock.ExpectQuery("SELECT COALESCE").
|
||||
WithArgs("ws-peer-b").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"instance_id"}).AddRow(""))
|
||||
|
||||
h := NewTerminalHandler(nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-peer-b"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-peer-b/terminal", nil)
|
||||
c.Request.Header.Set("X-Workspace-ID", "ws-peer-a")
|
||||
c.Request.Header.Set("Authorization", "Bearer peer-token")
|
||||
|
||||
h.HandleConnect(c)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("peer access: expected 503 (Docker unavailable), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKI005_CanCommunicateNonPeer_Forbidden — when caller and target have
|
||||
// different parents (not siblings, not root-level), CanCommunicate returns
|
||||
// false and the terminal access is blocked with 403.
|
||||
func TestKI005_CanCommunicateNonPeer_Forbidden(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
// DB: caller workspace row for token validation.
|
||||
mock.ExpectQuery("SELECT t.id, t.workspace_id").
|
||||
WithArgs(sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"}).
|
||||
AddRow("tok-attacker", "ws-attacker"))
|
||||
|
||||
// DB: caller and target have different parents → CanCommunicate denies.
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id").
|
||||
WithArgs("ws-attacker").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).
|
||||
AddRow("ws-attacker", "org-a"))
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id").
|
||||
WithArgs("ws-victim").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).
|
||||
AddRow("ws-victim", "org-b"))
|
||||
|
||||
h := NewTerminalHandler(nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-victim"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-victim/terminal", nil)
|
||||
c.Request.Header.Set("X-Workspace-ID", "ws-attacker")
|
||||
c.Request.Header.Set("Authorization", "Bearer attacker-token")
|
||||
|
||||
h.HandleConnect(c)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("cross-workspace: expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKI005_TokenMismatch_Unauthorized — when the bearer token belongs to a
|
||||
// different workspace than the claimed X-Workspace-ID, ValidateToken fails
|
||||
// and the request is rejected with 401 before CanCommunicate is checked.
|
||||
func TestKI005_TokenMismatch_Unauthorized(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
// DB: token belongs to a different workspace than claimed — ValidateToken
|
||||
// returns ErrInvalidToken (workspaceID mismatch).
|
||||
mock.ExpectQuery("SELECT t.id, t.workspace_id").
|
||||
WithArgs(sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"}))
|
||||
|
||||
h := NewTerminalHandler(nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-target"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-target/terminal", nil)
|
||||
c.Request.Header.Set("X-Workspace-ID", "ws-claimed")
|
||||
c.Request.Header.Set("Authorization", "Bearer wrong-workspace-token")
|
||||
|
||||
h.HandleConnect(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("token mismatch: expected 401, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKI005_NoXWorkspaceIDHeader_LegacyAllowed — when no X-Workspace-ID header
|
||||
// is present (legacy canvas, direct browser access), the hierarchy check is
|
||||
// skipped and the request proceeds to the container (standard WorkspaceAuth
|
||||
// gates apply upstream).
|
||||
func TestKI005_NoXWorkspaceIDHeader_LegacyAllowed(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
// DB: no instance_id → local Docker path.
|
||||
mock.ExpectQuery("SELECT COALESCE").
|
||||
WithArgs("ws-legacy").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"instance_id"}).AddRow(""))
|
||||
|
||||
h := NewTerminalHandler(nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-legacy"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-legacy/terminal", nil)
|
||||
// No X-Workspace-ID header: legacy access, no hierarchy check.
|
||||
|
||||
h.HandleConnect(c)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("legacy access: expected 503 (Docker unavailable), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenTunnelCmd_BuildsArgv guards against silent drift in the EIC
|
||||
// tunnel invocation (e.g. someone flipping --local-port to --port).
|
||||
func TestOpenTunnelCmd_BuildsArgv(t *testing.T) {
|
||||
|
||||
@ -132,6 +132,17 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) {
|
||||
RebuildConfig: body.RebuildConfig,
|
||||
})
|
||||
|
||||
// #239: rebuild_config=true — try org-templates as last-resort source so a
|
||||
// workspace with a destroyed config volume can self-recover without admin
|
||||
// intervention. Only fires when no other template was resolved above.
|
||||
if templatePath == "" && body.RebuildConfig {
|
||||
if p, label := resolveOrgTemplate(h.configsDir, wsName); p != "" {
|
||||
templatePath = p
|
||||
configLabel = label
|
||||
log.Printf("Restart: rebuild_config — using org-template %s for %s (%s)", label, wsName, id)
|
||||
}
|
||||
}
|
||||
|
||||
if templatePath == "" {
|
||||
log.Printf("Restart: reusing existing config volume for %s (%s)", wsName, id)
|
||||
} else {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user