UPSTREAM BASELINE: nextcloud/spreed v22.0.12 (без изменений)
Type checking / changes (push) Has been cancelled
Type checking / test (push) Has been cancelled
Type checking / typescript-summary (push) Has been cancelled
Node tests / changes (push) Has been cancelled
Node tests / test (push) Has been cancelled
Node tests / test-summary (push) Has been cancelled

Источник: https://github.com/nextcloud/spreed/archive/refs/tags/v22.0.12.tar.gz
С этого коммита ветка официального Nextcloud Talk отрезана (решение владельца 2026-07-06).
Все дальнейшие изменения — только наши; версии релизов: 22.0.12-f7.N.
This commit is contained in:
2026-07-06 14:07:50 +00:00
commit 01acfa3b40
1716 changed files with 613013 additions and 0 deletions
@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
use Behat\Step\Given;
use Behat\Step\Then;
use PHPUnit\Framework\Assert;
require __DIR__ . '/../../vendor/autoload.php';
// The following attributes are expected to be available in the class that uses
// this trait:
// - currentServer
// - localServerUrl
// - remoteServerUrl
trait CommandLineTrait {
/** @var int return code of last command */
private int $lastCode = 0;
/** @var string stdout of last command */
private string $lastStdOut = '';
/** @var string stderr of last command */
private string $lastStdErr = '';
protected string $ocPath = '../../../..';
/**
* Invokes an OCC command
*
* @param string[] $args OCC command, the part behind "occ". For example: "files:transfer-ownership"
* @param string[] $env environment variables
* @return int exit code
*/
public function runOcc(array $args = [], array $env = []): int {
// Set UTF-8 locale to ensure that escapeshellarg will not strip
// multibyte characters.
setlocale(LC_CTYPE, 'C.UTF-8');
$clearOpcodeCache = in_array($args[0], [
'app:disable',
'app:enable',
'config:system:delete',
'config:system:set',
'maintenance:mode',
], true);
$args = array_map(static function (string|int $arg) {
return escapeshellarg((string)$arg);
}, $args);
$args[] = '--no-ansi';
$argString = implode(' ', $args);
if ($this->currentServer === 'REMOTE') {
$env['NEXTCLOUD_CONFIG_DIR'] = getenv('NEXTCLOUD_REMOTE_CONFIG_DIR');
$serverRootDir = getenv('NEXTCLOUD_REMOTE_ROOT_DIR');
} else {
$env['NEXTCLOUD_CONFIG_DIR'] = getenv('NEXTCLOUD_HOST_CONFIG_DIR');
$serverRootDir = getenv('NEXTCLOUD_HOST_ROOT_DIR');
}
$descriptor = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open('php ' . $serverRootDir . '/console.php ' . $argString, $descriptor, $pipes, $this->ocPath, $env);
$this->lastStdOut = stream_get_contents($pipes[1]);
$this->lastStdErr = stream_get_contents($pipes[2]);
$this->lastCode = proc_close($process);
if ($clearOpcodeCache) {
// Clean opcode cache
$client = new GuzzleHttp\Client();
if ($this->currentServer === 'REMOTE') {
$client->request('GET', $this->remoteServerUrl . 'apps/testing/clean_opcode_cache.php');
} else {
$client->request('GET', $this->localServerUrl . 'apps/testing/clean_opcode_cache.php');
}
}
return $this->lastCode;
}
#[Given('/^invoking occ with "([^"]*)"$/')]
public function invokingTheCommand(string $cmd): void {
// FIXME this way is deprecated
if (preg_match('/room-name:(?P<token>\w+)/', $cmd, $matches)) {
if (array_key_exists($matches['token'], self::$identifierToToken)) {
$cmd = preg_replace('/room-name:(\w+)/', self::$identifierToToken[$matches['token']], $cmd);
}
}
if (preg_match('/ROOM\((?P<name>\w+)\)/', $cmd, $matches)) {
if (array_key_exists($matches['name'], self::$identifierToToken)) {
$cmd = preg_replace('/ROOM\((\w+)\)/', self::$identifierToToken[$matches['name']], $cmd);
}
}
if (preg_match('/BOT\((?P<name>\w+)\)/', $cmd, $matches)) {
if (array_key_exists($matches['name'], self::$botNameToId)) {
$cmd = preg_replace('/BOT\((\w+)\)/', (string)self::$botNameToId[$matches['name']], $cmd);
}
}
$args = explode(' ', $cmd);
$this->runOcc($args);
}
public function getLastStdOut(): string {
return $this->lastStdOut;
}
/**
* Find exception texts in stderr
*/
public function findExceptions(): array {
$exceptions = [];
$captureNext = false;
// the exception text usually appears after an "[Exception"] row
foreach (explode("\n", $this->lastStdErr) as $line) {
if (preg_match('/\[Exception\]/', $line)) {
$captureNext = true;
continue;
}
if ($captureNext) {
$exceptions[] = trim($line);
$captureNext = false;
}
}
return $exceptions;
}
#[Then('/^the command was successful$/')]
public function theCommandWasSuccessful(): void {
$exceptions = $this->findExceptions();
if ($this->lastCode !== 0) {
echo $this->lastStdErr;
$msg = 'The command was not successful, exit code was ' . $this->lastCode . '.';
if (!empty($exceptions)) {
$msg .= "\n" . ' Exceptions: ' . implode(', ', $exceptions);
} else {
$msg .= "\n" . ' ' . $this->lastStdOut;
$msg .= "\n" . ' ' . $this->lastStdErr;
}
throw new \Exception($msg);
} elseif (!empty($exceptions)) {
$msg = 'The command was successful but triggered exceptions: ' . implode(', ', $exceptions);
throw new \Exception($msg);
}
}
#[Then('/^the command failed with exit code ([0-9]+)$/')]
public function theCommandFailedWithExitCode(int $exitCode): void {
Assert::assertEquals($exitCode, $this->lastCode, 'The commands exit code did not match: ' . $this->lastStdOut . "\n\n" . $this->lastStdErr);
}
#[Then('/^the command failed with exception text "([^"]*)"$/')]
public function theCommandFailedWithException(string $exceptionText): void {
$exceptions = $this->findExceptions();
if (empty($exceptions)) {
throw new \Exception('The command did not throw any exceptions');
}
if (!in_array($exceptionText, $exceptions)) {
throw new \Exception('The command did not throw any exception with the text "' . $exceptionText . '"');
}
}
#[Then('/^the command output contains the text:$/')]
#[Then('/^the command output contains the text "([^"]*)"$/')]
#[Then("/^the command output contains the text '([^']*)'\$/")]
public function theCommandOutputContainsTheText(string $text): void {
if ($this->lastStdOut === '' && $this->lastStdErr !== '') {
Assert::assertStringContainsString($text, $this->lastStdErr, 'The command did not output the expected text on stdout');
Assert::assertTrue(false, 'The command did not output the expected text on stdout but stderr');
}
Assert::assertStringContainsString($text, $this->lastStdOut, 'The command did not output the expected text on stdout');
}
#[Then('/^the command output is empty$/')]
public function theCommandOutputIsEmpty(): void {
Assert::assertEmpty($this->lastStdOut, 'The command did output unexpected text on stdout');
}
#[Then("/^the command output contains the list entry '([^']*)' with value '([^']*)'\$/")]
public function theCommandOutputContainsTheListEntry(string $key, string $value): void {
if (preg_match('/^"ROOM\(([^"]+)\)"$/', $key, $matches)) {
$key = '"' . self::$identifierToToken[$matches[1]] . '"';
}
$text = '- ' . $key . ': ' . $value;
if ($this->lastStdOut === '' && $this->lastStdErr !== '') {
Assert::assertStringContainsString($text, $this->lastStdErr, 'The command did not output the expected text on stdout');
Assert::assertTrue(false, 'The command did not output the expected text on stdout but stderr');
}
Assert::assertStringContainsString($text, $this->lastStdOut, 'The command did not output the expected text on stdout');
}
#[Then('/^the command error output contains the text "([^"]*)"$/')]
public function theCommandErrorOutputContainsTheText(string $text): void {
if ($this->lastStdErr === '' && $this->lastStdOut !== '') {
Assert::assertStringContainsString($text, $this->lastStdOut, 'The command did not output the expected text on stdout');
Assert::assertTrue(false, 'The command did not output the expected text on stdout but stderr');
}
Assert::assertStringContainsString($text, $this->lastStdErr, 'The command did not output the expected text on stderr');
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,429 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
use Behat\Gherkin\Node\TableNode;
use Behat\Hook\AfterScenario;
use Behat\Step\Given;
use Behat\Step\Then;
use Behat\Step\When;
use GuzzleHttp\Client;
use PHPUnit\Framework\Assert;
// The following attributes and methods are expected to be available in the
// class that uses this trait:
// - baseUrl
// - assertStatusCode()
// - sendRequest()
// - sendRequestFullUrl()
// - setAppConfig()
trait RecordingTrait {
/** @var ?resource */
private $recordingServerProcess = '';
/** @var ?resource */
private $signalingServerProcess = '';
private string $recordingServerAddress = 'localhost';
private int $recordingServerPort = 0;
private string $signalingServerAddress = 'localhost';
private int $signalingServerPort = 0;
private function getRecordingServerAddress(): string {
if (!str_contains($this->recordingServerAddress, ':')) {
$port = $this->getOpenPort($this->recordingServerAddress);
$this->recordingServerAddress = $this->recordingServerAddress . ':' . $port;
}
return $this->recordingServerAddress;
}
private function getSignalingServerAddress(): string {
if (!str_contains($this->signalingServerAddress, ':')) {
$port = $this->getOpenPort($this->signalingServerAddress);
$this->signalingServerAddress = $this->signalingServerAddress . ':' . $port;
}
return $this->signalingServerAddress;
}
/**
* Get an open port
*/
private function getOpenPort(string $address): int {
$sock = socket_create(AF_INET, SOCK_STREAM, 0);
if (!socket_bind($sock, $address, 0)) {
throw new \Exception('Failute to bind socket to host ' . $address);
}
socket_getsockname($sock, $ipAddress, $port);
socket_close($sock);
if ($port > 0) {
return $port;
}
throw new \Exception('Impossible to find an open port');
}
#[Given('/^(recording|signaling) server is started$/')]
public function recordingServerIsStarted(): void {
if ($this->isRunning()) {
return;
}
// "the … secret" is hardcoded in the fake recording server.
$this->setAppConfig('spreed', new TableNode([['recording_servers', json_encode([
'servers' => [
[
'server' => 'http://' . $this->getRecordingServerAddress()
]
],
'secret' => 'the recording secret'
])]]));
$this->setAppConfig('spreed', new TableNode([['signaling_servers', json_encode([
'servers' => [
[
'server' => 'http://' . $this->getSignalingServerAddress()
]
],
'secret' => 'the signaling secret'
])]]));
$path = 'mocks/FakeRecordingServer.php';
$this->recordingServerProcess = $this->startMockServer(
$this->getRecordingServerAddress() . ' ' . $path
);
$path = 'mocks/FakeSignalingServer.php';
$this->signalingServerProcess = $this->startMockServer(
$this->getSignalingServerAddress() . ' ' . $path
);
$this->waitForMockServer();
register_shutdown_function(function () {
$this->recordingServerIsStopped();
});
}
/**
* @return resource
*/
private function startMockServer(string $path) {
$cmd = 'php -S ' . $path;
$stdout = tempnam(sys_get_temp_dir(), 'mockserv-stdout-');
// We need to prefix exec to get the correct process http://php.net/manual/ru/function.proc-get-status.php#93382
$fullCmd = sprintf('exec %s > %s 2>&1',
$cmd,
$stdout
);
$pipes = [];
$env = null;
$cwd = null;
$stdin = fopen('php://stdin', 'rb');
$stdoutf = tempnam(sys_get_temp_dir(), 'MockWebServer.stdout');
$stderrf = tempnam(sys_get_temp_dir(), 'MockWebServer.stderr');
$descriptorSpec = [
0 => $stdin,
1 => [ 'file', $stdoutf, 'a' ],
2 => [ 'file', $stderrf, 'a' ],
];
$process = proc_open($fullCmd, $descriptorSpec, $pipes, $cwd, $env, [
'suppress_errors' => false,
'bypass_shell' => true,
]);
if (is_resource($process)) {
return $process;
}
throw new \Exception('Error starting server');
}
private function waitForMockServer(): void {
[$host, $port] = explode(':', $this->getSignalingServerAddress());
$mockServerIsUp = false;
for ($i = 0; $i <= 20; $i++) {
$open = @fsockopen($host, (int)$port);
if (is_resource($open)) {
fclose($open);
$mockServerIsUp = true;
break;
}
usleep(100000);
}
if (!$mockServerIsUp) {
throw new \Exception('Failure to start mock server.');
}
}
/**
* Is the Web Server currently running?
*/
public function isRunning(): bool {
if (!is_resource($this->recordingServerProcess)) {
return false;
}
$processStatus = proc_get_status($this->recordingServerProcess);
if (!$processStatus) {
return false;
}
return $processStatus['running'];
}
#[AfterScenario]
#[Given('/^(recording|signaling) server is stopped$/')]
public function recordingServerIsStopped(): void {
if (gettype($this->recordingServerProcess) === 'resource') {
$this->stop($this->recordingServerProcess);
$this->recordingServerProcess = null;
}
if (gettype($this->signalingServerProcess) === 'resource') {
$this->stop($this->signalingServerProcess);
$this->signalingServerProcess = null;
}
}
private function stop($process): void {
proc_terminate($process);
$attempts = 0;
while ($this->isRunning()) {
if (++$attempts > 1000) {
throw new \Exception('Failed to stop server.');
}
usleep(10000);
}
}
#[When('/^recording server sent started request for "(audio|video)" recording in room "([^"]*)" as "([^"]*)" with (\d+)(?: \((v1)\))?$/')]
public function recordingServerSentStartedRequestForRecordingInRoomAsWith(string $recordingType, string $identifier, string $user, int $statusCode, string $apiVersion = 'v1'): void {
$recordingTypes = [
'video' => 1,
'audio' => 2,
];
$data = [
'type' => 'started',
'started' => [
'token' => FeatureContext::getTokenForIdentifier($identifier),
'status' => $recordingTypes[$recordingType],
'actor' => [
'type' => 'users',
'id' => $user,
],
],
];
$this->sendBackendRequestFromRecordingServer($data, $statusCode, $apiVersion);
}
#[When('/^recording server sent stopped request for recording in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')]
public function recordingServerSentStoppedRequestForRecordingInRoomWith(string $identifier, int $statusCode, string $apiVersion = 'v1'): void {
$this->recordingServerSentStoppedRequestForRecordingInRoomAsWith($identifier, null, $statusCode, $apiVersion);
}
#[When('/^recording server sent stopped request for recording in room "([^"]*)" as "([^"]*)" with (\d+)(?: \((v1)\))?$/')]
public function recordingServerSentStoppedRequestForRecordingInRoomAsWith(string $identifier, ?string $user, int $statusCode, string $apiVersion = 'v1'): void {
$data = [
'type' => 'stopped',
'stopped' => [
'token' => FeatureContext::getTokenForIdentifier($identifier),
],
];
if ($user !== null) {
$data['stopped']['actor'] = [
'type' => 'users',
'id' => $user,
];
}
$this->sendBackendRequestFromRecordingServer($data, $statusCode, $apiVersion);
}
#[When('/^recording server sent failed request for recording in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')]
public function recordingServerSentFailedRequestForRecordingInRoomWith(string $identifier, int $statusCode, string $apiVersion = 'v1'): void {
$data = [
'type' => 'failed',
'failed' => [
'token' => FeatureContext::getTokenForIdentifier($identifier),
],
];
$this->sendBackendRequestFromRecordingServer($data, $statusCode, $apiVersion);
}
private function sendBackendRequestFromRecordingServer(array $data, int $statusCode, string $apiVersion = 'v1'): void {
$body = json_encode($data);
$random = md5((string)rand());
$checksum = hash_hmac('sha256', $random . $body, 'the recording secret');
$headers = [
'Backend-Url' => $this->baseUrl . 'ocs/v2.php/apps/spreed/api/' . $apiVersion . '/recording/backend',
'Talk-Recording-Random' => $random,
'Talk-Recording-Checksum' => $checksum,
];
$this->sendRequestFullUrl('POST', 'http://' . $this->getRecordingServerAddress() . '/fake/send-backend-request', $body, $headers);
$this->assertStatusCode($this->response, $statusCode);
}
#[Then('/^(recording|signaling) server received the following requests$/')]
public function fakeServerReceivedTheFollowingRequests(string $server, ?TableNode $formData = null): void {
if ($server === 'recording') {
$requests = $this->getRecordingServerReceivedRequests();
} else {
$requests = $this->getSignalingServerReceivedRequests();
}
if ($formData === null) {
Assert::assertEmpty($requests);
return;
}
if ($requests === null) {
Assert::fail('No received requests');
return;
}
$count = count($formData->getHash());
Assert::assertCount($count, $requests, 'Request count does not match' . "\n" . json_encode($requests));
$requests = array_map(static function (array $actual) {
$actualDataJson = json_decode($actual['data'], true);
$write = false;
// Fix sorting of postgres
if (isset($actualDataJson['update']['userids'])) {
sort($actualDataJson['update']['userids']);
$write = true;
}
if (isset($actualDataJson['participants']['users'])) {
usort($actualDataJson['participants']['users'], static fn (array $u1, array $u2) => $u1['userId'] <=> $u2['userId']);
$write = true;
}
if ($write) {
$actual['data'] = json_encode($actualDataJson);
}
return $actual;
}, $requests);
$expected = array_map(static function (array $request, array $actual) {
$identifier = $request['token'];
$request['token'] = FeatureContext::getTokenForIdentifier($identifier);
$matched = preg_match('/ROOM\(([^)]+)\)/', $request['data'], $matches);
if ($matched) {
$request['data'] = str_replace(
'ROOM(' . $matches[1] . ')',
FeatureContext::getTokenForIdentifier($matches[1]),
$request['data']
);
}
$matched = preg_match('/PHONE\((\+\d+)\)/', $request['data'], $matches);
if ($matched) {
$request['data'] = str_replace(
'PHONE(' . $matches[1] . ')',
FeatureContext::getActorIdForPhoneNumber($matches[1]),
$request['data']
);
}
$matched = preg_match('/PHONEATTENDEE\((\+\d+)\)/', $request['data'], $matches);
if ($matched) {
$request['data'] = str_replace(
'PHONEATTENDEE(' . $matches[1] . ')',
(string)FeatureContext::getAttendeeIdForPhoneNumber($identifier, $matches[1]),
$request['data']
);
}
$matched = preg_match('/SESSION\(([^)]+)\)/', $request['data'], $matches);
if ($matched) {
$request['data'] = str_replace(
'SESSION(' . $matches[1] . ')',
str_replace('/', '\/', FeatureContext::getSessionIdForUser($matches[1])),
$request['data']
);
}
$matched = preg_match('/"lastPing":LAST_PING\(\)/', $request['data'], $matches);
if ($matched) {
$matched = preg_match('/"lastPing":(\d+)/', $actual['data'], $matches);
if ($matched) {
$request['data'] = str_replace(
'"lastPing":LAST_PING()',
$matches[0],
$request['data']
);
}
}
$matched = preg_match('/"active-since":\{"date":"ACTIVE_SINCE\(\)","timezone_type":3,"timezone":"UTC"}/', $request['data'], $matches);
if ($matched) {
$matched = preg_match('/"active-since":\{"date":"([\d\- :.]+)","timezone_type":3,"timezone":"UTC"}/', $actual['data'], $matches);
if ($matched) {
$request['data'] = str_replace(
'ACTIVE_SINCE()',
$matches[1],
$request['data']
);
}
}
return $request;
}, $formData->getHash(), $requests);
Assert::assertEquals($expected, $requests);
}
#[Then('/^signaling server will respond with$/')]
public function nextSignalingServerResponseIs(TableNode $formData): void {
$data = $formData->getRowsHash();
$nextResponseFile = sys_get_temp_dir() . '/fake-nextcloud-talk-signaling-response';
file_put_contents($nextResponseFile, $data['response']);
}
#[Then('/^reset (recording|signaling) server requests$/')]
public function resetSignalingServerRequests(string $server): void {
if ($server === 'recording') {
$this->getRecordingServerReceivedRequests();
} else {
$this->getSignalingServerReceivedRequests();
}
}
private function getRecordingServerReceivedRequests(): ?array {
$url = 'http://' . $this->getRecordingServerAddress() . '/fake/requests';
$client = new Client();
$response = $client->get($url);
return json_decode($response->getBody()->getContents(), true);
}
private function getSignalingServerReceivedRequests(): ?array {
$url = 'http://' . $this->getSignalingServerAddress() . '/fake/requests';
$client = new Client();
$response = $client->get($url);
return json_decode($response->getBody()->getContents(), true);
}
}
@@ -0,0 +1,847 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
use Behat\Behat\Context\Context;
use Behat\Gherkin\Node\TableNode;
use Behat\Step\Given;
use Behat\Step\Then;
use Behat\Step\When;
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use Psr\Http\Message\ResponseInterface;
class SharingContext implements Context {
private string $baseUrl;
private string $currentServer;
private ?ResponseInterface $response = null;
private ?string $responseBody = null;
private string $currentUser = '';
private array $adminUser;
private string $regularUserPassword;
private ?\SimpleXMLElement $lastCreatedShareData = null;
public function __construct(string $baseUrl, array $admin, string $regularUserPassword) {
$this->baseUrl = $baseUrl;
$this->adminUser = $admin;
$this->regularUserPassword = $regularUserPassword;
// in case of ci deployment we take the server url from the environment
$testServerUrl = getenv('TEST_SERVER_URL');
if ($testServerUrl !== false) {
$this->baseUrl = $testServerUrl;
}
}
public function setCurrentServer(string $currentServer, string $baseUrl): void {
$this->currentServer = $currentServer;
$this->baseUrl = $baseUrl;
}
#[Given('user :user creates folder :destination')]
public function userCreatesFolder(string $user, string $destination): void {
$this->currentUser = $user;
$url = "/$user/$destination/";
$this->sendingToDav('MKCOL', $url);
$this->theHTTPStatusCodeShouldBe(201);
}
#[Given('user :user moves file :source to :destination')]
public function userMovesFileTo(string $user, string $source, string $destination): void {
$this->currentUser = $user;
$url = "/$user/$source";
$headers = [];
$headers['Destination'] = $this->baseUrl . "remote.php/dav/files/$user/" . $destination;
$this->sendingToDav('MOVE', $url, $headers);
}
#[Given('user :user moves file :source to :destination with :statusCode')]
public function userMovesFileToWith(string $user, string $source, string $destination, int $statusCode): void {
$this->userMovesFileTo($user, $source, $destination);
$this->theHTTPStatusCodeShouldBe($statusCode);
}
#[Given('user :user deletes file :file')]
public function userDeletesFile(string $user, string $file): void {
$this->currentUser = $user;
$url = "/$user/$file";
$this->sendingToDav('DELETE', $url);
$this->theHTTPStatusCodeShouldBe(204);
}
#[When('user :user shares :path with user :sharee')]
public function userSharesWithUser(string $user, string $path, string $sharee, ?TableNode $body = null): void {
$this->userSharesWith($user, $path, 0 /*IShare::TYPE_USER*/, $sharee, $body);
}
#[When('user :user shares :path with user :sharee with OCS :statusCode')]
public function userSharesWithUserWithOcs(string $user, string $path, string $sharee, int $statusCode): void {
$this->userSharesWithUser($user, $path, $sharee);
$this->theOCSStatusCodeShouldBe($statusCode);
}
#[When('user :user shares :path with group :sharee')]
public function userSharesWithGroup(string $user, string $path, string $sharee, ?TableNode $body = null): void {
$this->userSharesWith($user, $path, 1 /*IShare::TYPE_GROUP*/, $sharee, $body);
}
#[When('user :user shares :path with group :sharee with OCS :statusCode')]
public function userSharesWithGroupWithOcs(string $user, string $path, string $sharee, int $statusCode): void {
$this->userSharesWithGroup($user, $path, $sharee);
$this->theOCSStatusCodeShouldBe($statusCode);
}
#[When('user :user shares :path with team :sharee')]
public function userSharesWithTeam(string $user, string $path, string $sharee, ?TableNode $body = null): void {
$this->userSharesWith($user, $path, 7 /*IShare::TYPE_CIRCLE*/, $sharee, $body);
}
#[When('user :user shares :path with team :sharee with OCS :statusCode')]
public function userSharesWithTeamWithOcs(string $user, string $path, string $sharee, int $statusCode): void {
$this->userSharesWithTeam($user, $path, FeatureContext::getTeamIdForLabel($this->currentServer, $sharee));
$this->theOCSStatusCodeShouldBe($statusCode);
}
#[When('user :user shares :path with room :room')]
public function userSharesWithRoom(string $user, string $path, string $room, ?TableNode $body = null): void {
$this->userSharesWith($user, $path, 10 /*IShare::TYPE_ROOM*/, FeatureContext::getTokenForIdentifier($room), $body);
}
#[When('user :user shares :path with :amount rooms')]
public function userSharesWithManyRooms(string $user, string $path, int $amount, ?TableNode $body = null): void {
for ($i = 1; $i <= $amount; $i++) {
$identifier = 'room' . $i;
$this->userSharesWith($user, $path, 10 /*IShare::TYPE_ROOM*/, FeatureContext::getTokenForIdentifier($identifier), $body);
}
}
#[When('user :user shares :path with room :room with OCS :statusCode')]
public function userSharesWithRoomWithOcs(string $user, string $path, string $room, int $statusCode): void {
$this->userSharesWithRoom($user, $path, $room);
$this->theOCSStatusCodeShouldBe($statusCode);
}
#[When('user :user shares :path by link')]
public function userSharesByLink(string $user, string $path, ?TableNode $body = null): void {
$this->userSharesWith($user, $path, 3 /*IShare::TYPE_LINK*/, '', $body);
}
#[When('user :user shares :path by link with OCS :statusCode')]
public function userSharesByLinkWithOcs(string $user, string $path, int $statusCode, ?TableNode $body = null): void {
$this->userSharesByLink($user, $path, $body);
$this->theOCSStatusCodeShouldBe($statusCode);
}
#[When('user :user updates last share with')]
public function userUpdatesLastShareWith(string $user, TableNode $body): void {
$this->currentUser = $user;
$url = '/apps/files_sharing/api/v1/shares/' . $this->getLastShareId();
$this->sendingTo('PUT', $url, $body);
}
#[When('user :user deletes last share')]
public function userDeletesLastShare(string $user): void {
$this->currentUser = $user;
$url = '/apps/files_sharing/api/v1/shares/' . $this->getLastShareId();
$this->sendingTo('DELETE', $url);
}
#[When('user :user deletes last share with OCS :statusCode')]
public function userDeletesLastShareWithOcs(string $user, int $statusCode): void {
$this->userDeletesLastShare($user);
$this->theOCSStatusCodeShouldBe($statusCode);
}
#[When('user :user restores last share')]
public function userRestoresLastShareWithOcs(string $user): void {
$this->currentUser = $user;
$url = '/apps/files_sharing/api/v1/deletedshares/ocRoomShare:' . $this->getLastShareId();
$this->sendingTo('POST', $url);
}
#[When('user :user gets last share')]
public function userGetsLastShare(string $user): void {
$this->currentUser = $user;
$url = '/apps/files_sharing/api/v1/shares/' . $this->getLastShareId();
$this->sendingTo('GET', $url);
}
#[When('user :user accepts last share')]
public function userAcceptsLastShare(string $user): void {
$this->currentUser = $user;
$url = '/apps/files_sharing/api/v1/shares/pending/' . $this->getLastShareId();
$this->sendingTo('POST', $url);
$this->theHTTPStatusCodeShouldBe(200);
$this->theOCSStatusCodeShouldBe(100);
}
#[When('user :user gets all shares')]
public function userGetsAllShares(string $user): void {
$this->currentUser = $user;
$url = '/apps/files_sharing/api/v1/shares';
$this->sendingTo('GET', $url);
}
#[When('user :user gets all shares and reshares')]
public function userGetsAllSharesAndReshares(string $user): void {
$this->currentUser = $user;
$url = '/apps/files_sharing/api/v1/shares?reshares=true';
$this->sendingTo('GET', $url);
}
#[When('user :user gets all shares for :path')]
public function userGetsAllSharesFor(string $user, string $path): void {
$this->currentUser = $user;
$url = '/apps/files_sharing/api/v1/shares?path=' . $path;
$this->sendingTo('GET', $url);
}
#[When('user :user gets all shares and reshares for :path')]
public function userGetsAllSharesAndResharesFor(string $user, string $path): void {
$this->currentUser = $user;
$url = '/apps/files_sharing/api/v1/shares?reshares=true&path=' . $path;
$this->sendingTo('GET', $url);
}
#[When('user :user gets all shares for :path and its subfiles')]
public function userGetsAllSharesForAndItsSubfiles(string $user, string $path): void {
$this->currentUser = $user;
$url = '/apps/files_sharing/api/v1/shares?subfiles=true&path=' . $path;
$this->sendingTo('GET', $url);
}
#[When('user :user gets all received shares')]
public function userGetsAllReceivedShares(string $user): void {
$this->currentUser = $user;
$url = '/apps/files_sharing/api/v1/shares?shared_with_me=true';
$this->sendingTo('GET', $url);
}
#[When('user :user gets all received shares for :path')]
public function userGetsAllReceivedSharesFor(string $user, string $path): void {
$this->currentUser = $user;
$url = '/apps/files_sharing/api/v1/shares?shared_with_me=true&path=' . $path;
$this->sendingTo('GET', $url);
}
#[When('user :user gets deleted shares')]
public function userGetsDeletedShares(string $user): void {
$this->currentUser = $user;
$url = '/apps/files_sharing/api/v1/deletedshares';
$this->sendingTo('GET', $url);
}
#[When('/^user "([^"]*)" gets sharees for$/')]
public function userGetsShareesFor(string $user, TableNode $body): void {
$this->currentUser = $user;
$url = '/apps/files_sharing/api/v1/sharees';
$parameters = [];
$parameters[] = 'shareType=10'; // IShare::TYPE_ROOM,
$parameters[] = 'itemType=file';
foreach ($body->getRowsHash() as $key => $value) {
$parameters[] = $key . '=' . $value;
}
$url .= '?' . implode('&', $parameters);
$this->sendingTo('GET', $url);
\PHPUnit\Framework\Assert::assertEquals(200, $this->response->getStatusCode());
}
#[When('user :user gets the DAV properties for :path')]
public function userGetsTheDavPropertiesFor(string $user, string $path): void {
$this->currentUser = $user;
$url = "/$user/$path";
$this->sendingToDav('PROPFIND', $url);
$this->theHTTPStatusCodeShouldBe(207);
}
#[When('user :user gets the share-type DAV property for :path')]
public function userGetsTheShareTypeDavPropertyFor(string $user, string $path): void {
$this->currentUser = $user;
$url = "/$user/$path";
$headers = null;
$body = '<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">'
. ' <d:prop>'
. ' <oc:share-types/>'
. ' </d:prop>'
. '</d:propfind>';
$this->sendingToDav('PROPFIND', $url, $headers, $body);
$this->theHTTPStatusCodeShouldBe(207);
}
#[When('user :user gets recent files')]
public function userGetsRecentFiles(string $user): void {
// Recents endpoint is not an OCS endpoint, so a request token must be
// provided.
[$requestToken, $cookieJar] = $this->loggingInUsingWebAs($user);
$url = '/index.php/apps/files/api/v1/recent';
$this->sendingToWithRequestToken('GET', $url, $requestToken, $cookieJar);
}
#[When('transfering ownership from :user1 to :user2')]
public function transferingOwnershipFromTo(string $user1, string $user2): void {
$args = ['files:transfer-ownership', $user1, $user2];
$args = array_map(function ($arg) {
return escapeshellarg($arg);
}, $args);
$args[] = '--no-ansi';
$args = implode(' ', $args);
$descriptor = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open('php console.php ' . $args, $descriptor, $pipes, '../../../../');
$lastStdOut = stream_get_contents($pipes[1]);
$lastStdErr = stream_get_contents($pipes[2]);
$lastCode = proc_close($process);
// Clean opcode cache
$client = new GuzzleHttp\Client();
$client->request('GET', $this->baseUrl . '/apps/testing/clean_opcode_cache.php');
\PHPUnit\Framework\Assert::assertEquals(0, $lastCode);
}
#[Then('the OCS status code should be :statusCode')]
public function theOCSStatusCodeShouldBe(int $statusCode): void {
$meta = $this->getXmlResponse()->meta[0];
\PHPUnit\Framework\Assert::assertEquals($statusCode, (int)$meta->statuscode, 'Response message: ' . (string)$meta->message);
}
#[Then('the HTTP status code should be :statusCode')]
public function theHTTPStatusCodeShouldBe(int $statusCode): void {
\PHPUnit\Framework\Assert::assertEquals($statusCode, $this->response->getStatusCode());
}
#[Then('the list of returned shares has :count shares')]
public function theListOfReturnedSharesHasShares(int $count): void {
$this->theHTTPStatusCodeShouldBe(200);
$this->theOCSStatusCodeShouldBe(100);
$returnedShares = $this->getXmlResponse()->data[0];
\PHPUnit\Framework\Assert::assertEquals($count, count($returnedShares->element));
}
#[Then('share is returned with')]
public function shareIsReturnedWith(TableNode $body): void {
$this->shareXIsReturnedWith(0, $body);
}
#[Then('share :number is returned with')]
public function shareXIsReturnedWith(int $number, TableNode $body): void {
$this->theHTTPStatusCodeShouldBe(200);
$this->theOCSStatusCodeShouldBe(100);
if (!($body instanceof TableNode)) {
return;
}
$returnedShare = $this->getXmlResponse()->data[0];
if ($returnedShare->element) {
$returnedShare = (array)$returnedShare;
$returnedShare = $returnedShare['element'];
if (is_array($returnedShare)) {
usort($returnedShare, static function ($share1, $share2) {
return (int)$share1->id - (int)$share2->id;
});
}
$returnedShare = $returnedShare[$number];
}
$defaultExpectedFields = [
'id' => 'A_NUMBER',
'share_type' => '10', // IShare::TYPE_ROOM,
'permissions' => '19',
'stime' => 'A_NUMBER',
'parent' => '',
'expiration' => '',
'token' => '',
'storage' => 'A_NUMBER',
'item_source' => 'A_NUMBER',
'file_source' => 'A_NUMBER',
'file_parent' => 'A_NUMBER',
'mail_send' => '0',
];
$fields = $body->getRowsHash();
if (isset($fields['share_type']) && ($fields['share_type'] === '10' || $fields['share_type'] === '11')) {
$defaultExpectedFields['share_with_link'] = 'URL';
}
if (isset($fields['share_type']) && ($fields['share_type'] === '0' || $fields['share_type'] === '1')) {
/**
* This field was changed so often in the server,
* that we simply don't care any more. We want to test Talk,
* not normal user shares.
* Refs:
* - https://github.com/nextcloud/server/pull/49898
* - https://github.com/nextcloud/server/pull/48381
* - https://github.com/nextcloud/spreed/pull/13632
*/
unset($defaultExpectedFields['mail_send']);
}
$expectedFields = array_merge($defaultExpectedFields, $fields);
if (!array_key_exists('uid_file_owner', $expectedFields)
&& array_key_exists('uid_owner', $expectedFields)) {
$expectedFields['uid_file_owner'] = $expectedFields['uid_owner'];
}
if (!array_key_exists('displayname_file_owner', $expectedFields)
&& array_key_exists('displayname_owner', $expectedFields)) {
$expectedFields['displayname_file_owner'] = $expectedFields['displayname_owner'];
}
if (array_key_exists('share_type', $expectedFields)
&& $expectedFields['share_type'] == 10 /* IShare::TYPE_ROOM */
&& array_key_exists('share_with', $expectedFields)) {
if ($expectedFields['share_with'] === 'private_conversation') {
$expectedFields['share_with'] = 'REGEXP /^private_conversation_[0-9a-f]{6}$/';
$expectedFields['share_with_link'] = '';
} else {
$expectedFields['share_with'] = FeatureContext::getTokenForIdentifier($expectedFields['share_with']);
}
}
if (array_key_exists('share_with_link', $expectedFields)
&& $expectedFields['share_with_link'] === 'URL') {
if (array_key_exists('share_with', $expectedFields)) {
$expectedFields['share_with_link'] = $this->baseUrl . 'index.php/call/' . $expectedFields['share_with'];
} else {
$expectedFields['share_with_link'] = 'REGEXP ' . '/\/call\//';
}
}
foreach ($expectedFields as $field => $value) {
$share = json_decode(json_encode($returnedShare), true);
if (isset($share[$field]) && empty($share[$field]) && is_array($share[$field])) {
// Fix XML parsing fails
$share[$field] = '';
}
if (in_array($field, ['path', 'storage_id'], true)
&& preg_match('/Transferred from [^ ]* on (\d{4}-\d{2}-\d{2} \d{2}-\d{2}-\d{2})/', $share[$field], $matches)) {
// We have to replace strings like
// "Transferred from participant1-displayname on 2025-06-30 12-31-32"
// with something neutral that works in tests
$share[$field] = str_replace($matches[1], '{{DATE AND TIME}}', $share[$field]);
}
$this->assertFieldIsInReturnedShare($field, $value, $share);
}
}
/**
* Each sharee is specified as "| room name | room test identifier |"; the
* name is checked against the returned "label" value, and the room test
* identifier is used to get the room token, which is checked against the
* returned "shareWith" value. The returned "shareType" value is expected to
* always be "IShare::TYPE_ROOM", so there is no need to specify it.
*/
#[Then('/^"([^"]*)" sharees returned (are|is empty)$/')]
public function shareesReturnedAreIsEmpty(string $shareeType, string $isEmpty, ?TableNode $shareesList = null): void {
if ($isEmpty !== 'is empty') {
$sharees = [];
foreach ($shareesList->getRows() as $row) {
$expectedSharee = [$row[0]];
$expectedSharee[] = 10; // IShare::TYPE_ROOM
$expectedSharee[] = FeatureContext::getTokenForIdentifier($row[1]);
$sharees[] = $expectedSharee;
}
$respondedArray = $this->getArrayOfShareesResponded($this->getXmlResponse(), $shareeType);
usort($sharees, function ($a, $b) {
return $a[2] <=> $b[2]; // Sort by token
});
usort($respondedArray, function ($a, $b) {
return $a[2] <=> $b[2]; // Sort by token
});
\PHPUnit\Framework\Assert::assertEquals($sharees, $respondedArray);
} else {
$respondedArray = $this->getArrayOfShareesResponded($this->getXmlResponse(), $shareeType);
\PHPUnit\Framework\Assert::assertEmpty($respondedArray);
}
}
#[Then('the list of returned files for :user is')]
public function theListOfReturnedFilesForIs(string $user, ?TableNode $table = null): void {
$xmlResponse = $this->getXmlResponse();
$xmlResponse->registerXPathNamespace('d', 'DAV:');
$hrefs = [];
foreach ($xmlResponse->xpath('//d:response/d:href') as $href) {
$hrefs[] = (string)$href;
}
$expectedHrefs = [];
if ($table !== null) {
foreach ($table->getRows() as $row) {
$expectedHrefs[] = '/remote.php/dav/files/' . $user . (string)$row[0];
}
}
\PHPUnit\Framework\Assert::assertEquals($expectedHrefs, $hrefs);
}
#[Then('the response contains a share-types DAV property with')]
public function theResponseContainsAShareTypesDavPropertyWith(?TableNode $table = null): void {
$xmlResponse = $this->getXmlResponse();
$xmlResponse->registerXPathNamespace('oc', 'http://owncloud.org/ns');
$shareTypes = [];
foreach ($xmlResponse->xpath('//oc:share-types/oc:share-type') as $shareType) {
$shareTypes[] = (int)$shareType;
}
$expectedShareTypes = [];
if ($table !== null) {
foreach ($table->getRows() as $row) {
$expectedShareTypes[] = (int)$row[0];
}
}
\PHPUnit\Framework\Assert::assertEquals($expectedShareTypes, $shareTypes);
}
#[Then('the response contains a share-types file property for :path with')]
public function theResponseContainsAShareTypesFilesPropertyForWith(string $path, ?TableNode $table = null): void {
if ($this->responseBody === null) {
$this->responseBody = $this->response->getBody()->getContents();
}
$response = json_decode($this->responseBody);
$fileForPath = array_filter($response->files, function ($file) use ($path) {
$filePath = $file->path . (substr($file->path, -1) === '/'? '': '/');
return ($filePath . $file->name) === $path;
});
if (empty($fileForPath)) {
\PHPUnit\Framework\Assert::fail("$path not found in the response");
}
$fileForPath = array_shift($fileForPath);
$shareTypes = [];
if (property_exists($fileForPath, 'shareTypes')) {
foreach ($fileForPath->shareTypes as $shareType) {
$shareTypes[] = (int)$shareType;
}
}
$expectedShareTypes = [];
if ($table !== null) {
foreach ($table->getRows() as $row) {
$expectedShareTypes[] = (int)$row[0];
}
}
\PHPUnit\Framework\Assert::assertEquals($expectedShareTypes, $shareTypes);
}
private function userSharesWith(string $user, string $path, int $shareType, string $shareWith, ?TableNode $body = null): void {
$this->currentUser = $user;
$url = '/apps/files_sharing/api/v1/shares';
$parameters = [];
$parameters[] = 'path=' . $path;
$parameters[] = 'shareType=' . $shareType;
$parameters[] = 'shareWith=' . $shareWith;
$talkMetaData = [];
if ($body instanceof TableNode) {
foreach ($body->getRowsHash() as $key => $value) {
if ($key === 'expireDate' && $value !== 'invalid date') {
$value = date('Y-m-d', strtotime($value));
}
if ($key === 'talkMetaData.replyTo' || $key === 'talkMetaData.threadId') {
$value = FeatureContext::getMessageIdForText($value);
}
if (str_starts_with($key, 'talkMetaData.')) {
$talkMetaData[substr($key, 13)] = $value;
} else {
$parameters[] = $key . '=' . $value;
}
}
}
if (!empty($talkMetaData)) {
$parameters[] = 'talkMetaData=' . json_encode($talkMetaData);
}
$url .= '?' . implode('&', $parameters);
$this->sendingTo('POST', $url);
$this->lastCreatedShareData = $this->getXmlResponse();
}
private function sendingTo(string $verb, string $url, ?TableNode $body = null): void {
$fullUrl = $this->baseUrl . 'ocs/v1.php' . $url;
$client = new Client();
$options = [];
if ($this->currentUser === 'admin') {
$options['auth'] = $this->adminUser;
} else {
$options['auth'] = [$this->currentUser, $this->regularUserPassword];
}
$options['headers'] = [
'OCS_APIREQUEST' => 'true'
];
if ($body instanceof TableNode) {
$fd = $body->getRowsHash();
if (array_key_exists('expireDate', $fd)) {
$fd['expireDate'] = date('Y-m-d', strtotime($fd['expireDate']));
}
$options['form_params'] = $fd;
}
try {
$this->response = $client->request($verb, $fullUrl, $options);
$this->responseBody = null;
} catch (GuzzleHttp\Exception\ClientException $ex) {
$this->response = $ex->getResponse();
$this->responseBody = null;
}
}
private function sendingToDav(string $verb, string $url, ?array $headers = null, ?string $body = null): void {
$fullUrl = $this->baseUrl . 'remote.php/dav/files' . $url;
$client = new Client();
$options = [];
if ($this->currentUser === 'admin') {
$options['auth'] = $this->adminUser;
} else {
$options['auth'] = [$this->currentUser, $this->regularUserPassword];
}
$options['headers'] = [
'OCS_APIREQUEST' => 'true'
];
if ($headers !== null) {
$options['headers'] = array_merge($options['headers'], $headers);
}
if ($body !== null) {
$options['body'] = $body;
}
try {
$this->response = $client->request($verb, $fullUrl, $options);
$this->responseBody = null;
} catch (GuzzleHttp\Exception\ClientException $ex) {
$this->response = $ex->getResponse();
$this->responseBody = null;
}
}
private function sendingToWithRequestToken(string $verb, string $url, string $requestToken, CookieJar $cookieJar): void {
$fullUrl = $this->baseUrl . $url;
$client = new Client();
try {
$this->response = $client->request(
$verb,
$fullUrl,
[
'cookies' => $cookieJar,
'headers' => [
'requesttoken' => $requestToken
]
]
);
$this->responseBody = null;
} catch (GuzzleHttp\Exception\ClientException $e) {
$this->response = $e->getResponse();
$this->responseBody = null;
}
}
private function extractRequestTokenFromResponse(ResponseInterface $response): string {
return substr(preg_replace('/(.*)data-requesttoken="(.*)">(.*)/sm', '\2', $response->getBody()->getContents()), 0, 89);
}
private function loggingInUsingWebAs(string $user): array {
$loginUrl = $this->baseUrl . '/login';
$cookieJar = new CookieJar();
// Request a new session and extract CSRF token
$client = new Client();
$response = $client->get(
$loginUrl,
[
'cookies' => $cookieJar,
]
);
$requestToken = $this->extractRequestTokenFromResponse($response);
// Login and extract new token
$password = ($user === 'admin') ? $this->adminUser[1] : $this->regularUserPassword;
$client = new Client();
$response = $client->post(
$loginUrl,
[
'form_params' => [
'user' => $user,
'password' => $password,
'requesttoken' => $requestToken,
],
'cookies' => $cookieJar,
'headers' => [
'Origin' => $this->baseUrl,
],
]
);
$requestToken = $this->extractRequestTokenFromResponse($response);
return [$requestToken, $cookieJar];
}
public function getLastShareToken(): string {
return (string)$this->lastCreatedShareData->data[0]->token;
}
private function getLastShareId(): string {
return (string)$this->lastCreatedShareData->data[0]->id;
}
private function getXmlResponse(): \SimpleXMLElement|false {
if ($this->responseBody === null) {
$this->responseBody = $this->response->getBody()->getContents();
}
return simplexml_load_string($this->responseBody);
}
private function assertFieldIsInReturnedShare(string $field, string $contentExpected, array $returnedShare): void {
if ($contentExpected === 'IGNORE') {
return;
}
if (!array_key_exists($field, $returnedShare)) {
\PHPUnit\Framework\Assert::fail("$field was not found in response");
}
if ($field === 'expiration' && !empty($contentExpected)) {
$contentExpected = date('Y-m-d', strtotime($contentExpected)) . ' 00:00:00';
}
if ($contentExpected === 'A_NUMBER') {
\PHPUnit\Framework\Assert::assertTrue(is_numeric((string)$returnedShare[$field]), "Field '$field' is not a number: " . $returnedShare[$field]);
} elseif ($contentExpected === 'A_TOKEN') {
// A token is composed by 15 characters from
// ISecureRandom::CHAR_HUMAN_READABLE.
\PHPUnit\Framework\Assert::assertMatchesRegularExpression('/^[abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789]{15}$/', (string)$returnedShare[$field], "Field '$field' is not a token");
} elseif (strpos($contentExpected, 'REGEXP ') === 0) {
\PHPUnit\Framework\Assert::assertMatchesRegularExpression(substr($contentExpected, strlen('REGEXP ')), (string)$returnedShare[$field], "Field '$field' does not match");
} else {
\PHPUnit\Framework\Assert::assertEquals($contentExpected, (string)$returnedShare[$field], "Field '$field' does not match");
}
}
private function getArrayOfShareesResponded(\SimpleXMLElement $response, string $shareeType): array {
$elements = $response->data;
$elements = json_decode(json_encode($elements), true);
if (strpos($shareeType, 'exact ') === 0) {
$elements = $elements['exact'];
$shareeType = substr($shareeType, 6);
}
// "simplexml_load_string" creates a SimpleXMLElement object for each
// XML element with child elements. In turn, each child is indexed by
// its tag in the SimpleXMLElement object. However, when there are
// several child XML elements with the same tag, an array with all the
// children with the same tag is indexed instead. Therefore, when the
// XML contains
// <rooms>
// <element>
// <label>...</label>
// <value>...</value>
// </element>
// </rooms>
// the "$elements[$shareeType]" variable contains an "element" key which
// in turn contains "label" and "value" keys, but when the XML contains
// <rooms>
// <element>
// <label>...</label>
// <value>...</value>
// </element>
// <element>
// <label>...</label>
// <value>...</value>
// </element>
// </rooms>
// the "$elements[$shareeType]" variable contains an "element" key which
// in turn contains "0" and "1" keys, and in turn each one contains
// "label" and "value" keys.
$elements = $elements[$shareeType];
if (array_key_exists('element', $elements) && is_int(array_keys($elements['element'])[0])) {
$elements = $elements['element'];
}
$sharees = [];
foreach ($elements as $element) {
$sharees[] = [$element['label'], $element['value']['shareType'], $element['value']['shareWith']];
}
return $sharees;
}
}