diff --git a/lib/Controller/OpenProjectAPIController.php b/lib/Controller/OpenProjectAPIController.php index b7b314cd7..037899865 100644 --- a/lib/Controller/OpenProjectAPIController.php +++ b/lib/Controller/OpenProjectAPIController.php @@ -154,17 +154,18 @@ public function getNotifications(): DataResponse { * * @return DataResponse */ - public function getSearchedWorkPackages(?string $searchQuery = null, ?int $fileId = null): DataResponse { + public function getSearchedWorkPackages(?string $searchQuery = null, ?int $fileId = null, bool $isSmartPicker = false): DataResponse { if ($this->accessToken === '') { return new DataResponse('', Http::STATUS_UNAUTHORIZED); } elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) { return new DataResponse('', Http::STATUS_BAD_REQUEST); } - + // when the search is done through smart picker we don't want to check if the work package is linkable $result = $this->openprojectAPIService->searchWorkPackage( $this->userId, $searchQuery, - $fileId + $fileId, + !$isSmartPicker ); if (!isset($result['error'])) { diff --git a/lib/Listener/OpenProjectReferenceListener.php b/lib/Listener/OpenProjectReferenceListener.php index b6d84eae6..e802197b4 100644 --- a/lib/Listener/OpenProjectReferenceListener.php +++ b/lib/Listener/OpenProjectReferenceListener.php @@ -23,15 +23,36 @@ namespace OCA\OpenProject\Listener; use OCA\OpenProject\AppInfo\Application; +use OCA\OpenProject\Service\OpenProjectAPIService; +use OCP\AppFramework\Services\IInitialState; use OCP\Collaboration\Reference\RenderReferenceEvent; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; +use OCP\IConfig; use OCP\Util; /** * @template-implements IEventListener */ class OpenProjectReferenceListener implements IEventListener { + + /** + * @var IInitialState + */ + private $initialStateService; + + /** + * @var IConfig + */ + private $config; + + public function __construct( + IInitialState $initialStateService, + IConfig $config + ) { + $this->initialStateService = $initialStateService; + $this->config = $config; + } public function handle(Event $event): void { // @phpstan-ignore-next-line - make phpstan not complain in nextcloud version other than 26 if (!$event instanceof RenderReferenceEvent) { @@ -39,5 +60,11 @@ public function handle(Event $event): void { } Util::addScript(Application::APP_ID, Application::APP_ID . '-reference'); + $this->initialStateService->provideInitialState('admin-config-status', OpenProjectAPIService::isAdminConfigOk($this->config)); + + $this->initialStateService->provideInitialState( + 'openproject-url', + $this->config->getAppValue(Application::APP_ID, 'openproject_instance_url') + ); } } diff --git a/lib/Reference/WorkPackageReferenceProvider.php b/lib/Reference/WorkPackageReferenceProvider.php index b3e26e56d..cb078eeb2 100644 --- a/lib/Reference/WorkPackageReferenceProvider.php +++ b/lib/Reference/WorkPackageReferenceProvider.php @@ -25,7 +25,6 @@ use OCA\OpenProject\Service\OpenProjectAPIService; use OCP\Collaboration\Reference\ADiscoverableReferenceProvider; -use OCP\Collaboration\Reference\ISearchableReferenceProvider; use OCP\Collaboration\Reference\Reference; use OC\Collaboration\Reference\ReferenceManager; use OCA\OpenProject\AppInfo\Application; @@ -34,7 +33,7 @@ use OCP\IL10N; use OCP\IURLGenerator; -class WorkPackageReferenceProvider extends ADiscoverableReferenceProvider implements ISearchableReferenceProvider { +class WorkPackageReferenceProvider extends ADiscoverableReferenceProvider { private const RICH_OBJECT_TYPE = Application::APP_ID . '_work_package'; // as we know we are on NC >= 26, we can use Php 8 syntax for class attributes @@ -76,13 +75,6 @@ public function getIconUrl(): string { ); } - /** - * @inheritDoc - */ - public function getSupportedSearchProviderIds(): array { - return ['openproject-search']; - } - /** * Parse a link to find a work package ID * @@ -90,15 +82,23 @@ public function getSupportedSearchProviderIds(): array { * * @return int|null */ - private function getWorkPackageIdFromUrl(string $referenceText): ?int { + public function getWorkPackageIdFromUrl(string $referenceText): ?int { + $patterns = array( + '\/wp\/([0-9]+)/', + '\/projects\/[^\/\?]+\/(?:work_packages|bcf)(?:\/details)?\/([0-9]+)/', + '\/(?:work_packages|notifications)\/details\/([0-9]+)/', + '\/work_packages\/([0-9]+)/', + '\/projects\/[^\/\?]+\/(?:boards|calendars|team_planners)\/[^\/\?]+\/details\/([0-9]+)/'); // example links // https://community.openproject.org/projects/nextcloud-integration/work_packages/40070 - $openProjectUrl = $this->config->getAppValue(Application::APP_ID, 'openproject_instance_url'); - preg_match('/^' . preg_quote($openProjectUrl, '/') . '\/projects\/[^\/\?]+\/work_packages\/([0-9]+)/', $referenceText, $matches); - if (count($matches) > 1) { - return (int) $matches[1]; + $openProjectUrl = rtrim($this->config->getAppValue(Application::APP_ID, 'openproject_instance_url'),'/'); + foreach ($patterns as $pattern) { + $patternString ='/^' . preg_quote($openProjectUrl, '/') . $pattern; + preg_match($patternString, $referenceText, $patternMatches); + if (count($patternMatches) > 1) { + return (int) $patternMatches[1]; + } } - return null; } @@ -124,7 +124,7 @@ public function matchReference(string $referenceText): bool { * @inheritDoc */ public function resolveReference(string $referenceText): ?IReference { - if ($this->matchReference($referenceText)) { + if ($this->matchReference($referenceText) && OpenProjectAPIService::isAdminConfigOk($this->config)) { $wpId = $this->getWorkPackageIdFromUrl($referenceText); if ($wpId !== null) { $wpInfo = $this->openProjectAPIService->getWorkPackageInfo($this->userId, $wpId); diff --git a/lib/Search/OpenProjectSearchProvider.php b/lib/Search/OpenProjectSearchProvider.php index e6a85957b..c8e16175f 100644 --- a/lib/Search/OpenProjectSearchProvider.php +++ b/lib/Search/OpenProjectSearchProvider.php @@ -110,24 +110,14 @@ public function search(IUser $user, ISearchQuery $query): SearchResult { $offset = $offset ? intval($offset) : 0; $openprojectUrl = OpenProjectAPIService::sanitizeUrl($this->config->getAppValue(Application::APP_ID, 'openproject_instance_url')); $accessToken = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'token'); - - if ($accessToken === '') { + $searchEnabled = $this->config->getUserValue( + $user->getUID(), + Application::APP_ID, 'search_enabled', + $this->config->getAppValue(Application::APP_ID, 'default_enable_unified_search', '0')) === '1'; + if ($accessToken === '' || !$searchEnabled) { return SearchResult::paginated($this->getName(), [], 0); } - $routeFrom = $query->getRoute(); - $requestedFromSmartPicker = $routeFrom === '' || $routeFrom === 'smart-picker'; - - if (!$requestedFromSmartPicker) { - $searchEnabled = $this->config->getUserValue( - $user->getUID(), - Application::APP_ID, 'search_enabled', - $this->config->getAppValue(Application::APP_ID, 'default_enable_unified_search', '0')) === '1'; - if (!$searchEnabled) { - return SearchResult::paginated($this->getName(), [], 0); - } - } - $searchResults = $this->service->searchWorkPackage($user->getUID(), $term, null, false); $searchResults = array_slice($searchResults, $offset, $limit); diff --git a/lib/Service/OpenProjectAPIService.php b/lib/Service/OpenProjectAPIService.php index 47a748fb2..04e957172 100644 --- a/lib/Service/OpenProjectAPIService.php +++ b/lib/Service/OpenProjectAPIService.php @@ -1129,8 +1129,12 @@ public function generateAppPasswordTokenForUser(): string { */ public function deleteAppPassword(): void { if ($this->hasAppPassword()) { - $tokenId = $this->tokenProvider->getTokenByUser(Application::OPEN_PROJECT_ENTITIES_NAME)[0]->getId(); - $this->tokenProvider->invalidateTokenById(Application::OPEN_PROJECT_ENTITIES_NAME, $tokenId); + $tokens = $this->tokenProvider->getTokenByUser(Application::OPEN_PROJECT_ENTITIES_NAME); + foreach ($tokens as $token) { + if ($token->getName() === Application::OPEN_PROJECT_ENTITIES_NAME) { + $this->tokenProvider->invalidateTokenById(Application::OPEN_PROJECT_ENTITIES_NAME, $token->getId()); + } + } } } @@ -1140,7 +1144,13 @@ public function deleteAppPassword(): void { * @return bool */ public function hasAppPassword(): bool { - return sizeof($this->tokenProvider->getTokenByUser(Application::OPEN_PROJECT_ENTITIES_NAME)) === 1; + $tokens = $this->tokenProvider->getTokenByUser(Application::OPEN_PROJECT_ENTITIES_NAME); + foreach ($tokens as $token) { + if ($token->getName() === Application::OPEN_PROJECT_ENTITIES_NAME) { + return true; + } + } + return false; } /** @@ -1151,11 +1161,14 @@ public function hasAppPassword(): bool { */ public function getWorkPackageInfo(string $userId, int $wpId): array { $result[] = null; - $searchResult = $this->searchWorkPackage($userId, null, null, false, $wpId); - $result['title'] = $this->getSubline($searchResult[0]); - $result['description'] = $this->getMainText($searchResult[0]); - $result['imageUrl'] = $this->getOpenProjectUserAvatarUrl($searchResult[0]); - $result['entry'] = $searchResult[0]; + $accessToken = $this->config->getUserValue($userId, Application::APP_ID, 'token'); + if ($accessToken) { + $searchResult = $this->searchWorkPackage($userId, null, null, false, $wpId); + $result['title'] = $this->getSubline($searchResult[0]); + $result['description'] = $this->getMainText($searchResult[0]); + $result['imageUrl'] = $this->getOpenProjectUserAvatarUrl($searchResult[0]); + $result['entry'] = $searchResult[0]; + } return $result; } diff --git a/phpstan.neon b/phpstan.neon index b6fc32062..ada9c13d0 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -9,3 +9,4 @@ parameters: reportUnmatchedIgnoredErrors: false excludePaths: - './lib/Reference/WorkPackageReferenceProvider.php' + - './tests/lib/Reference/WorkPackageReferenceProviderTest.php' diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index 6421bd0ee..428a8bce5 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -4,7 +4,9 @@
+ :is-complete="isServerHostFormComplete" + :is-dark-theme="isDarkTheme" /> + + :is-disabled="isOPOAuthFormInDisableMode" + :is-dark-theme="isDarkTheme" />
+ :is-disabled="isNcOAuthFormInDisableMode" + :is-dark-theme="isDarkTheme" />
+
+ + + {{ t('integration_openproject', 'Create Nextcloud OAuth values') }} + +
- Automatic managed folders: {{ opUserAppPassword ? t('integration_openproject', 'Active') : t('integration_openproject', 'Inactive') }} + Automatically managed folders: {{ opUserAppPassword ? t('integration_openproject', 'Active') : t('integration_openproject', 'Inactive') }}
+ :is-disabled="isOPUserAppPasswordInDisableMode" + :is-dark-theme="isDarkTheme" />
-
+
{{ index }}
+ + + + + + + + + diff --git a/src/components/tab/EmptyContent.vue b/src/components/tab/EmptyContent.vue index 0ad99eb51..621ab5361 100644 --- a/src/components/tab/EmptyContent.vue +++ b/src/components/tab/EmptyContent.vue @@ -3,10 +3,11 @@
- + +
-
+
{{ emptyContentTitleMessage }}
@@ -25,6 +26,7 @@ import LinkPlusIcon from 'vue-material-design-icons/LinkPlus.vue' import LinkOffIcon from 'vue-material-design-icons/LinkOff.vue' import CheckIcon from 'vue-material-design-icons/Check.vue' +import OpenProjectIcon from '../icons/OpenProjectIcon.vue' import { generateUrl } from '@nextcloud/router' import { translate as t } from '@nextcloud/l10n' import OAuthConnectButton from '../OAuthConnectButton.vue' @@ -32,7 +34,7 @@ import { STATE } from '../../utils.js' export default { name: 'EmptyContent', - components: { OAuthConnectButton, LinkPlusIcon, LinkOffIcon, CheckIcon }, + components: { OAuthConnectButton, LinkPlusIcon, LinkOffIcon, CheckIcon, OpenProjectIcon }, props: { state: { type: String, @@ -57,6 +59,10 @@ export default { type: Boolean, default: false, }, + isSmartPicker: { + type: Boolean, + default: false, + }, }, data() { return { @@ -109,9 +115,11 @@ export default { display: flex; align-items: center; justify-content: center; - img { - height: 50px; - width: 50px; + &--openproject { + margin: 90px; + display: block; + align-items: center; + justify-content: center; } } &--message { diff --git a/src/components/tab/SearchInput.vue b/src/components/tab/SearchInput.vue index c486dc2fd..7c9570ead 100644 --- a/src/components/tab/SearchInput.vue +++ b/src/components/tab/SearchInput.vue @@ -28,6 +28,7 @@ + + diff --git a/tests/acceptance/features/api/setup.feature b/tests/acceptance/features/api/setup.feature index 24d14f461..68d543be6 100644 --- a/tests/acceptance/features/api/setup.feature +++ b/tests/acceptance/features/api/setup.feature @@ -538,10 +538,9 @@ Feature: setup the integration through an API And groupfolder "OpenProject" should be managed by the user "OpenProject" # the next step is only for the tests, because that user has a random password Given the administrator has changed the password of "OpenProject" to the default testing password - Then user "OpenProject" should have a folder called "OpenProject" - + And user "OpenProject" should have a folder called "OpenProject" # folders inside the OpenProject folder can only be deleted/renamed by the OpenProject user - Given user "Carol" has been created + And user "Carol" has been created And user "Carol" has been added to the group "OpenProject" And user "OpenProject" has created folder "/OpenProject/project-abc" Then user "Carol" should have a folder called "OpenProject/project-abc" @@ -635,9 +634,12 @@ Feature: setup the integration through an API When user "OpenProject" sends a "PROPFIND" request to "/remote.php/webdav" using current app password Then the HTTP status code should be "207" + # this is to provide test coverage for issues like this + # https://community.openproject.org/projects/nextcloud-integration/work_packages/49621 + When a new browser session for "Openproject" starts # but other values can be updated by sending a PATCH request # also we can replace old app password by sending PATCH request to get new user app password - When the administrator sends a PATCH request to the "setup" endpoint with this data: + And the administrator sends a PATCH request to the "setup" endpoint with this data: """ { "values" : { diff --git a/tests/acceptance/features/bootstrap/FeatureContext.php b/tests/acceptance/features/bootstrap/FeatureContext.php index ae07a4cad..56d8bb9a0 100644 --- a/tests/acceptance/features/bootstrap/FeatureContext.php +++ b/tests/acceptance/features/bootstrap/FeatureContext.php @@ -8,6 +8,7 @@ use GuzzleHttp\Client; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Cookie\CookieJar; use PHPUnit\Framework\Assert; use Psr\Http\Message\ResponseInterface; @@ -39,6 +40,9 @@ class FeatureContext implements Context { private ?ResponseInterface $response = null; + private CookieJar $cookieJar; + private string $requestToken; + public function getAdminUsername(): string { return $this->adminUsername; } @@ -55,6 +59,20 @@ public function getBaseUrl(): string { return $this->baseUrl; } + /** + * @return string + */ + public function getRequestToken():string { + return $this->requestToken; + } + + /** + * @return CookieJar + */ + public function getCookieJar():CookieJar { + return $this->cookieJar; + } + /** * @return ResponseInterface|null */ @@ -83,6 +101,7 @@ public function __construct( $this->adminUsername = $adminUsername; $this->adminPassword = $adminPassword; $this->regularUserPassword = $regularUserPassword; + $this->cookieJar = new CookieJar(); } /** @@ -909,6 +928,70 @@ public function theContentOfFileAtForUserShouldBe( Assert::assertSame($content, $this->response->getBody()->getContents()); } + /** + * @When a new browser session for :user starts + * + * @param string $user + * + * @return void + */ + public function aNewBrowserSessionForUserStarts(string $user):void { + $loginUrl = $this->getBaseUrl() . '/index.php/login'; + $options['cookies'] = $this->getCookieJar(); + // Request a new session and extract CSRF token + $this->setResponse( + $this->sendHttpRequest( + $loginUrl, + null, + null, + 'GET', + null, + null, + $options + ) + ); + $this->theHttpStatusCodeShouldBe(200); + $this->extractRequestTokenFromResponse($this->getResponse()); + + // Login and extract new token + $body = [ + 'user' => $user, + 'password' => $this->getRegularUserPassword(), + 'requesttoken' => $this->getRequestToken() + ]; + $options['cookies'] = $this->getCookieJar(); + $this->setResponse( + $this->sendHttpRequest( + $loginUrl, + null, + null, + 'POST', + null, + $body, + $options + ) + ); + $this->theHttpStatusCodeShouldBe(200); + $this->extractRequestTokenFromResponse($this->getResponse()); + } + + /** + * @param ResponseInterface $response + * + * @return void + */ + public function extractRequestTokenFromResponse(ResponseInterface $response):void { + $this->requestToken = \substr( + \preg_replace( + '/(.*)data-requesttoken="(.*)">(.*)/sm', + '\2', + $response->getBody()->getContents() + ), + 0, + 89 + ); + } + /** * @param string|null $username * @param string|null $password diff --git a/tests/jest/components/AdminSettings.spec.js b/tests/jest/components/AdminSettings.spec.js index cf0292d70..a7168be9a 100644 --- a/tests/jest/components/AdminSettings.spec.js +++ b/tests/jest/components/AdminSettings.spec.js @@ -146,6 +146,7 @@ describe('AdminSettings.vue', () => { openproject_client_id: 'abcd', openproject_client_secret: 'abcdefgh', nc_oauth_client: null, + fresh_project_folder_setup: true, }, { server: F_MODES.VIEW, @@ -745,6 +746,30 @@ describe('AdminSettings.vue', () => { }) }) }) + describe('recreate button', () => { + it('should be displayed if nextcloud oauth credentials is empty and everything is set', async () => { + const wrapper = getMountedWrapper({ + state: { + openproject_instance_url: 'http://openproject.com', + openproject_client_id: 'op-client-id', + openproject_client_secret: 'op-client-secret', + nc_oauth_client: null, + }, + formMode: { + projectFolderSetUp: F_MODES.VIEW, + }, + showDefaultManagedProjectFolders: true, + isFormCompleted: { + projectFolderSetUp: true, + }, + + }) + const resetButton = wrapper.find(selectors.resetNcOAuthFormButton) + expect(resetButton.isVisible()).toBe(true) + expect(resetButton.text()).toBe('Create Nextcloud OAuth values') + wrapper.destroy() + }) + }) describe('edit mode', () => { it('should show the form and hide the field values', async () => { const wrapper = getWrapper({ diff --git a/tests/jest/components/__snapshots__/AdminSettings.spec.js.snap b/tests/jest/components/__snapshots__/AdminSettings.spec.js.snap index 0aaebe1c1..d34dde95b 100644 --- a/tests/jest/components/__snapshots__/AdminSettings.spec.js.snap +++ b/tests/jest/components/__snapshots__/AdminSettings.spec.js.snap @@ -12,6 +12,7 @@ exports[`AdminSettings.vue Nextcloud OAuth values form edit mode should show the
+
`; @@ -27,13 +28,14 @@ exports[`AdminSettings.vue Nextcloud OAuth values form view mode with complete v
+
`; exports[`AdminSettings.vue OpenProject OAuth values form edit mode should show the form and hide the field values 1`] = `
-
+
2
@@ -103,7 +105,7 @@ exports[`AdminSettings.vue Project folders form (Project Folder Setup) view mode
-
Automatic managed folders: Inactive +
Automatically managed folders: Inactive
diff --git a/tests/jest/components/admin/__snapshots__/FormHeading.spec.js.snap b/tests/jest/components/admin/__snapshots__/FormHeading.spec.js.snap index d49944d77..0ef68f125 100644 --- a/tests/jest/components/admin/__snapshots__/FormHeading.spec.js.snap +++ b/tests/jest/components/admin/__snapshots__/FormHeading.spec.js.snap @@ -2,7 +2,7 @@ exports[`FormHeading.vue is complete prop should hide the checkmark icon and show the index if not complete 1`] = `
-
+
1
@@ -22,7 +22,7 @@ exports[`FormHeading.vue is complete prop should show checkmark icon, add green exports[`FormHeading.vue is disabled prop should add disabled class to the form heading 1`] = `
-
+
1
diff --git a/tests/jest/components/tab/SearchInput.spec.js b/tests/jest/components/tab/SearchInput.spec.js index 13afea1fa..cd99ddadb 100644 --- a/tests/jest/components/tab/SearchInput.spec.js +++ b/tests/jest/components/tab/SearchInput.spec.js @@ -9,6 +9,7 @@ import workPackagesSearchResponseNoAssignee from '../../fixtures/workPackagesSea import workPackageSearchReqResponse from '../../fixtures/workPackageSearchReqResponse.json' import workPackageObjectsInSearchResults from '../../fixtures/workPackageObjectsInSearchResults.json' import { STATE } from '../../../../src/utils.js' +import * as initialState from '@nextcloud/initial-state' jest.mock('@nextcloud/axios') jest.mock('@nextcloud/dialogs') @@ -23,6 +24,13 @@ jest.mock('lodash/debounce', () => }) ) +// eslint-disable-next-line no-import-assign,import/namespace +initialState.loadState = jest.fn(() => { + return { + openproject_instance_url: null, + } +}) + global.t = (app, text) => text const localVue = createLocalVue() @@ -143,6 +151,7 @@ describe('SearchInput.vue', () => { { params: { searchQuery: 'orga', + isSmartPicker: false, }, }, ) @@ -487,7 +496,55 @@ describe('SearchInput.vue', () => { }) }) }) + + describe('search with smartpicker', () => { + let axiosGetSpy + beforeEach(async () => { + axiosGetSpy = jest.spyOn(axios, 'get') + .mockImplementationOnce(() => Promise.resolve({ + status: 200, + data: [], + })) + wrapper = mountSearchInput() + const inputField = wrapper.find(inputSelector) + await inputField.setValue('orga') + await wrapper.setData({ + searchResults: [{ + id: 999, + projectId: 1, + }], + openprojectUrl: 'https://openproject.com', + }) + await localVue.nextTick() + await wrapper.setProps({ + isSmartPicker: true, + }) + await localVue.nextTick() + }) + afterEach(() => { + axiosGetSpy.mockRestore() + }) + it('should emit an action', async () => { + const ncSelectItem = wrapper.find(firstWorkPackageSelector) + await ncSelectItem.trigger('click') + const savedEvent = wrapper.emitted('submit') + expect(savedEvent).toHaveLength(1) + expect(savedEvent[0][0]).toEqual('https://openproject.com/wp/999') + }) + + it('should not send a request to link file to workpackage', async () => { + const postSpy = jest.spyOn(axios, 'post') + .mockImplementationOnce(() => Promise.resolve({ + status: 200, + })) + const ncSelectItem = wrapper.find(firstWorkPackageSelector) + await ncSelectItem.trigger('click') + expect(postSpy).not.toBeCalled() + postSpy.mockRestore() + }) + }) }) + function mountSearchInput(fileInfo = {}, linkedWorkPackages = [], data = {}) { return mount(SearchInput, { localVue, diff --git a/tests/lib/Reference/WorkPackageReferenceProviderTest.php b/tests/lib/Reference/WorkPackageReferenceProviderTest.php new file mode 100644 index 000000000..ff9424287 --- /dev/null +++ b/tests/lib/Reference/WorkPackageReferenceProviderTest.php @@ -0,0 +1,82 @@ + + * + * @author Swikriti Tripathi + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\OpenProject\Reference; + +use OC\Collaboration\Reference\ReferenceManager; +use OCA\OpenProject\AppInfo\Application; +use OCA\OpenProject\Service\OpenProjectAPIService; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use PHPUnit\Framework\TestCase; +use OC_Util; + +class WorkPackageReferenceProviderTest extends TestCase { + protected function setUp(): void { + if (version_compare(OC_Util::getVersionString(), '26') < 0) { + $this->markTestSkipped('WorkPackageReferenceProvider is only available from nextcloud 26 so skip the tests on versions below'); + } + } + + /** + * @return array + */ + public function getWorkPackageIdFromUrlDataProvider() { + return[ + ['https://openproject.org/projects/123/work_packages/1111'], + ['https://openproject.org/wp/1111'], + ['https://openproject.org/projects/123/work_packages/details/1111'], + ['https://openproject.org/work_packages/details/1111'], + ['https://openproject.org/work_packages/1111'], + ['https://openproject.org/work_packages/details/1111/overview'], + ['https://openproject.org/projects/wielands-playground/boards/290/details/1111/overview'], + ['https://openproject.org/projects/wielands-playground/calendars/new/details/1111/overview?cdate=2023-08-01&cview=dayGridMonth'], + ['https://openproject.org/projects/wielands-playground/calendars/519/details/1111/overview?cdate=2023-08-01&cview=dayGridMonth'], + ['https://openproject.org/projects/blabla/bcf/details/1111/overview?'], + ['https://openproject.org/notifications/details/1111/activity'], + ['https://openproject.org/projects/openproject/team_planners/12454/details/1111/overview?cdate=2023-08-14&cview=resourceTimelineWorkWeek'] + ]; + } + /** + * @dataProvider getWorkPackageIdFromUrlDataProvider + * @param string $refrenceText + * @return void + */ + public function testGetWorkPackageIdFromUrl(string $refrenceText) { + $configMock = $this->getMockBuilder(IConfig::class)->getMock(); + $configMock->method('getAppValue')->with(Application::APP_ID, 'openproject_instance_url') + ->willReturn("https://openproject.org"); + $refrenceProvider = new WorkPackageReferenceProvider( + $configMock, + $this->createMock(IL10N::class), + $this->createMock(IURLGenerator::class), + $this->createMock(ReferenceManager::class), + $this->createMock(OpenProjectAPIService::class), + 'testUser' + ); + $result = $refrenceProvider->getWorkPackageIdFromUrl($refrenceText); + + $this->assertSame(1111, $result); + } +} diff --git a/tests/lib/Service/OpenProjectAPIServiceTest.php b/tests/lib/Service/OpenProjectAPIServiceTest.php index 113c01888..a18ae2e6b 100644 --- a/tests/lib/Service/OpenProjectAPIServiceTest.php +++ b/tests/lib/Service/OpenProjectAPIServiceTest.php @@ -400,6 +400,7 @@ private function getOpenProjectAPIService( * @param ISubAdmin|null $subAdminManagerMock * @param ISecureRandom|null $iSecureRandomMock * @param IConfig|null $configMock + * @param IProvider|null $tokenProviderMock * @return OpenProjectAPIService|MockObject */ private function getServiceMock( @@ -411,7 +412,8 @@ private function getServiceMock( $appManagerMock = null, $subAdminManagerMock = null, $iSecureRandomMock = null, - $configMock = null + $configMock = null, + $tokenProviderMock = null ): OpenProjectAPIService { $onlyMethods[] = 'getBaseUrl'; if ($rootMock === null) { @@ -438,6 +440,9 @@ private function getServiceMock( if ($configMock === null) { $configMock = $this->createMock(IConfig::class); } + if ($tokenProviderMock === null) { + $tokenProviderMock = $this->createMock(IProvider::class); + } $mock = $this->getMockBuilder(OpenProjectAPIService::class) ->setConstructorArgs( [ @@ -454,7 +459,7 @@ private function getServiceMock( $groupManagerMock, $appManagerMock, $this->createMock(IDBConnection::class), - $this->createMock(IProvider::class), + $tokenProviderMock, $iSecureRandomMock, $this->createMock(IEventDispatcher::class), $subAdminManagerMock, @@ -1739,6 +1744,150 @@ public function testIsSystemReadyForGroupFolderSetUpUserOrGroupExistsException( $service->isSystemReadyForProjectFolderSetUp(); } + public function testProjectFolderHasAppPassword(): void { + $tokenProviderMock = $this->getMockBuilder(IProvider::class)->disableOriginalConstructor() + ->getMock(); + $tokenMock = $this->getMockBuilder(IToken::class)->getMock(); + $tokenMock + ->method('getName') + ->willReturn('OpenProject'); + $tokenProviderMock + ->method('getTokenByUser') + ->with(Application::OPEN_PROJECT_ENTITIES_NAME) + ->willReturn([$tokenMock]); + $service = $this->getServiceMock([], + null, + null, + null, + null, + null, + null, + null, + null, + $tokenProviderMock); + $this->assertTrue($service->hasAppPassword()); + } + + public function testProjectFolderHasMultipleAppPassword(): void { + $tokenProviderMock = $this->getMockBuilder(IProvider::class)->disableOriginalConstructor() + ->getMock(); + $tokenMock1 = $this->getMockBuilder(IToken::class)->getMock(); + $tokenMock1 + ->method('getName') + ->willReturn('session'); + $tokenMock2 = $this->getMockBuilder(IToken::class)->getMock(); + $tokenMock2 + ->method('getName') + ->willReturn('test'); + $tokenMock3 = $this->getMockBuilder(IToken::class)->getMock(); + $tokenMock3 + ->method('getName') + ->willReturn('new-token'); + $tokenMock4 = $this->getMockBuilder(IToken::class)->getMock(); + $tokenMock4 + ->method('getName') + ->willReturn('OpenProject'); + $tokenProviderMock + ->method('getTokenByUser') + ->with(Application::OPEN_PROJECT_ENTITIES_NAME) + ->willReturn([$tokenMock1,$tokenMock2,$tokenMock3,$tokenMock4]); + $service = $this->getServiceMock([], + null, + null, + null, + null, + null, + null, + null, + null, + $tokenProviderMock); + $this->assertTrue($service->hasAppPassword()); + } + + public function testProjectFolderHasAppPasswordNegativeCondition(): void { + $tokenProviderMock = $this->getMockBuilder(IProvider::class)->disableOriginalConstructor() + ->getMock(); + $tokenMock = $this->getMockBuilder(IToken::class)->getMock(); + $tokenMock + ->method('getName') + ->willReturn('session'); + $tokenProviderMock + ->method('getTokenByUser') + ->with(Application::OPEN_PROJECT_ENTITIES_NAME) + ->willReturn([$tokenMock]); + $service = $this->getServiceMock([], + null, + null, + null, + null, + null, + null, + null, + null, + $tokenProviderMock); + $this->assertFalse($service->hasAppPassword()); + } + + public function testProjectFolderDeleteAppPassword(): void { + $tokenProviderMock = $this->getMockBuilder(IProvider::class)->disableOriginalConstructor() + ->getMock(); + $tokenMock1 = $this->getMockBuilder(IToken::class)->getMock(); + $tokenMock1 + ->method('getName') + ->willReturn('session'); + $tokenMock1 + ->method('getId') + ->willReturn(1); + $tokenMock2 = $this->getMockBuilder(IToken::class)->getMock(); + $tokenMock2 + ->method('getName') + ->willReturn('test'); + $tokenMock2 + ->method('getId') + ->willReturn(2); + $tokenMock3 = $this->getMockBuilder(IToken::class)->getMock(); + $tokenMock3 + ->method('getName') + ->willReturn('new-token'); + $tokenMock3 + ->method('getId') + ->willReturn(3); + $tokenMock4 = $this->getMockBuilder(IToken::class)->getMock(); + $tokenMock4 + ->method('getName') + ->willReturn('OpenProject'); + $tokenMock4 + ->method('getId') + ->willReturn(4); + $tokenMock5 = $this->getMockBuilder(IToken::class)->getMock(); + $tokenMock5 + ->method('getName') + ->willReturn('OpenProject'); + $tokenMock5 + ->method('getId') + ->willReturn(5); + $tokenProviderMock + ->method('getTokenByUser') + ->with(Application::OPEN_PROJECT_ENTITIES_NAME) + ->willReturn([$tokenMock1,$tokenMock2,$tokenMock3,$tokenMock4,$tokenMock5]); + $service = $this->getServiceMock(['hasAppPassword'], + null, + null, + null, + null, + null, + null, + null, + null, + $tokenProviderMock) + ; + $service->method('hasAppPassword')->willReturn(true); + $tokenProviderMock->expects($this->exactly(2)) + ->method('invalidateTokenById') + ->withConsecutive([Application::OPEN_PROJECT_ENTITIES_NAME, 4], [Application::OPEN_PROJECT_ENTITIES_NAME, 5]); + $service->deleteAppPassword(); + } + public function testLinkWorkPackageToFilePact(): void { $consumerRequest = new ConsumerRequest(); $consumerRequest