diff --git a/app/contracts/news/base_contract.rb b/app/contracts/news/base_contract.rb
index 2a91b8e61bc..92b93ca03ad 100644
--- a/app/contracts/news/base_contract.rb
+++ b/app/contracts/news/base_contract.rb
@@ -37,7 +37,6 @@ class News::BaseContract < ModelContract
News
end
- attribute :project
attribute :title
attribute :summary
attribute :description
diff --git a/app/contracts/news/create_contract.rb b/app/contracts/news/create_contract.rb
index d43e014da6a..8c1a1303ac5 100644
--- a/app/contracts/news/create_contract.rb
+++ b/app/contracts/news/create_contract.rb
@@ -29,4 +29,5 @@
#++
class News::CreateContract < News::BaseContract
+ attribute :project
end
diff --git a/app/contracts/work_packages/update_contract.rb b/app/contracts/work_packages/update_contract.rb
index 87d6bd5c7ab..83c3e6ac1e0 100644
--- a/app/contracts/work_packages/update_contract.rb
+++ b/app/contracts/work_packages/update_contract.rb
@@ -72,6 +72,8 @@ module WorkPackages
validate :user_allowed_to_edit
+ validate :user_allowed_to_move_from_source_project
+
validate :can_move_to_milestone
validate :user_allowed_to_change_parent
@@ -90,6 +92,21 @@ module WorkPackages
end
end
+ # When moving a work package to a different project, require
+ # :move_work_packages in the source project (the project the work
+ # package currently belongs to). The per-attribute permission check
+ # (reduce_by_writable_permissions) evaluates :move_work_packages
+ # against the target project; this validation covers the source side.
+ def user_allowed_to_move_from_source_project
+ return unless model.project_id_changed?
+
+ with_unchanged_project_id do
+ unless user.allowed_in_project?(:move_work_packages, model.project)
+ errors.add :project_id, :error_readonly
+ end
+ end
+ end
+
def user_allowed_to_access
unless ::WorkPackage.visible(@user).exists?(model.id)
errors.add :base, :error_not_found
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 001f1e48d63..ea07d121f0b 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -285,7 +285,8 @@ module ApplicationHelper
def body_data_attributes(local_assigns)
{
- controller: "application auto-theme-switcher hover-card-trigger beforeunload external-links highlight-target-element",
+ controller: ["application auto-theme-switcher hover-card-trigger beforeunload external-links highlight-target-element",
+ stimulus_body_controller].compact.join(" "),
relative_url_root: root_path,
overflowing_identifier: ".__overflowing_body",
external_links_enabled_value: Setting.capture_external_links?,
diff --git a/app/helpers/stimulus_helper.rb b/app/helpers/stimulus_helper.rb
index 33545acd811..afb619e9b61 100644
--- a/app/helpers/stimulus_helper.rb
+++ b/app/helpers/stimulus_helper.rb
@@ -39,5 +39,13 @@ module StimulusHelper
@stimulus_content_data || {}
end
+ def body_controller(name)
+ @stimulus_body_controller = name
+ end
+
+ def stimulus_body_controller
+ @stimulus_body_controller
+ end
+
# rubocop:enable Rails/HelperInstanceVariable
end
diff --git a/docs/glossary/README.md b/docs/glossary/README.md
index dc33abfc733..eeeca4da195 100644
--- a/docs/glossary/README.md
+++ b/docs/glossary/README.md
@@ -59,7 +59,7 @@ In OpenProject, artificial intelligence (AI) refers to the integration of AI sys
OpenProject approaches AI with a clear focus on control, transparency, and data protection. AI is designed to support users in their work, not to replace human decision-making. Users remain in full control at all times and can decide whether and how AI features are used.
-With features such as the [MCP Server](https://www.openproject.org/blog/openproject-17-2-0-release/), OpenProject enables secure connections between project data and external AI systems. This allows organizations to benefit from AI while maintaining full control over how their data is accessed and used.
+With features such as the [MCP Server](../system-admin-guide/integrations/mcp-server/), OpenProject enables secure connections between project data and external AI systems. This allows organizations to benefit from AI while maintaining full control over how their data is accessed and used.
### Attribute help texts
diff --git a/docs/system-admin-guide/backlogs/README.md b/docs/system-admin-guide/backlogs/README.md
index 2ae65838df9..d700b3bda6e 100644
--- a/docs/system-admin-guide/backlogs/README.md
+++ b/docs/system-admin-guide/backlogs/README.md
@@ -7,10 +7,12 @@ keywords: configure backlogs, backlogs settings, story type, task type, burn cha
---
# Backlogs configuration
-Backlogs settings let you tailor OpenProject’s Scrum features to match how your team plans and tracks work. By configuring story and task types, burn charts, and sprint wiki templates, you can ensure your backlogs and boards show the right work items and support consistent sprint planning and documentation.
+Backlogs settings let you tailor OpenProject’s Scrum features to match how your team plans and tracks work. Backlogs support teams in structuring, prioritizing, and refining work for upcoming sprints, helping ensure a clear and consistent approach to sprint planning and execution.
## Backlog administration settings are evolving
-We are currently redesigning the Backlogs module. Administration settings for sprints and backlogs will be visible here in the near future. Project-level settings remain available.
-
-
+> [!NOTE]
+>
+> We are currently redesigning the Backlogs module. Administration settings for sprints and backlogs will be visible here in the near future.
+>
+> Project-level settings remain available.
diff --git a/docs/user-guide/backlogs-scrum/README.md b/docs/user-guide/backlogs-scrum/README.md
index 8c0916f236f..8ec545a3834 100644
--- a/docs/user-guide/backlogs-scrum/README.md
+++ b/docs/user-guide/backlogs-scrum/README.md
@@ -13,12 +13,12 @@ keywords: backlogs, scrum, backlog, agile, sprint, sprint bucket
Working in agile project teams is becoming increasingly important, and with OpenProject, it is easier than ever.
-OpenProject supports your work with the Agile and Scrum methodology by providing a variety of improved functionalities. You can now create and manage sprints, record and prioritize user stories in sprints and the backlog, use sprint boards or burndown-charts, print story cards, and much more. For more information, please refer to the OpenProject [agile and scrum features](https://www.openproject.org/collaboration-software-features/agile-project-management/) page.
+OpenProject supports your work with the Agile and Scrum methodology by providing a variety of improved functionalities. You can now create and manage sprints, record and prioritize work packages in sprints and the backlog, use automated sprint boards or burndown-charts, and much more. For more information, please refer to the OpenProject [agile and scrum features](https://www.openproject.org/collaboration-software-features/agile-project-management/) page.
-A **Backlog** is defined as a plugin that allows you to use the backlogs feature in OpenProject. In order to use backlogs in a project, the Backlogs module has to be activated in the project settings.
+A **Backlog** is defined as a module that allows you to use the backlogs feature in OpenProject. In order to use backlogs in a project, the Backlogs module has to be activated in the project settings.
diff --git a/docs/user-guide/projects/project-settings/backlogs-settings/README.md b/docs/user-guide/projects/project-settings/backlogs-settings/README.md
index 64ebc308713..a62967407ed 100644
--- a/docs/user-guide/projects/project-settings/backlogs-settings/README.md
+++ b/docs/user-guide/projects/project-settings/backlogs-settings/README.md
@@ -23,20 +23,43 @@ Press the **Save** button to apply your changes.
## Sharing sprints
-Sharing is a project-level setting that allows you to choose whether sprints should be shared across projects or not.
+Sharing is a **project-level setting** that allows you to choose whether sprints should be shared across projects or not.
> [!NOTE]
> This is not a sprint-level setting as is currently the case with versions.
+Sharing sprints allows teams working across multiple projects to plan and track work in a coordinated way. Instead of managing separate, disconnected sprints in each project, you can define a sprint once and reuse it across projects. This is especially useful for cross-team Scrum setups, scaled agile environments, or when multiple teams contribute to the same increment.
+
+Depending on the selected option, a project can either provide sprints to others, use shared sprints, or remain independent:
+
**Don't share:** This is the default setting for projects. Sprints can be created in this project and are available and visible only within this project. None of the created sprints are shared with any other project or sub-projects.
**Share sprints:** Sprints can be created in this project and shared with either **all projects** or **subprojects**:
-**All projects:** Selecting this option means the sprints created are available to all projects within the instance. It also means that other projects will not be able to use this option.
+- **All projects:** Selecting this option means the sprints created are available to all projects within the instance. It also means that no other project can share sprints with all projects.
-**Subprojects:** Sprints created in this project will be available to all subprojects of the current project.
+- **Subprojects:** Sprints created in this project will be available to all subprojects of the current project.
-**Receive shared sprints:** No sprints can be created within this project. Instead, only sprints shared by other projects can be used.
+**Receive shared sprints:** No sprints can be created within this project. Instead, only sprints shared by another project can be used.

+### What is shared
+
+When sprints are shared, the sprint itself is shared across projects. This includes:
+
+- Sprint name
+- Start and finish dates
+- Sprint status (e.g. planning, active, completed)
+
+This ensures that all participating projects work with the same sprint definition and timeline.
+
+### What is not shared
+
+- Work packages remain in their respective projects
+- Backlogs and their structure remain project-specific
+- Permissions and visibility are still managed per project
+
+Even when using shared sprints, each project keeps its own work items and configuration.
+
+Read more on [how to work with Backlogs in OpenProject](../../../backlogs-scrum/).
diff --git a/frontend/src/stimulus/controllers/keep-scroll-position.controller.ts b/frontend/src/stimulus/controllers/keep-scroll-position.controller.ts
index 17ac82b2cf2..a56a7cd0fe3 100644
--- a/frontend/src/stimulus/controllers/keep-scroll-position.controller.ts
+++ b/frontend/src/stimulus/controllers/keep-scroll-position.controller.ts
@@ -31,16 +31,10 @@
import { ApplicationController } from 'stimulus-use';
export default class KeepScrollPositionController extends ApplicationController {
- static values = {
- url: String,
- };
-
static targets = ['triggerButton'];
declare triggerButtonTarget:HTMLLinkElement;
- declare urlValue:string;
-
connect() {
super.connect();
@@ -80,6 +74,6 @@ export default class KeepScrollPositionController extends ApplicationController
}
private scrollPositionKey():string {
- return `${this.urlValue}/scrollPosition`;
+ return `${window.location.pathname}/scrollPosition`;
}
}
diff --git a/lib/open_project/scm/adapters/subversion.rb b/lib/open_project/scm/adapters/subversion.rb
index f20d4fb21e4..3dca8bf71f2 100644
--- a/lib/open_project/scm/adapters/subversion.rb
+++ b/lib/open_project/scm/adapters/subversion.rb
@@ -345,6 +345,7 @@ module OpenProject
# Target path with optional peg revision
# http://svnbook.red-bean.com/en/1.7/svn.advanced.pegrevs.html
def target(path = "", peg: nil)
+ validate_path!(path)
path = super(path)
if peg
@@ -354,6 +355,27 @@ module OpenProject
end
end
+ def validate_path!(path)
+ decoded = path.to_s
+
+ # Unescape repeatedly to catch nested encoding such as %252e%252e.
+ loop do
+ unescaped = URI::DEFAULT_PARSER.unescape(decoded)
+ break if unescaped == decoded
+
+ decoded = unescaped
+ end
+
+ invalid_characters = [path.to_s, decoded].any? do |value|
+ value.include?("\0") || value.include?("\n") || value.include?("\r")
+ end
+
+ traverses_parent = decoded.split("/").include?("..")
+ return unless invalid_characters || traverses_parent
+
+ raise Exceptions::CommandFailed.new(client_command, "Invalid repository path")
+ end
+
##
# Builds the full git arguments from the parameters
# and calls the given block with in, out, err, thread
diff --git a/modules/meeting/app/components/meetings/header_component.html.erb b/modules/meeting/app/components/meetings/header_component.html.erb
index 9da1ca193f9..67e98443def 100644
--- a/modules/meeting/app/components/meetings/header_component.html.erb
+++ b/modules/meeting/app/components/meetings/header_component.html.erb
@@ -1,8 +1,8 @@
<%=
- helpers.content_controller "poll-for-changes keep-scroll-position",
+ helpers.content_controller "poll-for-changes",
poll_for_changes_url_value: check_for_updates_project_meeting_path(@meeting.project, @meeting),
- poll_for_changes_interval_value: check_for_updates_interval,
- keep_scroll_position_url_value: meeting_path(@meeting)
+ poll_for_changes_interval_value: check_for_updates_interval
+ helpers.body_controller "keep-scroll-position"
component_wrapper do
render(
diff --git a/modules/meeting/app/components/meetings/index_page_header_component.html.erb b/modules/meeting/app/components/meetings/index_page_header_component.html.erb
index 6e05000b7c4..f28c8e3eab8 100644
--- a/modules/meeting/app/components/meetings/index_page_header_component.html.erb
+++ b/modules/meeting/app/components/meetings/index_page_header_component.html.erb
@@ -1,5 +1,4 @@
-<% helpers.content_controller "keep-scroll-position",
- keep_scroll_position_url_value: polymorphic_path([@project, :meetings]) %>
+<% helpers.content_controller "keep-scroll-position" %>
<%= render(Primer::OpenProject::PageHeader.new) do |header|
header.with_title { page_title }
diff --git a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb
index 05005d81a6b..bbbd4295ebd 100644
--- a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb
+++ b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb
@@ -1,6 +1,5 @@
<%=
- helpers.content_controller "keep-scroll-position",
- keep_scroll_position_url_value: polymorphic_path([@project, @meeting])
+ helpers.content_controller "keep-scroll-position"
component_wrapper do
render(Primer::OpenProject::PageHeader.new) do |header|
diff --git a/modules/meeting/spec/features/meeting_scroll_position_spec.rb b/modules/meeting/spec/features/meeting_scroll_position_spec.rb
new file mode 100644
index 00000000000..ee46a71e23e
--- /dev/null
+++ b/modules/meeting/spec/features/meeting_scroll_position_spec.rb
@@ -0,0 +1,174 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+require_relative "../support/pages/meetings/show"
+require_relative "../support/pages/meetings/index"
+require_relative "../support/pages/recurring_meeting/show"
+
+RSpec.describe "Meeting scroll position",
+ :js do
+ shared_let(:project) { create(:project, enabled_module_names: %w[meetings]) }
+ shared_let(:user) { create(:admin) }
+
+ current_user { user }
+
+ describe "is restored after clicking 'Reload' in the flash banner" do
+ let(:meeting) { create(:meeting, project:, author: user) }
+ let(:show_page) { Pages::Meetings::Show.new(meeting) }
+
+ before do
+ 25.times { create(:meeting_agenda_item, meeting:, author: user) }
+
+ # Disable automatic polling so we can trigger it manually
+ allow_any_instance_of(Meetings::HeaderComponent) # rubocop:disable RSpec/AnyInstance
+ .to receive(:check_for_updates_interval)
+ .and_return(0)
+ end
+
+ it do
+ flash_component = ".op-primer-flash--item"
+
+ show_page.visit!
+ first_window = current_window
+ second_window = open_new_window
+
+ scroll_position = within_window(first_window) do
+ expect(page).to have_test_selector("meeting-page-header")
+ last_item = all("[data-test-selector='op-meeting-agenda-title']").last
+ page.execute_script("arguments[0].scrollIntoView({ block: 'center', behavior: 'instant' });", last_item)
+ page.evaluate_script("document.getElementById('content-body').scrollTop")
+ end
+
+ within_window(second_window) do
+ show_page.visit!
+
+ retry_block do
+ show_page.add_agenda_item do
+ fill_in "Title", with: "New item triggering update"
+ end
+ end
+ end
+
+ within_window(first_window) do
+ show_page.trigger_change_poll
+ expect(page).to have_css(flash_component)
+ expect(page).to have_text(I18n.t(:notice_meeting_updated))
+
+ click_on I18n.t("label_meeting_reload")
+
+ expect(page).to have_test_selector("meeting-page-header")
+
+ retry_block do
+ restored_position = page.evaluate_script("document.getElementById('content-body').scrollTop")
+ expect(restored_position).to be_within(25).of(scroll_position)
+ end
+ end
+ end
+ end
+
+ describe "is restored after clicking 'Show more' in the meetings index page footer" do
+ let(:index_page) { Pages::Meetings::Index.new(project:) }
+
+ # Freeze time at 8 AM so "today" meetings scheduled for 10 AM are always in the future
+ around do |example|
+ freeze_time_at = Time.zone.today.beginning_of_day + 8.hours
+ travel_to(freeze_time_at) { example.run }
+ end
+
+ before do
+ 30.times { create(:meeting, :author_participates, project:, author: user, start_time: Time.zone.today + 10.hours) }
+ 8.times { create(:meeting, :author_participates, project:, author: user, start_time: 2.weeks.from_now) }
+ end
+
+ it do
+ index_page.visit!
+ expect(page).to have_text(I18n.t(:label_recurring_meeting_show_more))
+
+ trigger_button = find("[data-keep-scroll-position-target='triggerButton']")
+ page.execute_script("arguments[0].scrollIntoView({ block: 'center', behavior: 'instant' });", trigger_button)
+ scroll_position = page.evaluate_script("document.getElementById('content-body').scrollTop")
+
+ trigger_button.click
+
+ expect(page).to have_no_text(I18n.t(:label_recurring_meeting_show_more))
+
+ retry_block do
+ restored_position = page.evaluate_script("document.getElementById('content-body').scrollTop")
+ expect(restored_position).to be_within(25).of(scroll_position)
+ end
+ end
+ end
+
+ describe "is restored after clicking 'Show more' in the recurring meeting show page footer" do
+ let(:recurring_meeting) do
+ create(:recurring_meeting,
+ project:,
+ author: user,
+ start_time: Date.tomorrow + 10.hours,
+ frequency: "daily",
+ end_after: "iterations",
+ iterations: 31)
+ end
+ let(:show_page) { Pages::RecurringMeeting::Show.new(recurring_meeting) }
+
+ before do
+ 25.times do |i|
+ meeting = create(:meeting,
+ project:,
+ author: user,
+ recurring_meeting:,
+ start_time: (i + 4).weeks.from_now + 10.hours)
+ create(:scheduled_meeting,
+ meeting:,
+ recurring_meeting:,
+ start_time: meeting.start_time)
+ end
+ end
+
+ it do
+ show_page.visit!
+ expect(page).to have_text(I18n.t(:label_recurring_meeting_show_more))
+
+ trigger_button = find("[data-keep-scroll-position-target='triggerButton']")
+ page.execute_script("arguments[0].scrollIntoView({ block: 'center', behavior: 'instant' });", trigger_button)
+ scroll_position = page.evaluate_script("document.getElementById('content-body').scrollTop")
+
+ trigger_button.click
+
+ expect(page).to have_no_text(I18n.t(:label_recurring_meeting_show_more))
+
+ retry_block do
+ restored_position = page.evaluate_script("document.getElementById('content-body').scrollTop")
+ expect(restored_position).to be_within(25).of(scroll_position)
+ end
+ end
+ end
+end
diff --git a/spec/contracts/work_packages/update_contract_spec.rb b/spec/contracts/work_packages/update_contract_spec.rb
index a74c7cb88c5..e9393b6a2d0 100644
--- a/spec/contracts/work_packages/update_contract_spec.rb
+++ b/spec/contracts/work_packages/update_contract_spec.rb
@@ -98,24 +98,60 @@ RSpec.describe WorkPackages::UpdateContract do
describe "project_id" do
let(:target_project) { persisted_other_project }
+ let(:source_permissions) { %i[view_work_packages edit_work_packages move_work_packages] }
let(:target_permissions) { [:move_work_packages] }
before do
mock_permissions_for(user) do |mock|
- mock.allow_in_project *permissions, project: persisted_project
+ mock.allow_in_project *source_permissions, project: persisted_project
mock.allow_in_project *target_permissions, project: target_project
end
work_package.project = target_project
end
- it_behaves_like "contract is valid"
+ context "with move_work_packages in both source and target" do
+ it_behaves_like "contract is valid"
+ end
- context "if the user lacks the permissions" do
+ context "if the user lacks move_work_packages in the target project" do
let(:target_permissions) { [] }
it_behaves_like "contract is invalid", project_id: :error_readonly
end
+
+ context "if the user lacks move_work_packages in the source project" do
+ let(:source_permissions) { %i[view_work_packages edit_work_packages] }
+
+ it_behaves_like "contract is invalid", project_id: :error_readonly
+ end
+
+ context "when modifying attributes while moving (authorization bypass prevention)" do
+ before do
+ work_package.subject = "modified-subject"
+ end
+
+ context "with edit_work_packages in target project" do
+ let(:target_permissions) { %i[move_work_packages edit_work_packages] }
+
+ it_behaves_like "contract is valid"
+ end
+
+ context "without edit_work_packages in target project" do
+ let(:target_permissions) { [:move_work_packages] }
+
+ it_behaves_like "contract is invalid", subject: :error_readonly
+ end
+
+ context "without move_work_packages in source project" do
+ let(:source_permissions) { %i[view_work_packages change_work_package_status] }
+ let(:target_permissions) { %i[move_work_packages edit_work_packages] }
+
+ it "blocks the move even when the target project grants all permissions" do
+ expect(validated_contract.errors.symbols_for(:project_id)).to include(:error_readonly)
+ end
+ end
+ end
end
describe "remaining_hours" do
diff --git a/spec/lib/open_project/scm/adapters/subversion_adapter_spec.rb b/spec/lib/open_project/scm/adapters/subversion_adapter_spec.rb
index 8725ba80af3..7873774c7ed 100644
--- a/spec/lib/open_project/scm/adapters/subversion_adapter_spec.rb
+++ b/spec/lib/open_project/scm/adapters/subversion_adapter_spec.rb
@@ -128,6 +128,32 @@ RSpec.describe OpenProject::SCM::Adapters::Subversion do
end
end
+ describe "path validation" do
+ shared_examples "rejects encoded parent traversal" do |method_name, *args|
+ it "rejects encoded parent traversal in ##{method_name}" do
+ allow(adapter).to receive(:capture_svn)
+ allow(adapter).to receive(:popen3)
+
+ expect { adapter.public_send(method_name, "%252e%252e/other_repo/credentials.txt", *args) }
+ .to raise_error(OpenProject::SCM::Exceptions::CommandFailed) { |error|
+ expect(error.message).to eq("Invalid repository path")
+ }
+
+ expect(adapter).not_to have_received(:capture_svn)
+ expect(adapter).not_to have_received(:popen3)
+ end
+ end
+
+ include_examples "rejects encoded parent traversal", :entries
+ include_examples "rejects encoded parent traversal", :cat
+ include_examples "rejects encoded parent traversal", :diff, 2, 1
+ include_examples "rejects encoded parent traversal", :annotate
+
+ it "allows dots that are not parent traversal" do
+ expect(adapter.send(:target, "subversion_test/file..txt")).to eq("#{url}/subversion_test/file..txt")
+ end
+ end
+
describe "empty repository" do
include_context "with tmpdir"
let(:root_url) { tmpdir }
@@ -178,7 +204,7 @@ RSpec.describe OpenProject::SCM::Adapters::Subversion do
out, process = Open3.capture2e("svn", "info", url)
expect(process.exitstatus).to eq(0)
- expect(out).to include("Repository UUID")
+ expect(out).to match(/UUID.*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)
end
it "is available" do
@@ -389,7 +415,9 @@ RSpec.describe OpenProject::SCM::Adapters::Subversion do
it "provides the selected diff for the given range" do
diff = adapter.diff("subversion_test/helloworld.c", 8, 6).map(&:chomp)
- expect(diff).to eq(<<~DIFF.split("\n"))
+ normalized_diff = diff.map { |line| line.gsub(/\((?i:revision) (\d+)\)/, "(revision \\1)") }
+
+ expect(normalized_diff).to eq(<<~DIFF.split("\n"))
Index: helloworld.c
===================================================================
--- helloworld.c (revision 6)
diff --git a/spec/requests/api/v3/news/update_resource_spec.rb b/spec/requests/api/v3/news/update_resource_spec.rb
index ce33bddc9bb..88b3aede3da 100644
--- a/spec/requests/api/v3/news/update_resource_spec.rb
+++ b/spec/requests/api/v3/news/update_resource_spec.rb
@@ -66,6 +66,33 @@ RSpec.describe API::V3::News::NewsAPI,
it_behaves_like "updates the news"
end
+ describe "user with only view_news on source project and manage_news on different project" do
+ let(:attacker_project) { create(:project, enabled_module_names: %w[news]) }
+ let(:user) do
+ create(:user,
+ member_with_permissions: {
+ project => %i[view_news],
+ attacker_project => %i[view_news manage_news]
+ })
+ end
+ let(:parameters) do
+ {
+ _links: {
+ project: {
+ href: api_v3_paths.project(attacker_project.id)
+ }
+ }
+ }
+ end
+
+ it "does not move the news to another project" do
+ expect(last_response.status).to eq(422)
+ expect(news.reload.project).to eq(project)
+ end
+
+ it_behaves_like "read-only violation", "project", News
+ end
+
describe "unauthorized user" do
let(:user) { build(:user) }
diff --git a/spec/requests/api/v3/work_packages/update_resource_spec.rb b/spec/requests/api/v3/work_packages/update_resource_spec.rb
index 975e65f605f..5260ece3f86 100644
--- a/spec/requests/api/v3/work_packages/update_resource_spec.rb
+++ b/spec/requests/api/v3/work_packages/update_resource_spec.rb
@@ -416,6 +416,7 @@ RSpec.describe "API v3 Work package resource",
let(:target_project) do
create(:project, public: false)
end
+ let(:permissions) { super() + [:move_work_packages] }
let(:project_link) { api_v3_paths.project target_project.id }
let(:project_parameter) { { _links: { project: { href: project_link } } } }
let(:params) { valid_params.merge(project_parameter) }
diff --git a/spec/support/form_fields/primerized/input_field.rb b/spec/support/form_fields/primerized/input_field.rb
index df0f41c459f..47a002b5129 100644
--- a/spec/support/form_fields/primerized/input_field.rb
+++ b/spec/support/form_fields/primerized/input_field.rb
@@ -10,11 +10,15 @@ module FormFields
# Capybara's native .click on a checkbox can update the DOM property directly
# without dispatching a browser click event, so Stimulus event handlers won't fire.
# Using execute_script with element.click() fires a real browser event.
+ # We first wait for the element via Capybara's find (which retries until it appears)
+ # to avoid null reference errors when the DOM is still being updated by Turbo.
def check
+ page.find(selector)
page.execute_script("document.querySelector(\"#{selector}\").click()")
end
def uncheck
+ page.find(selector)
page.execute_script("document.querySelector(\"#{selector}\").click()")
end
diff --git a/spec/support/pages/projects/show.rb b/spec/support/pages/projects/show.rb
index 8b494d3ed16..2525d95e4d8 100644
--- a/spec/support/pages/projects/show.rb
+++ b/spec/support/pages/projects/show.rb
@@ -104,9 +104,8 @@ module Pages
def open_inplace_edit_field_for_custom_field(custom_field)
scroll_to_element(page.find("[data-test-selector='project-custom-field-#{custom_field.id}']"))
field = Components::Common::InplaceEditField.new(project, custom_field.attribute_name.to_sym)
- field.open_field
- wait_for_network_idle
+ wait_for_turbo_stream { field.open_field }
field
end