Merge branch 'staging' into test/2026-04-23-regression-suite

This commit is contained in:
molecule-ai[bot] 2026-04-24 02:04:46 +00:00 committed by GitHub
commit 10c4fcc7fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 188 additions and 0 deletions

View File

@ -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) {

View File

@ -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 {