discourse-ai/spec/requests/ai_bot/shared_ai_conversations_spec.rb
Sam 13840f68b3
FEATURE: restrict public sharing on login required sites (#649)
Initial implementation allowed internet wide sharing of
AI conversations, on sites that require login.

This feature can be an anti feature for private sites cause they
can not share conversations internally.

For now we are removing support for public sharing on login required
sites, if the community need the feature we can consider adding a
setting.
2024-05-29 11:04:47 +10:00

281 lines
11 KiB
Ruby

# frozen_string_literal: true
require "rails_helper"
RSpec.describe DiscourseAi::AiBot::SharedAiConversationsController do
before do
SiteSetting.discourse_ai_enabled = true
SiteSetting.ai_bot_enabled_chat_bots = "claude-2"
SiteSetting.ai_bot_enabled = true
SiteSetting.ai_bot_allowed_groups = "10"
SiteSetting.ai_bot_public_sharing_allowed_groups = "10"
end
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:topic)
fab!(:pm) { Fabricate(:private_message_topic) }
fab!(:user_pm) { Fabricate(:private_message_topic, recipient: user) }
fab!(:bot_user) do
SiteSetting.discourse_ai_enabled = true
SiteSetting.ai_bot_enabled_chat_bots = "claude-2"
SiteSetting.ai_bot_enabled = true
SiteSetting.ai_bot_allowed_groups = "10"
SiteSetting.ai_bot_public_sharing_allowed_groups = "10"
User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID)
end
fab!(:user_pm_share) do
pm_topic = Fabricate(:private_message_topic, user: user, recipient: bot_user)
# a different unknown user
Fabricate(:post, topic: pm_topic, user: user)
Fabricate(:post, topic: pm_topic, user: bot_user)
Fabricate(:post, topic: pm_topic, user: user)
pm_topic
end
let(:path) { "/discourse-ai/ai-bot/shared-ai-conversations" }
let(:shared_conversation) { SharedAiConversation.share_conversation(user, user_pm_share) }
def share_error(key)
I18n.t("discourse_ai.share_ai.errors.#{key}")
end
describe "POST create" do
context "when logged in" do
before { sign_in(user) }
it "denies creating a new shared conversation on public topics" do
post "#{path}.json", params: { topic_id: topic.id }
expect(response).not_to have_http_status(:success)
expect(response.parsed_body["errors"]).to eq([share_error(:not_allowed)])
expect(response.parsed_body["errors"].to_s).not_to include("Translation missing")
end
it "denies creating a new shared conversation for a random PM" do
post "#{path}.json", params: { topic_id: pm.id }
expect(response).not_to have_http_status(:success)
expect(response.parsed_body["errors"]).to eq([share_error(:not_allowed)])
expect(response.parsed_body["errors"].to_s).not_to include("Translation missing")
end
it "denies creating a shared conversation for my PMs not with bots" do
post "#{path}.json", params: { topic_id: user_pm.id }
expect(response).not_to have_http_status(:success)
expect(response.parsed_body["errors"]).to eq([share_error(:other_people_in_pm)])
expect(response.parsed_body["errors"].to_s).not_to include("Translation missing")
end
it "denies creating a shared conversation for my PMs with bots that also have other users" do
pm_topic = Fabricate(:private_message_topic, user: user, recipient: bot_user)
# a different unknown user
Fabricate(:post, topic: pm_topic)
post "#{path}.json", params: { topic_id: pm_topic.id }
expect(response).not_to have_http_status(:success)
expect(response.parsed_body["errors"]).to eq([share_error(:other_content_in_pm)])
expect(response.parsed_body["errors"].to_s).not_to include("Translation missing")
end
it "allows creating a shared conversation for my PMs with bots only" do
post "#{path}.json", params: { topic_id: user_pm_share.id }
expect(response).to have_http_status(:success)
end
context "when secure uploads are enabled" do
let(:upload_1) { Fabricate(:s3_image_upload, user: bot_user, secure: true) }
let(:upload_2) { Fabricate(:s3_image_upload, user: bot_user, secure: true) }
let(:post_with_upload_1) { Fabricate(:post, topic: user_pm_share, user: bot_user) }
let(:post_with_upload_2) { Fabricate(:post, topic: user_pm_share, user: bot_user) }
before do
enable_secure_uploads
stub_s3_store
SiteSetting.secure_uploads_pm_only = true
FileStore::S3Store.any_instance.stubs(:update_upload_ACL).returns(true)
Jobs.run_immediately!
upload_1.update!(
access_control_post: post_with_upload_1,
sha1: SecureRandom.hex(20),
original_sha1: upload_1.sha1,
)
upload_2.update!(
access_control_post: post_with_upload_2,
sha1: SecureRandom.hex(20),
original_sha1: upload_2.sha1,
)
post_with_upload_1.update!(
raw: "This is a post with a cool AI generated picture ![wow](#{upload_1.short_url})",
)
post_with_upload_2.update!(
raw:
"Another post that has been birthed by AI with a picture ![meow](#{upload_2.short_url})",
)
end
it "marks all of those uploads as not secure when sharing the topic" do
post "#{path}.json", params: { topic_id: user_pm_share.id }
expect(response).to have_http_status(:success)
expect(upload_1.reload.secure).to eq(false)
expect(upload_2.reload.secure).to eq(false)
end
it "rebakes any posts in the topic with uploads attached when sharing the topic so image urls become non-secure" do
post_1_cooked = post_with_upload_1.cooked
post_2_cooked = post_with_upload_2.cooked
post "#{path}.json", params: { topic_id: user_pm_share.id }
expect(response).to have_http_status(:success)
expect(post_with_upload_1.reload.cooked).not_to eq(post_1_cooked)
expect(post_with_upload_1.reload.cooked).not_to include("secure-uploads")
expect(post_with_upload_2.reload.cooked).not_to eq(post_2_cooked)
expect(post_with_upload_2.reload.cooked).not_to include("secure-uploads")
end
end
end
context "when not logged in" do
it "requires login" do
post "#{path}.json", params: { topic_id: topic.id }
expect(response).not_to have_http_status(:success)
end
end
end
describe "DELETE destroy" do
context "when logged in" do
before { sign_in(user) }
it "deletes the shared conversation" do
delete "#{path}/#{shared_conversation.share_key}.json"
expect(response).to have_http_status(:success)
expect(SharedAiConversation.exists?(shared_conversation.id)).to be_falsey
end
it "returns an error if the shared conversation is not found" do
delete "#{path}/123.json"
expect(response).not_to have_http_status(:success)
end
context "when secure uploads are enabled" do
let(:upload_1) { Fabricate(:s3_image_upload, user: bot_user, secure: false) }
let(:upload_2) { Fabricate(:s3_image_upload, user: bot_user, secure: false) }
before do
enable_secure_uploads
stub_s3_store
SiteSetting.secure_uploads_pm_only = true
FileStore::S3Store.any_instance.stubs(:update_upload_ACL).returns(true)
Jobs.run_immediately!
upload_1.update!(
access_control_post: shared_conversation.target.posts.first,
sha1: SecureRandom.hex(20),
original_sha1: upload_1.sha1,
)
upload_2.update!(
access_control_post: shared_conversation.target.posts.second,
sha1: SecureRandom.hex(20),
original_sha1: upload_2.sha1,
)
shared_conversation.target.posts.first.update!(
raw: "This is a post with a cool AI generated picture ![wow](#{upload_1.short_url})",
)
shared_conversation.target.posts.second.update!(
raw:
"Another post that has been birthed by AI with a picture ![meow](#{upload_2.short_url})",
)
end
it "marks all uploads in the PM back as secure when unsharing the conversation" do
delete "#{path}/#{shared_conversation.share_key}.json"
expect(response).to have_http_status(:success)
expect(upload_1.reload.secure).to eq(true)
expect(upload_2.reload.secure).to eq(true)
end
it "rebakes any posts in the topic with uploads attached when sharing the topic so image urls become secure" do
post_1_cooked = shared_conversation.target.posts.first.cooked
post_2_cooked = shared_conversation.target.posts.second.cooked
delete "#{path}/#{shared_conversation.share_key}.json"
expect(response).to have_http_status(:success)
expect(shared_conversation.target.posts.first.reload.cooked).not_to eq(post_1_cooked)
expect(shared_conversation.target.posts.first.reload.cooked).to include("secure-uploads")
expect(shared_conversation.target.posts.second.reload.cooked).not_to eq(post_2_cooked)
expect(shared_conversation.target.posts.second.reload.cooked).to include("secure-uploads")
end
end
end
context "when not logged in" do
it "requires login" do
delete "#{path}/#{shared_conversation.share_key}.json"
expect(response).not_to have_http_status(:success)
end
end
end
describe "GET preview" do
it "denies preview from logged out users" do
get "#{path}/preview/#{user_pm_share.id}.json"
expect(response).not_to have_http_status(:success)
end
context "when logged in" do
before { sign_in(user) }
it "renders the shared conversation" do
get "#{path}/preview/#{user_pm_share.id}.json"
expect(response).to have_http_status(:success)
expect(response.parsed_body["llm_name"]).to eq("Claude-2")
expect(response.parsed_body["error"]).to eq(nil)
expect(response.parsed_body["share_key"]).to eq(nil)
expect(response.parsed_body["context"].length).to eq(3)
shared_conversation
get "#{path}/preview/#{user_pm_share.id}.json"
expect(response).to have_http_status(:success)
expect(response.parsed_body["share_key"]).to eq(shared_conversation.share_key)
SiteSetting.ai_bot_public_sharing_allowed_groups = ""
get "#{path}/preview/#{user_pm_share.id}.json"
expect(response).not_to have_http_status(:success)
end
end
end
describe "GET show" do
it "redirects to home page if site require login" do
SiteSetting.login_required = true
get "#{path}/#{shared_conversation.share_key}"
expect(response).to redirect_to("/login")
end
it "renders the shared conversation" do
get "#{path}/#{shared_conversation.share_key}"
expect(response).to have_http_status(:success)
expect(response.headers["Cache-Control"]).to eq("max-age=60, public")
expect(response.headers["X-Robots-Tag"]).to eq("noindex")
expect(response.body).not_to include("Translation missing")
end
it "is also able to render in json format" do
get "#{path}/#{shared_conversation.share_key}.json"
expect(response.parsed_body["llm_name"]).to eq("Claude-2")
expect(response.headers["X-Robots-Tag"]).to eq("noindex")
end
it "returns an error if the shared conversation is not found" do
get "#{path}/123"
expect(response).to have_http_status(:not_found)
end
end
end