Fixes #2634 - Merge not possible with not set, required attributes

This commit is contained in:
Mantas Masalskis 2021-08-20 05:36:30 +02:00
parent c8e2c7473c
commit f3464cc4ac
21 changed files with 1200 additions and 79 deletions

View file

@ -23,6 +23,10 @@
<%- @T(key) %>: <input class="js-boolean" name="screens::<%= screen %>::<%= role %>::<%= key %>" type="checkbox" <% if @params && @params.screens && @params.screens[screen] && @params.screens[screen][role] && @params.screens[screen][role][key] is true: %>checked<% end %> value="true"> <%- @T(key) %>: <input class="js-boolean" name="screens::<%= screen %>::<%= role %>::<%= key %>" type="checkbox" <% if @params && @params.screens && @params.screens[screen] && @params.screens[screen][role] && @params.screens[screen][role][key] is true: %>checked<% end %> value="true">
<% end %> <% end %>
<% end %> <% end %>
<tr>
<td class="settings-list-control-cell">
<td class="settings-list-control-cell small" colspan=2>
<%- @T('Not applicable to: merging, emails, form, Facebook, Telegram, Twitter, SMS') %>
<% end %> <% end %>
</tbody> </tbody>
</table> </table>

View file

@ -55,22 +55,24 @@ class ChannelsSmsController < ApplicationController
def webhook def webhook
raise Exceptions::UnprocessableEntity, 'token param missing' if params['token'].blank? raise Exceptions::UnprocessableEntity, 'token param missing' if params['token'].blank?
channel = nil ApplicationHandleInfo.in_context('sms') do
Channel.where(active: true, area: 'Sms::Account').each do |local_channel| channel = nil
next if local_channel.options[:webhook_token] != params['token'] Channel.where(active: true, area: 'Sms::Account').each do |local_channel|
next if local_channel.options[:webhook_token] != params['token']
channel = local_channel channel = local_channel
end end
if !channel if !channel
render( render(
json: { message: 'channel not found' }, json: { message: 'channel not found' },
status: :not_found status: :not_found
) )
return return
end end
conten_type, content = channel.process(params.permit!.to_h) content_type, content = channel.process(params.permit!.to_h)
send_data content, type: conten_type send_data content, type: content_type
end
end end
private private

View file

@ -89,46 +89,49 @@ class FormController < ApplicationController
) )
end end
ticket = nil
# set current user # set current user
UserInfo.current_user_id = customer.id UserInfo.current_user_id = customer.id
ApplicationHandleInfo.in_context('form') do # rubocop:disable Metrics/BlockLength
group = Group.find_by(id: Setting.get('form_ticket_create_group_id')) group = Group.find_by(id: Setting.get('form_ticket_create_group_id'))
if !group
group = Group.where(active: true).first
if !group if !group
group = Group.first group = Group.where(active: true).first
if !group
group = Group.first
end
end end
end ticket = Ticket.create!(
ticket = Ticket.create!( group_id: group.id,
group_id: group.id, customer_id: customer.id,
customer_id: customer.id, title: params[:title],
title: params[:title],
preferences: {
form: {
remote_ip: request.remote_ip,
fingerprint_md5: Digest::MD5.hexdigest(params[:fingerprint]),
}
}
)
article = Ticket::Article.create!(
ticket_id: ticket.id,
type_id: Ticket::Article::Type.find_by(name: 'web').id,
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
body: params[:body],
subject: params[:title],
internal: false,
)
params[:file]&.each do |file|
Store.add(
object: 'Ticket::Article',
o_id: article.id,
data: file.read,
filename: file.original_filename,
preferences: { preferences: {
'Mime-Type' => file.content_type, form: {
remote_ip: request.remote_ip,
fingerprint_md5: Digest::MD5.hexdigest(params[:fingerprint]),
}
} }
) )
article = Ticket::Article.create!(
ticket_id: ticket.id,
type_id: Ticket::Article::Type.find_by(name: 'web').id,
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
body: params[:body],
subject: params[:title],
internal: false,
)
params[:file]&.each do |file|
Store.add(
object: 'Ticket::Article',
o_id: article.id,
data: file.read,
filename: file.original_filename,
preferences: {
'Mime-Type' => file.content_type,
}
)
end
end end
UserInfo.current_user_id = 1 UserInfo.current_user_id = 1

View file

@ -4,7 +4,7 @@ class ObjectManager::Attribute::Validation < ActiveModel::Validator
include ::Mixin::HasBackends include ::Mixin::HasBackends
def validate(record) def validate(record)
return if validation_unneeded? return if !validation_needed?
@record = record @record = record
sanitize_memory_cache sanitize_memory_cache
@ -20,10 +20,12 @@ class ObjectManager::Attribute::Validation < ActiveModel::Validator
attr_reader :record attr_reader :record
def validation_unneeded? def validation_needed?
return true if Setting.get('import_mode') return false if Setting.get('import_mode')
ApplicationHandleInfo.current != 'application_server' return false if ApplicationHandleInfo.context_without_custom_attributes?
ApplicationHandleInfo.current == 'application_server'
end end
def attributes_unchanged? def attributes_unchanged?

View file

@ -290,7 +290,7 @@ returns
raise Exceptions::UnprocessableEntity, 'Can\'t merge ticket with it self!' if id == target_ticket.id raise Exceptions::UnprocessableEntity, 'Can\'t merge ticket with it self!' if id == target_ticket.id
# update articles # update articles
Transaction.execute do Transaction.execute context: 'merge' do
Ticket::Article.where(ticket_id: id).each(&:touch) Ticket::Article.where(ticket_id: id).each(&:touch)

View file

@ -2,7 +2,7 @@
class Transaction class Transaction
attr_reader :options attr_reader :options
attr_accessor :original_user_id, :original_interface_handle attr_accessor :original_user_id, :original_interface_handle, :original_interface_context
def initialize(options = {}) def initialize(options = {})
@options = options @options = options
@ -32,6 +32,7 @@ class Transaction
reset_user_id_start reset_user_id_start
bulk_import_start bulk_import_start
interface_handle_start interface_handle_start
interface_context_start
end end
def start_transaction def start_transaction
@ -45,6 +46,7 @@ class Transaction
def finish_transaction def finish_transaction
interface_handle_finish interface_handle_finish
interface_context_finish
TransactionDispatcher.commit(options) TransactionDispatcher.commit(options)
PushMessages.finish PushMessages.finish
@ -101,4 +103,22 @@ class Transaction
ApplicationHandleInfo.current = original_interface_handle ApplicationHandleInfo.current = original_interface_handle
end end
def interface_context?
options[:context].present?
end
def interface_context_start
return if !interface_context?
self.original_interface_context = ApplicationHandleInfo.context
ApplicationHandleInfo.context = options[:context]
end
def interface_context_finish
return if !interface_context?
ApplicationHandleInfo.context = original_interface_context
end
end end

View file

@ -1,13 +1,9 @@
# Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/ # Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/
module ApplicationHandleInfo module ApplicationHandleInfo
def self.current # stores current application handler.
Thread.current[:application_handle] || 'unknown' # for example application_server, scheduler, websocket, postmaster...
end thread_mattr_accessor :current
def self.current=(name)
Thread.current[:application_handle] = name
end
def self.postmaster? def self.postmaster?
return false if current.blank? return false if current.blank?
@ -24,4 +20,23 @@ module ApplicationHandleInfo
ensure ensure
self.current = orig self.current = orig
end end
# stores action context
# for example merge, twitter, telegram....
# used to determine if custom attribute validation shall run
thread_mattr_accessor :context
def self.in_context(name)
raise ArgumentError, 'requires a block' if !block_given?
orig = context
self.context = name
yield
ensure
self.context = orig
end
def self.context_without_custom_attributes?
%w[merge twitter telegram facebook form mail sms].include? context.to_s
end
end end

View file

@ -305,7 +305,7 @@ result
ticket = nil ticket = nil
# use transaction # use transaction
Transaction.execute(reset_user_id: true) do Transaction.execute(reset_user_id: true, context: 'facebook') do
existing_article = Ticket::Article.find_by(message_id: post['id']) existing_article = Ticket::Article.find_by(message_id: post['id'])
ticket = if existing_article ticket = if existing_article
existing_article.ticket existing_article.ticket

View file

@ -744,7 +744,7 @@ returns
ticket = nil ticket = nil
# use transaction # use transaction
Transaction.execute(reset_user_id: true) do Transaction.execute(reset_user_id: true, context: 'telegram') do
user = to_user(params) user = to_user(params)
ticket = to_ticket(params, user, group_id, channel) ticket = to_ticket(params, user, group_id, channel)
to_article(params, user, ticket, channel) to_article(params, user, ticket, channel)

View file

@ -450,7 +450,7 @@ class TwitterSync
Rails.logger.debug { 'import tweet' } Rails.logger.debug { 'import tweet' }
ticket = nil ticket = nil
Transaction.execute(reset_user_id: true) do Transaction.execute(reset_user_id: true, context: 'twitter') do
# check if parent exists # check if parent exists
user = to_user(tweet) user = to_user(tweet)

View file

@ -3,6 +3,24 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe ApplicationHandleInfo do RSpec.describe ApplicationHandleInfo do
shared_context 'safe block execution' do |attribute:|
# This `around` block is identical to ApplicationHandleInfo.use.
#
# Q: So why don't we just use it here to DRY things up?
# A: Because that's the method we're trying to test, dummy!
#
# Q: Why can't we do `before { ApplicationHandleInfo.current = 'foo' }` instead?
# A: Because that would change `ApplicationHandleInfo.current` for all subsequent specs.
# (RSpec uses database transactions to keep test environments clean,
# but `ApplicationHandleInfo.current` lives outside of the database.)
around do |example|
original = described_class.send(attribute)
described_class.send("#{attribute}=", 'foo')
example.run
described_class.send("#{attribute}=", original)
end
end
describe '.use' do describe '.use' do
it 'requires a block' do it 'requires a block' do
expect { described_class.use('foo') } expect { described_class.use('foo') }
@ -10,21 +28,7 @@ RSpec.describe ApplicationHandleInfo do
end end
context 'for a given starting ApplicationHandleInfo' do context 'for a given starting ApplicationHandleInfo' do
# This `around` block is identical to ApplicationHandleInfo.use. include_examples 'safe block execution', attribute: :current
#
# Q: So why don't we just use it here to DRY things up?
# A: Because that's the method we're trying to test, dummy!
#
# Q: Why can't we do `before { ApplicationHandleInfo.current = 'foo' }` instead?
# A: Because that would change `ApplicationHandleInfo.current` for all subsequent specs.
# (RSpec uses database transactions to keep test environments clean,
# but `ApplicationHandleInfo.current` lives outside of the database.)
around do |example|
original = described_class.current
described_class.current = 'foo'
example.run
described_class.current = original
end
it 'runs the block using the given ApplicationHandleInfo' do it 'runs the block using the given ApplicationHandleInfo' do
described_class.use('bar') do described_class.use('bar') do
@ -47,4 +51,56 @@ RSpec.describe ApplicationHandleInfo do
end end
end end
end end
describe '.in_context' do
it 'requires a block' do
expect { described_class.use('foo') }
.to raise_error(ArgumentError)
end
context 'for a given starting ApplicationHandleInfo' do
include_examples 'safe block execution', attribute: :context
it 'runs the block using the given ApplicationHandleInfo' do
described_class.in_context('bar') do
expect(described_class.context).to eq('bar')
end
end
it 'resets ApplicationHandleInfo to its original value' do
described_class.in_context('bar') { nil }
expect(described_class.context).to eq('foo')
end
context 'when an error is raised in the given block' do
it 'does not rescue the error, and still resets ApplicationHandleInfo' do
expect { described_class.in_context('bar') { raise } }
.to raise_error(StandardError)
.and not_change(described_class, :context)
end
end
end
end
describe '.context_without_custom_attributes?' do
it 'returns false when set to default context' do
expect(described_class).not_to be_context_without_custom_attributes
end
context 'for a given starting ApplicationHandleInfo' do
include_examples 'safe block execution', attribute: :context
it 'returns true when set to context that does not use custom attributes' do
described_class.context = 'merge'
expect(described_class).to be_context_without_custom_attributes
end
it 'returns true when in .in_context block' do
described_class.in_context(:merge) do
expect(described_class).to be_context_without_custom_attributes
end
end
end
end
end end

View file

@ -10,7 +10,21 @@ RSpec.describe Channel::Driver::Facebook, use_vcr: true, required_envs: %w[FACEB
let!(:channel) { create(:facebook_channel) } let!(:channel) { create(:facebook_channel) }
# This test requires ENV variables to run
# Yes, it runs off VCR cassette
# But it requires following ENV variables to be present:
#
# export FACEBOOK_CUSTOMER_ID=placeholder
# export FACEBOOK_CUSTOMER_FIRSTNAME=placeholder
# export FACEBOOK_CUSTOMER_LASTNAME=placeholder
# export FACEBOOK_PAGE_1_ACCCESS_TOKEN=placeholder
# export FACEBOOK_PAGE_1_ID=123
# export FACEBOOK_PAGE_1_NAME=placeholder
# export FACEBOOK_PAGE_1_POST_ID=placeholder
# export FACEBOOK_PAGE_1_POST_COMMENT_ID=placeholder
#
it 'tests full run' do # rubocop:disable RSpec/MultipleExpectations, RSpec/ExampleLength it 'tests full run' do # rubocop:disable RSpec/MultipleExpectations, RSpec/ExampleLength
allow(ApplicationHandleInfo).to receive('context=')
ExternalCredential.create name: :facebook, credentials: { application_id: ENV['FACEBOOK_APPLICATION_ID'], application_secret: ENV['FACEBOOK_APPLICATION_SECRET'] } ExternalCredential.create name: :facebook, credentials: { application_id: ENV['FACEBOOK_APPLICATION_ID'], application_secret: ENV['FACEBOOK_APPLICATION_SECRET'] }
channel.fetch channel.fetch
@ -50,5 +64,7 @@ RSpec.describe Channel::Driver::Facebook, use_vcr: true, required_envs: %w[FACEB
expect(outbound_article).to be_present expect(outbound_article).to be_present
expect(outbound_article.from).to eq ENV['FACEBOOK_PAGE_1_NAME'] expect(outbound_article.from).to eq ENV['FACEBOOK_PAGE_1_NAME']
expect(outbound_article.ticket.articles.count).to be ticket_initial_count + 1 expect(outbound_article.ticket.articles.count).to be ticket_initial_count + 1
expect(ApplicationHandleInfo).to have_received('context=').with('facebook').at_least(1)
end end
end end

View file

@ -745,6 +745,20 @@ RSpec.describe Channel::Driver::Twitter, required_envs: %w[TWITTER_CONSUMER_KEY
end end
describe '#fetch', use_vcr: :time_sensitive do describe '#fetch', use_vcr: :time_sensitive do
context 'when ApplicationHandleInfo context' do
it 'gets switched to "twitter"' do
allow(ApplicationHandleInfo).to receive('context=')
channel.fetch
expect(ApplicationHandleInfo).to have_received('context=').with('twitter').at_least(1)
end
it 'reverts back to default' do
allow(ApplicationHandleInfo).to receive('context=')
channel.fetch
expect(ApplicationHandleInfo.context).not_to eq 'twitter'
end
end
describe 'rate limiting' do describe 'rate limiting' do
before do before do
allow(Rails.env).to receive(:test?).and_return(false) allow(Rails.env).to receive(:test?).and_return(false)

View file

@ -97,5 +97,41 @@ RSpec.describe ObjectManager::Attribute::Validation, application_handle: 'applic
end end
end end
end end
context 'when custom attribute exists' do
before do
allow(subject).to receive(:attributes_unchanged?) # rubocop:disable RSpec/SubjectStub
end
it 'runs validation in default context' do
ApplicationHandleInfo.in_context(nil) do
subject.validate(record)
end
expect(subject).to have_received(:attributes_unchanged?) # rubocop:disable RSpec/SubjectStub
end
it 'does not run validations in contexts that do not use custom attributes' do
ApplicationHandleInfo.in_context('merge') do
subject.validate(record)
end
expect(subject).not_to have_received(:attributes_unchanged?) # rubocop:disable RSpec/SubjectStub
end
end
end
describe '#validation_needed' do
it 'runs validation in default context' do
ApplicationHandleInfo.in_context(nil) do
expect(subject.send(:validation_needed?)).to be true
end
end
it 'does not run validations in contexts that do not use custom attributes' do
ApplicationHandleInfo.in_context('merge') do
expect(subject.send(:validation_needed?)).to be false
end
end
end end
end end

View file

@ -418,6 +418,21 @@ RSpec.describe Ticket, type: :model do
expect(log).to include(start_with("Another ticket was merged into ticket (#{target_ticket.title})")) expect(log).to include(start_with("Another ticket was merged into ticket (#{target_ticket.title})"))
end end
end end
context 'ApplicationHandleInfo context' do
it 'gets switched to "merge"' do
allow(ApplicationHandleInfo).to receive('context=')
ticket.merge_to(ticket_id: target_ticket.id, user_id: 1)
expect(ApplicationHandleInfo).to have_received('context=').with('merge').at_least(1)
end
it 'reverts back to default' do
allow(ApplicationHandleInfo).to receive('context=')
ticket.merge_to(ticket_id: target_ticket.id, user_id: 1)
expect(ApplicationHandleInfo.context).not_to eq 'merge'
end
end
end end
describe '#perform_changes' do describe '#perform_changes' do

View file

@ -233,5 +233,27 @@ RSpec.describe 'Form', type: :request, searchindex: true do
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:forbidden)
end end
context 'when ApplicationHandleInfo context' do
let(:fingerprint) { SecureRandom.hex(40) }
let(:token) { json_response['token'] }
before do
Setting.set('form_ticket_create', true)
post '/api/v1/form_config', params: { fingerprint: fingerprint }, as: :json
end
it 'gets switched to "form"' do
allow(ApplicationHandleInfo).to receive('context=')
post '/api/v1/form_submit', params: { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'discard@znuny.com', title: 'test-last', body: 'hello' }, as: :json
expect(ApplicationHandleInfo).to have_received('context=').with('form').at_least(1)
end
it 'reverts back to default' do
allow(ApplicationHandleInfo).to receive('context=')
post '/api/v1/form_submit', params: { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'discard@znuny.com', title: 'test-last', body: 'hello' }, as: :json
expect(ApplicationHandleInfo.context).not_to eq 'form'
end
end
end end
end end

View file

@ -398,6 +398,20 @@ RSpec.describe 'Telegram Webhook Integration', type: :request do
expect(ticket2.articles.first.to).to eq('@ChrispressoBot2') expect(ticket2.articles.first.to).to eq('@ChrispressoBot2')
end end
context 'when ApplicationHandleInfo context' do
it 'gets switched to "telegram"' do
allow(ApplicationHandleInfo).to receive('context=')
post callback_url, params: read_message('private', 'text'), as: :json
expect(ApplicationHandleInfo).to have_received('context=').with('telegram').at_least(1)
end
it 'reverts back to default' do
allow(ApplicationHandleInfo).to receive('context=')
post callback_url, params: read_message('private', 'text'), as: :json
expect(ApplicationHandleInfo.context).not_to eq 'telegram'
end
end
end end
def read_message(type, file) def read_message(type, file)

View file

@ -201,6 +201,28 @@ RSpec.describe 'Twilio SMS', type: :request do
expect(customer.id).to eq(User.last.id) expect(customer.id).to eq(User.last.id)
end end
it 'does basic call when ticket has a custom attribute', db_strategy: :reset do
create(:object_manager_attribute_text, screens: attributes_for(:required_screen))
ObjectManager::Attribute.migration_execute
UserInfo.current_user_id = 1
create(
:channel,
area: 'Sms::Account',
options: {
adapter: 'sms/twilio',
webhook_token: 'f409460e50f76d331fdac8ba7b7963b6',
account_id: '111',
token: '223',
sender: '333',
},
group_id: Group.first.id,
)
post '/api/v1/sms_webhook/f409460e50f76d331fdac8ba7b7963b6', params: read_message('inbound_sms1'), as: :json
expect(response).to have_http_status(:ok)
end
def read_message(file) def read_message(file)
JSON.parse(File.read(Rails.root.join('test', 'data', 'twilio', "#{file}.json"))) JSON.parse(File.read(Rails.root.join('test', 'data', 'twilio', "#{file}.json")))
end end

View file

@ -6,4 +6,10 @@ RSpec.configure do |config|
example.run example.run
end end
end end
config.around(:each, :application_handle_context) do |example|
ApplicationHandleInfo.in_context(example.metadata[:application_handle_context]) do
example.run
end
end
end end