From 71afff4c206769c1066e62d25da01531a0be6027 Mon Sep 17 00:00:00 2001 From: Anna Larch Date: Mon, 9 Mar 2026 21:56:14 +0100 Subject: [PATCH] test: add federation tests to CI and fix some bugs discovered during the setup Signed-off-by: Anna Larch --- .github/workflows/integration-federation.yml | 94 +++++ lib/Db/BoardMapper.php | 14 +- lib/Db/Stack.php | 2 + lib/Service/BoardService.php | 12 + lib/Service/ExternalBoardService.php | 2 +- tests/integration/config/behat.yml | 7 + .../features/bootstrap/FederationContext.php | 345 ++++++++++++++++++ .../features/federation/cards.feature | 21 ++ .../features/federation/sharing.feature | 28 ++ .../features/federation/stacks.feature | 31 ++ tests/integration/run-federation.sh | 163 +++++++++ tests/integration/run.sh | 2 +- 12 files changed, 712 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/integration-federation.yml create mode 100644 tests/integration/features/bootstrap/FederationContext.php create mode 100644 tests/integration/features/federation/cards.feature create mode 100644 tests/integration/features/federation/sharing.feature create mode 100644 tests/integration/features/federation/stacks.feature create mode 100755 tests/integration/run-federation.sh diff --git a/.github/workflows/integration-federation.yml b/.github/workflows/integration-federation.yml new file mode 100644 index 0000000000..daf6bcc744 --- /dev/null +++ b/.github/workflows/integration-federation.yml @@ -0,0 +1,94 @@ +name: Federation integration tests + +on: + pull_request: + paths: + - '.github/workflows/integration-federation.yml' + - 'appinfo/**' + - 'lib/**' + - 'templates/**' + - 'tests/**' + - 'composer.json' + - 'composer.lock' + push: + branches: + - main + - master + - stable* + +permissions: + contents: read + +env: + APP_NAME: deck + +jobs: + federation: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php-versions: ['8.2'] + server-versions: ['master'] + + name: php${{ matrix.php-versions }}-${{ matrix.server-versions }} + + steps: + - name: Checkout server + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + repository: nextcloud/server + ref: ${{ matrix.server-versions }} + submodules: true + + - name: Checkout app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + path: apps/${{ env.APP_NAME }} + + - name: Checkout activity + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + repository: nextcloud/activity + ref: ${{ matrix.server-versions }} + path: apps/activity + + - name: Set up php ${{ matrix.php-versions }} + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.36.0 + with: + php-version: ${{ matrix.php-versions }} + extensions: mbstring, iconv, fileinfo, intl, sqlite, pdo_sqlite, apcu, gd + ini-values: + apc.enable_cli=on + coverage: none + + - name: Set up dependencies + working-directory: apps/${{ env.APP_NAME }} + run: composer i --no-dev + + - name: Set up Nextcloud + run: | + mkdir data + ./occ maintenance:install --verbose --database=sqlite --database-name=nextcloud --admin-user admin --admin-pass admin + ./occ config:system:set hashing_default_password --value=true --type=boolean + ./occ config:system:set memcache.local --value="\\OC\\Memcache\\APCu" + ./occ config:system:set memcache.distributed --value="\\OC\\Memcache\\APCu" + cat config/config.php + ./occ app:enable --force ${{ env.APP_NAME }} + + - name: Run federation behat tests + working-directory: apps/${{ env.APP_NAME }}/tests/integration + run: ./run-federation.sh + + - name: Print log + if: always() + run: | + if [ -f data/nextcloud.log ]; then + cat data/nextcloud.log + else + echo "Log file not found" + fi diff --git a/lib/Db/BoardMapper.php b/lib/Db/BoardMapper.php index 8d2706d9c4..3589668190 100644 --- a/lib/Db/BoardMapper.php +++ b/lib/Db/BoardMapper.php @@ -190,7 +190,7 @@ public function findAllByUser(string $userId, ?int $limit = null, ?int $offset = // FIXME this used to be a UNION to get boards owned by $userId and the user shares in one single query // Is it possible with the query builder? $qb = $this->db->getQueryBuilder(); - $qb->select('id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified') + $qb->select('id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') // this does not work in MySQL/PostgreSQL //->selectAlias('0', 'shared') ->from('deck_boards', 'b') @@ -230,7 +230,7 @@ public function findAllByUser(string $userId, ?int $limit = null, ?int $offset = // shared with user $qb = $this->db->getQueryBuilder(); - $qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified') + $qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') //->selectAlias('1', 'shared') ->from('deck_boards', 'b') ->innerJoin('b', 'deck_board_acl', 'acl', $qb->expr()->eq('b.id', 'acl.board_id')) @@ -298,7 +298,7 @@ public function findAllByGroups(string $userId, array $groups, ?int $limit = nul return []; } $qb = $this->db->getQueryBuilder(); - $qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified') + $qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') //->selectAlias('2', 'shared') ->from('deck_boards', 'b') ->innerJoin('b', 'deck_board_acl', 'acl', $qb->expr()->eq('b.id', 'acl.board_id')) @@ -354,7 +354,7 @@ public function findAllByCircles(string $userId, ?int $limit = null, ?int $offse } $qb = $this->db->getQueryBuilder(); - $qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified') + $qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') //->selectAlias('2', 'shared') ->from('deck_boards', 'b') ->innerJoin('b', 'deck_board_acl', 'acl', $qb->expr()->eq('b.id', 'acl.board_id')) @@ -404,7 +404,7 @@ public function findAllByCircles(string $userId, ?int $limit = null, ?int $offse public function findAllByTeam(string $teamId): array { $qb = $this->db->getQueryBuilder(); - $qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified') + $qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') ->from('deck_boards', 'b') ->innerJoin('b', 'deck_board_acl', 'acl', $qb->expr()->eq('b.id', 'acl.board_id')) ->where($qb->expr()->eq('acl.type', $qb->createNamedParameter(Acl::PERMISSION_TYPE_CIRCLE, IQueryBuilder::PARAM_INT))) @@ -432,7 +432,7 @@ public function findTeamsForBoard(int $boardId): array { public function isSharedWithTeam(int $boardId, string $teamId): bool { $qb = $this->db->getQueryBuilder(); - $qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified') + $qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') ->from('deck_boards', 'b') ->innerJoin('b', 'deck_board_acl', 'acl', $qb->expr()->eq('b.id', 'acl.board_id')) ->where($qb->expr()->eq('b.id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT))) @@ -458,7 +458,7 @@ public function findToDelete() { // add buffer of 5 min $timeLimit = time() - (60 * 5); $qb = $this->db->getQueryBuilder(); - $qb->select('id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified') + $qb->select('id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') ->from('deck_boards') ->where($qb->expr()->gt('deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->lt('deleted_at', $qb->createNamedParameter($timeLimit, IQueryBuilder::PARAM_INT))); diff --git a/lib/Db/Stack.php b/lib/Db/Stack.php index 482e72d34b..27d2defa5c 100644 --- a/lib/Db/Stack.php +++ b/lib/Db/Stack.php @@ -22,6 +22,8 @@ * @method \int getOrder() * @method void setOrder(int $order) * @method Card[] getCards() + * @method bool getIsDoneColumn() + * @method void setIsDoneColumn(bool $isDoneColumn) */ class Stack extends RelationalEntity { protected $title; diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index b91ec6e880..faf7102bc1 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -420,6 +420,18 @@ public function addAcl(int $boardId, int $type, $participant, bool $edit, bool $ $this->eventDispatcher->dispatchTyped(new AclCreatedEvent($acl)); + // Sync permissions to remote server since shareReceived() creates ACL with no permissions + if ($type === Acl::PERMISSION_TYPE_REMOTE && ($edit || $share || $manage)) { + $notification = $this->federationFactory->getCloudFederationNotification(); + $payload = [ + $newAcl->jsonSerialize(), + 'sharedSecret' => $newAcl->getToken(), + ]; + $notification->setMessage('update-permissions', 'deck', (string)$boardId, $payload); + $url = $this->cloudIdManager->resolveCloudId($participant); + $this->cloudFederationProviderManager->sendCloudNotification($url->getRemote(), $notification); + } + return $newAcl; } diff --git a/lib/Service/ExternalBoardService.php b/lib/Service/ExternalBoardService.php index 6125c63b30..c3e3fb89aa 100644 --- a/lib/Service/ExternalBoardService.php +++ b/lib/Service/ExternalBoardService.php @@ -165,7 +165,7 @@ public function createStackOnRemote( int $order = 0, ): array { $this->configService->ensureFederationEnabled(); - $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_EDIT, $this->userId, false, false); + $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_MANAGE, $this->userId, false, false); $shareToken = $localBoard->getShareToken(); $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); diff --git a/tests/integration/config/behat.yml b/tests/integration/config/behat.yml index 2204ff92e5..f37ac238e8 100644 --- a/tests/integration/config/behat.yml +++ b/tests/integration/config/behat.yml @@ -7,6 +7,8 @@ default: test: paths: - '%paths.base%/../features/' + filters: + tags: '~@federation' contexts: - ServerContext: baseUrl: http://localhost:8080/ @@ -16,3 +18,8 @@ default: - AttachmentContext - SearchContext - SessionContext + federation: + paths: + - '%paths.base%/../features/federation/' + contexts: + - FederationContext diff --git a/tests/integration/features/bootstrap/FederationContext.php b/tests/integration/features/bootstrap/FederationContext.php new file mode 100644 index 0000000000..7a1052dad6 --- /dev/null +++ b/tests/integration/features/bootstrap/FederationContext.php @@ -0,0 +1,345 @@ +localServerUrl = rtrim(getenv('TEST_SERVER_URL') ?: 'http://localhost:8080/', '/'); + $this->remoteServerUrl = rtrim(getenv('TEST_REMOTE_URL') ?: 'http://localhost:8280/', '/'); + $this->localOccPath = getenv('NEXTCLOUD_HOST_ROOT_DIR') ?: ''; + $this->remoteOccPath = getenv('NEXTCLOUD_REMOTE_ROOT_DIR') ?: ''; + $this->remoteConfigDir = getenv('NEXTCLOUD_REMOTE_CONFIG_DIR') ?: ''; + } + + private function getServerUrl(?string $server = null): string { + $server = $server ?? $this->currentServer; + return $server === 'REMOTE' ? $this->remoteServerUrl : $this->localServerUrl; + } + + private function getOccPath(?string $server = null): string { + $server = $server ?? $this->currentServer; + return $server === 'REMOTE' ? $this->remoteOccPath : $this->localOccPath; + } + + private function getOccEnvPrefix(?string $server = null): string { + $server = $server ?? $this->currentServer; + if ($server === 'REMOTE' && !empty($this->remoteConfigDir)) { + return 'NEXTCLOUD_CONFIG_DIR=' . escapeshellarg($this->remoteConfigDir) . ' '; + } + return ''; + } + + private function getPassword(string $user): string { + return ($user === 'admin') ? 'admin' : '123456'; + } + + /** + * Send an OCS request with Basic Auth to the current (or specified) server. + */ + private function sendOCSRequest(string $method, string $path, array $data = [], ?string $user = null, ?string $server = null): ResponseInterface { + $user = $user ?? $this->currentUser; + $baseUrl = $this->getServerUrl($server); + $url = $baseUrl . '/ocs/v2.php/' . ltrim($path, '/'); + + $client = new Client(); + $options = [ + 'auth' => [$user, $this->getPassword($user)], + 'headers' => [ + 'OCS-APIREQUEST' => 'true', + 'Accept' => 'application/json', + ], + ]; + if (!empty($data)) { + $options['json'] = $data; + } + + try { + $this->response = $client->request($method, $url, $options); + } catch (ClientException $e) { + $this->response = $e->getResponse(); + } + + return $this->response; + } + + private function getOCSData(): array { + $this->response->getBody()->seek(0); + $json = json_decode((string)$this->response->getBody(), true); + return $json['ocs']['data'] ?? []; + } + + // ---- Step definitions ---- + + /** + * @Given /^using server "([^"]*)"$/ + */ + public function usingServer(string $server) { + $server = strtoupper($server); + if (!in_array($server, ['LOCAL', 'REMOTE'])) { + throw new \InvalidArgumentException('Server must be LOCAL or REMOTE'); + } + $this->currentServer = $server; + } + + /** + * @Given /^acting as user "([^"]*)"$/ + */ + public function actingAsUser(string $user) { + $this->currentUser = $user; + } + + /** + * @Given /^federation is enabled on "([^"]*)"$/ + */ + public function federationIsEnabledOn(string $server) { + $occPath = $this->getOccPath($server); + if (empty($occPath)) { + throw new \RuntimeException("OCC path not configured for server $server"); + } + $envPrefix = $this->getOccEnvPrefix($server); + exec("{$envPrefix}php {$occPath}/occ config:app:set deck federationEnabled --value=yes 2>&1", $output, $returnCode); + if ($returnCode !== 0) { + throw new \RuntimeException("Failed to enable federation on {$server}: " . implode("\n", $output)); + } + // Also enable server-to-server sharing which is required by ensureFederationEnabled() + exec("{$envPrefix}php {$occPath}/occ config:app:set files_sharing outgoing_server2server_share_enabled --value=yes 2>&1"); + exec("{$envPrefix}php {$occPath}/occ config:app:set files_sharing incoming_server2server_share_enabled --value=yes 2>&1"); + } + + /** + * @Given /^user "([^"]*)" exists on "([^"]*)"$/ + */ + public function userExistsOn(string $user, string $server) { + $occPath = $this->getOccPath($server); + if (empty($occPath)) { + throw new \RuntimeException("OCC path not configured for server $server"); + } + + $envPrefix = $this->getOccEnvPrefix($server); + $password = $this->getPassword($user); + exec("{$envPrefix}OC_PASS={$password} php {$occPath}/occ user:add --password-from-env {$user} 2>&1", $output, $returnCode); + if ($returnCode !== 0) { + // User already exists — reset password to ensure it matches + exec("{$envPrefix}OC_PASS={$password} php {$occPath}/occ user:resetpassword --password-from-env {$user} 2>&1"); + } + } + + /** + * @Given /^creates a board named "([^"]*)" with color "([^"]*)"$/ + */ + public function createsABoardNamedWithColor(string $title, string $color) { + $this->sendOCSRequest('POST', '/apps/deck/api/v1.0/boards', [ + 'title' => $title, + 'color' => $color, + ]); + $this->board = $this->getOCSData(); + Assert::assertArrayHasKey('id', $this->board, 'Board creation failed: ' . (string)$this->response->getBody()); + } + + /** + * @Given /^create a stack named "([^"]*)"$/ + */ + public function createAStackNamed(string $name) { + Assert::assertNotNull($this->board, 'No board created yet'); + $this->sendOCSRequest('POST', '/apps/deck/api/v1.0/stacks', [ + 'title' => $name, + 'boardId' => $this->board['id'], + 'order' => 0, + ]); + $this->stack = $this->getOCSData(); + Assert::assertArrayHasKey('id', $this->stack, 'Stack creation failed: ' . (string)$this->response->getBody()); + } + + /** + * @When /^user "([^"]*)" on "([^"]*)" shares the board with federated user "([^"]*)"$/ + */ + public function userSharesBoardWithFederatedUser(string $user, string $server, string $remoteUser, ?TableNode $permissions = null) { + $remoteServerUrl = ($server === 'LOCAL') ? $this->remoteServerUrl : $this->localServerUrl; + $federatedUserId = $remoteUser . '@' . $remoteServerUrl; + + $defaults = [ + 'permissionEdit' => '0', + 'permissionShare' => '0', + 'permissionManage' => '0', + ]; + $tableRows = isset($permissions) ? $permissions->getRowsHash() : []; + $result = array_merge($defaults, $tableRows); + + Assert::assertNotNull($this->board, 'No board created yet'); + $this->sendOCSRequest('POST', '/apps/deck/api/v1.0/boards/' . $this->board['id'] . '/acl', [ + 'type' => 6, // Acl::PERMISSION_TYPE_REMOTE + 'participant' => $federatedUserId, + 'permissionEdit' => $result['permissionEdit'] === '1', + 'permissionShare' => $result['permissionShare'] === '1', + 'permissionManage' => $result['permissionManage'] === '1', + ], $user, $server); + } + + /** + * @Then /^user "([^"]*)" on "([^"]*)" should see the board "([^"]*)"$/ + */ + public function userOnShouldSeeBoard(string $user, string $server, string $boardTitle) { + $this->sendOCSRequest('GET', '/apps/deck/api/v1.0/boards', [], $user, $server); + $boards = $this->getOCSData(); + + $found = false; + foreach ($boards as $board) { + if ($board['title'] === $boardTitle) { + $found = true; + break; + } + } + + Assert::assertTrue($found, "Board '{$boardTitle}' not found for user '{$user}' on {$server}"); + } + + /** + * @Then /^user "([^"]*)" on "([^"]*)" should not see the board "([^"]*)"$/ + */ + public function userOnShouldNotSeeBoard(string $user, string $server, string $boardTitle) { + $this->sendOCSRequest('GET', '/apps/deck/api/v1.0/boards', [], $user, $server); + $boards = $this->getOCSData(); + + $found = false; + foreach ($boards as $board) { + if ($board['title'] === $boardTitle) { + $found = true; + break; + } + } + + Assert::assertFalse($found, "Board '{$boardTitle}' should not be visible for user '{$user}' on {$server}"); + } + + /** + * @When /^user "([^"]*)" on "([^"]*)" creates a stack "([^"]*)" on the federated board$/ + */ + public function userCreatesStackOnFederatedBoard(string $user, string $server, string $stackTitle) { + $federatedBoard = $this->findFederatedBoard($user, $server); + + $this->sendOCSRequest('POST', '/apps/deck/api/v1.0/stacks', [ + 'title' => $stackTitle, + 'boardId' => $federatedBoard['id'], + 'order' => 0, + ], $user, $server); + } + + /** + * @When /^user "([^"]*)" on "([^"]*)" creates a card "([^"]*)" on stack "([^"]*)" on the federated board$/ + */ + public function userCreatesCardOnFederatedBoard(string $user, string $server, string $cardTitle, string $stackTitle) { + $federatedBoard = $this->findFederatedBoard($user, $server); + + // Get stacks to find the right one + $this->sendOCSRequest('GET', '/apps/deck/api/v1.0/stacks/' . $federatedBoard['id'], [], $user, $server); + $stacks = $this->getOCSData(); + + $stackId = null; + foreach ($stacks as $stack) { + if ($stack['title'] === $stackTitle) { + $stackId = $stack['id']; + break; + } + } + + Assert::assertNotNull($stackId, "Stack '{$stackTitle}' not found on federated board"); + + $this->sendOCSRequest('POST', '/apps/deck/api/v1.0/cards', [ + 'title' => $cardTitle, + 'stackId' => $stackId, + 'boardId' => $federatedBoard['id'], + ], $user, $server); + } + + /** + * @Then /^the OCS response should have status code "([^"]*)"$/ + */ + public function theOcsResponseShouldHaveStatusCode(string $code) { + $currentCode = $this->response->getStatusCode(); + Assert::assertEquals((int)$code, $currentCode, "Expected status code {$code} but got {$currentCode}"); + } + + /** + * @Then /^user "([^"]*)" on "([^"]*)" should see (\d+) stacks? on the board "([^"]*)"$/ + */ + public function userShouldSeeStacksOnBoard(string $user, string $server, int $count, string $boardTitle) { + // Find the most recently created board with the given title + $this->sendOCSRequest('GET', '/apps/deck/api/v1.0/boards', [], $user, $server); + $boards = $this->getOCSData(); + + $boardId = null; + foreach ($boards as $board) { + if ($board['title'] === $boardTitle) { + if ($boardId === null || $board['id'] > $boardId) { + $boardId = $board['id']; + } + } + } + Assert::assertNotNull($boardId, "Board '{$boardTitle}' not found"); + + $this->sendOCSRequest('GET', '/apps/deck/api/v1.0/stacks/' . $boardId, [], $user, $server); + $stacks = $this->getOCSData(); + + Assert::assertCount($count, $stacks, "Expected {$count} stacks but got " . count($stacks)); + } + + private function findFederatedBoard(string $user, string $server): array { + Assert::assertNotNull($this->board, 'No board created in this scenario'); + $expectedTitle = $this->board['title']; + + // Federation shares may take a moment to propagate, retry a few times + for ($i = 0; $i < 10; $i++) { + $this->sendOCSRequest('GET', '/apps/deck/api/v1.0/boards', [], $user, $server); + $boards = $this->getOCSData(); + + // Find the most recently created federated board with the expected title + $match = null; + foreach ($boards as $board) { + if (!empty($board['externalId']) && $board['title'] === $expectedTitle) { + if ($match === null || $board['id'] > $match['id']) { + $match = $board; + } + } + } + + if ($match !== null) { + return $match; + } + + usleep(500000); // 500ms + } + + throw new \RuntimeException('No federated board "' . $expectedTitle . '" found for user ' . $user . ' on ' . $server); + } +} diff --git a/tests/integration/features/federation/cards.feature b/tests/integration/features/federation/cards.feature new file mode 100644 index 0000000000..b21510c12a --- /dev/null +++ b/tests/integration/features/federation/cards.feature @@ -0,0 +1,21 @@ +@federation +Feature: Federation card operations + Managing cards on federated boards + + Background: + Given using server "LOCAL" + And federation is enabled on "LOCAL" + And user "admin" exists on "LOCAL" + Given using server "REMOTE" + And federation is enabled on "REMOTE" + And user "admin" exists on "REMOTE" + + Scenario: Create a card on a federated board + Given using server "LOCAL" + And acting as user "admin" + And creates a board named "Card Board" with color "ff0000" + And create a stack named "ToDo" + When user "admin" on "LOCAL" shares the board with federated user "admin" + | permissionEdit | 1 | + And user "admin" on "REMOTE" creates a card "Remote Card" on stack "ToDo" on the federated board + Then the OCS response should have status code "200" diff --git a/tests/integration/features/federation/sharing.feature b/tests/integration/features/federation/sharing.feature new file mode 100644 index 0000000000..71ea775bf6 --- /dev/null +++ b/tests/integration/features/federation/sharing.feature @@ -0,0 +1,28 @@ +@federation +Feature: Federation board sharing + Share boards across federated Nextcloud instances + + Background: + Given using server "LOCAL" + And federation is enabled on "LOCAL" + And user "admin" exists on "LOCAL" + Given using server "REMOTE" + And federation is enabled on "REMOTE" + And user "admin" exists on "REMOTE" + + Scenario: Share a board with a federated user + Given using server "LOCAL" + And acting as user "admin" + And creates a board named "Shared Board" with color "ff0000" + When user "admin" on "LOCAL" shares the board with federated user "admin" + Then the OCS response should have status code "200" + And user "admin" on "REMOTE" should see the board "Shared Board" + + Scenario: Share a board with edit permissions + Given using server "LOCAL" + And acting as user "admin" + And creates a board named "Editable Board" with color "00ff00" + When user "admin" on "LOCAL" shares the board with federated user "admin" + | permissionEdit | 1 | + Then the OCS response should have status code "200" + And user "admin" on "REMOTE" should see the board "Editable Board" diff --git a/tests/integration/features/federation/stacks.feature b/tests/integration/features/federation/stacks.feature new file mode 100644 index 0000000000..117526d7d5 --- /dev/null +++ b/tests/integration/features/federation/stacks.feature @@ -0,0 +1,31 @@ +@federation +Feature: Federation stack operations + Managing stacks on federated boards + + Background: + Given using server "LOCAL" + And federation is enabled on "LOCAL" + And user "admin" exists on "LOCAL" + Given using server "REMOTE" + And federation is enabled on "REMOTE" + And user "admin" exists on "REMOTE" + + Scenario: List stacks on a federated board + Given using server "LOCAL" + And acting as user "admin" + And creates a board named "Stack Board" with color "ff0000" + And create a stack named "ToDo" + When user "admin" on "LOCAL" shares the board with federated user "admin" + | permissionEdit | 1 | + Then user "admin" on "REMOTE" should see 1 stack on the board "Stack Board" + + Scenario: Create a stack on a federated board + Given using server "LOCAL" + And acting as user "admin" + And creates a board named "Remote Stack Board" with color "0000ff" + When user "admin" on "LOCAL" shares the board with federated user "admin" + | permissionEdit | 1 | + | permissionManage | 1 | + And user "admin" on "REMOTE" creates a stack "New Remote Stack" on the federated board + Then the OCS response should have status code "200" + And user "admin" on "LOCAL" should see 1 stack on the board "Remote Stack Board" diff --git a/tests/integration/run-federation.sh b/tests/integration/run-federation.sh new file mode 100755 index 0000000000..c133283377 --- /dev/null +++ b/tests/integration/run-federation.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash + +# Federation integration test runner for Deck +# Sets up two Nextcloud instances (LOCAL + REMOTE) and runs behat federation tests + +set -e + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +OCC="${ROOT_DIR}/occ" +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCENARIO_TO_RUN=$1 + +# Port configuration +LOCAL_PORT=8080 +REMOTE_PORT=8280 + +# Check if the main instance is installed +INSTALLED=$($OCC status | grep installed: | cut -d " " -f 5) +if [ "$INSTALLED" != "true" ]; then + echo "Nextcloud instance needs to be installed" >&2 + exit 1 +fi + +# ---- Set up the remote Nextcloud instance ---- + +if [ -z "$REMOTE_ROOT_DIR" ]; then + # No external remote provided — create a local federated server + REMOTE_ROOT_DIR="${ROOT_DIR}/data/tests-deck-federated-server" + + echo "Setting up local federated Nextcloud instance at ${REMOTE_ROOT_DIR}" + + rm -rf "${REMOTE_ROOT_DIR}" + mkdir -p "${REMOTE_ROOT_DIR}" + + # Symlink all server files into the remote directory + for item in "${ROOT_DIR}"/*; do + name=$(basename "$item") + if [ "$name" != "data" ] && [ "$name" != "config" ]; then + ln -sf "$item" "${REMOTE_ROOT_DIR}/${name}" + fi + done + + mkdir -p "${REMOTE_ROOT_DIR}/data" + mkdir -p "${REMOTE_ROOT_DIR}/config" + + # Install remote instance with SQLite + # Use NEXTCLOUD_CONFIG_DIR so occ finds the remote config even when + # server files are symlinked (which causes SERVERROOT to resolve to the original). + REMOTE_OCC="NEXTCLOUD_CONFIG_DIR=${REMOTE_ROOT_DIR}/config php ${REMOTE_ROOT_DIR}/occ" + eval $REMOTE_OCC maintenance:install \ + --database=sqlite \ + --admin-user=admin \ + --admin-pass=admin \ + --data-dir="${REMOTE_ROOT_DIR}/data" + + eval $REMOTE_OCC config:system:set hashing_default_password --value=true --type=boolean + + # Enable required apps on remote + eval $REMOTE_OCC app:enable --force deck +else + echo "Using external remote Nextcloud instance at ${REMOTE_ROOT_DIR}" + REMOTE_OCC="NEXTCLOUD_CONFIG_DIR=${REMOTE_ROOT_DIR}/config php ${REMOTE_ROOT_DIR}/occ" +fi + +MAIN_SERVER_CONFIG_DIR="${ROOT_DIR}/config" +REMOTE_SERVER_CONFIG_DIR="${REMOTE_ROOT_DIR}/config" + +# ---- Install behat dependencies ---- + +# Server behat vendor +( + cd "${ROOT_DIR}/vendor-bin/behat" + composer install +) + +# App test dependencies +( + cd "${APP_DIR}/tests/integration" + composer install +) + +# ---- Enable deck and configure both instances ---- + +$OCC app:enable --force deck + +# Configure LOCAL instance +$OCC config:system:set allow_local_remote_servers --value=true --type=boolean +$OCC config:system:set auth.bruteforce.protection.enabled --value=false --type=boolean +$OCC config:system:set ratelimit.protection.enabled --value=false --type=boolean +$OCC config:system:set debug --value=true --type=boolean +$OCC config:system:set hashing_default_password --value=true --type=boolean +$OCC config:app:set deck federationEnabled --value=yes +$OCC config:app:set files_sharing outgoing_server2server_share_enabled --value=yes +$OCC config:app:set files_sharing incoming_server2server_share_enabled --value=yes + +# Configure REMOTE instance +eval $REMOTE_OCC config:system:set allow_local_remote_servers --value=true --type=boolean +eval $REMOTE_OCC config:system:set auth.bruteforce.protection.enabled --value=false --type=boolean +eval $REMOTE_OCC config:system:set ratelimit.protection.enabled --value=false --type=boolean +eval $REMOTE_OCC config:system:set debug --value=true --type=boolean +eval $REMOTE_OCC config:app:set deck federationEnabled --value=yes +eval $REMOTE_OCC config:app:set files_sharing outgoing_server2server_share_enabled --value=yes +eval $REMOTE_OCC config:app:set files_sharing incoming_server2server_share_enabled --value=yes + +# Set trusted domains on both instances +$OCC config:system:set trusted_domains 0 --value="localhost:${LOCAL_PORT}" +eval $REMOTE_OCC config:system:set trusted_domains 0 --value="localhost:${REMOTE_PORT}" + +# ---- Start PHP built-in servers ---- + +echo "Starting LOCAL server on port ${LOCAL_PORT}" +PHP_CLI_SERVER_WORKERS=3 php -S "localhost:${LOCAL_PORT}" -t "${ROOT_DIR}" & +LOCAL_PID=$! + +echo "Starting REMOTE server on port ${REMOTE_PORT}" +NEXTCLOUD_CONFIG_DIR="${REMOTE_ROOT_DIR}/config" PHP_CLI_SERVER_WORKERS=3 php -S "localhost:${REMOTE_PORT}" -t "${ROOT_DIR}" & +REMOTE_PID=$! + +# Wait for servers to start +sleep 2 + +# Verify servers are up +if ! curl -s "http://localhost:${LOCAL_PORT}/status.php" > /dev/null; then + echo "LOCAL server failed to start" >&2 + kill -9 $LOCAL_PID $REMOTE_PID 2>/dev/null + exit 1 +fi + +if ! curl -s "http://localhost:${REMOTE_PORT}/status.php" > /dev/null; then + echo "REMOTE server failed to start" >&2 + kill -9 $LOCAL_PID $REMOTE_PID 2>/dev/null + exit 1 +fi + +echo "Both servers are running" + +# ---- Export environment variables for behat ---- + +export TEST_SERVER_URL="http://localhost:${LOCAL_PORT}/" +export TEST_REMOTE_URL="http://localhost:${REMOTE_PORT}/" +export NEXTCLOUD_HOST_ROOT_DIR="${ROOT_DIR}" +export NEXTCLOUD_HOST_CONFIG_DIR="${MAIN_SERVER_CONFIG_DIR}" +export NEXTCLOUD_REMOTE_ROOT_DIR="${REMOTE_ROOT_DIR}" +export NEXTCLOUD_REMOTE_CONFIG_DIR="${REMOTE_SERVER_CONFIG_DIR}" + +# ---- Run behat federation tests ---- + +cd "${APP_DIR}/tests/integration" + +BEHAT_SUITE="federation" +if [ -n "$SCENARIO_TO_RUN" ]; then + vendor/bin/behat --colors --suite="${BEHAT_SUITE}" "$SCENARIO_TO_RUN" +else + vendor/bin/behat --colors --suite="${BEHAT_SUITE}" +fi +RESULT=$? + +# ---- Cleanup ---- + +kill -9 $LOCAL_PID $REMOTE_PID 2>/dev/null + +echo "Federation tests: Exit code: $RESULT" +exit $RESULT diff --git a/tests/integration/run.sh b/tests/integration/run.sh index 3b9fa2bf6b..87c1de2d03 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -33,7 +33,7 @@ echo $PHPPID export TEST_SERVER_URL="http://localhost:$PORT/ocs/" -vendor/bin/behat --colors $SCENARIO_TO_RUN +vendor/bin/behat --colors --suite=test $SCENARIO_TO_RUN RESULT=$? kill -9 $PHPPID