From a41fe0c64ccef04d8bf6bcdb716236ec37fe1a63 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Fri, 19 May 2023 17:18:23 +0200 Subject: [PATCH] finalized specs adjustments --- config/locales/js-en.yml | 2 +- .../query-get-ical-url.modal.ts | 18 +- .../v3/queries/ical_url/query_ical_url_api.rb | 8 +- .../ical_url/query_ical_url_representer.rb | 6 +- .../calendar/resolve_work_packages_service.rb | 20 +- .../spec/controllers/ical_controller_spec.rb | 157 ++++++++- .../spec/features/calendar_sharing_spec.rb | 116 ++++++- .../spec/routing/calendar_routing_spec.rb | 7 - .../spec/services/create_ical_service_spec.rb | 10 +- .../resolve_and_authorize_query_spec.rb | 327 +++++++++++------- spec/controllers/my_controller_spec.rb | 42 ++- spec/features/users/my_spec.rb | 65 ++-- .../query_ical_url_representer_spec.rb | 69 ++++ .../query_representer_rendering_spec.rb | 20 ++ spec/models/token/ical_token_spec.rb | 29 +- spec/policies/query_policy_spec.rb | 11 + .../ical_url/query_ical_url_api_spec.rb | 133 +++++++ spec/routing/my_spec.rb | 4 +- 18 files changed, 814 insertions(+), 230 deletions(-) create mode 100644 spec/lib/api/v3/queries/ical_url/query_ical_url_representer_spec.rb create mode 100644 spec/requests/api/v3/queries/ical_url/query_ical_url_api_spec.rb diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 8e5dc970ff7..947ce2f1442 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -368,7 +368,7 @@ en: copy_url_label: "Copy URL" ical_generation_error_text: "An error occured while generating the iCal URL" copy_url_success_text: "URL copied to clipboard" - copy_url_error_text: "couldn't be copied to clipboard" + copy_url_error_text: "Due to browser errors, the URL couldn't be copied to clipboard. Please copy it manually:" label_activate: "Activate" label_assignee: 'Assignee' diff --git a/frontend/src/app/shared/components/modals/get-ical-url-modal/query-get-ical-url.modal.ts b/frontend/src/app/shared/components/modals/get-ical-url-modal/query-get-ical-url.modal.ts index 8b11efa3f34..4fee857d96a 100644 --- a/frontend/src/app/shared/components/modals/get-ical-url-modal/query-get-ical-url.modal.ts +++ b/frontend/src/app/shared/components/modals/get-ical-url-modal/query-get-ical-url.modal.ts @@ -116,17 +116,25 @@ export class QueryGetIcalUrlModalComponent extends OpModalComponent implements O } public copyUrlAndCloseModal(url:string):void { - void navigator.clipboard.writeText(url) + if(!navigator.clipboard) { + // fallback for browsers that don't support clipboard API at all + this.toastService.addWarning( + this.I18n.t('js.ical_sharing_modal.copy_url_error_text') + " " + url + ); + } else { + void navigator.clipboard.writeText(url) .then(() => { this.toastService.addSuccess(this.text.copy_success_text); - this.closeMe(); }) .catch(() => { - // e.g. browser permission errors - this.toastService.addError( - url + " " + this.I18n.t('js.ical_sharing_modal.copy_url_error_text') + // fallback when running into e.g. browser permission errors + this.toastService.addWarning( + this.I18n.t('js.ical_sharing_modal.copy_url_error_text') + " " + url ); }); + } + + this.closeMe(); } public generateAndCopyUrl(event:any):void { diff --git a/lib/api/v3/queries/ical_url/query_ical_url_api.rb b/lib/api/v3/queries/ical_url/query_ical_url_api.rb index 0c9e3b8117e..aee9218ce9d 100644 --- a/lib/api/v3/queries/ical_url/query_ical_url_api.rb +++ b/lib/api/v3/queries/ical_url/query_ical_url_api.rb @@ -36,6 +36,7 @@ module API class QueryIcalUrlAPI < ::API::OpenProjectAPI namespace :ical_url do + # TODO: introduce OpenProject::Configuration.ical_subscriptions_enabled configuration # before do # raise API::Errors::NotFound unless OpenProject::Configuration.ical_subscriptions_enabled? # end @@ -46,9 +47,7 @@ module API post do authorize_by_policy(:share_via_ical) - # TODO: write test for this - - # TODO: currently the generated URL points to controller action in calendar module + # QUESTION: currently the generated URL points to controller action in calendar module # correct approach? or should it be implemented as a API here? call = ::Calendar::GenerateICalUrl.new.call( user: current_user, @@ -62,7 +61,8 @@ module API end status 201 - + + # QUESTION: is it ok to use an OpenStruct here? QueryICalUrlRepresenter.new( OpenStruct.new(ical_url: call.result, query: @query), current_user: diff --git a/lib/api/v3/queries/ical_url/query_ical_url_representer.rb b/lib/api/v3/queries/ical_url/query_ical_url_representer.rb index 3b5afe5fe04..899dc40867a 100644 --- a/lib/api/v3/queries/ical_url/query_ical_url_representer.rb +++ b/lib/api/v3/queries/ical_url/query_ical_url_representer.rb @@ -35,6 +35,10 @@ module API module ICalUrl class QueryICalUrlRepresenter < ::API::Decorators::Single + def initialize(model, *_) + super(model, current_user: nil) + end + link :self do { href: api_v3_paths.query_ical_url(represented.query.id), @@ -59,7 +63,7 @@ module API end def _type - 'QueryIcalUrl' + 'QueryICalUrl' end end end diff --git a/modules/calendar/app/services/calendar/resolve_work_packages_service.rb b/modules/calendar/app/services/calendar/resolve_work_packages_service.rb index 8d6f3f724f1..08512285a87 100644 --- a/modules/calendar/app/services/calendar/resolve_work_packages_service.rb +++ b/modules/calendar/app/services/calendar/resolve_work_packages_service.rb @@ -29,19 +29,15 @@ module Calendar class ResolveWorkPackagesService < ::BaseServices::BaseCallable def perform(query:) - unless query.nil? - # TODO: check if the includes makes sense here in order to avoid n+1 queries - work_packages = query.results.work_packages.includes( - :project, :assigned_to, :author, :priority, :status - ) - work_packages_with_dates = work_packages - .where.not(start_date: nil, due_date: nil) + raise ActiveRecord::RecordNotFound if query.nil? - ServiceResult.success(result: work_packages_with_dates) - else - # TODO: raise specific error - raise ActiveRecord::RecordNotFound - end + work_packages = query.results.work_packages.includes( + :project, :assigned_to, :author, :priority, :status + ) + work_packages_with_dates = work_packages + .where.not(start_date: nil, due_date: nil) + + ServiceResult.success(result: work_packages_with_dates) end end end diff --git a/modules/calendar/spec/controllers/ical_controller_spec.rb b/modules/calendar/spec/controllers/ical_controller_spec.rb index d0bc7d59cd5..50d10e670e9 100644 --- a/modules/calendar/spec/controllers/ical_controller_spec.rb +++ b/modules/calendar/spec/controllers/ical_controller_spec.rb @@ -36,6 +36,28 @@ describe Calendar::ICalController do member_with_permissions: sufficient_permissions) end let(:sufficient_permissions) { %i[view_work_packages share_calendars] } + let(:insufficient_permissions) { %i[view_work_packages] } + + let(:work_package_with_due_date) do + create(:work_package, project:, + due_date: Time.zone.today + 7.days) + end + let(:work_package_with_start_date) do + create(:work_package, project:, + start_date: Time.zone.today + 14.days) + end + let(:work_package_with_start_and_due_date) do + create(:work_package, project:, + start_date: Date.tomorrow, + due_date: Time.zone.today + 7.days) + end + let!(:work_packages) do + [ + work_package_with_due_date, + work_package_with_start_date, + work_package_with_start_and_due_date + ] + end let(:query) do create(:query, project:, @@ -43,25 +65,48 @@ describe Calendar::ICalController do public: false) end let(:valid_ical_token_value) do - Token::ICal.create_and_return_value(user, query) + Token::ICal.create_and_return_value(user, query, "Some Token Name") end # the ical urls are intended to be used without a logged in user from a calendar client app # before { login_as(user) } describe '#show' do - shared_examples_for 'ical#show' do |expected| + shared_examples_for 'success' do subject { response } - if expected == :success - it { is_expected.to be_successful } - end - if expected == :failure - it { is_expected.not_to be_successful } + it { is_expected.to be_successful } + + it 'returns a valid ical file' do + expect(response.headers['Content-Type']).to eq('text/calendar') + expect(response.headers['Content-Disposition']).to eq( + "attachment; filename=\"openproject_calendar_#{DateTime.now.to_i}.ics\"; filename*=UTF-8''openproject_calendar_#{DateTime.now.to_i}.ics" + ) + expect(subject.body).to match(/BEGIN:VCALENDAR/) + expect(subject.body).to match(/END:VCALENDAR/) + + work_packages.each do |work_package| + expect(subject.body).to include(work_package.subject) + end end end - context 'with valid params' do + shared_examples_for 'failure' do + subject { response } + + it { is_expected.not_to be_successful } + + it 'does not return a valid ical file' do + expect(subject.body).not_to match(/BEGIN:VCALENDAR/) + expect(subject.body).not_to match(/END:VCALENDAR/) + + work_packages.each do |work_package| + expect(subject.body).not_to include(work_package.subject) + end + end + end + + context 'with valid params and permissions when targeting own query' do before do get :show, params: { project_id: project.id, @@ -70,7 +115,95 @@ describe Calendar::ICalController do } end - it_behaves_like 'ical#show', :success + it_behaves_like 'success' + end + + context 'with valid params and permissions when targeting a public query of somebody else' do + let(:user2) do + create(:user, + member_in_project: project, + member_with_permissions: sufficient_permissions) + end + let(:query2) do + create(:query, + project:, + user: user2, + public: true) + end + let(:valid_ical_token_value) do + Token::ICal.create_and_return_value(user, query2, "Some Token Name") + end + before do + get :show, params: { + project_id: project.id, + id: query2.id, + ical_token: valid_ical_token_value + } + end + + it_behaves_like 'success' + end + + context 'with valid params and permissions when targeting a privat query of somebody else' do + let(:user2) do + create(:user, + member_in_project: project, + member_with_permissions: sufficient_permissions) + end + let(:query2) do + create(:query, + project:, + user: user2, + public: false) + end + let(:valid_ical_token_value) do + Token::ICal.create_and_return_value(user, query2, "Some Token Name") + end + before do + get :show, params: { + project_id: project.id, + id: query2.id, + ical_token: valid_ical_token_value + } + end + + it_behaves_like 'failure' + end + + context 'with valid params and permissions when not part of the project (anymore)' do + let(:project2) { create(:project) } + let(:user) do + create(:user, + member_in_project: project2, + member_with_permissions: sufficient_permissions) + end + before do + get :show, params: { + project_id: project.id, + id: query.id, + ical_token: valid_ical_token_value + } + end + + it_behaves_like 'failure' + end + + context 'with valid params and missing permissions' do + let(:user) do + create(:user, + member_in_project: project, + member_with_permissions: insufficient_permissions) + end + + before do + get :show, params: { + project_id: project.id, + id: query.id, + ical_token: valid_ical_token_value + } + end + + it_behaves_like 'failure' end context 'with invalid token' do @@ -82,7 +215,7 @@ describe Calendar::ICalController do } end - it_behaves_like 'ical#show', :failure + it_behaves_like 'failure' end context 'with invalid query' do @@ -94,7 +227,7 @@ describe Calendar::ICalController do } end - it_behaves_like 'ical#show', :failure + it_behaves_like 'failure' end context 'with invalid project' do @@ -108,7 +241,7 @@ describe Calendar::ICalController do # TODO: the project id is actually irrelevant - the query id is enough # should the project id still be used in the ical url anyways? - it_behaves_like 'ical#show', :success + it_behaves_like 'success' end end end diff --git a/modules/calendar/spec/features/calendar_sharing_spec.rb b/modules/calendar/spec/features/calendar_sharing_spec.rb index ee448efd50f..34ae8bf1a50 100644 --- a/modules/calendar/spec/features/calendar_sharing_spec.rb +++ b/modules/calendar/spec/features/calendar_sharing_spec.rb @@ -91,18 +91,18 @@ describe 'Calendar sharing via ical', js: true do # expect disabled sharing menu item within "#settingsDropdown" do - # expect(page).to have_button("Share iCalendar", disabled: true) # disabled selector not working - expect(page).to have_selector(".menu-item.inactive", text: "Share iCalendar") - page.click_button("Share iCalendar") + # expect(page).to have_button("Subscribe to iCalendar", disabled: true) # disabled selector not working + expect(page).to have_selector(".menu-item.inactive", text: "Subscribe to iCalendar") + page.click_button("Subscribe to iCalendar") # modal should not be shown - expect(page).not_to have_selector('.spot-modal--header', text: "Share iCalendar") + expect(page).not_to have_selector('.spot-modal--header', text: "Subscribe to iCalendar") end end end context 'on persisted calendar query' do - it 'shows sharing menu item and sharing modal if clicked' do + before do saved_query visit project_calendars_path(project) @@ -111,8 +111,10 @@ describe 'Calendar sharing via ical', js: true do click_link saved_query.name end - loading_indicator_saveguard + loading_indicator_saveguard + end + it 'shows an active menu item' do # wait for settings button to become visible expect(page).to have_selector("#work-packages-settings-button") @@ -121,21 +123,91 @@ describe 'Calendar sharing via ical', js: true do # expect disabled sharing menu item within "#settingsDropdown" do - expect(page).to have_selector(".menu-item", text: "Share iCalendar") - page.click_button("Share iCalendar") + expect(page).to have_selector(".menu-item", text: "Subscribe to iCalendar") end + end + + it 'shows a sharing modal' do + open_sharing_modal - expect(page).to have_selector('.spot-modal--header', text: "Share iCalendar") + expect(page).to have_selector('.spot-modal--header', text: "Subscribe to iCalendar") + end + + it 'closes the sharing modal when closed by user by clicking the close button' do + open_sharing_modal + + expect(page).to have_selector('.spot-modal--header', text: "Subscribe to iCalendar") + + click_button "Cancel" + + expect(page).not_to have_selector('.spot-modal--header', text: "Subscribe to iCalendar") + end + + # it 'closes the sharing modal when closed by user by hitting escape' do + # open_sharing_modal + + # expect(page).to have_selector('.spot-modal--header', text: "Subscribe to iCalendar") + + # page.find(:css, '.spot-modal--header').send_keys :escape + + # expect(page).not_to have_selector('.spot-modal--header', text: "Subscribe to iCalendar") + # end + + it 'successfully requests a new tokenized iCalendar URL when a unique name is provided' do + open_sharing_modal + + fill_in "Name", with: "A token name" click_button "Copy URL" - # Not working in test env, probably due to missing clipboard permissions of the headless browser + # implicitly testing for success -> modal is closed and fallback message is shown + expect(page).not_to have_selector('.spot-modal--header', text: "Subscribe to iCalendar") + expect(page).to have_content("/projects/#{saved_query.project.id}/calendars/#{saved_query.id}/ical?ical_token=") + + # explictly testing for success message is not working in test env, probably + # due to missing clipboard permissions of the headless browser + # # expect(page).to have_content("URL copied to clipboard") # TODO: Not able to test if the URL was actuall copied to the clipboard # Tried following without success # https://copyprogramming.com/howto/emulating-a-clipboard-copy-paste-with-selinum-capybara end + + it 'validates the presence of a name' do + open_sharing_modal + + # fill_in "Name", with: "A token name" + + click_button "Copy URL" + + # modal is still shown and error message is shown + expect(page).to have_selector('.spot-modal--header', text: "Subscribe to iCalendar") + expect(page).to have_content("Name is mandatory") + end + + it 'validates the uniqueness of a name' do + open_sharing_modal + + fill_in "Name", with: "A token name" + + click_button "Copy URL" + + expect(page).not_to have_selector('.spot-modal--header', text: "Subscribe to iCalendar") + expect(page).to have_content("/projects/#{saved_query.project.id}/calendars/#{saved_query.id}/ical?ical_token=") + + # do the same thing again, now expect validation error + + open_sharing_modal + + fill_in "Name", with: "A token name" # same name for same user and same query -> not allowed + + click_button "Copy URL" + + # modal is still shown and error message is shown + expect(page).to have_selector('.spot-modal--header', text: "Subscribe to iCalendar") + expect(page).to have_content("Name is already in use") + end end end @@ -159,14 +231,30 @@ describe 'Calendar sharing via ical', js: true do # expect disabled sharing menu item within "#settingsDropdown" do - # expect(page).to have_button("Share iCalendar", disabled: true) # disabled selector not working - expect(page).to have_selector(".menu-item.inactive", text: "Share iCalendar") - page.click_button("Share iCalendar") + # expect(page).to have_button("Subscribe to iCalendar", disabled: true) # disabled selector not working + expect(page).to have_selector(".menu-item.inactive", text: "Subscribe to iCalendar") + page.click_button("Subscribe to iCalendar") # modal should not be shown - expect(page).not_to have_selector('.spot-modal--header', text: "Share iCalendar") + expect(page).not_to have_selector('.spot-modal--header', text: "Subscribe to iCalendar") end end end end + + # helper methods + + def open_sharing_modal + # wait for settings button to become visible + expect(page).to have_selector("#work-packages-settings-button") + + # click on settings button + page.find_by_id('work-packages-settings-button').click + + # expect disabled sharing menu item + within "#settingsDropdown" do + expect(page).to have_selector(".menu-item", text: "Subscribe to iCalendar") + page.click_button("Subscribe to iCalendar") + end + end end diff --git a/modules/calendar/spec/routing/calendar_routing_spec.rb b/modules/calendar/spec/routing/calendar_routing_spec.rb index b811f716a9c..3bf5593511c 100644 --- a/modules/calendar/spec/routing/calendar_routing_spec.rb +++ b/modules/calendar/spec/routing/calendar_routing_spec.rb @@ -48,11 +48,4 @@ describe Calendar::CalendarsController do id: '2', project_id: '1') end - - it do - expect(get('/projects/1/calendars/2/ical')).to route_to(controller: 'calendar/ical', - action: 'ical', - id: '2', - project_id: '1') - end end diff --git a/modules/calendar/spec/services/create_ical_service_spec.rb b/modules/calendar/spec/services/create_ical_service_spec.rb index df976c093af..0b176e5ae32 100644 --- a/modules/calendar/spec/services/create_ical_service_spec.rb +++ b/modules/calendar/spec/services/create_ical_service_spec.rb @@ -45,7 +45,7 @@ describe Calendar::CreateICalService, type: :model do end let(:work_package_with_due_date_and_assignee) do create(:work_package, project:, - due_date: Time.zone.today + 30.days, assigned_to: user) + due_date: Time.zone.today + 60.days, assigned_to: user) end let(:work_packages) do [ @@ -93,7 +93,7 @@ describe Calendar::CreateICalService, type: :model do DTEND;VALUE=DATE:#{(work_package_with_due_date.due_date + 1.day).strftime('%Y%m%d')} DESCRIPTION:Project: #{project.name}\nType: None\nStatus: #{work_package_with_due_date.status.name}\nAssignee: \nPriority: #{work_package_with_due_date.priority.name}\n\nDescription:\n #{work_package_with_due_date.description} LOCATION:http://localhost:3000/work_packages/#{work_package_with_due_date.id} - ORGANIZER:Bob Bobbit + ORGANIZER:#{work_package_with_due_date.author&.name} SUMMARY:#{work_package_with_due_date.name} END:VEVENT BEGIN:VEVENT @@ -103,7 +103,7 @@ describe Calendar::CreateICalService, type: :model do DTEND;VALUE=DATE:#{(work_package_with_start_date.start_date + 1.day).strftime('%Y%m%d')} DESCRIPTION:Project: #{project.name}\nType: None\nStatus: #{work_package_with_start_date.status.name}\nAssignee: \nPriority: #{work_package_with_start_date.priority.name}\n\nDescription:\n #{work_package_with_start_date.description} LOCATION:http://localhost:3000/work_packages/#{work_package_with_start_date.id} - ORGANIZER:Bob Bobbit + ORGANIZER:#{work_package_with_start_date.author&.name} SUMMARY:#{work_package_with_start_date.name} END:VEVENT BEGIN:VEVENT @@ -113,7 +113,7 @@ describe Calendar::CreateICalService, type: :model do DTEND;VALUE=DATE:#{(work_package_with_start_and_due_date.due_date + 1.day).strftime('%Y%m%d')} DESCRIPTION:Project: #{project.name}\nType: None\nStatus: #{work_package_with_start_and_due_date.status.name}\nAssignee: \nPriority: #{work_package_with_start_and_due_date.priority.name}\n\nDescription:\n #{work_package_with_start_and_due_date.description} LOCATION:http://localhost:3000/work_packages/#{work_package_with_start_and_due_date.id} - ORGANIZER:Bob Bobbit + ORGANIZER:#{work_package_with_start_and_due_date.author&.name} SUMMARY:#{work_package_with_start_and_due_date.name} END:VEVENT BEGIN:VEVENT @@ -123,7 +123,7 @@ describe Calendar::CreateICalService, type: :model do DTEND;VALUE=DATE:#{(work_package_with_due_date_and_assignee.due_date + 1.day).strftime('%Y%m%d')} DESCRIPTION:Project: #{project.name}\nType: None\nStatus: #{work_package_with_due_date_and_assignee.status.name}\nAssignee: #{work_package_with_due_date_and_assignee.assigned_to.name}\nPriority: #{work_package_with_due_date_and_assignee.priority.name}\n\nDescription:\n #{work_package_with_due_date_and_assignee.description} LOCATION:http://localhost:3000/work_packages/#{work_package_with_due_date_and_assignee.id} - ORGANIZER:Bob Bobbit + ORGANIZER:#{work_package_with_due_date_and_assignee.author&.name} SUMMARY:#{work_package_with_due_date_and_assignee.name} ATTENDEE:#{work_package_with_due_date_and_assignee.assigned_to.name} END:VEVENT diff --git a/modules/calendar/spec/services/resolve_and_authorize_query_spec.rb b/modules/calendar/spec/services/resolve_and_authorize_query_spec.rb index 2942f24e066..5e7947f42f4 100644 --- a/modules/calendar/spec/services/resolve_and_authorize_query_spec.rb +++ b/modules/calendar/spec/services/resolve_and_authorize_query_spec.rb @@ -29,89 +29,133 @@ require 'spec_helper' describe Calendar::ResolveAndAuthorizeQueryService, type: :model do - # let(:user3_not_member) do - # create(:user, - # member_in_project: nil) - # end let(:sufficient_permissions) { %i[view_work_packages share_calendars] } let(:insufficient_permissions) { %i[view_work_packages] } let(:project) { create(:project) } + let(:user) do + create(:user, + member_in_project: project, + member_with_permissions: sufficient_permissions) + end + let(:query1) do + create(:query, + project:, + user: user, + public: false) # privat query + end + let(:query2) do + create(:query, + project:, + user: user, + public: true) # public query + end + let(:ical_token_instance_for_query1) do + Token::ICal.create(user: user, + ical_token_query_assignment_attributes: { query: query1, name: "My Token", user_id: user.id } + ) + end + let(:ical_token_instance_for_query2) do + Token::ICal.create(user: user, + ical_token_query_assignment_attributes: { query: query2, name: "My Token", user_id: user.id } + ) + end let(:instance) do described_class.new end + shared_examples 'not found' do + it 'raises ActiveRecord::RecordNotFound' do + expect { subject }.to raise_error(ActiveRecord::RecordNotFound) + end + end + context 'if user is authenticated to read from query and user is permitted to use ical sharing' do - let(:user) do - create(:user, - member_in_project: project, - member_with_permissions: sufficient_permissions) - end - let(:query1) do - create(:query, - project:, - user: user, - public: false) - end - let(:query2) do - create(:query, - project:, - user: user, - public: false) - end - let(:ical_token_instance_for_query1) do - Token::ICal.create(user: user, - ical_token_query_assignment_attributes: { query: query1, name: "My Token" } - ) - end - let(:ical_token_instance_for_query2) do - Token::ICal.create(user: user, - ical_token_query_assignment_attributes: { query: query2, name: "My Token" } - ) - end - context 'if ical token belongs to query' do - subject do - instance.call( - query_id: query1.id, - ical_token_instance: ical_token_instance_for_query1 - ) + context 'if user owns the query which is private' do + subject do + instance.call( + query_id: query1.id, + ical_token_instance: ical_token_instance_for_query1 + ) + end + + it 'returns the query as result' do + expect(subject.result) + .to eq query1 + end + + it 'is a success' do + expect(subject) + .to be_success + end end - it 'returns the query as result' do - expect(subject.result) - .to eq query1 - end + context 'if user owns the query which is public' do + subject do + instance.call( + query_id: query2.id, + ical_token_instance: ical_token_instance_for_query2 + ) + end - it 'is a success' do - expect(subject) - .to be_success + it 'returns the query as result' do + expect(subject.result) + .to eq query2 + end + + it 'is a success' do + expect(subject) + .to be_success + end end end context 'if ical token does NOT belong to query' do - subject do - instance.call( - query_id: query1.id, - ical_token_instance: ical_token_instance_for_query2 - ) + context 'if user owns the query which is private' do + subject do + instance.call( + query_id: query1.id, + ical_token_instance: ical_token_instance_for_query2 + ) + end + + it_behaves_like "not found" end - it 'raises ActiveRecord::RecordNotFound' do - expect { subject }.to raise_error(ActiveRecord::RecordNotFound) + context 'if user owns the query which is public' do + subject do + instance.call( + query_id: query2.id, + ical_token_instance: ical_token_instance_for_query1 + ) + end + + it_behaves_like "not found" end end context 'if ical token is nil' do - subject do - instance.call( - query_id: query1.id, - ical_token_instance: nil - ) + context 'if query is private' do + subject do + instance.call( + query_id: query1.id, + ical_token_instance: nil + ) + end + + it_behaves_like "not found" end - it 'raises ActiveRecord::RecordNotFound' do - expect { subject }.to raise_error(ActiveRecord::RecordNotFound) + context 'if query is public' do + subject do + instance.call( + query_id: query2.id, + ical_token_instance: nil + ) + end + + it_behaves_like "not found" end end end @@ -122,57 +166,57 @@ describe Calendar::ResolveAndAuthorizeQueryService, type: :model do member_in_project: project, member_with_permissions: insufficient_permissions) end - let(:query1) do - create(:query, - project:, - user: user, - public: false) - end - let(:query2) do - create(:query, - project:, - user: user, - public: false) - end - let(:ical_token_instance_for_query1) do - Token::ICal.create(user: user, - ical_token_query_assignment_attributes: { query: query1, name: "My Token" } - ) - end - let(:ical_token_instance_for_query2) do - Token::ICal.create(user: user, - ical_token_query_assignment_attributes: { query: query2, name: "My Token" } - ) - end context 'if ical token belongs to query' do - subject do - instance.call( - query_id: query1.id, - ical_token_instance: ical_token_instance_for_query1 - ) + context 'if user owns the query which is private' do + subject do + instance.call( + query_id: query1.id, + ical_token_instance: ical_token_instance_for_query1 + ) + end + + it_behaves_like "not found" end - it 'raises ActiveRecord::RecordNotFound' do - expect { subject }.to raise_error(ActiveRecord::RecordNotFound) + context 'if user owns the query which is public' do + subject do + instance.call( + query_id: query2.id, + ical_token_instance: ical_token_instance_for_query2 + ) + end + + it_behaves_like "not found" end end context 'if ical token does NOT belong to query' do - subject do - instance.call( - query_id: query1.id, - ical_token_instance: ical_token_instance_for_query2 - ) + context 'if user owns the query which is private' do + subject do + instance.call( + query_id: query1.id, + ical_token_instance: ical_token_instance_for_query2 + ) + end + + it_behaves_like "not found" end - it 'raises ActiveRecord::RecordNotFound' do - expect { subject }.to raise_error(ActiveRecord::RecordNotFound) + context 'if user owns the query which is public' do + subject do + instance.call( + query_id: query2.id, + ical_token_instance: ical_token_instance_for_query1 + ) + end + + it_behaves_like "not found" end end end - context 'if user is not authenticated to read from query' do + context 'if user does not own the query' do let(:user1) do create(:user, member_in_project: project, @@ -183,50 +227,99 @@ describe Calendar::ResolveAndAuthorizeQueryService, type: :model do member_in_project: project, member_with_permissions: sufficient_permissions) end - let(:query1) do + let(:private_query1_of_user1) do create(:query, project:, user: user1, public: false) end + let(:public_query2_of_user1) do + create(:query, + project:, + user: user1, + public: true) + end let(:ical_token_instance_of_user_2_for_query1) do Token::ICal.create(user: user2, - ical_token_query_assignment_attributes: { query: query1, name: "My Token" } + ical_token_query_assignment_attributes: { + query: private_query1_of_user1, name: "My Token", user_id: user2.id + } + ) + end + let(:ical_token_instance_of_user_2_for_query2) do + Token::ICal.create(user: user2, + ical_token_query_assignment_attributes: { + query: public_query2_of_user1, name: "My Token", user_id: user2.id + } ) end - context 'if ical token belongs to query' do + context 'if query is private' do subject do instance.call( - query_id: query1.id, + query_id: private_query1_of_user1.id, ical_token_instance: ical_token_instance_of_user_2_for_query1 ) end - it 'raises ActiveRecord::RecordNotFound' do - expect { subject }.to raise_error(ActiveRecord::RecordNotFound) + it_behaves_like "not found" + end + + context 'if query is public' do + subject do + instance.call( + query_id: public_query2_of_user1.id, + ical_token_instance: ical_token_instance_of_user_2_for_query2 + ) + end + + it 'returns the query as result' do + expect(subject.result) + .to eq public_query2_of_user1 + end + + it 'is a success' do + expect(subject) + .to be_success end end end - context 'if query id is invalid or nil' do + context 'if user is not member of the project (anymore)' do + let(:project2) { create(:project) } let(:user) do create(:user, - member_in_project: project, + member_in_project: project2, member_with_permissions: sufficient_permissions) end - let(:query1) do - create(:query, - project:, - user: user, - public: false) - end - let(:ical_token_instance_for_query1) do - Token::ICal.create(user: user, - ical_token_query_assignment_attributes: { query: query1, name: "My Token" } - ) + + # queries (privat and public) owned by user + # but user is not part of the project anymore + + context 'if query is private' do + subject do + instance.call( + query_id: query1.id, + ical_token_instance: ical_token_instance_for_query1 + ) + end + + it_behaves_like "not found" end + context 'if query is public' do + subject do + instance.call( + query_id: query2.id, + ical_token_instance: ical_token_instance_for_query2 + ) + end + + it_behaves_like "not found" + end + end + + context 'if query id is invalid or nil' do context 'if query id is invalid' do subject do instance.call( @@ -235,9 +328,7 @@ describe Calendar::ResolveAndAuthorizeQueryService, type: :model do ) end - it 'raises ActiveRecord::RecordNotFound' do - expect { subject }.to raise_error(ActiveRecord::RecordNotFound) - end + it_behaves_like "not found" end context 'if query id is nil' do @@ -248,9 +339,7 @@ describe Calendar::ResolveAndAuthorizeQueryService, type: :model do ) end - it 'raises ActiveRecord::RecordNotFound' do - expect { subject }.to raise_error(ActiveRecord::RecordNotFound) - end + it_behaves_like "not found" end end end diff --git a/spec/controllers/my_controller_spec.rb b/spec/controllers/my_controller_spec.rb index 42237310e09..e7abc4e09bd 100644 --- a/spec/controllers/my_controller_spec.rb +++ b/spec/controllers/my_controller_spec.rb @@ -315,20 +315,40 @@ describe MyController do # in this context all ical tokens of a user should be reverted at once # this invalidates all previously generated ical urls, which is the intention context 'with existing keys' do - let!(:project) { create(:project) } - let!(:query1) { create(:query, project: project) } - let!(:query2) { create(:query, project: project) } - let!(:key1_of_query1) { Token::ICal.create user:, query: query1} - let!(:key2_of_query1) { Token::ICal.create user:, query: query1} - let!(:key1_of_query2) { Token::ICal.create user:, query: query2} - let!(:key2_of_query2) { Token::ICal.create user:, query: query2} + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:query1) { create(:query, project: project) } + let(:query2) { create(:query, project: project) } + let!(:ical_token_for_query1) do + Token::ICal.create(user:, + ical_token_query_assignment_attributes: { query: query1, name: "Some Token Name", user_id: user.id } + ) + end + let!(:ical_token_for_query1_2) do + Token::ICal.create(user:, + ical_token_query_assignment_attributes: { query: query1, name: "Some Other Token Name", user_id: user.id } + ) + end + let!(:ical_token_for_query2_1) do + Token::ICal.create(user:, + ical_token_query_assignment_attributes: { query: query2, name: "Some Token Name", user_id: user.id } + ) + end + + it 'revoke specific ical tokens' do + expect(user.ical_tokens).to contain_exactly( + ical_token_for_query1, ical_token_for_query1_2, ical_token_for_query2_1 + ) - it 'revokes all existing keys' do - expect(user.ical_tokens).to contain_exactly(key1_of_query1, key2_of_query1, key1_of_query2, key2_of_query2) + delete :revoke_ical_token, params: { id: ical_token_for_query1_2.id } - delete :revoke_all_ical_tokens_of_query, params: { query_id: query2.id } + expect(user.ical_tokens.reload).to contain_exactly( + ical_token_for_query1, ical_token_for_query2_1 + ) - expect(user.ical_tokens.reload).to contain_exactly(key1_of_query1, key2_of_query1) + expect(user.ical_tokens.reload).not_to contain_exactly( + ical_token_for_query2_1 + ) expect(flash[:info]).to be_present expect(flash[:error]).not_to be_present diff --git a/spec/features/users/my_spec.rb b/spec/features/users/my_spec.rb index b0d3af5365b..0a48766a1c0 100644 --- a/spec/features/users/my_spec.rb +++ b/spec/features/users/my_spec.rb @@ -162,66 +162,81 @@ describe 'my', js: true do expect(page).to have_content 'iCalendar token(s) not present' end - new_ical_token_for_query1 = Token::ICal.create(user:, query: query1) + new_ical_token_for_query1 = Token::ICal.create(user:, + ical_token_query_assignment_attributes: { query: query1, name: "Some Token Name", user_id: user.id } + ) visit my_access_token_path expect(page).not_to have_content 'iCalendar token(s) not present' - within(:xpath, "//tr[contains(.,'iCalendar token(s) for calendar \"#{query1.name}\"')]") do - expect(page).to have_content "iCalendar token(s) for calendar \"#{query1.name}\" of project \"#{project.name}\"" - expect(page).to have_content "#{I18n.l(new_ical_token_for_query1.created_at, format: :time)} (latest)" + expected_content = "iCalendar token \"#{new_ical_token_for_query1.ical_token_query_assignment.name}\" for \"#{query1.name}\" in \"#{project.name}\"" + + within(:xpath, "//tr[contains(.,'#{expected_content}')]") do + expect(page).to have_content expected_content + expect(page).to have_content "#{I18n.l(new_ical_token_for_query1.created_at, format: :time)}" end Timecop.travel(1.minute.from_now) do - another_new_ical_token_for_query1 = Token::ICal.create(user:, query: query1) + another_new_ical_token_for_query1 = Token::ICal.create(user:, + ical_token_query_assignment_attributes: { query: query1, name: "Some Other Token Name", user_id: user.id } + ) visit my_access_token_path - expect(page).not_to have_content 'iCalendar token(s) not present' + expected_content = "iCalendar token \"#{another_new_ical_token_for_query1.ical_token_query_assignment.name}\" for \"#{query1.name}\" in \"#{project.name}\"" - within(:xpath, "//tr[contains(.,'iCalendar token(s) for calendar \"#{query1.name}\"')]") do - expect(page).to have_content "iCalendar token(s) for calendar \"#{query1.name}\" of project \"#{project.name}\"" - expect(page).to have_content "#{I18n.l(another_new_ical_token_for_query1.created_at, format: :time)} (latest)" + within(:xpath, "//tr[contains(.,'#{expected_content}')]") do + expect(page).to have_content expected_content + expect(page).to have_content "#{I18n.l(another_new_ical_token_for_query1.created_at, format: :time)}" end end Timecop.travel(2.minutes.from_now) do - new_ical_token_for_query2 = Token::ICal.create(user:, query: query2) + new_ical_token_for_query2 = Token::ICal.create(user:, + ical_token_query_assignment_attributes: { query: query2, name: "Some Token Name", user_id: user.id } + ) visit my_access_token_path - expect(page).not_to have_content 'iCalendar token(s) not present' + expected_content = "iCalendar token \"#{new_ical_token_for_query2.ical_token_query_assignment.name}\" for \"#{query2.name}\" in \"#{project.name}\"" - within(:xpath, "//tr[contains(.,'iCalendar token(s) for calendar \"#{query2.name}\"')]") do - expect(page).to have_content "iCalendar token(s) for calendar \"#{query2.name}\" of project \"#{project.name}\"" - expect(page).to have_content "#{I18n.l(new_ical_token_for_query2.created_at, format: :time)} (latest)" + within(:xpath, "//tr[contains(.,'#{expected_content}')]") do + expect(page).to have_content expected_content + expect(page).to have_content "#{I18n.l(new_ical_token_for_query2.created_at, format: :time)}" end end end it 'in Access Tokens they can revoke all existing Ical tokens' do - 2.times do - Token::ICal.create(user:, query: query1) - end - Token::ICal.create(user:, query: query2) + new_ical_token_for_query1 = Token::ICal.create(user:, + ical_token_query_assignment_attributes: { query: query1, name: "Some Token Name", user_id: user.id } + ) + another_new_ical_token_for_query1 = Token::ICal.create(user:, + ical_token_query_assignment_attributes: { query: query1, name: "Some Other Token Name", user_id: user.id } + ) + new_ical_token_for_query2 = Token::ICal.create(user:, + ical_token_query_assignment_attributes: { query: query2, name: "Some Token Name", user_id: user.id } + ) expect(user.ical_tokens.count).to eq 3 visit my_access_token_path - within(:xpath, "//tr[contains(.,'iCalendar token(s) for calendar \"#{query1.name}\"')]") do - expect(page).to have_content "Revoke all" + expected_content = "iCalendar token \"#{new_ical_token_for_query1.ical_token_query_assignment.name}\" for \"#{query1.name}\" in \"#{project.name}\"" + + within(:xpath, "//tr[contains(.,'#{expected_content}')]") do + expect(page).to have_content "Revoke" + click_link "Revoke" end - find(:xpath, "//tr[contains(.,'iCalendar token(s) for calendar \"#{query1.name}\"')]/td/a", text: 'Revoke all').click + # find(:xpath, "//tr[contains(.,'iCalendar token(s) for \"#{query1.name}\"')]/td/a", text: 'Revoke all').click - expect(page).to have_content "All iCalendar tokens for calendar #{query1.name} of project #{project.name} have been revoked." + expect(page).to have_content "iCalendar token \"#{new_ical_token_for_query1.ical_token_query_assignment.name}\" for calendar \"#{query1.name}\" of project \"#{project.name}\" has been revoked." - expect(page).not_to have_content "iCalendar token(s) for calendar \"#{query1.name}\" of project \"#{project.name}\"" - expect(page).to have_content "iCalendar token(s) for calendar \"#{query2.name}\" of project \"#{project.name}\"" + expect(page).not_to have_content expected_content - expect(user.ical_tokens.count).to eq 1 + expect(user.ical_tokens.count).to eq 2 end end end diff --git a/spec/lib/api/v3/queries/ical_url/query_ical_url_representer_spec.rb b/spec/lib/api/v3/queries/ical_url/query_ical_url_representer_spec.rb new file mode 100644 index 00000000000..96d0e08f2c1 --- /dev/null +++ b/spec/lib/api/v3/queries/ical_url/query_ical_url_representer_spec.rb @@ -0,0 +1,69 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require 'spec_helper' + +describe API::V3::Queries::ICalUrl::QueryICalUrlRepresenter do + include API::V3::Utilities::PathHelper + + let(:query) { build_stubbed(:query) } + let(:mocked_ical_url) { 'https://community.openproject.org/projects/3/calendars/46/ical?ical_token=66a44f91a18ad0355cfad77c319ef5ee2973291499fb8e44a220885f9124d2d2' } + let(:representer) do described_class.new( + OpenStruct.new(ical_url: mocked_ical_url, query: query) + ) end + + subject { representer.to_json } + + describe 'generation' do + describe '_links' do + it_behaves_like 'has an untitled link' do + let(:link) { 'self' } + let(:href) { api_v3_paths.query_ical_url(query.id) } + let(:method) { "post" } + end + + it_behaves_like 'has an untitled link' do + let(:link) { 'query' } + let(:href) { api_v3_paths.query(query.id) } + let(:method) { "get" } + end + + it_behaves_like 'has an untitled link' do + let(:link) { 'icalUrl' } + let(:href) { mocked_ical_url } + let(:method) { "get" } + end + end + + it 'has _type QueryICalUrl' do + expect(subject) + .to be_json_eql('QueryICalUrl'.to_json) + .at_path('_type') + end + end +end diff --git a/spec/lib/api/v3/queries/query_representer_rendering_spec.rb b/spec/lib/api/v3/queries/query_representer_rendering_spec.rb index 79fa02df747..b81398352a9 100644 --- a/spec/lib/api/v3/queries/query_representer_rendering_spec.rb +++ b/spec/lib/api/v3/queries/query_representer_rendering_spec.rb @@ -547,6 +547,26 @@ describe API::V3::Queries::QueryRepresenter do end end + describe 'ical url' do + context 'when allowed to subscribe to ical' do + let(:permissions) { %i(share_via_ical) } + + it_behaves_like 'has an untitled link' do + let(:link) { 'icalUrl' } + let(:href) { api_v3_paths.query_ical_url(query.id) } + end + end + + context 'when lacking permission' do + let(:permissions) { [] } + + it 'has no icalUrl link' do + expect(subject) + .not_to have_json_path('_links/icalUrl') + end + end + end + describe 'properties' do it_behaves_like 'property', :_type do let(:value) { 'Query' } diff --git a/spec/models/token/ical_token_spec.rb b/spec/models/token/ical_token_spec.rb index f228b362d6b..9b878a86d3d 100644 --- a/spec/models/token/ical_token_spec.rb +++ b/spec/models/token/ical_token_spec.rb @@ -29,9 +29,9 @@ require 'spec_helper' describe Token::ICal do - let(:user) { build(:user) } - let(:project) { build(:project) } - let(:query) { build(:query, project:) } + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:query) { create(:query, project:) } let(:name) { 'unique_name' } it 'inherits from Token::HashedToken' do @@ -54,7 +54,7 @@ describe Token::ICal do ical_token_query_assignment_attributes: { query: query, user_id: user.id } ) - expect(ical_token2.errors["ical_token_query_assignment.name"].first).to eq("can't be blank.") + expect(ical_token2.errors["ical_token_query_assignment.name"].first).to eq("is mandatory. Please select a name.") expect(described_class.where(user_id: user.id)).to be_empty # if a query and name is given, the token can be created @@ -115,7 +115,7 @@ describe Token::ICal do ) expect(ical_token2.errors["ical_token_query_assignment.name"].first).to eq( - "has already been taken for this query and user" + "is already in use. Please select another name." ) expect(described_class.where(user_id: user.id)).to contain_exactly( @@ -138,6 +138,8 @@ describe Token::ICal do describe '#create_and_return_value method' do it 'expects the query and token name and returns a token value' do + ical_token1_value = nil + expect do ical_token1_value = described_class.create_and_return_value( user, @@ -156,6 +158,9 @@ describe Token::ICal do end it 'does not return a token value if token was not successfully persisted' do + ical_token1_value = nil + ical_token2_value = nil + expect do ical_token1_value = described_class.create_and_return_value( user, @@ -163,12 +168,13 @@ describe Token::ICal do name ) # same name cannot be used twice for the same query and user - # -> token will not be persisted and no value should be returned - ical_token2_value = described_class.create_and_return_value( - user, - query, - name - ) + expect { + ical_token2_value = described_class.create_and_return_value( + user, + query, + name + ) + }.to raise_error(ActiveRecord::RecordInvalid) end.to change { described_class.where(user_id: user.id).count }.by(1) expect(ical_token1_value).to be_present @@ -176,7 +182,6 @@ describe Token::ICal do ical_token1 = described_class.where(user_id: user.id).last expect(described_class.find_by_plaintext_value(ical_token1_value)).to eq ical_token1 - expect(described_class.find_by_plaintext_value(ical_token2_value)).to be_nil end end end diff --git a/spec/policies/query_policy_spec.rb b/spec/policies/query_policy_spec.rb index 380d0631c41..52b11f21551 100644 --- a/spec/policies/query_policy_spec.rb +++ b/spec/policies/query_policy_spec.rb @@ -394,6 +394,17 @@ describe QueryPolicy, type: :controller do expect(subject.allowed?(query, :share_via_ical)).to be_falsy end + + it 'is true if the user has permission in the project' do + allow(user).to receive(:allowed_to?).with( + :share_calendars, + project, + global: project.nil? + ) + .and_return true + + expect(subject.allowed?(query, :share_via_ical)).to be_truthy + end end end diff --git a/spec/requests/api/v3/queries/ical_url/query_ical_url_api_spec.rb b/spec/requests/api/v3/queries/ical_url/query_ical_url_api_spec.rb new file mode 100644 index 00000000000..49ded5b0e7c --- /dev/null +++ b/spec/requests/api/v3/queries/ical_url/query_ical_url_api_spec.rb @@ -0,0 +1,133 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require 'spec_helper' +require 'rack/test' + +describe 'API v3 Query ICal Url' do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + describe '#post queries/:id/ical_url' do + let(:project) { create(:project) } + let(:role) { create(:role, permissions:) } + # TODO: check OpenProject::Configuration.ical_subscriptions_enabled configuration + # TODO: :view_work_packages permission is mandatory, otherwise a 404 is returned. Why? + let(:permissions) { [:view_work_packages, :share_calendars] } + let(:user) do + create(:user, + member_in_project: project, + member_through_role: role) + end + let(:query) { create(:query, project:, user:) } + let(:path) { api_v3_paths.query_ical_url(query.id) } + let(:params) { { token_name: "foo" } } + + before do + allow(User) + .to receive(:current) + .and_return(user) + + header "Content-Type", "application/json" + post path, params.to_json + end + + shared_examples_for 'success' do + it 'succeeds' do + expect(last_response.status) + .to eq(201) + end + + it 'returns the path pointing to self' do + expect(last_response.body) + .to be_json_eql(path.to_json) + .at_path('_links/self/href') + end + + it 'returns the path pointing to the associated query' do + expect(last_response.body) + .to be_json_eql(api_v3_paths.query(query.id).to_json) + .at_path('_links/query/href') + end + + it 'returns the tokenized, absolute url pointing to iCalendar endpoint' do + json = JSON.parse(last_response.body) + expect(json['_links']['icalUrl']['href']).to include('http') + expect(json['_links']['icalUrl']['href']).to include( + "projects/#{project.id}/calendars/#{query.id}/ical?ical_token=" + ) + end + end + + context 'when user has sufficient permissions and owns the query' do + it_behaves_like 'success' + end + + context 'when user has sufficient permissions and tries to get the iCalendar url of the public query of another user' do + let(:role_of_other_user) { create(:role, permissions: [:view_work_packages]) } + let(:other_user) do + create(:user, + member_in_project: project, + member_through_role: role_of_other_user) + end + let(:query) { create(:query, project:, user: other_user, public: true) } + let(:path) { api_v3_paths.query_ical_url(query.id) } + + it_behaves_like 'success' + end + + context 'when user has no access to the associated project' do + let(:other_project) { create(:project) } + let(:query) { create(:query, project: other_project, user:) } + let(:path) { api_v3_paths.query_ical_url(query.id) } + + it_behaves_like 'not found' + end + + context 'when user tries to get an iCalendar url from a private query of another user' do + let(:other_user) { create(:user) } + let(:query) { create(:query, project:, user: other_user, public: false) } + let(:path) { api_v3_paths.query_ical_url(query.id) } + + it_behaves_like 'not found' + end + + context 'when user has insufficient permissions' do + # TODO: :view_work_packages permission is mandatory, otherwise a 404 is returned. Why? + let(:permissions) { [:view_work_packages] } # share_calendars is missing + + it_behaves_like 'unauthorized access' + end + + context 'when query does not exist' do + let(:path) { api_v3_paths.query_ical_url(query.id+42) } + + it_behaves_like 'not found' + end + end +end diff --git a/spec/routing/my_spec.rb b/spec/routing/my_spec.rb index 644de6b9941..6f6985673ce 100644 --- a/spec/routing/my_spec.rb +++ b/spec/routing/my_spec.rb @@ -66,7 +66,7 @@ describe 'my routes' do action: 'deletion_info') end - it '/my/revoke_all_ical_tokens_of_query DELETE routes to my#revoke_all_ical_tokens_of_query' do - expect(delete('/my/revoke_all_ical_tokens_of_query')).to route_to('my#revoke_all_ical_tokens_of_query') + it '/my/revoke_ical_token DELETE routes to my#revoke_ical_token' do + expect(delete('/my/revoke_ical_token')).to route_to('my#revoke_ical_token') end end