diff --git a/app/graphql/types/work_items/email_participant_type.rb b/app/graphql/types/work_items/email_participant_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..73f9ce9c934f4e147f67cc0ad9c615b47c38851b --- /dev/null +++ b/app/graphql/types/work_items/email_participant_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module WorkItems + class EmailParticipantType < BaseObject + graphql_name 'EmailParticipantType' + + # Don't use read_external_emails here, because we obfuscate emails instead. + authorize :read_work_item + + present_using IssueEmailParticipantPresenter + + field :email, GraphQL::Types::String, + description: 'Email address of the email participant. For guests, the email address is obfuscated.', null: false + end + end +end diff --git a/app/graphql/types/work_items/widgets/email_participants_type.rb b/app/graphql/types/work_items/widgets/email_participants_type.rb index d6ee2cfd9ba0abbc76ae47a516c1ce4992969156..1ee532d612e56b1792c2f715829d1b699bf12e33 100644 --- a/app/graphql/types/work_items/widgets/email_participants_type.rb +++ b/app/graphql/types/work_items/widgets/email_participants_type.rb @@ -13,10 +13,10 @@ class EmailParticipantsType < BaseObject implements Types::WorkItems::WidgetInterface field :email_participants, - [GraphQL::Types::String], + Types::WorkItems::EmailParticipantType.connection_type, null: true, description: 'Collection of email participants associated with the work item.', - method: :email_participants_emails + method: :issue_email_participants end # rubocop:enable Graphql/AuthorizeTypes end diff --git a/app/models/work_items/widgets/email_participants.rb b/app/models/work_items/widgets/email_participants.rb index 0a0147276c59443d625ddd636b5946bdb5f1000c..37f385d8100e207bb5c94af6728308b332201f08 100644 --- a/app/models/work_items/widgets/email_participants.rb +++ b/app/models/work_items/widgets/email_participants.rb @@ -3,7 +3,7 @@ module WorkItems module Widgets class EmailParticipants < Base - delegate :email_participants_emails, to: :work_item + delegate :issue_email_participants, to: :work_item def self.quick_action_commands [:add_email, :remove_email] diff --git a/app/policies/issue_email_participant_policy.rb b/app/policies/issue_email_participant_policy.rb new file mode 100644 index 0000000000000000000000000000000000000000..15bd2ce3d0c444a5147ff2965e8afefcff88fc32 --- /dev/null +++ b/app/policies/issue_email_participant_policy.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Model is not in a product domain namespace. +class IssueEmailParticipantPolicy < BasePolicy # rubocop:disable Gitlab/BoundedContexts, Gitlab/NamespacedClass -- reason above + delegate { @subject.issue } +end diff --git a/app/presenters/issue_email_participant_presenter.rb b/app/presenters/issue_email_participant_presenter.rb index 8688b9a2af10a2ed8fc1a566c2b42078276e3c94..d6d79e4ccde3f621dac031f9d3d7634312515d92 100644 --- a/app/presenters/issue_email_participant_presenter.rb +++ b/app/presenters/issue_email_participant_presenter.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -class IssueEmailParticipantPresenter < Gitlab::View::Presenter::Delegated +# Model is not in a product domain namespace. +class IssueEmailParticipantPresenter < Gitlab::View::Presenter::Delegated # rubocop:disable Gitlab/NamespacedClass -- reason above presents ::IssueEmailParticipant, as: :participant delegator_override :email diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 75bdd491f001e0fc0027831c90bb9a00d89e7e60..15efe808e9ee34aa7b7afbc1b8cfa9e3f87d1cc3 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -13515,6 +13515,29 @@ The edge type for [`Email`](#email). | <a id="emailedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | | <a id="emailedgenode"></a>`node` | [`Email`](#email) | The item at the end of the edge. | +#### `EmailParticipantTypeConnection` + +The connection type for [`EmailParticipantType`](#emailparticipanttype). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="emailparticipanttypeconnectionedges"></a>`edges` | [`[EmailParticipantTypeEdge]`](#emailparticipanttypeedge) | A list of edges. | +| <a id="emailparticipanttypeconnectionnodes"></a>`nodes` | [`[EmailParticipantType]`](#emailparticipanttype) | A list of nodes. | +| <a id="emailparticipanttypeconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +#### `EmailParticipantTypeEdge` + +The edge type for [`EmailParticipantType`](#emailparticipanttype). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="emailparticipanttypeedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | +| <a id="emailparticipanttypeedgenode"></a>`node` | [`EmailParticipantType`](#emailparticipanttype) | The item at the end of the edge. | + #### `EnvironmentConnection` The connection type for [`Environment`](#environment). @@ -22446,6 +22469,14 @@ Events that describe the history and progress of a Duo Workflow. | <a id="emailid"></a>`id` | [`ID!`](#id) | Internal ID of the email. | | <a id="emailupdatedat"></a>`updatedAt` | [`Time!`](#time) | Timestamp the email was last updated. | +### `EmailParticipantType` + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="emailparticipanttypeemail"></a>`email` | [`String!`](#string) | Email address of the email participant. For guests, the email address is obfuscated. | + ### `Environment` Describes where code is deployed for a project. @@ -36202,7 +36233,7 @@ Represents email participants widget. | Name | Type | Description | | ---- | ---- | ----------- | -| <a id="workitemwidgetemailparticipantsemailparticipants"></a>`emailParticipants` | [`[String!]`](#string) | Collection of email participants associated with the work item. | +| <a id="workitemwidgetemailparticipantsemailparticipants"></a>`emailParticipants` | [`EmailParticipantTypeConnection`](#emailparticipanttypeconnection) | Collection of email participants associated with the work item. (see [Connections](#connections)) | | <a id="workitemwidgetemailparticipantstype"></a>`type` | [`WorkItemWidgetType`](#workitemwidgettype) | Widget type. | ### `WorkItemWidgetHealthStatus` diff --git a/spec/factories/notes/notes_metadata.rb b/spec/factories/notes/notes_metadata.rb index 555debbc0e55f4a1941e39d84c63e0243e903987..bb03e8870a17bdc5c25c58529a8dc629018fbe0c 100644 --- a/spec/factories/notes/notes_metadata.rb +++ b/spec/factories/notes/notes_metadata.rb @@ -3,6 +3,6 @@ FactoryBot.define do factory :note_metadata, class: 'Notes::NoteMetadata' do note - email_participant { 'email@example.com' } + email_participant { 'user@example.com' } end end diff --git a/spec/graphql/types/notes/note_type_spec.rb b/spec/graphql/types/notes/note_type_spec.rb index 863d8a074f0aaa77f392ce2c3f31f2def149786e..5e782626d76918ed8371d7c6c2db662c2e3df6b5 100644 --- a/spec/graphql/types/notes/note_type_spec.rb +++ b/spec/graphql/types/notes/note_type_spec.rb @@ -56,7 +56,7 @@ context 'when system note with issue_email_participants action', feature_category: :service_desk do let_it_be(:note_text) { "added #{email}" } - # Create project and issue separately because we need to public project. + # Create project and issue separately because we need a public project. # rubocop:disable RSpec/FactoryBot/AvoidCreate -- Notes::RenderService updates #note and #cached_markdown_version let_it_be(:issue) { create(:issue, project: project) } let_it_be(:note) do @@ -69,13 +69,13 @@ describe '#body' do subject { resolve_field(:body, note, current_user: user) } - it_behaves_like 'a note content field with obfuscated email address' + it_behaves_like 'a field with obfuscated email address' end describe '#body_html' do subject { resolve_field(:body_html, note, current_user: user) } - it_behaves_like 'a note content field with obfuscated email address' + it_behaves_like 'a field with obfuscated email address' end end @@ -83,12 +83,12 @@ let(:note_text) { 'Note body from external participant' } let!(:note) { build(:note, note: note_text, project: project, author: Users::Internal.support_bot) } - let!(:note_metadata) { build(:note_metadata, note: note, email_participant: email) } + let!(:note_metadata) { build(:note_metadata, note: note) } describe '#external_author' do subject { resolve_field(:external_author, note, current_user: user) } - it_behaves_like 'a note content field with obfuscated email address' + it_behaves_like 'a field with obfuscated email address' end end diff --git a/spec/graphql/types/work_items/email_participant_type_spec.rb b/spec/graphql/types/work_items/email_participant_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5029df657298b5538dfd2926435a53a0bde10724 --- /dev/null +++ b/spec/graphql/types/work_items/email_participant_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::WorkItems::EmailParticipantType, feature_category: :service_desk do + include GraphqlHelpers + + it 'exposes the expected fields' do + expected_fields = %i[email] + + expected_fields.each do |field| + expect(described_class).to have_graphql_field(field) + end + end +end diff --git a/spec/models/work_items/widgets/email_participants_spec.rb b/spec/models/work_items/widgets/email_participants_spec.rb index dc3a9fe2793781f6481fce9830d71cd9b6843744..3061cf6fd923988456124ee267e5fad8c4317310 100644 --- a/spec/models/work_items/widgets/email_participants_spec.rb +++ b/spec/models/work_items/widgets/email_participants_spec.rb @@ -30,9 +30,9 @@ it { is_expected.to eq(:email_participants) } end - describe '#email_participants_emails' do - subject { described_class.new(work_item).email_participants_emails } + describe '#issue_email_participants' do + subject { described_class.new(work_item).issue_email_participants } - it { is_expected.to eq(work_item.email_participants_emails) } + it { is_expected.to match_array(work_item.issue_email_participants) } end end diff --git a/spec/presenters/note_presenter_spec.rb b/spec/presenters/note_presenter_spec.rb index 1c2c6526e8be0d2d5176286488fc686c112b64e3..b5ac542f7be5fbaabc3731ad93baefe196a2a92d 100644 --- a/spec/presenters/note_presenter_spec.rb +++ b/spec/presenters/note_presenter_spec.rb @@ -20,13 +20,13 @@ describe '#note' do subject { presenter.note } - it_behaves_like 'a note content field with obfuscated email address' + it_behaves_like 'a field with obfuscated email address' end describe '#note_html' do subject { presenter.note_html } - it_behaves_like 'a note content field with obfuscated email address' + it_behaves_like 'a field with obfuscated email address' it 'runs post processing pipeline' do # Ensure post process pipeline runs @@ -40,10 +40,10 @@ describe '#external_author' do let!(:note_text) { "note body" } let!(:note) { build(:note, :system, author: Users::Internal.support_bot, note: note_text) } - let!(:note_metadata) { build(:note_metadata, note: note, email_participant: email) } + let!(:note_metadata) { build(:note_metadata, note: note) } subject { presenter.external_author } - it_behaves_like 'a note content field with obfuscated email address' + it_behaves_like 'a field with obfuscated email address' end end diff --git a/spec/requests/api/graphql/work_item_spec.rb b/spec/requests/api/graphql/work_item_spec.rb index 80d97df429bab3436b015a4640cd1b7dcbeb89de..16307fbd9049624076bf44ea5d63212b7888cfca 100644 --- a/spec/requests/api/graphql/work_item_spec.rb +++ b/spec/requests/api/graphql/work_item_spec.rb @@ -1190,6 +1190,66 @@ def id_hash(object) end end + describe 'email participants widget' do + let_it_be(:email) { 'user@example.com' } + let_it_be(:obfuscated_email) { 'us*****@e*****.c**' } + let_it_be(:issue_email_participant) { create(:issue_email_participant, issue_id: work_item.id, email: email) } + + let(:work_item_fields) do + <<~GRAPHQL + id + widgets { + type + ... on WorkItemWidgetEmailParticipants { + emailParticipants { + nodes { + email + } + } + } + } + GRAPHQL + end + + it 'contains the email' do + expect(work_item_data).to include( + 'widgets' => array_including( + hash_including( + 'type' => 'EMAIL_PARTICIPANTS', + 'emailParticipants' => { + 'nodes' => containing_exactly( + hash_including( + 'email' => email + ) + ) + } + ) + ) + ) + end + + context 'when user has the guest role' do + let(:current_user) { guest } + + it 'contains the obfuscated email' do + expect(work_item_data).to include( + 'widgets' => array_including( + hash_including( + 'type' => 'EMAIL_PARTICIPANTS', + 'emailParticipants' => { + 'nodes' => containing_exactly( + hash_including( + 'email' => obfuscated_email + ) + ) + } + ) + ) + ) + end + end + end + context 'when an Issue Global ID is provided' do let(:global_id) { Issue.find(work_item.id).to_gid.to_s } diff --git a/spec/serializers/note_entity_spec.rb b/spec/serializers/note_entity_spec.rb index dd463b89252930fb4d3b8ae3bd5b66189888f43a..a0a3626d82b891b14357cf638a39d4b3df991884 100644 --- a/spec/serializers/note_entity_spec.rb +++ b/spec/serializers/note_entity_spec.rb @@ -8,50 +8,43 @@ # rubocop:disable RSpec/FactoryBot/AvoidCreate -- Persisted records required let_it_be(:note) { create(:note) } let_it_be(:user) { create(:user) } + let_it_be(:email) { 'user@example.com' } # rubocop:enable RSpec/FactoryBot/AvoidCreate let(:request) { double('request', current_user: user, noteable: note.noteable) } let(:entity) { described_class.new(note, request: request) } + let(:obfuscated_email) { 'us*****@e*****.c**' } subject(:entity_hash) { entity.as_json } it_behaves_like 'note entity' - describe 'with email participant', feature_category: :service_desk do + context 'when note from external participant', feature_category: :service_desk do let!(:note_metadata) { build(:note_metadata, note: note) } subject { entity.as_json[:external_author] } - context 'with external note author' do - let(:obfuscated_email) { 'em*****@e*****.c**' } - let(:email) { 'email@example.com' } - - it_behaves_like 'a note content field with obfuscated email address' - end + it_behaves_like 'a field with obfuscated email address' end context 'when system note with issue_email_participants action', feature_category: :service_desk do - let_it_be(:email) { 'user@example.com' } let_it_be(:note_text) { "added #{email}" } # rubocop:disable RSpec/FactoryBot/AvoidCreate -- Notes::RenderService updates #note and #cached_markdown_version let_it_be(:note) { create(:note, :system, author: Users::Internal.support_bot, note: note_text) } let_it_be(:system_note_metadata) { create(:system_note_metadata, note: note, action: :issue_email_participants) } # rubocop:enable RSpec/FactoryBot/AvoidCreate - let(:obfuscated_email) { 'us*****@e*****.c**' } - let(:expected_text) { obfuscated_email } - describe 'note' do subject { entity_hash[:note] } - it_behaves_like 'a note content field with obfuscated email address' + it_behaves_like 'a field with obfuscated email address' end describe 'note_html' do subject { entity_hash[:note_html] } - it_behaves_like 'a note content field with obfuscated email address' + it_behaves_like 'a field with obfuscated email address' end end end diff --git a/spec/support/shared_examples/graphql/notes_content_obfuscation_shared_examples.rb b/spec/support/shared_examples/graphql/notes_content_obfuscation_shared_examples.rb index 0f8f24a034f2e8825fb676bd42e83fe340c7395c..e378db0ce2a836c0d5f5307c81fe81221f68c57a 100644 --- a/spec/support/shared_examples/graphql/notes_content_obfuscation_shared_examples.rb +++ b/spec/support/shared_examples/graphql/notes_content_obfuscation_shared_examples.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true -RSpec.shared_examples 'a note content field with obfuscated email address' do +RSpec.shared_examples 'a field with obfuscated email address' do + let(:resource_parent) { note.project } + context 'when anonymous' do let(:user) { nil } @@ -9,7 +11,7 @@ context 'with signed in user' do before do - stub_member_access_level(note.project, access_level => user) if access_level + stub_member_access_level(resource_parent, access_level => user) if access_level end context 'when user has no role in project' do