From 97c3b1b24b7d2e0fb209a671c5d7041b87ecf0ae Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 4 Sep 2026 22:13:06 +0200 Subject: [PATCH 1/2] test: use phpunit instead of behat for integration tests Signed-off-by: Ferdinand Thiessen --- build/integration-phpunit/.gitignore | 2 + build/integration-phpunit/bootstrap.php | 23 ++++ build/integration-phpunit/lib/ApiClient.php | 64 ++++++++++ build/integration-phpunit/lib/ApiTestCase.php | 84 +++++++++++++ build/integration-phpunit/lib/Occ.php | 97 ++++++++++++++ build/integration-phpunit/lib/OccResult.php | 58 +++++++++ build/integration-phpunit/lib/Users.php | 53 ++++++++ build/integration-phpunit/phpunit.xml | 23 ++++ build/integration-phpunit/run-docker.sh | 118 ++++++++++++++++++ build/integration-phpunit/run.sh | 64 ++++++++++ .../tests/RateLimitingTest.php | 107 ++++++++++++++++ 11 files changed, 693 insertions(+) create mode 100644 build/integration-phpunit/.gitignore create mode 100644 build/integration-phpunit/bootstrap.php create mode 100644 build/integration-phpunit/lib/ApiClient.php create mode 100644 build/integration-phpunit/lib/ApiTestCase.php create mode 100644 build/integration-phpunit/lib/Occ.php create mode 100644 build/integration-phpunit/lib/OccResult.php create mode 100644 build/integration-phpunit/lib/Users.php create mode 100644 build/integration-phpunit/phpunit.xml create mode 100755 build/integration-phpunit/run-docker.sh create mode 100755 build/integration-phpunit/run.sh create mode 100644 build/integration-phpunit/tests/RateLimitingTest.php diff --git a/build/integration-phpunit/.gitignore b/build/integration-phpunit/.gitignore new file mode 100644 index 0000000000000..228f5f027f758 --- /dev/null +++ b/build/integration-phpunit/.gitignore @@ -0,0 +1,2 @@ +.phpunit.cache/ +phpserver.log diff --git a/build/integration-phpunit/bootstrap.php b/build/integration-phpunit/bootstrap.php new file mode 100644 index 0000000000000..9cd67c92a118b --- /dev/null +++ b/build/integration-phpunit/bootstrap.php @@ -0,0 +1,23 @@ +client = new Client(); + } + + public function asUser(string $userId, string $password): self { + return new self($this->baseUrl, [$userId, $password]); + } + + /** + * @param string $path Path relative to the server root, e.g. "/index.php/apps/testing/anonProtected" + * @param array $options Guzzle request options + */ + public function request(string $method, string $path, array $options = []): ResponseInterface { + $options['http_errors'] = false; + $options['headers']['OCS-APIREQUEST'] = 'true'; + if ($this->auth !== null) { + $options['auth'] = $this->auth; + } + + return $this->client->request($method, $this->baseUrl . $path, $options); + } + + /** + * @param string $path Path below the OCS entry point, e.g. "/cloud/users" + * @param array $options Guzzle request options + * @param int $version OCS API version, defaults to 2 because it maps OCS statuses onto HTTP statuses + */ + public function ocs(string $method, string $path, array $options = [], int $version = 2): ResponseInterface { + return $this->request($method, "/ocs/v{$version}.php" . $path, $options); + } +} diff --git a/build/integration-phpunit/lib/ApiTestCase.php b/build/integration-phpunit/lib/ApiTestCase.php new file mode 100644 index 0000000000000..724d14ebe75fd --- /dev/null +++ b/build/integration-phpunit/lib/ApiTestCase.php @@ -0,0 +1,84 @@ +asUser($userId, $password); + } + + protected static function admin(): ApiClient { + return self::user(self::ADMIN_USER, self::ADMIN_PASSWORD); + } + + protected static function occ(): Occ { + return self::$occ ??= new Occ(self::serverRoot(), self::guest()); + } + + protected static function users(): Users { + return self::$users ??= new Users(self::admin()); + } + + /** + * Asserts the HTTP status of a response and reports the body when it differs, + * which is usually where the reason for an unexpected status is. + */ + protected static function assertStatus(int $expectedStatus, ResponseInterface $response, string $message = ''): void { + $actualStatus = $response->getStatusCode(); + if ($actualStatus !== $expectedStatus) { + $body = trim((string)$response->getBody()); + $message = trim($message . "\nResponse body: " . ($body === '' ? '' : mb_substr($body, 0, 1000))); + } + + self::assertSame($expectedStatus, $actualStatus, $message); + } +} diff --git a/build/integration-phpunit/lib/Occ.php b/build/integration-phpunit/lib/Occ.php new file mode 100644 index 0000000000000..53cd095b355e8 --- /dev/null +++ b/build/integration-phpunit/lib/Occ.php @@ -0,0 +1,97 @@ + ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open($command, $descriptors, $pipes, $this->serverRoot); + if ($process === false) { + throw new RuntimeException('Could not start occ: ' . $command); + } + + if ($input !== '') { + fwrite($pipes[0], $input . "\n"); + } + fclose($pipes[0]); + + $stdOut = stream_get_contents($pipes[1]); + $stdErr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + + // The built-in PHP web server keeps its own opcode cache, so config + // changes made through occ are otherwise not visible to requests. + $this->client->request('GET', '/apps/testing/clean_opcode_cache.php'); + + return new OccResult($exitCode, (string)$stdOut, (string)$stdErr); + } + + /** + * Runs a command and fails loudly instead of letting a later assertion fail + * with an unrelated message. + * + * @param string[] $args + */ + public function mustRun(array $args, string $input = ''): OccResult { + $result = $this->run($args, $input); + if (!$result->succeeded()) { + throw new RuntimeException( + 'occ ' . implode(' ', $args) . " failed:\n" . $result->describe() + ); + } + + return $result; + } + + public function setSystemConfig(string $key, string $value, string $type = 'string'): void { + $this->mustRun(['config:system:set', $key, '--value', $value, '--type', $type]); + } + + public function isAppEnabled(string $appId): bool { + $result = $this->mustRun(['app:list', '--output=json']); + $apps = json_decode($result->stdOut, true, flags: JSON_THROW_ON_ERROR); + + return isset($apps['enabled'][$appId]); + } + + public function enableApp(string $appId): void { + $this->mustRun(['app:enable', '--force', $appId]); + } + + public function disableApp(string $appId): void { + $this->mustRun(['app:disable', $appId]); + } +} diff --git a/build/integration-phpunit/lib/OccResult.php b/build/integration-phpunit/lib/OccResult.php new file mode 100644 index 0000000000000..f40b85dc36c82 --- /dev/null +++ b/build/integration-phpunit/lib/OccResult.php @@ -0,0 +1,58 @@ +exitCode === 0 && $this->exceptions() === []; + } + + /** + * Exception texts reported on stderr. The message follows the line + * containing "[Exception]". + * + * @return string[] + */ + public function exceptions(): array { + $exceptions = []; + $captureNext = false; + foreach (explode("\n", $this->stdErr) as $line) { + if (str_contains($line, '[Exception]')) { + $captureNext = true; + continue; + } + if ($captureNext) { + $exceptions[] = trim($line); + $captureNext = false; + } + } + + return $exceptions; + } + + public function describe(): string { + return sprintf( + "exit code %d\n--- stdout ---\n%s\n--- stderr ---\n%s", + $this->exitCode, + trim($this->stdOut), + trim($this->stdErr), + ); + } +} diff --git a/build/integration-phpunit/lib/Users.php b/build/integration-phpunit/lib/Users.php new file mode 100644 index 0000000000000..ce1070be65123 --- /dev/null +++ b/build/integration-phpunit/lib/Users.php @@ -0,0 +1,53 @@ +admin->ocs('GET', '/cloud/users/' . rawurlencode($userId))->getStatusCode() === 200; + } + + public function ensureExists(string $userId, string $password = self::DEFAULT_PASSWORD): void { + if ($this->exists($userId)) { + return; + } + + $response = $this->admin->ocs('POST', '/cloud/users', [ + 'form_params' => [ + 'userid' => $userId, + 'password' => $password, + ], + ]); + if ($response->getStatusCode() !== 200) { + throw new RuntimeException( + sprintf('Could not create user "%s": HTTP %d %s', $userId, $response->getStatusCode(), (string)$response->getBody()) + ); + } + + // Log in once so that the home storage is set up, matching what the + // Behat step "user :user exists" does. + $this->admin->asUser($userId, $password)->ocs('GET', '/cloud/users/' . rawurlencode($userId)); + } +} diff --git a/build/integration-phpunit/phpunit.xml b/build/integration-phpunit/phpunit.xml new file mode 100644 index 0000000000000..f41340a826840 --- /dev/null +++ b/build/integration-phpunit/phpunit.xml @@ -0,0 +1,23 @@ + + + + + + tests + + + + + + + diff --git a/build/integration-phpunit/run-docker.sh b/build/integration-phpunit/run-docker.sh new file mode 100755 index 0000000000000..0c8c7194b156f --- /dev/null +++ b/build/integration-phpunit/run-docker.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Helper script to run the PHPUnit API integration tests on a fresh Nextcloud +# server through Docker. It is the counterpart of build/integration/run-docker.sh +# for the Behat suite and follows the same approach: the root directory of the +# Nextcloud server is copied into a container, ignoring the configuration and +# data of the local instance, a new installation is performed inside the +# container, and the tests are run against it. The container is removed when the +# script exits. +# +# Only SQLite is supported; see build/integration/run-docker.sh for a variant +# that can start a MySQL or PostgreSQL container as well. +# +# The script requires the "docker" command to be available. Being able to talk +# to the Docker daemon is equivalent to root access on the host, so run this +# only as a trusted user: +# https://docs.docker.com/engine/security/security/#docker-daemon-attack-surface +# +# Any arguments are forwarded to PHPUnit, for example: +# ./run-docker.sh --filter testUserLimitIsCountedSeparatelyFromAnonymousLimit + +set -o errexit + +# Switches between mktemp on GNU/Linux and gmktemp on macOS. +function setOperatingSystemAbstractionVariables() { + case "$OSTYPE" in + darwin*) + if [ "$(which gmktemp)" == "" ]; then + echo "Please install coreutils (brew install coreutils)" + exit 1 + fi + + MKTEMP=gmktemp + ;; + linux*) + MKTEMP=mktemp + ;; + *) + echo "Operating system ($OSTYPE) not supported" + exit 1 + ;; + esac +} + +function cleanUp() { + # Disable (yes, "+" disables) exiting immediately on errors to ensure that + # all the cleanup commands are executed. + set +o errexit + + echo "Cleaning up" + + if [ -n "$NEXTCLOUD_LOCAL_TAR" ] && [ -f "$NEXTCLOUD_LOCAL_TAR" ]; then + rm "$NEXTCLOUD_LOCAL_TAR" + fi + + # The name filter must be specified as "^/XXX$" to get an exact match. + if [ -n "$(docker ps --all --quiet --filter name="^/$NEXTCLOUD_LOCAL_CONTAINER$")" ]; then + echo "Removing Docker container $NEXTCLOUD_LOCAL_CONTAINER" + docker rm --volumes --force $NEXTCLOUD_LOCAL_CONTAINER + fi +} + +trap cleanUp EXIT + +# Ensure working directory is script directory, as copying the Git working +# directory to the container expects that. +cd "$(dirname $0)" + +# "--image XXX" option can be provided to set the Docker image to use to run +# the integration tests (one of the "nextcloud/continuous-integration-phpX.Y:latest" images). +NEXTCLOUD_LOCAL_IMAGE="ghcr.io/nextcloud/continuous-integration-php8.3:latest" +if [ "$1" = "--image" ]; then + NEXTCLOUD_LOCAL_IMAGE=$2 + + shift 2 +fi + +NEXTCLOUD_LOCAL_CONTAINER=nextcloud-local-test-integration-phpunit + +setOperatingSystemAbstractionVariables + +echo "Starting the Nextcloud container" +# The image exits immediately if no command is given, so a Bash session is +# created to prevent that. +docker run \ + --volume composer_cache:/root/.composer \ + --detach --name=$NEXTCLOUD_LOCAL_CONTAINER --interactive --tty $NEXTCLOUD_LOCAL_IMAGE bash + +# Use the $TMPDIR or, if not set, fall back to /tmp. +NEXTCLOUD_LOCAL_TAR="$($MKTEMP --tmpdir="${TMPDIR:-/tmp}" --suffix=.tar nextcloud-local-XXXXXXXXXX)" + +echo "Copying local Git working directory of Nextcloud to the container" +tar --create --file="$NEXTCLOUD_LOCAL_TAR" \ + --exclude=".git" \ + --exclude="./config/config.php" \ + --exclude="./config/*.config.php" \ + --exclude="./data" \ + --exclude="./data-autotest" \ + --exclude="./tests" \ + --exclude="node_modules" \ + --directory=../../ \ + . + +docker exec $NEXTCLOUD_LOCAL_CONTAINER mkdir /nextcloud +docker cp - $NEXTCLOUD_LOCAL_CONTAINER:/nextcloud/ < "$NEXTCLOUD_LOCAL_TAR" +docker exec $NEXTCLOUD_LOCAL_CONTAINER chown -R www-data:www-data /nextcloud + +docker exec -w /nextcloud $NEXTCLOUD_LOCAL_CONTAINER composer install + +echo "Installing Nextcloud in the container" +docker exec --user www-data --workdir /nextcloud $NEXTCLOUD_LOCAL_CONTAINER php occ maintenance:install --admin-pass=admin + +echo "Running tests" +# --tty is needed to get colourful output. +docker exec --tty --user www-data -w "/nextcloud/build/integration-phpunit" $NEXTCLOUD_LOCAL_CONTAINER bash -c "./run.sh $*" diff --git a/build/integration-phpunit/run.sh b/build/integration-phpunit/run.sh new file mode 100755 index 0000000000000..68a61eb33bf03 --- /dev/null +++ b/build/integration-phpunit/run.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +set -euo pipefail + +PHPUNIT_EXECUTABLE=../../vendor-bin/behat/vendor/bin/phpunit +if [ ! -f "$PHPUNIT_EXECUTABLE" ]; then + echo "PHPUnit executable not found. Please run 'composer install' in the root directory first." >&2 + exit 1 +fi + +OC_PATH=../../ +OCC=${OC_PATH}occ + +INSTALLED=$($OCC status | grep installed: | cut -d " " -f 5 || true) +if [ "$INSTALLED" != "true" ]; then + echo "Nextcloud instance needs to be installed" >&2 + exit 1 +fi + +# Disable appstore to avoid spamming from CI +$OCC config:system:set appstoreenabled --value=false --type=boolean +# Disable bruteforce protection because the integration tests do trigger them +$OCC config:system:set auth.bruteforce.protection.enabled --value false --type bool +# Disable rate limit protection, the tests enable it for themselves +$OCC config:system:set ratelimit.protection.enabled --value false --type bool +# Allow creating users with dummy passwords +$OCC app:disable password_policy || true + +NC_DATADIR=$($OCC config:system:get datadirectory) + +# avoid port collision on jenkins - use $EXECUTOR_NUMBER +PORT=$((8080 + ${EXECUTOR_NUMBER:-0})) +export PORT + +echo "" > "${NC_DATADIR}/nextcloud.log" +echo "" > phpserver.log + +PHP_CLI_SERVER_WORKERS=2 php -S localhost:$PORT -t ../.. &> phpserver.log & +PHPPID=$! + +# Output filtered php server logs +tail -f phpserver.log | grep --line-buffered -v -E ":[0-9]+ (Accepted|Closing)$" & +LOGPID=$! + +function cleanup() { + kill $PHPPID 2>/dev/null || true + kill $LOGPID 2>/dev/null || true +} +trap cleanup EXIT + +export NEXTCLOUD_BASE_URL="http://localhost:$PORT" + +set +e +php "$PHPUNIT_EXECUTABLE" --configuration phpunit.xml "$@" +RESULT=$? +set -e + +tail "${NC_DATADIR}/nextcloud.log" + +echo "run.sh: Exit code: $RESULT" +exit $RESULT diff --git a/build/integration-phpunit/tests/RateLimitingTest.php b/build/integration-phpunit/tests/RateLimitingTest.php new file mode 100644 index 0000000000000..099b0778f8a72 --- /dev/null +++ b/build/integration-phpunit/tests/RateLimitingTest.php @@ -0,0 +1,107 @@ +isAppEnabled('testing'); + if (!self::$testingAppWasEnabled) { + self::occ()->enableApp('testing'); + } + + // Rate limiting is disabled by default and by build/integration/run.sh. + self::occ()->setSystemConfig('ratelimit.protection.enabled', 'true', 'bool'); + + self::users()->ensureExists('user0'); + } + + public static function tearDownAfterClass(): void { + self::occ()->setSystemConfig('ratelimit.protection.enabled', 'false', 'bool'); + + if (!self::$testingAppWasEnabled) { + self::occ()->disableApp('testing'); + } + } + + public function testAnonymousLimitAlsoAppliesToAuthenticatedRequests(): void { + $this->waitOutAnonProtectedPeriod(); + $user0 = self::user('user0'); + + self::assertStatus(200, $user0->request('GET', self::ANON_PROTECTED)); + self::assertStatus(429, $user0->request('GET', self::ANON_PROTECTED), + 'The route carries no user limit, so an authenticated client is counted against the anonymous limit.'); + + $this->waitOutAnonProtectedPeriod(); + self::assertStatus(200, $user0->request('GET', self::ANON_PROTECTED)); + } + + public function testAnonymousAndAuthenticatedRequestsShareTheSameLimit(): void { + $this->waitOutAnonProtectedPeriod(); + + self::assertStatus(200, self::guest()->request('GET', self::ANON_PROTECTED)); + self::assertStatus(429, self::user('user0')->request('GET', self::ANON_PROTECTED), + 'Both clients come from the same address and the route has no user limit, so they share one counter.'); + + $this->waitOutAnonProtectedPeriod(); + self::assertStatus(200, self::user('user0')->request('GET', self::ANON_PROTECTED)); + } + + public function testUserLimitIsCountedSeparatelyFromAnonymousLimit(): void { + $guest = self::guest(); + $user0 = self::user('user0'); + + self::assertStatus(200, $guest->request('GET', self::USER_AND_ANON_PROTECTED)); + self::assertStatus(429, $guest->request('GET', self::USER_AND_ANON_PROTECTED), + 'The anonymous limit of 1 request is exhausted.'); + + // The user counter is untouched by the two guest requests above. + for ($request = 1; $request <= self::USER_AND_ANON_USER_LIMIT; $request++) { + self::assertStatus(200, $user0->request('GET', self::USER_AND_ANON_PROTECTED), + sprintf('Request %d of the user limit of %d.', $request, self::USER_AND_ANON_USER_LIMIT)); + } + + self::assertStatus(429, $user0->request('GET', self::USER_AND_ANON_PROTECTED), + sprintf('Request %d exceeds the user limit of %d.', self::USER_AND_ANON_USER_LIMIT + 1, self::USER_AND_ANON_USER_LIMIT)); + + self::assertStatus(429, $guest->request('GET', self::USER_AND_ANON_PROTECTED), + 'The anonymous counter is still exhausted and was not reset by the authenticated requests.'); + } + + /** + * Waits until the anonymous counter of {@see self::ANON_PROTECTED} is + * released again. The counter is shared by every test using that route, so + * each of them has to wait rather than relying on the order they run in. + */ + private function waitOutAnonProtectedPeriod(): void { + sleep(self::ANON_PROTECTED_PERIOD + 1); + } +} From c43e486e9bd42b250bad1e85657285494dd80b2b Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 4 Sep 2026 22:49:35 +0200 Subject: [PATCH 2/2] test: use playwright requests for API integration tests Signed-off-by: Ferdinand Thiessen --- package.json | 1 + playwright.config.ts | 11 ++ tests/playwright/integration/fixtures/api.ts | 80 ++++++++++++ .../integration/ratelimiting.spec.ts | 118 ++++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 tests/playwright/integration/fixtures/api.ts create mode 100644 tests/playwright/integration/ratelimiting.spec.ts diff --git a/package.json b/package.json index 602f685b9c9ac..1c28ea470f865 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "lint:fix": "concurrently 'npm run lint -- --fix' 'build/demi.sh lint:fix'", "playwright": "playwright test --project=default --project=admin-settings", "playwright:install": "playwright install chromium", + "playwright:integration": "playwright test --project=integration", "playwright:setup": "playwright test --project=setup", "sass": "sass --style compressed --load-path core/css core/css/ $(for cssdir in $(find apps -mindepth 2 -maxdepth 2 -name \"css\"); do if ! $(git check-ignore -q $cssdir); then printf \"$cssdir \"; fi; done)", "sass:icons": "node build/icons.mjs", diff --git a/playwright.config.ts b/playwright.config.ts index 3e34aa3eb0a34..0a4e52abc5fc8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -57,6 +57,17 @@ export default defineConfig({ ...BROWSWER_CONFIG_CHROME, }, }, + + { + // API tests driving the HTTP endpoints directly. They change + // instance-wide system config, so they must never run alongside + // other tests. No browser is launched. + name: 'integration', + testDir: './tests/playwright/integration', + fullyParallel: false, + workers: 1, + timeout: 90_000, // rate limit periods are waited out in real time + }, ], webServer: { diff --git a/tests/playwright/integration/fixtures/api.ts b/tests/playwright/integration/fixtures/api.ts new file mode 100644 index 0000000000000..e50546a309f33 --- /dev/null +++ b/tests/playwright/integration/fixtures/api.ts @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { APIRequestContext, APIResponse } from '@playwright/test' + +import { test as randomUserTest } from '../../support/fixtures/random-user.ts' +import { expect } from '../../support/matchers.ts' + +export interface ApiFixtures { + /** Unauthenticated request context. */ + guestRequest: APIRequestContext + /** Request context authenticated as `user`. */ + userRequest: APIRequestContext +} + +/** + * Build the basic auth header for a user. + * + * The header is set explicitly instead of through Playwright's + * `httpCredentials`, which only attaches credentials after the server answered + * `401`. Endpoints that are not behind an authentication check never issue that + * challenge, so the credentials would never be sent and the request would be + * handled as anonymous. + * + * @param user - The user to authenticate as + */ +function basicAuthHeader(user: User): string { + return 'Basic ' + Buffer.from(`${user.userId}:${user.password}`).toString('base64') +} + +/** + * Request contexts for API tests: one anonymous, one authenticated as the + * random `user` provided by the base fixture. Neither is tied to a browser + * session, so they share no cookies and no browser is launched. + */ +export const test = randomUserTest.extend({ + guestRequest: async ({ playwright, baseURL }, use) => { + const context = await playwright.request.newContext({ + baseURL, + extraHTTPHeaders: { 'OCS-APIRequest': 'true' }, + }) + await use(context) + await context.dispose() + }, + + userRequest: async ({ playwright, baseURL, user }, use) => { + const context = await playwright.request.newContext({ + baseURL, + extraHTTPHeaders: { + 'OCS-APIRequest': 'true', + Authorization: basicAuthHeader(user), + }, + }) + await use(context) + await context.dispose() + }, +}) + +/** + * Assert the HTTP status of a response and report the body when it differs, + * which is usually where the reason for an unexpected status is. + * + * @param response - The response to check + * @param expectedStatus - The expected HTTP status code + * @param message - Explains why this status is expected + */ +export async function expectStatus(response: APIResponse, expectedStatus: number, message = ''): Promise { + const status = response.status() + if (status !== expectedStatus) { + const body = (await response.text()).trim() + message = `${message}\nResponse body: ${body === '' ? '' : body.slice(0, 1000)}`.trim() + } + + expect(status, message).toBe(expectedStatus) +} + +export { expect } diff --git a/tests/playwright/integration/ratelimiting.spec.ts b/tests/playwright/integration/ratelimiting.spec.ts new file mode 100644 index 0000000000000..e0d2478b2c448 --- /dev/null +++ b/tests/playwright/integration/ratelimiting.spec.ts @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { expectStatus, test } from './fixtures/api.ts' + +/** Route carrying `#[AnonRateLimit(limit: 1, period: 10)]`. */ +const ANON_PROTECTED = 'apps/testing/anonProtected' +const ANON_PROTECTED_PERIOD = 10 + +/** Route carrying `#[UserRateLimit(limit: 5, period: 100)]` and `#[AnonRateLimit(limit: 1, period: 100)]`. */ +const USER_AND_ANON_PROTECTED = 'apps/testing/userAndAnonProtected' +const USER_AND_ANON_USER_LIMIT = 5 + +/** + * Rate limiting of app framework routes. + * + * Exercises the two routes of OCA\Testing\Controller\RateLimitTestController. + * Limits are keyed by client IP respectively user and are only released by + * time, so tests sharing a route have to wait out its period. + */ +test.describe('Rate limiting', () => { + let testingAppWasEnabled = false + + test.beforeAll(async () => { + const { stdout } = await runOcc(['app:list', '--output=json']) + testingAppWasEnabled = 'testing' in JSON.parse(stdout).enabled + if (!testingAppWasEnabled) { + await runOcc(['app:enable', '--force', 'testing']) + } + + // Rate limiting is disabled by default and by the test server setup. + // Enabling it affects the whole instance, which is why this project runs + // on its own with a single worker. + await runOcc(['config:system:set', 'ratelimit.protection.enabled', '--value', 'true', '--type', 'bool']) + }) + + test.afterAll(async () => { + await runOcc(['config:system:set', 'ratelimit.protection.enabled', '--value', 'false', '--type', 'bool']) + + if (!testingAppWasEnabled) { + await runOcc(['app:disable', 'testing']) + } + }) + + test('the anonymous limit also applies to authenticated requests', async ({ userRequest }) => { + await waitOutAnonProtectedPeriod() + + await expectStatus(await userRequest.get(ANON_PROTECTED), 200) + await expectStatus( + await userRequest.get(ANON_PROTECTED), + 429, + 'The route carries no user limit, so an authenticated client is counted against the anonymous limit.', + ) + + await waitOutAnonProtectedPeriod() + await expectStatus(await userRequest.get(ANON_PROTECTED), 200) + }) + + test('anonymous and authenticated requests share the same limit', async ({ guestRequest, userRequest }) => { + await waitOutAnonProtectedPeriod() + + await expectStatus(await guestRequest.get(ANON_PROTECTED), 200) + await expectStatus( + await userRequest.get(ANON_PROTECTED), + 429, + 'Both clients come from the same address and the route has no user limit, so they share one counter.', + ) + + await waitOutAnonProtectedPeriod() + await expectStatus(await userRequest.get(ANON_PROTECTED), 200) + }) + + test('the user limit is counted separately from the anonymous limit', async ({ guestRequest, userRequest }) => { + await expectStatus(await guestRequest.get(USER_AND_ANON_PROTECTED), 200) + await expectStatus( + await guestRequest.get(USER_AND_ANON_PROTECTED), + 429, + 'The anonymous limit of 1 request is exhausted.', + ) + + // The user counter is untouched by the two guest requests above. + for (let request = 1; request <= USER_AND_ANON_USER_LIMIT; request++) { + await expectStatus( + await userRequest.get(USER_AND_ANON_PROTECTED), + 200, + `Request ${request} of the user limit of ${USER_AND_ANON_USER_LIMIT}.`, + ) + } + + await expectStatus( + await userRequest.get(USER_AND_ANON_PROTECTED), + 429, + `Request ${USER_AND_ANON_USER_LIMIT + 1} exceeds the user limit of ${USER_AND_ANON_USER_LIMIT}.`, + ) + + await expectStatus( + await guestRequest.get(USER_AND_ANON_PROTECTED), + 429, + 'The anonymous counter is still exhausted and was not reset by the authenticated requests.', + ) + }) +}) + +/** + * Wait until the anonymous counter of `ANON_PROTECTED` is released again. The + * counter is shared by every test using that route, so each of them waits + * rather than relying on the order they run in. + * + * The wait cannot be replaced by polling the route until it answers `200`, + * because the successful poll would itself consume the single request the + * period allows. + */ +function waitOutAnonProtectedPeriod(): Promise { + return new Promise((resolve) => setTimeout(resolve, (ANON_PROTECTED_PERIOD + 1) * 1000)) +}