Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build/integration-phpunit/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.phpunit.cache/
phpserver.log
23 changes: 23 additions & 0 deletions build/integration-phpunit/bootstrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

require __DIR__ . '/../../vendor-bin/behat/vendor/autoload.php';
require __DIR__ . '/../../3rdparty/autoload.php';

spl_autoload_register(static function (string $class): void {
$prefix = 'NextcloudIntegration\\';
if (!str_starts_with($class, $prefix)) {
return;
}

$path = __DIR__ . '/lib/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($path)) {
require $path;
}
});
64 changes: 64 additions & 0 deletions build/integration-phpunit/lib/ApiClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace NextcloudIntegration;

use GuzzleHttp\Client;
use Psr\Http\Message\ResponseInterface;

/**
* HTTP client for the Nextcloud instance under test.
*
* A response is returned for every status code: 4xx and 5xx do not raise an
* exception, so tests assert on the status instead of catching one.
*
* Instances are immutable; {@see self::asUser()} returns a new client rather
* than changing the identity of an existing one.
*/
final class ApiClient {
private readonly Client $client;

/**
* @param string $baseUrl Server root without a trailing slash, e.g. "http://localhost:8080"
* @param ?array{0: string, 1: string} $auth Basic auth credentials, or null for an anonymous client
*/
public function __construct(
private readonly string $baseUrl,
private readonly ?array $auth = null,
) {
$this->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<string, mixed> $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<string, mixed> $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);
}
}
84 changes: 84 additions & 0 deletions build/integration-phpunit/lib/ApiTestCase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace NextcloudIntegration;

use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;

/**
* Base class for API integration tests running against a live Nextcloud instance.
*/
abstract class ApiTestCase extends TestCase {
protected const ADMIN_USER = 'admin';
protected const ADMIN_PASSWORD = 'admin';

private static ?ApiClient $guest = null;
private static ?Occ $occ = null;
private static ?Users $users = null;

/**
* Server root of the instance under test, without a trailing slash.
*
* NEXTCLOUD_BASE_URL takes precedence; TEST_SERVER_URL is accepted as well
* so the suite can run inside the environment set up by
* build/integration/run.sh, which points it at the OCS entry point.
*/
protected static function baseUrl(): string {
$baseUrl = getenv('NEXTCLOUD_BASE_URL');
if ($baseUrl === false || $baseUrl === '') {
$baseUrl = getenv('TEST_SERVER_URL') ?: 'http://localhost:8080';
}

$baseUrl = rtrim($baseUrl, '/');
if (str_ends_with($baseUrl, '/ocs')) {
$baseUrl = substr($baseUrl, 0, -strlen('/ocs'));
}

return $baseUrl;
}

protected static function serverRoot(): string {
return dirname(__DIR__, 3);
}

protected static function guest(): ApiClient {
return self::$guest ??= new ApiClient(self::baseUrl());
}

protected static function user(string $userId, string $password = Users::DEFAULT_PASSWORD): ApiClient {
return self::guest()->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 === '' ? '<empty>' : mb_substr($body, 0, 1000)));
}

self::assertSame($expectedStatus, $actualStatus, $message);
}
}
97 changes: 97 additions & 0 deletions build/integration-phpunit/lib/Occ.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace NextcloudIntegration;

use RuntimeException;

/**
* Runs occ commands against the server under test.
*/
final class Occ {
/**
* @param string $serverRoot Filesystem path of the Nextcloud checkout
* @param ApiClient $client Used to flush the opcode cache of the web server after a command
*/
public function __construct(
private readonly string $serverRoot,
private readonly ApiClient $client,
) {
}

/**
* @param string[] $args Everything behind "occ", e.g. ['app:enable', '--force', 'testing']
*/
public function run(array $args, string $input = ''): OccResult {
$command = 'php console.php ' . implode(' ', array_map(escapeshellarg(...), $args)) . ' --no-ansi';

$descriptors = [
0 => ['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]);
}
}
58 changes: 58 additions & 0 deletions build/integration-phpunit/lib/OccResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace NextcloudIntegration;

/**
* Outcome of a single occ invocation.
*/
final readonly class OccResult {
public function __construct(
public int $exitCode,
public string $stdOut,
public string $stdErr,
) {
}

public function succeeded(): bool {
return $this->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),
);
}
}
53 changes: 53 additions & 0 deletions build/integration-phpunit/lib/Users.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace NextcloudIntegration;

use RuntimeException;

/**
* User fixtures created through the provisioning API.
*/
final class Users {
public const DEFAULT_PASSWORD = '123456';

/**
* @param ApiClient $admin Client authenticated as an administrator
*/
public function __construct(
private readonly ApiClient $admin,
) {
}

public function exists(string $userId): bool {
return $this->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));
}
}
Loading
Loading