# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ require 'rails_helper' RSpec.describe Gql::Mutations::Ticket::Create, :aggregate_failures, type: :graphql do let(:query) do <<~QUERY mutation ticketCreate($input: TicketCreateInput!) { ticketCreate(input: $input) { ticket { id title group { name } priority { name } customer { fullname } owner { fullname } objectAttributeValues { attribute { name } value } tags } errors { message field } } } QUERY end let(:agent) { create(:agent, groups: [ Group.find_by(name: 'Users')]) } let(:customer) { create(:customer) } let(:user) { agent } let(:group) { agent.groups.first } let(:priority) { Ticket::Priority.last } let(:article_payload) { nil } let(:input_base_payload) do { title: 'Ticket Create Mutation Test', groupId: gql.id(group), priorityId: gql.id(priority), customer: { id: gql.id(customer) }, ownerId: gql.id(agent), tags: %w[foo bar], article: article_payload # pending_time: 10.minutes.from_now, # type: ... } end let(:input_payload) { input_base_payload } let(:variables) { { input: input_payload } } let(:expected_base_response) do { 'id' => gql.id(Ticket.last), 'title' => 'Ticket Create Mutation Test', 'owner' => { 'fullname' => agent.fullname }, 'group' => { 'name' => agent.groups.first.name }, 'customer' => { 'fullname' => customer.fullname }, 'priority' => { 'name' => Ticket::Priority.last.name }, 'tags' => %w[foo bar], 'objectAttributeValues' => [], } end let(:expected_response) do expected_base_response end def it_creates_ticket(articles: 0, stores: 0) expect { gql.execute(query, variables: variables) } .to change(Ticket, :count).by(1) .and change(Ticket::Article, :count).by(articles) .and change(Store, :count).by(stores) end def it_fails_to_create_ticket expect { gql.execute(query, variables: variables) } .not_to change(Ticket, :count) end context 'when creating a new ticket' do context 'with an agent', authenticated_as: :agent do it 'creates Ticket record' do it_creates_ticket expect(gql.result.data[:ticket]).to eq(expected_response) end context 'without title' do let(:input_payload) { input_base_payload.tap { |h| h[:title] = ' ' } } it 'fails validation' do it_fails_to_create_ticket expect(gql.result.error_message).to include('Variable $input of type TicketCreateInput! was provided invalid value for title') end end context 'with custom object_attribute', db_strategy: :reset do let(:object_attribute) do screens = { create: { 'admin.organization': { shown: true, required: false } } } create(:object_manager_attribute_text, object_name: 'Ticket', screens: screens).tap do |_oa| ObjectManager::Attribute.migration_execute end end let(:input_payload) do input_base_payload.merge( { objectAttributeValues: [ { name: object_attribute.name, value: 'object_attribute_value' } ] } ) end let(:expected_response) do expected_base_response.merge( { 'objectAttributeValues' => [{ 'attribute' => { 'name'=>object_attribute.name }, 'value' => 'object_attribute_value' }] } ) end it 'creates the ticket' do it_creates_ticket expect(gql.result.data[:ticket]).to eq(expected_response) end end context 'with links' do let!(:other_ticket) { create(:ticket, group: agent.groups.first) } let(:links) do [ { linkObjectId: gql.id(other_ticket), linkType: 'child' }, { linkObjectId: gql.id(other_ticket), linkType: 'normal' }, ] end let(:input_payload) { input_base_payload.merge(links:) } it 'creates the ticket and adds links' do it_creates_ticket expect(Link.list(link_object: 'Ticket', link_object_value: Ticket.last.id)).to contain_exactly( { 'link_object' => 'Ticket', 'link_object_value' => other_ticket.id, 'link_type' => 'parent' }, { 'link_object' => 'Ticket', 'link_object_value' => other_ticket.id, 'link_type' => 'normal' }, ) end end context 'with issue tracker links' do let(:github_link) { 'https://github.com/issue/123' } let(:input_payload) { input_base_payload.merge(externalReferences: { github: [github_link] }) } before { Setting.set('github_integration', true) } it 'creates the ticket and adds issue trackeer links' do it_creates_ticket expect(Ticket.last.preferences) .to include('github' => include('issue_links' => contain_exactly(github_link))) end end context 'when customer is provided as an email address' do let(:email_address) { Faker::Internet.email } let(:input_payload) { input_base_payload.merge(customer: { email: email_address }) } context 'with valid email address' do it 'creates the ticket and a new customer' do it_creates_ticket expect(User.find_by(email: email_address)).to be_present expect(gql.result.data[:ticket][:customer][:fullname]).to eq(User.find_by(email: email_address).fullname) end end context 'with invalid email address' do let(:email_address) { 'invalid-email' } it 'fails to create the ticket' do it_fails_to_create_ticket expect(gql.result.error_message).to include('The email address is invalid.') end end context 'with valid email address of an existing customer' do let(:email_address) { customer.email } it 'creates the ticket' do it_creates_ticket expect(gql.result.data[:ticket][:customer][:fullname]).to eq(customer.fullname) end end end context 'when creating the ticket in a group with only :create permission' do let(:group) { create(:group) } let(:owner) { create(:agent, groups: [group]) } let(:input_payload) { input_base_payload.merge(ownerId: gql.id(owner)) } before do user.groups << group user.group_names_access_map = { user.groups.first.name => ['full'], group.name => ['create'] } end it 'creates the ticket in the correct group, but returns an error trying to access the new ticket' do expect { gql.execute(query, variables: variables) }.to change(Ticket, :count).by(1) expect(Ticket.last.group.id).to eq(group.id) expect(gql.result.payload['data']['ticketCreate']).to eq({ 'ticket' => nil, 'errors' => nil }) # Mutation did run, but data retrieval was not authorized. expect(gql.result.payload['errors'].first['message']).to eq('Access forbidden by Gql::Types::TicketType') expect(gql.result.payload['errors'].first['extensions']['type']).to eq('Exceptions::Forbidden') end end context 'when creating the ticket in a group without email address' do let(:group) { create(:group, email_address: nil) } let(:agent) { create(:agent, groups: [group]) } let(:article_payload) { { body: 'dummy', type: 'email' } } let(:input_payload) { input_base_payload.merge(groupId: gql.id(group)) } it 'fails to create the ticket' do it_fails_to_create_ticket expect(gql.result.payload['data']['ticketCreate']).to eq( { 'ticket' => nil, 'errors' => [ { 'message' => 'This group has no email address configured for outgoing communication.', 'field' => 'group_id' } ] } ) end end context 'with no permission to the group' do let(:group) { create(:group) } it 'raises an error', :aggregate_failures do it_fails_to_create_ticket expect(gql.result.error_type).to eq(Exceptions::Forbidden) expect(gql.result.error_message).to eq('Access forbidden by Gql::Types::GroupType') end end context 'with article' do before do Group.find(agent.groups.first.id).update(email_address: create(:email_address)) end context 'with inline attachments' do let(:body) do <<~BODY This is a test article with inline attachments. BODY end let(:article_payload) do { body: body, contentType: 'text/html', } end it 'creates a new ticket + a new article with inline attachments' do it_creates_ticket(articles: 1, stores: 1) expect(Store.last.filename).to eq('image1.png') end end context 'with attachments' do let(:article_payload) do form_id = SecureRandom.uuid file_name = 'file1.txt' file_type = 'text/plain' file_content = Base64.strict_encode64('file1') UploadCache.new(form_id).tap do |cache| cache.add( data: file_content, filename: file_name, preferences: { 'Content-Type' => file_type }, created_by_id: agent.id ) end { body: 'dummy', contentType: 'text/html', attachments: { formId: form_id, files: [ { name: file_name, type: file_type, content: file_content, }, ], }, } end it 'creates a new ticket + a new article with attachments' do it_creates_ticket(articles: 1, stores: 1) expect(Store.last.filename).to eq('file1.txt') end end context 'with inline attachments + attachments' do let(:body) do <<~BODY This is a test article with inline attachments. BODY end let(:article_payload) do form_id = SecureRandom.uuid file_name = 'file1.txt' file_type = 'text/plain' file_content = Base64.strict_encode64('file1') UploadCache.new(form_id).tap do |cache| cache.add( data: file_content, filename: file_name, preferences: { 'Content-Type' => file_type }, created_by_id: agent.id ) end { body: body, contentType: 'text/html', attachments: { formId: form_id, files: [ { name: file_name, type: file_type, content: file_content, }, ], }, } end it 'creates a new ticket + a new article with inline attachments + attachments' do it_creates_ticket(articles: 1, stores: 2) expect(Store.last.filename).to eq('image1.png') end end context 'with a specific sender' do let(:article_payload) do { body: 'dummy', sender: 'Agent', } end it 'creates a new ticket + a new article with a specific sender' do it_creates_ticket(articles: 1) expect(Ticket.last.articles.last.sender.name).to eq('Agent') end it 'sets correct "to" and "from" values', :aggregate_failures do it_creates_ticket(articles: 1) expect(Ticket.last.articles.last) .to have_attributes( from: agent.fullname, to: "#{customer.fullname} <#{customer.email}>" ) end end context 'with no type' do let(:article_payload) do { body: 'dummy', } end it 'creates a new ticket + a new article, but falls back to type "note"' do it_creates_ticket(articles: 1) expect(Ticket.last.articles.last.type.name).to eq('note') end end context 'with a specific type' do let(:article_payload) do { body: 'dummy', type: Ticket::Article::Type.first.name, to: 'dummy@example.org', } end it 'creates a new ticket + a new article with a specific type' do it_creates_ticket(articles: 1) expect(Ticket.last.articles.last.type.name).to eq(Ticket::Article::Type.first.name) end context 'with all integrations disabled' do let(:article_payload) do { body: 'dummy', to: ['to@example.com'], type: 'email', security: { method: 'SMIME', options: %w[encryption sign] } } end before do Setting.set('smime_integration', false) Setting.set('pgp_integration', false) end it 'doesn\'t set security if security integrations are not enabled', :aggregate_failures do it_creates_ticket(articles: 1) expect(Ticket.last.articles.last.preferences[:security]).to be_nil end end context 'with smime enabled' do let(:article_payload) do { body: 'dummy', to: ['to@example.com'], type: 'email', security: { method: 'SMIME', options: %w[encryption sign] } } end before do Setting.set('smime_integration', true) Setting.set('pgp_integration', false) end it 'creates a new ticket with correct security preferences', :aggregate_failures do it_creates_ticket(articles: 1) expect(Ticket.last.articles.last.preferences[:security]).to eq( 'type' => 'S/MIME', 'encryption' => { 'success' => true }, 'sign' => { 'success' => true }, ) end end context 'with pgp enabled' do let(:article_payload) do { body: 'dummy', to: ['to@example.com'], type: 'email', security: { method: 'PGP', options: %w[encryption sign] } } end before do Setting.set('smime_integration', false) Setting.set('pgp_integration', true) end it 'creates a new ticket with correct security preferences', :aggregate_failures do it_creates_ticket(articles: 1) expect(Ticket.last.articles.last.preferences[:security]).to eq( 'type' => 'PGP', 'encryption' => { 'success' => true }, 'sign' => { 'success' => true }, ) end end end end context 'with to: and cc: being string values' do let(:article_payload) do { body: 'dummy', to: 'to@example.com', cc: 'cc@example.com', } end it 'creates a new ticket + a new article and sets correct "to" and "cc" values', :aggregate_failures do it_creates_ticket(articles: 1) expect(Ticket.last.articles.last).to have_attributes(to: 'to@example.com', cc: 'cc@example.com') end end context 'with to: and cc: containing array values' do let(:article_payload) do { body: 'dummy', to: ['to@example.com', 'to2@example.com'], cc: ['cc@example.com', 'cc2@example.com'], } end it 'creates a new ticket + a new article and sets correct "to" and "cc" values', :aggregate_failures do it_creates_ticket(articles: 1) expect(Ticket.last.articles.last).to have_attributes(to: 'to@example.com, to2@example.com', cc: 'cc@example.com, cc2@example.com') end end context 'with a shared draft' do let(:shared_draft) { create(:ticket_shared_draft_start, group:) } let(:input_payload) do input_base_payload .merge(sharedDraftId: Gql::ZammadSchema.id_from_object(shared_draft)) end it 'passed to ticket create service' do expect_any_instance_of(Service::Ticket::Create) .to receive(:execute) .with(ticket_data: include(shared_draft:)) .and_call_original gql.execute(query, variables: variables) end end end context 'with a customer', authenticated_as: :customer do let(:input_payload) { input_base_payload.tap { |h| h.delete(:customer) } } let(:expected_response) do expected_base_response.merge( { 'owner' => { 'fullname' => nil }, 'priority' => { 'name' => Ticket::Priority.where(default_create: true).first.name }, 'tags' => nil } ) end it 'creates the ticket with filtered values' do it_creates_ticket expect(gql.result.data[:ticket]).to eq(expected_response) end context 'when sending a different customerId' do let(:input_payload) { input_base_payload.tap { |h| h[:customer][:id] = gql.id(create(:customer)) } } it 'fails creating a ticket with permission exception' do it_fails_to_create_ticket expect(gql.result.error_type).to eq(Exceptions::Forbidden) expect(gql.result.error_message).to eq('Access forbidden by Gql::Types::UserType') end end context 'with links' do let!(:other_ticket) { create(:ticket, customer: customer) } let(:links) do [ { linkObjectId: gql.id(other_ticket), linkType: 'child' }, { linkObjectId: gql.id(other_ticket), linkType: 'normal' }, ] end let(:input_payload) { input_base_payload.merge(links:) } it 'creates the ticket without links' do it_creates_ticket expect(Link.list(link_object: 'Ticket', link_object_value: Ticket.last.id)).to eq([]) end end context 'with issue tracker links' do let(:github_link) { 'https://github.com/issue/123' } let(:input_payload) { input_base_payload.merge(externalReferences: { github: [github_link] }) } before { Setting.set('github_integration', true) } it 'creates the ticket and adds issue tracker links' do it_creates_ticket expect(Ticket.last.preferences).not_to include('github') end end context 'with article' do context 'with a forbidden sender' do let(:article_payload) do { body: 'dummy', sender: 'Agent', } end it 'creates a new ticket + a new article, but falls back to "Customer" as sender' do it_creates_ticket(articles: 1) expect(Ticket.last.articles.last.sender.name).to eq('Customer') end end context 'with type "phone"' do let(:article_payload) do { body: 'dummy', type: 'phone', } end it 'creates a new ticket + a new article, but falls back to "note" as type' do it_creates_ticket(articles: 1) expect(Ticket.last.articles.last.type.name).to eq('note') end it 'sets correct "to" and "from" values', :aggregate_failures do it_creates_ticket(articles: 1) expect(Ticket.last.articles.last) .to have_attributes( to: Ticket.last.group.name, from: customer.fullname ) end end context 'with an article flagged as internal' do let(:article_payload) do { body: 'dummy', internal: true, } end it 'creates a new ticket + a new article, but flags it as not internal' do it_creates_ticket(articles: 1) expect(Ticket.last.articles.last.internal).to be(false) end end end end context 'with an agent that has a specific role limited to create/update permission', authenticated_as: :user do let(:user) { create(:user, roles: [api_role]) } let(:api_role) do role = create(:role, name: 'API', permission_names: ['ticket.agent']) role.group_names_access_map = { Group.first.name => %w[create], } role end let(:input_payload) do { title: 'Test title for issue #4647', groupId: gql.id(Group.first), customer: { id: gql.id(customer) }, article: article_payload, } end let(:article_payload) do { type: 'web', internal: false, sender: 'Customer', subject: 'Test subject', body: SecureRandom.uuid, } end before { Trigger.destroy_all } # triggers may cause additional articles to be created it 'contains correct "origin_by" + "from" information' do gql.execute(query, variables: variables) expect(Ticket.last.articles.last).to have_attributes( origin_by_id: customer.id, from: "#{customer.fullname} <#{customer.email}>", ) end end end end