diff --git a/appinfo/info.xml b/appinfo/info.xml
index 9df4fb24..bd765481 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -45,6 +45,7 @@ While theoretically any other authentication provider implementing either one of
OCA\User_SAML\Migration\CleanupRemovedConfig
+ OCA\User_SAML\Migration\TransferGroupMembers
@@ -53,6 +54,7 @@ While theoretically any other authentication provider implementing either one of
OCA\User_SAML\Command\ConfigGet
OCA\User_SAML\Command\ConfigSet
OCA\User_SAML\Command\GetMetadata
+ OCA\User_SAML\Command\GroupMigrationCopyIncomplete
OCA\User_SAML\Settings\Admin
diff --git a/lib/Command/ConfigDelete.php b/lib/Command/ConfigDelete.php
index 0d5e04d3..eba270cc 100644
--- a/lib/Command/ConfigDelete.php
+++ b/lib/Command/ConfigDelete.php
@@ -44,7 +44,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->samlSettings->delete($pId);
$output->writeln('Provider deleted.');
} catch (Exception $e) {
- $output->writeln('Provider with id: ' . $providerId . ' does not exist.');
+ $output->writeln('Provider with id: ' . $pId . ' does not exist.');
return 1;
}
return 0;
diff --git a/lib/Command/GroupMigrationCopyIncomplete.php b/lib/Command/GroupMigrationCopyIncomplete.php
new file mode 100644
index 00000000..7d746245
--- /dev/null
+++ b/lib/Command/GroupMigrationCopyIncomplete.php
@@ -0,0 +1,93 @@
+setName('saml:group-migration:copy-incomplete-members');
+ $this->setDescription('Transfers remaining group members from old local to current SAML groups');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $groupsToTreat = $this->groupMigration->findGroupsWithLocalMembers();
+ if (empty($groupsToTreat)) {
+ if ($output->isVerbose()) {
+ $output->writeln('No pending group member transfer');
+ }
+ return 0;
+ }
+
+ if (!$this->doMemberTransfer($groupsToTreat, $output)) {
+ if (!$output->isQuiet()) {
+ $output->writeln('Not all group members could be transferred completely. Rerun this command or check the Nextcloud log.');
+ }
+ return 1;
+ }
+
+ if (!$output->isQuiet()) {
+ $output->writeln('All group members could be transferred completely.');
+ }
+ return 0;
+ }
+
+ /**
+ * @param string[]|array $groups
+ * @param OutputInterface $output
+ * @return bool
+ */
+ protected function doMemberTransfer(array $groups, OutputInterface $output): bool {
+ $errorOccurred = false;
+ for ($i = 0; $i < 2; $i++) {
+ $retry = [];
+ foreach ($groups as $gid) {
+ try {
+ $isComplete = $this->groupMigration->migrateGroupUsers($gid);
+ if (!$isComplete) {
+ $retry[] = $gid;
+ } else {
+ $this->groupMigration->cleanUpOldGroupUsers($gid);
+ if ($output->isVerbose()) {
+ $output->writeln(sprintf('Members transferred successfully for group %s', $gid));
+ }
+ }
+ } catch (Throwable $e) {
+ $errorOccurred = true;
+ if (!$output->isQuiet()) {
+ $output->writeln(sprintf('Failed to transfer users from group %s: %s', $gid, $e->getMessage()));
+ }
+ $this->logger->warning('Error while transferring group members of {gid}', ['gid' => $gid, 'exception' => $e]);
+ }
+ }
+ if (empty($retry)) {
+ return true;
+ }
+ /** @var string[]|array $groups */
+ $groups = $retry;
+ }
+ if (!empty($groups) && !$output->isQuiet()) {
+ $output->writeln(sprintf(
+ 'Members not or incompletely transferred for groups: %s',
+ implode(', ', $groups)
+ ));
+ }
+ return empty($groups) && !$errorOccurred;
+ }
+}
diff --git a/lib/GroupManager.php b/lib/GroupManager.php
index 989c1bb4..63948fa1 100644
--- a/lib/GroupManager.php
+++ b/lib/GroupManager.php
@@ -23,6 +23,7 @@
class GroupManager {
public const LOCAL_GROUPS_CHECK_FOR_MIGRATION = 'localGroupsCheckForMigration';
+ public const STATE_MIGRATION_PHASE_EXPIRED = 'EXPIRED';
/**
* @var IDBConnection $db
@@ -68,8 +69,6 @@ public function __construct(
*/
private function getGroupsToRemove(array $samlGroupNames, array $assignedGroups): array {
$groupsToRemove = [];
- // FIXME: Seems unused
- $this->config->getAppValue('user_saml', self::LOCAL_GROUPS_CHECK_FOR_MIGRATION, '');
foreach ($assignedGroups as $group) {
// if group is not supplied by SAML and group has SAML backend
if (!in_array($group->getGID(), $samlGroupNames) && $this->hasSamlBackend($group)) {
@@ -201,7 +200,7 @@ protected function findGroup(string $gid): IGroup {
$strictBackendCheck = $migrationAllowListRaw === '';
$migrationAllowList = null;
- if ($migrationAllowListRaw !== '') {
+ if ($migrationAllowListRaw !== '' || $migrationAllowListRaw !== self::STATE_MIGRATION_PHASE_EXPIRED) {
/** @var array{dropAfter: int, groups: string[]} $migrationAllowList */
$migrationAllowList = \json_decode($migrationAllowListRaw, true);
}
@@ -238,13 +237,8 @@ protected function hasSamlBackend(IGroup $group): bool {
}
protected function evaluateGroupMigrations(array $groups): void {
- $candidateInfo = $this->config->getAppValue('user_saml', self::LOCAL_GROUPS_CHECK_FOR_MIGRATION, '');
- if ($candidateInfo === '') {
- return;
- }
- $candidateInfo = \json_decode($candidateInfo, true);
- if (!isset($candidateInfo['dropAfter']) || $candidateInfo['dropAfter'] < time()) {
- $this->config->deleteAppValue('user_saml', self::LOCAL_GROUPS_CHECK_FOR_MIGRATION);
+ $candidateInfo = $this->getCandidateInfoIfValid();
+ if ($candidateInfo === null) {
return;
}
@@ -252,17 +246,29 @@ protected function evaluateGroupMigrations(array $groups): void {
}
protected function isGroupInTransitionList(string $groupId): bool {
- $candidateInfo = $this->config->getAppValue('user_saml', self::LOCAL_GROUPS_CHECK_FOR_MIGRATION, '');
- if ($candidateInfo === '') {
+ $candidateInfo = $this->getCandidateInfoIfValid();
+ if (!$candidateInfo) {
return false;
}
+ return in_array($groupId, $candidateInfo['groups'], true);
+ }
+
+ /**
+ * @return array{dropAfter: int, groups: string[]}|null
+ */
+ public function getCandidateInfoIfValid(): ?array {
+ $candidateInfo = $this->config->getAppValue('user_saml', self::LOCAL_GROUPS_CHECK_FOR_MIGRATION, '');
+ if ($candidateInfo === '' || $candidateInfo === self::STATE_MIGRATION_PHASE_EXPIRED) {
+ return null;
+ }
+ /** @var array{dropAfter: int, groups: string[]} $candidateInfo */
$candidateInfo = \json_decode($candidateInfo, true);
if (!isset($candidateInfo['dropAfter']) || $candidateInfo['dropAfter'] < time()) {
- $this->config->deleteAppValue('user_saml', self::LOCAL_GROUPS_CHECK_FOR_MIGRATION);
- return false;
+ $this->config->setAppValue('user_saml', self::LOCAL_GROUPS_CHECK_FOR_MIGRATION, self::STATE_MIGRATION_PHASE_EXPIRED);
+ return null;
}
- return in_array($groupId, $candidateInfo['groups']);
+ return $candidateInfo;
}
protected function hasGroupForeignMembers(IGroup $group): bool {
diff --git a/lib/Jobs/MigrateGroups.php b/lib/Jobs/MigrateGroups.php
index d5e02338..8a41a65f 100644
--- a/lib/Jobs/MigrateGroups.php
+++ b/lib/Jobs/MigrateGroups.php
@@ -10,12 +10,16 @@
use OCA\User_SAML\GroupBackend;
use OCA\User_SAML\GroupManager;
+use OCA\User_SAML\Service\GroupMigration;
+use OCP\AppFramework\Db\TTransactional;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\QueuedJob;
+use OCP\DB\Exception;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IGroupManager;
use Psr\Log\LoggerInterface;
+use Throwable;
/**
* Class MigrateGroups
@@ -24,6 +28,9 @@
* @todo: remove this, when dropping Nextcloud 29 support
*/
class MigrateGroups extends QueuedJob {
+ use TTransactional;
+
+ protected const BATCH_SIZE = 1000;
/** @var IConfig */
private $config;
@@ -37,12 +44,14 @@ class MigrateGroups extends QueuedJob {
private $logger;
public function __construct(
+ protected GroupMigration $groupMigration,
+ protected GroupManager $ownGroupManager,
IConfig $config,
IGroupManager $groupManager,
IDBConnection $dbc,
GroupBackend $ownGroupBackend,
LoggerInterface $logger,
- ITimeFactory $timeFactory
+ ITimeFactory $timeFactory,
) {
parent::__construct($timeFactory);
$this->config = $config;
@@ -63,16 +72,16 @@ protected function run($argument) {
}
}
- protected function updateCandidatePool($migrateGroups) {
+ protected function updateCandidatePool(array $migratedGroups): void {
$candidateInfo = $this->config->getAppValue('user_saml', GroupManager::LOCAL_GROUPS_CHECK_FOR_MIGRATION, '');
- if ($candidateInfo === null || $candidateInfo === '') {
+ if ($candidateInfo === '' || $candidateInfo === GroupManager::STATE_MIGRATION_PHASE_EXPIRED) {
return;
}
$candidateInfo = \json_decode($candidateInfo, true);
if (!isset($candidateInfo['dropAfter']) || !isset($candidateInfo['groups'])) {
return;
}
- $candidateInfo['groups'] = array_diff($candidateInfo['groups'], $migrateGroups);
+ $candidateInfo['groups'] = array_diff($candidateInfo['groups'], $migratedGroups);
$this->config->setAppValue(
'user_saml',
GroupManager::LOCAL_GROUPS_CHECK_FOR_MIGRATION,
@@ -87,7 +96,11 @@ protected function migrateGroups(array $toMigrate): array {
}
protected function migrateGroup(string $gid): bool {
+ $isMigrated = false;
+ $allUsersInserted = false;
try {
+ $allUsersInserted = $this->groupMigration->migrateGroupUsers($gid);
+
$this->dbc->beginTransaction();
$qb = $this->dbc->getQueryBuilder();
@@ -97,20 +110,30 @@ protected function migrateGroup(string $gid): bool {
if ($affected === 0) {
throw new \RuntimeException('Could not delete group from local backend');
}
-
if (!$this->ownGroupBackend->createGroup($gid)) {
throw new \RuntimeException('Could not create group in SAML backend');
}
$this->dbc->commit();
-
- return true;
- } catch (\Exception $e) {
+ $isMigrated = true;
+ } catch (Throwable $e) {
$this->dbc->rollBack();
$this->logger->warning($e->getMessage(), ['app' => 'user_saml', 'exception' => $e]);
}
- return false;
+ if ($allUsersInserted && $isMigrated) {
+ try {
+ $this->groupMigration->cleanUpOldGroupUsers($gid);
+ } catch (Exception $e) {
+ $this->logger->warning('Error while cleaning up group members in (oc_)group_user of group (gid) {gid}', [
+ 'app' => 'user_saml',
+ 'gid' => $gid,
+ 'exception' => $e,
+ ]);
+ }
+ }
+
+ return $isMigrated;
}
protected function getGroupsToMigrate(array $samlGroups, array $pool): array {
@@ -140,14 +163,9 @@ protected function getGroupsToMigrate(array $samlGroups, array $pool): array {
}
protected function getMigratableGroups(): array {
- $candidateInfo = $this->config->getAppValue('user_saml', GroupManager::LOCAL_GROUPS_CHECK_FOR_MIGRATION, '');
- if ($candidateInfo === null || $candidateInfo === '') {
- throw new \RuntimeException('No migration of groups to SAML backend anymore');
- }
- $candidateInfo = \json_decode($candidateInfo, true);
- if (!isset($candidateInfo['dropAfter']) || !isset($candidateInfo['groups']) || $candidateInfo['dropAfter'] < time()) {
- $this->config->deleteAppValue('user_saml', GroupManager::LOCAL_GROUPS_CHECK_FOR_MIGRATION);
- throw new \RuntimeException('Period for migration groups is over');
+ $candidateInfo = $this->ownGroupManager->getCandidateInfoIfValid();
+ if ($candidateInfo === null) {
+ throw new \RuntimeException('No migration tasks of groups to SAML backend');
}
return $candidateInfo['groups'];
diff --git a/lib/Migration/RememberLocalGroupsForPotentialMigrations.php b/lib/Migration/RememberLocalGroupsForPotentialMigrations.php
index cafbfd46..09c73562 100644
--- a/lib/Migration/RememberLocalGroupsForPotentialMigrations.php
+++ b/lib/Migration/RememberLocalGroupsForPotentialMigrations.php
@@ -44,6 +44,11 @@ public function getName(): string {
* @since 9.1.0
*/
public function run(IOutput $output) {
+ $candidateInfo = $this->config->getAppValue('user_saml', GroupManager::LOCAL_GROUPS_CHECK_FOR_MIGRATION, '');
+ if ($candidateInfo !== '') {
+ return;
+ }
+
try {
$backend = $this->findBackend();
$groupIds = $this->findGroupIds($backend);
diff --git a/lib/Migration/TransferGroupMembers.php b/lib/Migration/TransferGroupMembers.php
new file mode 100644
index 00000000..68e13424
--- /dev/null
+++ b/lib/Migration/TransferGroupMembers.php
@@ -0,0 +1,52 @@
+groupMigration->findGroupsWithLocalMembers();
+ if (empty($groupsToTreat)) {
+ return;
+ }
+ $hasError = false;
+ $output->startProgress(count($groupsToTreat));
+ foreach ($groupsToTreat as $gid) {
+ try {
+ if ($this->groupMigration->migrateGroupUsers($gid)) {
+ $this->groupMigration->cleanUpOldGroupUsers($gid);
+ }
+ } catch (Throwable $e) {
+ $hasError = true;
+ $this->logger->warning('Error while transferring group members of {gid}', ['gid' => $gid, 'exception' => $e]);
+ } finally {
+ $output->advance();
+ }
+ }
+ $output->finishProgress();
+ if ($hasError) {
+ $output->warning('There were errors while transferring group members to SAML groups. You may try later `occ saml:group-migration:copy-incomplete-members` later and check your nextcloud.log.');
+ }
+ }
+}
diff --git a/lib/Service/GroupMigration.php b/lib/Service/GroupMigration.php
new file mode 100644
index 00000000..ce3b7a26
--- /dev/null
+++ b/lib/Service/GroupMigration.php
@@ -0,0 +1,101 @@
+dbc->getQueryBuilder();
+ $qb->selectDistinct('gid')
+ ->from('group_user')
+ ->where($qb->expr()->in('gid', $qb->createParameter('gidList')));
+
+ $allOwnedGroups = $this->ownGroupBackend->getGroups();
+ foreach (array_chunk($allOwnedGroups, self::CHUNK_SIZE) as $groupsChunk) {
+ $qb->setParameter('gidList', $groupsChunk, IQueryBuilder::PARAM_STR_ARRAY);
+ $result = $qb->executeQuery();
+ while ($gid = $result->fetchOne()) {
+ $foundGroups[] = $gid;
+ }
+ $result->closeCursor();
+ }
+
+ return $foundGroups;
+ }
+
+ /**
+ * @returns bool true when all users were migrated, when they were only partly migrated
+ * @throws Exception
+ * @throws Throwable
+ */
+ public function migrateGroupUsers(string $gid): bool {
+ $originalGroup = $this->groupManager->get($gid);
+ $members = $originalGroup?->getUsers();
+
+ $areAllInserted = true;
+ foreach (array_chunk($members ?? [], (int)floor(self::CHUNK_SIZE / 2)) as $userBatch) {
+ $areAllInserted = ($this->atomic(function () use ($userBatch, $gid) {
+ /** @var IUser $user */
+ foreach ($userBatch as $user) {
+ $this->dbc->insertIgnoreConflict(
+ GroupBackend::TABLE_MEMBERS,
+ [
+ 'gid' => $gid,
+ 'uid' => $user->getUID(),
+ ]
+ );
+ }
+ return true;
+ }, $this->dbc) === true) && $areAllInserted;
+ }
+ if (!$areAllInserted) {
+ $this->logger->warning('Partial migration of users from local group {gid} to SAML.', [
+ 'app' => 'user_saml',
+ 'gid' => $gid,
+ ]);
+ }
+ return $areAllInserted;
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function cleanUpOldGroupUsers(string $gid): void {
+ $cleanup = $this->dbc->getQueryBuilder();
+ $cleanup->delete('group_user')
+ ->where($cleanup->expr()->eq('gid', $cleanup->createNamedParameter($gid)));
+ $cleanup->executeStatement();
+ }
+
+}
diff --git a/tests/integration/features/Shibboleth.feature b/tests/integration/features/Shibboleth.feature
index 240e8282..5503a5b3 100644
--- a/tests/integration/features/Shibboleth.feature
+++ b/tests/integration/features/Shibboleth.feature
@@ -165,6 +165,139 @@ Feature: Shibboleth
And Then the group backend of "Astrophysics" must not be "Database"
And The user value "groups" should be "Astrophysics,SAML_Students"
+ Scenario: Migrating a local group to SAML backend and taking the assigned users as well
+ Given The setting "type" is set to "saml"
+ And The setting "general-uid_mapping" is set to "urn:oid:0.9.2342.19200300.100.1.1"
+ And The setting "idp-entityId" is set to "https://shibboleth-integration-nextcloud.localdomain/idp/shibboleth"
+ And The setting "idp-singleSignOnService.url" is set to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO"
+ And The setting "idp-singleLogoutService.url" is set to "https://localhost:4443/idp/profile/Logout"
+ And The setting "idp-x509cert" is set to "MIIDnTCCAoWgAwIBAgIUGPx9uPjCu7c172IUgV84Tm94pBcwDQYJKoZIhvcNAQEL BQAwNzE1MDMGA1UEAwwsc2hpYmJvbGV0aC1pbnRlZ3JhdGlvbi1uZXh0Y2xvdWQu bG9jYWxkb21haW4wHhcNMTcwMTA0MTAxMTI3WhcNMzcwMTA0MTAxMTI3WjA3MTUw MwYDVQQDDCxzaGliYm9sZXRoLWludGVncmF0aW9uLW5leHRjbG91ZC5sb2NhbGRv bWFpbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKH+bPu45tk8/JRk XOxkyfbxocWZlY4mRumEUxscd3fn0oVzOrdWbHH7lCZV4bus4KxvJljc0Nm2K+Zr LoiRUUnf/LQ4LlehWVm5Kbc4kRgOXS0iGZN3SslAWPKyIg0tywg+TLOBPoS6EtST 1WuYg1JPMFxPfeFDWQ0dQYPlXIJWBFh6F2JMTb0FLECqA5l/ryYE13QisX5l+Mqo 6y3Dh7qIgaH0IJNobXoAcEWza7Kb2RnfhZRh9e0qjZIwBqTJUFM/6I86RYXn829s INUvYQQbez6VkGTdUQJ/GuXb/dD5sMQfOyK8hrRY5MozOmK32cz3JaAzSXpiSRS9 NxFwvicCAwEAAaOBoDCBnTAdBgNVHQ4EFgQUKn8+TV0WXSDeavvF0M8mWn1o8ukw fAYDVR0RBHUwc4Isc2hpYmJvbGV0aC1pbnRlZ3JhdGlvbi1uZXh0Y2xvdWQubG9j YWxkb21haW6GQ2h0dHBzOi8vc2hpYmJvbGV0aC1pbnRlZ3JhdGlvbi1uZXh0Y2xv dWQubG9jYWxkb21haW4vaWRwL3NoaWJib2xldGgwDQYJKoZIhvcNAQELBQADggEB ABI6uzoIeLZT9Az2KTlLxIc6jZ4MDmhaVja4ZuBxTXEb7BFLfeASEJmQm1wgIMOn pJId3Kh3njW+3tOBWKm7lj8JxVVpAu4yMFSoQGPaVUgYB1AVm+pmAyPLzfJ/XGhf esCU2F/b0eHWcaIb3x+BZFX089Cd/PBtP84MNXdo+TccibxC8N39sr45qJM/7SC7 TfDYU0L4q2WZHJr4S7+0F+F4KaxLx9NzCvN4h6XaoWofZWir2iHO4NzbrVQGC0ei QybS/neBfni4A2g1lyzCb6xFB58JBvNCn7AAnDJULOE7S5XWUKsDAQVQrxTNkUq7 pnhlCQqZDwUdgmIXd1KB1So="
+ And The setting "security-authnRequestsSigned" is set to "1"
+ And The setting "security-wantAssertionsEncrypted" is set to "1"
+ And The setting "sp-x509cert" is set to "-----BEGIN CERTIFICATE-----MIIC+zCCAeOgAwIBAgIJAIgZuvWDBIrdMA0GCSqGSIb3DQEBBQUAMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0xNzAxMDQxMTM5MjFaFw0yNzAxMDIxMTM5MjFaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN3ESWaDH1JiJTy9yRJQV7kahPOxgBkIH2xwcYDL1k9deKNhSKLx7aGfxE244+HBcC6WLHKVUnOm0ld2qxQ4bMYiJXzZuqL67r07L5wxGAssv12lO92qohGmlHy3+VzRYUBmovu6upqOv3R2F8HBbo7Jc7Hvt7hOEJn/jPuFuF/fHit3mqU8l6IkrIZjpaW8T9fIWOXRq98U4+hkgWpqEZWsqlfE8BxAs9DeIMZab0GxO9stHLp+GYKx10uE4ezFcaDS8W+g2C8enCTt1HXGvcnj4o5zkC1lITGvcFTsiFqfIWyXeSufcxdc0W7HoG6J3ks0WJyK38sfFn0t2Ao6kX0CAwEAAaNQME4wHQYDVR0OBBYEFAoJzX6TVYAwC1GSPe6nObBG54zaMB8GA1UdIwQYMBaAFAoJzX6TVYAwC1GSPe6nObBG54zaMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAJia9R70uXdUZtgujUPjLas4+sVajzlBFmqhBqpLAo934vljf9HISsHrPtdBcbS0d0rucqXwabDf0MlR18ksnT/NYpsTwMbMx76CrXi4zYEEW5lISKEO65aIkzVTcqKWSuhjtSnRdB6iOLsFiKmNMWXaIKMR5T0+AbR9wdQgn08W+3EEeHGvafVQfE3STVsSgNb1ft7DvcSUnfPXGU7KzvmTpZa0Hfmc7uY4vpdEEhLAdRhgLReS7USZskov7ooiPSoD+JRFi2gM4klBxTemHdNUa9oFnHMXuYKOkLbkgFvHxyy+QlLq2ELQTga5e7I83ZyOfGctyf8Ul6vGw10vbQ=-----END CERTIFICATE-----"
+ And The setting "sp-privateKey" is set to "-----BEGIN RSA PRIVATE KEY-----MIIEpAIBAAKCAQEA3cRJZoMfUmIlPL3JElBXuRqE87GAGQgfbHBxgMvWT114o2FIovHtoZ/ETbjj4cFwLpYscpVSc6bSV3arFDhsxiIlfNm6ovruvTsvnDEYCyy/XaU73aqiEaaUfLf5XNFhQGai+7q6mo6/dHYXwcFujslzse+3uE4Qmf+M+4W4X98eK3eapTyXoiSshmOlpbxP18hY5dGr3xTj6GSBamoRlayqV8TwHECz0N4gxlpvQbE72y0cun4ZgrHXS4Th7MVxoNLxb6DYLx6cJO3Udca9yePijnOQLWUhMa9wVOyIWp8hbJd5K59zF1zRbsegboneSzRYnIrfyx8WfS3YCjqRfQIDAQABAoIBAQC5CQAdcqZ9vLpJNilBCJxJLCFmm+HAAREHD8MErg9A5UK1P4S1wJp/0qieGPi68wXBOTgY2xKSwMycgb04/+NyZidVRu388takOW/+KNBg8pMxdZ6/05GqnI0kivSbR3CXpYuz8hekwhpo9+fWmKjApsHL47ItK6WaeKmPbAFsq1YJGzfp/DXg7LIvh9GA3C1LWWGV7SuCGOyX/2Moi8xRa7qBtH4hDo/0NRhTx7zjYjlBgNEr330pJUopc3+AtHE40R+xMr2zkGvq9RsCZxYxD2VWbLwQW0yNjWmQ2OTuMgJJvk2+N73QLHcB+tea82ZTszsNzRS9DLtc6qbsKEPZAoGBAO78U3vEuRyY56f/1hpo0xuCDwOkWGzgBQWkjJl6dlyVz/zKkhXBHpEYImyt8XRN0W3iGZYpZ2hCFJGTcDp32R6UiEyGLz0Uc8R/tva/TiRVW1FdNczzSHcB24b9OMK4vE9JLs8mA8Rp8YBgtLr5DDuMfYt/a/rZJbg/HIfIN98nAoGBAO2OInCX93t2I6zzRPIqKtI6q6FYNp64VIQjvw9Y8l0x3IdJZRP9H5C8ZhCeYPsgEqTXcXa4j5hL4rQzoUtxfxflBUUH60bcnd4LGaTCMYLS14G011E3GZlIP0sJi5OjEhy8fq3zt6jVzS9V/lPHB8i+w1D7CbPrMpW7B3k32vC7AoGAX/HvdkYhZyjAAEOG6m1hK68IZhbp5TP+8CgCxm9S65K9wKh3A8LXibrdvzIKOP4w8WOPkCipOkMlTNibeu24vj01hztr5aK7Y40+oEtnjNCz67N3MQQO+LBHOSeaTRqrh01DPKjvZECAU2D/zfzEe3fIw2Nxr3DUYub7hkvMmosCgYAzxbVVypjiLGYsDDyrdmsstCKxoDMPNmcdAVljc+QmUXaZeXJw/8qAVb78wjeqo1vM1zNgR2rsKyW2VkZB1fN39q7GU6qAIBa7zLmDAduegmr7VrlSduq6UFeS9/qWa4TIBICrUqFlR2tXdKtgANF+e6y/mmaL8qdsoH1JetXZfwKBgQC1vscRpdAXivjOOZAh+mzJWzS4BUl4CTJLYYIuOEXikmN5g0EdV2fhUEdkewmyKnXHsd0x83167bYgpTDNs71jUxDHy5NXlg2qIjLkf09X9wr19gBzDApfWzfh3vUqttyMZuQMLVNepGCWM2vjlY9KGl5OvZqY6d+7yO0mLV9GmQ==-----END RSA PRIVATE KEY-----"
+ And The setting "security-wantAssertionsSigned" is set to "1"
+ And The setting "localGroupsCheckForMigration" is set to '{\"dropAfter\":9223372036854775807,\"groups\":[\"Astrophysics\",\"Students\"]}'
+ And the local group "Astrophysics" is created
+ # Initial login so the user is known and belonging to SAML, directly followed by a logout
+ And I send a GET request to "http://localhost:8080/index.php/login"
+ And I should be redirected to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO"
+ And I send a POST request to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO?execution=e1s1" with the following data
+ |j_username|j_password|_eventId_proceed|
+ |student1 |password | |
+ And The response should be a SAML redirect page that gets submitted
+ And I send a GET request with requesttoken to "http://localhost:8080/index.php/apps/user_saml/saml/sls"
+ And the cookies are cleared
+ # Have a second SAML users being known
+ And I send a GET request to "http://localhost:8080/index.php/login"
+ And I should be redirected to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO"
+ And I send a POST request to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO?execution=e1s1" with the following data
+ |j_username|j_password|_eventId_proceed|
+ |staff1 |password | |
+ And The response should be a SAML redirect page that gets submitted
+ And I send a GET request with requesttoken to "http://localhost:8080/index.php/apps/user_saml/saml/sls"
+ And the cookies are cleared
+ # The users are already known as members
+ And the user "student1" is added to the group "Astrophysics"
+ And the user "staff1" is added to the group "Astrophysics"
+ # Set the proper attributes
+ And The setting "saml-attribute-mapping-email_mapping" is set to "urn:oid:0.9.2342.19200300.100.1.3"
+ And The setting "saml-attribute-mapping-displayName_mapping" is set to "urn:oid:2.5.4.42 urn:oid:2.5.4.4"
+ And The setting "saml-attribute-mapping-group_mapping" is set to "groups"
+ # Now login in for good
+ When I send a GET request to "http://localhost:8080/index.php/login"
+ Then I should be redirected to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO"
+ And I send a POST request to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO?execution=e1s1" with the following data
+ |j_username|j_password|_eventId_proceed|
+ |student1 |password | |
+ And The response should be a SAML redirect page that gets submitted
+ And The user value "groups" should be "Astrophysics,SAML_Students"
+ And the group backend of "Astrophysics" should be "Database"
+ And Then the group backend of "Astrophysics" must not be "user_saml"
+ When I execute the background job for class "OCA\\User_SAML\\Jobs\\MigrateGroups"
+ Then the group backend of "Astrophysics" should be "user_saml"
+ And Then the group backend of "Astrophysics" must not be "Database"
+ And The user value "groups" should be "Astrophysics,SAML_Students"
+ And The group "Astrophysics" has exactly the members "student1, staff1"
+
+ Scenario: Migrating a local group to SAML backend and taking the assigned users via occ command
+ Given The setting "type" is set to "saml"
+ And The setting "general-uid_mapping" is set to "urn:oid:0.9.2342.19200300.100.1.1"
+ And The setting "idp-entityId" is set to "https://shibboleth-integration-nextcloud.localdomain/idp/shibboleth"
+ And The setting "idp-singleSignOnService.url" is set to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO"
+ And The setting "idp-singleLogoutService.url" is set to "https://localhost:4443/idp/profile/Logout"
+ And The setting "idp-x509cert" is set to "MIIDnTCCAoWgAwIBAgIUGPx9uPjCu7c172IUgV84Tm94pBcwDQYJKoZIhvcNAQEL BQAwNzE1MDMGA1UEAwwsc2hpYmJvbGV0aC1pbnRlZ3JhdGlvbi1uZXh0Y2xvdWQu bG9jYWxkb21haW4wHhcNMTcwMTA0MTAxMTI3WhcNMzcwMTA0MTAxMTI3WjA3MTUw MwYDVQQDDCxzaGliYm9sZXRoLWludGVncmF0aW9uLW5leHRjbG91ZC5sb2NhbGRv bWFpbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKH+bPu45tk8/JRk XOxkyfbxocWZlY4mRumEUxscd3fn0oVzOrdWbHH7lCZV4bus4KxvJljc0Nm2K+Zr LoiRUUnf/LQ4LlehWVm5Kbc4kRgOXS0iGZN3SslAWPKyIg0tywg+TLOBPoS6EtST 1WuYg1JPMFxPfeFDWQ0dQYPlXIJWBFh6F2JMTb0FLECqA5l/ryYE13QisX5l+Mqo 6y3Dh7qIgaH0IJNobXoAcEWza7Kb2RnfhZRh9e0qjZIwBqTJUFM/6I86RYXn829s INUvYQQbez6VkGTdUQJ/GuXb/dD5sMQfOyK8hrRY5MozOmK32cz3JaAzSXpiSRS9 NxFwvicCAwEAAaOBoDCBnTAdBgNVHQ4EFgQUKn8+TV0WXSDeavvF0M8mWn1o8ukw fAYDVR0RBHUwc4Isc2hpYmJvbGV0aC1pbnRlZ3JhdGlvbi1uZXh0Y2xvdWQubG9j YWxkb21haW6GQ2h0dHBzOi8vc2hpYmJvbGV0aC1pbnRlZ3JhdGlvbi1uZXh0Y2xv dWQubG9jYWxkb21haW4vaWRwL3NoaWJib2xldGgwDQYJKoZIhvcNAQELBQADggEB ABI6uzoIeLZT9Az2KTlLxIc6jZ4MDmhaVja4ZuBxTXEb7BFLfeASEJmQm1wgIMOn pJId3Kh3njW+3tOBWKm7lj8JxVVpAu4yMFSoQGPaVUgYB1AVm+pmAyPLzfJ/XGhf esCU2F/b0eHWcaIb3x+BZFX089Cd/PBtP84MNXdo+TccibxC8N39sr45qJM/7SC7 TfDYU0L4q2WZHJr4S7+0F+F4KaxLx9NzCvN4h6XaoWofZWir2iHO4NzbrVQGC0ei QybS/neBfni4A2g1lyzCb6xFB58JBvNCn7AAnDJULOE7S5XWUKsDAQVQrxTNkUq7 pnhlCQqZDwUdgmIXd1KB1So="
+ And The setting "security-authnRequestsSigned" is set to "1"
+ And The setting "security-wantAssertionsEncrypted" is set to "1"
+ And The setting "sp-x509cert" is set to "-----BEGIN CERTIFICATE-----MIIC+zCCAeOgAwIBAgIJAIgZuvWDBIrdMA0GCSqGSIb3DQEBBQUAMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0xNzAxMDQxMTM5MjFaFw0yNzAxMDIxMTM5MjFaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN3ESWaDH1JiJTy9yRJQV7kahPOxgBkIH2xwcYDL1k9deKNhSKLx7aGfxE244+HBcC6WLHKVUnOm0ld2qxQ4bMYiJXzZuqL67r07L5wxGAssv12lO92qohGmlHy3+VzRYUBmovu6upqOv3R2F8HBbo7Jc7Hvt7hOEJn/jPuFuF/fHit3mqU8l6IkrIZjpaW8T9fIWOXRq98U4+hkgWpqEZWsqlfE8BxAs9DeIMZab0GxO9stHLp+GYKx10uE4ezFcaDS8W+g2C8enCTt1HXGvcnj4o5zkC1lITGvcFTsiFqfIWyXeSufcxdc0W7HoG6J3ks0WJyK38sfFn0t2Ao6kX0CAwEAAaNQME4wHQYDVR0OBBYEFAoJzX6TVYAwC1GSPe6nObBG54zaMB8GA1UdIwQYMBaAFAoJzX6TVYAwC1GSPe6nObBG54zaMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAJia9R70uXdUZtgujUPjLas4+sVajzlBFmqhBqpLAo934vljf9HISsHrPtdBcbS0d0rucqXwabDf0MlR18ksnT/NYpsTwMbMx76CrXi4zYEEW5lISKEO65aIkzVTcqKWSuhjtSnRdB6iOLsFiKmNMWXaIKMR5T0+AbR9wdQgn08W+3EEeHGvafVQfE3STVsSgNb1ft7DvcSUnfPXGU7KzvmTpZa0Hfmc7uY4vpdEEhLAdRhgLReS7USZskov7ooiPSoD+JRFi2gM4klBxTemHdNUa9oFnHMXuYKOkLbkgFvHxyy+QlLq2ELQTga5e7I83ZyOfGctyf8Ul6vGw10vbQ=-----END CERTIFICATE-----"
+ And The setting "sp-privateKey" is set to "-----BEGIN RSA PRIVATE KEY-----MIIEpAIBAAKCAQEA3cRJZoMfUmIlPL3JElBXuRqE87GAGQgfbHBxgMvWT114o2FIovHtoZ/ETbjj4cFwLpYscpVSc6bSV3arFDhsxiIlfNm6ovruvTsvnDEYCyy/XaU73aqiEaaUfLf5XNFhQGai+7q6mo6/dHYXwcFujslzse+3uE4Qmf+M+4W4X98eK3eapTyXoiSshmOlpbxP18hY5dGr3xTj6GSBamoRlayqV8TwHECz0N4gxlpvQbE72y0cun4ZgrHXS4Th7MVxoNLxb6DYLx6cJO3Udca9yePijnOQLWUhMa9wVOyIWp8hbJd5K59zF1zRbsegboneSzRYnIrfyx8WfS3YCjqRfQIDAQABAoIBAQC5CQAdcqZ9vLpJNilBCJxJLCFmm+HAAREHD8MErg9A5UK1P4S1wJp/0qieGPi68wXBOTgY2xKSwMycgb04/+NyZidVRu388takOW/+KNBg8pMxdZ6/05GqnI0kivSbR3CXpYuz8hekwhpo9+fWmKjApsHL47ItK6WaeKmPbAFsq1YJGzfp/DXg7LIvh9GA3C1LWWGV7SuCGOyX/2Moi8xRa7qBtH4hDo/0NRhTx7zjYjlBgNEr330pJUopc3+AtHE40R+xMr2zkGvq9RsCZxYxD2VWbLwQW0yNjWmQ2OTuMgJJvk2+N73QLHcB+tea82ZTszsNzRS9DLtc6qbsKEPZAoGBAO78U3vEuRyY56f/1hpo0xuCDwOkWGzgBQWkjJl6dlyVz/zKkhXBHpEYImyt8XRN0W3iGZYpZ2hCFJGTcDp32R6UiEyGLz0Uc8R/tva/TiRVW1FdNczzSHcB24b9OMK4vE9JLs8mA8Rp8YBgtLr5DDuMfYt/a/rZJbg/HIfIN98nAoGBAO2OInCX93t2I6zzRPIqKtI6q6FYNp64VIQjvw9Y8l0x3IdJZRP9H5C8ZhCeYPsgEqTXcXa4j5hL4rQzoUtxfxflBUUH60bcnd4LGaTCMYLS14G011E3GZlIP0sJi5OjEhy8fq3zt6jVzS9V/lPHB8i+w1D7CbPrMpW7B3k32vC7AoGAX/HvdkYhZyjAAEOG6m1hK68IZhbp5TP+8CgCxm9S65K9wKh3A8LXibrdvzIKOP4w8WOPkCipOkMlTNibeu24vj01hztr5aK7Y40+oEtnjNCz67N3MQQO+LBHOSeaTRqrh01DPKjvZECAU2D/zfzEe3fIw2Nxr3DUYub7hkvMmosCgYAzxbVVypjiLGYsDDyrdmsstCKxoDMPNmcdAVljc+QmUXaZeXJw/8qAVb78wjeqo1vM1zNgR2rsKyW2VkZB1fN39q7GU6qAIBa7zLmDAduegmr7VrlSduq6UFeS9/qWa4TIBICrUqFlR2tXdKtgANF+e6y/mmaL8qdsoH1JetXZfwKBgQC1vscRpdAXivjOOZAh+mzJWzS4BUl4CTJLYYIuOEXikmN5g0EdV2fhUEdkewmyKnXHsd0x83167bYgpTDNs71jUxDHy5NXlg2qIjLkf09X9wr19gBzDApfWzfh3vUqttyMZuQMLVNepGCWM2vjlY9KGl5OvZqY6d+7yO0mLV9GmQ==-----END RSA PRIVATE KEY-----"
+ And The setting "security-wantAssertionsSigned" is set to "1"
+ And The setting "localGroupsCheckForMigration" is set to '{\"dropAfter\":9223372036854775807,\"groups\":[\"Astrophysics\",\"Students\"]}'
+ And the local group "Astrophysics" is created
+ # Initial login so the user is known and belonging to SAML, directly followed by a logout
+ And I send a GET request to "http://localhost:8080/index.php/login"
+ And I should be redirected to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO"
+ And I send a POST request to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO?execution=e1s1" with the following data
+ |j_username|j_password|_eventId_proceed|
+ |student1 |password | |
+ And The response should be a SAML redirect page that gets submitted
+ And I send a GET request with requesttoken to "http://localhost:8080/index.php/apps/user_saml/saml/sls"
+ And the cookies are cleared
+ # Have a second SAML users being known
+ And I send a GET request to "http://localhost:8080/index.php/login"
+ And I should be redirected to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO"
+ And I send a POST request to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO?execution=e1s1" with the following data
+ |j_username|j_password|_eventId_proceed|
+ |staff1 |password | |
+ And The response should be a SAML redirect page that gets submitted
+ And I send a GET request with requesttoken to "http://localhost:8080/index.php/apps/user_saml/saml/sls"
+ And the cookies are cleared
+ # Have a third SAML users being known
+ And I send a GET request to "http://localhost:8080/index.php/login"
+ And I should be redirected to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO"
+ And I send a POST request to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO?execution=e1s1" with the following data
+ |j_username|j_password|_eventId_proceed|
+ |student2 |password | |
+ And The response should be a SAML redirect page that gets submitted
+ And I send a GET request with requesttoken to "http://localhost:8080/index.php/apps/user_saml/saml/sls"
+ And the cookies are cleared
+ # The users are already known as members - student 2 is not
+ And the user "student1" is added to the group "Astrophysics"
+ And the user "staff1" is added to the group "Astrophysics"
+ # Set the proper attributes
+ And The setting "saml-attribute-mapping-email_mapping" is set to "urn:oid:0.9.2342.19200300.100.1.3"
+ And The setting "saml-attribute-mapping-displayName_mapping" is set to "urn:oid:2.5.4.42 urn:oid:2.5.4.4"
+ And The setting "saml-attribute-mapping-group_mapping" is set to "groups"
+ # Now login in for good
+ When I send a GET request to "http://localhost:8080/index.php/login"
+ Then I should be redirected to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO"
+ And I send a POST request to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO?execution=e1s1" with the following data
+ |j_username|j_password|_eventId_proceed|
+ |student1 |password | |
+ And The response should be a SAML redirect page that gets submitted
+ And The user value "groups" should be "Astrophysics,SAML_Students"
+ And the group backend of "Astrophysics" should be "Database"
+ And Then the group backend of "Astrophysics" must not be "user_saml"
+ And I execute the background job for class "OCA\\User_SAML\\Jobs\\MigrateGroups"
+ And the group backend of "Astrophysics" should be "user_saml"
+ And Then the group backend of "Astrophysics" must not be "Database"
+ # Recreate state from v6.1 where members where not migrated - terribly awful tinkering
+ And I "disable" the app "user_saml"
+ And the local group "Astrophysics" is created
+ And I hack "student2" into existence
+ And the user "student2" is added to the group "Astrophysics"
+ And I "enable" the app "user_saml"
+ # Now test the occ command, which counts as a test against the repair step as well
+ Then I run the copy-incomplete-members command
+ # Remove local group remnants
+ And I "disable" the app "user_saml"
+ And the group "Astrophysics" is deleted
+ And I "enable" the app "user_saml"
+ # Now the proper checks
+ And The user value "groups" should be "Astrophysics,SAML_Students"
+ # student2 will be removed upon proper login, this tests whether the old data is taken over
+ And The group "Astrophysics" has exactly the members "student1, student2, staff1"
+
Scenario: Keeping the local admin group assigned to the SAML user
Given The setting "type" is set to "saml"
And The setting "general-uid_mapping" is set to "urn:oid:0.9.2342.19200300.100.1.1"
diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php
index 4c359169..51f6c872 100644
--- a/tests/integration/features/bootstrap/FeatureContext.php
+++ b/tests/integration/features/bootstrap/FeatureContext.php
@@ -302,6 +302,43 @@ public function theUserValueShouldBe(string $key, string $value): void {
}
}
+ /**
+ * @Then The group :group has exactly the members :memberList
+ */
+ public function theGroupHasExactlyTheMembers(string $group, string $memberList): void {
+ $this->response = $this->client->request(
+ 'GET',
+ sprintf('http://localhost:8080/ocs/v2.php/cloud/groups/%s', $group),
+ [
+ 'headers' => [
+ 'OCS-APIRequest' => 'true',
+ ],
+ 'query' => [
+ 'format' => 'json',
+ ],
+ 'auth' => [
+ 'admin', 'admin'
+ ],
+ 'cookies' => '',
+ ]
+ );
+
+ $responseArray = (json_decode($this->response->getBody(), true))['ocs'];
+ if ($responseArray['meta']['statuscode'] !== 200) {
+ throw new UnexpectedValueException(sprintf('Expected 200 status code but got %d', $responseArray['meta']['statusCode']));
+ }
+
+ $expectedMembers = array_map('trim', explode(',', $memberList));
+ $actualMembers = array_map('trim', $responseArray['data']['users']);
+
+ sort($expectedMembers);
+ sort($actualMembers);
+
+ if ($expectedMembers !== $actualMembers) {
+ throw new UnexpectedValueException(sprintf('Unexpectedly the returned members are: %s', implode(', ', $actualMembers)));
+ }
+ }
+
/**
* @Given A local user with uid :uid exists
* @param string $uid
@@ -317,6 +354,22 @@ public function aLocalUserWithUidExists($uid) {
);
}
+ /**
+ * @Then I hack :uid into existence
+ */
+ public function hackUserIntoExistence(string $uid): void {
+ rename(__DIR__ . '/../../../../../../data/' . $uid, __DIR__ . '/../../../../../../data/hide-' . $uid);
+ shell_exec(
+ sprintf(
+ 'OC_PASS=password %s %s user:add %s --display-name "Default displayname of '.$uid.'" --password-from-env',
+ PHP_BINARY,
+ __DIR__ . '/../../../../../../occ',
+ $uid
+ )
+ );
+ rename(__DIR__ . '/../../../../../../data/hide-' . $uid, __DIR__ . '/../../../../../../data/' . $uid);
+ }
+
/**
* @Then The last login timestamp of :uid should not be empty
*
@@ -455,7 +508,19 @@ public function theLocalGroupIsCreated(string $groupName) {
);
}
-
+ /**
+ * @Given the group :group is deleted
+ */
+ public function theGroupIsDeleted(string $group) {
+ shell_exec(
+ sprintf(
+ '%s %s group:delete "%s"',
+ PHP_BINARY,
+ __DIR__ . '/../../../../../../occ',
+ $group
+ )
+ );
+ }
/**
* @Given /^I send a GET request with requesttoken to "([^"]*)"$/
@@ -503,6 +568,37 @@ public function theUserIsAddedToTheGroup(string $userId, string $groupId) {
);
}
+ /**
+ * @Given I run the copy-incomplete-members command
+ */
+ public function theCopyIncompleteMembersCommandIsRun() {
+ $out = shell_exec(
+ sprintf(
+ '%s %s saml:group-migration:copy-incomplete-members --verbose',
+ PHP_BINARY,
+ __DIR__ . '/../../../../../../occ',
+ )
+ );
+ if ($out === false || $out === null) {
+ throw new RuntimeException('Failed to execute saml:group-migration:copy-incomplete-members command');
+ }
+ }
+
+ /**
+ * @Given I :stateAction the app :appId
+ */
+ public function theAppIsEnabledOrDisabled(string $appId, string $stateAction) {
+ shell_exec(
+ sprintf(
+ '%s %s app:%s "%s"',
+ PHP_BINARY,
+ __DIR__ . '/../../../../../../occ',
+ $stateAction,
+ $appId
+ )
+ );
+ }
+
/**
* @Given /^I expect no background job for class "([^"]*)"$/
*/
diff --git a/tests/stub.phpstub b/tests/stub.phpstub
index 12a82669..352cb702 100644
--- a/tests/stub.phpstub
+++ b/tests/stub.phpstub
@@ -9,6 +9,47 @@ namespace OCA\Files\Event {
}
}
+namespace OC\Core\Command {
+ use Symfony\Component\Console\Command\Command;
+ use Symfony\Component\Console\Input\InputInterface;
+ use Symfony\Component\Console\Output\OutputInterface;
+
+ class Base {
+ public function __construct() {}
+ protected function configure(): void {}
+ public function run(InputInterface $input, OutputInterface $output): int {}
+ public function setName(string $name): self {}
+ public function setDescription(string $description): self {}
+ public function addOption(string $name, $shortcut = null, ?int $mode = null, string $description = '', $default = null): self;
+ public function addArgument(string $name, int $mode = null, string $description = '', $default = null): self;
+ protected function writeArrayInOutputFormat(InputInterface $input, OutputInterface $output, array $items, string $prefix = ' - '): void;
+ }
+}
+
+namespace Symfony\Component\Console\Output {
+ class OutputInterface {
+ public function isVerbose(): bool;
+ public function isQuiet(): bool;
+ public function writeln($messages, int $options = 0): void;
+ }
+}
+
+namespace Symfony\Component\Console\Input {
+ class InputInterface {
+ public function getArgument(string $name): mixed;
+ public function getOptions(): array;
+ public function getOption(string $name): mixed;
+ }
+
+ class InputArgument {
+ public const REQUIRED = 1;
+ }
+
+ class InputOption {
+ public const VALUE_REQUIRED = 2;
+ }
+}
+
namespace OC\Group {
abstract class Database extends \OCP\Group\Backend\ABackend implements
\OCP\Group\Backend\IAddToGroupBackend,
@@ -24,3 +65,14 @@ namespace OC\Group {
\OCP\Group\Backend\INamedBackend {
}
}
+
+namespace OC\DB\Exceptions {
+ class DbalException extends \OCP\DB\Exception {
+ public function isRetryable(): bool {
+ }
+ }
+}
+
+namespace OCP\Log {
+ public function logger(?string $appId = null): \Psr\Log\LoggerInterface;
+}