From 25df2a9883bb734f63c718b4803a206d94112c6c Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Mon, 14 Nov 2022 14:07:20 +0100 Subject: [PATCH 1/9] Use public OCP\BackgroundJob\TimedJob class Signed-off-by: Joas Schilling --- lib/BackgroundJob/ExpireUsers.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/BackgroundJob/ExpireUsers.php b/lib/BackgroundJob/ExpireUsers.php index e3ac39b8..d9509f6d 100644 --- a/lib/BackgroundJob/ExpireUsers.php +++ b/lib/BackgroundJob/ExpireUsers.php @@ -24,9 +24,9 @@ use OC\Authentication\Token\DefaultToken; use OC\Authentication\Token\Manager; -use OC\BackgroundJob\TimedJob; use OCA\Guests\UserBackend; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; use OCP\IConfig; use OCP\IGroupManager; use OCP\ILogger; @@ -50,8 +50,6 @@ class ExpireUsers extends TimedJob { protected $userManager; /** @var IGroupManager */ protected $groupManager; - /** @var ITimeFactory */ - protected $timeFactory; /** @var LoggerInterface */ protected $logger; /** @var IServerContainer */ @@ -67,14 +65,15 @@ public function __construct( IConfig $config, IUserManager $userManager, IGroupManager $groupManager, - ITimeFactory $timeFactory, + ITimeFactory $time, IServerContainer $server, LoggerInterface $logger ) { + parent::__construct($time); + $this->config = $config; $this->userManager = $userManager; $this->groupManager = $groupManager; - $this->timeFactory = $timeFactory; $this->server = $server; $this->logger = $logger; @@ -146,7 +145,7 @@ protected function shouldExpireUser(IUser $user, int $maxLastLogin): bool { $createdAt = $this->getCreatedAt($user); if ($createdAt === 0) { // Set "now" as created at timestamp for the user. - $this->setCreatedAt($user, $this->timeFactory->getTime()); + $this->setCreatedAt($user, $this->time->getTime()); $this->logger->debug('New user, saving discovery time: {user}', [ 'user' => $user->getUID(), ]); From 7aec78efae5280f3ac514110540a31b8ffdf6491 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Mon, 14 Nov 2022 14:11:59 +0100 Subject: [PATCH 2/9] Use an existing IToken implementation after removal of DefaultToken Signed-off-by: Joas Schilling --- lib/BackgroundJob/ExpireUsers.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/BackgroundJob/ExpireUsers.php b/lib/BackgroundJob/ExpireUsers.php index d9509f6d..bd1abc21 100644 --- a/lib/BackgroundJob/ExpireUsers.php +++ b/lib/BackgroundJob/ExpireUsers.php @@ -22,14 +22,13 @@ namespace OCA\UserRetention\BackgroundJob; -use OC\Authentication\Token\DefaultToken; use OC\Authentication\Token\Manager; +use OC\Authentication\Token\PublicKeyToken; use OCA\Guests\UserBackend; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; use OCP\IConfig; use OCP\IGroupManager; -use OCP\ILogger; use OCP\IServerContainer; use OCP\IUser; use OCP\IUserManager; @@ -200,7 +199,7 @@ protected function shouldExpireUser(IUser $user, int $maxLastLogin): bool { protected function allAuthTokensInactive(IUser $user, int $maxLastActivity): bool { /** @var Manager $authTokenManager */ $authTokenManager = $this->server->get(Manager::class); - /** @var DefaultToken[] $tokens */ + /** @var PublicKeyToken[] $tokens */ $tokens = $authTokenManager->getTokenByUser($user->getUID()); foreach ($tokens as $token) { From 1f07f9d25d08473897eadf3204c322ad41bc5912 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Mon, 14 Nov 2022 17:39:21 +0100 Subject: [PATCH 3/9] Add unit tests Signed-off-by: Joas Schilling --- .github/workflows/phpunit-sqlite.yml | 136 ++ .gitignore | 2 + composer.json | 30 +- composer.lock | 1752 ++++++++++++++++++++++++++ tests/bootstrap.php | 39 + tests/phpunit.xml | 15 + 6 files changed, 1962 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/phpunit-sqlite.yml create mode 100644 composer.lock create mode 100644 tests/bootstrap.php create mode 100644 tests/phpunit.xml diff --git a/.github/workflows/phpunit-sqlite.yml b/.github/workflows/phpunit-sqlite.yml new file mode 100644 index 00000000..57e5304f --- /dev/null +++ b/.github/workflows/phpunit-sqlite.yml @@ -0,0 +1,136 @@ +# This workflow is provided via the organization template repository +# +# https://github.com/nextcloud/.github +# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization + +name: PHPUnit + +on: + pull_request: + paths: + - '.github/workflows/**' + - 'appinfo/**' + - 'lib/**' + - 'templates/**' + - 'tests/**' + - 'vendor/**' + - 'vendor-bin/**' + - '.php-cs-fixer.dist.php' + - 'composer.json' + - 'composer.lock' + + push: + branches: + - main + - master + - stable* + +permissions: + contents: read + +concurrency: + group: phpunit-sqlite-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + # Location of the phpunit.xml and phpunit.integration.xml files + PHPUNIT_CONFIG: ./tests/phpunit.xml + PHPUNIT_INTEGRATION_CONFIG: ./tests/phpunit.integration.xml + +jobs: + phpunit-sqlite: + runs-on: ubuntu-latest + + strategy: + matrix: + php-versions: ['7.4', '8.0', '8.1'] + server-versions: ['master'] + + steps: + - name: Set app env + run: | + # Split and keep last + echo "APP_NAME=${GITHUB_REPOSITORY##*/}" >> $GITHUB_ENV + + - name: Checkout server + uses: actions/checkout@v3 + with: + submodules: true + repository: nextcloud/server + ref: ${{ matrix.server-versions }} + + - name: Checkout app + uses: actions/checkout@v3 + with: + path: apps/${{ env.APP_NAME }} + + - name: Set up php ${{ matrix.php-versions }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + tools: phpunit + extensions: mbstring, iconv, fileinfo, intl, sqlite, pdo_sqlite + coverage: none + + - name: Check composer file existence + id: check_composer + uses: andstor/file-existence-action@v2 + with: + files: apps/${{ env.APP_NAME }}/composer.json + + - name: Set up PHPUnit + # Only run if phpunit config file exists + if: steps.check_composer.outputs.files_exists == 'true' + working-directory: apps/${{ env.APP_NAME }} + run: composer i + + - name: Set up Nextcloud + env: + DB_PORT: 4444 + run: | + mkdir data + ./occ maintenance:install --verbose --database=sqlite --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass password + ./occ app:enable --force ${{ env.APP_NAME }} + + - name: Check PHPUnit config file existence + id: check_phpunit + uses: andstor/file-existence-action@v2 + with: + files: apps/${{ env.APP_NAME }}/${{ env.PHPUNIT_CONFIG }} + + - name: PHPUnit + # Only run if phpunit config file exists + if: steps.check_phpunit.outputs.files_exists == 'true' + working-directory: apps/${{ env.APP_NAME }} + run: ./vendor/phpunit/phpunit/phpunit -c ${{ env.PHPUNIT_CONFIG }} + + - name: Check PHPUnit integration config file existence + id: check_integration + uses: andstor/file-existence-action@v2 + with: + files: apps/${{ env.APP_NAME }}/${{ env.PHPUNIT_INTEGRATION_CONFIG }} + + - name: Run Nextcloud + # Only run if phpunit integration config file exists + if: steps.check_integration.outputs.files_exists == 'true' + run: php -S localhost:8080 & + + - name: PHPUnit integration + # Only run if phpunit integration config file exists + if: steps.check_integration.outputs.files_exists == 'true' + working-directory: apps/${{ env.APP_NAME }} + run: ./vendor/phpunit/phpunit/phpunit -c ${{ env.PHPUNIT_INTEGRATION_CONFIG }} + + summary: + permissions: + contents: none + runs-on: ubuntu-latest + needs: phpunit-sqlite + + if: always() + + name: phpunit-sqlite-summary + + steps: + - name: Summary status + run: if ${{ needs.phpunit-sqlite.result != 'success' }}; then exit 1; fi diff --git a/.gitignore b/.gitignore index 7d5b7a94..f67afa17 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /build /node_modules +/tests/.phpunit.result.cache +/vendor diff --git a/composer.json b/composer.json index f9608e0f..1a542629 100644 --- a/composer.json +++ b/composer.json @@ -1,14 +1,20 @@ { - "name": "nextcloud/user_retention", - "description": "user_retention", - "license": "AGPL", - "config": { - "optimize-autoloader": true, - "classmap-authoritative": true - }, - "require": { - }, - "scripts": { - "lint": "find . -name \\*.php -not -path './vendor/*' -print0 | xargs -0 -n1 php -l" - } + "name": "nextcloud/user_retention", + "description": "user_retention", + "license": "AGPL", + "config": { + "optimize-autoloader": true, + "classmap-authoritative": true, + "platform": { + "php": "7.4" + }, + "sort-packages": true + }, + "scripts": { + "lint": "find . -name \\*.php -not -path './vendor/*' -print0 | xargs -0 -n1 php -l", + "test": "cd tests/ && phpunit -c phpunit.xml" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + } } diff --git a/composer.lock b/composer.lock new file mode 100644 index 00000000..7b690192 --- /dev/null +++ b/composer.lock @@ -0,0 +1,1752 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "af6e1c5c4009bbf620a68ef6fbf9efe1", + "packages": [], + "packages-dev": [ + { + "name": "doctrine/instantiator", + "version": "1.4.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/10dcfce151b967d20fde1b34ae6640712c3891bc", + "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^0.16 || ^1", + "phpstan/phpstan": "^1.4", + "phpstan/phpstan-phpunit": "^1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "vimeo/psalm": "^4.22" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/1.4.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-03-03T08:28:38+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.11.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/14daed4296fae74d9e3201d2c4925d1acb7aa614", + "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3,<3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.11.0" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2022-03-03T13:19:32+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v4.15.2", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "f59bbe44bf7d96f24f3e2b4ddc21cd52c1d2adbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f59bbe44bf7d96f24f3e2b4ddc21cd52c1d2adbc", + "reference": "f59bbe44bf7d96f24f3e2b4ddc21cd52c1d2adbc", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": ">=7.0" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.2" + }, + "time": "2022-11-12T15:38:23+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "97803eca37d319dfa7826cc2437fc020857acb53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53", + "reference": "97803eca37d319dfa7826cc2437fc020857acb53", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.3" + }, + "time": "2021-07-20T11:28:43+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "9.2.18", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "12fddc491826940cf9b7e88ad9664cf51f0f6d0a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/12fddc491826940cf9b7e88ad9664cf51f0f6d0a", + "reference": "12fddc491826940cf9b7e88ad9664cf51f0f6d0a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.14", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.3", + "phpunit/php-text-template": "^2.0.2", + "sebastian/code-unit-reverse-lookup": "^2.0.2", + "sebastian/complexity": "^2.0", + "sebastian/environment": "^5.1.2", + "sebastian/lines-of-code": "^1.0.3", + "sebastian/version": "^3.0.1", + "theseer/tokenizer": "^1.2.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcov": "*", + "ext-xdebug": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.18" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2022-10-27T13:35:33+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "3.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2021-12-02T12:48:52+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:58:55+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T05:33:50+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:16:10+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "9.5.26", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "851867efcbb6a1b992ec515c71cdcf20d895e9d2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/851867efcbb6a1b992ec515c71cdcf20d895e9d2", + "reference": "851867efcbb6a1b992ec515c71cdcf20d895e9d2", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.3.1", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.10.1", + "phar-io/manifest": "^2.0.3", + "phar-io/version": "^3.0.2", + "php": ">=7.3", + "phpunit/php-code-coverage": "^9.2.13", + "phpunit/php-file-iterator": "^3.0.5", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.3", + "phpunit/php-timer": "^5.0.2", + "sebastian/cli-parser": "^1.0.1", + "sebastian/code-unit": "^1.0.6", + "sebastian/comparator": "^4.0.8", + "sebastian/diff": "^4.0.3", + "sebastian/environment": "^5.1.3", + "sebastian/exporter": "^4.0.5", + "sebastian/global-state": "^5.0.1", + "sebastian/object-enumerator": "^4.0.3", + "sebastian/resource-operations": "^3.0.3", + "sebastian/type": "^3.2", + "sebastian/version": "^3.0.2" + }, + "suggest": { + "ext-soap": "*", + "ext-xdebug": "*" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.26" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2022-10-28T06:00:21+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/442e7c7e687e42adc03470c7b668bc4b2402c0b2", + "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:08:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" + }, + { + "name": "sebastian/comparator", + "version": "4.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "fa0f136dd2334583309d32b62544682ee972b51a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", + "reference": "fa0f136dd2334583309d32b62544682ee972b51a", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2022-09-14T12:41:17+00:00" + }, + { + "name": "sebastian/complexity", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "739b35e53379900cc9ac327b2147867b8b6efd88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88", + "reference": "739b35e53379900cc9ac327b2147867b8b6efd88", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.7", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T15:52:27+00:00" + }, + { + "name": "sebastian/diff", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/3461e3fccc7cfdfc2720be910d3bd73c69be590d", + "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:10:38+00:00" + }, + { + "name": "sebastian/environment", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/1b5dff7bb151a4db11d49d90e5408e4e938270f7", + "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2022-04-03T09:37:03+00:00" + }, + { + "name": "sebastian/exporter", + "version": "4.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d", + "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2022-09-14T06:03:37+00:00" + }, + { + "name": "sebastian/global-state", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/0ca8db5a5fc9c8646244e629625ac486fa286bf2", + "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2022-02-14T08:28:10+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc", + "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.6", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-11-28T06:42:11+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/cd9d8cf3c5804de4341c283ed787f099f5506172", + "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "http://www.github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:17:30+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", + "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "issues": "https://github.com/sebastianbergmann/resource-operations/issues", + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:45:17+00:00" + }, + { + "name": "sebastian/type", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e", + "reference": "fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2022-09-12T14:47:03+00:00" + }, + { + "name": "sebastian/version", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:39:44+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e", + "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.2.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2021-07-28T10:34:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "platform-overrides": { + "php": "7.4" + }, + "plugin-api-version": "2.3.0" +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 00000000..97864554 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,39 @@ + + * + * @author Joas Schilling + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +if (!defined('PHPUNIT_RUN')) { + define('PHPUNIT_RUN', 1); +} + +require_once __DIR__.'/../../../lib/base.php'; + +// Fix for "Autoload path not allowed: .../tests/lib/testcase.php" +\OC::$loader->addValidRoot(OC::$SERVERROOT . '/tests'); + +// Fix for "Autoload path not allowed: .../user_retention/tests/testcase.php" +\OC_App::loadApp('user_retention'); + +if (!class_exists('\PHPUnit\Framework\TestCase')) { + require_once('PHPUnit/Autoload.php'); +} + +OC_Hook::clear(); diff --git a/tests/phpunit.xml b/tests/phpunit.xml new file mode 100644 index 00000000..2892a6dd --- /dev/null +++ b/tests/phpunit.xml @@ -0,0 +1,15 @@ + + + + . + + + From 352ae1f6529c55cfae0a13944d82e658f326dde7 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Mon, 14 Nov 2022 17:39:53 +0100 Subject: [PATCH 4/9] Start with reminder logic Signed-off-by: Joas Schilling --- lib/BackgroundJob/ExpireUsers.php | 111 ++++++++++++------ tests/BackgroundJob/ExpireUsersTest.php | 149 ++++++++++++++++++++++++ 2 files changed, 222 insertions(+), 38 deletions(-) create mode 100644 tests/BackgroundJob/ExpireUsersTest.php diff --git a/lib/BackgroundJob/ExpireUsers.php b/lib/BackgroundJob/ExpireUsers.php index bd1abc21..39fa1273 100644 --- a/lib/BackgroundJob/ExpireUsers.php +++ b/lib/BackgroundJob/ExpireUsers.php @@ -42,23 +42,18 @@ * @package OCA\UserRetention\BackgroundJob */ class ExpireUsers extends TimedJob { + protected IConfig $config; + protected IUserManager $userManager; + protected IGroupManager $groupManager; + protected LoggerInterface $logger; + protected IServerContainer $server; - /** @var IConfig */ - protected $config; - /** @var IUserManager */ - protected $userManager; - /** @var IGroupManager */ - protected $groupManager; - /** @var LoggerInterface */ - protected $logger; - /** @var IServerContainer */ - private $server; - - protected $userMaxLastLogin = 0; - protected $guestMaxLastLogin = 0; - protected $excludedGroups = []; - /** @var bool*/ - protected $keepUsersWithoutLogin = true; + protected int $userMaxLastLogin = 0; + protected int $guestMaxLastLogin = 0; + protected array $excludedGroups = []; + protected array $reminders = []; + protected array $remindersPlain = []; + protected bool $keepUsersWithoutLogin = true; public function __construct( IConfig $config, @@ -82,7 +77,7 @@ public function __construct( protected function run($argument): void { $now = new \DateTimeImmutable(); - $userDays = (int) $this->config->getAppValue('user_retention', 'user_days', 0); + $userDays = (int) $this->config->getAppValue('user_retention', 'user_days', '0'); if ($userDays > 0) { $userMaxLastLogin = $now->sub(new \DateInterval('P' . $userDays . 'D')); $this->userMaxLastLogin = $userMaxLastLogin->getTimestamp(); @@ -91,7 +86,7 @@ protected function run($argument): void { $this->logger->debug('Account retention is disabled'); } - $guestDays = (int) $this->config->getAppValue('user_retention', 'guest_days', 0); + $guestDays = (int) $this->config->getAppValue('user_retention', 'guest_days', '0'); if ($guestDays > 0) { $guestMaxLastLogin = $now->sub(new \DateInterval('P' . $guestDays . 'D')); $this->guestMaxLastLogin = $guestMaxLastLogin->getTimestamp(); @@ -100,11 +95,24 @@ protected function run($argument): void { $this->logger->debug('Guest account retention is disabled'); } + $reminderDaysString = $this->config->getAppValue('user_retention', 'reminder_days', ''); + $reminderDayOptions = explode(',', $reminderDaysString); + foreach ($reminderDayOptions as $option) { + $option = (int) trim($option); + if ($option !== 0) { + $this->remindersPlain[] = $option; + $this->reminders[] = $now->sub(new \DateInterval('P' . $option . 'D'))->getTimestamp(); + } + } + $this->keepUsersWithoutLogin = $this->config->getAppValue('user_retention', 'keep_users_without_login', 'yes') === 'yes'; - $excludedGroups = $this->config->getAppValue('user_retention', 'excluded_groups', '["admin"]'); - $excludedGroups = json_decode($excludedGroups, true); - $this->excludedGroups = \is_array($excludedGroups) ? $excludedGroups : []; + try { + $excludedGroups = $this->config->getAppValue('user_retention', 'excluded_groups', '["admin"]'); + $excludedGroups = json_decode($excludedGroups, true, 512, JSON_THROW_ON_ERROR); + $this->excludedGroups = \is_array($excludedGroups) ? $excludedGroups : []; + } catch (\JsonException $e) { + } $handler = function(IUser $user) { $maxLastLogin = $this->userMaxLastLogin; @@ -112,7 +120,7 @@ protected function run($argument): void { $maxLastLogin = $this->guestMaxLastLogin; } - if ($this->shouldExpireUser($user, $maxLastLogin)) { + if ($this->shouldPerformActionOnUser($user, $maxLastLogin)) { $this->logger->debug('Attempting to delete account: {user}', [ 'user' => $user->getUID(), ]); @@ -126,6 +134,21 @@ protected function run($argument): void { } else { $this->logger->warning('Expired account ' . $user->getUID() . ' was not deleted'); } + return; + } + + foreach ($this->reminders as $key => $reminder) { + $this->logger->debug('Checking reminder with {reminder} day: {user}', [ + 'reminder' => $this->remindersPlain[$key] ?? 0, + 'user' => $user->getUID(), + ]); + if ($this->shouldPerformActionOnUser($user, $reminder, false)) { + $this->logger->debug('Send reminder to account: {user}', [ + 'user' => $user->getUID(), + ]); + + // FIXME send notification + } } }; @@ -136,7 +159,8 @@ protected function run($argument): void { } } - protected function shouldExpireUser(IUser $user, int $maxLastLogin): bool { + + protected function shouldPerformActionOnUser(IUser $user, int $maxLastLogin, bool $retryOnFollowupDays = true): bool { if (!$maxLastLogin) { return false; } @@ -151,30 +175,37 @@ protected function shouldExpireUser(IUser $user, int $maxLastLogin): bool { return false; } - if ($this->keepUsersWithoutLogin && $user->getLastLogin() === 0) { - // no need for deletion when no user dir was initialized - $this->logger->debug('Skipping user that never logged in: {user}', [ + if (!$this->keepUsersWithoutLogin && $maxLastLogin < $createdAt) { + $this->logger->debug('Skipping user because of discovery time: {user}', [ 'user' => $user->getUID(), ]); return false; } - if ($maxLastLogin < $user->getLastLogin()) { - $this->logger->debug('Skipping user because of login time: {user}', [ + if ($this->keepUsersWithoutLogin && $user->getLastLogin() === 0) { + // no need for deletion when no user dir was initialized + $this->logger->debug('Skipping user that never logged in: {user}', [ 'user' => $user->getUID(), ]); return false; } - if (!$this->allAuthTokensInactive($user, $maxLastLogin)) { - $this->logger->debug('Skipping user because of auth token time: {user}', [ + $authTokensLastActivity = $this->getAuthTokensLastActivity($user); + if ($authTokensLastActivity === null) { + $lastAuthentication = $user->getLastLogin(); + } else { + $lastAuthentication = max($user->getLastLogin(), $authTokensLastActivity); + } + + if ($maxLastLogin < $lastAuthentication) { + $this->logger->debug('Skipping user because of login or auth token time: {user}', [ 'user' => $user->getUID(), ]); return false; } - if (!$this->keepUsersWithoutLogin && $maxLastLogin < $createdAt) { - $this->logger->debug('Skipping user because of discovery time: {user}', [ + if (!$retryOnFollowupDays && ($maxLastLogin - 86400) > $lastAuthentication) { + $this->logger->debug('Skipping user because of login or auth token time is not in retry window: {user}', [ 'user' => $user->getUID(), ]); return false; @@ -193,22 +224,26 @@ protected function shouldExpireUser(IUser $user, int $maxLastLogin): bool { ]); return false; } + return true; } - protected function allAuthTokensInactive(IUser $user, int $maxLastActivity): bool { + protected function getAuthTokensLastActivity(IUser $user): ?int { /** @var Manager $authTokenManager */ $authTokenManager = $this->server->get(Manager::class); /** @var PublicKeyToken[] $tokens */ $tokens = $authTokenManager->getTokenByUser($user->getUID()); + $lastActivities = []; foreach ($tokens as $token) { - if ($maxLastActivity < $token->getLastActivity()) { - return false; - } + $lastActivities[] = $token->getLastActivity(); } - return true; + if (empty($lastActivities)) { + return null; + } + + return max(...$lastActivities); } protected function getCreatedAt(IUser $user): int { @@ -216,7 +251,7 @@ protected function getCreatedAt(IUser $user): int { $user->getUID(), 'user_retention', 'user_created_at', - 0 + '0' ); } diff --git a/tests/BackgroundJob/ExpireUsersTest.php b/tests/BackgroundJob/ExpireUsersTest.php new file mode 100644 index 00000000..acf44d1e --- /dev/null +++ b/tests/BackgroundJob/ExpireUsersTest.php @@ -0,0 +1,149 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +namespace OCA\UserRetention\Tests; + +use OCA\UserRetention\BackgroundJob\ExpireUsers; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IConfig; +use OCP\IGroupManager; +use OCP\IServerContainer; +use OCP\IUser; +use OCP\IUserManager; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; +use Test\TestCase; + +class ExpireUsersTest extends TestCase { + /** @var MockObject|IConfig */ + protected $config; + /** @var MockObject|IUserManager */ + protected $userManager; + /** @var MockObject|IGroupManager */ + protected $groupManager; + /** @var MockObject|ITimeFactory */ + protected $timeFactory; + /** @var MockObject|IServerContainer */ + protected $container; + /** @var MockObject|LoggerInterface */ + protected $logger; + + protected function setUp(): void { + parent::setUp(); + + /** @var MockObject|IConfig $config */ + $this->config = $this->createMock(IConfig::class); + /** @var MockObject|IUserManager $userManager */ + $this->userManager = $this->createMock(IUserManager::class); + /** @var MockObject|IGroupManager $groupManager */ + $this->groupManager = $this->createMock(IGroupManager::class); + /** @var MockObject|ITimeFactory $timeFactory */ + $this->timeFactory = $this->createMock(ITimeFactory::class); + /** @var MockObject|IServerContainer $urlGenerator */ + $this->container = $this->createMock(IServerContainer::class); + /** @var MockObject|LoggerInterface $dispatcher */ + $this->logger = $this->createMock(LoggerInterface::class); + } + + protected function createJob(array $methods = []): ExpireUsers { + if (empty($methods)) { + return new ExpireUsers( + $this->config, + $this->userManager, + $this->groupManager, + $this->timeFactory, + $this->container, + $this->logger + ); + } + + $mock = $this->getMockBuilder(ExpireUsers::class); + $mock->setConstructorArgs([ + $this->config, + $this->userManager, + $this->groupManager, + $this->timeFactory, + $this->container, + $this->logger, + ]); + $mock->onlyMethods($methods); + return $mock->getMock(); + } + + public function dataShouldPerformActionOnUser(): array { + return [ + 'No expiration configured' + => [0, null, null, null, false, false, false], + 'Newly discovered user' + => [120000000, 0, null, null, true, false, false], + 'Too new discovery' + => [120000000, 120000001, null, null, true, false, false], + 'No login at all' + => [120000000, 119999999, 0, null, true, true, false], + 'Too new login (without auth tokens)' + => [120000000, 119999999, 120000001, null, true, true, false], + 'Too new login (with old auth tokens)' + => [120000000, 119999999, 120000001, 119999999, true, true, false], + 'Too new auth token' + => [120000000, 119999999, 119999999, 120000001, true, true, false], + 'Performing action' + => [120000000, 119900000, 119900000, 119900000, true, true, true], + 'Already performed' + => [120000000, 119900000, 119900000, 119900000, false, true, false], + ]; + } + + /** + * @dataProvider dataShouldPerformActionOnUser + */ + public function testShouldPerformActionOnUser(int $maxLastLogin, ?int $discoveryTime, ?int $lastLogin, ?int $lastTokenActivity, bool $retryOnFollowupDays, bool $keepUsersWithoutLogin, bool $expected): void { + /** @var ExpireUsers|MockObject $job */ + $job = $this->createJob(['getAuthTokensLastActivity', 'setCreatedAt']); + + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('uid'); + $user->method('getLastLogin') + ->willReturn($lastLogin); + + if ($maxLastLogin !== 0) { + $this->config + ->expects($this->once()) + ->method('getUserValue') + ->with('uid', 'user_retention', 'user_created_at', '0') + ->willReturn($discoveryTime); + } + + if ($discoveryTime === 0) { + $job->expects($this->once()) + ->method('setCreatedAt'); + } else { + $job->expects($this->never()) + ->method('setCreatedAt'); + } + + $job->method('getAuthTokensLastActivity') + ->willReturn($lastTokenActivity); + + self::invokePrivate($job, 'keepUsersWithoutLogin', [$keepUsersWithoutLogin]); + + $this->assertSame($expected, self::invokePrivate($job, 'shouldPerformActionOnUser', [$user, $maxLastLogin, $retryOnFollowupDays])); + } +} From 9a1711ca99e6d459c2c2ee1dffbf65164a84f171 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Tue, 15 Nov 2022 14:30:08 +0100 Subject: [PATCH 5/9] Move the logic to a service for better testing Signed-off-by: Joas Schilling --- lib/BackgroundJob/ExpireUsers.php | 250 +---------------- lib/Service/RetentionService.php | 351 ++++++++++++++++++++++++ lib/SkipUserException.php | 40 +++ tests/BackgroundJob/ExpireUsersTest.php | 149 ---------- tests/Service/RetentionServiceTest.php | 247 +++++++++++++++++ 5 files changed, 646 insertions(+), 391 deletions(-) create mode 100644 lib/Service/RetentionService.php create mode 100644 lib/SkipUserException.php delete mode 100644 tests/BackgroundJob/ExpireUsersTest.php create mode 100644 tests/Service/RetentionServiceTest.php diff --git a/lib/BackgroundJob/ExpireUsers.php b/lib/BackgroundJob/ExpireUsers.php index 39fa1273..d473bc96 100644 --- a/lib/BackgroundJob/ExpireUsers.php +++ b/lib/BackgroundJob/ExpireUsers.php @@ -1,7 +1,9 @@ + * @copyright Copyright (c) 2022 Joas Schilling * * @license GNU AGPL version 3 or any later version * @@ -22,261 +24,25 @@ namespace OCA\UserRetention\BackgroundJob; -use OC\Authentication\Token\Manager; -use OC\Authentication\Token\PublicKeyToken; -use OCA\Guests\UserBackend; +use OCA\UserRetention\Service\RetentionService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; -use OCP\IConfig; -use OCP\IGroupManager; -use OCP\IServerContainer; -use OCP\IUser; -use OCP\IUserManager; -use OCP\LDAP\IDeletionFlagSupport; -use OCP\LDAP\ILDAPProvider; -use Psr\Log\LoggerInterface; -/** - * Class ExpireUsers - * - * @package OCA\UserRetention\BackgroundJob - */ class ExpireUsers extends TimedJob { - protected IConfig $config; - protected IUserManager $userManager; - protected IGroupManager $groupManager; - protected LoggerInterface $logger; - protected IServerContainer $server; - - protected int $userMaxLastLogin = 0; - protected int $guestMaxLastLogin = 0; - protected array $excludedGroups = []; - protected array $reminders = []; - protected array $remindersPlain = []; - protected bool $keepUsersWithoutLogin = true; + protected RetentionService $service; public function __construct( - IConfig $config, - IUserManager $userManager, - IGroupManager $groupManager, ITimeFactory $time, - IServerContainer $server, - LoggerInterface $logger + RetentionService $service ) { parent::__construct($time); - - $this->config = $config; - $this->userManager = $userManager; - $this->groupManager = $groupManager; - $this->server = $server; - $this->logger = $logger; + $this->service = $service; // Every day $this->setInterval(60 * 60 * 24); } protected function run($argument): void { - $now = new \DateTimeImmutable(); - $userDays = (int) $this->config->getAppValue('user_retention', 'user_days', '0'); - if ($userDays > 0) { - $userMaxLastLogin = $now->sub(new \DateInterval('P' . $userDays . 'D')); - $this->userMaxLastLogin = $userMaxLastLogin->getTimestamp(); - $this->logger->debug('Account retention with last login before ' . $userMaxLastLogin->format(\DateTimeInterface::ATOM)); - } else { - $this->logger->debug('Account retention is disabled'); - } - - $guestDays = (int) $this->config->getAppValue('user_retention', 'guest_days', '0'); - if ($guestDays > 0) { - $guestMaxLastLogin = $now->sub(new \DateInterval('P' . $guestDays . 'D')); - $this->guestMaxLastLogin = $guestMaxLastLogin->getTimestamp(); - $this->logger->debug('Guest account retention with last login before ' . $guestMaxLastLogin->format(\DateTimeInterface::ATOM)); - } else { - $this->logger->debug('Guest account retention is disabled'); - } - - $reminderDaysString = $this->config->getAppValue('user_retention', 'reminder_days', ''); - $reminderDayOptions = explode(',', $reminderDaysString); - foreach ($reminderDayOptions as $option) { - $option = (int) trim($option); - if ($option !== 0) { - $this->remindersPlain[] = $option; - $this->reminders[] = $now->sub(new \DateInterval('P' . $option . 'D'))->getTimestamp(); - } - } - - $this->keepUsersWithoutLogin = $this->config->getAppValue('user_retention', 'keep_users_without_login', 'yes') === 'yes'; - - try { - $excludedGroups = $this->config->getAppValue('user_retention', 'excluded_groups', '["admin"]'); - $excludedGroups = json_decode($excludedGroups, true, 512, JSON_THROW_ON_ERROR); - $this->excludedGroups = \is_array($excludedGroups) ? $excludedGroups : []; - } catch (\JsonException $e) { - } - - $handler = function(IUser $user) { - $maxLastLogin = $this->userMaxLastLogin; - if ($user->getBackend() instanceof UserBackend) { - $maxLastLogin = $this->guestMaxLastLogin; - } - - if ($this->shouldPerformActionOnUser($user, $maxLastLogin)) { - $this->logger->debug('Attempting to delete account: {user}', [ - 'user' => $user->getUID(), - ]); - if($user->getBackendClassName() === 'LDAP' && !$this->prepareLDAPUser($user)) { - $this->logger->warning('Expired LDAP account ' . $user->getUID() . ' was not deleted'); - return; - } - - if ($user->delete()) { - $this->logger->info('Account deleted: ' . $user->getUID()); - } else { - $this->logger->warning('Expired account ' . $user->getUID() . ' was not deleted'); - } - return; - } - - foreach ($this->reminders as $key => $reminder) { - $this->logger->debug('Checking reminder with {reminder} day: {user}', [ - 'reminder' => $this->remindersPlain[$key] ?? 0, - 'user' => $user->getUID(), - ]); - if ($this->shouldPerformActionOnUser($user, $reminder, false)) { - $this->logger->debug('Send reminder to account: {user}', [ - 'user' => $user->getUID(), - ]); - - // FIXME send notification - } - } - }; - - if ($this->keepUsersWithoutLogin) { - $this->userManager->callForSeenUsers($handler); - } else { - $this->userManager->callForAllUsers($handler); - } - } - - - protected function shouldPerformActionOnUser(IUser $user, int $maxLastLogin, bool $retryOnFollowupDays = true): bool { - if (!$maxLastLogin) { - return false; - } - - $createdAt = $this->getCreatedAt($user); - if ($createdAt === 0) { - // Set "now" as created at timestamp for the user. - $this->setCreatedAt($user, $this->time->getTime()); - $this->logger->debug('New user, saving discovery time: {user}', [ - 'user' => $user->getUID(), - ]); - return false; - } - - if (!$this->keepUsersWithoutLogin && $maxLastLogin < $createdAt) { - $this->logger->debug('Skipping user because of discovery time: {user}', [ - 'user' => $user->getUID(), - ]); - return false; - } - - if ($this->keepUsersWithoutLogin && $user->getLastLogin() === 0) { - // no need for deletion when no user dir was initialized - $this->logger->debug('Skipping user that never logged in: {user}', [ - 'user' => $user->getUID(), - ]); - return false; - } - - $authTokensLastActivity = $this->getAuthTokensLastActivity($user); - if ($authTokensLastActivity === null) { - $lastAuthentication = $user->getLastLogin(); - } else { - $lastAuthentication = max($user->getLastLogin(), $authTokensLastActivity); - } - - if ($maxLastLogin < $lastAuthentication) { - $this->logger->debug('Skipping user because of login or auth token time: {user}', [ - 'user' => $user->getUID(), - ]); - return false; - } - - if (!$retryOnFollowupDays && ($maxLastLogin - 86400) > $lastAuthentication) { - $this->logger->debug('Skipping user because of login or auth token time is not in retry window: {user}', [ - 'user' => $user->getUID(), - ]); - return false; - } - - if (empty($this->excludedGroups)) { - return true; - } - - $userGroups = $this->groupManager->getUserGroupIds($user); - $excludedGroups = array_intersect($userGroups, $this->excludedGroups); - if (!empty($excludedGroups)) { - $this->logger->debug('Skipping user because of excluded groups ({groups}): {user}', [ - 'user' => $user->getUID(), - 'groups' => implode(',', $excludedGroups), - ]); - return false; - } - - return true; - } - - protected function getAuthTokensLastActivity(IUser $user): ?int { - /** @var Manager $authTokenManager */ - $authTokenManager = $this->server->get(Manager::class); - /** @var PublicKeyToken[] $tokens */ - $tokens = $authTokenManager->getTokenByUser($user->getUID()); - - $lastActivities = []; - foreach ($tokens as $token) { - $lastActivities[] = $token->getLastActivity(); - } - - if (empty($lastActivities)) { - return null; - } - - return max(...$lastActivities); - } - - protected function getCreatedAt(IUser $user): int { - return (int) $this->config->getUserValue( - $user->getUID(), - 'user_retention', - 'user_created_at', - '0' - ); - } - - protected function setCreatedAt(IUser $user, int $time): void { - $this->config->setUserValue( - $user->getUID(), - 'user_retention', - 'user_created_at', - $time - ); - } - - protected function prepareLDAPUser(IUser $user): bool { - try { - $ldapProvider = $this->server->get(ILDAPProvider::class); - if($ldapProvider instanceof IDeletionFlagSupport) { - $ldapProvider->flagRecord($user->getUID()); - $this->logger->info('Marking LDAP user as deleted: ' . $user->getUID()); - } - } catch (\Exception $e) { - $this->logger->warning($e->getMessage(), [ - 'exception' => $e, - ]); - return false; - } - return true; + $this->service->runCron(); } } diff --git a/lib/Service/RetentionService.php b/lib/Service/RetentionService.php new file mode 100644 index 00000000..4d359f03 --- /dev/null +++ b/lib/Service/RetentionService.php @@ -0,0 +1,351 @@ + + * + * @author Joas Schilling + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\UserRetention\Service; + +use OC\Authentication\Token\Manager; +use OC\Authentication\Token\PublicKeyToken; +use OCA\Guests\UserBackend; +use OCA\UserRetention\SkipUserException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IConfig; +use OCP\IGroupManager; +use OCP\IL10N; +use OCP\IServerContainer; +use OCP\IUser; +use OCP\IUserManager; +use OCP\L10N\IFactory; +use OCP\LDAP\IDeletionFlagSupport; +use OCP\LDAP\ILDAPProvider; +use OCP\Mail\IMailer; +use Psr\Log\LoggerInterface; + +class RetentionService { + protected IConfig $config; + protected IUserManager $userManager; + protected IGroupManager $groupManager; + protected ITimeFactory $time; + protected IServerContainer $server; + protected IMailer $mailer; + protected IFactory $l10nFactory; + protected LoggerInterface $logger; + + protected int $userDays = 0; + protected int $userMaxLastLogin = 0; + protected int $guestDays = 0; + protected int $guestMaxLastLogin = 0; + protected array $excludedGroups = []; + protected array $reminders = []; + protected array $remindersPlain = []; + protected bool $keepUsersWithoutLogin = true; + + public function __construct( + IConfig $config, + IUserManager $userManager, + IGroupManager $groupManager, + ITimeFactory $time, + IServerContainer $server, + IMailer $mailer, + IFactory $l10nFactory, + LoggerInterface $logger + ) { + $this->config = $config; + $this->userManager = $userManager; + $this->groupManager = $groupManager; + $this->time = $time; + $this->server = $server; + $this->mailer = $mailer; + $this->l10nFactory = $l10nFactory; + $this->logger = $logger; + } + + public function runCron(): void { + $now = new \DateTimeImmutable(); + $this->userDays = (int) $this->config->getAppValue('user_retention', 'user_days', '0'); + if ($this->userDays > 0) { + $userMaxLastLogin = $now->sub(new \DateInterval('P' . $this->userDays . 'D')); + $this->userMaxLastLogin = $userMaxLastLogin->getTimestamp(); + $this->logger->debug('Account retention with last login before ' . $userMaxLastLogin->format(\DateTimeInterface::ATOM)); + } else { + $this->logger->debug('Account retention is disabled'); + } + + $this->guestDays = (int) $this->config->getAppValue('user_retention', 'guest_days', '0'); + if ($this->guestDays > 0) { + $guestMaxLastLogin = $now->sub(new \DateInterval('P' . $this->guestDays . 'D')); + $this->guestMaxLastLogin = $guestMaxLastLogin->getTimestamp(); + $this->logger->debug('Guest account retention with last login before ' . $guestMaxLastLogin->format(\DateTimeInterface::ATOM)); + } else { + $this->logger->debug('Guest account retention is disabled'); + } + + $reminderDaysString = $this->config->getAppValue('user_retention', 'reminder_days', ''); + $reminderDayOptions = explode(',', $reminderDaysString); + foreach ($reminderDayOptions as $option) { + $option = (int) trim($option); + if ($option !== 0) { + $this->remindersPlain[] = $option; + $this->reminders[] = $now->sub(new \DateInterval('P' . $option . 'D'))->getTimestamp(); + } + } + + $this->keepUsersWithoutLogin = $this->config->getAppValue('user_retention', 'keep_users_without_login', 'yes') === 'yes'; + + try { + $excludedGroups = $this->config->getAppValue('user_retention', 'excluded_groups', '["admin"]'); + $excludedGroups = json_decode($excludedGroups, true, 512, JSON_THROW_ON_ERROR); + $this->excludedGroups = \is_array($excludedGroups) ? $excludedGroups : []; + } catch (\JsonException $e) { + } + + if ($this->keepUsersWithoutLogin) { + $this->userManager->callForSeenUsers(\Closure::fromCallable([$this, 'executeRetentionPolicy'])); + } else { + $this->userManager->callForAllUsers(\Closure::fromCallable([$this, 'executeRetentionPolicy'])); + } + } + + public function executeRetentionPolicy(IUser $user): ?bool { + $this->logger->warning($user->getUID()); + $skipIfNewerThan = $this->userMaxLastLogin; + $policyDays = $this->userDays; + if ($user->getBackend() instanceof UserBackend) { + $skipIfNewerThan = $this->guestMaxLastLogin; + $policyDays = $this->guestDays; + } + + if (!$skipIfNewerThan) { + $this->logger->debug('Skipping retention because not defined for user backend: {user}', [ + 'user' => $user->getUID(), + ]); + return true; + } + + // Skip user completely when member of a protected group + try { + $this->skipUserBasedOnProtectedGroupMembership($user); + } catch (SkipUserException $e) { + $this->logger->debug($e->getMessage(), $e->getLogParameters()); + return true; + } + + // Check if we delete the user + try { + $this->shouldPerformActionOnUser($user, $skipIfNewerThan); + + $this->logger->debug('Attempting to delete account: {user}', [ + 'user' => $user->getUID(), + ]); + if($user->getBackendClassName() === 'LDAP' && !$this->prepareLDAPUser($user)) { + $this->logger->warning('Expired LDAP account ' . $user->getUID() . ' was not deleted'); + return true; + } + + if ($user->delete()) { + $this->logger->info('Account deleted: ' . $user->getUID()); + } else { + $this->logger->warning('Expired account ' . $user->getUID() . ' was not deleted'); + } + return true; + } catch (SkipUserException $e) { + // Not deleting yet, continue with checking reminders + } + + // Check if we remind the user + foreach ($this->reminders as $key => $reminder) { + $reminderDays = $this->remindersPlain[$key] ?? 0; + + $this->logger->debug('Checking reminder with {reminder} day: {user}', [ + 'reminder' => $reminderDays, + 'user' => $user->getUID(), + ]); + + try { + $lastActivity = $this->shouldPerformActionOnUser($user, $reminder, $reminder - 86400); + + // FIXME send notification + $this->sendReminder($user, $lastActivity, $policyDays); + } catch (SkipUserException $e) { + $this->logger->debug($e->getMessage(), $e->getLogParameters()); + continue; + } + } + + return true; + } + + /** + * @param IUser $user + * @param int $skipIfNewerThan + * @param ?int $skipIfOlderThan + * @return int Return the last activity timestamp + * @throws SkipUserException When the user should be skipped + */ + protected function shouldPerformActionOnUser(IUser $user, int $skipIfNewerThan, ?int $skipIfOlderThan = null): int { + $discoveryTimestamp = $this->skipUserBasedOnDiscovery($user); + $lastWebLogin = $user->getLastLogin(); + $authTokensLastActivity = $this->getAuthTokensLastActivity($user); + + $lastAction = max($discoveryTimestamp, $lastWebLogin, $authTokensLastActivity); + + if ($this->keepUsersWithoutLogin && $lastAction === 0) { + throw new SkipUserException( + 'Skipping user that never logged in: {user}', + ['user' => $user->getUID()] + ); + } + + if ($skipIfNewerThan < $lastAction) { + throw new SkipUserException( + 'Skipping user because last action is newer: {user}', + ['user' => $user->getUID()] + ); + } + + if ($skipIfOlderThan !== null && $skipIfOlderThan > $lastAction) { + throw new SkipUserException( + 'Skipping user because last action is older: {user}', + ['user' => $user->getUID()] + ); + } + + return $lastAction; + } + + /** + * @param IUser $user + * @return int Return the discovery timestamp as last activity timestamp + * @throws SkipUserException When the user was just discovered + */ + protected function skipUserBasedOnDiscovery(IUser $user): int { + $discoveryTimestamp = (int) $this->config->getUserValue($user->getUID(), 'user_retention', 'user_created_at', '0'); + if ($discoveryTimestamp === 0) { + // Set "now" as created at timestamp for the user. + $this->config->setUserValue($user->getUID(), 'user_retention', 'user_created_at', (string) $this->time->getTime()); + + throw new SkipUserException( + 'New user, saving discovery time: {user}', + ['user' => $user->getUID()] + ); + } + + return $discoveryTimestamp; + } + + /** + * @param IUser $user + * @throws SkipUserException When the user is part of a group and should therefor be skipped + */ + protected function skipUserBasedOnProtectedGroupMembership(IUser $user): void { + if (empty($this->excludedGroups)) { + return; + } + + $userGroups = $this->groupManager->getUserGroupIds($user); + $excludedGroups = array_intersect($userGroups, $this->excludedGroups); + if (!empty($excludedGroups)) { + throw new SkipUserException( + 'Skipping user because of excluded groups ({groups}): {user}', + [ + 'user' => $user->getUID(), + 'groups' => implode(',', $excludedGroups), + ] + ); + } + } + + protected function getAuthTokensLastActivity(IUser $user): ?int { + /** @var Manager $authTokenManager */ + $authTokenManager = $this->server->get(Manager::class); + /** @var PublicKeyToken[] $tokens */ + $tokens = $authTokenManager->getTokenByUser($user->getUID()); + + $lastActivities = []; + foreach ($tokens as $token) { + $lastActivities[] = $token->getLastActivity(); + } + + if (empty($lastActivities)) { + return null; + } + + return max(...$lastActivities); + } + + protected function prepareLDAPUser(IUser $user): bool { + try { + $ldapProvider = $this->server->get(ILDAPProvider::class); + if($ldapProvider instanceof IDeletionFlagSupport) { + $ldapProvider->flagRecord($user->getUID()); + $this->logger->info('Marking LDAP user as deleted: ' . $user->getUID()); + } + } catch (\Exception $e) { + $this->logger->warning($e->getMessage(), [ + 'exception' => $e, + ]); + return false; + } + return true; + } + + protected function sendReminder(IUser $user, int $lastActivity, int $policyDays): void { + $this->logger->debug('Send reminder to account: {user}', [ + 'user' => $user->getUID(), + ]); + + $l = $this->l10nFactory->get('user_retention', $this->l10nFactory->getUserLanguage($user)); + + $message = $this->mailer->createMessage(); + $template = $this->mailer->createEMailTemplate('user_retention.Reminder'); + $template->setSubject($l->t('Important information regarding your account')); + + $template->addHeader(); + $template->addHeading($l->t('Account deletion')); + $template->addBodyText(str_replace('{date}', $l->l('date', $lastActivity), $l->t('You have used your account since {date}.'))); + $template->addBodyText($l->n( + 'Due to the configured policy for accounts, inactive accounts will be deleted after %n day.', + 'Due to the configured policy for accounts, inactive accounts will be deleted after %n days.', + $policyDays + )); + $template->addBodyText($l->t('To keep your account you only need to login or connect with a desktop or mobile app. Otherwise your account and all the connected data will be permanently deleted.')); + $template->addBodyText($l->t('If you have any questions, please contact your administration.')); + $template->addFooter(); + + $message->useTemplate($template); + $message->setTo([ + $user->getEMailAddress() => $user->getDisplayName(), + ]); + + try { + $this->mailer->send($message); + } catch (\Exception $e) { + $this->logger->error('Error while sending user retention reminder to {user}', [ + 'user' => $user->getUID(), + 'exception' => $e, + ]); + } + } +} diff --git a/lib/SkipUserException.php b/lib/SkipUserException.php new file mode 100644 index 00000000..bce17a04 --- /dev/null +++ b/lib/SkipUserException.php @@ -0,0 +1,40 @@ + + * + * @author Joas Schilling + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\UserRetention; + +class SkipUserException extends \RuntimeException { + protected array $logParameters; + + public function __construct(string $logMessage = '', array $logParameters = []) { + parent::__construct($logMessage); + $this->logParameters = $logParameters; + } + + public function getLogParameters(): array { + return $this->logParameters; + } +} diff --git a/tests/BackgroundJob/ExpireUsersTest.php b/tests/BackgroundJob/ExpireUsersTest.php deleted file mode 100644 index acf44d1e..00000000 --- a/tests/BackgroundJob/ExpireUsersTest.php +++ /dev/null @@ -1,149 +0,0 @@ - - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - */ -namespace OCA\UserRetention\Tests; - -use OCA\UserRetention\BackgroundJob\ExpireUsers; -use OCP\AppFramework\Utility\ITimeFactory; -use OCP\IConfig; -use OCP\IGroupManager; -use OCP\IServerContainer; -use OCP\IUser; -use OCP\IUserManager; -use PHPUnit\Framework\MockObject\MockObject; -use Psr\Log\LoggerInterface; -use Test\TestCase; - -class ExpireUsersTest extends TestCase { - /** @var MockObject|IConfig */ - protected $config; - /** @var MockObject|IUserManager */ - protected $userManager; - /** @var MockObject|IGroupManager */ - protected $groupManager; - /** @var MockObject|ITimeFactory */ - protected $timeFactory; - /** @var MockObject|IServerContainer */ - protected $container; - /** @var MockObject|LoggerInterface */ - protected $logger; - - protected function setUp(): void { - parent::setUp(); - - /** @var MockObject|IConfig $config */ - $this->config = $this->createMock(IConfig::class); - /** @var MockObject|IUserManager $userManager */ - $this->userManager = $this->createMock(IUserManager::class); - /** @var MockObject|IGroupManager $groupManager */ - $this->groupManager = $this->createMock(IGroupManager::class); - /** @var MockObject|ITimeFactory $timeFactory */ - $this->timeFactory = $this->createMock(ITimeFactory::class); - /** @var MockObject|IServerContainer $urlGenerator */ - $this->container = $this->createMock(IServerContainer::class); - /** @var MockObject|LoggerInterface $dispatcher */ - $this->logger = $this->createMock(LoggerInterface::class); - } - - protected function createJob(array $methods = []): ExpireUsers { - if (empty($methods)) { - return new ExpireUsers( - $this->config, - $this->userManager, - $this->groupManager, - $this->timeFactory, - $this->container, - $this->logger - ); - } - - $mock = $this->getMockBuilder(ExpireUsers::class); - $mock->setConstructorArgs([ - $this->config, - $this->userManager, - $this->groupManager, - $this->timeFactory, - $this->container, - $this->logger, - ]); - $mock->onlyMethods($methods); - return $mock->getMock(); - } - - public function dataShouldPerformActionOnUser(): array { - return [ - 'No expiration configured' - => [0, null, null, null, false, false, false], - 'Newly discovered user' - => [120000000, 0, null, null, true, false, false], - 'Too new discovery' - => [120000000, 120000001, null, null, true, false, false], - 'No login at all' - => [120000000, 119999999, 0, null, true, true, false], - 'Too new login (without auth tokens)' - => [120000000, 119999999, 120000001, null, true, true, false], - 'Too new login (with old auth tokens)' - => [120000000, 119999999, 120000001, 119999999, true, true, false], - 'Too new auth token' - => [120000000, 119999999, 119999999, 120000001, true, true, false], - 'Performing action' - => [120000000, 119900000, 119900000, 119900000, true, true, true], - 'Already performed' - => [120000000, 119900000, 119900000, 119900000, false, true, false], - ]; - } - - /** - * @dataProvider dataShouldPerformActionOnUser - */ - public function testShouldPerformActionOnUser(int $maxLastLogin, ?int $discoveryTime, ?int $lastLogin, ?int $lastTokenActivity, bool $retryOnFollowupDays, bool $keepUsersWithoutLogin, bool $expected): void { - /** @var ExpireUsers|MockObject $job */ - $job = $this->createJob(['getAuthTokensLastActivity', 'setCreatedAt']); - - $user = $this->createMock(IUser::class); - $user->method('getUID') - ->willReturn('uid'); - $user->method('getLastLogin') - ->willReturn($lastLogin); - - if ($maxLastLogin !== 0) { - $this->config - ->expects($this->once()) - ->method('getUserValue') - ->with('uid', 'user_retention', 'user_created_at', '0') - ->willReturn($discoveryTime); - } - - if ($discoveryTime === 0) { - $job->expects($this->once()) - ->method('setCreatedAt'); - } else { - $job->expects($this->never()) - ->method('setCreatedAt'); - } - - $job->method('getAuthTokensLastActivity') - ->willReturn($lastTokenActivity); - - self::invokePrivate($job, 'keepUsersWithoutLogin', [$keepUsersWithoutLogin]); - - $this->assertSame($expected, self::invokePrivate($job, 'shouldPerformActionOnUser', [$user, $maxLastLogin, $retryOnFollowupDays])); - } -} diff --git a/tests/Service/RetentionServiceTest.php b/tests/Service/RetentionServiceTest.php new file mode 100644 index 00000000..e64834f1 --- /dev/null +++ b/tests/Service/RetentionServiceTest.php @@ -0,0 +1,247 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +namespace OCA\UserRetention\Tests; + +use OCA\UserRetention\BackgroundJob\ExpireUsers; +use OCA\UserRetention\Service\RetentionService; +use OCA\UserRetention\SkipUserException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IConfig; +use OCP\IGroupManager; +use OCP\IServerContainer; +use OCP\IUser; +use OCP\IUserManager; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; +use Test\TestCase; + +class RetentionServiceTest extends TestCase { + /** @var MockObject|IConfig */ + protected $config; + /** @var MockObject|IUserManager */ + protected $userManager; + /** @var MockObject|IGroupManager */ + protected $groupManager; + /** @var MockObject|ITimeFactory */ + protected $timeFactory; + /** @var MockObject|IServerContainer */ + protected $container; + /** @var MockObject|LoggerInterface */ + protected $logger; + + protected function setUp(): void { + parent::setUp(); + + /** @var MockObject|IConfig $config */ + $this->config = $this->createMock(IConfig::class); + /** @var MockObject|IUserManager $userManager */ + $this->userManager = $this->createMock(IUserManager::class); + /** @var MockObject|IGroupManager $groupManager */ + $this->groupManager = $this->createMock(IGroupManager::class); + /** @var MockObject|ITimeFactory $timeFactory */ + $this->timeFactory = $this->createMock(ITimeFactory::class); + /** @var MockObject|IServerContainer $urlGenerator */ + $this->container = $this->createMock(IServerContainer::class); + /** @var MockObject|LoggerInterface $dispatcher */ + $this->logger = $this->createMock(LoggerInterface::class); + } + + /** + * @param string[] $methods + * @return RetentionService|MockObject + */ + protected function createService(array $methods = []) { + if (empty($methods)) { + return new RetentionService( + $this->config, + $this->userManager, + $this->groupManager, + $this->timeFactory, + $this->container, + $this->logger + ); + } + + $mock = $this->getMockBuilder(RetentionService::class); + $mock->setConstructorArgs([ + $this->config, + $this->userManager, + $this->groupManager, + $this->timeFactory, + $this->container, + $this->logger, + ]); + $mock->onlyMethods($methods); + return $mock->getMock(); + } + + public function dataShouldPerformActionOnUser(): array { + return [ + // No action at all + [true, 0, 0, 0, null, true], + [false, 0, 0, 0, null, false, 0], + + // Deletion part without skip older + // Everything is old without max age + [true, 9, 9, 9, null, false, 9], + [true, 99_999, 9, 9, null, false, 99_999], + [true, 9, 99_999, 9, null, false, 99_999], + [true, 9, 9, 99_999, null, false, 99_999], + + // One is new enough + [true, 100_001, 9, 9, null, true], + [true, 100_001, 99_999, 99_999, null, true], + [true, 9, 100_001, 9, null, true], + [true, 99_999, 100_001, 99_999, null, true], + [true, 9, 9, 100_001, null, true], + [true, 99_999, 99_999, 100_001, null, true], + + // Reminder part with skip older + // Everything is old but one is newer than skip + [true, 9, 9, 9, 100, true], + [true, 300, 9, 9, 100, false, 300], + [true, 9, 300, 9, 100, false, 300], + [true, 9, 9, 300, 100, false, 300], + + // One is new enough + [true, 100_001, 9, 9, 100, true], + [true, 100_001, 99_999, 99_999, 100, true], + [true, 9, 100_001, 9, 100, true], + [true, 99_999, 100_001, 99_999, 100, true], + [true, 9, 9, 100_001, 100, true], + [true, 99_999, 99_999, 100_001, 100, true], + ]; + } + + /** + * @dataProvider dataShouldPerformActionOnUser + * @param bool $skipWithoutLogin + * @param int $discoveryTimestamp + * @param int $lastLogin + * @param int $authTokenLastActivity + * @param bool $expectsThrow + */ + public function testShouldPerformActionOnUser(bool $skipWithoutLogin, int $discoveryTimestamp, int $lastLogin, int $authTokenLastActivity, ?int $skipOlderThan, bool $expectsThrow, ?int $expectedReturn = null): void { + /** @var MockObject|RetentionService $service */ + $service = $this->createService(['skipUserBasedOnDiscovery', 'getAuthTokensLastActivity']); + self::invokePrivate($service, 'keepUsersWithoutLogin', [$skipWithoutLogin]); + + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('uid'); + $user->method('getLastLogin') + ->willReturn($lastLogin); + + $service->method('skipUserBasedOnDiscovery') + ->with($user) + ->willReturn($discoveryTimestamp); + + $service->method('getAuthTokensLastActivity') + ->with($user) + ->willReturn($authTokenLastActivity); + + if ($expectsThrow) { + $this->expectException(SkipUserException::class); + self::invokePrivate($service, 'shouldPerformActionOnUser', [$user, 100_000, $skipOlderThan]); + } else { + $this->assertSame($expectedReturn, self::invokePrivate($service, 'shouldPerformActionOnUser', [$user, 100_000, $skipOlderThan])); + } + } + + public function dataSkipUserBasedOnDiscovery(): array { + return [ + [0, 100_001, true], + [99_999, null, false], + ]; + } + + /** + * @dataProvider dataSkipUserBasedOnDiscovery + * @param int $discoveryTimestamp + * @param int|null $newDiscoveryTimestamp + * @param bool $expectsThrow + */ + public function testSkipUserBasedOnDiscovery(int $discoveryTimestamp, ?int $newDiscoveryTimestamp, bool $expectsThrow): void { + $service = $this->createService(); + + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('uid'); + + $this->config + ->expects($this->once()) + ->method('getUserValue') + ->with('uid', 'user_retention', 'user_created_at', '0') + ->willReturn($discoveryTimestamp); + + if ($newDiscoveryTimestamp !== null) { + $this->timeFactory->method('getTime') + ->willReturn($newDiscoveryTimestamp); + + $this->config + ->expects($this->once()) + ->method('setUserValue') + ->with('uid', 'user_retention', 'user_created_at', $newDiscoveryTimestamp); + } + + if ($expectsThrow) { + $this->expectException(SkipUserException::class); + } + self::invokePrivate($service, 'skipUserBasedOnDiscovery', [$user]); + } + + public function dataSkipUserBasedOnProtectedGroupMembership(): array { + return [ + [[], [], false], + [[], ['foobar'], false], + [[], ['foo', 'admin', 'bar'], false], + [['admin'], [], false], + [['admin'], ['foobar'], false], + [['admin'], ['admin'], true], + [['admin'], ['foo', 'admin', 'bar'], true], + ]; + } + + /** + * @dataProvider dataSkipUserBasedOnProtectedGroupMembership + * @param string[] $excludedGroups + * @param string[] $groupMemberships + * @param bool $expectsThrow + */ + public function testSkipUserBasedOnProtectedGroupMembership(array $excludedGroups, array $groupMemberships, bool $expectsThrow): void { + $service = $this->createService(); + self::invokePrivate($service, 'excludedGroups', [$excludedGroups]); + + $user = $this->createMock(IUser::class); + + $this->groupManager->method('getUserGroupIds') + ->with($user) + ->willReturn($groupMemberships); + + if ($expectsThrow) { + $this->expectException(SkipUserException::class); + } + self::invokePrivate($service, 'skipUserBasedOnProtectedGroupMembership', [$user]); + if (!$expectsThrow) { + $this->assertTrue(true); + } + } +} From 3a39062386d469e593fbf5989a5fc63f3c11895b Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Tue, 15 Nov 2022 14:42:16 +0100 Subject: [PATCH 6/9] Document Signed-off-by: Joas Schilling --- README.md | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b17c099d..4e78dcd0 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,13 @@ Accounts are deleted when they did not log in within the given number of days. T ![Screenshot of the admin settings](docs/screenshot.png) -## Accounts that never logged-in +## 🔐 Accounts that never logged-in By default, accounts that have never logged in at all, will be spared from removal. To also take them into consideration, set the config flag accordingly: -`occ config:app:set user_retention keep_users_without_login --value=no` +```shell +occ config:app:set user_retention keep_users_without_login --value='no' +``` In this case the number of days will start counting from the day on which the account has been seen for the first time by the app (first run of the background job after the account was created). @@ -20,9 +22,25 @@ In this case the number of days will start counting from the day on which the ac Retention set to 30 days: -Account created | Account logged in | `keep_users_without_login` | Cleaned up after ----|---|---|--- -7th June | 14th June | yes/default | 14th July -7th June | 14th June | no | 14th July -7th June | - | yes/default | - -7th June | - | no | 7th July +| Account created | Account logged in | `keep_users_without_login` | Cleaned up after | +|-----------------|-------------------|----------------------------|------------------| +| 7th June | 14th June | yes/default | 14th July | +| 7th June | 14th June | no | 14th July | +| 7th June | - | yes/default | - | +| 7th June | - | no | 7th July | + +## 📬 Reminders + +It is also possible to send an email reminder to accounts (when an email is configured). +To send a reminder **14 days after** the last activity: + +```shell +occ config:app:set user_retention reminder_days --value='14' +``` + +You can also provide multiple reminder days as a comma separated list: +```shell +occ config:app:set user_retention reminder_days --value='14,21,28' +``` + +*Note:* There is no validation of the reminder days against the retention days. From 8899aba8773b3003884577043135b55e56cec1b3 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Tue, 15 Nov 2022 14:42:22 +0100 Subject: [PATCH 7/9] Log a warning when the user has no email configured Signed-off-by: Joas Schilling --- lib/Service/RetentionService.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/Service/RetentionService.php b/lib/Service/RetentionService.php index 4d359f03..231cc893 100644 --- a/lib/Service/RetentionService.php +++ b/lib/Service/RetentionService.php @@ -312,6 +312,13 @@ protected function prepareLDAPUser(IUser $user): bool { } protected function sendReminder(IUser $user, int $lastActivity, int $policyDays): void { + if (!$user->getEMailAddress()) { + $this->logger->warning('Could not send account retention reminder to {user} because no email address is configured.', [ + 'user' => $user->getUID(), + ]); + return; + } + $this->logger->debug('Send reminder to account: {user}', [ 'user' => $user->getUID(), ]); From c815c325ce6e1b4d32738312e3d28ecd0ae026ba Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Tue, 15 Nov 2022 14:58:04 +0100 Subject: [PATCH 8/9] Fix tests Signed-off-by: Joas Schilling --- tests/Service/RetentionServiceTest.php | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/Service/RetentionServiceTest.php b/tests/Service/RetentionServiceTest.php index e64834f1..978100ac 100644 --- a/tests/Service/RetentionServiceTest.php +++ b/tests/Service/RetentionServiceTest.php @@ -29,6 +29,8 @@ use OCP\IServerContainer; use OCP\IUser; use OCP\IUserManager; +use OCP\L10N\IFactory; +use OCP\Mail\IMailer; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; use Test\TestCase; @@ -44,23 +46,31 @@ class RetentionServiceTest extends TestCase { protected $timeFactory; /** @var MockObject|IServerContainer */ protected $container; + /** @var MockObject|IMailer */ + protected $mailer; + /** @var MockObject|IFactory */ + protected $l10nFactory; /** @var MockObject|LoggerInterface */ protected $logger; protected function setUp(): void { parent::setUp(); - /** @var MockObject|IConfig $config */ + /** @var MockObject|IConfig */ $this->config = $this->createMock(IConfig::class); - /** @var MockObject|IUserManager $userManager */ + /** @var MockObject|IUserManager */ $this->userManager = $this->createMock(IUserManager::class); - /** @var MockObject|IGroupManager $groupManager */ + /** @var MockObject|IGroupManager */ $this->groupManager = $this->createMock(IGroupManager::class); - /** @var MockObject|ITimeFactory $timeFactory */ + /** @var MockObject|ITimeFactory */ $this->timeFactory = $this->createMock(ITimeFactory::class); - /** @var MockObject|IServerContainer $urlGenerator */ + /** @var MockObject|IServerContainer */ $this->container = $this->createMock(IServerContainer::class); - /** @var MockObject|LoggerInterface $dispatcher */ + /** @var MockObject|IMailer */ + $this->mailer = $this->createMock(IMailer::class); + /** @var MockObject|IFactory */ + $this->l10nFactory = $this->createMock(IFactory::class); + /** @var MockObject|LoggerInterface */ $this->logger = $this->createMock(LoggerInterface::class); } @@ -76,6 +86,8 @@ protected function createService(array $methods = []) { $this->groupManager, $this->timeFactory, $this->container, + $this->mailer, + $this->l10nFactory, $this->logger ); } @@ -87,6 +99,8 @@ protected function createService(array $methods = []) { $this->groupManager, $this->timeFactory, $this->container, + $this->mailer, + $this->l10nFactory, $this->logger, ]); $mock->onlyMethods($methods); From 069f7a5702700e2770c62b47de7083a12373c014 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Thu, 17 Nov 2022 12:03:56 +0100 Subject: [PATCH 9/9] Fix review comments Signed-off-by: Joas Schilling --- lib/Service/RetentionService.php | 18 +++++++++++------- tests/Service/RetentionServiceTest.php | 14 +++++--------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/Service/RetentionService.php b/lib/Service/RetentionService.php index 231cc893..d4f0af12 100644 --- a/lib/Service/RetentionService.php +++ b/lib/Service/RetentionService.php @@ -28,7 +28,7 @@ use OC\Authentication\Token\Manager; use OC\Authentication\Token\PublicKeyToken; -use OCA\Guests\UserBackend; +use OCA\Guests\UserBackend as GuestUserBackend; use OCA\UserRetention\SkipUserException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IConfig; @@ -106,7 +106,7 @@ public function runCron(): void { $reminderDayOptions = explode(',', $reminderDaysString); foreach ($reminderDayOptions as $option) { $option = (int) trim($option); - if ($option !== 0) { + if ($option > 0) { $this->remindersPlain[] = $option; $this->reminders[] = $now->sub(new \DateInterval('P' . $option . 'D'))->getTimestamp(); } @@ -119,6 +119,7 @@ public function runCron(): void { $excludedGroups = json_decode($excludedGroups, true, 512, JSON_THROW_ON_ERROR); $this->excludedGroups = \is_array($excludedGroups) ? $excludedGroups : []; } catch (\JsonException $e) { + $this->logger->warning('User retention excluded groups is not a valid JSON array'); } if ($this->keepUsersWithoutLogin) { @@ -132,7 +133,7 @@ public function executeRetentionPolicy(IUser $user): ?bool { $this->logger->warning($user->getUID()); $skipIfNewerThan = $this->userMaxLastLogin; $policyDays = $this->userDays; - if ($user->getBackend() instanceof UserBackend) { + if ($user->getBackend() instanceof GuestUserBackend) { $skipIfNewerThan = $this->guestMaxLastLogin; $policyDays = $this->guestDays; } @@ -186,7 +187,6 @@ public function executeRetentionPolicy(IUser $user): ?bool { try { $lastActivity = $this->shouldPerformActionOnUser($user, $reminder, $reminder - 86400); - // FIXME send notification $this->sendReminder($user, $lastActivity, $policyDays); } catch (SkipUserException $e) { $this->logger->debug($e->getMessage(), $e->getLogParameters()); @@ -209,7 +209,11 @@ protected function shouldPerformActionOnUser(IUser $user, int $skipIfNewerThan, $lastWebLogin = $user->getLastLogin(); $authTokensLastActivity = $this->getAuthTokensLastActivity($user); - $lastAction = max($discoveryTimestamp, $lastWebLogin, $authTokensLastActivity); + if ($authTokensLastActivity === null) { + $lastAction = max($discoveryTimestamp, $lastWebLogin); + } else { + $lastAction = max($discoveryTimestamp, $lastWebLogin, $authTokensLastActivity); + } if ($this->keepUsersWithoutLogin && $lastAction === 0) { throw new SkipUserException( @@ -331,13 +335,13 @@ protected function sendReminder(IUser $user, int $lastActivity, int $policyDays) $template->addHeader(); $template->addHeading($l->t('Account deletion')); - $template->addBodyText(str_replace('{date}', $l->l('date', $lastActivity), $l->t('You have used your account since {date}.'))); + $template->addBodyText(str_replace('{date}', $l->l('date', $lastActivity), $l->t('You have not used your account since {date}.'))); $template->addBodyText($l->n( 'Due to the configured policy for accounts, inactive accounts will be deleted after %n day.', 'Due to the configured policy for accounts, inactive accounts will be deleted after %n days.', $policyDays )); - $template->addBodyText($l->t('To keep your account you only need to login or connect with a desktop or mobile app. Otherwise your account and all the connected data will be permanently deleted.')); + $template->addBodyText($l->t('To keep your account you only need to login with your browser or connect with a desktop or mobile app. Otherwise your account and all the connected data will be permanently deleted.')); $template->addBodyText($l->t('If you have any questions, please contact your administration.')); $template->addFooter(); diff --git a/tests/Service/RetentionServiceTest.php b/tests/Service/RetentionServiceTest.php index 978100ac..126ee49d 100644 --- a/tests/Service/RetentionServiceTest.php +++ b/tests/Service/RetentionServiceTest.php @@ -56,21 +56,13 @@ class RetentionServiceTest extends TestCase { protected function setUp(): void { parent::setUp(); - /** @var MockObject|IConfig */ $this->config = $this->createMock(IConfig::class); - /** @var MockObject|IUserManager */ $this->userManager = $this->createMock(IUserManager::class); - /** @var MockObject|IGroupManager */ $this->groupManager = $this->createMock(IGroupManager::class); - /** @var MockObject|ITimeFactory */ $this->timeFactory = $this->createMock(ITimeFactory::class); - /** @var MockObject|IServerContainer */ $this->container = $this->createMock(IServerContainer::class); - /** @var MockObject|IMailer */ $this->mailer = $this->createMock(IMailer::class); - /** @var MockObject|IFactory */ $this->l10nFactory = $this->createMock(IFactory::class); - /** @var MockObject|LoggerInterface */ $this->logger = $this->createMock(LoggerInterface::class); } @@ -142,6 +134,10 @@ public function dataShouldPerformActionOnUser(): array { [true, 99_999, 100_001, 99_999, 100, true], [true, 9, 9, 100_001, 100, true], [true, 99_999, 99_999, 100_001, 100, true], + + // Don't break with null on client response + [true, 100_001, 9, null, null, true], + [true, 9, 9, null, null, false, 9], ]; } @@ -153,7 +149,7 @@ public function dataShouldPerformActionOnUser(): array { * @param int $authTokenLastActivity * @param bool $expectsThrow */ - public function testShouldPerformActionOnUser(bool $skipWithoutLogin, int $discoveryTimestamp, int $lastLogin, int $authTokenLastActivity, ?int $skipOlderThan, bool $expectsThrow, ?int $expectedReturn = null): void { + public function testShouldPerformActionOnUser(bool $skipWithoutLogin, int $discoveryTimestamp, int $lastLogin, ?int $authTokenLastActivity, ?int $skipOlderThan, bool $expectsThrow, ?int $expectedReturn = null): void { /** @var MockObject|RetentionService $service */ $service = $this->createService(['skipUserBasedOnDiscovery', 'getAuthTokensLastActivity']); self::invokePrivate($service, 'keepUsersWithoutLogin', [$skipWithoutLogin]);