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/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. 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/lib/BackgroundJob/ExpireUsers.php b/lib/BackgroundJob/ExpireUsers.php index e3ac39b8..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,228 +24,25 @@ namespace OCA\UserRetention\BackgroundJob; -use OC\Authentication\Token\DefaultToken; -use OC\Authentication\Token\Manager; -use OC\BackgroundJob\TimedJob; -use OCA\Guests\UserBackend; +use OCA\UserRetention\Service\RetentionService; use OCP\AppFramework\Utility\ITimeFactory; -use OCP\IConfig; -use OCP\IGroupManager; -use OCP\ILogger; -use OCP\IServerContainer; -use OCP\IUser; -use OCP\IUserManager; -use OCP\LDAP\IDeletionFlagSupport; -use OCP\LDAP\ILDAPProvider; -use Psr\Log\LoggerInterface; +use OCP\BackgroundJob\TimedJob; -/** - * Class ExpireUsers - * - * @package OCA\UserRetention\BackgroundJob - */ class ExpireUsers extends TimedJob { - - /** @var IConfig */ - protected $config; - /** @var IUserManager */ - protected $userManager; - /** @var IGroupManager */ - protected $groupManager; - /** @var ITimeFactory */ - protected $timeFactory; - /** @var LoggerInterface */ - protected $logger; - /** @var IServerContainer */ - private $server; - - protected $userMaxLastLogin = 0; - protected $guestMaxLastLogin = 0; - protected $excludedGroups = []; - /** @var bool*/ - protected $keepUsersWithoutLogin = true; + protected RetentionService $service; public function __construct( - IConfig $config, - IUserManager $userManager, - IGroupManager $groupManager, - ITimeFactory $timeFactory, - IServerContainer $server, - LoggerInterface $logger + ITimeFactory $time, + RetentionService $service ) { - $this->config = $config; - $this->userManager = $userManager; - $this->groupManager = $groupManager; - $this->timeFactory = $timeFactory; - $this->server = $server; - $this->logger = $logger; + parent::__construct($time); + $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'); - } - - $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 : []; - - $handler = function(IUser $user) { - $maxLastLogin = $this->userMaxLastLogin; - if ($user->getBackend() instanceof UserBackend) { - $maxLastLogin = $this->guestMaxLastLogin; - } - - if ($this->shouldExpireUser($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'); - } - } - }; - - if ($this->keepUsersWithoutLogin) { - $this->userManager->callForSeenUsers($handler); - } else { - $this->userManager->callForAllUsers($handler); - } - } - - protected function shouldExpireUser(IUser $user, int $maxLastLogin): 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->timeFactory->getTime()); - $this->logger->debug('New user, saving 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; - } - - if ($maxLastLogin < $user->getLastLogin()) { - $this->logger->debug('Skipping user because of login time: {user}', [ - 'user' => $user->getUID(), - ]); - return false; - } - - if (!$this->allAuthTokensInactive($user, $maxLastLogin)) { - $this->logger->debug('Skipping user because of auth token 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 (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 allAuthTokensInactive(IUser $user, int $maxLastActivity): bool { - /** @var Manager $authTokenManager */ - $authTokenManager = $this->server->get(Manager::class); - /** @var DefaultToken[] $tokens */ - $tokens = $authTokenManager->getTokenByUser($user->getUID()); - - foreach ($tokens as $token) { - if ($maxLastActivity < $token->getLastActivity()) { - return false; - } - } - - return true; - } - - 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..d4f0af12 --- /dev/null +++ b/lib/Service/RetentionService.php @@ -0,0 +1,362 @@ + + * + * @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 as GuestUserBackend; +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) { + $this->logger->warning('User retention excluded groups is not a valid JSON array'); + } + + 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 GuestUserBackend) { + $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); + + $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); + + if ($authTokensLastActivity === null) { + $lastAction = max($discoveryTimestamp, $lastWebLogin); + } else { + $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 { + 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(), + ]); + + $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 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 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(); + + $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/Service/RetentionServiceTest.php b/tests/Service/RetentionServiceTest.php new file mode 100644 index 00000000..126ee49d --- /dev/null +++ b/tests/Service/RetentionServiceTest.php @@ -0,0 +1,257 @@ + + * + * @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 OCP\L10N\IFactory; +use OCP\Mail\IMailer; +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|IMailer */ + protected $mailer; + /** @var MockObject|IFactory */ + protected $l10nFactory; + /** @var MockObject|LoggerInterface */ + protected $logger; + + protected function setUp(): void { + parent::setUp(); + + $this->config = $this->createMock(IConfig::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->container = $this->createMock(IServerContainer::class); + $this->mailer = $this->createMock(IMailer::class); + $this->l10nFactory = $this->createMock(IFactory::class); + $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->mailer, + $this->l10nFactory, + $this->logger + ); + } + + $mock = $this->getMockBuilder(RetentionService::class); + $mock->setConstructorArgs([ + $this->config, + $this->userManager, + $this->groupManager, + $this->timeFactory, + $this->container, + $this->mailer, + $this->l10nFactory, + $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], + + // Don't break with null on client response + [true, 100_001, 9, null, null, true], + [true, 9, 9, null, null, false, 9], + ]; + } + + /** + * @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); + } + } +} 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 @@ + + + + . + + +