diff --git a/CHANGELOG b/CHANGELOG index 843410918..c47454b76 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -39,6 +39,7 @@ v5.2.0 (July 2026) - Remove a stray leading blank line from streamed responses - Send only completed conversation turns to the AI provider, so a failed or in-progress reply can no longer break the next one - Show a generic message when a reply fails instead of surfacing the provider's raw error + - Return a graceful error instead of a server error when a chat session is started with a blank prompt v5.1.0 (May 2026) - DataTables: add sticky table toolbar that tracks below the navigation bar when scrolling diff --git a/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb b/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb new file mode 100644 index 000000000..faa9d7c84 --- /dev/null +++ b/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb @@ -0,0 +1,48 @@ +module Dradis::Plugins::Echo + # Custom Turbo Streams channel for Echo sessions. Turbo's default channel + # trusts any client holding a validly-signed stream name for the lifetime of + # that name — which never expires — so a user who loses project access could + # subscribe and keep receiving the transcript. We re-check authorization when + # a subscription is established: resolve the session behind the signed name + # and only stream if the subscriber may still :use its project, otherwise + # reject. Note this guards *new* subscriptions only; a connection opened while + # still authorized keeps streaming until it is torn down. + # + # Follows the documented turbo-rails custom-channel pattern + # (Turbo::StreamsChannel). + class SessionsChannel < ApplicationCable::Channel + extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName + include Turbo::Streams::StreamName::ClassMethods + + def subscribed + session = session_from_stream_name + + if session && authorized?(session) + stream_from verified_stream_name_from_params + else + reject + end + end + + private + + def authorized?(session) + Ability.new(current_user).can?(:use, session.project) + end + + # The signed name encodes `[session, :messages]`, i.e. + # ":messages". A tampered name fails verification and + # yields nil (never reaching locate), so the only raise path left is a + # session deleted between signing and subscribe — RecordNotFound, which we + # treat as unauthorized. Narrower than a blanket rescue so genuine bugs + # (NoMethodError etc.) still surface. + def session_from_stream_name + name = verified_stream_name_from_params + return unless name + + GlobalID::Locator.locate(name.split(':').first, only: Session) + rescue ActiveRecord::RecordNotFound + nil + end + end +end diff --git a/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/has_session.rb b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/has_session.rb new file mode 100644 index 000000000..98d97b358 --- /dev/null +++ b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/has_session.rb @@ -0,0 +1,21 @@ +module Dradis::Plugins::Echo + # Loads the URL-nested session for the message/reply controllers and re-scopes + # it through the current project, so an out-of-scope session_id raises + # ActiveRecord::RecordNotFound rather than leaking another project's session. + # Pulls in RecordScoping for the scoped lookup; consumers only include this. + module HasSession + extend ActiveSupport::Concern + include RecordScoping + + included do + before_action :set_session + end + + private + + def set_session + @session = Session.find(params[:session_id]) + scoped_record(@session) + end + end +end diff --git a/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb new file mode 100644 index 000000000..e87712766 --- /dev/null +++ b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb @@ -0,0 +1,22 @@ +module Dradis::Plugins::Echo + # Shared record scoping for the session controllers. A session is always + # anchored to a record (an Issue or a Note) that must live inside the current + # project; resolving it through the project's collections gives us + # cross-project/record isolation for free — an out-of-scope id raises + # ActiveRecord::RecordNotFound, mirroring Projects::GrammarController. + # + # We read the session's stored record_type/record_id directly rather than + # loading session.record: the association would fire a polymorphic query just + # to re-scope through the project. Issues are persisted as 'Issue' even though + # they descend from Note (see Session#record=), so record_type maps straight + # onto the matching project collection (issues, notes, ...). + module RecordScoping + extend ActiveSupport::Concern + + private + + def scoped_record(session) + current_project.public_send(session.record_type.underscore.pluralize).find(session.record_id) + end + end +end diff --git a/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/turbo_config_check.rb b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/turbo_config_check.rb new file mode 100644 index 000000000..92982ff97 --- /dev/null +++ b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/turbo_config_check.rb @@ -0,0 +1,32 @@ +module Dradis::Plugins::Echo + # Shared `check_turbo_config` before_action for the streaming controllers. + # Turbo Streams only work if Action Cable can reach its backend, so the view + # can warn the user when it can't. + # + # Only a Redis-backed adapter needs a reachable external server, so we ping + # just those (duck-typed on the subscription connection) and memoize the + # result. Every other adapter (async in development, test in specs) is always + # treated as healthy — no spurious "can't contact Redis" alert and no + # per-request Redis round-trip. + module TurboConfigCheck + extend ActiveSupport::Concern + + private + + def check_turbo_config + return @turbo_status if defined?(@turbo_status) + + @turbo_status = turbo_backend_reachable? + end + + def turbo_backend_reachable? + adapter = ActionCable.server.pubsub + return true unless adapter.respond_to?(:redis_connection_for_subscriptions) + + adapter.redis_connection_for_subscriptions.ping + true + rescue StandardError + false + end + end +end diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb new file mode 100644 index 000000000..8c5f31ccb --- /dev/null +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb @@ -0,0 +1,20 @@ +module Dradis::Plugins::Echo + class Projects::Sessions::MessagesController < AuthenticatedController + include HasSession + include ProjectScoped + layout false + + # Appends a user turn and re-opens the reply gate. The new message + # broadcasts itself into the transcript, so there's nothing to render back. + def create + message = @session.messages.build(content: params.expect(:content), user: current_user) + + if message.save + @session.request_reply! + head :ok + else + head :unprocessable_entity + end + end + end +end diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb new file mode 100644 index 000000000..e4cef5c30 --- /dev/null +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb @@ -0,0 +1,19 @@ +module Dradis::Plugins::Echo + class Projects::Sessions::RepliesController < AuthenticatedController + include HasSession + include ProjectScoped + layout false + + # Starts generation for a freshly-created session. The session Stimulus + # controller POSTs here from `connect`, i.e. after the browser has subscribed + # to the SessionsChannel, so the streaming container ReplyJob broadcasts lands + # on a listening socket (SEC-506 Bug 4). Guarded by reply_pending? so a + # reconnect, a second viewer, or a stray POST on an already-answered session + # can never spawn an unsolicited reply — request_reply! only fires while a + # reply is genuinely owed. + def create + @session.request_reply! if @session.reply_pending? + head :ok + end + end +end diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb new file mode 100644 index 000000000..10c3e1a91 --- /dev/null +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb @@ -0,0 +1,83 @@ +module Dradis::Plugins::Echo + class Projects::SessionsController < AuthenticatedController + include EventPublisher + include ProjectScoped + include RecordScoping + include TurboConfigCheck + layout false + + before_action :set_type, only: [:create, :index] + before_action :set_record, only: [:create, :index] + before_action :check_turbo_config, only: [:show, :create] + before_action :set_session, only: [:show] + + # Lists the record's past sessions plus the prompts available to start a new + # one. Roslin-disabled and prompts empty-state warnings are handled by the + # view. + def index + @sessions = Session.for_record(@record) + @prompts = current_user.prompts.for(@type) + end + + def show + @messages = @session.messages.order(:created_at, :id) + end + + # Starts a conversation from a saved prompt. The Prompt is read only here, at + # the controller boundary — we copy its title and never store a FK, so a + # later edit or deletion of the template can't rewrite history. Scoping the + # lookup through `for(@type)` honours the Prompt::SCOPES whitelist. The first + # user Message carries the (Liquid-rendered, possibly edited) prompt text. + # + # We deliberately do NOT call request_reply! here: on initial creation the + # browser hasn't subscribed to the SessionsChannel yet, so an immediate + # generation would broadcast the streaming container before the socket is + # listening and the reply would never render live (SEC-506 Bug 4). Instead we + # render `show` in the reply_pending? state and let the session Stimulus + # controller POST to RepliesController once it has connected. + def create + return head :unprocessable_entity if params[:prompt].blank? + + prompt = current_user.prompts.for(@type).find(params[:prompt_id]) + + @session = Session.new( + agent: Agents::Roslin.instance, + record: @record, + title: prompt.title, + user: current_user + ) + @session.messages.build(content: params[:prompt], user: current_user) + + if @session.save + publish_event('echo_session.created', session: { id: @session.id }) + render :show + else + head :unprocessable_entity + end + end + + private + + def record_params + params.permit(:id, :prompt, :prompt_id, :project_id, :record, :type) + end + + def set_record + @record = current_project.send(@type.to_s.pluralize).find(record_params[:record]) + end + + def set_session + @session = Session.find(record_params[:id]) + @record = scoped_record(@session) + end + + # Whitelists the record type against Prompt::SCOPES before it reaches a + # dynamic `current_project.send(@type.pluralize)` dispatch. An unknown type + # is an out-of-scope request, not a 500 — raise the same RecordNotFound the + # scoped lookups do. + def set_type + @type = record_params[:type]&.to_sym + raise ActiveRecord::RecordNotFound unless Prompt::SCOPES.include?(@type) + end + end +end diff --git a/engines/dradis-echo/app/jobs/dradis/plugins/echo/reply_job.rb b/engines/dradis-echo/app/jobs/dradis/plugins/echo/reply_job.rb index dc514fac7..a0fc45e24 100644 --- a/engines/dradis-echo/app/jobs/dradis/plugins/echo/reply_job.rb +++ b/engines/dradis-echo/app/jobs/dradis/plugins/echo/reply_job.rb @@ -9,7 +9,7 @@ class ReplyJob < ApplicationJob # Generates one assistant reply for a session: it streams the provider # response into a `streaming` message, persists the final text as - # `complete` with model/provider metadata, then serializes — re-enqueueing + # `complete` with model/provider metadata, then finalizes — re-enqueueing # itself if the user spoke again mid-generation, or flipping the session # back to `idle`. Session#request_reply! owns the idle->generating gate. def perform(session) @@ -23,7 +23,7 @@ def perform(session) text, duration_ms = stream_reply(agent, session, message, context) complete(agent, message, text, duration_ms) - serialize(session, cutoff_id) + finalize(session, cutoff_id) rescue Provider::HttpStreaming::Error => e # A genuine provider/transport failure: surface it to the user as a failed # message. Anything else (a disabled agent, a bug in our code) is left to @@ -89,7 +89,7 @@ def strip_thinking(text) # Under a lock so it can't race the controller flipping idle<->generating: # if a user message landed after the reply started (id past the cutoff), # answer it too by re-enqueueing; otherwise release the session to idle. - def serialize(session, cutoff_id) + def finalize(session, cutoff_id) session.with_lock do if session.messages.where(role: :user).where('id > ?', cutoff_id).exists? self.class.perform_later(session) diff --git a/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb b/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb index 68a10408f..4b3660e4b 100644 --- a/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb @@ -16,6 +16,12 @@ class Message < ApplicationRecord belongs_to :user, optional: true # -- Callbacks ------------------------------------------------------------ + # The author is the source of truth for a user turn, so controllers don't + # repeat `role: :user` on every build. ReplyJob still sets `role: :assistant` + # explicitly (assistant messages carry no user). Runs before the predicate + # callbacks below so `user?` sees the derived role. + before_validation :set_role_from_user + # User messages are authored in full, so they're never mid-stream. before_validation :complete_user_messages, if: :user? @@ -45,5 +51,9 @@ def broadcast_created def complete_user_messages self.status = :complete end + + def set_role_from_user + self.role ||= :user if user.present? + end end end diff --git a/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb b/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb index 1e7e95c14..37a6a886b 100644 --- a/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb @@ -66,6 +66,17 @@ def request_reply! ReplyJob.perform_later(self) if enqueue end + # True when a reply is owed but generation hasn't started yet: the session is + # idle and the newest message is a user turn. `create` renders `show` in this + # state and lets the freshly-subscribed client trigger request_reply!, so the + # streaming container is only broadcast once the socket is listening (SEC-506 + # Bug 4). It flips back to false the moment ReplyJob flips the session to + # `generating` or an assistant reply lands, which makes the client trigger + # idempotent across reconnects and multiple viewers. + def reply_pending? + idle? && messages.order(:created_at, :id).last&.user? + end + # Only completed turns are safe to replay to a provider: a streaming row has # no content yet, and a failed one carries a nil/partial body. Sending either # would poison the next request with a `content: nil` turn. diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_messages.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_messages.html.erb new file mode 100644 index 000000000..60935049b --- /dev/null +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_messages.html.erb @@ -0,0 +1,7 @@ +<%# locals: (messages:) -%> +<%# The live transcript container. ReplyJob and Message#broadcast_created append %> +<%# new turns into #echo-messages, so its id must stay stable across renders. %> +
+ <%= render partial: 'dradis/plugins/echo/projects/sessions/messages/message', + collection: messages, as: :message %> +
diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/index.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/index.html.erb new file mode 100644 index 000000000..5bc6007a9 --- /dev/null +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/index.html.erb @@ -0,0 +1,47 @@ +<% if Dradis::Plugins::Echo::Agents::Roslin.enabled? %> + <% if @prompts.any? %> + <% if @sessions.any? %> +
+ <% @sessions.each do |session| %> + <%= link_to session.title.presence || "Session ##{session.id}", + echo.project_session_path(current_project, session), + id: dom_id(session), + class: 'list-group-item list-group-item-action', + data: { turbo_frame: dom_id(@record, :echo) } %> + <% end %> +
+ <% end %> + +

+ Interact with Roslin by selecting a + <%= link_to 'saved prompt', echo.prompts_path, data: { turbo_frame: '_top' } %> to start a session for this Issue. +

+

+ You can edit the prompt before sending it, any changes are one-off and + won't affect the saved prompt template. +

+ <% else %> + <%= render 'shared/empty_state', + actions_partial: 'dradis/plugins/echo/prompts/empty_state_actions', + docs_link: 'https://dradis.com/support/guides/echo/prompts.html', + name: 'prompt', + text: 'Create reusable prompts to standardize how your team analyzes findings, writes reports, summarizes vulnerabilities, and more. Save time by building a library of prompts that anyone on your team can use for consistent results.' + %> + <% end %> +<% else %> +
+ <% if !current_user.respond_to?(:role?) || current_user.role?(:admin) %> + The Roslin agent is not enabled. + <%= link_to 'Enable it', echo.agents_path, data: { turbo_frame: '_top' } %> + to allow LLM interactions for AI-assisted writing. + <% else %> + Roslin is not enabled. Ask an + admin to enable it to allow LLM interactions. + <% end %> +
+

+ Think of Roslin as an editor or writing companion. It allows you to interact + with different LLM providers to help enhance your findings' readability + or adapt them to your audience. +

+<% end %> diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/show.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/show.html.erb new file mode 100644 index 000000000..d176a89a4 --- /dev/null +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/show.html.erb @@ -0,0 +1,31 @@ + + <% unless @turbo_status %> +
+ There was an error contacting Redis: please make sure the Redis server is running. +
+ <% end %> + + <%# Live transcript + composer state. A later slice wires the custom %> + <%# SessionsChannel (`channel:`) so the socket re-authorizes on subscribe. %> + <%= turbo_stream_from [@session, :messages] %> + <%= turbo_stream_from [@session, :composer_state] %> + + <%= render 'dradis/plugins/echo/projects/sessions/messages', messages: @messages %> + + <%= render 'dradis/plugins/echo/projects/sessions/composer_state', session: @session %> + + <%= form_with url: echo.project_session_messages_path(current_project, @session), method: :post do |f| %> +
+ <%= + f.text_area :content, + class: 'form-control field-sizing-content', + placeholder: 'Message Roslin…', + rows: 3 + %> +
+ +
+ <%= f.submit 'Send', class: 'btn btn-primary mt-2' %> +
+ <% end %> +
diff --git a/engines/dradis-echo/config/routes.rb b/engines/dradis-echo/config/routes.rb index 7d72c30fc..3800bcf1a 100644 --- a/engines/dradis-echo/config/routes.rb +++ b/engines/dradis-echo/config/routes.rb @@ -4,12 +4,21 @@ resources :providers, except: [:show] resources :projects, only: [] do - resources :interactions, only: [:index, :show, :create], controller: 'projects/interactions' do - get :preview, on: :member - end + scope module: 'projects' do + resources :interactions, only: [:index, :show, :create] do + get :preview, on: :member + end + + resources :grammar_corrections, only: [:create] + resources :grammar_suggestions, only: [:create] - resources :grammar_corrections, only: [:create], controller: 'projects/grammar_corrections' - resources :grammar_suggestions, only: [:create], controller: 'projects/grammar_suggestions' + resources :sessions, only: [:index, :show, :create] do + scope module: 'sessions' do + resource :reply, only: [:create] + resources :messages, only: [:create] + end + end + end end resources :prompts, except: [:show] diff --git a/engines/dradis-echo/spec/channels/dradis/plugins/echo/sessions_channel_spec.rb b/engines/dradis-echo/spec/channels/dradis/plugins/echo/sessions_channel_spec.rb new file mode 100644 index 000000000..b33227e7f --- /dev/null +++ b/engines/dradis-echo/spec/channels/dradis/plugins/echo/sessions_channel_spec.rb @@ -0,0 +1,47 @@ +require 'rails_helper' + +Dir[Dradis::Plugins::Echo::Engine.root.join('spec/factories/*.rb')].sort.each { |f| require f } + +describe Dradis::Plugins::Echo::SessionsChannel, type: :channel do + let(:user) { create(:user) } + let(:agent) { create(:agent) } + let(:session) { create(:echo_session, agent: agent) } + + let(:signed_name) { Turbo::StreamsChannel.signed_stream_name([session, :messages]) } + let(:stream_name) { Turbo::StreamsChannel.verified_stream_name(signed_name) } + + before { stub_connection(current_user: user) } + + it 'accepts the subscription and streams the transcript for an authorized user' do + subscribe(signed_stream_name: signed_name) + + expect(subscription).to be_confirmed + expect(subscription).to have_stream_from(stream_name) + end + + it "rejects a user whose :use on the session's project is denied" do + allow_any_instance_of(Ability).to receive(:can?).and_return(false) + + subscribe(signed_stream_name: signed_name) + + expect(subscription).to be_rejected + end + + # Re-authorizes at subscribe time: the same signed name is accepted while the + # user may :use the project and rejected once that permission is revoked. + it 'flips between accept and reject as authorization changes' do + subscribe(signed_stream_name: signed_name) + expect(subscription).to be_confirmed + + allow_any_instance_of(Ability).to receive(:can?).and_return(false) + + subscribe(signed_stream_name: signed_name) + expect(subscription).to be_rejected + end + + it 'rejects a tampered stream name that fails verification' do + subscribe(signed_stream_name: 'not-a-valid-signed-stream-name') + + expect(subscription).to be_rejected + end +end diff --git a/engines/dradis-echo/spec/controllers/concerns/dradis/plugins/echo/turbo_config_check_spec.rb b/engines/dradis-echo/spec/controllers/concerns/dradis/plugins/echo/turbo_config_check_spec.rb new file mode 100644 index 000000000..1a5173083 --- /dev/null +++ b/engines/dradis-echo/spec/controllers/concerns/dradis/plugins/echo/turbo_config_check_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' + +describe Dradis::Plugins::Echo::TurboConfigCheck do + subject(:controller) do + Class.new { include Dradis::Plugins::Echo::TurboConfigCheck }.new + end + + describe '#check_turbo_config' do + it 'reports healthy without pinging when the adapter is not Redis' do + # A plain double doesn't respond_to :redis_connection_for_subscriptions, + # so the duck-typed guard returns healthy without a ping. (Stubbing the + # method here would make respond_to? true and defeat the check — an + # unexpected call would instead raise, still failing the example.) + adapter = double('non-redis adapter') + allow(ActionCable.server).to receive(:pubsub).and_return(adapter) + + expect(controller.send(:check_turbo_config)).to be(true) + end + + it 'pings and reports healthy for a reachable Redis adapter' do + redis = double('redis', ping: 'PONG') + adapter = ActionCable::SubscriptionAdapter::Redis.allocate + allow(adapter).to receive(:redis_connection_for_subscriptions).and_return(redis) + allow(ActionCable.server).to receive(:pubsub).and_return(adapter) + + expect(controller.send(:check_turbo_config)).to be(true) + end + + it 'reports unhealthy when the Redis ping fails' do + adapter = ActionCable::SubscriptionAdapter::Redis.allocate + allow(adapter).to receive(:redis_connection_for_subscriptions).and_raise(StandardError) + allow(ActionCable.server).to receive(:pubsub).and_return(adapter) + + expect(controller.send(:check_turbo_config)).to be(false) + end + end +end diff --git a/engines/dradis-echo/spec/jobs/dradis/plugins/echo/reply_job_spec.rb b/engines/dradis-echo/spec/jobs/dradis/plugins/echo/reply_job_spec.rb index 7a2cdbf5e..86cc457df 100644 --- a/engines/dradis-echo/spec/jobs/dradis/plugins/echo/reply_job_spec.rb +++ b/engines/dradis-echo/spec/jobs/dradis/plugins/echo/reply_job_spec.rb @@ -26,6 +26,13 @@ def perform allow_any_instance_of(Dradis::Plugins::Echo::Session).to receive(:broadcast_composer_state) end + # Guards against a private method shadowing ActiveJob::Core#serialize, which + # every queue adapter calls when enqueuing — a collision there breaks + # perform_later (and so Session#request_reply!) at runtime. + it 'can be serialized for enqueuing' do + expect { described_class.new(session).serialize }.not_to raise_error + end + describe 'a successful reply' do before { stub_stream('Hello ', 'world') } diff --git a/engines/dradis-echo/spec/models/dradis/plugins/echo/message_spec.rb b/engines/dradis-echo/spec/models/dradis/plugins/echo/message_spec.rb index 6ee3f2c65..cd17dde56 100644 --- a/engines/dradis-echo/spec/models/dradis/plugins/echo/message_spec.rb +++ b/engines/dradis-echo/spec/models/dradis/plugins/echo/message_spec.rb @@ -46,6 +46,20 @@ end end + describe 'role derivation' do + it 'derives the user role from the author when none is given' do + message = build(:echo_message, role: nil) + message.valid? + expect(message.role).to eq('user') + end + + it 'leaves an explicit assistant role untouched' do + message = build(:assistant_message) + message.valid? + expect(message.role).to eq('assistant') + end + end + describe 'metadata' do it 'is stored as JSON' do message = create(:echo_message, metadata: { 'model' => 'qwen2.5:14b' }) diff --git a/engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb b/engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb index b223a339f..bd22b25fb 100644 --- a/engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb +++ b/engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb @@ -171,6 +171,31 @@ end end + describe '#reply_pending?' do + let(:session) { create(:echo_session) } + + it 'is true when idle and the newest message is a user turn' do + create(:echo_message, session: session, role: :user, content: 'Hello') + expect(session).to be_reply_pending + end + + it 'is false once an assistant reply is the newest message' do + create(:echo_message, session: session, role: :user, content: 'Hello') + create(:assistant_message, session: session, content: 'Hi there') + expect(session).not_to be_reply_pending + end + + it 'is false while the session is already generating' do + create(:echo_message, session: session, role: :user, content: 'Hello') + session.update!(status: :generating) + expect(session).not_to be_reply_pending + end + + it 'is false with no messages yet' do + expect(session).not_to be_reply_pending + end + end + describe 'destroying the record' do it 'destroys sessions attached to a destroyed Note' do note = create(:note) diff --git a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/messages_spec.rb b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/messages_spec.rb new file mode 100644 index 000000000..cca74a084 --- /dev/null +++ b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/messages_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' + +Dir[Dradis::Plugins::Echo::Engine.root.join('spec/factories/*.rb')].sort.each { |f| require f } + +describe 'Echo session messages' do + include ActiveJob::TestHelper + + let(:user) { create(:user) } + + before do + login_as_user(user) + @project = Project.new + end + + let(:agent) { create(:agent, enabled: true) } + let(:issue) { create(:issue, node: @project.issue_library, text: "#[Title]#\nSQLi") } + let(:session) { create(:echo_session, agent: agent, record: issue) } + + describe 'POST /addons/echo/projects/:project_id/sessions/:session_id/messages' do + it 'appends a user message and triggers a reply' do + expect do + post "/addons/echo/projects/#{@project.id}/sessions/#{session.id}/messages", + params: { content: 'What is the impact?' } + end.to change { session.messages.count }.by(1) + .and have_enqueued_job(Dradis::Plugins::Echo::ReplyJob) + + expect(response).to have_http_status(:ok) + + message = session.messages.order(:id).last + expect(message.role).to eq('user') + expect(message.user).to eq(user) + expect(message.content).to eq('What is the impact?') + end + + it 'rejects a blank message without enqueuing a reply' do + # params.expect(:content) treats a blank required scalar as missing and + # raises ParameterMissing (a 400 in production, before the reply gate is + # ever reached) rather than persisting an invalid message. + expect do + post "/addons/echo/projects/#{@project.id}/sessions/#{session.id}/messages", + params: { content: '' } + end.to raise_error(ActionController::ParameterMissing) + + expect(session.messages.count).to eq(0) + end + + it 'denies a session whose record is outside the current project scope' do + other_session = create(:echo_session, agent: agent, record: create(:issue, node: create(:node))) + + expect do + post "/addons/echo/projects/#{@project.id}/sessions/#{other_session.id}/messages", + params: { content: 'Hello' } + end.to raise_error(ActiveRecord::RecordNotFound) + end + end +end diff --git a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/replies_spec.rb b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/replies_spec.rb new file mode 100644 index 000000000..f8ed626eb --- /dev/null +++ b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions/replies_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +Dir[Dradis::Plugins::Echo::Engine.root.join('spec/factories/*.rb')].sort.each { |f| require f } + +describe 'Echo session replies' do + include ActiveJob::TestHelper + + let(:user) { create(:user) } + + before do + login_as_user(user) + @project = Project.new + end + + let(:agent) { create(:agent, enabled: true) } + let(:issue) { create(:issue, node: @project.issue_library, text: "#[Title]#\nSQLi") } + let(:session) { create(:echo_session, agent: agent, record: issue) } + + describe 'POST /addons/echo/projects/:project_id/sessions/:session_id/reply' do + it 'enqueues a reply when one is pending (the newest message is a user turn)' do + create(:echo_message, session: session, role: :user, content: 'What is the impact?', user: user) + + expect do + post "/addons/echo/projects/#{@project.id}/sessions/#{session.id}/reply" + end.to have_enqueued_job(Dradis::Plugins::Echo::ReplyJob).with(session) + + expect(response).to have_http_status(:ok) + end + + it 'does not enqueue a reply once the session has already been answered' do + create(:echo_message, session: session, role: :user, content: 'What is the impact?', user: user) + create(:assistant_message, session: session, content: 'It is high.') + + expect do + post "/addons/echo/projects/#{@project.id}/sessions/#{session.id}/reply" + end.not_to have_enqueued_job(Dradis::Plugins::Echo::ReplyJob) + + expect(response).to have_http_status(:ok) + end + + it 'denies a session whose record is outside the current project scope' do + other_session = create(:echo_session, agent: agent, record: create(:issue, node: create(:node))) + + expect do + post "/addons/echo/projects/#{@project.id}/sessions/#{other_session.id}/reply" + end.to raise_error(ActiveRecord::RecordNotFound) + end + end +end diff --git a/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb new file mode 100644 index 000000000..8e2fc8b73 --- /dev/null +++ b/engines/dradis-echo/spec/requests/dradis/plugins/echo/projects/sessions_spec.rb @@ -0,0 +1,133 @@ +require 'rails_helper' + +Dir[Dradis::Plugins::Echo::Engine.root.join('spec/factories/*.rb')].sort.each { |f| require f } + +describe 'Echo sessions' do + include ActiveJob::TestHelper + + let(:user) { create(:user) } + + before do + login_as_user(user) + @project = Project.new + end + + let!(:roslin) do + Dradis::Plugins::Echo::Agents::Roslin.provision!.tap { |agent| agent.update!(enabled: true) } + end + + let(:prompt) do + user.prompts.create!( + title: 'Summarise the finding', + prompt: 'Summarise {{ issue.title }}', + scope: 'issue', + visibility: :user + ) + end + + let(:issue) do + create(:issue, node: @project.issue_library, text: "#[Title]#\nSQLi") + end + + describe 'GET /addons/echo/projects/:project_id/sessions' do + it 'lists the record-scoped sessions and prompts' do + prompt # a prompt must exist for the sessions list to render + session = Dradis::Plugins::Echo::Session.create!( + agent: roslin, record: issue, title: 'Existing session', user: user + ) + + get "/addons/echo/projects/#{@project.id}/sessions", + params: { type: 'issue', record: issue.id } + + expect(response).to have_http_status(:ok) + expect(response.body).to include('Existing session') + expect(response.body).to include( + ActionView::RecordIdentifier.dom_id(session) + ) + end + + it 'honours the Prompt::SCOPES whitelist' do + expect do + get "/addons/echo/projects/#{@project.id}/sessions", + params: { type: 'node', record: issue.id } + end.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'denies a record outside the current project scope' do + other_issue = create(:issue, node: create(:node)) + + expect do + get "/addons/echo/projects/#{@project.id}/sessions", + params: { type: 'issue', record: other_issue.id } + end.to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe 'POST /addons/echo/projects/:project_id/sessions' do + let(:params) do + { type: 'issue', record: issue.id, prompt_id: prompt.id, prompt: 'Summarise the SQLi finding' } + end + + it 'creates a session with the first user message but defers the reply to the subscribed client' do + expect do + post "/addons/echo/projects/#{@project.id}/sessions", params: params + end.to change(Dradis::Plugins::Echo::Session, :count).by(1) + .and change(Dradis::Plugins::Echo::Message, :count).by(1) + + # The reply is triggered by the session Stimulus controller once it has + # subscribed, not by create — otherwise the streaming container broadcasts + # before the socket is listening and never renders live (SEC-506 Bug 4). + expect(Dradis::Plugins::Echo::ReplyJob).not_to have_been_enqueued + + session = Dradis::Plugins::Echo::Session.last + expect(session.title).to eq(prompt.title) + expect(session.user).to eq(user) + expect(session.record).to eq(issue) + expect(session).to be_reply_pending + + message = session.messages.first + expect(message.role).to eq('user') + expect(message.content).to eq('Summarise the SQLi finding') + end + + it 'responds with the session turbo frame' do + post "/addons/echo/projects/#{@project.id}/sessions", params: params + + expect(response).to have_http_status(:ok) + expect(response.body).to include("id=\"#{ActionView::RecordIdentifier.dom_id(issue, :echo)}\"") + end + + it 'copies the title without storing a prompt FK' do + post "/addons/echo/projects/#{@project.id}/sessions", params: params + + session = Dradis::Plugins::Echo::Session.last + expect(session.attributes).not_to have_key('prompt_id') + expect(session.title).to eq(prompt.title) + end + + it 'returns 422 for a blank prompt instead of raising a 500' do + expect do + post "/addons/echo/projects/#{@project.id}/sessions", + params: params.merge(prompt: '') + end.not_to change(Dradis::Plugins::Echo::Session, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'honours the Prompt::SCOPES whitelist' do + expect do + post "/addons/echo/projects/#{@project.id}/sessions", + params: params.merge(type: 'node') + end.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'denies a record outside the current project scope' do + other_issue = create(:issue, node: create(:node)) + + expect do + post "/addons/echo/projects/#{@project.id}/sessions", + params: params.merge(record: other_issue.id) + end.to raise_error(ActiveRecord::RecordNotFound) + end + end +end