diff --git a/.github/workflows/check-coding-standards.yml b/.github/workflows/check-coding-standards.yml deleted file mode 100644 index 222aae0..0000000 --- a/.github/workflows/check-coding-standards.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Check Coding Standards - -on: - push: - paths: - - '**.php' - -jobs: - ci: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php - with: - php-version: '8.1' - extensions: mbstring, dom, fileinfo - - - name: Install Composer dependencies - run: composer update --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist - - - name: Coding Style Checks - run: composer test:lint - - - name: Type Checks - run: composer test:types - - - name: Type Coverage Checks - run: composer test:type-coverage diff --git a/.github/workflows/laravel.yml b/.github/workflows/laravel.yml new file mode 100644 index 0000000..45cff04 --- /dev/null +++ b/.github/workflows/laravel.yml @@ -0,0 +1,74 @@ +name: CI + +on: + push: + branches: [ 3.x, 4.x ] + pull_request: + branches: [ 3.x, 4.x ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + laravel: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: seatplus + POSTGRES_PASSWORD: secret + POSTGRES_DB: laravel + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, dom, fileinfo, pgsql, pdo_pgsql, redis + coverage: xdebug + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install Dependencies + run: composer install --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + + - name: Check Coding Standards + run: composer run test:lint + + - name: Static Analysis + run: composer run test:types + + - name: Type Coverage + run: composer run test:type-coverage + + - name: Run Tests + env: + XDEBUG_MODE: coverage + run: vendor/bin/pest --coverage --min=100 --colors=always diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index bc5e396..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Tests - -on: - push: - branches: [ 2.x, 3.x ] - pull_request: - branches: [ 2.x, 3.x ] - -jobs: - laravel: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - name: Setup PHP, with composer and extensions - uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php - with: - php-version: '8.1' - extensions: mbstring, dom, fileinfo - coverage: xdebug #optional - - uses: getong/mariadb-action@v1.1 - with: - host port: 3308 # Optional, default value is 3306. The port of host - mariadb version: '10.7' # Optional, default value is "latest". The version of the MariaDB - mysql database: 'testbench' # Optional, default value is "test". The specified database which will be create - mysql user: 'default' # Required if "mysql root password" is empty, default is empty. The superuser for the specified database. Can use secrets, too - mysql password: 'secret' # Required if "mysql user" exists. The password for the "mysql user" - - name: Redis Server in GitHub Actions - uses: supercharge/redis-github-action@1.1.0 - with: - # Redis version to use - redis-version: 5 # optional, default is latest - - name: Install Dependencies - run: composer install --no-ansi --no-interaction --no-scripts --prefer-dist - - name: Test & publish code coverage - uses: paambaati/codeclimate-action@v2.6.0 - env: - CC_TEST_REPORTER_ID: dfdd27f143f73ad7911f5393e3378b9b989cfed884bf522fa3b0d9fbc2b8d4c1 - with: - coverageCommand: vendor/bin/pest --coverage --ci - debug: false diff --git a/.gitignore b/.gitignore index 1c18527..3a1a1c8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,6 @@ build/ .php_cs .php_cs.cache .phpunit.result.cache +.phpunit.cache .php-cs-fixer.cache diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..268121d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +Upgrading to this version will require you to update your `auth` package to `^4.0.0`. +You are required to implement the `auth.login` and `auth.login` routes in your application. The `LoginController` and `LogoutController` have been removed from the package. + + +### Added +- Introduced LoginAssetAction to serve login assets +- Introduced LogoutAction to serve logout assets + +### Changed +- Switching main character has changed to use `PUT: auth/main-character/switch/{new_character_id}`. The correct route parameters are now required. + +### Fixed + +### Removed +- Removed login controller and route +- Removed logout controller and route + +## [4.0.0] - 2024-09-01 +### Added diff --git a/README.md b/README.md index 95f13e6..4505257 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,87 @@ -# auth -handels authentication for web and eveapi +# seatplus/auth -# Usage +[![CI](https://github.com/seatplus/auth/actions/workflows/laravel.yml/badge.svg)](https://github.com/seatplus/auth/actions/workflows/laravel.yml) -## Add more scopes -By default the minimal scopes are requested for users. However one might add scopes to an existing user by adding -a query parameters stating comma separated which scopes should be add: +Handles authentication, authorisation, and SSO scope compliance for the seatplus EVE Online management platform. This is the core package — `seatplus/eveapi` and `seatplus/web` both depend on it. + +## Overview + +### Role system + +Four role types with distinct membership and permission semantics: + +| Type | Membership | Use case | +|------|-----------|---------| +| `automatic` | Auto-assigned when a character belongs to a configured corporation or alliance | Fleet / alliance access | +| `on-request` | User applies, moderator approves or denies | Corp-specific elevated access | +| `manual` | Admin explicitly adds / removes individual users | One-off grants | +| `opt-in` | User self-joins if they meet the criteria | Opt-in programmes | + +### Affiliation system + +Every role has `Affiliation` records that define **permission scope** (which EVE entities the role holder can access data for), not membership. Three types: + +- `allowed` — these corporations / alliances / characters are in scope +- `inverse` — everyone *except* these is in scope +- `forbidden` — always excluded, overrides `allowed` / `inverse` + +### SSO scope compliance + +`IsUserCompliantService` checks whether every character owned by a user has all required OAuth scopes. Required scopes are aggregated from global settings, corporation-level `SsoScopes` records, and alliance-level records. Non-compliant users have their role memberships set to `inactive` automatically on the next `handleMembers()` call. + +### Permission checking + +`CanUserService::check()` runs a Laravel Pipeline to validate a set of EVE entity IDs against a user's permissions. The pipeline strips IDs the user owns, IDs covered by in-game corporation roles (e.g. Director), and IDs covered by Spatie permissions. Any remaining IDs are denied. The `superuser` permission bypasses all checks. + +## Installation + +```bash +composer require seatplus/auth +``` + +Publish and run migrations: + +```bash +php artisan vendor:publish --provider="Seatplus\Auth\AuthServiceProvider" +php artisan migrate ``` -/eve/sso/{character_id?}/step_up?add_scopes=scope1,scope2 + +## Usage + +### Add OAuth scopes to a character + +By default the minimal scopes are requested. To step up a character to additional scopes, redirect to: + +``` +/eve/sso/{character_id}/step_up?add_scopes=esi-skills.read_skills.v1,esi-wallet.read_character_wallet.v1 +``` + +### Check permissions + +```php +use Seatplus\Auth\Services\Dtos\ValidateIdsDTO; +use Seatplus\Auth\Services\CanUserService; + +$dto = ValidateIdsDTO::make(entity_ids: [12345678], user: $user); +CanUserService::check($user, $dto, permissions: ['view member tracking']); +``` + +## Development + +### Requirements + +- PHP 8.3+ +- PostgreSQL (user `seatplus`, password `secret`, database `laravel` @ `127.0.0.1:5432`) +- Redis @ `127.0.0.1:6379` + +### Running the test suite + +```bash +composer run test # lint + PHPStan + type-coverage + unit tests +composer run test:unit # unit tests only +composer run test:lint # Pint formatting check +composer run lint # auto-fix formatting with Pint +composer run test:types # PHPStan static analysis +composer run test:type-coverage # 100% type coverage check ``` + diff --git a/composer.json b/composer.json index 34b5b08..005f1fe 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,6 @@ "Seatplus\\Auth\\Database\\Factories\\": "database/factories/" }, "files": [ - "src/Helpers/helpers.php" ] }, "autoload-dev": { @@ -23,25 +22,42 @@ "Seatplus\\Auth\\Tests\\": "tests/" } }, - "minimum-stability": "stable", + "minimum-stability": "dev", "prefer-stable": true, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/seatplus/eveapi.git" + }, + { + "type": "vcs", + "url": "https://github.com/seatplus/esi-client.git" + }, + { + "type": "vcs", + "url": "https://github.com/seatplus/esi-schema.git" + } + ], "require": { - "php": "^8.1", - "laravel/framework": "^10.0", + "php": "^8.5", + "laravel/framework": "^13.0", "laravel/socialite": "^5.0", - "seatplus/eveapi": "^3.0", - "spatie/laravel-permission": "^5.4", + "seatplus/eveapi": "dev-chore/laravel-13-upgrade as 4.1.0", + "seatplus/esi-client": "dev-chore/php-8.5-upgrade as 4.1.0", + "spatie/laravel-permission": "^6.10", "socialiteproviders/eveonline": "^4.0" }, "require-dev": { - "orchestra/testbench": "^8.0", - "nunomaduro/collision": "^7.0", - "pestphp/pest": "^2.0", - "pestphp/pest-plugin-laravel": "^2.0", - "rector/rector": "^0.15.21", - "driftingly/rector-laravel": "^0.17.0", - "larastan/larastan": "^2.9", - "pestphp/pest-plugin-type-coverage": "^2.8" + "orchestra/testbench": "^11.0", + "nunomaduro/collision": "^8.1", + "pestphp/pest": "^4.0", + "pestphp/pest-plugin-laravel": "^4.1", + "pestphp/pest-plugin-type-coverage": "^4.0", + "phpstan/phpstan": "^2.0", + "rector/rector": "^2.0", + "driftingly/rector-laravel": "^2.0", + "larastan/larastan": "^3.0", + "laravel/pint": "^1.9" }, "extra": { "laravel": { @@ -67,6 +83,12 @@ "config": { "allow-plugins": { "pestphp/pest-plugin": true + }, + "audit": { + "ignore": [ + "PKSA-y2cr-5h3j-g3ys", + "PKSA-2kqm-ps5x-s4f5" + ] } } } diff --git a/config/auth.permissions.php b/config/auth.permissions.php new file mode 100644 index 0000000..eaddcac --- /dev/null +++ b/config/auth.permissions.php @@ -0,0 +1,6 @@ + Seatplus\Auth\Models\Permissions\Permission::class, + 'permission' => Permission::class, /* * When using the "HasRoles" trait from this package, we need to know which @@ -48,7 +27,7 @@ * `Spatie\Permission\Contracts\Role` contract. */ - 'role' => Seatplus\Auth\Models\Permissions\Role::class, + 'role' => Role::class, ], @@ -96,6 +75,11 @@ ], 'column_names' => [ + /* + * Change this if you want to name the related pivots other than defaults + */ + 'role_pivot_key' => null, // default 'role_id', + 'permission_pivot_key' => null, // default 'permission_id', /* * Change this if you want to name the related model primary key other than @@ -106,16 +90,79 @@ */ 'model_morph_key' => 'model_id', + + /* + * Change this if you want to use the teams feature and your related model's + * foreign key is other than `team_id`. + */ + + 'team_foreign_key' => 'team_id', ], /* - * When set to true, the required permission/role names are added to the exception - * message. This could be considered an information leak in some contexts, so - * the default setting is false here for optimum safety. + * When set to true, the method for checking permissions will be registered on the gate. + * Set this to false if you want to implement custom logic for checking permissions. + */ + + 'register_permission_check_method' => true, + + /* + * When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered + * this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated + * NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it. + */ + 'register_octane_reset_listener' => false, + + /* + * Teams Feature. + * When set to true the package implements teams using the 'team_foreign_key'. + * If you want the migrations to register the 'team_foreign_key', you must + * set this to true before doing the migration. + * If you already did the migration then you must make a new migration to also + * add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions' + * (view the latest version of this package's migration file) + */ + + 'teams' => false, + + /* + * Passport Client Credentials Grant + * When set to true the package will use Passports Client to check permissions + */ + + 'use_passport_client_credentials' => false, + + /* + * When set to true, the required permission names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. */ 'display_permission_in_exception' => false, + /* + * When set to true, the required role names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_role_in_exception' => false, + + /* + * By default wildcard permission lookups are disabled. + * See documentation to understand supported syntax. + */ + + 'enable_wildcard_permission' => false, + + /* + * The class to use for interpreting wildcard permissions. + * If you need to modify delimiters, override the class and specify its name here. + */ + // 'permission.wildcard_permission' => Spatie\Permission\WildcardPermission::class, + + /* Cache-specific settings */ + 'cache' => [ /* @@ -123,7 +170,7 @@ * When permissions or roles are updated the cache is flushed automatically. */ - 'expiration_time' => \DateInterval::createFromDateString('24 hours'), + 'expiration_time' => DateInterval::createFromDateString('24 hours'), /* * The cache key used to store all permissions. @@ -131,17 +178,6 @@ 'key' => 'spatie.permission.cache', - /* - * When checking for a permission against a model by passing a Permission - * instance to the check, this key determines what attribute on the - * Permissions model is used to cache against. - * - * Ideally, this should match your preferred way of checking permissions, eg: - * `$user->can('view-posts')` would be 'name'. - */ - - 'model_key' => 'name', - /* * You may optionally indicate a specific cache driver to use for permission and * role caching using any of the `store` drivers listed in the cache.php config diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 04c6e05..515c694 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -27,6 +27,7 @@ namespace Seatplus\Auth\Database\Factories; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Str; use Seatplus\Auth\Models\CharacterUser; use Seatplus\Auth\Models\User; use Seatplus\Eveapi\Models\Character\CharacterInfo; @@ -47,6 +48,7 @@ public function definition(): array return [ 'main_character_id' => CharacterInfo::factory(), 'active' => true, + 'remember_token' => Str::random(10), ]; } } diff --git a/database/migrations/2024_07_15_183306_migrate_acl.php b/database/migrations/2024_07_15_183306_migrate_acl.php new file mode 100644 index 0000000..0b93486 --- /dev/null +++ b/database/migrations/2024_07_15_183306_migrate_acl.php @@ -0,0 +1,75 @@ +createTables(); + + $this->migrateData(); + + $this->dropTables(); + } + + private function createTables(): void + { + Schema::create('role_memberships', function (Blueprint $table) { + $table->id(); + $table->unsignedInteger('role_id'); + $table->foreign('role_id')->references('id')->on('roles')->onDelete('cascade'); + $table->morphs('entity'); + $table->boolean('can_moderate')->default(false); + $table->string('status')->nullable()->default(null); + $table->timestamps(); + }); + + Schema::table('affiliations', function (Blueprint $table) { + $table->unsignedInteger('role_id')->change(); + $table->foreign('role_id')->references('id')->on('roles')->onDelete('cascade'); + }); + } + + private function migrateData(): void + { + DB::table('acl_affiliations')->get()->each(function ($acl_affiliation) { + DB::table('role_memberships')->insert([ + 'role_id' => $acl_affiliation->role_id, + 'entity_type' => $acl_affiliation->affiliatable_type, + 'entity_id' => $acl_affiliation->affiliatable_id, + 'can_moderate' => $acl_affiliation->can_moderate, + 'status' => $acl_affiliation->affiliatable_type === User::class ? 'member' : null, + 'created_at' => $acl_affiliation->created_at, + 'updated_at' => $acl_affiliation->updated_at, + ]); + }); + + DB::table('acl_members')->get()->each(function ($acl_member) { + DB::table('role_memberships')->insert([ + 'role_id' => $acl_member->role_id, + 'entity_type' => User::class, + 'entity_id' => $acl_member->user_id, + 'can_moderate' => false, + 'status' => $acl_member->status, + 'created_at' => $acl_member->created_at, + 'updated_at' => $acl_member->updated_at, + ]); + }); + } + + private function dropTables(): void + { + Schema::dropIfExists('acl_affiliations'); + Schema::dropIfExists('acl_members'); + } +}; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 666f5fd..535cc33 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,21 +1,67 @@ parameters: ignoreErrors: - - message: "#^Consider using bind method instead or pass a closure\\.$#" + message: '#^Called ''env'' outside of the config directory which returns null when the config is cached, use ''config''\.$#' + identifier: larastan.noEnvCallsOutsideOfConfig + count: 3 + path: config/auth.services.php + + - + message: '#^Method Seatplus\\Auth\\Database\\Factories\\UserFactory\:\:configure\(\) should return \$this\(Seatplus\\Auth\\Database\\Factories\\UserFactory\) but returns static\(Seatplus\\Auth\\Database\\Factories\\UserFactory\)\.$#' + identifier: return.type + count: 1 + path: database/factories/UserFactory.php + + - + message: '#^Consider using bind method instead or pass a closure\.$#' + identifier: larastan.octaneCompatibility count: 1 path: src/AuthenticationServiceProvider.php - - message: "#^Call to static method render\\(\\) on an unknown class Inertia\\\\Inertia\\.$#" + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$characters\.$#' + identifier: property.notFound count: 1 - path: src/Http/Controllers/Auth/LoginController.php + path: src/Http/Actions/Sso/FindOrCreateUserAction.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$main_character_id\.$#' + identifier: property.notFound + count: 1 + path: src/Http/Actions/Sso/FindOrCreateUserAction.php + + - + message: '#^Property Seatplus\\Auth\\Http\\Actions\\Sso\\FindOrCreateUserAction\:\:\$user \(Seatplus\\Auth\\Models\\User\) does not accept Illuminate\\Database\\Eloquent\\Model\|null\.$#' + identifier: assign.propertyType + count: 1 + path: src/Http/Actions/Sso/FindOrCreateUserAction.php + + - + message: '#^Using nullsafe property access "\?\-\>scopes" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull + count: 1 + path: src/Http/Controllers/Auth/StepUpController.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$active\.$#' + identifier: property.notFound + count: 1 + path: src/Observers/CharacterAffiliationObserver.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$characters\.$#' + identifier: property.notFound + count: 2 + path: src/Observers/CharacterAffiliationObserver.php - - message: "#^Method Seatplus\\\\Auth\\\\Http\\\\Controllers\\\\Auth\\\\LoginController\\:\\:showLoginForm\\(\\) has invalid return type Inertia\\\\Response\\.$#" + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$main_character_id\.$#' + identifier: property.notFound count: 1 - path: src/Http/Controllers/Auth/LoginController.php + path: src/Observers/CharacterAffiliationObserver.php - - message: "#^Method Seatplus\\\\Auth\\\\Http\\\\Middleware\\\\CheckPermissionAndAffiliation\\:\\:checkPermission\\(\\) is unused\\.$#" + message: '#^Property Seatplus\\Auth\\Services\\Roles\\BaseRoleService\:\:\$role \(Seatplus\\Auth\\Models\\Permissions\\Role\|null\) does not accept Spatie\\Permission\\Contracts\\Role\.$#' + identifier: assign.propertyType count: 1 - path: src/Http/Middleware/CheckPermissionAndAffiliation.php + path: src/Services/Roles/BaseRoleService.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 9aee2c3..83ece64 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -3,6 +3,7 @@ includes: - vendor/larastan/larastan/extension.neon parameters: + editorUrl: 'phpstorm://open?file=%%file%%&line=%%line%%' databaseMigrationsPath: - database/migrations - vendor/seatplus/eveapi/database/migrations diff --git a/phpunit.xml b/phpunit.xml index 3180dc8..051873c 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -16,10 +16,11 @@ + - - - + + + diff --git a/rector.php b/rector.php index e1fb92a..1e70eef 100644 --- a/rector.php +++ b/rector.php @@ -10,7 +10,7 @@ // here we can define, what sets of rules will be applied // tip: use "SetList" class to autocomplete sets $rectorConfig->sets([ - //SetList::CODE_QUALITY, + // SetList::CODE_QUALITY, LaravelSetList::LARAVEL_100, LaravelSetList::LARAVEL_CODE_QUALITY, ]); @@ -21,5 +21,5 @@ $rectorConfig->phpVersion(PhpVersion::PHP_81); // register single rule - //$rectorConfig->rule(TypedPropertyRector::class); + // $rectorConfig->rule(TypedPropertyRector::class); }; diff --git a/src/Models/AccessControl/AclMember.php b/resources/lang/en/auth.php similarity index 68% rename from src/Models/AccessControl/AclMember.php rename to resources/lang/en/auth.php index 6c1db79..7f33393 100644 --- a/src/Models/AccessControl/AclMember.php +++ b/resources/lang/en/auth.php @@ -24,26 +24,7 @@ * SOFTWARE. */ -namespace Seatplus\Auth\Models\AccessControl; - -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Seatplus\Auth\Models\Permissions\Role; -use Seatplus\Auth\Models\User; - -class AclMember extends Model -{ - public $incrementing = false; - - protected $guarded = []; - - public function user(): BelongsTo - { - return $this->belongsTo(User::class, 'user_id'); - } - - public function role(): BelongsTo - { - return $this->belongsTo(Role::class, 'role_id'); - } -} +return [ + 'sso_config_warning' => 'SSO does not appear to have been configured yet. Please check your .env file.', + 'login_welcome' => 'Welcome, please login using EVE Online SSO', +]; diff --git a/routes/routes.php b/routes/routes.php index 6ba1e89..00e3ac0 100644 --- a/routes/routes.php +++ b/routes/routes.php @@ -25,26 +25,34 @@ */ use Illuminate\Support\Facades\Route; -use Seatplus\Auth\Http\Controllers\Auth\LoginController; -use Seatplus\Auth\Http\Controllers\Auth\SsoController; +use Seatplus\Auth\Http\Controllers\Auth\CallbackController; +use Seatplus\Auth\Http\Controllers\Auth\RedirectSSOController; use Seatplus\Auth\Http\Controllers\Auth\StepUpController; -use Seatplus\Auth\Http\Controllers\MainCharacterController; +use Seatplus\Auth\Http\Controllers\SwitchMainCharacterController; -Route::prefix('auth') - ->middleware('web') +Route::middleware('web') ->group(function () { - // Auth - Route::get('login', [LoginController::class, 'showLoginForm'])->name('auth.login'); - Route::get('logout', [LoginController::class, 'logout'])->name('auth.logout'); + // auth/eve/callback + // auth/eve/redirect + // auth/eve/step-up/{character_id} + // auth/main-character/switch/{new_character_id} + + // Auth + Route::prefix('auth') + ->group(function () { - // SSO - Route::get('/eve/sso/', [SsoController::class, 'redirectToProvider'])->name('auth.eve'); - Route::get('/eve/sso/{character_id}/step_up', StepUpController::class)->name('auth.eve.step_up'); + // SSO + Route::prefix('eve') + ->group(function () { + Route::get('sso', RedirectSSOController::class)->name('auth.eve'); + Route::get('sso/{character_id}/step_up', StepUpController::class)->name('auth.eve.step_up'); + Route::get('callback', CallbackController::class)->name('auth.eve.callback'); // do not change this route /auth/eve/callback - this is registered in eve application + }); - Route::get('/eve/callback', [SsoController::class, 'handleProviderCallback'])->name('auth.eve.callback'); + // MainCharacter + Route::put('main-character/switch/{new_character_id}', SwitchMainCharacterController::class) + ->name('change.main_character'); + }); - // MainCharacter - Route::post('main_character/change', [MainCharacterController::class, 'change']) - ->name('change.main_character'); }); diff --git a/src/Actions/GetAffiliatedIdsByPermissionArray.php b/src/Actions/GetAffiliatedIdsByPermissionArray.php deleted file mode 100644 index b8d8f8e..0000000 --- a/src/Actions/GetAffiliatedIdsByPermissionArray.php +++ /dev/null @@ -1,153 +0,0 @@ -cache_key; - } - - public function __construct(private string $permission, private string $corporation_role = '') - { - $this->user = User::find(auth()->id()); - $this->cache_key = "affiliated character ids by permission {$this->user->id} for user wit user_id: {$this->permission}"; - } - - public function execute(): array - { - try { - return cache($this->cache_key) ?? $this->getResult(); - } catch (\Exception $e) { - throw $e; - } - } - - /** - * @throws \Exception - */ - private function getResult(): array - { - $affiliated_ids = $this->getAffiliatedIds()->toArray(); - - cache([$this->cache_key => $affiliated_ids], now()->addMinutes(5)); - - return $affiliated_ids; - } - - private function getAffiliatedIds(): Collection - { - if ($this->user->can('superuser')) { - return $this->getAllCharacterAndCorporationIds(); - } - - $user = User::with( - [ - 'roles.permissions', - //'roles.affiliations.affiliatable.characters' => fn ($query) => $query->has('characters')->select('character_infos.character_id'), - 'roles.affiliations.affiliatable' => fn (MorphTo $morph_to) => $morph_to->morphWith([CorporationInfo::class => 'characters', AllianceInfo::class => ['characters', 'corporations']]), - ] - )->whereHas('roles.permissions', function (Builder $query) { - $query->where('name', $this->permission); - }) - ->where('id', $this->user->id) - ->first(); - - // if authenticated user has no roles, make sure to skip the roles access - $affiliated_ids = ! $user ? collect() : $user->roles->map(fn (Role $role) => $role->affiliated_ids); - - // before returning add the owned character ids - return $affiliated_ids->merge($this->buildOwnedIds()) - ->flatten() - ->unique(); - } - - private function getAllCharacterAndCorporationIds(): Collection - { - $all_ids = collect(); - - CharacterInfo::query()->cursor()->each(fn (CharacterInfo $character) => $all_ids->push($character->character_id)); - CorporationInfo::query()->cursor()->each(fn (CorporationInfo $corporation) => $all_ids->push($corporation->corporation_id)); - - return $all_ids; - } - - private function buildOwnedIds(): Collection - { - //$this->user->characters->pluck('character_id'); - // TODO: optimize and load user initially with necessairy relations - - return User::whereId($this->user->getAuthIdentifier()) - ->with('characters.roles', 'characters.corporation') - ->get() - ->whenNotEmpty( - fn (Collection $collection) => $collection - ->first() - ->characters - // for owned corporation tokens, we need to add the affiliation as long as the character has the required role - ->map(fn (CharacterInfo $character) => [$this->getCorporationId($character), $character->character_id]) - ->flatten() - ->filter() - ) - ->flatten()->unique(); - } - - private function getCorporationId(CharacterInfo $character): ?int - { - if (! $this->corporation_role || ! $character->roles) { - return null; - } - - $roles = explode('|', $this->corporation_role); - - foreach ($roles as $role) { - if ($character->roles->hasRole('roles', Str::ucfirst($role))) { - return $character->corporation->corporation_id; - } - } - - return null; - } -} diff --git a/src/AuthenticationServiceProvider.php b/src/AuthenticationServiceProvider.php index 89ed714..40d9d88 100644 --- a/src/AuthenticationServiceProvider.php +++ b/src/AuthenticationServiceProvider.php @@ -32,6 +32,8 @@ use Laravel\Socialite\SocialiteManager; use Seatplus\Auth\Listeners\ReactOnFreshRefreshToken; use Seatplus\Auth\Listeners\UpdatingRefreshTokenListener; +use Seatplus\Auth\Models\Permissions\Permission; +use Seatplus\Auth\Models\Permissions\Role; use Seatplus\Auth\Models\User; use Seatplus\Auth\Observers\ApplicationObserver; use Seatplus\Auth\Observers\CharacterAffiliationObserver; @@ -50,7 +52,7 @@ class AuthenticationServiceProvider extends ServiceProvider { public function boot(): void { - //Add Migrations + // Add Migrations $this->loadMigrationsFrom(__DIR__.'/../database/migrations/'); // Add routes @@ -59,6 +61,9 @@ public function boot(): void // Add event listeners $this->addEventListeners(); + // Add translations + $this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'auth'); + // Add GateLogic Gate::before(function (User $user, string $ability): ?bool { try { @@ -89,16 +94,20 @@ public function register(): void $socialite->extend( 'eveonline', function (Container $app) use ($socialite) { - $config = $app['config']['services.eveonline']; + $config = config('services.eveonline'); return $socialite->buildProvider(Provider::class, $config); } ); - $this->mergeConfigFrom(__DIR__.'/../config/permission.php', 'permission'); $this->mergeConfigFrom(__DIR__.'/../config/auth.updateJobs.php', 'seatplus.updateJobs'); $this->mergeConfigFrom(__DIR__.'/../config/auth.services.php', 'services'); + config()->set('permission.models', [ + 'permission' => Permission::class, + 'role' => Role::class, + ]); + $this->setUserModel(); } diff --git a/src/DataTransferObjects/CheckPermissionAffiliationDto.php b/src/DataTransferObjects/CheckPermissionAffiliationDto.php deleted file mode 100644 index f93e1b3..0000000 --- a/src/DataTransferObjects/CheckPermissionAffiliationDto.php +++ /dev/null @@ -1,31 +0,0 @@ -validated_ids = collect(); - } - - public function allIdsValidated(): bool - { - $different_ids = $this->requested_ids->diff($this->validated_ids); - - return $different_ids->isEmpty(); - } - - public function mergeValidatedIds(array|Collection $validatedIds): void - { - $this->validated_ids = $this->validated_ids - ->merge($validatedIds) - ->unique(); - } -} diff --git a/src/Enums/AffiliationType.php b/src/Enums/AffiliationType.php index 8da9700..c37f8e2 100644 --- a/src/Enums/AffiliationType.php +++ b/src/Enums/AffiliationType.php @@ -2,26 +2,9 @@ namespace Seatplus\Auth\Enums; -enum AffiliationType +enum AffiliationType: string { - case ALLOWED; - case INVERSE; - case FORBIDDEN; - - public function operator(): string - { - return match ($this) { - self::ALLOWED, self::FORBIDDEN => '=', - self::INVERSE => '=' - }; - } - - public function value(): string - { - return match ($this) { - self::ALLOWED => 'allowed', - self::FORBIDDEN => 'forbidden', - self::INVERSE => 'inverse' - }; - } + case ALLOWED = 'allowed'; + case INVERSE = 'inverse'; + case FORBIDDEN = 'forbidden'; } diff --git a/src/Enums/RoleMembershipStatus.php b/src/Enums/RoleMembershipStatus.php new file mode 100644 index 0000000..11d2af3 --- /dev/null +++ b/src/Enums/RoleMembershipStatus.php @@ -0,0 +1,10 @@ +execute(); - } catch (Exception $exception) { - report($exception); - $ids = []; - } - - return $ids; - } -} diff --git a/src/Http/Actions/LoginAssetsAction.php b/src/Http/Actions/LoginAssetsAction.php new file mode 100644 index 0000000..87bd337 --- /dev/null +++ b/src/Http/Actions/LoginAssetsAction.php @@ -0,0 +1,30 @@ +flash('warning', trans('auth::auth.sso_config_warning')); + } + + return [ + 'login_welcome' => trans('auth::auth.login_welcome'), + 'evesso_img_src' => asset('img/evesso.png'), + ]; + } +} diff --git a/src/Http/Actions/LogoutAction.php b/src/Http/Actions/LogoutAction.php new file mode 100644 index 0000000..3e1db98 --- /dev/null +++ b/src/Http/Actions/LogoutAction.php @@ -0,0 +1,19 @@ +logout(); + + $session = session(); + $session->invalidate(); + $session->regenerateToken(); + + return redirect('/'); + } +} diff --git a/src/Http/Actions/Roles/AddModeratorRoleAction.php b/src/Http/Actions/Roles/AddModeratorRoleAction.php new file mode 100644 index 0000000..970fd49 --- /dev/null +++ b/src/Http/Actions/Roles/AddModeratorRoleAction.php @@ -0,0 +1,18 @@ +setModerator->execute($role_id, $user_id, true); + } +} diff --git a/src/Http/Actions/Roles/ManageAutomaticRoleAction.php b/src/Http/Actions/Roles/ManageAutomaticRoleAction.php new file mode 100644 index 0000000..4d21560 --- /dev/null +++ b/src/Http/Actions/Roles/ManageAutomaticRoleAction.php @@ -0,0 +1,63 @@ +checkPermission(); + + $validated = $request->validated(); + $roleService = $this->baseRoleService->for($validated['role_id'])->automatic(); + + // setRoleType first: if the type changes it calls resetRoleMemberships(), + // which would wipe any criteria written below. + $roleService->setRoleType(RoleType::AUTOMATIC); + + if ($name = Arr::get($validated, 'name')) { + $roleService->updateRoleName($name); + } + + if (is_array($affiliated = Arr::get($validated, 'affiliated'))) { + $roleService->syncAffiliateManyEntities( + ...array_map(fn (array $affiliationData) => AffiliationData::fromArray($affiliationData), $affiliated) + ); + } + + if (is_array($assigned = Arr::get($validated, 'assigned'))) { + $roleService->automaticallyAssignRoleTo( + ...array_map(fn (array $criteriaData) => CriteriaData::fromArray($criteriaData), $assigned) + ); + } + + $roleService->handleMembers(); + } + + private function checkPermission(): void + { + $auth = auth()->user(); + + throw_unless($auth, \Exception::class, 'User not authenticated'); + + if (! auth()->user()->can('administrate access control groups')) { + abort(403, 'You are not allowed to administrate access control groups'); + } + } +} diff --git a/src/Http/Actions/Roles/Manual/AddMemberAction.php b/src/Http/Actions/Roles/Manual/AddMemberAction.php new file mode 100644 index 0000000..5485662 --- /dev/null +++ b/src/Http/Actions/Roles/Manual/AddMemberAction.php @@ -0,0 +1,18 @@ +setMember->execute($role_id, $user_id, true); + } +} diff --git a/src/Http/Actions/Roles/Manual/ManageManualRoleAction.php b/src/Http/Actions/Roles/Manual/ManageManualRoleAction.php new file mode 100644 index 0000000..70952d7 --- /dev/null +++ b/src/Http/Actions/Roles/Manual/ManageManualRoleAction.php @@ -0,0 +1,41 @@ +validated(); + $roleService = $this->baseRoleService->for($validated['role_id'])->manual(); + + $roleService->setRoleType(RoleType::MANUAL); + + if ($name = Arr::get($validated, 'name')) { + $roleService->updateRoleName($name); + } + + if (is_array($affiliated = Arr::get($validated, 'affiliated'))) { + $roleService->syncAffiliateManyEntities( + ...array_map(fn (array $affiliationData) => AffiliationData::fromArray($affiliationData), $affiliated) + ); + } + + $roleService->handleMembers(); + } +} diff --git a/src/Http/Actions/Roles/Manual/RemoveMemberAction.php b/src/Http/Actions/Roles/Manual/RemoveMemberAction.php new file mode 100644 index 0000000..311b736 --- /dev/null +++ b/src/Http/Actions/Roles/Manual/RemoveMemberAction.php @@ -0,0 +1,18 @@ +setMember->execute($role_id, $user_id, false); + } +} diff --git a/src/Http/Actions/Roles/Manual/SetMemberAction.php b/src/Http/Actions/Roles/Manual/SetMemberAction.php new file mode 100644 index 0000000..05fcef2 --- /dev/null +++ b/src/Http/Actions/Roles/Manual/SetMemberAction.php @@ -0,0 +1,45 @@ +baseRoleService->for($role_id); + $this->checkPermission(); + + $roleService = $this->baseRoleService->manual(); + + /** @var User $user */ + $user = User::query()->findOrFail($user_id); + + match ($is_member) { + true => $roleService->addMember($user), + false => $roleService->removeMember($user) + }; + } + + private function checkPermission(): void + { + /* @var User $user */ + $user = auth()->user(); + + $can_moderate = $this->baseRoleService->canModerate($user); + + if (! $can_moderate) { + abort(403, 'You are not allowed to do this action'); + } + + } +} diff --git a/src/Http/Actions/Roles/OnRequest/ApplyAction.php b/src/Http/Actions/Roles/OnRequest/ApplyAction.php new file mode 100644 index 0000000..b33d599 --- /dev/null +++ b/src/Http/Actions/Roles/OnRequest/ApplyAction.php @@ -0,0 +1,26 @@ +baseRoleService->for($role_id)->onRequest(); + + /** @var User $user */ + $user = User::query()->findOrFail($user_id); + + $roleService->submitApplicationForRole($user); + } +} diff --git a/src/Http/Actions/Roles/OnRequest/ApproveAction.php b/src/Http/Actions/Roles/OnRequest/ApproveAction.php new file mode 100644 index 0000000..14e34a5 --- /dev/null +++ b/src/Http/Actions/Roles/OnRequest/ApproveAction.php @@ -0,0 +1,28 @@ +baseRoleService = $baseRoleService ?? new BaseRoleService; + } + + /** + * @throws \Throwable + */ + public function execute(int $role_id, int $user_id): void + { + $roleService = $this->baseRoleService->for($role_id)->onRequest(); + + /** @var User $user */ + $user = User::query()->findOrFail($user_id); + + $roleService->approveApplicationForRole($user); + } +} diff --git a/src/Http/Actions/Roles/OnRequest/DenyAction.php b/src/Http/Actions/Roles/OnRequest/DenyAction.php new file mode 100644 index 0000000..0275c10 --- /dev/null +++ b/src/Http/Actions/Roles/OnRequest/DenyAction.php @@ -0,0 +1,28 @@ +baseRoleService = $baseRoleService ?? new BaseRoleService; + } + + /** + * @throws \Throwable + */ + public function execute(int $role_id, int $user_id): void + { + $roleService = $this->baseRoleService->for($role_id)->onRequest(); + + /** @var User $user */ + $user = User::query()->findOrFail($user_id); + + $roleService->denyApplication($user); + } +} diff --git a/src/Http/Actions/Roles/OnRequest/ManageOnRequestRoleAction.php b/src/Http/Actions/Roles/OnRequest/ManageOnRequestRoleAction.php new file mode 100644 index 0000000..efe1df3 --- /dev/null +++ b/src/Http/Actions/Roles/OnRequest/ManageOnRequestRoleAction.php @@ -0,0 +1,57 @@ +checkPermission(); + + $validated = $request->validated(); + $roleService = $this->baseRoleService->for($validated['role_id'])->onRequest(); + + $roleService->setRoleType(RoleType::ON_REQUEST); + + if ($name = Arr::get($validated, 'name')) { + $roleService->updateRoleName($name); + } + + if (is_array($affiliated = Arr::get($validated, 'affiliated'))) { + $roleService->syncAffiliateManyEntities( + ...array_map(fn (array $affiliationData) => AffiliationData::fromArray($affiliationData), $affiliated) + ); + } + + if (is_array($assigned = Arr::get($validated, 'assigned'))) { + $roleService->addCriteriaForRoleApplication( + ...array_map(fn (array $criteriaData) => CriteriaData::fromArray($criteriaData), $assigned) + ); + } + + $roleService->handleMembers(); + } + + private function checkPermission(): void + { + if (! auth()->user()->can('administrate access control groups')) { + abort(403, 'You are not allowed to administrate access control groups'); + } + } +} diff --git a/src/Http/Actions/Roles/OnRequest/OptOutAction.php b/src/Http/Actions/Roles/OnRequest/OptOutAction.php new file mode 100644 index 0000000..aaa1e1c --- /dev/null +++ b/src/Http/Actions/Roles/OnRequest/OptOutAction.php @@ -0,0 +1,26 @@ +baseRoleService->for($role_id)->onRequest(); + + /** @var User $user */ + $user = User::query()->findOrFail($user_id); + + $roleService->removeApplication($user); + } +} diff --git a/src/Http/Actions/Roles/OptIn/JoinAction.php b/src/Http/Actions/Roles/OptIn/JoinAction.php new file mode 100644 index 0000000..6d88892 --- /dev/null +++ b/src/Http/Actions/Roles/OptIn/JoinAction.php @@ -0,0 +1,28 @@ +baseRoleService = $baseRoleService ?? new BaseRoleService; + } + + /** + * @throws \Throwable + */ + public function execute(int $role_id, int $user_id): void + { + $roleService = $this->baseRoleService->for($role_id)->optIn(); + + /** @var User $user */ + $user = User::query()->findOrFail($user_id); + + $roleService->joinRole($user); + } +} diff --git a/src/Http/Actions/Roles/OptIn/LeaveAction.php b/src/Http/Actions/Roles/OptIn/LeaveAction.php new file mode 100644 index 0000000..36ea2e2 --- /dev/null +++ b/src/Http/Actions/Roles/OptIn/LeaveAction.php @@ -0,0 +1,28 @@ +baseRoleService = $baseRoleService ?? new BaseRoleService; + } + + /** + * @throws \Throwable + */ + public function execute(int $role_id, int $user_id): void + { + $roleService = $this->baseRoleService->for($role_id)->optIn(); + + /** @var User $user */ + $user = User::query()->findOrFail($user_id); + + $roleService->leaveRole($user); + } +} diff --git a/src/Http/Actions/Roles/OptIn/ManageOptInRoleAction.php b/src/Http/Actions/Roles/OptIn/ManageOptInRoleAction.php new file mode 100644 index 0000000..325b350 --- /dev/null +++ b/src/Http/Actions/Roles/OptIn/ManageOptInRoleAction.php @@ -0,0 +1,57 @@ +checkPermission(); + + $validated = $request->validated(); + $roleService = $this->baseRoleService->for($validated['role_id'])->optIn(); + + $roleService->setRoleType(RoleType::OPT_IN); + + if ($name = Arr::get($validated, 'name')) { + $roleService->updateRoleName($name); + } + + if (is_array($affiliated = Arr::get($validated, 'affiliated'))) { + $roleService->syncAffiliateManyEntities( + ...array_map(fn (array $affiliationData) => AffiliationData::fromArray($affiliationData), $affiliated) + ); + } + + if (is_array($assigned = Arr::get($validated, 'assigned'))) { + $roleService->addCriteriaForRole( + ...array_map(fn (array $criteriaData) => CriteriaData::fromArray($criteriaData), $assigned) + ); + } + + $roleService->handleMembers(); + } + + private function checkPermission(): void + { + if (! auth()->user()->can('administrate access control groups')) { + abort(403, 'You are not allowed to administrate access control groups'); + } + } +} diff --git a/src/Http/Actions/Roles/RemoveModeratorRoleAction.php b/src/Http/Actions/Roles/RemoveModeratorRoleAction.php new file mode 100644 index 0000000..856ab24 --- /dev/null +++ b/src/Http/Actions/Roles/RemoveModeratorRoleAction.php @@ -0,0 +1,18 @@ +action->execute($role_id, $user_id, false); + } +} diff --git a/src/Http/Actions/Roles/SetModeratorAction.php b/src/Http/Actions/Roles/SetModeratorAction.php new file mode 100644 index 0000000..1d25b02 --- /dev/null +++ b/src/Http/Actions/Roles/SetModeratorAction.php @@ -0,0 +1,53 @@ +baseRoleService->for($role_id); + $this->checkPermission(); + + /** @var OnRequestRoleService|ManualRoleService|OptInRoleService $roleService */ + $roleService = $this->baseRoleService->getTypeService(); + + $this->validateRoleType($roleService); + + /** @var User $user */ + $user = User::query()->findOrFail($user_id); + + $roleService->setModerator($user, $can_moderate); + } + + private function checkPermission(): void + { + /* @var User $user */ + $user = auth()->user(); + + $can_moderate = $this->baseRoleService->canModerate($user); + + if (! $can_moderate) { + abort(403, 'You are not allowed to add moderators'); + } + + } + + private function validateRoleType(AbstractRoleService $roleService): void + { + if (! $roleService instanceof ManualRoleService && ! $roleService instanceof OnRequestRoleService && ! $roleService instanceof OptInRoleService) { + abort(403, 'This action is not allowed'); + } + } +} diff --git a/src/Http/Actions/Sso/UpdateRefreshTokenAction.php b/src/Http/Actions/Sso/UpdateRefreshTokenAction.php index efeae9b..6fe7ef5 100644 --- a/src/Http/Actions/Sso/UpdateRefreshTokenAction.php +++ b/src/Http/Actions/Sso/UpdateRefreshTokenAction.php @@ -53,6 +53,6 @@ public function __invoke(EveUser $eve_data) $refresh_token->restore(); } - //TODO: if user was deactivated reactivate him https://github.com/eveseat/web/blob/a0c1dd6a73c10e91813276cd57b5b51460bdfc43/src/Http/Controllers/Auth/SsoController.php#L264 + // TODO: if user was deactivated reactivate him https://github.com/eveseat/web/blob/a0c1dd6a73c10e91813276cd57b5b51460bdfc43/src/Http/Controllers/Auth/SsoController.php#L264 } } diff --git a/src/Http/Controllers/Auth/CallbackController.php b/src/Http/Controllers/Auth/CallbackController.php new file mode 100644 index 0000000..6ca31c6 --- /dev/null +++ b/src/Http/Controllers/Auth/CallbackController.php @@ -0,0 +1,100 @@ +driver('eveonline')->user(); + + $eve_data = new EveUser( + character_id: data_get($socialite_user, 'attributes.character_id'), + character_owner_hash: data_get($socialite_user, 'attributes.character_owner_hash'), + token: data_get($socialite_user, 'token'), + refreshToken: data_get($socialite_user, 'refreshToken'), + expiresIn: data_get($socialite_user, 'expiresIn'), + user: data_get($socialite_user, 'user'), + ); + + // if return url was set, set the intended URL + $return_url = session()->pull('rurl'); + if ($return_url) { + $this->authenticationService->setIntendedUrl($return_url); + } + + // check if the requested scopes matches the provided scopes + if ($this->authenticationService->isUserAuthenticated()) { + $hasNotMatchingSsoScopes = $this->hasNotMatchingSsoScopes($eve_data); + $isDifferentCharacterIdProvided = $this->isDifferentCharacterIdProvided($eve_data); + + if ($isDifferentCharacterIdProvided || $hasNotMatchingSsoScopes) { + return redirect()->intended(); + } + } + + // Get or create the User bound to this login. + $user = $find_or_create_user_action($eve_data); + + /* + * Update the refresh token for this character. + */ + $update_refresh_token_action($eve_data); + + if (! $this->authenticationService->loginUser($user)) { + return redirect()->back() + ->with('error', 'Login failed. Please contact your administrator.'); + } + + $this->authenticationService->flashMessage('success', 'Character added/updated successfully'); + + RoleMemberSync::dispatch()->onQueue('high'); + + return redirect()->intended(); + } + + private function hasNotMatchingSsoScopes(EveUser $user): bool + { + $sso_scopes = $this->authenticationService->getSessionValue('sso_scopes'); + $missing_scopes = array_diff($sso_scopes, $user->getScopes()); + + if (! empty($missing_scopes)) { + $this->authenticationService->flashMessage('error', 'Something might have gone wrong. You might have changed the requested scopes on esi, please refer from doing so.'); + + return true; + } + + return false; + } + + private function isDifferentCharacterIdProvided(EveUser $user): bool + { + $step_up_character_id = $this->authenticationService->getSessionValue('step_up'); + + if (! $step_up_character_id || $step_up_character_id === $user->character_id) { + return false; + } + + $this->authenticationService->flashMessage('error', 'Please make sure to select the same character to step up on CCP as on seatplus.'); + + return true; + } +} diff --git a/src/Http/Controllers/Auth/LoginController.php b/src/Http/Controllers/Auth/RedirectSSOController.php similarity index 51% rename from src/Http/Controllers/Auth/LoginController.php rename to src/Http/Controllers/Auth/RedirectSSOController.php index 0687a62..d483c75 100644 --- a/src/Http/Controllers/Auth/LoginController.php +++ b/src/Http/Controllers/Auth/RedirectSSOController.php @@ -26,43 +26,52 @@ namespace Seatplus\Auth\Http\Controllers\Auth; -use Inertia\Inertia; +use Laravel\Socialite\Contracts\Factory as Socialite; use Seatplus\Auth\Http\Controllers\Controller; +use Seatplus\Auth\Services\AuthenticationService; +use Seatplus\Auth\Services\SsoScopes\GlobalSsoScopesService; +use SocialiteProviders\Eveonline\Provider; +use Symfony\Component\HttpFoundation\RedirectResponse; -class LoginController extends Controller +class RedirectSSOController extends Controller { - /** - * Where to redirect users after login. - */ - protected string $redirectTo = '/home'; + public function __construct( + private GlobalSsoScopesService $service, + private AuthenticationService $authenticationService + ) {} /** - * Create a new controller instance. + * Redirect the user to the Eve Online authentication page. * - * @return void + * @throws \Throwable */ - public function __construct() - { - $this->middleware('guest')->except('logout'); - } - - public function showLoginForm(): \Inertia\Response + public function __invoke(Socialite $socialite): RedirectResponse { - // Warn if SSO has not been configured yet. - if (strlen(config('web.config.EVE_CLIENT_ID')) < 5 || strlen(config('web.config.EVE_CLIENT_SECRET')) < 5) { - session()->flash('warning', trans('web::auth.sso_config_warning')); + if ($this->authenticationService->isUserAuthenticated()) { + return redirect('/'); } - return Inertia::render('Auth/Login', [ - 'login_welcome' => trans('web::auth.login_welcome'), - 'evesso_img_src' => asset('img/evesso.png'), + $scopes = $this->getScopes(); + + session([ + 'rurl' => $this->authenticationService->getPreviousUrl(), + 'sso_scopes' => $scopes, ]); + + $driver = $socialite->driver('eveonline'); + + /** @var Provider $driver */ + return $driver->scopes($scopes)->redirect(); } - public function logout(): \Illuminate\Http\RedirectResponse + private function getScopes(): array { - auth()->logout(); + $global_scopes = $this->service->get(); - return redirect('/'); + return collect(config('eveapi.scopes.minimum')) + ->merge($global_scopes) + ->unique() + ->filter() + ->all(); } } diff --git a/src/Http/Controllers/Auth/SsoController.php b/src/Http/Controllers/Auth/SsoController.php deleted file mode 100644 index 3ffa1a7..0000000 --- a/src/Http/Controllers/Auth/SsoController.php +++ /dev/null @@ -1,159 +0,0 @@ -execute()->toArray(); - - session([ - 'rurl' => session()->previousUrl(), - 'sso_scopes' => $scopes, - ]); - - $driver = $socialite->driver('eveonline'); - - /** @var Provider $driver */ - return $driver->scopes($scopes)->redirect(); - } - - /** - * Obtain the user information from Eve Online. - * - * @return \Illuminate\Http\RedirectResponse - */ - public function handleProviderCallback( - Socialite $social, - FindOrCreateUserAction $find_or_create_user_action, - UpdateRefreshTokenAction $update_refresh_token_action - ): RedirectResponse { - - /* @var \SocialiteProviders\Manager\OAuth2\User $socialite_user */ - $socialite_user = $social->driver('eveonline')->user(); - $rurl = session()->pull('rurl'); - - $eve_data = new EveUser( - character_id: data_get($socialite_user, 'attributes.character_id'), - character_owner_hash: data_get($socialite_user, 'attributes.character_owner_hash'), - token: data_get($socialite_user, 'token'), - refreshToken: data_get($socialite_user, 'refreshToken'), - expiresIn: data_get($socialite_user, 'expiresIn'), - user: data_get($socialite_user, 'user'), - ); - - // if return url was set, set the intended URL - if ($rurl) { - redirect()->setIntendedUrl($rurl); - } - - // check if the requested scopes matches the provided scopes - if (auth()->user()) { - $this->checkForInvalidProviderCallback($eve_data); - $this->checkIfDifferentCharacterIdHasBeenProvided($eve_data); - - if ($this->should_redirect) { - return redirect()->intended(); - } - } - - // Get or create the User bound to this login. - $user = $find_or_create_user_action($eve_data); - - /* - * Update the refresh token for this character. - */ - $update_refresh_token_action($eve_data); - - if (! $this->loginUser($user)) { - return redirect()->route('auth.login') - ->with('error', 'Login failed. Please contact your administrator.'); - } - - session()->flash('success', 'Character added/updated successfully'); - - UserRolesSync::dispatch($user)->onQueue('high'); - - return redirect()->intended(); - } - - /** - * Login the user. - * - * This method returns a boolean as a status flag for the - * login routine. If a false is returned, it might mean - * that that account is not allowed to sign in. - */ - public function loginUser(User $user): bool - { - // Login and "remember" the given user... - auth()->login($user, true); - - return true; - } - - private function checkForInvalidProviderCallback(EveUser $user): void - { - $missing_scopes = array_diff(session()->pull('sso_scopes'), $user->getScopes()); - - if (empty($missing_scopes)) { - return; - } - - session()->flash('error', 'Something might have gone wrong. You might have changed the requested scopes on esi, please refer from doing so.'); - $this->should_redirect = true; - } - - private function checkIfDifferentCharacterIdHasBeenProvided(EveUser $user): void - { - $step_up_character_id = session()->pull('step_up'); - - if (! $step_up_character_id || $step_up_character_id === $user->character_id) { - return; - } - - session()->flash('error', 'Please make sure to select the same character to step up on CCP as on seatplus.'); - $this->should_redirect = true; - } -} diff --git a/src/Http/Controllers/Auth/StepUpController.php b/src/Http/Controllers/Auth/StepUpController.php index 7f971e4..04dae8b 100644 --- a/src/Http/Controllers/Auth/StepUpController.php +++ b/src/Http/Controllers/Auth/StepUpController.php @@ -31,15 +31,14 @@ use Seatplus\Auth\Models\User; use Seatplus\Eveapi\Models\RefreshToken; use SocialiteProviders\Eveonline\Provider; +use Symfony\Component\HttpFoundation\RedirectResponse; class StepUpController extends Controller { /** * Redirect the user to the Eve Online authentication page. - * - * @return \Symfony\Component\HttpFoundation\RedirectResponse */ - public function __invoke(Socialite $socialite, int $character_id) + public function __invoke(Socialite $socialite, int $character_id): RedirectResponse { if (! $this->isCharacterAssociatedToCurrentUser($character_id)) { return redirect()->back()->with('error', 'character must belong to your account'); diff --git a/src/Http/Controllers/MainCharacterController.php b/src/Http/Controllers/SwitchMainCharacterController.php similarity index 72% rename from src/Http/Controllers/MainCharacterController.php rename to src/Http/Controllers/SwitchMainCharacterController.php index 71baef1..6896724 100644 --- a/src/Http/Controllers/MainCharacterController.php +++ b/src/Http/Controllers/SwitchMainCharacterController.php @@ -27,23 +27,19 @@ namespace Seatplus\Auth\Http\Controllers; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Http\Request; +use Illuminate\Http\RedirectResponse; use Seatplus\Auth\Models\User; -class MainCharacterController extends Controller +class SwitchMainCharacterController extends Controller { - public function change(Request $request): \Illuminate\Http\RedirectResponse + public function __invoke(int $new_character_id): RedirectResponse { - $request->validate(['character_id' => ['required', 'exists:character_infos,character_id']]); - - $character_id = $request->get('character_id'); - - $user = User::whereHas('character_users', fn (Builder $query) => $query->where('character_id', $character_id)) + $user = User::whereHas('character_users', fn (Builder $query) => $query->where('character_id', $new_character_id)) ->firstWhere('id', auth()->user()->getAuthIdentifier()); - abort_if(is_null($user), 403, 'Unauthorized: supplied character_id does not belong to the current user'); + abort_if(is_null($user), 403); - $user->changeMainCharacter($character_id); + $user->changeMainCharacter($new_character_id); return back(); } diff --git a/src/Models/AccessControl/AclAffiliation.php b/src/Http/Middleware/CheckAuthorization.php similarity index 53% rename from src/Models/AccessControl/AclAffiliation.php rename to src/Http/Middleware/CheckAuthorization.php index cbd2dd0..8679019 100644 --- a/src/Models/AccessControl/AclAffiliation.php +++ b/src/Http/Middleware/CheckAuthorization.php @@ -24,41 +24,37 @@ * SOFTWARE. */ -namespace Seatplus\Auth\Models\AccessControl; +namespace Seatplus\Auth\Http\Middleware; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\MorphTo; -use Illuminate\Support\Collection; -use Seatplus\Auth\Models\Permissions\Role; -use Seatplus\Eveapi\Models\Character\CharacterInfo; +use Closure; +use Illuminate\Http\Request; +use Seatplus\Auth\Models\User; +use Seatplus\Auth\Services\Permissions\CanUserService; +use Seatplus\Auth\Services\Permissions\DTO\ValidateIdsDTO; -class AclAffiliation extends Model +class CheckAuthorization { - public $incrementing = false; - - protected $guarded = []; - - protected $casts = [ - 'can_moderate' => 'boolean', - ]; - - public function affiliatable(): MorphTo - { - return $this->morphTo(); + public function __construct( + private ?CanUserService $canUserService = null + ) { + $this->canUserService = $this->canUserService ?? new CanUserService; } - public function role(): BelongsTo + public function handle(Request $request, Closure $next, string $permissions, ?string $corporation_role = null): mixed { - return $this->belongsTo(Role::class, 'id', 'role_id'); - } - - public function getCharacterIdsAttribute(): Collection - { - if (! $this->affiliatable) { - return collect(); - } - - return $this->affiliatable instanceof CharacterInfo ? collect($this->affiliatable->character_id) : $this->affiliatable->characters->pluck('character_id'); + /** @var User $user */ + $user = auth()->user(); + $ids_dto = ValidateIdsDTO::fromRequest($request); + $permissions = explode('|', $permissions); + $corporation_role = explode('|', $corporation_role); + + abort_unless($this->canUserService->check( + user: $user, + idsDTO: $ids_dto, + permissions: $permissions, + corporation_roles: $corporation_role + ), 403); + + return $next($request); } } diff --git a/src/Http/Middleware/CheckPermissionAndAffiliation.php b/src/Http/Middleware/CheckPermissionAndAffiliation.php deleted file mode 100644 index 72fc1af..0000000 --- a/src/Http/Middleware/CheckPermissionAndAffiliation.php +++ /dev/null @@ -1,177 +0,0 @@ -checkPermissionAffiliation($request, $permissions, $corporation_role); - - return $next($request); - } - - private function checkPermissionAffiliation(Request $request, string $permissions, ?string $corporation_role): void - { - $this->validateAndSetRequestedIds($request); - - if ($this->getUser()->can('superuser')) { - return; - } - - $checkPermissionAffiliationDto = new CheckPermissionAffiliationDto( - requested_ids: $this->getRequestedIds(), - affiliationsDto: $this->getAffiliationsDto($permissions, $corporation_role), - ); - - $all_requested_ids_validated = app(Pipeline::class) - ->send($checkPermissionAffiliationDto) - ->through($this->getPipelines()) - ->thenReturn() - ->allIdsValidated(); - - abort_unless($all_requested_ids_validated, 401, 'You are not allowed to access the requested entity'); - } - - private function checkPermission(string $permissions, ?string $corporation_role): void - { - if ($this->getUser()->can('superuser')) { - return; - } - - $permissions = explode('|', $permissions); - - if ($this->getUser()->hasAnyPermission($permissions)) { - return; - } - - if ($this->hasCorporationRole($corporation_role)) { - return; - } - - abort('401', 'You are not authorized to perform this action'); - } - - private function hasCorporationRole(?string $corporation_role): bool - { - if (is_null($corporation_role)) { - return false; - } - - return CharacterUser::query() - ->whereHas( - 'character.roles', - fn (HasOne $query) => $query - ->whereJsonContains('roles', 'Director') - ->orWhereJsonContains('roles', $corporation_role) - ) - ->where('user_id', $this->getUser()->getAuthIdentifier()) - ->exists(); - } - - private function validateAndSetRequestedIds(Request $request): void - { - // validate request and set requsted ids - // ignore non-validated payload - $current_payload = Arr::where($request->input(), fn (mixed $value, string $key) => in_array($key, [ - 'character_id', 'character_ids', - 'corporation_id', 'corporation_ids', - 'alliance_id', 'alliance_ids', - ])); - - $route_parameters = $request->route()->parameters(); - - $constructed_payload = collect($current_payload) - ->merge($route_parameters) - ->unique() - ->toArray(); - - $validator = Validator::make($constructed_payload, [ - 'character_id' => ['required_without_all:corporation_id,alliance_id,character_ids,corporation_ids,alliance_ids', 'integer'], - 'corporation_id' => ['required_without_all:character_id,alliance_id,character_ids,corporation_ids,alliance_ids', 'integer'], - 'alliance_id' => ['required_without_all:character_id,corporation_id,character_ids,corporation_ids,alliance_ids', 'integer'], - 'character_ids' => ['required_without_all:character_id,corporation_id,alliance_id,corporation_ids,alliance_ids', 'array'], - 'corporation_ids' => ['required_without_all:character_id,corporation_id,alliance_id,character_ids,alliance_ids', 'array'], - 'alliance_ids' => ['required_without_all:character_id,corporation_id,alliance_id,character_ids,corporation_ids', 'array'], - ]); - - abort_if($validator->fails(), 403, implode(', ', $validator->errors()->all())); - - $this->requested_ids = collect($validator->validated())->flatten()->unique(); - } - - /** - * @return array|string[] - */ - public function getPipelines(): array - { - return $this->pipelines; - } - - public function getRequestedIds(): Collection - { - return $this->requested_ids; - } - - public function getUser(): User - { - return User::find(auth()->user()->getAuthIdentifier()); - } - - public function getAffiliationsDto(string $permissions, ?string $character_role = null): AffiliationsDto - { - return new AffiliationsDto( - permissions: explode('|', $permissions), - user: $this->getUser(), - corporation_roles: is_string($character_role) ? explode('|', $character_role) : null - ); - } -} diff --git a/src/Http/Middleware/CheckPermissionOrCorporationRole.php b/src/Http/Middleware/CheckPermissionOrCorporationRole.php deleted file mode 100644 index a0bbcc2..0000000 --- a/src/Http/Middleware/CheckPermissionOrCorporationRole.php +++ /dev/null @@ -1,90 +0,0 @@ -user()) { - abort(401); - } - - // validate request and set requsted ids - // we do this before fast tracking superuser to ensure superuser requests are valid too. - - $this->checkPermission($permissions, $corporation_role); - - return $next($request); - } - - private function checkPermission(string $permissions, ?string $corporation_role): void - { - if ($this->getUser()->can('superuser')) { - return; - } - - $permissions = explode('|', $permissions); - - if ($this->getUser()->hasAnyPermission($permissions)) { - return; - } - - if ($this->hasCorporationRole($corporation_role)) { - return; - } - - abort('401', 'You are not authorized to perform this action'); - } - - private function hasCorporationRole(?string $corporation_role): bool - { - if (is_null($corporation_role)) { - return false; - } - - return CharacterUser::query() - ->whereHas( - 'character.roles', - fn (\Illuminate\Database\Eloquent\Builder $query) => $query - ->whereJsonContains('roles', 'Director') - ->orWhereJsonContains('roles', $corporation_role) - ) - ->where('user_id', $this->getUser()->getAuthIdentifier()) - ->exists(); - } - - public function getUser(): User - { - return User::find(auth()->user()->getAuthIdentifier()); - } -} diff --git a/src/Http/Middleware/CheckRequiredScopes.php b/src/Http/Middleware/CheckRequiredScopes.php index 996a05c..07f7850 100644 --- a/src/Http/Middleware/CheckRequiredScopes.php +++ b/src/Http/Middleware/CheckRequiredScopes.php @@ -28,82 +28,31 @@ use Closure; use Illuminate\Http\Request; -use Illuminate\Support\Arr; -use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Cache; use Seatplus\Auth\Models\User; -use Seatplus\Auth\Services\BuildCharacterScopesArray; -use Seatplus\Auth\Services\BuildUserLevelRequiredScopes; -use Seatplus\Eveapi\Models\Character\CharacterInfo; -use Seatplus\Eveapi\Models\SsoScopes; +use Seatplus\Auth\Services\SsoScopes\IsUserCompliantService; +use Symfony\Component\HttpFoundation\Response; class CheckRequiredScopes { - private User $user; + public function __construct( + private ?IsUserCompliantService $isUserCompliantService = null, + ) { + $this->isUserCompliantService ??= new IsUserCompliantService; + } public function handle(Request $request, Closure $next) // @pest-ignore-type { - $characters_with_missing_scopes = Cache::tags(['characters_with_missing_scopes', $this->getUserId()])->get($this->getCacheKey()); - - if (is_null($characters_with_missing_scopes)) { - $this->buildUser(); - - $characters_with_missing_scopes = $this->getCharactersWithMissingScopes(); - } - return $characters_with_missing_scopes->isEmpty() + return $this->isUserCompliantService->check($request->user()) ? $next($request) - : $this->redirectTo($characters_with_missing_scopes); - } - - public function buildUser(): void - { - /** @noinspection PhpFieldAssignmentTypeMismatchInspection */ - $this->user = User::with( - 'characters.alliance.ssoScopes', - 'characters.corporation.ssoScopes', - 'characters.alliance.ssoScopes', - 'characters.application.corporation.ssoScopes', - 'characters.application.corporation.alliance.ssoScopes', - 'characters.refresh_token', - 'application.corporation.ssoScopes', - 'application.corporation.alliance.ssoScopes' - )->addSelect(['global_scope' => SsoScopes::global()->select('selected_scopes')]) - ->find(auth()->user()->getAuthIdentifier()); - } - - private function getCharactersWithMissingScopes(): Collection - { - // Get user level required scopes - $user_scopes = BuildUserLevelRequiredScopes::get($this->user); - - $missing_scopes = $this->user - ->characters - ->map(fn (CharacterInfo $character) => BuildCharacterScopesArray::make()->setUserScopes($user_scopes)->setCharacter($character)->get()) - ->filter(fn (array $character_scopes) => Arr::get($character_scopes, 'missing_scopes')); - - Cache::tags(['characters_with_missing_scopes', $this->getUserId()])->put($this->getCacheKey(), $missing_scopes, now()->addMinutes(15)); - - return $missing_scopes; - } - - private function getCacheKey(): string - { - $user_id = $this->getUserId(); - - return "UserScopes:${user_id}"; - } - - private function getUserId(): string - { - return (string) isset($this->user) ? $this->user->id : auth()->user()->getAuthIdentifier(); + : $this->redirectTo($this->isUserCompliantService->getMissingScopes($request->user())); } /* * This method should return the user to a view where he needs to handle the addition of required scopes */ - protected function redirectTo(Collection $missing_character_scopes) // @pest-ignore-type + protected function redirectTo(array $missing_character_scopes): Response { - //TODO: extend this with default view. + return redirect('/'); } } diff --git a/src/Http/Requests/RoleRequest.php b/src/Http/Requests/RoleRequest.php new file mode 100644 index 0000000..7e89cb6 --- /dev/null +++ b/src/Http/Requests/RoleRequest.php @@ -0,0 +1,30 @@ + 'required|integer', + 'name' => 'nullable|string', + 'affiliated' => 'nullable|array', + 'affiliated.*.entity_id' => 'required|integer', + 'affiliated.*.entity_type' => ['required', 'string', Rule::in(['character', 'corporation', 'alliance'])], + 'affiliated.*.affiliation_type' => [ + 'required', + 'string', + Rule::in(array_map(fn (AffiliationType $affiliationType) => $affiliationType->value, AffiliationType::cases())), + ], + 'assigned' => 'nullable|array', + 'assigned.*.entity_id' => 'required|integer', + 'assigned.*.entity_type' => ['required', 'string', Rule::in(['character', 'corporation', 'alliance'])], + 'assigned.*.can_moderate' => 'nullable|boolean', + ]; + } +} diff --git a/src/Jobs/DispatchUserRoleSync.php b/src/Jobs/RoleMemberSync.php similarity index 81% rename from src/Jobs/DispatchUserRoleSync.php rename to src/Jobs/RoleMemberSync.php index 042607d..fbae40e 100644 --- a/src/Jobs/DispatchUserRoleSync.php +++ b/src/Jobs/RoleMemberSync.php @@ -32,9 +32,10 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Seatplus\Auth\Models\User; +use Seatplus\Auth\Models\Permissions\Role; +use Seatplus\Auth\Services\Roles\BaseRoleService; -class DispatchUserRoleSync implements ShouldBeUnique, ShouldQueue +class RoleMemberSync implements ShouldBeUnique, ShouldQueue { use Dispatchable; use InteractsWithQueue; @@ -43,6 +44,12 @@ class DispatchUserRoleSync implements ShouldBeUnique, ShouldQueue public int $tries = 1; + public function __construct( + private ?BaseRoleService $service = null + ) { + $this->service = $service ?? new BaseRoleService; + } + /** * Assign this job a tag so that Horizon can categorize and allow * for specific tags to be monitored. @@ -58,8 +65,8 @@ public function tags(): array public function handle(): void { - foreach (User::cursor() as $user) { - UserRolesSync::dispatch($user)->onQueue('high'); - } + Role::query()->each(function (Role $role) { + $this->service->for($role)->handleMembers(); + }); } } diff --git a/src/Jobs/UserRolesSync.php b/src/Jobs/UserRolesSync.php deleted file mode 100644 index 3f08346..0000000 --- a/src/Jobs/UserRolesSync.php +++ /dev/null @@ -1,133 +0,0 @@ -tags()); - } - - /** - * The number of seconds after which the job's unique lock will be released. - */ - public int $uniqueFor = 3600; - - public function __construct( - private User $user - ) { - $this->character_ids = User::query() - ->has('characters.refresh_token') - ->with(['characters.refresh_token' => fn (HasOne $query) => $query->select('character_id')]) - ->whereId($this->user->id) - ->get() - ->whenNotEmpty(function (Collection $collection) { - return $collection->first()->characters->map(fn (CharacterInfo $character) => $character->character_id); - }) - ->toArray(); - } - - /** - * Assign this job a tag so that Horizon can categorize and allow - * for specific tags to be monitored. - * - * If a job specifies the tags property, that is added. - */ - public function tags(): array - { - return [ - 'Roles sync', - "user_id: {$this->user->id}", - 'main_character: '.($this->user->main_character->name ?? ''), - ]; - } - - public function handle(): void - { - $this->handleAutomaticRoles(); - $this->handleOtherRoles(); - } - - private function handleAutomaticRoles(): void - { - $automatic_roles = Role::has('acl_affiliations') - ->whereType('automatic') - ->with('acl_affiliations.affiliatable.characters') - ->cursor(); - - $this->handleMemberships($automatic_roles); - } - - private function handleOtherRoles(): void - { - $roles = Role::query() - ->has('acl_affiliations') - ->with('acl_affiliations.affiliatable.characters') - ->whereNotIn('type', ['manual', 'automatic']) - ->whereHas( - 'acl_members', - fn (Builder $query) => $query->where('user_id', $this->user->getAuthIdentifier()) - ->whereIn('status', ['member', 'paused']) - ) - ->cursor(); - - $this->handleMemberships($roles); - } - - private function handleMemberships(LazyCollection $roles): void - { - foreach ($roles as $role) { - collect($this->character_ids)->intersect($role->acl_affiliated_ids)->isNotEmpty() - ? $role->activateMember($this->user) - : $role->pauseMember($this->user); - } - } -} diff --git a/src/Listeners/ReactOnFreshRefreshToken.php b/src/Listeners/ReactOnFreshRefreshToken.php index c5eb56a..51de674 100644 --- a/src/Listeners/ReactOnFreshRefreshToken.php +++ b/src/Listeners/ReactOnFreshRefreshToken.php @@ -38,7 +38,6 @@ public function handle(RefreshTokenCreated $refresh_token_event): void ->where('character_id', $refresh_token_event->refresh_token->character_id) ->firstOrFail(); - $user_id = $character_user->user_id; - Cache::tags(['characters_with_missing_scopes', $user_id])->flush(); + Cache::forget("user_permissions_{$character_user->user_id}"); } } diff --git a/src/Listeners/UpdatingRefreshTokenListener.php b/src/Listeners/UpdatingRefreshTokenListener.php index d9baeb9..9ae5282 100644 --- a/src/Listeners/UpdatingRefreshTokenListener.php +++ b/src/Listeners/UpdatingRefreshTokenListener.php @@ -44,8 +44,7 @@ public function handle(UpdatingRefreshTokenEvent $refresh_token_event): void ->where('character_id', $refresh_token->character_id) ->firstOrFail(); - $user_id = $character_user->user_id; - Cache::tags(['characters_with_missing_scopes', $user_id])->flush(); + Cache::forget("user_permissions_{$character_user->user_id}"); } } diff --git a/src/Models/AccessControl/RoleMembership.php b/src/Models/AccessControl/RoleMembership.php new file mode 100644 index 0000000..a05d3fd --- /dev/null +++ b/src/Models/AccessControl/RoleMembership.php @@ -0,0 +1,39 @@ + 'integer', + 'entity_id' => 'integer', + 'can_moderate' => 'boolean', + ]; + + protected $fillable = [ + 'role_id', + 'entity_type', + 'entity_id', + 'can_moderate', + 'status', + ]; + + public function role(): BelongsTo + { + return $this->belongsTo(Role::class, 'role_id'); + } + + public function entity(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/src/Models/Permissions/Affiliation.php b/src/Models/Permissions/Affiliation.php index b53d073..a2bead8 100644 --- a/src/Models/Permissions/Affiliation.php +++ b/src/Models/Permissions/Affiliation.php @@ -26,18 +26,18 @@ namespace Seatplus\Auth\Models\Permissions; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; -use Illuminate\Support\Collection; use Seatplus\Eveapi\Models\Alliance\AllianceInfo; use Seatplus\Eveapi\Models\Character\CharacterInfo; use Seatplus\Eveapi\Models\Corporation\CorporationInfo; /* + * Seatplus\Auth\Models\Permissions\Affiliation + * * @property string $type - * @property Collection $affiliated_ids - * @property Collection $inverse_affiliated_ids */ class Affiliation extends Model { @@ -61,46 +61,28 @@ public function role(): BelongsTo return $this->belongsTo(Role::class, 'role_id', 'id'); } - public function getAffiliatedIdsAttribute(): Collection + /** + * @return Attribute + */ + protected function affiliatedIds(): Attribute { - return $this->getCharacterIds()->merge($this->getCorporationIds()); - } - - public function getInverseAffiliatedIdsAttribute(): Collection - { - return $this->getInverseCharacterIds()->merge($this->getInverseCorporationIds()); - } - - private function getCharacterIds(): Collection - { - if (! $this->affiliatable) { - return collect(); - } - - return $this->affiliatable instanceof CharacterInfo ? collect($this->affiliatable->character_id) : $this->affiliatable->characters->pluck('character_id'); - } + return new Attribute( + get: function () { + return match (true) { + $this->affiliatable instanceof CharacterInfo => collect($this->affiliatable->character_id), + $this->affiliatable instanceof CorporationInfo => collect([ + $this->affiliatable->corporation_id, + $this->affiliatable->characters->pluck('character_id'), + ])->flatten(), + $this->affiliatable instanceof AllianceInfo => collect([ + $this->affiliatable->alliance_id, + $this->affiliatable->corporations->pluck('corporation_id'), + $this->affiliatable->characters->pluck('character_id'), + ])->flatten(), + default => collect(), + }; + } + ); - private function getInverseCharacterIds(): Collection - { - return CharacterInfo::query() - ->whereNotIn('character_id', $this->getCharacterIds()->toArray()) - ->pluck('character_id'); - } - - private function getCorporationIds(): Collection - { - if (! $this->affiliatable) { - return collect(); - } - - return $this->affiliatable instanceof CorporationInfo ? collect($this->affiliatable->corporation_id) - : ($this->affiliatable instanceof AllianceInfo ? $this->affiliatable->corporations->pluck('corporation_id') : collect()); - } - - private function getInverseCorporationIds(): Collection - { - return CorporationInfo::query() - ->whereNotIn('corporation_id', $this->getCorporationIds()->toArray()) - ->pluck('corporation_id'); } } diff --git a/src/Models/Permissions/Role.php b/src/Models/Permissions/Role.php index 3321f78..752f525 100644 --- a/src/Models/Permissions/Role.php +++ b/src/Models/Permissions/Role.php @@ -26,178 +26,27 @@ namespace Seatplus\Auth\Models\Permissions; -use Exception; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Relations\MorphTo; -use Illuminate\Support\Collection; -use Seatplus\Auth\Models\AccessControl\AclAffiliation; -use Seatplus\Auth\Models\AccessControl\AclMember; -use Seatplus\Auth\Models\User; -use Seatplus\Eveapi\Models\Alliance\AllianceInfo; -use Seatplus\Eveapi\Models\Corporation\CorporationInfo; +use Seatplus\Auth\Enums\RoleType; +use Seatplus\Auth\Models\AccessControl\RoleMembership; use Spatie\Permission\Models\Role as SpatieRole; /** - * @property string $type + * @property RoleType $type */ class Role extends SpatieRole { + protected $casts = [ + 'type' => RoleType::class, + ]; + public function affiliations(): HasMany { return $this->hasMany(Affiliation::class, 'role_id'); } - public function acl_affiliations(): HasMany - { - return $this->hasMany(AclAffiliation::class, 'role_id') - ->where('can_moderate', false); - } - - public function moderators(): HasMany - { - return $this->hasMany(AclAffiliation::class, 'role_id') - ->where('can_moderate', true); - } - - public function acl_members(): HasMany - { - return $this->hasMany(AclMember::class, 'role_id'); - } - - public function members(): HasMany - { - return $this->hasMany(AclMember::class, 'role_id') - ->where('status', 'member'); - } - - public function activateMember(User $user): void - { - if (in_array($this->type, ['automatic', 'opt-in', 'on-request'])) { - if ($user->characters->pluck('character_id')->intersect($this->getAclAffiliatedIdsAttribute())->isEmpty()) { - throw new Exception('User is not allowed for this access control group'); - } - } - - AclMember::query()->updateOrInsert( - ['role_id' => $this->id, 'user_id' => $user->getAuthIdentifier()], - ['status' => 'member'] - ); - - $user->assignRole($this); - } - - public function joinWaitlist(User $user): void - { - if ($this->type !== 'on-request') { - throw new Exception('Only on-request control groups do have a waitlist'); - } - - if ($user->characters->pluck('character_id')->intersect($this->getAclAffiliatedIdsAttribute())->isEmpty()) { - throw new Exception('User is not allowed for this access control group'); - } - - AclMember::query()->updateOrInsert( - ['role_id' => $this->id, 'user_id' => $user->getAuthIdentifier()], - ['status' => 'waitlist'] - ); - } - - public function pauseMember(User $user): void - { - AclMember::where('user_id', $user->getAuthIdentifier()) - ->where('role_id', $this->id) - ->where('status', 'member') - ->update(['status' => 'paused']); - - $user->removeRole($this); - } - - public function removeMember(User $user): void + public function role_memberships(): HasMany { - AclMember::where('user_id', $user->getAuthIdentifier()) - ->where('role_id', $this->id) - ->where('status', 'member') - ->delete(); - - $user->removeRole($this); - } - - public function isModerator(User $user): bool - { - return $user->characters - ->pluck('character_id') - ->intersect($this->getModeratorIdsAttribute()) - ->isNotEmpty(); - } - - public function getAffiliatedIdsAttribute(): array - { - //eager load relations for preventing n+1 queries - $role_with_relationships = $this->loadMissing([ - 'affiliations.affiliatable' => fn (MorphTo $morph_to) => $morph_to->morphWith([CorporationInfo::class => 'characters', AllianceInfo::class => ['characters', 'corporations']]), - ]); - - return $role_with_relationships->getAffiliatedIds() - ->diff($role_with_relationships->getForbiddenAndInverseIds()->toArray()) - ->all(); - } - - public function getAclAffiliatedIdsAttribute(): array - { - $acl_affiliations = $this->acl_affiliations() - ->with( - ['affiliatable' => function (MorphTo $morph_to) { - $morph_to->morphWith([CorporationInfo::class => 'characters', AllianceInfo::class => 'characters']); - }] - ) - ->cursor(); - - return $acl_affiliations - ->map(fn (AclAffiliation $affiliation) => $affiliation->character_ids) - ->flatten() - ->unique() - ->toArray(); - } - - public function getModeratorIdsAttribute(): array - { - //eager load relations for preventing n+1 queries - $role_with_relationships = $this->loadMissing([ - 'moderators.affiliatable' => fn (MorphTo $morph_to) => $morph_to->morphWith([CorporationInfo::class => 'characters', AllianceInfo::class => 'characters']), - ]); - - return $role_with_relationships->moderators - ->map(fn (AclAffiliation $affiliation) => $affiliation->character_ids) - ->flatten() - ->unique() - ->toArray(); - } - - private function getAffiliatedIds(): Collection - { - return $this->affiliations - ->reject(fn (Affiliation $affiliation) => $affiliation->type === 'forbidden') - // TODO get IDs instead of character_ids - ->map(fn (Affiliation $affiliation) => $affiliation->type === 'allowed' ? $affiliation->affiliated_ids : $affiliation->inverse_affiliated_ids) - ->flatten() - ->unique(); - } - - private function getForbiddenAndInverseIds(): Collection - { - return $this->affiliations - // we are only concerned about forbidden and inverse ids - ->reject(fn (Affiliation $affiliation) => $affiliation->type === 'allowed') - ->map(fn (Affiliation $affiliation) => $affiliation->affiliated_ids) - ->flatten() - ->unique(); - } - - public function delete(): bool - { - $this->affiliations()->delete(); - $this->acl_affiliations()->delete(); - - return parent::delete(); + return $this->hasMany(RoleMembership::class, 'role_id'); } } diff --git a/src/Models/User.php b/src/Models/User.php index f378f6b..c11846c 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -57,7 +57,7 @@ class User extends Authenticatable public $incrementing = true; protected $fillable = [ - 'main_character_id', 'character_owner_hash', + 'main_character_id', 'character_owner_hash', 'active', ]; protected $hidden = [ @@ -102,6 +102,11 @@ public function application(): MorphOne return $this->morphOne(Application::class, 'applicationable')->whereStatus('open'); } + public function getAuthPassword(): string + { + return ''; + } + public function changeMainCharacter(int $character_id): bool { $this->main_character_id = $character_id; diff --git a/src/Observers/ApplicationObserver.php b/src/Observers/ApplicationObserver.php index e74f1d3..16728e9 100644 --- a/src/Observers/ApplicationObserver.php +++ b/src/Observers/ApplicationObserver.php @@ -34,14 +34,21 @@ class ApplicationObserver { + /** + * @throws \Throwable + */ public function created(Application $application): void { - $user_id = match ($application->applicationable_type) { + + $application_type = $application->applicationable_type; + + throw_unless(in_array($application_type, [User::class, CharacterInfo::class]), new \Exception('Applicationable type not supported')); + + $user_id = match ($application_type) { User::class => $application->applicationable_id, CharacterInfo::class => CharacterUser::query()->firstWhere('character_id', $application->applicationable_id)->user_id, - default => null, }; - Cache::tags(['characters_with_missing_scopes', $user_id])->flush(); + Cache::forget("user_permissions_{$user_id}"); } } diff --git a/src/Observers/SsoScopeObserver.php b/src/Observers/SsoScopeObserver.php index 5e30268..e384127 100644 --- a/src/Observers/SsoScopeObserver.php +++ b/src/Observers/SsoScopeObserver.php @@ -27,6 +27,7 @@ namespace Seatplus\Auth\Observers; use Illuminate\Support\Facades\Cache; +use Seatplus\Auth\Models\User; use Seatplus\Eveapi\Models\SsoScopes; class SsoScopeObserver @@ -48,6 +49,10 @@ public function deleted(SsoScopes $ssoScopes): void private function flushCache(): void { - Cache::tags(['characters_with_missing_scopes'])->flush(); + $user_ids = User::query()->pluck('id'); + + foreach ($user_ids as $user_id) { + Cache::forget("user_permissions_{$user_id}"); + } } } diff --git a/src/Pipelines/Middleware/CheckAffiliatedIdsPipe.php b/src/Pipelines/Middleware/CheckAffiliatedIdsPipe.php deleted file mode 100644 index bf8e15c..0000000 --- a/src/Pipelines/Middleware/CheckAffiliatedIdsPipe.php +++ /dev/null @@ -1,26 +0,0 @@ -affiliationsDto) - ->getQuery() - ->pluck('affiliated_id') - ->intersect($checkPermissionAffiliationDto->requested_ids); - - $checkPermissionAffiliationDto->mergeValidatedIds($validated_ids); - - return $checkPermissionAffiliationDto; - } - - protected function shouldBeChecked(CheckPermissionAffiliationDto $checkPermissionAffiliationDto): bool - { - return ! $checkPermissionAffiliationDto->allIdsValidated(); - } -} diff --git a/src/Pipelines/Middleware/CheckOwnedAffiliatedIdsPipe.php b/src/Pipelines/Middleware/CheckOwnedAffiliatedIdsPipe.php deleted file mode 100644 index 79a5d17..0000000 --- a/src/Pipelines/Middleware/CheckOwnedAffiliatedIdsPipe.php +++ /dev/null @@ -1,30 +0,0 @@ -affiliationsDto) - ->getQuery() - ->pluck('affiliated_id') - ->intersect($checkPermissionAffiliationDto->requested_ids); - - $checkPermissionAffiliationDto->mergeValidatedIds($validated_ids); - - return $checkPermissionAffiliationDto; - } - - protected function shouldBeChecked(CheckPermissionAffiliationDto $checkPermissionAffiliationDto): bool - { - if ($checkPermissionAffiliationDto->allIdsValidated()) { - return false; - } - - return true; - } -} diff --git a/src/Pipelines/Middleware/CheckPermissionAffiliationPipeline.php b/src/Pipelines/Middleware/CheckPermissionAffiliationPipeline.php deleted file mode 100644 index 9a8e34e..0000000 --- a/src/Pipelines/Middleware/CheckPermissionAffiliationPipeline.php +++ /dev/null @@ -1,22 +0,0 @@ -shouldBeChecked($checkPermissionAffiliationDto)) { - return $next($checkPermissionAffiliationDto); - } - - return $next($this->check($checkPermissionAffiliationDto)); - } - - abstract protected function check(CheckPermissionAffiliationDto $checkPermissionAffiliationDto): CheckPermissionAffiliationDto; - - abstract protected function shouldBeChecked(CheckPermissionAffiliationDto $checkPermissionAffiliationDto): bool; -} diff --git a/src/Pipelines/Middleware/CheckPermissionAffiliationPipelineInterface.php b/src/Pipelines/Middleware/CheckPermissionAffiliationPipelineInterface.php deleted file mode 100644 index 1eec777..0000000 --- a/src/Pipelines/Middleware/CheckPermissionAffiliationPipelineInterface.php +++ /dev/null @@ -1,11 +0,0 @@ -getAllowedAffiliatedCharacterAffiliations(); - $inverted = $this->getInvertedAffiliatedCharacterAffiliations(); - - return $allowed - ->union($inverted) - ->distinct(); - } - - private function getAllowedAffiliatedCharacterAffiliations(): QueryBuilder - { - $allowed_affiliations = GetAllowedAffiliatedIdsService::make($this->affiliationsDto) - ->getQuery(); - - return $this->removeForbiddenAffiliations($allowed_affiliations); - } - - private function getInvertedAffiliatedCharacterAffiliations(): QueryBuilder - { - $inverse_affiliations = GetInvertedAffiliatedIdsService::make($this->affiliationsDto) - ->getQuery(); - - return $this->removeForbiddenAffiliations($inverse_affiliations); - } -} diff --git a/src/Services/Affiliations/GetAffiliatedIdsServiceBase.php b/src/Services/Affiliations/GetAffiliatedIdsServiceBase.php deleted file mode 100644 index 5a3367e..0000000 --- a/src/Services/Affiliations/GetAffiliatedIdsServiceBase.php +++ /dev/null @@ -1,81 +0,0 @@ -on('character_affiliations.character_id', '=', "$alias.affiliatable_id")->where("$alias.affiliatable_type", CharacterInfo::class) - ->orOn('character_affiliations.corporation_id', '=', "$alias.affiliatable_id")->where("$alias.affiliatable_type", CorporationInfo::class) - ->orOn('character_affiliations.alliance_id', '=', "$alias.affiliatable_id")->where("$alias.affiliatable_type", AllianceInfo::class); - } - - protected function joinAffiliatedCorporationAffiliations(JoinClause $join, string $alias): JoinClause - { - return $join - ->on('character_affiliations.corporation_id', '=', "$alias.affiliatable_id")->where("$alias.affiliatable_type", CorporationInfo::class) - ->orOn('character_affiliations.alliance_id', '=', "$alias.affiliatable_id")->where("$alias.affiliatable_type", AllianceInfo::class); - } - - protected function getAffiliations(): Builder - { - if (! isset($this->affiliations)) { - $this->createAffiliations(); - } - - return clone $this->affiliations; - } - - protected function createAffiliations(): void - { - $permissions = $this->affiliationsDto->permissions; - - $affiliations = Affiliation::query() - ->whereRelation('role.permissions', 'name', array_shift($permissions)) - ->whereRelation('role.members', 'user_id', $this->affiliationsDto->user->getAuthIdentifier()); - - foreach ($permissions as $permission) { - $affiliations->whereRelation('role.permissions', 'name', $permission); - } - - $this->affiliations = $affiliations; - } - - protected function removeForbiddenAffiliations(Builder $query): QueryBuilder - { - $forbidden = GetForbiddenAffiliatedIdService::make($this->affiliationsDto)->getQuery(); - - return \DB::query() - ->fromSub($query, 'affiliations') - ->when( - $forbidden->count(), - fn (QueryBuilder $query) => $query - ->leftJoinSub( - $forbidden, - 'remove_forbidden', - 'remove_forbidden.forbidden_id', - '=', - 'affiliations.affiliated_id' - ) - ->whereNull('forbidden_id') - ) - ->select('affiliated_id'); - } -} diff --git a/src/Services/Affiliations/GetAllowedAffiliatedIdsService.php b/src/Services/Affiliations/GetAllowedAffiliatedIdsService.php deleted file mode 100644 index 402d4f5..0000000 --- a/src/Services/Affiliations/GetAllowedAffiliatedIdsService.php +++ /dev/null @@ -1,50 +0,0 @@ -value()); - - $affiliation = $this->getAffiliations()->where('type', $type->value()); - - $character_affiliations = CharacterAffiliation::query() - ->joinSub( - $affiliation, - $alias, - fn (JoinClause $join) => $this->joinAffiliatedCharacterAffiliations($join, $alias) - ) - ->select('character_affiliations.character_id as affiliated_id'); - - $corporation_affiliations = CharacterAffiliation::query() - ->joinSub( - $affiliation, - $alias, - fn (JoinClause $join) => $this->joinAffiliatedCorporationAffiliations($join, $alias) - ) - ->select('character_affiliations.corporation_id as affiliated_id'); - - $alliance_affiliations = $affiliation - ->where('affiliatable_type', AllianceInfo::class) - ->select('affiliatable_id as affiliated_id'); - - return $character_affiliations - ->union($corporation_affiliations) - ->union($alliance_affiliations); - } -} diff --git a/src/Services/Affiliations/GetForbiddenAffiliatedIdService.php b/src/Services/Affiliations/GetForbiddenAffiliatedIdService.php deleted file mode 100644 index 2913f0f..0000000 --- a/src/Services/Affiliations/GetForbiddenAffiliatedIdService.php +++ /dev/null @@ -1,83 +0,0 @@ -value()); - - $owned_character_affiliations = GetOwnedAffiliatedIdsService::make($this->affiliationsDto) - ->getQuery(); - - $affiliation = $this->getAffiliations()->where('type', $type->value()); - /*->whereNotExists( - fn (QueryBuilder $query) => $query - ->select(DB::raw(1)) - ->fromSub($owned_character_affiliations, 'owned') - ->whereColumn('affiliations.affiliatable_id', 'owned.affiliated_id') - )*/ - - $character_affiliations = CharacterAffiliation::query() - ->when( - $affiliation->count(), - fn (Builder $query) => $query - ->joinSub( - $affiliation, - $alias, - fn (JoinClause $join) => $this->joinAffiliatedCharacterAffiliations($join, $alias) - ), - fn (Builder $query) => $query->whereNull('character_affiliations.character_id') - ) - ->whereNotExists( - fn (QueryBuilder $query) => $query - ->select(DB::raw(1)) - ->fromSub($owned_character_affiliations, 'owned') - ->whereColumn('character_affiliations.character_id', 'owned.affiliated_id') - ) - ->select('character_affiliations.character_id as forbidden_id'); - - $corporation_affiliations = CharacterAffiliation::query() - ->when( - $affiliation->count(), - fn (Builder $query) => $query - ->joinSub( - $affiliation, - $alias, - fn (JoinClause $join) => $this->joinAffiliatedCorporationAffiliations($join, $alias) - ), - fn (Builder $query) => $query->whereNull('character_affiliations.corporation_id') - ) - ->whereNotExists( - fn (QueryBuilder $query) => $query - ->select(DB::raw(1)) - ->fromSub($owned_character_affiliations, 'owned') - ->whereColumn('character_affiliations.corporation_id', 'owned.affiliated_id') - ) - ->select('character_affiliations.corporation_id as forbidden_id'); - - $alliance_affiliations = $affiliation - ->where('affiliatable_type', AllianceInfo::class) - ->select('affiliatable_id as forbidden_id'); - - return $character_affiliations - ->union($corporation_affiliations) - ->union($alliance_affiliations); - } -} diff --git a/src/Services/Affiliations/GetInvertedAffiliatedIdsService.php b/src/Services/Affiliations/GetInvertedAffiliatedIdsService.php deleted file mode 100644 index 443e197..0000000 --- a/src/Services/Affiliations/GetInvertedAffiliatedIdsService.php +++ /dev/null @@ -1,75 +0,0 @@ -value()); - - $affiliation = $this->getAffiliations()->where('type', $type->value()); - - $character_affiliations = CharacterAffiliation::query() - ->when( - $affiliation->count(), - fn (Builder $query) => $query - ->leftJoinSub( - $affiliation, - $alias, - fn (JoinClause $join) => $this->joinAffiliatedCharacterAffiliations($join, $alias) - ) - ->whereNull("$alias.affiliatable_id"), - fn (Builder $query) => $query->whereNull('character_id') - ) - ->select('character_affiliations.character_id as affiliated_id'); - - $corporation_affiliations = CharacterAffiliation::query() - ->when( - $affiliation->whereIn('affiliatable_type', [CorporationInfo::class, AllianceInfo::class])->count(), - fn (Builder $query) => $query - ->leftJoinSub( - $affiliation, - $alias, - fn (JoinClause $join) => $this->joinAffiliatedCorporationAffiliations($join, $alias) - ) - ->whereNull("$alias.affiliatable_id"), - fn (Builder $query) => $query->whereNull('corporation_id') - ) - ->select('character_affiliations.corporation_id as affiliated_id'); - - $alliance_affiliations = CharacterAffiliation::query() - ->when( - $affiliation->where('affiliatable_type', AllianceInfo::class)->count(), - fn (Builder $query) => $query - ->leftJoinSub( - $affiliation, - $alias, - 'character_affiliations.alliance_id', - '=', - "$alias.affiliatable_id" - ) - ->whereNull("$alias.affiliatable_id"), - fn (Builder $query) => $query->whereNull('alliance_id') - ) - ->select('character_affiliations.alliance_id as affiliated_id'); - - return $character_affiliations - ->union($corporation_affiliations) - ->union($alliance_affiliations); - } -} diff --git a/src/Services/Affiliations/GetOwnedAffiliatedIdsService.php b/src/Services/Affiliations/GetOwnedAffiliatedIdsService.php deleted file mode 100644 index 95a1a76..0000000 --- a/src/Services/Affiliations/GetOwnedAffiliatedIdsService.php +++ /dev/null @@ -1,73 +0,0 @@ -getCharacterQuery(); - - if (! $this->affiliationsDto->corporation_roles) { - return $character_query; - } - - $corporation_query = $this->getCorporationQuery(); - - return $character_query - ->union($corporation_query); - } - - private function getCharacterQuery(): Builder - { - return CharacterAffiliation::query() - ->join( - 'character_users', - fn (JoinClause $join) => $join - ->on('character_affiliations.character_id', '=', 'character_users.character_id') - ->where('user_id', $this->affiliationsDto->user->getAuthIdentifier()) - ) - ->select('character_affiliations.character_id as affiliated_id'); - } - - private function getCorporationQuery(): Builder - { - $character_users = CharacterUser::query() - ->whereHas( - 'character.roles', - function (Builder $query) { - $query->whereJsonContains('roles', 'Director'); - - foreach ($this->affiliationsDto->corporation_roles as $role) { - $query->orWhereJsonContains('roles', $role); - } - } - ) - ->where('user_id', $this->affiliationsDto->user->getAuthIdentifier()); - - return CharacterAffiliation::query() - ->joinSub( - $character_users, - 'character_users_sub', - 'character_affiliations.character_id', - '=', - 'character_users_sub.character_id' - ) - ->select('character_affiliations.corporation_id as affiliated_id'); - } -} diff --git a/src/Services/AuthenticationService.php b/src/Services/AuthenticationService.php new file mode 100644 index 0000000..2a696d9 --- /dev/null +++ b/src/Services/AuthenticationService.php @@ -0,0 +1,66 @@ +auth = $auth; + $this->session = $session; + } + + /** + * Login the user. + * + * This method returns a boolean as a status flag for the + * login routine. If a false is returned, it might mean + * that that account is not allowed to sign in. + */ + public function loginUser(User $user): bool + { + try { + $this->auth->login($user, true); + } catch (\Exception $e) { + report($e); + + return false; + } + + return true; + } + + public function setIntendedUrl(string $url): void + { + Redirect::setIntendedUrl($url); + } + + public function getPreviousUrl(): string + { + return $this->session->previousUrl(); + } + + public function flashMessage(string $type, string $message): void + { + $this->session->flash($type, $message); + } + + public function getSessionValue(string $key): mixed + { + return $this->session->pull($key); + } + + public function isUserAuthenticated(): bool + { + return $this->auth->check(); + } +} diff --git a/src/Services/BuildCharacterScopesArray.php b/src/Services/BuildCharacterScopesArray.php deleted file mode 100644 index 6713873..0000000 --- a/src/Services/BuildCharacterScopesArray.php +++ /dev/null @@ -1,100 +0,0 @@ -withUserScope) { - return []; - } - - return $this->user_scopes; - } - - public function getCharacter(): CharacterInfo - { - return $this->character; - } - - public static function make(): self - { - return new self(); - } - - public function setUserScopes(array $user_scopes): self - { - $this->withUserScope = true; - $this->user_scopes = $user_scopes; - - return $this; - } - - public function setCharacter(CharacterInfo $character): self - { - $this->character = $character; - - return $this; - } - - public function get(): array - { - $character_array = [ - 'character' => $this->getCharacter(), - 'required_scopes' => collect([ - 'corporation_scopes' => $this->getCharacter()->corporation->ssoScopes->selected_scopes ?? [], - 'alliance_scopes' => $this->getCharacter()->alliance->ssoScopes->selected_scopes ?? [], - 'character_application_corporation_scopes' => $this->getCharacter()->application->corporation->ssoScopes->selected_scopes ?? [], - 'character_application_alliance_scopes' => $this->getCharacter()->application->corporation->alliance->ssoScopes->selected_scopes ?? [], - 'user_scope' => $this->getUserScopes(), - ])->flatten(1) - ->filter() - ->unique() - ->flatten(1) - ->toArray(), - 'token_scopes' => $this->getCharacter()->refresh_token->scopes ?? [], - ]; - - $required_scopes = Arr::get($character_array, 'required_scopes'); - $token_scopes = Arr::get($character_array, 'token_scopes'); - $missing_scopes = collect($required_scopes) - ->reject(fn (string $required_scope) => in_array($required_scope, $token_scopes)) - ->toArray(); - - return Arr::add($character_array, 'missing_scopes', array_values($missing_scopes)); - } -} diff --git a/src/Services/BuildUserLevelRequiredScopes.php b/src/Services/BuildUserLevelRequiredScopes.php deleted file mode 100644 index dff3813..0000000 --- a/src/Services/BuildUserLevelRequiredScopes.php +++ /dev/null @@ -1,76 +0,0 @@ -replicate(); - - return $user - ->characters - ->map(fn (CharacterInfo $character) => collect([ - $character->corporation->ssoScopes ?? [], - $character->alliance->ssoScopes ?? [], - ])->where('type', 'user')) - ->filter(fn (Collection $collection) => $collection->isNotEmpty()) - ->map(fn (Collection $collection) => $collection->map(fn (SsoScopes $scope) => [ - $scope->selected_scopes, - ])) - ->concat([ - 'user_application_corporation_scopes' => $user->getRelation('application') ? $user->application->corporation->ssoScopes?->selected_scopes : [], - 'user_application_alliance_scopes' => $user->getRelation('application') ? $user->application->corporation->alliance?->ssoScopes?->selected_scopes : [], - 'global_scopes' => self::getGlobalScopes($user), - ]) - ->flatten() - ->unique() - ->toArray(); - } - - private static function getSelectedScopes(): array - { - $query_result = SsoScopes::global()->select('selected_scopes')->first(); - - return $query_result ? $query_result->selected_scopes : []; - } - - private static function getGlobalScopes(User $user): array - { - - $global_scopes = Arr::has($user->getAttributes(), 'global_scope') ? $user->getAttribute('global_scope') : self::getSelectedScopes(); - - // return is_array($global_scopes) ? $global_scopes : json_decode($global_scopes, true) ?? []; - return is_array($global_scopes) ? $global_scopes : (is_string($global_scopes) ? json_decode($global_scopes) : []); - } -} diff --git a/src/Services/ConvertClassToPermissionStringService.php b/src/Services/ConvertClassToPermissionStringService.php deleted file mode 100644 index 29452e7..0000000 --- a/src/Services/ConvertClassToPermissionStringService.php +++ /dev/null @@ -1,13 +0,0 @@ -scopes = collect(config('eveapi.scopes.minimum')); - } - - public function execute(): Collection - { - if (auth()->guest()) { - return $this->scopes->merge(setting('global_sso_scopes')); - } - - /** @noinspection PhpFieldAssignmentTypeMismatchInspection */ - $this->user = User::with( - 'application.corporation.ssoScopes', - 'application.corporation.alliance.ssoScopes' - )->find(auth()->user()->getAuthIdentifier()); - - return $this->scopes - ->merge( - collect([ - setting('global_sso_scopes'), - $this->user->application->corporation->ssoScopes->selected_scopes ?? [], - $this->user->application->corporation->alliance->ssoScopes->selected_scopes ?? [], - ]) - ) - ->flatten(1) - ->unique() - ->filter(); - } -} diff --git a/src/Services/Permissions/CanUserService.php b/src/Services/Permissions/CanUserService.php new file mode 100644 index 0000000..903cb0a --- /dev/null +++ b/src/Services/Permissions/CanUserService.php @@ -0,0 +1,159 @@ +user_permission_service = $this->user_permission_service ?? new UserPermissionService; + } + + /** + * @throws ValidationException + */ + public function check(User $user, ValidateIdsDTO $idsDTO, array $permissions, array $corporation_roles = []): bool + { + $ids_to_validate = $idsDTO->get(); + + // match whether ids are provided or not + $is_validated = match (empty($ids_to_validate)) { + true => $this->validateSimplePermissions($user, $permissions, $corporation_roles), + false => $this->validateIds($user, $ids_to_validate, $permissions, $corporation_roles) + }; + + return $is_validated || $user->can('superuser'); + } + + private function validateOwnedCharacterIds(array $data, Closure $next): array + { + $ids_to_validate = $data['ids_to_validate']; + + $owned_character_ids = $data['user_permissions']['owned_character_ids']; + + // remove owned character ids from ids + $ids_to_validate = array_diff($ids_to_validate, $owned_character_ids); + + $data['ids_to_validate'] = $ids_to_validate; + + return $next($data); + } + + private function validateCorporationRoles(array $data, Closure $next): array + { + $ids_to_validate = $data['ids_to_validate']; + + // if no ids are left, we return early + if (empty($ids_to_validate)) { + return $next($data); + } + + $corporation_roles = $data['corporation_roles']; + $user_permissions = $data['user_permissions']; + + // if a corporation role is provided, we check if the user has the required role + if ($corporation_roles) { + + // add Director to corporation roles + $corporation_roles[] = 'Director'; + + foreach ($corporation_roles as $corporation_role) { + $corporation_role_ids = $user_permissions['corporation_roles'][$corporation_role] ?? []; + + // remove ids that are within the corp_ids from ids_to_validate + $ids_to_validate = array_diff($ids_to_validate, $corporation_role_ids); + $data['ids_to_validate'] = $ids_to_validate; + + // if ids are empty, end the loop + if (empty($ids_to_validate)) { + break; + } + } + } + + return $next($data); + } + + private function validatePermissions(array $data, Closure $next): array + { + $ids_to_validate = $data['ids_to_validate']; + + // if no ids are left, we return early + if (empty($ids_to_validate)) { + return $next($data); + } + + $permissions = $data['permissions']; + $user_permissions = $data['user_permissions']; + + // check if user has the required permissions + foreach ($permissions as $permission) { + $ids_with_permission = $user_permissions['permissions'][$permission] ?? []; + + // remove ids that are within the ids_with_permission from ids_to_validate + $ids_to_validate = array_diff($ids_to_validate, $ids_with_permission); + $data['ids_to_validate'] = $ids_to_validate; + + // if ids are empty, end the loop + if (empty($ids_to_validate)) { + break; + } + } + + return $next($data); + } + + private function validateIds(User $user, array $ids_to_validate, array $permissions, array $corporation_roles): bool + { + + $data = app(Pipeline::class) + ->send([ + 'ids_to_validate' => $ids_to_validate, + 'user_permissions' => $this->getUserPermissionObject($user), + 'permissions' => $permissions, + 'corporation_roles' => $corporation_roles, + ]) + ->through([ + fn (array $data, Closure $next) => $this->validateOwnedCharacterIds($data, $next), + fn (array $data, Closure $next) => $this->validateCorporationRoles($data, $next), + fn (array $data, Closure $next) => $this->validatePermissions($data, $next), + ])->thenReturn(); + + $ids_not_validated = $data['ids_to_validate']; + + // return true if all ids are validated + return empty($ids_not_validated); + } + + private function validateSimplePermissions(User $user, array $permissions, array $corporation_role): bool + { + if ($user->hasAnyPermission($permissions)) { + return true; + } + + $user_permission_object = $this->getUserPermissionObject($user); + + $users_corporation_roles = array_keys([...$user_permission_object['corporation_roles']]); + + // if user corporation roles contain the role 'Director' we return true + if (in_array('Director', $users_corporation_roles)) { + return true; + } + + // if any of the corporation roles is in the users corporation roles, we return true + return (bool) array_intersect($corporation_role, $users_corporation_roles); + } + + public function getUserPermissionObject(User $user): mixed + { + return Cache::remember("user_permissions_{$user->id}", now()->addMinutes(5), fn () => $this->user_permission_service->get($user)); + } +} diff --git a/src/Services/Permissions/DTO/ValidateIdsDTO.php b/src/Services/Permissions/DTO/ValidateIdsDTO.php new file mode 100644 index 0000000..fdc4b45 --- /dev/null +++ b/src/Services/Permissions/DTO/ValidateIdsDTO.php @@ -0,0 +1,100 @@ +all(), ...$request->route()->parameters()]; + + return new self( + character_id: Arr::get($all_data, 'character_id'), + corporation_id: Arr::get($all_data, 'corporation_id'), + alliance_id: Arr::get($all_data, 'alliance_id'), + character_ids: Arr::get($all_data, 'character_ids'), + corporation_ids: Arr::get($all_data, 'corporation_ids'), + alliance_ids: Arr::get($all_data, 'alliance_ids') + ); + } + + /*public static function make(...$args): ValidateIdsDTO + { + return new self(...$args); + }*/ + + /** + * @throws ValidationException + */ + public function get(): array + { + + // if any of the constructor parameters is not null, we return the validated array + if (! array_filter(get_object_vars($this), fn (null|int|array $value) => ! is_null($value))) { + return []; + } + + return collect($this->validate()) + ->flatten() + ->map(fn (string|int $value) => (int) $value) + ->all(); + } + + /** + * @throws ValidationException + */ + private function validate(): array + { + $ids = collect([ + 'character_id' => $this->character_id, + 'corporation_id' => $this->corporation_id, + 'alliance_id' => $this->alliance_id, + 'character_ids' => $this->character_ids, + 'corporation_ids' => $this->corporation_ids, + 'alliance_ids' => $this->alliance_ids, + ])->filter()->all(); + + $keys = [ + 'character_id', 'character_ids', + 'corporation_id', 'corporation_ids', + 'alliance_id', 'alliance_ids', + ]; + + $presentKeys = array_filter($keys, function (string $key) use ($ids) { + return ! is_null($ids[$key] ?? null); + }); + + abort_unless(count($presentKeys) === 1, 403, 'Exactly one of the parameters ['.implode(', ', $keys).'] must be present.'); + + $validator = Validator::make($ids, [ + 'character_id' => 'nullable|integer', + 'character_ids' => 'nullable|array', + 'character_ids.*' => 'integer', + 'corporation_id' => 'nullable|integer', + 'corporation_ids' => 'nullable|array', + 'corporation_ids.*' => 'integer', + 'alliance_id' => 'nullable|integer', + 'alliance_ids' => 'nullable|array', + 'alliance_ids.*' => 'integer', + ]); + + abort_if($validator->fails(), 403, implode(', ', $validator->errors()->all())); + + return $validator->validated(); + } +} diff --git a/src/Services/Permissions/RolePermissionObjectService.php b/src/Services/Permissions/RolePermissionObjectService.php new file mode 100644 index 0000000..9c58c3c --- /dev/null +++ b/src/Services/Permissions/RolePermissionObjectService.php @@ -0,0 +1,27 @@ +role_affiliated_ids_service = $role_affiliated_ids_service ?? new RoleAffiliatedIdsService; + } + + public function get(Role $role): Collection + { + $role = $role->loadMissing('permissions'); + + $affiliated_ids = $this->role_affiliated_ids_service->get($role); + + return $role->permissions + ->mapWithKeys(fn (Permission $permission) => [$permission->name => $affiliated_ids]); + } +} diff --git a/src/Services/Permissions/UserPermissionService.php b/src/Services/Permissions/UserPermissionService.php new file mode 100644 index 0000000..4a3c666 --- /dev/null +++ b/src/Services/Permissions/UserPermissionService.php @@ -0,0 +1,77 @@ +role_permission_object_service = $role_permission_object_service ?? new RolePermissionObjectService; + } + + public function get(User $user): array + { + + $user = $user->loadMissing(['characters.roles', 'roles.permissions']); + + $this->buildCorporationRoles($user); + $this->buildPermissions($user); + $this->buildCharacterIds($user); + + return [ + 'corporation_roles' => $this->corporation_roles, + 'permissions' => $this->permissions, + 'character_ids' => $this->character_ids, + 'owned_character_ids' => $user->characters->pluck('character_id')->toArray(), + ]; + + } + + private function buildCorporationRoles(User $user): void + { + $user + ->characters + ->each(function (CharacterInfo $character) { + + /** @var array $roles */ + $roles = $character->roles->roles ?? []; + + if (empty($roles)) { + return; + } + + foreach ($roles as $role) { + $this->corporation_roles[$role] = array_merge($this->corporation_roles[$role] ?? [], [$character->corporation_id]); + } + }); + } + + private function buildPermissions(User $user): void + { + $user->roles->each(function (Role $role) { + $role_permissions = $this->role_permission_object_service->get($role); + + // merge on permissions. The key might exist, so we extend the array + $this->permissions = $role_permissions + ->mergeRecursive($this->permissions) + ->toArray(); + + }); + } + + private function buildCharacterIds(User $user): void + { + $this->character_ids = $user->characters->pluck('character_id')->toArray(); + } +} diff --git a/src/Services/Roles/AbstractRoleService.php b/src/Services/Roles/AbstractRoleService.php new file mode 100644 index 0000000..adb814a --- /dev/null +++ b/src/Services/Roles/AbstractRoleService.php @@ -0,0 +1,271 @@ +create([ + 'role_id' => $this->role->id, + 'affiliatable_id' => $entity_id, + 'affiliatable_type' => $entity_type, + 'type' => $affiliationType->value, + ]); + } + + private function resetAffiliation(): void + { + Affiliation::query() + ->where('role_id', $this->role->id) + ->delete(); + } + + /** + * @throws \Throwable + */ + protected function addCriteria(CriteriaData ...$entities): void + { + $this->resetCriteria(); + + foreach ($entities as $entity) { + $this->setRoleMembership( + entity_id: $entity->entity_id, + entity_type: $entity->entityClass() + ); + } + } + + private function resetCriteria(): void + { + RoleMembership::query() + ->where('role_id', $this->role->id) + ->whereIn('entity_type', [CorporationInfo::class, AllianceInfo::class]) + ->delete(); + } + + private function revokeTheRolesFromUsersThatAreNotInMembers(\Illuminate\Support\Collection $member_ids): void + { + User::query() + ->whereHas('roles', fn (Builder $query) => $query->where('id', $this->role->id)) + ->whereNotIn('id', $member_ids) + ->each(fn (User $user) => $user->removeRole($this->role)); + } + + private function getActiveMembers(): \Illuminate\Support\Collection + { + return $this->role->role_memberships() + ->where('entity_type', User::class) + ->where('status', RoleMembershipStatus::ACTIVE) + ->pluck('entity_id'); + } + + private function assignTheRolesToUsersThatAreInMembers(\Illuminate\Support\Collection $member_ids): void + { + User::query() + ->whereDoesntHave('roles', fn (Builder $query) => $query->where('id', $this->role->id)) + ->whereIn('id', $member_ids) + ->each(fn (User $user) => $user->assignRole($this->role)); + } + + protected function removeRoleMembership(User $user): void + { + RoleMembership::query() + ->where('role_id', $this->role->id) + ->where('entity_id', $user->id) + ->where('entity_type', User::class) + ->delete(); + } + + public function setRoleType(RoleType $roleType): void + { + $originalRoleType = $this->role->type; + + // if the role type has not changed, we return early + if ($originalRoleType === $roleType) { + return; + } + + $this->role->update([ + 'type' => $roleType, + ]); + + $this->resetRoleMemberships(); + } + + protected function setRoleMembership( + int|string $entity_id, + string $entity_type, + bool $can_moderate = false, + ?RoleMembershipStatus $status = null + ): void { + + $values_to_update = ['can_moderate' => $can_moderate]; + + // if $status is set, we add it to the values to update + if ($status) { + $values_to_update['status'] = $status->value; + } + + RoleMembership::query()->updateOrInsert([ + 'role_id' => $this->role->id, + 'entity_id' => $entity_id, + 'entity_type' => $entity_type, + ], $values_to_update); + } + + protected function getAssignedCharacterIds(): array + { + $role = $this->role->refresh()->loadMissing(['role_memberships.entity' => function (MorphTo $morph_to) { + $morph_to->morphWith([CorporationInfo::class => 'characters', AllianceInfo::class => 'characters']); + }]); + + return $role + ->role_memberships + ->filter(fn (RoleMembership $role_membership) => $role_membership->entity_type === CorporationInfo::class || $role_membership->entity_type === AllianceInfo::class) + ->pluck('entity.characters') + ->flatten() + ->pluck('character_id') + ->toArray(); + } + + protected function getUnassignedMembers(): Collection + { + + $members = RoleMembership::query() + ->where('role_id', $this->role->id) + ->where('entity_type', User::class); + + $character_ids = $this->getAssignedCharacterIds(); + + if (! array_filter($character_ids)) { + return $members->get(); + } + + return $members->whereDoesntHaveMorph( + 'entity', + [User::class], + fn (Builder $query) => $query + ->whereHas('characters', fn (Builder $query) => $query + ->whereIn('character_infos.character_id', $character_ids) + ) + ) + ->get(); + } + + protected function removeUnassignedMembers(): void + { + $unassigned_members = $this->getUnassignedMembers(); + $unassigned_members->each(fn (RoleMembership $role_membership) => $role_membership->delete()); + } + + public function handleMembers(): void + { + // sync the members, so that only the members that are compliant are considered as active + $this->syncMembers(); + + // get the active members + $member_ids = $this->getActiveMembers(); + + // revoke the roles from users that are not in members + $this->revokeTheRolesFromUsersThatAreNotInMembers($member_ids); + + // assign the roles to users that are in members + $this->assignTheRolesToUsersThatAreInMembers($member_ids); + } + + protected function isUserCompliant(User $user): bool + { + // build a service to check if user is compliant. We do not consider applications here + $is_user_compliant_service = new IsUserCompliantService(false); + + return $is_user_compliant_service->check($user); + } + + /** + * @throws \Throwable + */ + public function syncAffiliateManyEntities(AffiliationData ...$entity_sets): void + { + $this->resetAffiliation(); + + foreach ($entity_sets as $entity_set) { + $this->affiliateEntity($entity_set->entity_id, $entity_set->entityClass(), $entity_set->affiliation_type); + } + } + + public function updateMemberStatusBasedOnUserCompliance(): void + { + RoleMembership::query() + ->where('role_id', $this->role->id) + ->where('entity_type', User::class) + ->whereIn('status', [RoleMembershipStatus::ACTIVE->value, RoleMembershipStatus::INACTIVE->value]) + ->with('entity') + ->get() + ->each(fn (RoleMembership $role_membership) => $role_membership->updateOrFail([ + 'status' => $this->isUserCompliant($role_membership->entity) ? RoleMembershipStatus::ACTIVE : RoleMembershipStatus::INACTIVE, + ])); + } + + abstract public function syncMembers(): void; + + protected function resetRoleMemberships(): void + { + RoleMembership::query() + ->where('role_id', $this->role->id) + ->delete(); + } + + protected function isModerator(User $user): bool + { + return RoleMembership::query() + ->where('role_id', $this->role->id) + ->where('entity_id', $user->id) + ->where('entity_type', User::class) + ->where('can_moderate', true) + ->exists(); + } + + protected function meetsCriteria(User $user): bool + { + + $assigned_character_ids = $this->getAssignedCharacterIds(); + + // return early if no character is assigned + if (empty($assigned_character_ids)) { + return false; + } + + return User::query() + ->where('id', $user->id) + ->whereHas('characters', fn (Builder $query) => $query->whereIn('character_infos.character_id', $assigned_character_ids)) + ->exists(); + } + + public function updateRoleName(string $name): void + { + $this->role->update([ + 'name' => $name, + ]); + } +} diff --git a/src/Services/Roles/AutomaticRoleService.php b/src/Services/Roles/AutomaticRoleService.php new file mode 100644 index 0000000..6331b37 --- /dev/null +++ b/src/Services/Roles/AutomaticRoleService.php @@ -0,0 +1,62 @@ +addCriteria(...$entities); + + $this->handleMembers(); + } + + public function syncMembers(): void + { + // remove members that are not within users + $this->removeUnassignedMembers(); + + $this->addAssignedMembers(); + + $this->updateMemberStatusBasedOnUserCompliance(); + } + + private function addAssignedMembers(): void + { + $assigned_character_ids = $this->getAssignedCharacterIds(); + $users = User::query() + ->whereHas('characters', fn (Builder $query) => $query->whereIn('character_infos.character_id', $assigned_character_ids)) + ->get(); + + $users->each(fn (User $user) => $this->setRoleMembership( + entity_id: $user->id, + entity_type: User::class, + status: RoleMembershipStatus::ACTIVE + )); + } + + public function canJoin(User $user): bool + { + return false; + } + + public function canModerate(User $user): bool + { + return false; + } + + public function canView(User $user): bool + { + return $this->meetsCriteria($user); + } +} diff --git a/src/Services/Roles/BaseRoleService.php b/src/Services/Roles/BaseRoleService.php new file mode 100644 index 0000000..3e68990 --- /dev/null +++ b/src/Services/Roles/BaseRoleService.php @@ -0,0 +1,92 @@ +for($role); + } + + public function for(Role|string|int $role): self + { + + /* @var Role $resolved_role */ + $resolved_role = match (true) { + $role instanceof Role => $role, + is_string($role) => Role::findByName($role), + is_int($role) => Role::findById($role), + }; + + $this->role = $resolved_role; + + return $this; + } + + public function automatic(): AutomaticRoleService + { + return new AutomaticRoleService($this->role); + } + + public function onRequest(): OnRequestRoleService + { + return new OnRequestRoleService($this->role); + } + + public function manual(): ManualRoleService + { + return new ManualRoleService($this->role); + } + + public function optIn(): OptInRoleService + { + return new OptInRoleService($this->role); + } + + /** + * @throws \Exception + */ + public function getTypeService(): RoleServiceInterface + { + return match ($this->getType()) { + RoleType::AUTOMATIC => $this->automatic(), + RoleType::ON_REQUEST => $this->onRequest(), + RoleType::MANUAL => $this->manual(), + RoleType::OPT_IN => $this->optIn(), + }; + } + + public function getType(): RoleType + { + return $this->role->type; + } + + public function handleMembers(): void + { + $this->getTypeService()->handleMembers(); + } + + public function canView(User $user): bool + { + return $this->getTypeService()->canView($user); + } + + public function canJoin(User $user): bool + { + return $this->getTypeService()->canJoin($user); + } + + public function canModerate(User $user): bool + { + return $this->getTypeService()->canModerate($user); + } +} diff --git a/src/Services/Roles/DTO/AffiliationData.php b/src/Services/Roles/DTO/AffiliationData.php new file mode 100644 index 0000000..57e4fa9 --- /dev/null +++ b/src/Services/Roles/DTO/AffiliationData.php @@ -0,0 +1,38 @@ +entity_type) { + 'character' => CharacterInfo::class, + 'corporation' => CorporationInfo::class, + 'alliance' => AllianceInfo::class, + default => throw new \ValueError("Unknown entity type: {$this->entity_type}"), + }; + } +} diff --git a/src/Services/Roles/DTO/CriteriaData.php b/src/Services/Roles/DTO/CriteriaData.php new file mode 100644 index 0000000..3353d5b --- /dev/null +++ b/src/Services/Roles/DTO/CriteriaData.php @@ -0,0 +1,33 @@ +entity_type) { + 'corporation' => CorporationInfo::class, + 'alliance' => AllianceInfo::class, + default => throw new \ValueError("Unknown entity type: {$this->entity_type}"), + }; + } +} diff --git a/src/Services/Roles/ManualRoleService.php b/src/Services/Roles/ManualRoleService.php new file mode 100644 index 0000000..758895c --- /dev/null +++ b/src/Services/Roles/ManualRoleService.php @@ -0,0 +1,53 @@ +setRoleMembership( + entity_id: $user->id, + entity_type: User::class, + can_moderate: $can_moderate + ); + } + + public function addMember(User $user): void + { + $this->setRoleMembership( + entity_id: $user->id, + entity_type: User::class, + status: RoleMembershipStatus::ACTIVE + ); + } + + public function removeMember(User $user): void + { + $this->removeRoleMembership($user); + } + + public function syncMembers(): void + { + // update the status of the members based on the user compliance + $this->updateMemberStatusBasedOnUserCompliance(); + } + + public function canJoin(User $user): bool + { + return false; + } + + public function canModerate(User $user): bool + { + return $this->isModerator($user); + } + + public function canView(User $user): bool + { + return false; + } +} diff --git a/src/Services/Roles/OnRequestRoleService.php b/src/Services/Roles/OnRequestRoleService.php new file mode 100644 index 0000000..978557d --- /dev/null +++ b/src/Services/Roles/OnRequestRoleService.php @@ -0,0 +1,101 @@ +addCriteria(...$entities); + + $this->syncMembers(); + } + + public function submitApplicationForRole(User $user): void + { + + $meets_criteria = $this->meetsCriteria($user); + + if (! $meets_criteria) { + throw new \Exception('User does not meet criteria to join role'); + } + + $this->setRoleMembership( + entity_id: $user->id, + entity_type: User::class, + status: RoleMembershipStatus::PENDING + ); + } + + /** + * @throws \Exception + */ + public function approveApplicationForRole(User $user): void + { + + $meets_criteria = $this->meetsCriteria($user); + + if (! $meets_criteria) { + $this->removeRoleMembership($user); + throw new \Exception('User does not meet criteria to join role'); + } + + $this->setRoleMembership( + entity_id: $user->id, + entity_type: User::class, + status: RoleMembershipStatus::ACTIVE + ); + } + + public function denyApplication(User $user): void + { + $this->removeRoleMembership($user); + } + + public function removeApplication(User $user): void + { + $this->removeRoleMembership($user); + } + + public function setModerator(User $user, bool $can_moderate = true): void + { + $this->setRoleMembership( + entity_id: $user->id, + entity_type: User::class, + can_moderate: $can_moderate + ); + } + + public function syncMembers(): void + { + // remove all members that are not within the criteria + $this->removeUnassignedMembers(); + + // update the status of the members based on the user compliance + $this->updateMemberStatusBasedOnUserCompliance(); + } + + public function canView(User $user): bool + { + return $this->meetsCriteria($user); + } + + public function canJoin(User $user): bool + { + return $this->meetsCriteria($user); + } + + public function canModerate(User $user): bool + { + return $this->isModerator($user); + } +} diff --git a/src/Services/Roles/OptInRoleService.php b/src/Services/Roles/OptInRoleService.php new file mode 100644 index 0000000..b6d9bda --- /dev/null +++ b/src/Services/Roles/OptInRoleService.php @@ -0,0 +1,75 @@ +addCriteria(...$entities); + + $this->syncMembers(); + } + + /** + * @throws \Throwable + */ + public function joinRole(User $user): void + { + + throw_unless($this->meetsCriteria($user), \Exception::class, 'User does not meet criteria to join role'); + + $this->setRoleMembership( + entity_id: $user->id, + entity_type: User::class, + status: RoleMembershipStatus::ACTIVE + ); + } + + public function leaveRole(User $user): void + { + $this->removeRoleMembership($user); + } + + public function setModerator(User $user, bool $can_moderate = true): void + { + $this->setRoleMembership( + entity_id: $user->id, + entity_type: User::class, + can_moderate: $can_moderate + ); + } + + public function syncMembers(): void + { + // remove all members that are not within the criteria + $this->removeUnassignedMembers(); + + // update the status of the members based on the user compliance + $this->updateMemberStatusBasedOnUserCompliance(); + } + + public function canView(User $user): bool + { + return $this->meetsCriteria($user); + } + + public function canJoin(User $user): bool + { + return $this->meetsCriteria($user); + } + + public function canModerate(User $user): bool + { + return $this->isModerator($user); + } +} diff --git a/src/Services/Roles/RoleAffiliatedIdsService.php b/src/Services/Roles/RoleAffiliatedIdsService.php new file mode 100644 index 0000000..5cff9b1 --- /dev/null +++ b/src/Services/Roles/RoleAffiliatedIdsService.php @@ -0,0 +1,71 @@ +buildAffiliatedIds($role); + } + + private function buildInverse(Collection $inverted): Collection + { + + return CharacterInfo::query()->whereNotIn('character_id', $inverted)->pluck('character_id') + ->merge(CorporationInfo::query()->whereNotIn('corporation_id', $inverted)->pluck('corporation_id')) + ->merge(AllianceInfo::query()->whereNotIn('alliance_id', $inverted)->pluck('alliance_id')); + } + + private function buildAffiliatedIds(Role $role): array + { + $role = $this->loadMissingRelationships($role); + + $allowed = collect(); + $inverted = collect(); + $forbidden = collect(); + + $role->affiliations->each(function (Affiliation $affiliation) use (&$allowed, &$inverted, &$forbidden) { + + $affiliated_ids = $affiliation->affiliated_ids; + + match ($affiliation->type) { + AffiliationType::ALLOWED->value => $allowed = $allowed->merge($affiliated_ids), + AffiliationType::INVERSE->value => $inverted = $inverted->merge($affiliated_ids), + AffiliationType::FORBIDDEN->value => $forbidden = $forbidden->merge($affiliated_ids), + }; + }); + + // if ids are present, + // build inverse of inverted and merge with allowed + if ($inverted->isNotEmpty()) { + $allowed = $allowed->merge($this->buildInverse($inverted)); + } + + // remove forbidden + $allowed = $allowed->diff($forbidden); + + return $allowed->all(); + } + + public function loadMissingRelationships(Role $role): Role + { + return $role->loadMissing([ + 'affiliations.affiliatable' => fn (MorphTo $morph_to) => $morph_to + ->morphWith([ + CorporationInfo::class => 'characters', + AllianceInfo::class => ['characters', 'corporations'], + ]), + ]); + } +} diff --git a/src/Services/Roles/RoleServiceInterface.php b/src/Services/Roles/RoleServiceInterface.php new file mode 100644 index 0000000..b999a0c --- /dev/null +++ b/src/Services/Roles/RoleServiceInterface.php @@ -0,0 +1,23 @@ + */ + const array USER_RELATIONS = [ + 'characters' => self::CHARACTER_RELATIONS, + 'application.corporation' => ['ssoScopes', 'alliance.ssoScopes'], + ]; + + /** @var array */ + const array CHARACTER_RELATIONS = [ + 'alliance.ssoScopes', + 'corporation.ssoScopes', + 'application.corporation' => ['ssoScopes', 'alliance.ssoScopes'], + 'refresh_token', + ]; + + public function __construct( + private readonly bool $with_application_scopes = true, + private ?GlobalSsoScopesService $globalSsoScopesService = null + ) { + $this->globalSsoScopesService = $globalSsoScopesService ?? new GlobalSsoScopesService; + } + + private function getUserRequiredScopes(User $user): array + { + $user = $user->loadMissing(self::USER_RELATIONS); + + $required_scopes = $this->getUserScopes($user); + + if ($this->isWithApplicationScopes()) { + $required_scopes['user_application_corporation_scopes'] = $user->application->corporation->ssoScopes->selected_scopes ?? []; + $required_scopes['user_application_alliance_scopes'] = $user->application->corporation->alliance->ssoScopes->selected_scopes ?? []; + } + + return collect($required_scopes) + ->flatten(1) + ->filter() + ->unique() + ->flatten(1) + ->toArray(); + } + + private function getGlobalScopes(): array + { + return $this->globalSsoScopesService->get(); + } + + private function getUserScopes(User $user): array + { + // get all corporation and alliance ids + $corporation_ids = $user->characters->pluck('corporation.corporation_id')->unique()->all(); + $alliance_ids = $user->characters->pluck('alliance.alliance_id')->filter()->unique()->all(); + + // get all scopes for the corporations and alliances + return SsoScopes::query() + ->whereIn('morphable_id', [...$corporation_ids, ...$alliance_ids]) + ->where('type', 'user') + ->pluck('selected_scopes') + ->flatten() + ->unique() + ->toArray(); + } + + private function build(User $user): array + { + $user_required_scopes = $this->getUserRequiredScopes($user); + + return $user->characters + ->map(function (CharacterInfo $character) use ($user_required_scopes) { + + $required_scopes = [...$user_required_scopes, ...$this->getCharacterRequiredScopes($character)]; + $token_scopes = $character->refresh_token->scopes ?? []; + $missing_scopes = array_diff($required_scopes, $token_scopes); + + return [ + 'character' => $character, + 'required_scopes' => $required_scopes, + 'missing_scopes' => $missing_scopes, + ]; + }) + ->toArray(); + } + + private function getCharacterRequiredScopes(CharacterInfo $character): array + { + $character = $character->loadMissing(self::CHARACTER_RELATIONS); + + $required_scopes = [ + 'corporation_scopes' => $character->corporation->ssoScopes->selected_scopes ?? [], + 'alliance_scopes' => $character->alliance->ssoScopes->selected_scopes ?? [], + 'global_scope' => $this->getGlobalScopes(), + ]; + + if ($this->isWithApplicationScopes()) { + $required_scopes['character_application_corporation_scopes'] = $character->application->corporation->ssoScopes->selected_scopes ?? []; + $required_scopes['character_application_alliance_scopes'] = $character->application->corporation->alliance->ssoScopes->selected_scopes ?? []; + } + + return collect($required_scopes) + ->flatten(1) + ->filter() + ->unique() + ->flatten(1) + ->toArray(); + } + + private function isWithApplicationScopes(): bool + { + return $this->with_application_scopes; + } + + public function get(User|CharacterInfo $entity): array + { + + $user = User::query() + ->when($entity instanceof CharacterInfo, fn (Builder $query) => $query + ->whereHas('characters', fn (Builder $query) => $query + ->where('character_id', $entity->character_id) + ) + ) + ->with(self::USER_RELATIONS) + ->first(); + + return $this->build($user); + } +} diff --git a/src/Services/SsoScopes/GlobalSsoScopesService.php b/src/Services/SsoScopes/GlobalSsoScopesService.php new file mode 100644 index 0000000..a957d5e --- /dev/null +++ b/src/Services/SsoScopes/GlobalSsoScopesService.php @@ -0,0 +1,24 @@ +create([ + 'selected_scopes' => $scopes, + 'type' => 'global', + ]); + } + + public function get(): array + { + return SsoScopes::query() + ->where('type', 'global') + ->pluck('selected_scopes') + ->toArray(); + } +} diff --git a/src/Services/SsoScopes/IsUserCompliantService.php b/src/Services/SsoScopes/IsUserCompliantService.php new file mode 100644 index 0000000..1194a01 --- /dev/null +++ b/src/Services/SsoScopes/IsUserCompliantService.php @@ -0,0 +1,42 @@ +build_scopes_array_service = new BuildScopesArrayService($this->consider_applications); + } + + public function check(User $user): bool + { + $missing_scopes = $this->getMissingScopes($user); + + return $this->isUserCompliant($missing_scopes); + } + + public function getMissingScopes(User $user): array + { + $scopes = $this->build_scopes_array_service + ->get($user); + + return collect($scopes) + ->pluck('missing_scopes') + ->toArray(); + } + + private function isUserCompliant(array $missing_scopes): bool + { + $flat_missing_scopes = collect($missing_scopes) + ->flatten() + ->unique(); + + return $flat_missing_scopes->isEmpty(); + } +} diff --git a/tests/Architecture/ArchitectureTest.php b/tests/Architecture/ArchitectureTest.php new file mode 100644 index 0000000..29a26ff --- /dev/null +++ b/tests/Architecture/ArchitectureTest.php @@ -0,0 +1,5 @@ +expect(['dd', 'dump']) + ->not->toBeUsed(); diff --git a/tests/Feature/Jobs/UserRolesSyncTest.php b/tests/Feature/Jobs/UserRolesSyncTest.php deleted file mode 100644 index 919e208..0000000 --- a/tests/Feature/Jobs/UserRolesSyncTest.php +++ /dev/null @@ -1,153 +0,0 @@ -role = Role::create(['name' => 'derp']); - - test()->test_user = test()->test_user->refresh(); - - test()->job = new UserRolesSync(test()->test_user); -}); - -it('gives automatic role', function () { - // Update role to be automatic - test()->role->update(['type' => 'automatic']); - - //assure that role is of type auto - expect(test()->role->type)->toEqual('automatic'); - - // First create acl affiliation with user - test()->role->acl_affiliations()->create([ - 'affiliatable_id' => test()->test_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - ]); - - expect(test()->role->members->isEmpty())->toBeTrue(); - - test()->job->handle(); - - expect(test()->role->refresh()->members->isEmpty())->toBeFalse(); - - expect(test()->test_user->hasRole('derp'))->toBeTrue(); -}); - -it('removes automatic role', function () { - // Update role to be automatic - test()->role->update(['type' => 'automatic']); - - //assure that role is of type auto - expect(test()->role->type)->toEqual('automatic'); - - // First create acl affiliation with user - test()->role->acl_affiliations()->create([ - 'affiliatable_id' => test()->test_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - ]); - - expect(test()->role->members->isEmpty())->toBeTrue(); - - test()->job->handle(); - - expect(test()->role->refresh()->members->isEmpty())->toBeFalse(); - - expect(test()->test_user->hasRole('derp'))->toBeTrue(); - - RefreshToken::find(test()->test_character->character_id)->delete(); - - // we need a new job instance, as the valid character_ids are build in the constructor of the job - $job = new UserRolesSync(test()->test_user->refresh()); - $job->handle(); - - expect(test()->test_user->hasRole('derp'))->toBeFalse(); -}); - -it('adds membership for paused user', function () { - // Update role to be on-request - test()->role->update(['type' => 'on-request']); - - //assure that role is of type auto - expect(test()->role->type)->toEqual('on-request'); - - // First create acl affiliation with user - test()->role->acl_affiliations()->create([ - 'affiliatable_id' => test()->test_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - ]); - - // Second add character as paused to role - test()->role->acl_members()->create([ - 'user_id' => test()->test_user->getAuthIdentifier(), - 'status' => 'paused', - ]); - - expect(test()->role->members->isEmpty())->toBeTrue(); - - test()->job->handle(); - - expect(test()->role->refresh()->members->isEmpty())->toBeFalse(); -}); - -it('removes membership if refresh token is removed', function () { - // Update role to be on-request - test()->role->update(['type' => 'on-request']); - - //assure that role is of type auto - expect(test()->role->type)->toEqual('on-request'); - - // First create acl affiliation with user - test()->role->acl_affiliations()->create([ - 'affiliatable_id' => test()->test_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - ]); - - // Second add character as paused to role - test()->role->acl_members()->create([ - 'user_id' => test()->test_user->getAuthIdentifier(), - 'status' => 'member', - ]); - - expect(test()->role->refresh()->members->isEmpty())->toBeFalse(); - - // Remove refresh_token - RefreshToken::find(test()->test_character->character_id)->delete(); - - // we need a new job instance, as the valid character_ids are build in the constructor of the job - $job = new UserRolesSync(test()->test_user->refresh()); - $job->handle(); - - expect(test()->role->refresh()->members->isEmpty())->toBeTrue(); -}); - -test('roles without acl affiliations are not impacted by job', function () { - // Update role to be on-request - test()->role->update(['type' => 'automatic']); - - expect(test()->role->acl_affiliations->isEmpty())->toBeTrue(); - - //assure that role is of type auto - expect(test()->role->type)->toEqual('automatic'); - - expect(test()->test_user->hasRole(test()->role))->toBeFalse(); - - test()->job->handle(); - - expect(test()->test_user->hasRole(test()->role))->toBeFalse(); -}); - -test('dispatching roles sync', function () { - Queue::fake(); - - $dispatch_job = new DispatchUserRoleSync; - - $dispatch_job->handle(); - - Queue::assertPushedOn('high', UserRolesSync::class); -}); diff --git a/tests/Feature/Middleware/CheckAuthorizationTest.php b/tests/Feature/Middleware/CheckAuthorizationTest.php new file mode 100644 index 0000000..6071019 --- /dev/null +++ b/tests/Feature/Middleware/CheckAuthorizationTest.php @@ -0,0 +1,326 @@ +role = Role::create(['name' => faker()->name]); + $this->permission_name = faker()->name; + test()->permission = Permission::create(['name' => $this->permission_name]); + test()->role->givePermissionTo(test()->permission); + + test()->test_user->assignRole(test()->role); + + app()[PermissionRegistrar::class]->forgetCachedPermissions(); + + Route::middleware([CheckAuthorization::class.":$this->permission_name"]) + ->prefix('character') + ->name('character.') + ->group(function () { + Route::post('/test/', fn () => response('Hello World'))->name('post'); + Route::get('/character/{character_id}/', fn (int $character_id) => response('Hello World'))->name('character'); + Route::get('/corporation/{corporation_id}/', fn (int $corporation_id) => response('Hello World'))->name('corporation'); + Route::get('/alliance/{alliance_id}/', fn (int $alliance_id) => response('Hello World'))->name('alliance'); + Route::get('/character_ids', fn () => response('Hello World'))->name('character_ids'); + Route::get('/corporation_ids', fn () => response('Hello World'))->name('corporation_ids'); + Route::get('/alliance_ids', fn () => response('Hello World'))->name('alliance_ids'); + }); + + Route::middleware([CheckAuthorization::class.":$this->permission_name,Director"]) + ->prefix('corporation') + ->name('corporation.') + ->group(function () { + Route::post('/test/', fn () => response('Hello World'))->name('post'); + Route::get('/character/{character_id}/', fn (int $character_id) => response('Hello World'))->name('character'); + Route::get('/corporation/{corporation_id}/', fn (int $corporation_id) => response('Hello World'))->name('corporation'); + Route::get('/alliance/{alliance_id}/', fn (int $alliance_id) => response('Hello World'))->name('alliance'); + Route::get('/character_ids', fn () => response('Hello World'))->name('character_ids'); + Route::get('/corporation_ids', fn () => response('Hello World'))->name('corporation_ids'); + Route::get('/alliance_ids', fn () => response('Hello World'))->name('alliance_ids'); + }); + + test()->secondary_character = CharacterInfo::factory()->create(); + }); + + it('it validates parameters for superuser', function (string $method, string $route, int|array $route_param, string $status = 'ok') { + assignPermissionToTestUser(['superuser']); + + test()->actingAs(test()->test_user); + + $response = match ($method) { + 'post' => post(route($route, $route_param)), + 'get' => get(route($route, $route_param)) + }; + + match ($status) { + 'forbidden' => $response->assertForbidden(), // 403 + 'ok' => $response->assertOk() + }; + }) + ->with([ + // Character + ['post', 'character.post', fn () => ['character_id' => test()->test_character->character_id]], + ['post', 'character.post', fn () => ['corporation_id' => test()->test_character->corporation->corporation_id]], + ['post', 'character.post', fn () => ['alliance_id' => test()->test_character->alliance->alliance_id]], + ['get', 'character.character', fn () => test()->test_character->character_id], + ['get', 'character.corporation', fn () => test()->test_character->corporation->corporation_id], + ['get', 'character.alliance', fn () => test()->test_character->alliance->alliance_id], + // ['get', 'character.character_ids', fn () => ['character_ids' => []], 'forbidden'], + // ['get', 'character.corporation_ids', fn () => ['corporation_ids' => []], 'forbidden'], + // ['get', 'character.alliance_ids', fn () => ['alliance_ids' => []], 'forbidden'], + ['get', 'character.character_ids', fn () => ['character_ids' => [test()->test_character->character_id]]], + ['get', 'character.corporation_ids', fn () => ['corporation_ids' => [test()->test_character->corporation->corporation_id]]], + ['get', 'character.corporation_ids', fn () => ['alliance_ids' => [test()->test_character->alliance->alliance_id]]], + // Corporation Role + ['post', 'corporation.post', fn () => ['character_id' => test()->test_character->character_id]], + ['post', 'corporation.post', fn () => ['corporation_id' => test()->test_character->corporation->corporation_id]], + ['post', 'corporation.post', fn () => ['alliance_id' => test()->test_character->alliance->alliance_id]], + ['get', 'corporation.character', fn () => test()->test_character->character_id], + ['get', 'corporation.corporation', fn () => test()->test_character->corporation->corporation_id], + ['get', 'corporation.alliance', fn () => test()->test_character->alliance->alliance_id], + // ['get', 'corporation.character_ids', fn () => ['corporation_ids' => []], 'forbidden'], + // ['get', 'corporation.corporation_ids', fn () => ['corporation_ids' => []], 'forbidden'], + // ['get', 'corporation.alliance_ids', fn () => ['alliance_ids' => []], 'forbidden'], + ['get', 'corporation.character_ids', fn () => ['corporation_ids' => [test()->test_character->character_id]]], + ['get', 'corporation.corporation_ids', fn () => ['corporation_ids' => [test()->test_character->corporation->corporation_id]]], + ['get', 'corporation.alliance_ids', fn () => ['alliance_ids' => [test()->test_character->alliance->alliance_id]]], + ]); + + it('checks owned character ids', function (string $method, string $route, array|int $route_param, string $status = 'ok') { + expect(test()->test_user->can('superuser'))->toBeFalse(); + + // Ensure no stale character roles from other test runs pollute the permission check. + // This test verifies that owning a character does NOT grant corporation-level access. + CharacterRole::query()->delete(); + + test()->actingAs(test()->test_user); + + // Act + $response = match ($method) { + 'post' => post(route($route, $route_param)), + 'get' => get(route($route, $route_param)) + }; + + // Assert + + match ($status) { + 'forbidden' => $response->assertForbidden(), // 403 + 'ok' => $response->assertOk() + }; + }) + ->with([ + ['post', 'character.post', fn () => ['character_id' => test()->test_character->character_id]], + ['post', 'character.post', fn () => ['corporation_id' => test()->test_character->corporation->corporation_id], 'forbidden'], + ['post', 'character.post', fn () => ['alliance_id' => test()->test_character->alliance->alliance_id], 'forbidden'], + ['get', 'character.character', fn () => test()->test_character->character_id], + ['get', 'character.corporation', fn () => test()->test_character->corporation->corporation_id, 'forbidden'], + ['get', 'character.alliance', fn () => test()->test_character->alliance->alliance_id, 'forbidden'], + ['get', 'character.character_ids', fn () => ['character_ids' => [test()->test_character->character_id]]], + ['get', 'character.corporation_ids', fn () => ['corporation_ids' => [test()->test_character->corporation->corporation_id]], 'forbidden'], + ['get', 'character.corporation_ids', fn () => ['alliance_ids' => [test()->test_character->alliance->alliance_id]], 'forbidden'], + // Corporation Role + ['post', 'corporation.post', fn () => ['character_id' => test()->test_character->character_id]], + ['post', 'corporation.post', fn () => ['corporation_id' => test()->test_character->corporation->corporation_id], 'forbidden'], + ['post', 'corporation.post', fn () => ['alliance_id' => test()->test_character->alliance->alliance_id], 'forbidden'], + ['get', 'corporation.character', fn () => test()->test_character->character_id], + ['get', 'corporation.corporation', fn () => test()->test_character->corporation->corporation_id, 'forbidden'], + ['get', 'corporation.alliance', fn () => test()->test_character->alliance->alliance_id, 'forbidden'], + ['get', 'corporation.character_ids', fn () => ['corporation_ids' => [test()->test_character->character_id]]], + ['get', 'corporation.corporation_ids', fn () => ['corporation_ids' => [test()->test_character->corporation->corporation_id]], 'forbidden'], + ['get', 'corporation.alliance_ids', fn () => ['alliance_ids' => [test()->test_character->alliance->alliance_id]], 'forbidden'], + ]); + + it('checks owned corporation id', function (string $method, string $route, array|int $route_param) { + expect(test()->test_user->can('superuser'))->toBeFalse(); + + CharacterRole::query()->delete(); + + CharacterRole::factory()->create([ + 'character_id' => test()->test_character->character_id, + 'roles' => ['Director'], + ]); + + test()->actingAs(test()->test_user); + + match ($method) { + 'post' => post(route($route, $route_param))->assertOk(), + 'get' => get(route($route, $route_param))->assertOk() + }; + }) + ->with([ + ['post', 'corporation.post', fn () => ['corporation_id' => test()->test_character->corporation->corporation_id]], + ['get', 'corporation.corporation_ids', fn () => ['character_ids' => [test()->test_character->corporation->corporation_id]]], + ['get', 'corporation.corporation', fn () => test()->test_character->corporation->corporation_id], + ]); + + it('checks affiliated ids', function (string $method, string $route, array|int $route_param, string $status = 'ok') { + expect(test()->test_user->can('superuser'))->toBeFalse(); + + createAffiliation( + test()->role, + test()->secondary_character->alliance->alliance_id, + AllianceInfo::class, + AffiliationType::ALLOWED + ); + + test()->actingAs(test()->test_user); + + // Act + $response = match ($method) { + 'post' => post(route($route), $route_param), + 'get' => get(route($route, $route_param)) + }; + + // Assert + expect(test()->test_user->roles)->toHaveCount(1) + ->and(test()->test_user->roles->first()->permissions->first()->name)->toBe($this->permission_name); + + match ($status) { + 'forbidden' => $response->assertForbidden(), // 403 + 'ok' => $response->assertOk() + }; + }) + ->with([ + ['post', 'character.post', fn () => ['character_id' => test()->secondary_character->character_id]], + ['post', 'character.post', fn () => ['corporation_id' => test()->secondary_character->corporation->corporation_id]], + ['post', 'character.post', fn () => ['alliance_id' => test()->secondary_character->alliance->alliance_id]], + ['get', 'character.character', fn () => test()->secondary_character->character_id], + ['get', 'character.corporation', fn () => test()->secondary_character->corporation->corporation_id], + ['get', 'character.alliance', fn () => test()->secondary_character->alliance->alliance_id], + ['get', 'character.character_ids', fn () => ['character_ids' => [test()->secondary_character->character_id]]], + ['get', 'character.corporation_ids', fn () => ['corporation_ids' => [test()->secondary_character->corporation->corporation_id]]], + ['get', 'character.corporation_ids', fn () => ['alliance_ids' => [test()->secondary_character->alliance->alliance_id]]], + // Corporation Role + ['post', 'corporation.post', fn () => ['character_id' => test()->secondary_character->character_id]], + ['post', 'corporation.post', fn () => ['corporation_id' => test()->secondary_character->corporation->corporation_id]], + ['post', 'corporation.post', fn () => ['alliance_id' => test()->secondary_character->alliance->alliance_id]], + ['get', 'corporation.character', fn () => test()->secondary_character->character_id], + ['get', 'corporation.corporation', fn () => test()->secondary_character->corporation->corporation_id], + ['get', 'corporation.alliance', fn () => test()->secondary_character->alliance->alliance_id], + ['get', 'corporation.character_ids', fn () => ['corporation_ids' => [test()->secondary_character->character_id]]], + ['get', 'corporation.corporation_ids', fn () => ['corporation_ids' => [test()->secondary_character->corporation->corporation_id]]], + ['get', 'corporation.alliance_ids', fn () => ['alliance_ids' => [test()->secondary_character->alliance->alliance_id]]], + ]); + + it('returns forbidden for non affiliated ids', function (string $method, string $route, array|int $route_param, string $status = 'ok') { + expect(test()->test_user->can('superuser'))->toBeFalse(); + + createAffiliation( + test()->role, + test()->secondary_character->character_id, + CharacterInfo::class, + AffiliationType::FORBIDDEN + ); + + test()->actingAs(test()->test_user); + + $response = match ($method) { + 'post' => post(route($route), $route_param), + 'get' => get(route($route, $route_param)) + }; + + match ($status) { + 'forbidden' => $response->assertForbidden(), // 403 + 'ok' => $response->assertOk() + }; + }) + ->with([ + // POST + ['post', 'character.post', fn () => ['character_id' => test()->secondary_character->character_id], 'forbidden'], + ['post', 'character.post', fn () => ['character_id' => test()->test_character->character_id], 'ok'], + ['post', 'character.post', fn () => ['character_ids' => [test()->secondary_character->character_id]], 'forbidden'], + ['post', 'character.post', fn () => ['character_ids' => [test()->test_character->character_id]], 'ok'], + ['post', 'character.post', fn () => ['character_ids' => [test()->test_character->character_id, test()->secondary_character->character_id]], 'forbidden'], + // GET + ['get', 'character.character', fn () => test()->secondary_character->character_id, 'forbidden'], + ['get', 'character.character', fn () => test()->test_character->character_id, 'ok'], + ['get', 'character.character_ids', fn () => ['character_ids' => [test()->secondary_character->character_id]], 'forbidden'], + ['get', 'character.character_ids', fn () => ['character_ids' => [test()->test_character->character_id]], 'ok'], + ['get', 'character.character_ids', fn () => ['character_ids' => [test()->test_character->character_id, test()->secondary_character->character_id]], 'forbidden'], + ]); + + it('works with duplication of params', function () { + expect(test()->test_user->can('superuser'))->toBeFalse(); + + test()->actingAs(test()->test_user); + + get(route('character.character', [ + 'character_id' => test()->test_character->character_id, + '0' => test()->test_character->character_id, + ]))->assertOk(); + }); +}); + +describe('middleware checks permission or corporation role test', function () { + beforeEach(function () { + test()->role = Role::create(['name' => faker()->name]); + $this->permission_name = faker()->streetName(); + test()->permission = Permission::create(['name' => $this->permission_name]); + + Route::middleware([CheckAuthorization::class.":$this->permission_name,Accountant"]) + ->prefix('test') + ->get('/', function () { + return 'test'; + })->name('test'); + }); + + it('user has permission', function (string $permission) { + test()->actingAs(test()->test_user); + test()->assignPermissionToTestUser($permission); + + $response = $this->get(route('test')); + $response->assertStatus(200); + })->with([ + 'superuser' => 'superuser', + 'accountant' => fn () => $this->permission_name, + ]); + + it('has corporation_role', function (string $corporation_role) { + test()->actingAs(test()->test_user); + CharacterRole::query()->delete(); + + CharacterRole::factory()->create([ + 'character_id' => test()->test_character->character_id, + 'roles' => [$corporation_role], + ]); + + $response = $this->get(route('test')); + $response->assertStatus(200); + })->with([ + 'Accountant' => 'Accountant', + 'Director' => 'Director', + ]); + + it('is missing corporation_role', function () { + test()->actingAs(test()->test_user); + CharacterRole::query()->delete(); + + $response = $this->get(route('test')); + $response->assertStatus(403); + }); +}); + +function createAffiliation(Role $role, int|string $affiliatable_id, string $affiliatable_type, AffiliationType $type): Affiliation +{ + /** @var Affiliation $affiliation */ + $affiliation = Affiliation::query()->create([ + 'role_id' => $role->id, + 'affiliatable_id' => $affiliatable_id, + 'affiliatable_type' => $affiliatable_type, + 'type' => $type->value, + ]); + + return $affiliation; +} diff --git a/tests/Feature/Middleware/CheckPermissionAffiliationTest.php b/tests/Feature/Middleware/CheckPermissionAffiliationTest.php deleted file mode 100644 index 8065ecd..0000000 --- a/tests/Feature/Middleware/CheckPermissionAffiliationTest.php +++ /dev/null @@ -1,259 +0,0 @@ -role = Role::create(['name' => faker()->name]); - test()->permission = Permission::create(['name' => faker()->streetName()]); - - test()->role->givePermissionTo(test()->permission); - test()->role->activateMember(test()->test_user); - - $permission = test()->permission->name; - - Route::middleware([CheckPermissionAndAffiliation::class.":$permission"]) - ->prefix('character') - ->name('character.') - ->group(function () { - Route::post('/test/', fn () => response('Hello World'))->name('post'); - Route::get('/character/{character_id}/', fn (int $character_id) => response('Hello World'))->name('character'); - Route::get('/corporation/{corporation_id}/', fn (int $corporation_id) => response('Hello World'))->name('corporation'); - Route::get('/alliance/{alliance_id}/', fn (int $alliance_id) => response('Hello World'))->name('alliance'); - Route::get('/character_ids', fn () => response('Hello World'))->name('character_ids'); - Route::get('/corporation_ids', fn () => response('Hello World'))->name('corporation_ids'); - Route::get('/alliance_ids', fn () => response('Hello World'))->name('alliance_ids'); - }); - - Route::middleware([CheckPermissionAndAffiliation::class.":$permission,Director"]) - ->prefix('corporation') - ->name('corporation.') - ->group(function () { - Route::post('/test/', fn () => response('Hello World'))->name('post'); - Route::get('/character/{character_id}/', fn (int $character_id) => response('Hello World'))->name('character'); - Route::get('/corporation/{corporation_id}/', fn (int $corporation_id) => response('Hello World'))->name('corporation'); - Route::get('/alliance/{alliance_id}/', fn (int $alliance_id) => response('Hello World'))->name('alliance'); - Route::get('/character_ids', fn () => response('Hello World'))->name('character_ids'); - Route::get('/corporation_ids', fn () => response('Hello World'))->name('corporation_ids'); - Route::get('/alliance_ids', fn () => response('Hello World'))->name('alliance_ids'); - }); - - test()->secondary_character = CharacterInfo::factory()->create(); -}); - -it('it validates parameters for superuser', function (string $method, string $route, int|array $route_param, string $status = 'ok') { - assignPermissionToTestUser(['superuser']); - - test()->actingAs(test()->test_user); - - $response = match ($method) { - 'post' => post(route($route, $route_param)), - 'get' => get(route($route, $route_param)) - }; - - match ($status) { - 'forbidden' => $response->assertForbidden(), //403 - 'unauthorized' => $response->assertUnauthorized(), //401 - 'ok' => $response->assertOk() - }; -}) - ->with([ - // Character - ['post', 'character.post', fn () => ['character_id' => test()->test_character->character_id]], - ['post', 'character.post', fn () => ['corporation_id' => test()->test_character->corporation->corporation_id]], - ['post', 'character.post', fn () => ['alliance_id' => test()->test_character->alliance->alliance_id]], - ['get', 'character.character', fn () => test()->test_character->character_id], - ['get', 'character.corporation', fn () => test()->test_character->corporation->corporation_id], - ['get', 'character.alliance', fn () => test()->test_character->alliance->alliance_id], - ['get', 'character.character_ids', fn () => ['character_ids' => []], 'forbidden'], - ['get', 'character.corporation_ids', fn () => ['corporation_ids' => []], 'forbidden'], - ['get', 'character.alliance_ids', fn () => ['alliance_ids' => []], 'forbidden'], - ['get', 'character.character_ids', fn () => ['character_ids' => [test()->test_character->character_id]]], - ['get', 'character.corporation_ids', fn () => ['corporation_ids' => [test()->test_character->corporation->corporation_id]]], - ['get', 'character.corporation_ids', fn () => ['alliance_ids' => [test()->test_character->alliance->alliance_id]]], - // Corporation Role - ['post', 'corporation.post', fn () => ['character_id' => test()->test_character->character_id]], - ['post', 'corporation.post', fn () => ['corporation_id' => test()->test_character->corporation->corporation_id]], - ['post', 'corporation.post', fn () => ['alliance_id' => test()->test_character->alliance->alliance_id]], - ['get', 'corporation.character', fn () => test()->test_character->character_id], - ['get', 'corporation.corporation', fn () => test()->test_character->corporation->corporation_id], - ['get', 'corporation.alliance', fn () => test()->test_character->alliance->alliance_id], - ['get', 'corporation.character_ids', fn () => ['corporation_ids' => []], 'forbidden'], - ['get', 'corporation.corporation_ids', fn () => ['corporation_ids' => []], 'forbidden'], - ['get', 'corporation.alliance_ids', fn () => ['alliance_ids' => []], 'forbidden'], - ['get', 'corporation.character_ids', fn () => ['corporation_ids' => [test()->test_character->character_id]]], - ['get', 'corporation.corporation_ids', fn () => ['corporation_ids' => [test()->test_character->corporation->corporation_id]]], - ['get', 'corporation.alliance_ids', fn () => ['alliance_ids' => [test()->test_character->alliance->alliance_id]]], - ]); - -it('checks owned character ids', function (string $method, string $route, array|int $route_param, string $status = 'ok') { - expect(test()->test_user->can('superuser'))->toBeFalse(); - - test()->actingAs(test()->test_user); - - $response = match ($method) { - 'post' => post(route($route, $route_param)), - 'get' => get(route($route, $route_param)) - }; - - match ($status) { - 'forbidden' => $response->assertForbidden(), //403 - 'unauthorized' => $response->assertUnauthorized(), //401 - 'ok' => $response->assertOk() - }; -}) - ->with([ - ['post', 'character.post', fn () => ['character_id' => test()->test_character->character_id]], - ['post', 'character.post', fn () => ['corporation_id' => test()->test_character->corporation->corporation_id], 'unauthorized'], - ['post', 'character.post', fn () => ['alliance_id' => test()->test_character->alliance->alliance_id], 'unauthorized'], - ['get', 'character.character', fn () => test()->test_character->character_id], - ['get', 'character.corporation', fn () => test()->test_character->corporation->corporation_id, 'unauthorized'], - ['get', 'character.alliance', fn () => test()->test_character->alliance->alliance_id, 'unauthorized'], - ['get', 'character.character_ids', fn () => ['character_ids' => []], 'forbidden'], - ['get', 'character.corporation_ids', fn () => ['corporation_ids' => []], 'forbidden'], - ['get', 'character.alliance_ids', fn () => ['alliance_ids' => []], 'forbidden'], - ['get', 'character.character_ids', fn () => ['character_ids' => [test()->test_character->character_id]]], - ['get', 'character.corporation_ids', fn () => ['corporation_ids' => [test()->test_character->corporation->corporation_id]], 'unauthorized'], - ['get', 'character.corporation_ids', fn () => ['alliance_ids' => [test()->test_character->alliance->alliance_id]], 'unauthorized'], - // Corporation Role - ['post', 'corporation.post', fn () => ['character_id' => test()->test_character->character_id]], - ['post', 'corporation.post', fn () => ['corporation_id' => test()->test_character->corporation->corporation_id], 'unauthorized'], - ['post', 'corporation.post', fn () => ['alliance_id' => test()->test_character->alliance->alliance_id], 'unauthorized'], - ['get', 'corporation.character', fn () => test()->test_character->character_id], - ['get', 'corporation.corporation', fn () => test()->test_character->corporation->corporation_id, 'unauthorized'], - ['get', 'corporation.alliance', fn () => test()->test_character->alliance->alliance_id, 'unauthorized'], - ['get', 'corporation.character_ids', fn () => ['corporation_ids' => []], 'forbidden'], - ['get', 'corporation.corporation_ids', fn () => ['corporation_ids' => []], 'forbidden'], - ['get', 'corporation.alliance_ids', fn () => ['alliance_ids' => []], 'forbidden'], - ['get', 'corporation.character_ids', fn () => ['corporation_ids' => [test()->test_character->character_id]]], - ['get', 'corporation.corporation_ids', fn () => ['corporation_ids' => [test()->test_character->corporation->corporation_id]], 'unauthorized'], - ['get', 'corporation.alliance_ids', fn () => ['alliance_ids' => [test()->test_character->alliance->alliance_id]], 'unauthorized'], - ]); - -it('checks owned corporation id', function (string $method, string $route, array|int $route_param) { - expect(test()->test_user->can('superuser'))->toBeFalse(); - - CharacterRole::factory()->create([ - 'character_id' => test()->test_character->character_id, - 'roles' => ['Director'], - ]); - - test()->actingAs(test()->test_user); - - match ($method) { - 'post' => post(route($route, $route_param))->assertOk(), - 'get' => get(route($route, $route_param))->assertOk() - }; -}) - ->with([ - ['post', 'corporation.post', fn () => ['corporation_id' => test()->test_character->corporation->corporation_id]], - ['get', 'corporation.corporation_ids', fn () => ['character_ids' => [test()->test_character->corporation->corporation_id]]], - ['get', 'corporation.corporation', fn () => test()->test_character->corporation->corporation_id], - ]); - -it('checks affiliated ids', function (string $method, string $route, array|int $route_param, string $status = 'ok') { - expect(test()->test_user->can('superuser'))->toBeFalse(); - - test()->createAffiliation( - test()->role, - test()->secondary_character->alliance->alliance_id, - \Seatplus\Eveapi\Models\Alliance\AllianceInfo::class, - 'allowed' - ); - - test()->actingAs(test()->test_user); - - $response = match ($method) { - 'post' => post(route($route), $route_param), - 'get' => get(route($route, $route_param)) - }; - - match ($status) { - 'forbidden' => $response->assertForbidden(), //403 - 'unauthorized' => $response->assertUnauthorized(), //401 - 'ok' => $response->assertOk() - }; -}) - ->with([ - ['post', 'character.post', fn () => ['character_id' => test()->secondary_character->character_id]], - ['post', 'character.post', fn () => ['corporation_id' => test()->secondary_character->corporation->corporation_id]], - ['post', 'character.post', fn () => ['alliance_id' => test()->secondary_character->alliance->alliance_id]], - ['get', 'character.character', fn () => test()->secondary_character->character_id], - ['get', 'character.corporation', fn () => test()->secondary_character->corporation->corporation_id], - ['get', 'character.alliance', fn () => test()->secondary_character->alliance->alliance_id], - ['get', 'character.character_ids', fn () => ['character_ids' => []], 'forbidden'], - ['get', 'character.corporation_ids', fn () => ['corporation_ids' => []], 'forbidden'], - ['get', 'character.alliance_ids', fn () => ['alliance_ids' => []], 'forbidden'], - ['get', 'character.character_ids', fn () => ['character_ids' => [test()->secondary_character->character_id]]], - ['get', 'character.corporation_ids', fn () => ['corporation_ids' => [test()->secondary_character->corporation->corporation_id]]], - ['get', 'character.corporation_ids', fn () => ['alliance_ids' => [test()->secondary_character->alliance->alliance_id]]], - // Corporation Role - ['post', 'corporation.post', fn () => ['character_id' => test()->secondary_character->character_id]], - ['post', 'corporation.post', fn () => ['corporation_id' => test()->secondary_character->corporation->corporation_id]], - ['post', 'corporation.post', fn () => ['alliance_id' => test()->secondary_character->alliance->alliance_id]], - ['get', 'corporation.character', fn () => test()->secondary_character->character_id], - ['get', 'corporation.corporation', fn () => test()->secondary_character->corporation->corporation_id], - ['get', 'corporation.alliance', fn () => test()->secondary_character->alliance->alliance_id], - ['get', 'corporation.character_ids', fn () => ['corporation_ids' => []], 'forbidden'], - ['get', 'corporation.corporation_ids', fn () => ['corporation_ids' => []], 'forbidden'], - ['get', 'corporation.alliance_ids', fn () => ['alliance_ids' => []], 'forbidden'], - ['get', 'corporation.character_ids', fn () => ['corporation_ids' => [test()->secondary_character->character_id]]], - ['get', 'corporation.corporation_ids', fn () => ['corporation_ids' => [test()->secondary_character->corporation->corporation_id]]], - ['get', 'corporation.alliance_ids', fn () => ['alliance_ids' => [test()->secondary_character->alliance->alliance_id]]], - ]); - -it('returns unauthorized for non affiliated ids', function (string $method, string $route, array|int $route_param, string $status = 'ok') { - expect(test()->test_user->can('superuser'))->toBeFalse(); - - test()->createAffiliation( - test()->role, - test()->secondary_character->character_id, - CharacterInfo::class, - 'forbidden' - ); - - test()->actingAs(test()->test_user); - - $response = match ($method) { - 'post' => post(route($route), $route_param), - 'get' => get(route($route, $route_param)) - }; - - match ($status) { - 'forbidden' => $response->assertForbidden(), //403 - 'unauthorized' => $response->assertUnauthorized(), //401 - 'ok' => $response->assertOk() - }; -}) - ->with([ - // POST - ['post', 'character.post', fn () => ['character_id' => test()->secondary_character->character_id], 'unauthorized'], - ['post', 'character.post', fn () => ['character_id' => test()->test_character->character_id], 'ok'], - ['post', 'character.post', fn () => ['character_ids' => [test()->secondary_character->character_id]], 'unauthorized'], - ['post', 'character.post', fn () => ['character_ids' => [test()->test_character->character_id]], 'ok'], - ['post', 'character.post', fn () => ['character_ids' => [test()->test_character->character_id, test()->secondary_character->character_id]], 'unauthorized'], - // GET - ['get', 'character.character', fn () => test()->secondary_character->character_id, 'unauthorized'], - ['get', 'character.character', fn () => test()->test_character->character_id, 'ok'], - ['get', 'character.character_ids', fn () => ['character_ids' => [test()->secondary_character->character_id]], 'unauthorized'], - ['get', 'character.character_ids', fn () => ['character_ids' => [test()->test_character->character_id]], 'ok'], - ['get', 'character.character_ids', fn () => ['character_ids' => [test()->test_character->character_id, test()->secondary_character->character_id]], 'unauthorized'], - ]); - -it('works with duplication of params', function () { - expect(test()->test_user->can('superuser'))->toBeFalse(); - - test()->actingAs(test()->test_user); - - get(route('character.character', [ - 'character_id' => test()->test_character->character_id, - '0' => test()->test_character->character_id, - ]))->assertOk(); -}); diff --git a/tests/Feature/Middleware/CheckPermissionOrCorporationRoleTest.php b/tests/Feature/Middleware/CheckPermissionOrCorporationRoleTest.php deleted file mode 100644 index a3e96cc..0000000 --- a/tests/Feature/Middleware/CheckPermissionOrCorporationRoleTest.php +++ /dev/null @@ -1,59 +0,0 @@ -role = Role::create(['name' => faker()->name]); - test()->permission = Permission::create(['name' => faker()->streetName()]); - - $permission = test()->permission->name; - - Route::middleware([CheckPermissionOrCorporationRole::class.":$permission,Accountant"]) - ->prefix('test') - ->get('/', function () { - return 'test'; - })->name('test'); -}); - -it('returns a 401 if user is not authenticated', function () { - $response = $this->get(route('test')); - $response->assertStatus(401); -}); - -it('returns a 200 if user has permission', function (string $permission) { - test()->actingAs(test()->test_user); - test()->assignPermissionToTestUser($permission); - - $response = $this->get(route('test')); - $response->assertStatus(200); -})->with([ - 'superuser' => 'superuser', - 'accountant' => fn () => test()->permission->name, -]); - -it('return a 200 if user has corporation_role', function (string $corporation_role) { - test()->actingAs(test()->test_user); - CharacterRole::query()->delete(); - - CharacterRole::factory()->create([ - 'character_id' => test()->test_character->character_id, - 'roles' => [$corporation_role], - ]); - - $response = $this->get(route('test')); - $response->assertStatus(200); -})->with([ - 'Accountant' => 'Accountant', - 'Director' => 'Director', -]); - -it('return a 401 if user is missing corporation_role', function () { - test()->actingAs(test()->test_user); - CharacterRole::query()->delete(); - - $response = $this->get(route('test')); - $response->assertStatus(401); -}); diff --git a/tests/Feature/MainCharacter/MainCharacterTest.php b/tests/Feature/Routes/MainCharacterTest.php similarity index 71% rename from tests/Feature/MainCharacter/MainCharacterTest.php rename to tests/Feature/Routes/MainCharacterTest.php index e8bb61c..d772883 100644 --- a/tests/Feature/MainCharacter/MainCharacterTest.php +++ b/tests/Feature/Routes/MainCharacterTest.php @@ -13,9 +13,9 @@ test()->assertNotEquals($secondary->character_id, test()->test_user->main_character_id); - test()->actingAs(test()->test_user)->post(route('change.main_character'), [ - 'character_id' => $secondary->character_id, - ])->assertRedirect(); + test()->actingAs(test()->test_user)->put(route('change.main_character', [ + 'new_character_id' => $secondary->character_id, + ]))->assertRedirect(); expect(test()->test_user->refresh()->main_character_id)->toEqual($secondary->character_id); }); @@ -27,7 +27,7 @@ test()->assertNotEquals($secondary->character_id, test()->test_user->main_character_id); - test()->actingAs(test()->test_user)->post(route('change.main_character'), [ - 'character_id' => $secondary->character_id, - ])->assertForbidden(); + test()->actingAs(test()->test_user)->put(route('change.main_character', [ + 'new_character_id' => $secondary->character_id, + ]))->assertForbidden(); }); diff --git a/tests/Feature/Auth/SsoControllerTest.php b/tests/Feature/Routes/SsoControllerTest.php similarity index 97% rename from tests/Feature/Auth/SsoControllerTest.php rename to tests/Feature/Routes/SsoControllerTest.php index 9599518..1301d7b 100644 --- a/tests/Feature/Auth/SsoControllerTest.php +++ b/tests/Feature/Routes/SsoControllerTest.php @@ -28,7 +28,7 @@ use Illuminate\Support\Facades\Queue; use Laravel\Socialite\Contracts\Provider; use Laravel\Socialite\Facades\Socialite; -use Seatplus\Auth\Jobs\UserRolesSync; +use Seatplus\Auth\Jobs\RoleMemberSync; it('works for non authed users', function () { $abstractUser = createSocialiteUser(); @@ -102,7 +102,7 @@ $result = test()->get(route('auth.eve.callback')); // assert no UserRolesSync job has been dispatched - Queue::assertPushedOn('high', UserRolesSync::class); + Queue::assertPushedOn('high', RoleMemberSync::class); // assert that no error is present expect(session('error'))->toBeNull(); diff --git a/tests/Feature/Auth/StepUpTest.php b/tests/Feature/Routes/StepUpTest.php similarity index 84% rename from tests/Feature/Auth/StepUpTest.php rename to tests/Feature/Routes/StepUpTest.php index 9c4f92a..2626f20 100644 --- a/tests/Feature/Auth/StepUpTest.php +++ b/tests/Feature/Routes/StepUpTest.php @@ -48,8 +48,8 @@ 'add_scopes' => $add_scopes, ])); - expect(session('step_up'))->toEqual(test()->test_character->character_id); - expect(session('sso_scopes'))->toEqual(['a', 'b', '1', '2']); + expect(session('step_up'))->toEqual(test()->test_character->character_id) + ->and(session('sso_scopes'))->toEqual(['a', 'b', '1', '2']); }); test('one can request another scope for a deleted token', function () { @@ -67,6 +67,14 @@ 'add_scopes' => $add_scopes, ])); - expect(session('step_up'))->toEqual(test()->test_character->character_id); - expect(session('sso_scopes'))->toEqual(['1', '2']); + expect(session('step_up'))->toEqual(test()->test_character->character_id) + ->and(session('sso_scopes'))->toEqual(['1', '2']); +}); + +test('one can not request another scope for a character not associated to the user', function () { + $response = test()->actingAs(test()->test_user)->get(route('auth.eve.step_up', [ + 'character_id' => 123, + ])); + + $response->assertSessionHas('error', 'character must belong to your account'); }); diff --git a/tests/Feature/Services/RoleAffiliatedIdsServiceTest.php b/tests/Feature/Services/RoleAffiliatedIdsServiceTest.php new file mode 100644 index 0000000..64453b5 --- /dev/null +++ b/tests/Feature/Services/RoleAffiliatedIdsServiceTest.php @@ -0,0 +1,201 @@ +secondary_character = CharacterInfo::factory()->create(); + + test()->tertiary_character = CharacterInfo::factory()->create(); + + test()->role = Role::create(['name' => 'derp']); + + $this->service = new AutomaticRoleService($this->role); +}); + +dataset('entity_types', [ + 'character', + 'corporation', + 'alliance', +]); + +function getId(string $entity_type, int $character_level) +{ + $character = match ($character_level) { + 1 => test()->test_character, + 2 => test()->secondary_character, + 3 => test()->tertiary_character, + }; + + return match ($entity_type) { + 'character' => $character->character_id, + 'corporation' => $character->corporation_id, + 'alliance' => $character->alliance_id, + }; +} + +describe('allowed only', function () { + test('primary and secondary are affiliated ', function ($entity_type, $affiliation_type) { + + $primaray_id = getId($entity_type, 1); + + $secondary_id = getId($entity_type, 2); + + $this->service->syncAffiliateManyEntities( + new AffiliationData($primaray_id, $entity_type, AffiliationType::from($affiliation_type)), + new AffiliationData($secondary_id, $entity_type, AffiliationType::from($affiliation_type)), + ); + + $affiliated_ids = (new RoleAffiliatedIdsService)->get(test()->role); + + expect($affiliated_ids)->toContain($primaray_id) + ->toContain($secondary_id) + ->not()->toContain(test()->tertiary_character->character_id); + + })->with('entity_types')->with([AffiliationType::ALLOWED->value]); +}); + +describe('inverse only', function () { + test('primary and secondary are affiliated, but not tertiary ', function ($entity_type, $affiliation_type) { + + $primary_id = getId($entity_type, 1); + $secondary_id = getId($entity_type, 2); + $tertiary_id = getId($entity_type, 3); + + $this->service->syncAffiliateManyEntities( + new AffiliationData($tertiary_id, $entity_type, AffiliationType::from($affiliation_type)), + ); + + $affiliated_ids = (new RoleAffiliatedIdsService)->get(test()->role); + + expect($affiliated_ids) + ->toContain($primary_id) + ->toContain($secondary_id) + ->not()->toContain(test()->tertiary_character->character_id) + ->not()->toContain($tertiary_id); + + })->with('entity_types')->with([AffiliationType::INVERSE->value]); +}); + +describe('forbidden only', function () { + test('primary and secondary are affiliated, but not tertiary ', function ($entity_type, $affiliation_type) { + + $primary_id = getId($entity_type, 1); + $secondary_id = getId($entity_type, 2); + $tertiary_id = getId($entity_type, 3); + + $this->service->syncAffiliateManyEntities( + new AffiliationData($tertiary_id, $entity_type, AffiliationType::from($affiliation_type)), + ); + + $affiliated_ids = (new RoleAffiliatedIdsService)->get(test()->role); + + expect($affiliated_ids) + ->not()->toContain($primary_id) + ->not()->toContain($secondary_id) + ->not()->toContain(test()->tertiary_character->character_id) + ->not()->toContain($tertiary_id); + + })->with('entity_types')->with([AffiliationType::FORBIDDEN->value]); +}); + +describe('allowed and inverse', function () { + test('testcharacter, secondary and tertiary are affiliated, but not tertiary ', function ($entity_type) { + + $primary_id = getId($entity_type, 1); + $secondary_id = getId($entity_type, 2); + $tertiary_id = getId($entity_type, 3); + + $this->service->syncAffiliateManyEntities( + new AffiliationData(test()->test_character->character_id, 'character', AffiliationType::ALLOWED), + new AffiliationData($primary_id, $entity_type, AffiliationType::INVERSE), + ); + + $affiliated_ids = (new RoleAffiliatedIdsService)->get(test()->role); + + expect($affiliated_ids) + ->toContain(test()->test_character->character_id) + ->toContain($secondary_id) + ->toContain($tertiary_id) + ->toContain(test()->secondary_character->character_id) + ->toContain(test()->tertiary_character->character_id); + + })->with('entity_types'); +}); + +describe('allowed and forbidden', function () { + test('primary affiliated but test_character forbidden ', function ($entity_type) { + + $primary_id = getId($entity_type, 1); + + $this->service->syncAffiliateManyEntities( + new AffiliationData(test()->test_character->character_id, 'character', AffiliationType::FORBIDDEN), + new AffiliationData($primary_id, $entity_type, AffiliationType::ALLOWED), + ); + + $affiliated_ids = (new RoleAffiliatedIdsService)->get(test()->role); + + expect($affiliated_ids) + ->not()->toContain(test()->test_character->character_id) + ->when($entity_type === 'character', function ($collection) { + $collection->toHaveCount(0); + }) + ->when($entity_type !== 'character', function ($collection) use ($primary_id) { + $collection->toContain($primary_id); + }); + + })->with('entity_types'); +}); + +describe('inverse and forbidden', function () { + test('test_character forbidden but primary affiliated through inverse', function ($entity_type) { + + $primary_id = getId($entity_type, 1); + $secondary_id = getId($entity_type, 2); + + $this->service->syncAffiliateManyEntities( + new AffiliationData(test()->test_character->character_id, 'character', AffiliationType::FORBIDDEN), + new AffiliationData($secondary_id, $entity_type, AffiliationType::INVERSE), + ); + + $affiliated_ids = (new RoleAffiliatedIdsService)->get(test()->role); + + expect($affiliated_ids) + ->not()->toContain(test()->test_character->character_id) + ->when($entity_type !== 'character', function ($collection) use ($primary_id) { + $collection->toContain($primary_id); + }); + + })->with('entity_types'); +}); + +describe('allowed, inverse and forbidden', function () { + test('test_character forbidden, primary allowed, secondary inverse', function ($entity_type) { + + $primary_id = getId($entity_type, 1); + $secondary_id = getId($entity_type, 2); + + $this->service->syncAffiliateManyEntities( + new AffiliationData(test()->test_character->character_id, 'character', AffiliationType::FORBIDDEN), + new AffiliationData($primary_id, $entity_type, AffiliationType::ALLOWED), + new AffiliationData($secondary_id, $entity_type, AffiliationType::INVERSE), + ); + + $affiliated_ids = (new RoleAffiliatedIdsService)->get(test()->role); + + expect($affiliated_ids) + ->toContain(test()->tertiary_character->character_id) + ->not()->toContain(test()->test_character->character_id) + ->not()->toContain($secondary_id); + + })->with('entity_types'); + +}); diff --git a/tests/Pest.php b/tests/Pest.php index b27e48d..3d107f5 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,12 +1,12 @@ in('Unit', 'Feature'); -uses(\Illuminate\Foundation\Testing\LazilyRefreshDatabase::class)->in('Unit', 'Feature'); +uses(TestCase::class)->in('Unit', 'Feature'); +uses(LazilyRefreshDatabase::class)->in('Unit', 'Feature'); /* |-------------------------------------------------------------------------- @@ -52,7 +52,7 @@ */ /** @link https://pestphp.com/docs/helpers */ -function createRefreshTokenWithScopes(array $scopes) +function createRefreshTokenWithScopes(array $scopes): void { Event::fakeFor(function () use ($scopes) { if (test()->test_character->refresh_token) { @@ -87,7 +87,7 @@ function createSocialiteUser($character_id = null, array $scopes = ['esi-skills. { $refresh_token = RefreshToken::factory()->scopes($scopes)->make(); - $socialiteUser = test()->createMock(SocialiteUser::class); + $socialiteUser = Mockery::mock(SocialiteUser::class)->makePartial(); $attributes = (object) [ 'character_id' => $character_id ?? $refresh_token->character_id, @@ -97,7 +97,7 @@ function createSocialiteUser($character_id = null, array $scopes = ['esi-skills. $socialiteUser->attributes = $attributes; $socialiteUser->token = $refresh_token->token; $socialiteUser->refreshToken = $refresh_token->refresh_token; - $socialiteUser->expiresIn = 12 * 60; //let's just say 12 minutes + $socialiteUser->expiresIn = 12 * 60; // let's just say 12 minutes $socialiteUser->user = [ 'scp' => $scopes, ]; @@ -107,11 +107,7 @@ function createSocialiteUser($character_id = null, array $scopes = ['esi-skills. function faker() { - if (! isset(test()->faker)) { - test()->faker = Factory::create(); - } - - return test()->faker; + return Factory::create(); } function createEveUser(?int $character_id = null, ?string $character_owner_hash = null): EveUser @@ -139,14 +135,5 @@ function assignPermissionToTestUser(array|string $permission_strings) } // now re-register all the roles and permissions - app()->make(PermissionRegistrar::class)->registerPermissions(); -} - -function createAffiliation(Role $role, $affiliatable_id, $affiliatable_type, $type = 'allowed'): Affiliation -{ - return $role->affiliations()->create([ //test()->role - 'affiliatable_id' => $affiliatable_id, - 'affiliatable_type' => $affiliatable_type, - 'type' => $type, - ]); + app()[PermissionRegistrar::class]->forgetCachedPermissions(); } diff --git a/tests/TestCase.php b/tests/TestCase.php index a05367c..fe18cd0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -27,6 +27,8 @@ namespace Seatplus\Auth\Tests; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Foundation\Application; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; @@ -34,6 +36,7 @@ use Seatplus\Auth\AuthenticationServiceProvider; use Seatplus\Auth\Models\User; use Seatplus\Eveapi\EveapiServiceProvider; +use Spatie\Permission\PermissionServiceProvider; abstract class TestCase extends OrchestraTestCase { @@ -45,6 +48,8 @@ protected function setUp(): void { parent::setUp(); + Model::shouldBeStrict(); + Factory::guessFactoryNamesUsing( fn (string $modelName) => match (true) { Str::startsWith($modelName, 'Seatplus\Auth') => 'Seatplus\\Auth\\Database\\Factories\\'.class_basename($modelName).'Factory', @@ -68,7 +73,7 @@ protected function setUp(): void /** * Get application providers. * - * @param \Illuminate\Foundation\Application $app + * @param Application $app * @return array */ protected function getPackageProviders($app) @@ -76,35 +81,34 @@ protected function getPackageProviders($app) return [ EveapiServiceProvider::class, AuthenticationServiceProvider::class, + PermissionServiceProvider::class, ]; } /** - * @param \Illuminate\Foundation\Application $app + * @param Application $app */ private function setupDatabase($app) { // Path to our migrations to load - //$this->loadMigrationsFrom(__DIR__ . '/database/migrations'); + // $this->loadMigrationsFrom(__DIR__ . '/database/migrations'); $this->artisan('migrate'); } /** * Define environment setup. * - * @param \Illuminate\Foundation\Application $app + * @param Application $app * @return void */ protected function getEnvironmentSetUp($app) { - config(['database.default' => 'mysql']); - config(['app.debug' => true]); config(['activitylog.table_name' => 'activity_log']); // Use test User model for users provider $app['config']->set('auth.providers.users.model', User::class); - //$app['config']->set('cache.prefix', 'seatplus_tests---'); + // $app['config']->set('cache.prefix', 'seatplus_tests---'); } } diff --git a/tests/Unit/Actions/AddMemberActionTest.php b/tests/Unit/Actions/AddMemberActionTest.php new file mode 100644 index 0000000..71e5e6f --- /dev/null +++ b/tests/Unit/Actions/AddMemberActionTest.php @@ -0,0 +1,14 @@ +mock(SetMemberAction::class, function ($mock) { + $mock->shouldReceive('execute')->with(1, 2, true)->once(); + }); + + $action = app(AddMemberAction::class); + + $action->execute(1, 2); +}); diff --git a/tests/Unit/Actions/AddModeratorRoleActionTest.php b/tests/Unit/Actions/AddModeratorRoleActionTest.php new file mode 100644 index 0000000..c1585f9 --- /dev/null +++ b/tests/Unit/Actions/AddModeratorRoleActionTest.php @@ -0,0 +1,16 @@ +mock(SetModeratorAction::class, function ($mock) { + $mock->shouldReceive('execute')->with(1, 2, true)->once(); + }); + + $action = app(AddModeratorRoleAction::class); + + $action->execute(1, 2); +}); diff --git a/tests/Unit/Actions/ApplyActionTest.php b/tests/Unit/Actions/ApplyActionTest.php new file mode 100644 index 0000000..7df7322 --- /dev/null +++ b/tests/Unit/Actions/ApplyActionTest.php @@ -0,0 +1,47 @@ +mock(BaseRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('for')->with(1) + ->andReturnSelf(); + + $mock->shouldReceive('onRequest') + ->once() + ->andReturn(mock(OnRequestRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('onRequest')->andReturnSelf(); + $mock->shouldReceive('submitApplicationForRole')->once(); + })); + }); + + $user = $this->test_user; + + $action = app(ApplyAction::class); + + $action->execute(1, $user->id); + + // expect(true)->toBeTrue(); // Just to ensure the test runs without exceptions +}); + +it('throws exception if user not found', function () { + $this->mock(BaseRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('for')->with(1) + ->andReturnSelf(); + + $mock->shouldReceive('onRequest') + ->once() + ->andReturn(mock(OnRequestRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('onRequest')->andReturnSelf(); + $mock->shouldReceive('submitApplicationForRole')->never(); + })); + }); + + $action = app(ApplyAction::class); + + expect(fn () => $action->execute(1, 999))->toThrow(ModelNotFoundException::class); +}); diff --git a/tests/Unit/Actions/ApproveActionTest.php b/tests/Unit/Actions/ApproveActionTest.php new file mode 100644 index 0000000..39d81d0 --- /dev/null +++ b/tests/Unit/Actions/ApproveActionTest.php @@ -0,0 +1,36 @@ +mock(BaseRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('for')->with(1)->andReturnSelf(); + $mock->shouldReceive('onRequest')->andReturn(mock(OnRequestRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('approveApplicationForRole')->once(); + })); + }); + + $user = User::factory()->create(); + + $action = app(ApproveAction::class); + $action->execute(1, $user->id); +}); + +it('throws exception if user not found during approval', function () { + $roleServiceMock = $this->mock(BaseRoleService::class, function ($mock) { + $mock->shouldReceive('for')->with(1)->andReturnSelf(); + $mock->shouldReceive('onRequest')->andReturn(mock(OnRequestRoleService::class, function ($mock) { + $mock->shouldReceive('approveApplicationForRole')->never(); + })); + }); + + $action = app(ApproveAction::class, ['baseRoleService' => $roleServiceMock]); + + expect(fn () => $action->execute(1, 999))->toThrow(ModelNotFoundException::class); +}); diff --git a/tests/Unit/Actions/DenyActionTest.php b/tests/Unit/Actions/DenyActionTest.php new file mode 100644 index 0000000..52a8cf0 --- /dev/null +++ b/tests/Unit/Actions/DenyActionTest.php @@ -0,0 +1,34 @@ +mock(BaseRoleService::class, function ($mock) { + $mock->shouldReceive('for')->with(1)->andReturnSelf(); + $mock->shouldReceive('onRequest')->andReturn(mock(OnRequestRoleService::class, function ($mock) { + $mock->shouldReceive('denyApplication')->once(); + })); + }); + + $user = User::factory()->create(); + + $action = app(DenyAction::class); + $action->execute(1, $user->id); +}); + +it('throws exception if user not found during application', function () { + $this->mock(BaseRoleService::class, function ($mock) { + $mock->shouldReceive('for')->with(1)->andReturnSelf(); + $mock->shouldReceive('onRequest')->andReturn(mock(OnRequestRoleService::class, function ($mock) { + $mock->shouldReceive('submitApplicationForRole')->never(); + })); + }); + + $action = app(DenyAction::class); + + expect(fn () => $action->execute(1, 999))->toThrow(ModelNotFoundException::class); +}); diff --git a/tests/Unit/Actions/GetAffiliatedIdsByPermissionArrayTest.php b/tests/Unit/Actions/GetAffiliatedIdsByPermissionArrayTest.php deleted file mode 100644 index e8142cc..0000000 --- a/tests/Unit/Actions/GetAffiliatedIdsByPermissionArrayTest.php +++ /dev/null @@ -1,379 +0,0 @@ -role = Role::create(['name' => 'writer']); - test()->permission = Permission::create(['name' => 'edit articles']); - - test()->role->givePermissionTo(test()->permission); - test()->test_user->assignRole(test()->role); - - test()->test_character_user = test()->test_user->character_users->first(); - - test()->actingAs(test()->test_user); - - Event::fakeFor(function () { - test()->secondary_character = CharacterInfo::factory()->create(); - - test()->tertiary_character = CharacterInfo::factory()->create(); - }); -}); - -/** - * @throws \Exception - */ -it('returns own character id', function () { - test()->role->affiliations()->create([ - 'affiliatable_id' => test()->test_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'type' => 'allowed', - ]); - - $ids = (new GetAffiliatedIdsByPermissionArray(test()->permission->name))->execute(); - - expect(in_array(test()->test_character_user->character_id, $ids))->toBeTrue(); -}); - -it('returns other and own character id for inverted', function () { - test()->role->affiliations()->create([ - 'affiliatable_id' => test()->test_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'type' => 'inverse', - ]); - - $ids = (new GetAffiliatedIdsByPermissionArray(test()->permission->name))->execute(); - - expect(in_array(test()->test_character_user->character_id, $ids))->toBeTrue(); - expect(in_array(test()->secondary_character->character_id, $ids))->toBeTrue(); -}); - -it('does not return secondary character id if secondary character is inverted', function () { - test()->role->affiliations()->create([ - 'affiliatable_id' => test()->secondary_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'type' => 'inverse', - ]); - - $ids = (new GetAffiliatedIdsByPermissionArray(test()->permission->name))->execute(); - - // Assert that the owned character_ids are present - expect(in_array(test()->test_character_user->character_id, $ids))->toBeTrue(); - - // Assert that ids from the inverted corporation is missing - expect(in_array(test()->secondary_character->character_id, $ids))->toBeFalse(); - - // Assert that ids from any other third party is present - expect(in_array(test()->tertiary_character->character_id, $ids))->toBeTrue(); -}); - -it('does not return secondary character id if secondary corporation is inverted', function () { - test()->role->affiliations()->create([ - 'affiliatable_id' => test()->secondary_character->corporation->corporation_id, - 'affiliatable_type' => CorporationInfo::class, - 'type' => 'inverse', - ]); - - $ids = (new GetAffiliatedIdsByPermissionArray(test()->permission->name))->execute(); - - // Assert that second character does not share same corporation as the first character - test()->assertNotEquals(test()->secondary_character->corporation->corporation_id, test()->test_character->corporation->corporation_id); - - // Assert that the owned character_ids are present - expect(in_array(test()->test_character_user->character_id, $ids))->toBeTrue(); - - // Assert that ids from the inverted corporation is missing - expect(in_array(test()->secondary_character->character_id, $ids))->toBeFalse(); - - // Assert that ids from any other third party is present - expect(in_array(test()->tertiary_character->character_id, $ids))->toBeTrue(); -}); - -it('does not return secondary character id if secondary alliance is inverted', function () { - test()->role->affiliations()->create([ - 'affiliatable_id' => test()->secondary_character->alliance->alliance_id, - 'affiliatable_type' => AllianceInfo::class, - 'type' => 'inverse', - ]); - - $ids = (new GetAffiliatedIdsByPermissionArray(test()->permission->name))->execute(); - - // Assert that second character does not share same corporation as the first character - test()->assertNotEquals(test()->secondary_character->alliance->alliance_id, test()->test_character->alliance->alliance_id); - - // Assert that the owned character_ids are present - expect(in_array(test()->test_character_user->character_id, $ids))->toBeTrue(); - - // Assert that ids from the inverted corporation is missing - expect(in_array(test()->secondary_character->character_id, $ids))->toBeFalse(); - - // Assert that ids from any other third party is present - expect(in_array(test()->tertiary_character->character_id, $ids))->toBeTrue(); -}); - -it('does return secondary character id if secondary character is allowed', function () { - test()->role->affiliations()->create([ - 'affiliatable_id' => test()->secondary_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'type' => 'allowed', - ]); - - $ids = (new GetAffiliatedIdsByPermissionArray(test()->permission->name))->execute(); - - // Assert that second character does not share same corporation as the first character - test()->assertNotEquals(test()->secondary_character->character_id, test()->test_character->character_id); - - // Assert that the owned character_ids are present - expect(in_array(test()->test_character_user->character_id, $ids))->toBeTrue(); - - // Assert that ids from the allowed character is present - expect(in_array(test()->secondary_character->character_id, $ids))->toBeTrue(); - - // Assert that ids from any other third party is not present - expect(in_array(test()->tertiary_character->character_id, $ids))->toBeFalse(); -}); - -it('does return secondary character id if secondary corporation is allowed', function () { - test()->role->affiliations()->create([ - 'affiliatable_id' => test()->secondary_character->corporation->corporation_id, - 'affiliatable_type' => CorporationInfo::class, - 'type' => 'allowed', - ]); - - $ids = (new GetAffiliatedIdsByPermissionArray(test()->permission->name))->execute(); - - // Assert that second character does not share same corporation as the first character - test()->assertNotEquals(test()->secondary_character->corporation->corporation_id, test()->test_character->corporation->corporation_id); - - // Assert that the owned character_ids are present - expect(in_array(test()->test_character_user->character_id, $ids))->toBeTrue(); - - // Assert that ids from the allowed character is present - expect(in_array(test()->secondary_character->character_id, $ids))->toBeTrue(); - - // Assert that ids from any other third party is not present - expect(in_array(test()->tertiary_character->character_id, $ids))->toBeFalse(); -}); - -it('does return secondary character id if secondary alliance is allowed', function () { - test()->role->affiliations()->create([ - 'affiliatable_id' => test()->secondary_character->alliance->alliance_id, - 'affiliatable_type' => AllianceInfo::class, - 'type' => 'allowed', - ]); - - $ids = (new GetAffiliatedIdsByPermissionArray(test()->permission->name))->execute(); - - // Assert that second character does not share same corporation as the first character - test()->assertNotEquals(test()->secondary_character->alliance->alliance_id, test()->test_character->alliance->alliance_id); - - // Assert that the owned character_ids are present - expect(in_array(test()->test_character_user->character_id, $ids))->toBeTrue(); - - // Assert that ids from the allowed character is present - expect(in_array(test()->secondary_character->character_id, $ids))->toBeTrue(); - - // Assert that ids from any other third party is not present - expect(in_array(test()->tertiary_character->character_id, $ids))->toBeFalse(); -}); - -it('does return own character even if listed as forbidden', function () { - test()->role->affiliations()->create([ - 'affiliatable_id' => test()->secondary_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'type' => 'forbidden', - ]); - - $ids = (new GetAffiliatedIdsByPermissionArray(test()->permission->name))->execute(); - - // Assert that the owned character_ids are present - expect(in_array(test()->test_character_user->character_id, $ids))->toBeTrue(); -}); - -it('does not return secondary character id if secondary character is forbidden', function () { - test()->role->affiliations()->createMany([ - [ - 'affiliatable_id' => test()->secondary_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'type' => 'allowed', - ], - [ - 'affiliatable_id' => test()->secondary_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'type' => 'forbidden', - ], - ]); - - $ids = (new GetAffiliatedIdsByPermissionArray(test()->permission->name))->execute(); - - // Assert that second character does not share same corporation as the first character - test()->assertNotEquals(test()->secondary_character->character_id, test()->test_character->character_id); - - // Assert that the owned character_ids are present - expect(in_array(test()->test_character_user->character_id, $ids))->toBeTrue(); - - // Assert that ids from the allowed character is not present - expect(in_array(test()->secondary_character->character_id, $ids))->toBeFalse(); - - // Assert that ids from any other third party is not present - expect(in_array(test()->tertiary_character->character_id, $ids))->toBeFalse(); -}); - -it('does not return secondary character id if secondary corporation is forbidden', function () { - test()->role->affiliations()->createMany([ - [ - 'affiliatable_id' => test()->secondary_character->corporation->corporation_id, - 'affiliatable_type' => CorporationInfo::class, - 'type' => 'allowed', - ], - [ - 'affiliatable_id' => test()->secondary_character->corporation->corporation_id, - 'affiliatable_type' => CorporationInfo::class, - 'type' => 'forbidden', - ], - ]); - - $ids = (new GetAffiliatedIdsByPermissionArray(test()->permission->name))->execute(); - - // Assert that second character does not share same corporation as the first character - test()->assertNotEquals(test()->secondary_character->corporation->corporation_id, test()->test_character->corporation->corporation_id); - - // Assert that the owned character_ids are present - expect(in_array(test()->test_character_user->character_id, $ids))->toBeTrue(); - - // Assert that ids from the allowed character is not present - expect(in_array(test()->secondary_character->character_id, $ids))->toBeFalse(); - - // Assert that ids from any other third party is not present - expect(in_array(test()->tertiary_character->character_id, $ids))->toBeFalse(); -}); - -it('does not return secondary character id if secondary alliance is forbidden', function () { - test()->role->affiliations()->createMany([ - [ - 'affiliatable_id' => test()->secondary_character->alliance->alliance_id, - 'affiliatable_type' => AllianceInfo::class, - 'type' => 'allowed', - ], - [ - 'affiliatable_id' => test()->secondary_character->alliance->alliance_id, - 'affiliatable_type' => AllianceInfo::class, - 'type' => 'forbidden', - ], - ]); - - $ids = (new GetAffiliatedIdsByPermissionArray(test()->permission->name))->execute(); - - // Assert that second character does not share same corporation as the first character - test()->assertNotEquals(test()->secondary_character->alliance->alliance_id, test()->test_character->alliance->alliance_id); - - // Assert that the owned character_ids are present - expect(in_array(test()->test_character_user->character_id, $ids))->toBeTrue(); - - // Assert that ids from the allowed character is not present - expect(in_array(test()->secondary_character->character_id, $ids))->toBeFalse(); - - // Assert that ids from any other third party is not present - expect(in_array(test()->tertiary_character->character_id, $ids))->toBeFalse(); -}); - -it('caches results', function () { - test()->role->affiliations()->createMany([ - [ - 'affiliatable_id' => test()->secondary_character->alliance->alliance_id, - 'affiliatable_type' => AllianceInfo::class, - 'type' => 'allowed', - ], - [ - 'affiliatable_id' => test()->secondary_character->alliance->alliance_id, - 'affiliatable_type' => AllianceInfo::class, - 'type' => 'forbidden', - ], - ]); - - $action = new GetAffiliatedIdsByPermissionArray(test()->permission->name); - - expect(cache()->has($action->getCacheKey()))->toBeFalse(); - - $ids = $action->execute(); - - expect(cache()->has($action->getCacheKey()))->toBeTrue(); - expect(cache($action->getCacheKey()))->toEqual($ids); -}); - -it('returns corporation id', function () { - // first make sure test_character corporation is in the alliance - $corporation = test()->test_character->corporation; - $corporation->alliance_id = test()->test_character->alliance->alliance_id; - $corporation->save(); - - // create role affiliation on alliance level - test()->role->affiliations()->create([ - 'affiliatable_id' => test()->test_character->alliance->alliance_id, - 'affiliatable_type' => AllianceInfo::class, - 'type' => 'allowed', - ]); - - // Create director role for corporation - $character_role = CharacterRole::factory()->make([ - 'character_id' => test()->test_character->character_id, - 'roles' => ['Contract_Manager', 'Director'], - ]); - - $ids = (new GetAffiliatedIdsByPermissionArray(test()->permission->name, 'Director'))->execute(); - - // Assert that the owned character_ids are present - expect(in_array(test()->test_character->character_id, $ids))->toBeTrue(); - - // Assert that the corporation_id of test_character with director role is present - expect(in_array(test()->test_character->corporation->corporation_id, $ids))->toBeTrue(); -}); - -it('returns all character and corporation ids for superuser', function () { - // give test user superuser - Permission::create(['name' => 'superuser']); - test()->test_user->givePermissionTo('superuser'); - - // collect all corporation_ids - $corporation_ids = CorporationInfo::all()->pluck('corporation_id')->values(); - - // collect all character_ids - $character_ids = CharacterInfo::all()->pluck('character_id')->values(); - - // get ids - $ids = (new GetAffiliatedIdsByPermissionArray(test()->permission->name))->execute(); - - // check if ids are present - expect(collect([...$character_ids, ...$corporation_ids])->diff($ids)->isEmpty())->toBeTrue(); -}); diff --git a/tests/Unit/Actions/JoinActionTest.php b/tests/Unit/Actions/JoinActionTest.php new file mode 100644 index 0000000..c94d202 --- /dev/null +++ b/tests/Unit/Actions/JoinActionTest.php @@ -0,0 +1,48 @@ +mock(BaseRoleService::class, function ($mock) { + $mock->shouldReceive('for')->with(1)->andReturnSelf(); + $mock->shouldReceive('optIn')->andReturn(mock(OptInRoleService::class, function ($mock) { + $mock->shouldReceive('joinRole')->once(); + })); + }); + + $user = User::factory()->create(); + + $action = app(JoinAction::class); + $action->execute(1, $user->id); + + expect(true)->toBeTrue(); // Just to ensure the test runs without exceptions +}); + +it('throws exception if user not found during join', function () { + $this->mock(BaseRoleService::class, function ($mock) { + $mock->shouldReceive('for')->with(1)->andReturnSelf(); + $mock->shouldReceive('optIn')->andReturn(mock(OptInRoleService::class, function ($mock) { + $mock->shouldReceive('joinRole')->never(); + })); + }); + + $action = app(JoinAction::class); + + expect(fn () => $action->execute(1, 999))->toThrow(ModelNotFoundException::class); +}); + +it('throws exception if role service not found during join', function () { + $this->mock(BaseRoleService::class, function ($mock) { + $mock->shouldReceive('for')->with(1)->andThrow(new Exception('Role service not found')); + }); + + $user = User::factory()->create(); + + $action = app(JoinAction::class); + + expect(fn () => $action->execute(1, $user->id))->toThrow(Exception::class); +}); diff --git a/tests/Unit/Actions/LeaveActionTest.php b/tests/Unit/Actions/LeaveActionTest.php new file mode 100644 index 0000000..a9d84b6 --- /dev/null +++ b/tests/Unit/Actions/LeaveActionTest.php @@ -0,0 +1,36 @@ +mock(BaseRoleService::class, function ($mock) { + $mock->shouldReceive('for')->with(1)->andReturnSelf(); + $mock->shouldReceive('optIn')->andReturn(mock(OptInRoleService::class, function ($mock) { + $mock->shouldReceive('leaveRole')->once(); + })); + }); + + $user = User::factory()->create(); + + $action = app(LeaveAction::class); + $action->execute(1, $user->id); + + expect(true)->toBeTrue(); // Just to ensure the test runs without exceptions +}); + +it('throws exception if user not found during leave', function () { + $this->mock(BaseRoleService::class, function ($mock) { + $mock->shouldReceive('for')->with(1)->andReturnSelf(); + $mock->shouldReceive('optIn')->andReturn(mock(OptInRoleService::class, function ($mock) { + $mock->shouldReceive('leaveRole')->never(); + })); + }); + + $action = app(LeaveAction::class); + + expect(fn () => $action->execute(1, 999))->toThrow(ModelNotFoundException::class); +}); diff --git a/tests/Unit/Actions/LoginAssetActionTest.php b/tests/Unit/Actions/LoginAssetActionTest.php new file mode 100644 index 0000000..89c8043 --- /dev/null +++ b/tests/Unit/Actions/LoginAssetActionTest.php @@ -0,0 +1,38 @@ +toBe([ + 'login_welcome' => trans('auth::auth.login_welcome'), + 'evesso_img_src' => asset('img/evesso.png'), + ]); +}); + +it('adds a warning if SSO is not configured', function () { + Config::set('services.eveonline.client_id', '1234'); + Config::set('services.eveonline.client_secret', '1234'); + + $action = new LoginAssetsAction; + $action(); + + expect(Session::get('warning'))->toBe(trans('auth::auth.sso_config_warning')); +}); + +it('does not add a warning if SSO is configured correctly', function () { + Config::set('services.eveonline.client_id', 'valid_client_id'); + Config::set('services.eveonline.client_secret', 'valid_client_secret'); + + $action = new LoginAssetsAction; + $action(); + + expect(Session::get('warning'))->toBeNull(); +}); diff --git a/tests/Unit/Actions/LogoutActionTest.php b/tests/Unit/Actions/LogoutActionTest.php new file mode 100644 index 0000000..a10dad5 --- /dev/null +++ b/tests/Unit/Actions/LogoutActionTest.php @@ -0,0 +1,17 @@ +test_user->id; + $test_user = User::find($test_user_id)->makeVisible(['remember_token']); + + $this->actingAs($test_user); + + $action = new LogoutAction; + $response = $action(); + + expect($response->getStatusCode())->toBe(302); +}); diff --git a/tests/Unit/Actions/ManageAutomaticRoleActionTest.php b/tests/Unit/Actions/ManageAutomaticRoleActionTest.php new file mode 100644 index 0000000..668fe15 --- /dev/null +++ b/tests/Unit/Actions/ManageAutomaticRoleActionTest.php @@ -0,0 +1,120 @@ +actingAs(test()->test_user); + + $action = app(ManageAutomaticRoleAction::class); + $action->execute($request); +})->throws(Exception::class, 'You are not allowed to administrate access control groups'); + +it('invokes role service with valid role id', function () { + $role = Role::create(['name' => 'test']); + + $request = mock(RoleRequest::class, function (MockInterface $mock) use ($role) { + $mock->shouldReceive('validated')->once()->andReturn(['role_id' => $role->refresh()->id, 'affiliated' => [], 'assigned' => []]); + }); + + $admin_permission = 'administrate access control groups'; + + // give the user the permission to administrate access control groups + assignPermissionToTestUser($admin_permission); + + $this->actingAs(test()->test_user); + + expect(test()->test_user->hasPermissionTo($admin_permission))->toBeTrue() // ok + ->and(auth()->user()->hasPermissionTo($admin_permission))->toBeTrue() // ok + ->and(auth()->user()->can($admin_permission))->toBeTrue(); // fails + + $action = app(ManageAutomaticRoleAction::class); + $action->execute($request); +}); + +it('invokes role service with affiliated entities', function () { + $request = mock(RoleRequest::class, function (MockInterface $mock) { + $mock->shouldReceive('validated')->andReturn(['role_id' => 1, 'affiliated' => [['entity_id' => 1, 'entity_type' => 'corporation', 'affiliation_type' => 'allowed']], 'assigned' => []]); + }); + + $this->mock(BaseRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('for')->with(1)->andReturn($mock); + $mock->shouldReceive('automatic')->andReturn(mock(AutomaticRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('setRoleType')->once()->with(RoleType::AUTOMATIC); + $mock->shouldReceive('syncAffiliateManyEntities')->once()->withArgs(function (AffiliationData $entity) { + return $entity->entity_id === 1 && $entity->entity_type === 'corporation' && $entity->affiliation_type === AffiliationType::ALLOWED; + }); + // assigned: [] → empty array → automaticallyAssignRoleTo called with 0 args (clears criteria) + $mock->shouldReceive('automaticallyAssignRoleTo')->once()->withNoArgs(); + $mock->shouldReceive('handleMembers')->once(); + })); + }); + + $this->actingAs(test()->test_user); + // give the user the permission to administrate access control groups + assignPermissionToTestUser('administrate access control groups'); + + $action = app(ManageAutomaticRoleAction::class); + $action->execute($request); +}); + +it('invokes role service with assigned entities', function () { + $request = mock(RoleRequest::class, function (MockInterface $mock) { + $mock->shouldReceive('validated')->andReturn(['role_id' => 1, 'affiliated' => [], 'assigned' => [['entity_id' => 1, 'entity_type' => 'corporation']]]); + }); + + $this->mock(BaseRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('for')->once()->with(1)->andReturn($mock); + $mock->shouldReceive('automatic')->andReturn(mock(AutomaticRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('setRoleType')->once()->with(RoleType::AUTOMATIC); + // affiliated: [] → empty array → syncAffiliateManyEntities called with 0 args (clears scope) + $mock->shouldReceive('syncAffiliateManyEntities')->once()->withNoArgs(); + $mock->shouldReceive('automaticallyAssignRoleTo')->once()->withArgs(function (CriteriaData $entity) { + return $entity->entity_id === 1 && $entity->entity_type === 'corporation'; + }); + $mock->shouldReceive('handleMembers')->once(); + })); + }); + + $this->actingAs(test()->test_user); + // give the user the permission to administrate access control groups + assignPermissionToTestUser('administrate access control groups'); + + $action = app(ManageAutomaticRoleAction::class); + $action->execute($request); +}); + +it('updates name of role', function () { + $request = mock(RoleRequest::class, function (MockInterface $mock) { + $mock->shouldReceive('validated')->andReturn(['role_id' => 1, 'name' => 'new name', 'affiliated' => [], 'assigned' => []]); + }); + + $this->mock(BaseRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('for')->once()->with(1)->andReturn($mock); + $mock->shouldReceive('automatic')->andReturn(mock(AutomaticRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('setRoleType')->once()->with(RoleType::AUTOMATIC); + $mock->shouldReceive('updateRoleName')->once()->with('new name'); + // affiliated: [] and assigned: [] → both called with 0 args + $mock->shouldReceive('syncAffiliateManyEntities')->once()->withNoArgs(); + $mock->shouldReceive('automaticallyAssignRoleTo')->once()->withNoArgs(); + $mock->shouldReceive('handleMembers')->once(); + })); + }); + + $this->actingAs(test()->test_user); + // give the user the permission to administrate access control groups + assignPermissionToTestUser('administrate access control groups'); + + $action = app(ManageAutomaticRoleAction::class); + $action->execute($request); +}); diff --git a/tests/Unit/Actions/ManageManualRoleActionTest.php b/tests/Unit/Actions/ManageManualRoleActionTest.php new file mode 100644 index 0000000..a4db0bf --- /dev/null +++ b/tests/Unit/Actions/ManageManualRoleActionTest.php @@ -0,0 +1,96 @@ + 'test_role']); + + $role_request = mock(RoleRequest::class, function (MockInterface $mock) use ($role) { + $mock->shouldReceive('validated') + ->andReturn(['role_id' => $role->id]); + }); + + $this->mock(BaseRoleService::class, function (MockInterface $mock) use ($role) { + $mock->shouldReceive('for') + ->with($role->id) + ->andReturn($mock); + + $mock->shouldReceive('manual') + ->andReturn(mock(ManualRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('setRoleType')->once(); + $mock->shouldReceive('handleMembers')->once(); + })); + }); + + $action = app(ManageManualRoleAction::class); + + $action->execute($role_request); + + expect($role->refresh()->type)->toBe(RoleType::MANUAL); +}); + +it('updates the role name', function () { + $role = Role::create(['name' => 'test_role']); + + $role_request = mock(RoleRequest::class, function (MockInterface $mock) use ($role) { + $mock->shouldReceive('validated') + ->andReturn(['role_id' => $role->id, 'name' => 'new_name']); + }); + + $this->mock(BaseRoleService::class, function (MockInterface $mock) use ($role) { + $mock->shouldReceive('for') + ->with($role->id) + ->andReturn($mock); + + $mock->shouldReceive('manual') + ->andReturn(mock(ManualRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('setRoleType')->once(); + $mock->shouldReceive('updateRoleName')->once()->with('new_name'); + $mock->shouldReceive('handleMembers')->once(); + })); + }); + + $action = app(ManageManualRoleAction::class); + + $action->execute($role_request); +}); + +it('affiliates many entities', function () { + $role = Role::create(['name' => 'test_role']); + + $role_request = mock(RoleRequest::class, function (MockInterface $mock) use ($role) { + $mock->shouldReceive('validated') + ->andReturn(['role_id' => $role->id, 'affiliated' => [['entity_id' => 1, 'entity_type' => 'corporation', 'affiliation_type' => 'allowed']]]); + }); + + $this->mock(BaseRoleService::class, function (MockInterface $mock) use ($role) { + $mock->shouldReceive('for') + ->with($role->id) + ->andReturn($mock); + + $mock->shouldReceive('manual') + ->andReturn(mock(ManualRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('setRoleType')->once(); + $mock->shouldReceive('syncAffiliateManyEntities')->once()->withArgs(function (AffiliationData $affiliationData) { + return $affiliationData->entity_id === 1 + && $affiliationData->entity_type === 'corporation' + && $affiliationData->affiliation_type === AffiliationType::ALLOWED; + }); + $mock->shouldReceive('handleMembers')->once(); + })); + }); + + $action = app(ManageManualRoleAction::class); + + $action->execute($role_request); +}); diff --git a/tests/Unit/Actions/ManageOnRequestRoleActionTest.php b/tests/Unit/Actions/ManageOnRequestRoleActionTest.php new file mode 100644 index 0000000..889fdac --- /dev/null +++ b/tests/Unit/Actions/ManageOnRequestRoleActionTest.php @@ -0,0 +1,76 @@ + 'Role Name']); + + $request = mock(RoleRequest::class, function (MockInterface $mock) use ($role) { + + $mock->shouldReceive('validated')->once()->andReturn([ + 'role_id' => $role->refresh()->id, + 'affiliated' => [['entity_id' => 1, 'entity_type' => 'corporation', 'affiliation_type' => 'allowed']], + 'assigned' => [['entity_id' => 5, 'entity_type' => 'character']], + 'name' => 'New Role Name', + ]); + }); + + $this->mock(BaseRoleService::class, function ($mock) use ($role) { + $mock->shouldReceive('for') + ->with($role->id) + ->andReturn($mock); + + $mock->shouldReceive('onRequest')->andReturn(mock(OnRequestRoleService::class, function ($mock) { + $mock->shouldReceive('setRoleType')->with(RoleType::ON_REQUEST)->once(); + $mock->shouldReceive('updateRoleName')->once(); + $mock->shouldReceive('syncAffiliateManyEntities')->once()->withArgs(function (AffiliationData $entity) { + return $entity->entity_id === 1 && $entity->entity_type === 'corporation' && $entity->affiliation_type === AffiliationType::ALLOWED; + }); + $mock->shouldReceive('addCriteriaForRoleApplication')->once()->withArgs(function (CriteriaData $entity) { + return $entity->entity_id === 5 && $entity->entity_type === 'character'; + }); + $mock->shouldReceive('handleMembers')->once(); + })); + }); + + // give the user the permission to manage roles + assignPermissionToTestUser('administrate access control groups'); + $this->actingAs($this->test_user->refresh()); + + $action = app(ManageOnRequestRoleAction::class); + $action->execute($request); + + expect(true)->toBeTrue(); // Just to ensure the test runs without exceptions +}); + +it('throws exception if user does not have permission', function () { + $this->mock(BaseRoleService::class, function ($mock) { + $mock->shouldReceive('for')->never(); + $mock->shouldReceive('onRequest')->never(); + }); + + $this->actingAs($this->test_user); + + $request = Mockery::mock(RoleRequest::class); + $request->shouldReceive('validated')->andReturn([ + 'role_id' => 1, + 'affiliated' => ['entity1', 'entity2'], + 'assigned' => ['criteria1', 'criteria2'], + 'name' => 'New Role Name', + ]); + + $action = app(ManageOnRequestRoleAction::class); + + expect(fn () => $action->execute($request))->toThrow(HttpException::class); +}); diff --git a/tests/Unit/Actions/ManageOptInRoleActionTest.php b/tests/Unit/Actions/ManageOptInRoleActionTest.php new file mode 100644 index 0000000..0307164 --- /dev/null +++ b/tests/Unit/Actions/ManageOptInRoleActionTest.php @@ -0,0 +1,76 @@ + 'Role Name']); + + $request = mock(RoleRequest::class, function (MockInterface $mock) use ($role) { + + $mock->shouldReceive('validated')->once()->andReturn([ + 'role_id' => $role->refresh()->id, + 'affiliated' => [['entity_id' => 1, 'entity_type' => 'corporation', 'affiliation_type' => 'allowed']], + 'assigned' => [['entity_id' => 5, 'entity_type' => 'character']], + 'name' => 'New Role Name', + ]); + }); + + $this->mock(BaseRoleService::class, function ($mock) use ($role) { + $mock->shouldReceive('for') + ->with($role->id) + ->andReturn($mock); + + $mock->shouldReceive('optIn')->andReturn(mock(OptInRoleService::class, function ($mock) { + $mock->shouldReceive('setRoleType')->with(RoleType::OPT_IN)->once(); + $mock->shouldReceive('updateRoleName')->once(); + $mock->shouldReceive('syncAffiliateManyEntities')->once()->withArgs(function (AffiliationData $entity) { + return $entity->entity_id === 1 && $entity->entity_type === 'corporation' && $entity->affiliation_type === AffiliationType::ALLOWED; + }); + $mock->shouldReceive('addCriteriaForRole')->once()->withArgs(function (CriteriaData $entity) { + return $entity->entity_id === 5 && $entity->entity_type === 'character'; + }); + $mock->shouldReceive('handleMembers')->once(); + })); + }); + + // give the user the permission to manage roles + assignPermissionToTestUser('administrate access control groups'); + $this->actingAs($this->test_user->refresh()); + + $action = app(ManageOptInRoleAction::class); + $action->execute($request); + + expect(true)->toBeTrue(); // Just to ensure the test runs without exceptions +}); + +it('throws exception if user does not have permission', function () { + $this->mock(BaseRoleService::class, function ($mock) { + $mock->shouldReceive('for')->never(); + $mock->shouldReceive('optIn')->never(); + }); + + $this->actingAs($this->test_user); + + $request = Mockery::mock(RoleRequest::class); + $request->shouldReceive('validated')->andReturn([ + 'role_id' => 1, + 'affiliated' => ['entity1', 'entity2'], + 'assigned' => ['criteria1', 'criteria2'], + 'name' => 'New Role Name', + ]); + + $action = app(ManageOptInRoleAction::class); + + expect(fn () => $action->execute($request))->toThrow(HttpException::class); +}); diff --git a/tests/Unit/Actions/OptOutActionTest.php b/tests/Unit/Actions/OptOutActionTest.php new file mode 100644 index 0000000..3ca3cf7 --- /dev/null +++ b/tests/Unit/Actions/OptOutActionTest.php @@ -0,0 +1,36 @@ +mock(BaseRoleService::class, function ($mock) { + $mock->shouldReceive('for')->with(1)->andReturnSelf(); + $mock->shouldReceive('onRequest')->andReturn(mock(OnRequestRoleService::class, function ($mock) { + $mock->shouldReceive('removeApplication')->once(); + })); + }); + + $user = User::factory()->create(); + + $action = app(OptOutAction::class); + $action->execute(1, $user->id); + + expect(true)->toBeTrue(); // Just to ensure the test runs without exceptions +}); + +it('throws exception if user not found during opt out', function () { + $this->mock(BaseRoleService::class, function ($mock) { + $mock->shouldReceive('for')->with(1)->andReturnSelf(); + $mock->shouldReceive('onRequest')->andReturn(mock(OnRequestRoleService::class, function ($mock) { + $mock->shouldReceive('removeApplication')->never(); + })); + }); + + $action = app(OptOutAction::class); + + expect(fn () => $action->execute(1, 999))->toThrow(ModelNotFoundException::class); +}); diff --git a/tests/Unit/Actions/RemoveMemberActionTest.php b/tests/Unit/Actions/RemoveMemberActionTest.php new file mode 100644 index 0000000..2eaae4c --- /dev/null +++ b/tests/Unit/Actions/RemoveMemberActionTest.php @@ -0,0 +1,16 @@ + 'test']); + + $this->mock(SetMemberAction::class, function ($mock) use ($role) { + $mock->shouldReceive('execute')->with($role->id, 1, false)->once(); + }); + + $action = app(RemoveMemberAction::class); + $action->execute($role->id, 1); +}); diff --git a/tests/Unit/Actions/RemoveModeratorRoleActionTest.php b/tests/Unit/Actions/RemoveModeratorRoleActionTest.php new file mode 100644 index 0000000..6cdc147 --- /dev/null +++ b/tests/Unit/Actions/RemoveModeratorRoleActionTest.php @@ -0,0 +1,16 @@ +mock(SetModeratorAction::class, function ($mock) { + $mock->shouldReceive('execute')->with(1, 2, false)->once(); + }); + + $action = app(RemoveModeratorRoleAction::class); + + $action->execute(1, 2); +}); diff --git a/tests/Unit/Actions/SetMemberActionTest.php b/tests/Unit/Actions/SetMemberActionTest.php new file mode 100644 index 0000000..76c7b1e --- /dev/null +++ b/tests/Unit/Actions/SetMemberActionTest.php @@ -0,0 +1,58 @@ +mock(BaseRoleService::class, function (MockInterface $mock) { + + $mock->shouldReceive('for') + ->once() + ->with(1); + + $mock->shouldReceive('canModerate') + ->once() + ->andReturn(false); + }); + + $action = app(SetMemberAction::class); + + $this->actingAs($this->test_user); + $action->execute(1, 1, true); +})->throws(Exception::class, 'You are not allowed to do this action'); + +it('sets member', function (bool $is_member) { + + $this->mock(BaseRoleService::class, function (MockInterface $mock) use ($is_member) { + + $mock->shouldReceive('for') + ->once() + ->with(1); + + $mock->shouldReceive('canModerate') + ->once() + ->andReturn(true); + + $mock->shouldReceive('manual') + ->once() + ->andReturn(mock(ManualRoleService::class, function (MockInterface $mock) use ($is_member) { + + if ($is_member) { + $mock->shouldReceive('addMember') + ->once(); + } else { + $mock->shouldReceive('removeMember') + ->once(); + } + + })); + }); + + $action = app(SetMemberAction::class); + + $this->actingAs($this->test_user); + $action->execute(1, $this->test_user->id, $is_member); +})->with([true, false]); diff --git a/tests/Unit/Actions/SetModeratorActionTest.php b/tests/Unit/Actions/SetModeratorActionTest.php new file mode 100644 index 0000000..68694f3 --- /dev/null +++ b/tests/Unit/Actions/SetModeratorActionTest.php @@ -0,0 +1,55 @@ +actingAs(test()->test_user); + + $this->mock(BaseRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('for')->with(1); + $mock->shouldReceive('canModerate')->andReturn(false); + }); + + $action = app(SetModeratorAction::class); + + $action->execute(1, 1, true); + +})->throws(Exception::class, 'You are not allowed to add moderators'); + +it('sets moderator role', function () { + $this->actingAs(test()->test_user); + + $this->mock(BaseRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('for')->with(1); + $mock->shouldReceive('canModerate')->andReturn(true); + $mock->shouldReceive('getTypeService')->andReturn( + mock(OnRequestRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('setModerator')->once()->andReturn(); + }) + ); + }); + + $action = app(SetModeratorAction::class); + + $action->execute(1, test()->test_user->id, true); +}); + +it('throws exception if role type is not manual or on request', function () { + $this->actingAs(test()->test_user); + + $this->mock(BaseRoleService::class, function (MockInterface $mock) { + $mock->shouldReceive('for')->with(1); + $mock->shouldReceive('canModerate')->andReturn(true); + $mock->shouldReceive('getTypeService')->andReturn( + mock(AutomaticRoleService::class) + ); + }); + + $action = app(SetModeratorAction::class); + + $action->execute(1, 1, true); +})->throws(Exception::class, 'This action is not allowed'); diff --git a/tests/Unit/Affiliations/SeatPlusRolesTest.php b/tests/Unit/Affiliations/SeatPlusRolesTest.php deleted file mode 100644 index a7fa70f..0000000 --- a/tests/Unit/Affiliations/SeatPlusRolesTest.php +++ /dev/null @@ -1,261 +0,0 @@ -secondary_character = CharacterInfo::factory()->create(); - - test()->tertiary_character = CharacterInfo::factory()->create(); - - test()->role = Role::create(['name' => 'derp']); -}); - -test('user has no roles test', function () { - expect(test()->test_user->roles->isEmpty())->toBeTrue(); -}); - -test('user has role test', function () { - test()->test_user->assignRole(test()->role); - - expect(test()->test_user->roles->isNotEmpty())->toBeTrue(); -}); - -test('role has no affiliation test', function () { - expect(test()->role->affiliations->isEmpty())->toBeTrue(); -}); - -test('role has an affiliation test', function () { - test()->role->affiliations()->create([ - 'affiliatable_id' => test()->test_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'type' => 'allowed', - ]); - - test()->assertNotNUll(test()->role->affiliations); -}); - -test('user is in affiliation test', function () { - test()->role->affiliations()->create([ - 'affiliatable_id' => test()->test_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'type' => 'allowed', - ]); - - expect(in_array(test()->test_character->character_id, test()->role->affiliated_ids))->toBeTrue(); -}); - -test('character is in character allowed affiliation test', function () { - $secondary_character = CharacterInfo::factory()->create(); - - test()->role->affiliations()->createMany([ - [ - 'affiliatable_id' => test()->test_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'type' => 'allowed', - ], - [ - 'affiliatable_id' => $secondary_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'type' => 'allowed', - ], - - ]); - - expect(in_array(test()->test_character->character_id, test()->role->affiliated_ids))->toBeTrue(); - expect(in_array($secondary_character->character_id, test()->role->affiliated_ids))->toBeTrue(); -}); - -test('character is in character inversed affiliation test', function () { - test()->role->affiliations()->createMany([ - [ - 'affiliatable_id' => test()->test_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'type' => 'inverse', - ], - [ - 'affiliatable_id' => 1234, - 'affiliatable_type' => CharacterInfo::class, - 'type' => 'inverse', - ], - ]); - - expect(in_array(test()->test_character->character_id, test()->role->affiliated_ids))->toBeFalse(); - expect(in_array(test()->secondary_character->character_id, test()->role->affiliated_ids))->toBeTrue(); -}); - -test('character is not in character inverse affiliation test', function () { - test()->role->affiliations()->createMany([ - [ - 'affiliatable_id' => test()->secondary_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'type' => 'inverse', - ], - [ - 'affiliatable_id' => test()->tertiary_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'type' => 'inverse', - ], - ]); - - expect(in_array(test()->test_character->character_id, test()->role->affiliated_ids))->toBeTrue(); - expect(in_array(test()->secondary_character->character_id, test()->role->affiliated_ids))->toBeFalse(); - expect(in_array(test()->tertiary_character->character_id, test()->role->affiliated_ids))->toBeFalse(); -}); - -test('character is in character forbidden affiliation test', function () { - test()->role->affiliations()->createMany([ - [ - 'affiliatable_id' => test()->test_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'type' => 'forbidden', - ], - [ - 'affiliatable_id' => test()->secondary_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'type' => 'forbidden', - ], - ]); - - expect(in_array(test()->test_character->character_id, test()->role->affiliated_ids))->toBeFalse(); - expect(in_array(test()->secondary_character->character_id, test()->role->affiliated_ids))->toBeFalse(); - expect(in_array(test()->tertiary_character->character_id, test()->role->affiliated_ids))->toBeFalse(); -}); - -test('character is in corporation allowed affiliation test', function () { - test()->role->affiliations()->create([ - 'affiliatable_id' => test()->test_character->corporation_id, - 'affiliatable_type' => CorporationInfo::class, - 'type' => 'allowed', - ]); - - expect(in_array(test()->test_character->character_id, test()->role->affiliated_ids))->toBeTrue(); - expect(in_array(test()->secondary_character->character_id, test()->role->affiliated_ids))->toBeFalse(); - expect(in_array(test()->tertiary_character->character_id, test()->role->affiliated_ids))->toBeFalse(); -}); - -test('character is in corporation inversed affiliation test', function () { - test()->role->affiliations()->createMany([ - [ - 'affiliatable_id' => test()->test_character->corporation_id, - 'affiliatable_type' => CorporationInfo::class, - 'type' => 'inverse', - ], - [ - 'affiliatable_id' => test()->secondary_character->corporation_id, - 'affiliatable_type' => CorporationInfo::class, - 'type' => 'inverse', - ], - ]); - - expect(in_array(test()->test_character->character_id, test()->role->affiliated_ids))->toBeFalse(); - expect(in_array(test()->secondary_character->character_id, test()->role->affiliated_ids))->toBeFalse(); - expect(in_array(test()->tertiary_character->character_id, test()->role->affiliated_ids))->toBeTrue(); -}); - -test('character is in corporation forbidden affiliation test', function () { - test()->role->affiliations()->createMany([ - [ - 'affiliatable_id' => test()->test_character->corporation_id, - 'affiliatable_type' => CorporationInfo::class, - 'type' => 'forbidden', - ], - [ - 'affiliatable_id' => test()->secondary_character->corporation_id, - 'affiliatable_type' => CorporationInfo::class, - 'type' => 'forbidden', - ], - ]); - - expect(in_array(test()->test_character->character_id, test()->role->affiliated_ids))->toBeFalse(); - expect(in_array(test()->secondary_character->character_id, test()->role->affiliated_ids))->toBeFalse(); - expect(in_array(test()->tertiary_character->character_id, test()->role->affiliated_ids))->toBeFalse(); -}); - -test('character is in alliance allowed affiliation test', function () { - test()->role->affiliations()->createMany([ - [ - 'affiliatable_id' => test()->test_character->alliance_id, - 'affiliatable_type' => AllianceInfo::class, - 'type' => 'allowed', - ], - [ - 'affiliatable_id' => test()->secondary_character->alliance_id, - 'affiliatable_type' => AllianceInfo::class, - 'type' => 'allowed', - ], - ]); - - expect(in_array(test()->test_character->character_id, test()->role->affiliated_ids))->toBeTrue(); - expect(in_array(test()->secondary_character->character_id, test()->role->affiliated_ids))->toBeTrue(); - expect(in_array(test()->tertiary_character->character_id, test()->role->affiliated_ids))->toBeFalse(); -}); - -test('character is in alliance inversed affiliation test', function () { - test()->role->affiliations()->createMany([ - [ - 'affiliatable_id' => test()->test_character->alliance_id, - 'affiliatable_type' => AllianceInfo::class, - 'type' => 'inverse', - ], - [ - 'affiliatable_id' => test()->secondary_character->alliance_id, - 'affiliatable_type' => AllianceInfo::class, - 'type' => 'inverse', - ], - ]); - - expect(in_array(test()->test_character->character_id, test()->role->affiliated_ids))->toBeFalse(); - expect(in_array(test()->secondary_character->character_id, test()->role->affiliated_ids))->toBeFalse(); - expect(in_array(test()->tertiary_character->character_id, test()->role->affiliated_ids))->toBeTrue(); -}); - -test('character is in alliance forbidden affiliation test', function () { - test()->role->affiliations()->createMany([ - [ - 'affiliatable_id' => test()->test_character->alliance_id, - 'affiliatable_type' => AllianceInfo::class, - 'type' => 'forbidden', - ], - [ - 'affiliatable_id' => test()->secondary_character->alliance_id, - 'affiliatable_type' => AllianceInfo::class, - 'type' => 'forbidden', - ], - ]); - - expect(in_array(test()->test_character->character_id, test()->role->affiliated_ids))->toBeFalse(); - expect(in_array(test()->secondary_character->character_id, test()->role->affiliated_ids))->toBeFalse(); - expect(in_array(test()->tertiary_character->character_id, test()->role->affiliated_ids))->toBeFalse(); -}); diff --git a/tests/Unit/AuthenticationServiceProviderTest.php b/tests/Unit/AuthenticationServiceProviderTest.php new file mode 100644 index 0000000..9e821dc --- /dev/null +++ b/tests/Unit/AuthenticationServiceProviderTest.php @@ -0,0 +1,11 @@ +driver('eveonline'); + + expect($driver)->toBeInstanceOf(Provider::class); +}); diff --git a/tests/Unit/Controllers/CallbackControllerTest.php b/tests/Unit/Controllers/CallbackControllerTest.php new file mode 100644 index 0000000..b0126fd --- /dev/null +++ b/tests/Unit/Controllers/CallbackControllerTest.php @@ -0,0 +1,98 @@ +makePartial(); + }); + + $socialite_user->attributes = (object) [ + 'character_id' => 1, + 'character_owner_hash' => faker()->sha256, + ]; + $socialite_user->token = 'token'; + $socialite_user->refreshToken = 'refreshToken'; + $socialite_user->expiresIn = 12 * 60; // let's just say 12 minutes + $socialite_user->user = [ + 'scp' => ['esi-skills.read_skills.v1', 'esi-skills.read_skillqueue.v1'], + ]; + + $social = mock(Socialite::class, function (MockInterface $social) use ($socialite_user) { + $social->shouldReceive('driver->user')->andReturn($socialite_user); + }); + + $find_or_create_user_action = mock(FindOrCreateUserAction::class, function (MockInterface $mock) { + $mock->shouldReceive('__invoke')->andReturn(mock(User::class)); + }); + + $update_refresh_token_action = mock(UpdateRefreshTokenAction::class, function (MockInterface $mock) { + $mock->shouldReceive('__invoke')->andReturnNull(); + }); + + $authenticationService = mock(AuthenticationService::class, function (MockInterface $mock) { + $mock->shouldReceive('isUserAuthenticated')->andReturnFalse(); + $mock->shouldReceive('loginUser')->andReturnFalse(); + }); + + $controller = new CallbackController($authenticationService); + + $response = $controller($social, $find_or_create_user_action, $update_refresh_token_action); + + expect($response)->toBeInstanceOf(RedirectResponse::class) + ->and(session('error'))->toBe('Login failed. Please contact your administrator.'); +}); + +it('redirects back if different character id is provided', function () { + $socialite_user = mock(SocialiteUser::class, function (MockInterface $mock) { + $mock->makePartial(); + }); + + $socialite_user->attributes = (object) [ + 'character_id' => 1, + 'character_owner_hash' => faker()->sha256, + ]; + $socialite_user->token = 'token'; + $socialite_user->refreshToken = 'refreshToken'; + $socialite_user->expiresIn = 12 * 60; // let's just say 12 minutes + $socialite_user->user = [ + 'scp' => ['esi-skills.read_skills.v1', 'esi-skills.read_skillqueue.v1'], + ]; + + $social = mock(Socialite::class, function (MockInterface $social) use ($socialite_user) { + $social->shouldReceive('driver->user')->andReturn($socialite_user); + }); + + $find_or_create_user_action = mock(FindOrCreateUserAction::class, function (MockInterface $mock) { + $mock->shouldReceive('__invoke')->andReturn(mock(User::class)); + }); + + $update_refresh_token_action = mock(UpdateRefreshTokenAction::class, function (MockInterface $mock) { + $mock->shouldReceive('__invoke')->andReturnNull(); + }); + + $authenticationService = mock(AuthenticationService::class, function (MockInterface $mock) { + $mock->shouldReceive('isUserAuthenticated')->andReturnTrue(); + $mock->shouldReceive('getSessionValue')->with('sso_scopes')->andReturn(['esi-skills.read_skills.v1', 'esi-skills.read_skillqueue.v1']); + $mock->shouldReceive('getSessionValue')->with('step_up')->andReturn(2); + $mock->shouldReceive('flashMessage') + ->once() + ->with('error', 'Please make sure to select the same character to step up on CCP as on seatplus.') + ->andReturnNull(); + }); + + $controller = new CallbackController($authenticationService); + + $response = $controller($social, $find_or_create_user_action, $update_refresh_token_action); + + expect($response)->toBeInstanceOf(RedirectResponse::class); +}); diff --git a/tests/Unit/Controllers/RedirectSSOControllerTest.php b/tests/Unit/Controllers/RedirectSSOControllerTest.php new file mode 100644 index 0000000..b1569a6 --- /dev/null +++ b/tests/Unit/Controllers/RedirectSSOControllerTest.php @@ -0,0 +1,44 @@ +serviceMock = mock(GlobalSsoScopesService::class); + $this->authenticationServiceMock = mock(AuthenticationService::class); + $this->socialiteMock = mock(Socialite::class); + $this->controller = new RedirectSSOController($this->serviceMock, $this->authenticationServiceMock); +}); + +afterEach(function () { + Mockery::close(); +}); + +it('redirects to Eve Online authentication page when user is not authenticated', function () { + $this->authenticationServiceMock->shouldReceive('isUserAuthenticated')->andReturn(false); + $this->authenticationServiceMock->shouldReceive('getPreviousUrl')->andReturn('http://example.com/previous'); + $this->serviceMock->shouldReceive('get')->andReturn(['scope1', 'scope2']); + $this->socialiteMock->shouldReceive('driver') + ->with('eveonline') + ->andReturn(mock(Provider::class, function (MockInterface $mock) { + $mock->shouldReceive('scopes')->andReturnSelf(); + $mock->shouldReceive('redirect')->andReturn(new RedirectResponse('http://example.com/redirect')); + })); + + $response = $this->controller->__invoke($this->socialiteMock); + + expect($response->getTargetUrl())->toBe('http://example.com/redirect'); +}); + +it('redirects home when user is already authenticated', function () { + $this->authenticationServiceMock->shouldReceive('isUserAuthenticated')->andReturn(true); + + $response = $this->controller->__invoke($this->socialiteMock); + + expect($response)->toBeInstanceOf(RedirectResponse::class); +}); diff --git a/tests/Unit/Events/RefreshTokenTest.php b/tests/Unit/Events/RefreshTokenTest.php index c648304..6dc9c30 100644 --- a/tests/Unit/Events/RefreshTokenTest.php +++ b/tests/Unit/Events/RefreshTokenTest.php @@ -4,11 +4,12 @@ use Seatplus\Auth\Models\CharacterUser; use Seatplus\Eveapi\Models\RefreshToken; -it('flush characters_with_missing_scopes cache for user when a new character is added', function () { +it('forgets user permission object when a new character is added', function () { $user_id = test()->test_user->id; - Cache::shouldReceive('tags')->with(['characters_with_missing_scopes', $user_id])->andReturnSelf(); - Cache::shouldReceive('flush')->once(); + Cache::shouldReceive('forget') + ->once() + ->with("user_permissions_{$user_id}"); $character_user = CharacterUser::factory()->create([ 'user_id' => $user_id, @@ -18,11 +19,12 @@ RefreshToken::factory()->create(['character_id' => $character_user->character_id]); }); -it('flush characters_with_missing_scopes cache for user when refresh_token scopes are updated', function () { +it('forgets user permission object when refresh_token scopes are updated', function () { $user_id = test()->test_user->id; - Cache::shouldReceive('tags')->with(['characters_with_missing_scopes', $user_id])->andReturnSelf(); - Cache::shouldReceive('flush')->once(); + Cache::shouldReceive('forget') + ->once() + ->with("user_permissions_{$user_id}"); $refresh_token = test()->test_character->refresh_token; $refresh_token->token = createSocialiteUser($refresh_token->character_id, ['foo', 'bar'])->token; diff --git a/tests/Unit/FindOrCreateUserActionTest.php b/tests/Unit/FindOrCreateUserActionTest.php index 9a1eaaa..ba8af7f 100644 --- a/tests/Unit/FindOrCreateUserActionTest.php +++ b/tests/Unit/FindOrCreateUserActionTest.php @@ -34,7 +34,7 @@ 'main_character_id' => $eve_user->character_id, ]); - $action = new FindOrCreateUserAction(); + $action = new FindOrCreateUserAction; $user = $action($eve_user); test()->assertDatabaseHas('users', [ @@ -63,7 +63,7 @@ $secondary_character->character_owner_hash ); - $action = new FindOrCreateUserAction(); + $action = new FindOrCreateUserAction; $user = $action($eve_user); expect($user->id)->toEqual(test()->test_user->id); @@ -82,7 +82,7 @@ 'anotherHashValue' ); - $action = new FindOrCreateUserAction(); + $action = new FindOrCreateUserAction; $user = $action($eve_user); test()->assertDatabaseHas('users', [ @@ -117,7 +117,7 @@ 'anotherHashValue' ); - $action = new FindOrCreateUserAction(); + $action = new FindOrCreateUserAction; $user = $action($eve_user); expect($user->character_users->count())->toEqual(1); @@ -134,7 +134,7 @@ expect(test()->test_user->id)->not()->toBe($user->id); - //5. assert that secondary character is not affiliated to first user + // 5. assert that secondary character is not affiliated to first user test()->assertDatabaseMissing('character_users', [ 'user_id' => test()->test_user->id, @@ -156,7 +156,7 @@ $secondary_user->character_owner_hash ); - $action = new FindOrCreateUserAction(); + $action = new FindOrCreateUserAction; // act as test user test()->actingAs(test()->test_user); diff --git a/tests/Unit/Jobs/RoleMemberSyncTest.php b/tests/Unit/Jobs/RoleMemberSyncTest.php new file mode 100644 index 0000000..546887d --- /dev/null +++ b/tests/Unit/Jobs/RoleMemberSyncTest.php @@ -0,0 +1,30 @@ +serviceMock = mock(BaseRoleService::class); + $this->job = new RoleMemberSync($this->serviceMock); +}); + +afterEach(function () { + Mockery::close(); +}); + +it('handles role member synchronization successfully', function () { + + $role = Role::create(['name' => 'derp']); + + $this->serviceMock->shouldReceive('for')->once()->andReturnSelf(); + $this->serviceMock->shouldReceive('handleMembers')->once(); + + $this->job->handle(); +}); + +it('returns correct tags for the job', function () { + $tags = $this->job->tags(); + + expect($tags)->toBe(['Dispatch Role Updates']); +}); diff --git a/tests/Unit/Middleware/CheckRequiredScopesTest.php b/tests/Unit/Middleware/CheckRequiredScopesTest.php index 6cdc195..910cd97 100644 --- a/tests/Unit/Middleware/CheckRequiredScopesTest.php +++ b/tests/Unit/Middleware/CheckRequiredScopesTest.php @@ -24,374 +24,350 @@ * SOFTWARE. */ +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Str; use Seatplus\Auth\Http\Middleware\CheckRequiredScopes; use Seatplus\Auth\Models\CharacterUser; +use Seatplus\Auth\Models\User; +use Seatplus\Auth\Services\SsoScopes\IsUserCompliantService; use Seatplus\Eveapi\Models\Character\CharacterInfo; use Seatplus\Eveapi\Models\RefreshToken; use Seatplus\Eveapi\Models\SsoScopes; beforeEach(function () { - //test()->actingAs(test()->test_user); + // test()->actingAs(test()->test_user); mockRequest(); Event::fake(); }); -it('lets request through if no scopes are required', function () { - createRefreshTokenWithScopes(['a', 'b']); +describe('redirect request', function () { + it('if required scopes are missing', function () { + // 1. Create RefreshToken for Character + createRefreshTokenWithScopes(['a', 'b']); - test()->actingAs(test()->test_user); - - mockMiddleware(); - - //test()->middleware->shouldReceive('redirectTo')->once(); - test()->request->shouldReceive('forward')->times(1); - - test()->middleware->handle(test()->request, test()->next); -}); - -it('lets request through if required scopes are present', function () { - // 1. Create RefreshToken for Character - createRefreshTokenWithScopes(['a', 'b']); + // 2. Create SsoScope (Corporation) + createCorporationSsoScope([ + 'character' => ['c'], + 'corporation' => [], + ]); - // 2. Create SsoScope (Corporation) - createCorporationSsoScope([ - 'character' => ['a'], - 'corporation' => [], - ]); + // TestingTime - // TestingTime + test()->actingAs(test()->test_user); - test()->actingAs(test()->test_user); + mockMiddleware(); - mockMiddleware(); + // Expect redirect + test()->middleware->shouldReceive('redirectTo')->times(1); - //Expect 1 forward - test()->request->shouldReceive('forward')->times(1); + test()->middleware->handle(test()->request, test()->next); + }); - test()->middleware->handle(test()->request, test()->next); -}); + it('if required corporation role scopes is missing', function () { + // 1. Create RefreshToken for Character + createRefreshTokenWithScopes(['a', 'b']); -it('stops request if required scopes are missing', function () { - // 1. Create RefreshToken for Character - createRefreshTokenWithScopes(['a', 'b']); + // 2. Create SsoScope (Corporation) + createCorporationSsoScope(['c']); - // 2. Create SsoScope (Corporation) - createCorporationSsoScope([ - 'character' => ['c'], - 'corporation' => [], - ]); + // TestingTime - // TestingTime + test()->actingAs(test()->test_user); - test()->actingAs(test()->test_user); + mockMiddleware(); - mockMiddleware(); + // Expect redirect + test()->middleware->shouldReceive('redirectTo')->times(1); - //Expect redirect - test()->middleware->shouldReceive('redirectTo')->times(1); + test()->middleware->handle(test()->request, test()->next); + }); - test()->middleware->handle(test()->request, test()->next); -}); + it('if user scopes is missing', function () { + // Create RefreshToken for Character + createRefreshTokenWithScopes(['a', 'b']); -it('stops request if required corporation role scopes is missing', function () { - // 1. Create RefreshToken for Character - createRefreshTokenWithScopes(['a', 'b']); + // create user corporation scope + createCorporationSsoScope(['a'], 'user'); - // 2. Create SsoScope (Corporation) - createCorporationSsoScope(['c']); + // to this point the middleware should pass no question asked - // TestingTime + // Create secondary character + $secondary_character = Event::fakeFor(function () { + $character_user = CharacterUser::factory()->make(); + test()->test_user->character_users()->save($character_user); - test()->actingAs(test()->test_user); + return CharacterInfo::find($character_user->character_id); + }); - mockMiddleware(); + // test that the test user owns both characters + expect(test()->test_user->refresh()->characters)->toHaveCount(2); - //Expect redirect - test()->middleware->shouldReceive('redirectTo')->times(1); + // test that primary and secondary character has different corporations + test()->assertNotEquals(test()->test_character->corporation->corporation_id, $secondary_character->corporation->corporation_id); - test()->middleware->handle(test()->request, test()->next); -}); + // create refresh_token for secondary character + Event::fakeFor(function () use ($secondary_character) { + $helper_token = RefreshToken::factory()->scopes(['c'])->make([ + 'character_id' => $secondary_character->character_id, + ]); -it('lets request through if required corporation role scopes is present', function () { - // 1. Create RefreshToken for Character - createRefreshTokenWithScopes(['a', 'b', 'esi-characters.read_corporation_roles.v1']); + $refresh_token = $secondary_character->refresh_token; + $refresh_token->token = $helper_token->token; + $refresh_token->save(); + }); + // at this point secondary character has scope c and misses scope a thus should result in an error - // 2. Create SsoScope (Corporation) - createCorporationSsoScope([ - 'character' => [], - 'corporation' => ['b'], - ]); + // TestingTime - // TestingTime + test()->actingAs(test()->test_user); - test()->actingAs(test()->test_user); + mockMiddleware(); - mockMiddleware(); + // Expect redirect + test()->middleware->shouldReceive('redirectTo')->times(1); - //Expect redirect - test()->request->shouldReceive('forward')->times(1); + test()->middleware->handle(test()->request, test()->next); + }); - test()->middleware->handle(test()->request, test()->next); -}); + it('if user misses global scopes', function () { + // 1. Create RefreshToken for Character + createRefreshTokenWithScopes(['a', 'b']); -it('forwards request if user misses global scopes', function () { - // 1. Create RefreshToken for Character - createRefreshTokenWithScopes(['a', 'b']); + // 2. create global required scope + SsoScopes::updateOrCreate(['type' => 'global'], ['selected_scopes' => ['c']]); - // 2. create global required scope - SsoScopes::updateOrCreate(['type' => 'global'], ['selected_scopes' => ['c']]); + // TestingTime - // TestingTime + test()->actingAs(test()->test_user); - test()->actingAs(test()->test_user); + mockMiddleware(); - mockMiddleware(); + // Expect redirect + test()->middleware->shouldReceive('redirectTo')->times(1); - //Expect redirect - test()->middleware->shouldReceive('redirectTo')->times(1); + test()->middleware->handle(test()->request, test()->next); + }); - test()->middleware->handle(test()->request, test()->next); -}); + it('if user application has not required scopes', function () { + // 1. Create RefreshToken for Character + createRefreshTokenWithScopes(['a', 'b']); -it('lets request through if required global scopes are present', function () { - // 1. Create RefreshToken for Character - createRefreshTokenWithScopes(['a', 'b']); + // 2. create user application + test()->test_user->application()->create(['id' => Str::uuid(), 'corporation_id' => test()->test_character->corporation->corporation_id]); - // 2. create global required sso scope - SsoScopes::updateOrCreate(['type' => 'global'], ['selected_scopes' => ['a']]); + // 3. create required corp scopes + createCorporationSsoScope(['c']); - // TestingTime + // TestingTime - test()->actingAs(test()->test_user); + test()->actingAs(test()->test_user); - mockMiddleware(); + mockMiddleware(); - //Expect 1 forward - test()->request->shouldReceive('forward')->times(1); + // Expect redirect + test()->middleware->shouldReceive('redirectTo')->times(1); - test()->middleware->handle(test()->request, test()->next); + test()->middleware->handle(test()->request, test()->next); + }); }); -it('stops request if user scopes is missing', function () { - // Create RefreshToken for Character - createRefreshTokenWithScopes(['a', 'b']); +describe('passes middleware', function () { + it('lets request through if no scopes are required', function () { + createRefreshTokenWithScopes(['a', 'b']); - // create user corporation scope - createCorporationSsoScope(['a'], 'user'); + test()->actingAs(test()->test_user); - // to this point the middleware should pass no question asked + mockMiddleware(); - // Create secondary character - $secondary_character = Event::fakeFor(function () { - $character_user = CharacterUser::factory()->make(); - test()->test_user->character_users()->save($character_user); + // test()->middleware->shouldReceive('redirectTo')->once(); + test()->request->shouldReceive('forward')->times(1); - return CharacterInfo::find($character_user->character_id); + test()->middleware->handle(test()->request, test()->next); }); - // test that the test user owns both characters - expect(test()->test_user->refresh()->characters)->toHaveCount(2); + it('if required scopes are present', function () { + // 1. Create RefreshToken for Character + createRefreshTokenWithScopes(['a', 'b']); - // test that primary and secondary character has different corporations - test()->assertNotEquals(test()->test_character->corporation->corporation_id, $secondary_character->corporation->corporation_id); - - // create refresh_token for secondary character - Event::fakeFor(function () use ($secondary_character) { - $helper_token = RefreshToken::factory()->scopes(['c'])->make([ - 'character_id' => $secondary_character->character_id, + // 2. Create SsoScope (Corporation) + createCorporationSsoScope([ + 'character' => ['a'], + 'corporation' => [], ]); - $refresh_token = $secondary_character->refresh_token; - $refresh_token->token = $helper_token->token; - $refresh_token->save(); - }); - - // at this point secondary character has scope c and misses scope a thus should result in an error + // TestingTime - // TestingTime + test()->actingAs(test()->test_user); - test()->actingAs(test()->test_user); + mockMiddleware(); - mockMiddleware(); + // Expect 1 forward + test()->request->shouldReceive('forward')->times(1); - //Expect redirect - test()->middleware->shouldReceive('redirectTo')->times(1); - - test()->middleware->handle(test()->request, test()->next); -}); - -it('lets request through if user scopes is present', function () { - // Create RefreshToken for Character - createRefreshTokenWithScopes(['a', 'b']); - - // create user corporation scope - createCorporationSsoScope(['a'], 'user'); + test()->middleware->handle(test()->request, test()->next); + }); - // to this point the middleware should pass no question asked + it('if required corporation role scopes is present', function () { + // 1. Create RefreshToken for Character + createRefreshTokenWithScopes(['a', 'b', 'esi-characters.read_corporation_roles.v1']); - // Create secondary character - $secondary_character = Event::fakeFor(function () { - $character_user = CharacterUser::factory()->make(); - test()->test_user->character_users()->save($character_user); + // 2. Create SsoScope (Corporation) + createCorporationSsoScope([ + 'character' => [], + 'corporation' => ['b'], + ]); - return CharacterInfo::find($character_user->character_id); - }); + // TestingTime - // test that the test user owns both characters - expect(test()->test_user->refresh()->characters)->toHaveCount(2); + test()->actingAs(test()->test_user); - // test that primary and secondary character has different corporations - test()->assertNotEquals(test()->test_character->corporation->corporation_id, $secondary_character->corporation->corporation_id); + mockMiddleware(); - // update refresh_token for secondary character - Event::fakeFor(function () use ($secondary_character) { - $helper_token = RefreshToken::factory()->scopes(['a'])->make([ - 'character_id' => $secondary_character->character_id, - ]); + // Expect redirect + test()->request->shouldReceive('forward')->times(1); - $refresh_token = $secondary_character->refresh_token; - $refresh_token->token = $helper_token->token; - $refresh_token->save(); + test()->middleware->handle(test()->request, test()->next); }); - // at this point secondary character has scope a and scope a is required, thus should result in an forward - - // TestingTime + it('if required global scopes are present', function () { + // 1. Create RefreshToken for Character + createRefreshTokenWithScopes(['a', 'b']); - test()->actingAs(test()->test_user); + // 2. create global required sso scope + SsoScopes::updateOrCreate(['type' => 'global'], ['selected_scopes' => ['a']]); - mockMiddleware(); + // TestingTime - //Expect redirect - test()->request->shouldReceive('forward')->times(1); + test()->actingAs(test()->test_user); - test()->middleware->handle(test()->request, test()->next); -}); + mockMiddleware(); -it('lets request through if user application has no required scopes', function () { - // 1. Create RefreshToken for Character - createRefreshTokenWithScopes(['a', 'b']); + // Expect 1 forward + test()->request->shouldReceive('forward')->times(1); - // 2. create user application - test()->test_user->application()->create(['id' => \Illuminate\Support\Str::uuid(), 'corporation_id' => test()->test_character->corporation->corporation_id]); + test()->middleware->handle(test()->request, test()->next); + }); - // TestingTime + it('if user scopes is present', function () { + // Create RefreshToken for Character + createRefreshTokenWithScopes(['a', 'b']); - test()->actingAs(test()->test_user); + // create user corporation scope + createCorporationSsoScope(['a'], 'user'); - mockMiddleware(); + // to this point the middleware should pass no question asked - //Expect 1 forward - test()->request->shouldReceive('forward')->times(1); + // Create secondary character + $secondary_character = Event::fakeFor(function () { + $character_user = CharacterUser::factory()->make(); + test()->test_user->character_users()->save($character_user); - test()->middleware->handle(test()->request, test()->next); -}); + return CharacterInfo::find($character_user->character_id); + }); -it('lets request through if user application has required scopes', function () { - // 1. Create RefreshToken for Character - createRefreshTokenWithScopes(['a', 'b']); + // test that the test user owns both characters + expect(test()->test_user->refresh()->characters)->toHaveCount(2); - // 2. create user application - test()->test_user->application()->create(['id' => \Illuminate\Support\Str::uuid(), 'corporation_id' => test()->test_character->corporation->corporation_id]); + // test that primary and secondary character has different corporations + test()->assertNotEquals(test()->test_character->corporation->corporation_id, $secondary_character->corporation->corporation_id); - // 3. create required corp scopes - createCorporationSsoScope(['a']); + // update refresh_token for secondary character + Event::fakeFor(function () use ($secondary_character) { + $helper_token = RefreshToken::factory()->scopes(['a'])->make([ + 'character_id' => $secondary_character->character_id, + ]); - // TestingTime + $refresh_token = $secondary_character->refresh_token; + $refresh_token->token = $helper_token->token; + $refresh_token->save(); + }); - test()->actingAs(test()->test_user); + // at this point secondary character has scope a and scope a is required, thus should result in an forward - mockMiddleware(); + // TestingTime - //Expect 1 forward - test()->request->shouldReceive('forward')->times(1); + test()->actingAs(test()->test_user); - test()->middleware->handle(test()->request, test()->next); -}); + mockMiddleware(); -it('forwards request if user application has not required scopes', function () { - // 1. Create RefreshToken for Character - createRefreshTokenWithScopes(['a', 'b']); + // Expect redirect + test()->request->shouldReceive('forward')->times(1); - // 2. create user application - test()->test_user->application()->create(['id' => \Illuminate\Support\Str::uuid(), 'corporation_id' => test()->test_character->corporation->corporation_id]); + test()->middleware->handle(test()->request, test()->next); + }); - // 3. create required corp scopes - createCorporationSsoScope(['c']); + it('if user application has no required scopes', function () { + // 1. Create RefreshToken for Character + createRefreshTokenWithScopes(['a', 'b']); - // TestingTime + // 2. create user application + test()->test_user->application()->create(['id' => Str::uuid(), 'corporation_id' => test()->test_character->corporation->corporation_id]); - test()->actingAs(test()->test_user); + // TestingTime - mockMiddleware(); + test()->actingAs(test()->test_user); - //Expect redirect - test()->middleware->shouldReceive('redirectTo')->times(1); + mockMiddleware(); - test()->middleware->handle(test()->request, test()->next); -}); + // Expect 1 forward + test()->request->shouldReceive('forward')->times(1); -it('caches characters_with_missing_scopes', function () { - // 1. Create RefreshToken for Character - createRefreshTokenWithScopes(['a', 'b']); + test()->middleware->handle(test()->request, test()->next); + }); - // 2. create user application - test()->test_user->application()->create(['id' => \Illuminate\Support\Str::uuid(), 'corporation_id' => test()->test_character->corporation->corporation_id]); + it('lets request through if user application has required scopes', function () { + // 1. Create RefreshToken for Character + createRefreshTokenWithScopes(['a', 'b']); - // 3. create required corp scopes - createCorporationSsoScope(['c']); + // 2. create user application + test()->test_user->application()->create(['id' => Str::uuid(), 'corporation_id' => test()->test_character->corporation->corporation_id]); - // TestingTime + // 3. create required corp scopes + createCorporationSsoScope(['a']); - test()->actingAs(test()->test_user); + // TestingTime - mockMiddleware(); + test()->actingAs(test()->test_user); - //Expect redirect - test()->middleware->shouldReceive('redirectTo')->times(1); + mockMiddleware(); - Cache::shouldReceive('tags') - ->with(['characters_with_missing_scopes', test()->test_user->id]) - ->andReturnSelf(); - Cache::shouldReceive('get')->andReturnNull(); - Cache::shouldReceive('put') - ->once(); + // Expect 1 forward + test()->request->shouldReceive('forward')->times(1); - test()->middleware->handle(test()->request, test()->next); + test()->middleware->handle(test()->request, test()->next); + }); }); -it('it get caches characters_with_missing_scopes', function () { - // prepare - test()->actingAs(test()->test_user); - $user_id = test()->test_user->id; - $cache_key = "UserScopes:{$user_id}"; - - mockMiddleware(); - - Cache::shouldReceive('tags') - ->with(['characters_with_missing_scopes', $user_id]) - ->andReturnSelf(); +it('redirects when user is not compliant', function () { + $this->mock(IsUserCompliantService::class, function ($mock) { + $mock->shouldReceive('check')->with(Mockery::type(User::class))->andReturn(false); + $mock->shouldReceive('getMissingScopes')->with(Mockery::type(User::class))->andReturn(['scope1', 'scope2']); + }); - Cache::shouldReceive('get')->with($cache_key)->andReturn(collect(['foo' => 'bar'])); + $middleware = new CheckRequiredScopes(app(IsUserCompliantService::class)); + $request = Mockery::mock(Request::class); + $request->shouldReceive('user')->andReturn(new User); - Cache::shouldReceive('put')->never(); + $next = function ($req) { + return 'next'; + }; - //Expect redirect - test()->middleware->shouldReceive('redirectTo')->times(1); + $response = $middleware->handle($request, $next); - // test - test()->middleware->handle(test()->request, test()->next); + expect($response)->toBeInstanceOf(RedirectResponse::class) + ->and($response->getTargetUrl())->toBe('http://localhost'); }); // Helpers function mockRequest(): void { - test()->request = Mockery::mock(Request::class); + test()->request = mock(Request::class, function ($mock) { + $mock->shouldReceive('user')->andReturn(test()->test_user); + }); test()->next = function ($request) { $request->forward(); diff --git a/tests/Unit/Models/AclMemberTest.php b/tests/Unit/Models/AclMemberTest.php deleted file mode 100644 index 07d216d..0000000 --- a/tests/Unit/Models/AclMemberTest.php +++ /dev/null @@ -1,21 +0,0 @@ -role = Role::create(['name' => 'derp']); -}); - -it('has user relationship', function () { - test()->role->members()->create([ - 'user_id' => test()->test_user->id, - 'status' => 'member', - ]); - - $member = AclMember::where('user_id', test()->test_user->id) - ->get()->first(); - - expect($member->user::class)->toEqual(User::class); -}); diff --git a/tests/Unit/Models/AffiliationModelTest.php b/tests/Unit/Models/AffiliationModelTest.php new file mode 100644 index 0000000..f579c55 --- /dev/null +++ b/tests/Unit/Models/AffiliationModelTest.php @@ -0,0 +1,76 @@ +role = Role::create(['name' => 'derp']); +}); + +dataset('primary entities', [ + 'character' => fn () => [test()->test_character->character_id, CharacterInfo::class], + 'corporation' => fn () => [test()->test_character->corporation_id, CorporationInfo::class], + 'alliance' => fn () => [test()->test_character->alliance_id, AllianceInfo::class], +]); + +dataset('affiliation types', [ + 'allowed' => AffiliationType::ALLOWED->value, + 'inverted' => AffiliationType::INVERSE->value, + 'forbidden' => AffiliationType::FORBIDDEN->value, +]); + +it('has affiliated_ids attribute', function ($primary_entities, $affiliation_type) { + + [$entity_id, $entity_type] = $primary_entities(); + // Arrange + $affiliation = Affiliation::query()->create([ + 'role_id' => test()->role->id, + 'affiliatable_id' => $entity_id, + 'affiliatable_type' => $entity_type, + 'type' => $affiliation_type, + ]); + + // Assert + expect($affiliation->affiliated_ids)->toContain(test()->test_character->character_id) + ->when($entity_type === CorporationInfo::class, function ($collection) { + $collection->toContain(test()->test_character->corporation_id); + }) + ->when($entity_type === AllianceInfo::class, function ($collection) { + $collection->toContain(test()->test_character->alliance_id); + }); + +})->with('primary entities')->with('affiliation types'); + +describe('relationship tests', function () { + + beforeEach(function () { + Affiliation::query()->create([ + 'role_id' => test()->role->id, + 'affiliatable_id' => test()->test_character->character_id, + 'affiliatable_type' => CharacterInfo::class, + 'type' => AffiliationType::ALLOWED->value, + ]); + }); + + it('has role relationship', function () { + // Arrange + $affiliation = Affiliation::first(); + + // Assert + expect($affiliation->role)->toBeInstanceOf(Role::class); + }); + + it('has affiliatable relationship', function () { + // Arrange + $affiliation = Affiliation::first(); + + // Assert + expect($affiliation->affiliatable)->toBeInstanceOf(CharacterInfo::class); + }); +}); diff --git a/tests/Unit/Models/CharacterUserTest.php b/tests/Unit/Models/CharacterUserTest.php new file mode 100644 index 0000000..25bd5e0 --- /dev/null +++ b/tests/Unit/Models/CharacterUserTest.php @@ -0,0 +1,9 @@ +test_user; + + $character_user = $test_user->character_users->first(); + + expect($character_user->character->character_id)->toBe($this->test_character->character_id); +}); diff --git a/tests/Unit/Models/RoleMembershipTest.php b/tests/Unit/Models/RoleMembershipTest.php new file mode 100644 index 0000000..4c2250e --- /dev/null +++ b/tests/Unit/Models/RoleMembershipTest.php @@ -0,0 +1,24 @@ + 'test role']); + + RoleMembership::query()->create([ + 'role_id' => $role->id, + 'entity_id' => test()->test_character->corporation_id, + 'entity_type' => CorporationInfo::class, + ]); + + // Act + $role_membership = RoleMembership::first(); + + // Assert + expect($role_membership->role->name)->toEqual('test role'); + +}); diff --git a/tests/Unit/Models/RoleModelTest.php b/tests/Unit/Models/RoleModelTest.php index ad6af78..c1232d4 100644 --- a/tests/Unit/Models/RoleModelTest.php +++ b/tests/Unit/Models/RoleModelTest.php @@ -24,10 +24,11 @@ * SOFTWARE. */ +use Seatplus\Auth\Enums\RoleType; +use Seatplus\Auth\Models\AccessControl\RoleMembership; use Seatplus\Auth\Models\Permissions\Affiliation; use Seatplus\Auth\Models\Permissions\Permission; use Seatplus\Auth\Models\Permissions\Role; -use Seatplus\Eveapi\Models\Character\CharacterInfo; use Seatplus\Eveapi\Models\Corporation\CorporationInfo; beforeEach(function () { @@ -85,108 +86,16 @@ }); it('has default type attribute', function () { - expect(test()->role->fresh()->type)->toEqual('manual'); + expect(test()->role->fresh()->type)->toEqual(RoleType::MANUAL); }); -it('has acl affiliations', function () { - test()->role->acl_affiliations()->create([ - 'affiliatable_id' => test()->test_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - ]); - - expect(test()->role->acl_affiliations->first()->affiliatable::class)->toEqual(CharacterInfo::class); -}); - -it('has acl moderators', function () { - test()->role->acl_affiliations()->create([ - 'affiliatable_id' => test()->test_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'can_moderate' => true, - ]); - - expect(test()->role->acl_affiliations->isEmpty())->toBeTrue(); - - expect(test()->role->moderators->first()->affiliatable::class)->toEqual(CharacterInfo::class); -}); - -it('has acl members', function () { - test()->role->members()->create([ - 'user_id' => test()->test_user->id, - 'status' => 'member', - ]); - - expect(test()->role->members->isNotEmpty())->toBeTrue(); -}); - -test('one can add member', function () { - test()->role->activateMember(test()->test_user); - - expect(test()->role->members->isNotEmpty())->toBeTrue(); -}); - -test('one can pause member', function () { - test()->role->activateMember(test()->test_user); - - expect(test()->role->members->isNotEmpty())->toBeTrue(); - - test()->role->pauseMember(test()->test_user); - - expect(test()->role->refresh()->members->isEmpty())->toBeTrue(); -}); - -test('one can remove member', function () { - test()->role->activateMember(test()->test_user); - - expect(test()->role->members->isNotEmpty())->toBeTrue(); +it('has role memberships', function () { - test()->role->removeMember(test()->test_user); - - expect(test()->role->refresh()->members->isEmpty())->toBeTrue(); -}); - -it('throws error if unaffiliated user wants to join', function () { - $role = Role::create(['name' => 'test', 'type' => 'on-request']); - - test()->expectExceptionMessage('User is not allowed for this access control group'); - - $role->activateMember(test()->test_user); -}); - -it('throws error if one tries to join waitlist on invalid role', function () { - test()->expectExceptionMessage('Only on-request control groups do have a waitlist'); - - test()->role->joinWaitlist(test()->test_user); -}); - -it('throws error if unaffiliated user tries to join waitlist', function () { - $role = Role::create(['name' => 'test', 'type' => 'on-request']); - - test()->expectExceptionMessage('User is not allowed for this access control group'); - - $role->joinWaitlist(test()->test_user); -}); - -test('user can join waitlist', function () { - $role = Role::create(['name' => 'test', 'type' => 'on-request']); - - $role->acl_affiliations()->create([ - 'affiliatable_id' => test()->test_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - ]); - - $role->joinWaitlist(test()->test_user); - - expect($role->refresh()->acl_members()->whereStatus('waitlist')->first()->user_id)->toEqual(test()->test_user->id); -}); - -test('one can get moderator ids', function () { - $role = Role::create(['name' => 'test', 'type' => 'on-request']); - - $role->acl_affiliations()->create([ - 'affiliatable_id' => test()->test_character->character_id, - 'affiliatable_type' => CharacterInfo::class, - 'can_moderate' => true, + RoleMembership::query()->create([ + 'role_id' => test()->role->id, + 'entity_id' => test()->test_character->corporation_id, + 'entity_type' => CorporationInfo::class, ]); - expect($role->refresh()->isModerator(test()->test_user))->toBeTrue(); + expect(test()->role->role_memberships->first()->entity)->toBeInstanceOf(CorporationInfo::class); }); diff --git a/tests/Unit/Observers/ApplicationObserverTest.php b/tests/Unit/Observers/ApplicationObserverTest.php index a0cfbd9..6526821 100644 --- a/tests/Unit/Observers/ApplicationObserverTest.php +++ b/tests/Unit/Observers/ApplicationObserverTest.php @@ -5,8 +5,11 @@ use Seatplus\Eveapi\Models\Character\CharacterInfo; it('flushes cache after creation', function (User|CharacterInfo $entity) { - Cache::shouldReceive('tags')->with(['characters_with_missing_scopes', test()->test_user->id])->andReturnSelf(); - Cache::shouldReceive('flush')->once(); + $user_id = test()->test_user->id; + + Cache::shouldReceive('forget') + ->once() + ->with("user_permissions_{$user_id}"); $entity->application()->create([ 'corporation_id' => test()->test_character->corporation->corporation_id, diff --git a/tests/Unit/Observers/CharacterAffiliationObserverTest.php b/tests/Unit/Observers/CharacterAffiliationObserverTest.php index 2eebaba..364e1de 100644 --- a/tests/Unit/Observers/CharacterAffiliationObserverTest.php +++ b/tests/Unit/Observers/CharacterAffiliationObserverTest.php @@ -1,11 +1,15 @@ test_user) ->active->toBeTrue() ->characters->toHaveCount(1); - $character_affiliation = \Seatplus\Eveapi\Models\Character\CharacterAffiliation::firstWhere('character_id', $this->test_user->characters->first()->character_id); + $character_affiliation = CharacterAffiliation::firstWhere('character_id', $this->test_user->characters->first()->character_id); // doomheim the character $character_affiliation->corporation_id = 1000001; @@ -20,7 +24,7 @@ $user->main_character_id = test()->test_character->character_id; $user->save(); - $character_user = \Seatplus\Auth\Models\CharacterUser::factory() + $character_user = CharacterUser::factory() ->create(['user_id' => test()->test_user->id]); expect(test()->test_user->refresh()) @@ -30,7 +34,7 @@ ->main_character_id->not()->toBe($character_user->character_id); // doomheim the character - $character_affiliation = \Seatplus\Eveapi\Models\Character\CharacterAffiliation::firstWhere('character_id', $character_user->character_id); + $character_affiliation = CharacterAffiliation::firstWhere('character_id', $character_user->character_id); $character_affiliation->corporation_id = 1000001; $character_affiliation->save(); @@ -46,7 +50,7 @@ $user->main_character_id = test()->test_character->character_id; $user->save(); - $character_user = \Seatplus\Auth\Models\CharacterUser::factory() + $character_user = CharacterUser::factory() ->create(['user_id' => test()->test_user->id]); expect(test()->test_user->refresh()) @@ -55,14 +59,14 @@ ->main_character_id->toBeInt()->toBe(test()->test_character->character_id) ->main_character_id->not()->toBe($character_user->character_id); - expect(\Seatplus\Auth\Models\User::all())->toHaveCount(1); + expect(User::all())->toHaveCount(1); // doomheim the character - $character_affiliation = \Seatplus\Eveapi\Models\Character\CharacterAffiliation::firstWhere('character_id', test()->test_character->character_id); + $character_affiliation = CharacterAffiliation::firstWhere('character_id', test()->test_character->character_id); $character_affiliation->corporation_id = 1000001; $character_affiliation->save(); - expect(\Seatplus\Auth\Models\User::all())->toHaveCount(2); + expect(User::all())->toHaveCount(2); // original user should still be active expect($user->refresh()) @@ -71,7 +75,7 @@ ->main_character_id->toBeInt()->toBe($character_user->character_id) ->characters->toHaveCount(1); - expect(\Seatplus\Auth\Models\User::firstWhere('main_character_id', '<>', $character_user->character_id)) + expect(User::firstWhere('main_character_id', '<>', $character_user->character_id)) ->active->toBeFalsy() ->main_character_id->toBeInt()->toBe(test()->test_character->character_id) ->main_character_id->toBeInt()->not()->toBe($character_user->character_id) diff --git a/tests/Unit/Observers/SsoScopeObserverTest.php b/tests/Unit/Observers/SsoScopeObserverTest.php index 97f3b5e..09db1ae 100644 --- a/tests/Unit/Observers/SsoScopeObserverTest.php +++ b/tests/Unit/Observers/SsoScopeObserverTest.php @@ -5,8 +5,12 @@ use Seatplus\Eveapi\Models\SsoScopes; it('flushes cache after creation', function () { - Cache::shouldReceive('tags')->with(['characters_with_missing_scopes'])->andReturnSelf(); - Cache::shouldReceive('flush')->once(); + + $user_id = test()->test_user->id; + + Cache::shouldReceive('forget') + ->once() + ->with("user_permissions_{$user_id}"); SsoScopes::factory()->create(); }); @@ -14,8 +18,11 @@ it('flushes cache after updated', function () { Event::fakeFor(fn () => SsoScopes::factory()->create()); - Cache::shouldReceive('tags')->with(['characters_with_missing_scopes'])->andReturnSelf(); - Cache::shouldReceive('flush')->once(); + $user_id = test()->test_user->id; + + Cache::shouldReceive('forget') + ->once() + ->with("user_permissions_{$user_id}"); $ssoScopes = SsoScopes::first(); $ssoScopes->morphable_id = faker()->randomNumber(); @@ -25,8 +32,11 @@ it('flushes cache after deleted', function () { Event::fakeFor(fn () => SsoScopes::factory()->create()); - Cache::shouldReceive('tags')->with(['characters_with_missing_scopes'])->andReturnSelf(); - Cache::shouldReceive('flush')->once(); + $user_id = test()->test_user->id; + + Cache::shouldReceive('forget') + ->once() + ->with("user_permissions_{$user_id}"); $ssoScopes = SsoScopes::first(); $ssoScopes->delete(); diff --git a/tests/Unit/Requests/RoleRequestTest.php b/tests/Unit/Requests/RoleRequestTest.php new file mode 100644 index 0000000..b4d8742 --- /dev/null +++ b/tests/Unit/Requests/RoleRequestTest.php @@ -0,0 +1,75 @@ +rules()); + + return $validator->passes(); +} + +dataset('role request', [ + fn () => [ + 'role_id' => 1, + 'affiliated' => [ + [ + 'entity_id' => 1, + 'entity_type' => 'corporation', + 'affiliation_type' => AffiliationType::cases()[fake()->randomElement([0, 1, 2])]->value, + ], + ], + 'assigned' => [ + [ + 'entity_id' => 1, + 'entity_type' => fake()->randomElement(['corporation', 'alliance']), + 'can_moderate' => fake()->boolean(), + ], + ], + ], +]); + +it('can validate role request', function ($data) { + + expect(validate($data))->toBeTrue(); +})->with('role request'); + +it('fails when role_id is missing', function ($data) { + unset($data['role_id']); + + expect(validate($data))->toBeFalse(); +})->with('role request'); + +it('does not fail when affiliated is missing', function ($data) { + unset($data['affiliated']); + + expect(validate($data))->toBeTrue(); +})->with('role request'); + +it('fails when affiliated.*.entity_id is missing', function ($data) { + unset($data['affiliated'][0]['entity_id']); + + expect(validate($data))->toBeFalse(); +})->with('role request'); + +it('fails when affiliation_type is not in ENUM', function ($data) { + + $data['affiliated'][0]['affiliation_type'] = 'not in enum'; + + expect(validate($data))->toBeFalse(); +})->with('role request'); + +it('fails when assigned.*.entity_type is not corporation or alliance', function ($data) { + + $data['assigned'][0]['entity_type'] = 'not corporation or alliance'; + + expect(validate($data))->toBeFalse(); +})->with('role request'); + +it('validates when assigned is missing', function ($data) { + unset($data['assigned']); + + expect(validate($data))->toBeTrue(); +})->with('role request'); diff --git a/tests/Unit/Services/AuthenticationServiceTest.php b/tests/Unit/Services/AuthenticationServiceTest.php new file mode 100644 index 0000000..9abb303 --- /dev/null +++ b/tests/Unit/Services/AuthenticationServiceTest.php @@ -0,0 +1,76 @@ +authMock = mock(Guard::class); + $this->sessionMock = mock(Session::class); + $this->authenticationService = new AuthenticationService($this->authMock, $this->sessionMock); +}); + +afterEach(function () { + Mockery::close(); +}); + +it('logs in user successfully', function () { + $user = mock(User::class); + $this->authMock->shouldReceive('login')->with($user, true)->andReturnNull(); + + $result = $this->authenticationService->loginUser($user); + + expect($result)->toBeTrue(); +}); + +it('fails to log in user and reports exception', function () { + $user = mock(User::class); + $this->authMock->shouldReceive('login')->with($user, true)->andThrow(new Exception); + + $result = $this->authenticationService->loginUser($user); + + expect($result)->toBeFalse(); +}); + +it('sets intended URL', function () { + $url = 'http://example.com'; + Redirect::shouldReceive('setIntendedUrl')->with($url)->once(); + + $this->authenticationService->setIntendedUrl($url); +}); + +it('flashes a message to the session', function () { + $type = 'error'; + $message = 'An error occurred'; + $this->sessionMock->shouldReceive('flash')->with($type, $message)->once(); + + $this->authenticationService->flashMessage($type, $message); +}); + +it('retrieves and removes a session value', function () { + $key = 'step_up'; + $value = 'some_value'; + $this->sessionMock->shouldReceive('pull')->with($key)->andReturn($value); + + $result = $this->authenticationService->getSessionValue($key); + + expect($result)->toBe($value); +}); + +it('checks if user is authenticated', function () { + $this->authMock->shouldReceive('check')->andReturn(true); + + $result = $this->authenticationService->isUserAuthenticated(); + + expect($result)->toBeTrue(); +}); + +it('retrieves the previous URL from the session', function () { + $previousUrl = 'http://example.com/previous'; + $this->sessionMock->shouldReceive('previousUrl')->andReturn($previousUrl); + + $result = $this->authenticationService->getPreviousUrl(); + + expect($result)->toBe($previousUrl); +}); diff --git a/tests/Unit/Services/ConvertClassToPermissionStringServiceTest.php b/tests/Unit/Services/ConvertClassToPermissionStringServiceTest.php deleted file mode 100644 index fdef543..0000000 --- a/tests/Unit/Services/ConvertClassToPermissionStringServiceTest.php +++ /dev/null @@ -1,8 +0,0 @@ -toBe('assets'); -}); diff --git a/tests/Unit/Services/GetAffiliatedIdsServiceTest.php b/tests/Unit/Services/GetAffiliatedIdsServiceTest.php deleted file mode 100644 index 73efb46..0000000 --- a/tests/Unit/Services/GetAffiliatedIdsServiceTest.php +++ /dev/null @@ -1,122 +0,0 @@ -role = Role::create(['name' => faker()->name]); - test()->permission = Permission::create(['name' => faker()->company]); - - test()->role->givePermissionTo(test()->permission); - test()->role->activateMember(test()->test_user); - - test()->affiliationsDto = new \Seatplus\Auth\Services\Dtos\AffiliationsDto( - user: test()->test_user, - permissions: [test()->permission->name] - ); - - \Illuminate\Support\Facades\Queue::fake(); - - expect(test()->test_character->corporation->alliance_id)->not()->toBeNull(); - // {character_id: 1, corporation_id: A, alliance_id: B} - - test()->secondary_character = CharacterInfo::factory()->create(); - - CharacterAffiliation::query() - ->updateOrCreate([ - 'character_id' => test()->secondary_character->character_id, - ], [ - 'corporation_id' => test()->test_character->corporation->corporation_id, - 'alliance_id' => test()->test_character->corporation->alliance_id, - ]); - - // {character_id: 2, corporation_id: A, alliance_id: B} - - test()->tertiary_character = CharacterInfo::factory()->create(); - - CharacterAffiliation::query() - ->updateOrCreate([ - 'character_id' => test()->tertiary_character->character_id, - ], [ - 'alliance_id' => test()->test_character->corporation->alliance_id, - ]); - - // {character_id: 3, corporation_id: C, alliance_id: B} - - // delete all other character_affiliations - CharacterAffiliation::query() - ->whereNotIn('character_id', [ - test()->test_character->character_id, - test()->secondary_character->character_id, - test()->tertiary_character->character_id, - ])->delete(); - - // {character_id: 1, corporation_id: A, alliance_id: B} - // {character_id: 2, corporation_id: A, alliance_id: B} - // {character_id: 3, corporation_id: C, alliance_id: B} - expect(test()->tertiary_character->corporation->corporation_id) - ->not()->toBe(test()->secondary_character->corporation->corporation_id) - ->and(test()->tertiary_character->alliance->alliance_id) - ->toBe(test()->test_character->alliance->alliance_id); -}); - -it('returns inverse affiliated_ids via GetAffiliatedIdsService', function () { - test()->createAffiliation( - test()->role, - test()->secondary_character->character_id, - CharacterInfo::class, - 'inverse' - ); - - $affiliated_ids = GetAffiliatedIdsService::make(test()->affiliationsDto) - ->getQuery() - ->pluck('affiliated_id'); - - // {character_id: 1, corporation_id: A, alliance_id: B} - // {character_id: 2, corporation_id: A, alliance_id: B} - // {character_id: 3, corporation_id: C, alliance_id: B} - // result: [1,3] - expect($affiliated_ids) - ->toHaveCount(2) - ->toBeCollection() - ->contains(test()->test_character->character_id)->toBeTrue() - ->contains(test()->secondary_character->character_id)->toBeFalse() - ->contains(test()->tertiary_character->character_id)->toBeTrue(); -}); - -it('returns allowed ids from affiliated corporation but not the forbidden character_id', function () { - test()->createAffiliation( - test()->role, - test()->secondary_character->corporation->corporation_id, - CorporationInfo::class, - 'allowed' - ); - - test()->createAffiliation( - test()->role, - test()->secondary_character->character_id, - CharacterInfo::class, - 'forbidden' - ); - - $allowed_ids = GetAffiliatedIdsService::make(test()->affiliationsDto) - ->getQuery() - ->pluck('affiliated_id'); - - // {character_id: 1, corporation_id: A, alliance_id: B} - // {character_id: 2, corporation_id: A, alliance_id: B} - // {character_id: 3, corporation_id: C, alliance_id: B} - // result: [1,A] - expect($allowed_ids) - ->toHaveCount(2) - ->toBeCollection() - ->contains(test()->test_character->character_id)->toBeTrue() - ->contains(test()->secondary_character->character_id)->toBeFalse() - ->contains(test()->secondary_character->corporation->corporation_id)->toBeTrue() - ->contains(test()->tertiary_character->character_id)->toBeFalse() - ->contains(test()->tertiary_character->corporation->corporation_id)->toBeFalse(); -}); diff --git a/tests/Unit/Services/GetAllowedAffiliatedIdsServiceTest.php b/tests/Unit/Services/GetAllowedAffiliatedIdsServiceTest.php deleted file mode 100644 index 83e228c..0000000 --- a/tests/Unit/Services/GetAllowedAffiliatedIdsServiceTest.php +++ /dev/null @@ -1,131 +0,0 @@ -role = Role::create(['name' => faker()->name]); - test()->permission = Permission::create(['name' => faker()->company]); - - test()->role->givePermissionTo(test()->permission); - test()->role->activateMember(test()->test_user); - - test()->affiliationsDto = new \Seatplus\Auth\Services\Dtos\AffiliationsDto( - user: test()->test_user, - permissions: [test()->permission->name] - ); - - \Illuminate\Support\Facades\Queue::fake(); - - expect(test()->test_character->corporation->alliance_id)->not()->toBeNull(); - // {character_id: 1, corporation_id: A, alliance_id: B} - - test()->secondary_character = CharacterInfo::factory()->create(); - - CharacterAffiliation::query() - ->updateOrCreate([ - 'character_id' => test()->secondary_character->character_id, - ], [ - 'corporation_id' => test()->test_character->corporation->corporation_id, - 'alliance_id' => test()->test_character->corporation->alliance_id, - ]); - - // {character_id: 2, corporation_id: A, alliance_id: B} - - test()->tertiary_character = CharacterInfo::factory()->create(); - - CharacterAffiliation::query() - ->updateOrCreate([ - 'character_id' => test()->tertiary_character->character_id, - ], [ - 'alliance_id' => test()->test_character->corporation->alliance_id, - ]); - - // {character_id: 3, corporation_id: C, alliance_id: B} - - // {character_id: 1, corporation_id: A, alliance_id: B} - // {character_id: 2, corporation_id: A, alliance_id: B} - // {character_id: 3, corporation_id: C, alliance_id: B} - expect(test()->tertiary_character->corporation->corporation_id) - ->not()->toBe(test()->secondary_character->corporation->corporation_id); - - expect(test()->tertiary_character->alliance->alliance_id) - ->toBe(test()->test_character->alliance->alliance_id); -}); - -it('returns allowed ids from affiliated character', function () { - test()->createAffiliation( - test()->role, - test()->secondary_character->character_id, - CharacterInfo::class, - 'allowed' - ); - - $allowed_ids = GetAllowedAffiliatedIdsService::make(test()->affiliationsDto) - ->getQuery(); - - // {character_id: 1, corporation_id: A, alliance_id: B} - // {character_id: 2, corporation_id: A, alliance_id: B} - // {character_id: 3, corporation_id: C, alliance_id: B} - // result: [2] - expect($allowed_ids->pluck('affiliated_id')) - ->toHaveCount(1) - ->toBeCollection() - ->contains(test()->secondary_character->character_id)->toBeTrue(); -}); - -it('returns allowed ids from affiliated corporation', function () { - test()->createAffiliation( - test()->role, - test()->secondary_character->corporation->corporation_id, - CorporationInfo::class, - 'allowed' - ); - - $allowed_ids = GetAllowedAffiliatedIdsService::make(test()->affiliationsDto) - ->getQuery() - ->pluck('affiliated_id'); - - // {character_id: 1, corporation_id: A, alliance_id: B} - // {character_id: 2, corporation_id: A, alliance_id: B} - // {character_id: 3, corporation_id: C, alliance_id: B} - // result: [1, 2, A] - expect($allowed_ids) - ->toHaveCount(3) - ->toBeCollection() - ->contains(test()->test_character->character_id)->toBeTrue() - ->contains(test()->secondary_character->character_id)->toBeTrue() - ->contains(test()->secondary_character->corporation->corporation_id)->toBeTrue() - ->contains(test()->tertiary_character->character_id)->toBeFalse(); -}); - -it('returns allowed ids from affiliated alliance', function () { - test()->createAffiliation( - test()->role, - test()->secondary_character->corporation->alliance_id, - AllianceInfo::class, - 'allowed' - ); - - $allowed_ids = GetAllowedAffiliatedIdsService::make(test()->affiliationsDto) - ->getQuery() - ->pluck('affiliated_id'); - - // {character_id: 1, corporation_id: A, alliance_id: B} - // {character_id: 2, corporation_id: A, alliance_id: B} - // {character_id: 3, corporation_id: C, alliance_id: B} - // result: [1, 2, 3, A, C, B] - expect($allowed_ids) - ->toBeCollection() - ->contains(test()->test_character->character_id)->toBeTrue() - ->contains(test()->secondary_character->character_id)->toBeTrue() - ->contains(test()->tertiary_character->character_id)->toBeTrue() - ->contains(test()->test_character->corporation->corporation_id)->toBeTrue() - ->contains(test()->secondary_character->corporation->corporation_id)->toBeTrue() - ->contains(test()->tertiary_character->corporation->corporation_id)->toBeTrue(); -}); diff --git a/tests/Unit/Services/GetForbiddenAffiliatedIdsServiceTest.php b/tests/Unit/Services/GetForbiddenAffiliatedIdsServiceTest.php deleted file mode 100644 index ea4f744..0000000 --- a/tests/Unit/Services/GetForbiddenAffiliatedIdsServiceTest.php +++ /dev/null @@ -1,169 +0,0 @@ -role = Role::create(['name' => faker()->name]); - test()->permission = Permission::create(['name' => faker()->company]); - - test()->role->givePermissionTo(test()->permission); - test()->role->activateMember(test()->test_user); - - test()->affiliationsDto = new \Seatplus\Auth\Services\Dtos\AffiliationsDto( - user: test()->test_user, - permissions: [test()->permission->name] - ); - - \Illuminate\Support\Facades\Queue::fake(); - - expect(test()->test_character->corporation->alliance_id)->not()->toBeNull(); - // {character_id: 1, corporation_id: A, alliance_id: B} - - test()->secondary_character = CharacterInfo::factory()->create(); - - CharacterAffiliation::query() - ->updateOrCreate([ - 'character_id' => test()->secondary_character->character_id, - ], [ - 'corporation_id' => test()->test_character->corporation->corporation_id, - 'alliance_id' => test()->test_character->corporation->alliance_id, - ]); - - // {character_id: 2, corporation_id: A, alliance_id: B} - - test()->tertiary_character = CharacterInfo::factory()->create(); - - CharacterAffiliation::query() - ->updateOrCreate([ - 'character_id' => test()->tertiary_character->character_id, - ], [ - 'alliance_id' => test()->test_character->corporation->alliance_id, - ]); - - // {character_id: 3, corporation_id: C, alliance_id: B} - - // {character_id: 1, corporation_id: A, alliance_id: B} - // {character_id: 2, corporation_id: A, alliance_id: B} - // {character_id: 3, corporation_id: C, alliance_id: B} - expect(test()->tertiary_character->corporation->corporation_id) - ->not()->toBe(test()->secondary_character->corporation->corporation_id) - ->and(test()->tertiary_character->alliance->alliance_id) - ->toBe(test()->test_character->alliance->alliance_id); -}); - -it('returns forbidden ids from forbidden character', function () { - test()->createAffiliation( - test()->role, - test()->secondary_character->character_id, - CharacterInfo::class, - 'forbidden' - ); - - $forbidden_ids = GetForbiddenAffiliatedIdService::make(test()->affiliationsDto) - ->getQuery() - ->pluck('forbidden_id'); - - // {character_id: 1, corporation_id: A, alliance_id: B} - // {character_id: 2, corporation_id: A, alliance_id: B} - // {character_id: 3, corporation_id: C, alliance_id: B} - // result: [2] - expect($forbidden_ids) - ->toHaveCount(1) - ->toBeCollection() - ->contains(test()->secondary_character->character_id)->toBeTrue(); -}); - -it('returns forbidden ids from forbidden corporation but not owned character_id', function () { - test()->createAffiliation( - test()->role, - test()->secondary_character->corporation->corporation_id, - CorporationInfo::class, - 'forbidden' - ); - - $forbidden_ids = GetForbiddenAffiliatedIdService::make(test()->affiliationsDto) - ->getQuery() - ->pluck('forbidden_id'); - - // {character_id: 1, corporation_id: A, alliance_id: B} - // {character_id: 2, corporation_id: A, alliance_id: B} - // {character_id: 3, corporation_id: C, alliance_id: B} - // result: [2, A] - expect($forbidden_ids) - ->toHaveCount(2) - ->toBeCollection() - ->contains(test()->test_character->character_id)->toBeFalse() - ->contains(test()->secondary_character->character_id)->toBeTrue() - ->contains(test()->secondary_character->corporation->corporation_id)->toBeTrue() - ->contains(test()->tertiary_character->character_id)->toBeFalse(); -}); - -// TODO own corporation -it('returns forbidden ids from forbidden alliance', function () { - test()->createAffiliation( - test()->role, - test()->secondary_character->corporation->alliance_id, - AllianceInfo::class, - 'forbidden' - ); - - $forbidden_ids = GetForbiddenAffiliatedIdService::make(test()->affiliationsDto) - ->getQuery() - ->pluck('forbidden_id'); - - // {character_id: 1, corporation_id: A, alliance_id: B} - // {character_id: 2, corporation_id: A, alliance_id: B} - // {character_id: 3, corporation_id: C, alliance_id: B} - // result: [A,B,2,3,C] - expect($forbidden_ids) - ->toBeCollection() - ->toHaveCount(5) - ->contains(test()->test_character->character_id)->toBeFalse() - ->contains(test()->secondary_character->character_id)->toBeTrue() - ->contains(test()->tertiary_character->character_id)->toBeTrue() - ->contains(test()->test_character->corporation->corporation_id)->toBeTrue() - ->contains(test()->secondary_character->corporation->corporation_id)->toBeTrue() - ->contains(test()->tertiary_character->corporation->corporation_id)->toBeTrue() - ->contains(test()->secondary_character->corporation->alliance_id)->toBeTrue(); -}); - -it('returns forbidden ids from forbidden alliance but not owned corporation via role', function () { - test()->createAffiliation( - test()->role, - test()->secondary_character->corporation->alliance_id, - AllianceInfo::class, - 'forbidden' - ); - - test()->affiliationsDto->corporation_roles = ['Director']; - - \Seatplus\Eveapi\Models\Character\CharacterRole::factory()->create([ - 'character_id' => test()->test_character->character_id, - 'roles' => test()->affiliationsDto->corporation_roles, - ]); - - $forbidden_ids = GetForbiddenAffiliatedIdService::make(test()->affiliationsDto) - ->getQuery() - ->pluck('forbidden_id'); - - // {character_id: 1, corporation_id: A, alliance_id: B} - // {character_id: 2, corporation_id: A, alliance_id: B} - // {character_id: 3, corporation_id: C, alliance_id: B} - // result: [B,2,3,C] - expect($forbidden_ids) - ->toBeCollection() - ->toHaveCount(4) - ->contains(test()->test_character->character_id)->toBeFalse() - ->contains(test()->secondary_character->character_id)->toBeTrue() - ->contains(test()->tertiary_character->character_id)->toBeTrue() - ->contains(test()->test_character->corporation->corporation_id)->toBeFalse() - ->contains(test()->secondary_character->corporation->corporation_id)->toBeFalse() - ->contains(test()->tertiary_character->corporation->corporation_id)->toBeTrue() - ->contains(test()->secondary_character->corporation->alliance_id)->toBeTrue(); -}); diff --git a/tests/Unit/Services/GetInvertedAffiliatedIdsServiceTest.php b/tests/Unit/Services/GetInvertedAffiliatedIdsServiceTest.php deleted file mode 100644 index a2224c1..0000000 --- a/tests/Unit/Services/GetInvertedAffiliatedIdsServiceTest.php +++ /dev/null @@ -1,152 +0,0 @@ -role = Role::create(['name' => faker()->name]); - test()->permission = Permission::create(['name' => faker()->company]); - - test()->role->givePermissionTo(test()->permission); - test()->role->activateMember(test()->test_user); - - test()->affiliationsDto = new \Seatplus\Auth\Services\Dtos\AffiliationsDto( - user: test()->test_user, - permissions: [test()->permission->name] - ); - - \Illuminate\Support\Facades\Queue::fake(); - - expect(test()->test_character->corporation->alliance_id)->not()->toBeNull(); - // {character_id: 1, corporation_id: A, alliance_id: B} - - test()->secondary_character = CharacterInfo::factory()->create(); - - CharacterAffiliation::query() - ->updateOrCreate([ - 'character_id' => test()->secondary_character->character_id, - ], [ - 'corporation_id' => test()->test_character->corporation->corporation_id, - 'alliance_id' => test()->test_character->corporation->alliance_id, - ]); - - // {character_id: 2, corporation_id: A, alliance_id: B} - - test()->tertiary_character = CharacterInfo::factory()->create(); - - CharacterAffiliation::query() - ->updateOrCreate([ - 'character_id' => test()->tertiary_character->character_id, - ], [ - 'alliance_id' => test()->test_character->corporation->alliance_id, - ]); - - // {character_id: 3, corporation_id: C, alliance_id: B} - - // delete all other character_affiliations - CharacterAffiliation::query() - ->whereNotIn('character_id', [ - test()->test_character->character_id, - test()->secondary_character->character_id, - test()->tertiary_character->character_id, - ])->delete(); - - // {character_id: 1, corporation_id: A, alliance_id: B} - // {character_id: 2, corporation_id: A, alliance_id: B} - // {character_id: 3, corporation_id: C, alliance_id: B} - expect(test()->tertiary_character->corporation->corporation_id) - ->not()->toBe(test()->secondary_character->corporation->corporation_id) - ->and(test()->tertiary_character->alliance->alliance_id) - ->toBe(test()->test_character->alliance->alliance_id); -}); - -it('returns inversed ids from affiliated character', function () { - test()->createAffiliation( - test()->role, - test()->secondary_character->character_id, - CharacterInfo::class, - 'inverse' - ); - - $allowed_ids = GetInvertedAffiliatedIdsService::make(test()->affiliationsDto) - ->getQuery() - ->pluck('affiliated_id'); - - // {character_id: 1, corporation_id: A, alliance_id: B} - // {character_id: 2, corporation_id: A, alliance_id: B} - // {character_id: 3, corporation_id: C, alliance_id: B} - // result: [1,3] - expect($allowed_ids) - ->toHaveCount(2) - ->toBeCollection() - ->contains(test()->test_character->character_id)->toBeTrue() - ->contains(test()->secondary_character->character_id)->toBeFalse() - ->contains(test()->tertiary_character->character_id)->toBeTrue(); -}); - -it('returns inverted ids from affiliated corporation', function () { - test()->createAffiliation( - test()->role, - test()->secondary_character->corporation->corporation_id, - CorporationInfo::class, - 'inverse' - ); - - $allowed_ids = GetInvertedAffiliatedIdsService::make(test()->affiliationsDto) - ->getQuery() - ->pluck('affiliated_id'); - - // {character_id: 1, corporation_id: A, alliance_id: B} - // {character_id: 2, corporation_id: A, alliance_id: B} - // {character_id: 3, corporation_id: C, alliance_id: B} - // result: [3,C] - expect($allowed_ids) - ->toHaveCount(2) - ->toBeCollection() - ->contains(test()->test_character->character_id)->toBeFalse() - ->contains(test()->secondary_character->character_id)->toBeFalse() - ->contains(test()->secondary_character->corporation->corporation_id)->toBeFalse() - ->contains(test()->tertiary_character->character_id)->toBeTrue() - ->contains(test()->tertiary_character->corporation->corporation_id)->toBeTrue(); -}); - -it('returns inverted ids from affiliated alliance', function () { - test()->createAffiliation( - test()->role, - test()->secondary_character->corporation->alliance_id, - AllianceInfo::class, - 'inverse' - ); - - $random_affiliation = CharacterAffiliation::factory()->withAlliance()->create(); - - // {character_id: 4, corporation_id: X, alliance_id: Y} - expect($random_affiliation->alliance_id)->not()->toBe(test()->secondary_character->corporation->alliance_id); - - $allowed_ids = GetInvertedAffiliatedIdsService::make(test()->affiliationsDto) - ->getQuery() - - ->pluck('affiliated_id'); - - // {character_id: 1, corporation_id: A, alliance_id: B} - // {character_id: 2, corporation_id: A, alliance_id: B} - // {character_id: 3, corporation_id: C, alliance_id: B} - // {character_id: 4, corporation_id: X, alliance_id: Y} - // result: [4, X, Y] - expect($allowed_ids) - ->toBeCollection() - ->toHaveCount(3) - ->contains(test()->test_character->character_id)->toBeFalse() - ->contains(test()->secondary_character->character_id)->toBeFalse() - ->contains(test()->tertiary_character->character_id)->toBeFalse() - ->contains(test()->test_character->corporation->corporation_id)->toBeFalse() - ->contains(test()->secondary_character->corporation->corporation_id)->toBeFalse() - ->contains(test()->tertiary_character->corporation->corporation_id)->toBeFalse() - ->contains(test()->tertiary_character->alliance->alliance_id)->toBeFalse() - ->contains($random_affiliation->alliance_id)->toBeTrue(); -}); diff --git a/tests/Unit/Services/GetOwnedAffiliatedIdsServiceTest.php b/tests/Unit/Services/GetOwnedAffiliatedIdsServiceTest.php deleted file mode 100644 index 5cc89b2..0000000 --- a/tests/Unit/Services/GetOwnedAffiliatedIdsServiceTest.php +++ /dev/null @@ -1,63 +0,0 @@ -role = Role::create(['name' => faker()->name]); - test()->permission = Permission::create(['name' => faker()->company]); - - test()->role->givePermissionTo(test()->permission); - - test()->affiliationsDto = new \Seatplus\Auth\Services\Dtos\AffiliationsDto( - user: test()->test_user, - permissions: [test()->permission->name], - corporation_roles: ['Director'] - ); - - \Illuminate\Support\Facades\Queue::fake(); - - expect(test()->test_character->corporation->alliance_id)->not()->toBeNull(); - // {character_id: 1, corporation_id: A, alliance_id: B} -}); - -it('returns own character ids', function () { - test()->createAffiliation( - test()->role, - test()->test_character->character_id, - CharacterInfo::class, - 'inverse' - ); - - $allowed_ids = GetOwnedAffiliatedIdsService::make(test()->affiliationsDto) - ->getQuery() - ->pluck('affiliated_id'); - - // {character_id: 1, corporation_id: A, alliance_id: B} - // result: [1] - expect($allowed_ids) - ->toHaveCount(1) - ->toBeCollection() - ->contains(test()->test_character->character_id)->toBeTrue(); -}); - -it('returns owned character_id and corporation_id if corp role exists', function () { - \Seatplus\Eveapi\Models\Character\CharacterRole::factory()->create([ - 'character_id' => test()->test_character->character_id, - 'roles' => test()->affiliationsDto->corporation_roles, - ]); - - $allowed_ids = GetOwnedAffiliatedIdsService::make(test()->affiliationsDto) - ->getQuery() - ->pluck('affiliated_id'); - - // {character_id: 1, corporation_id: A, alliance_id: B} - // result: [3, A] - expect($allowed_ids) - ->toHaveCount(2) - ->toBeCollection() - ->contains(test()->test_character->character_id)->toBeTrue() - ->contains(test()->test_character->corporation->corporation_id)->toBeTrue(); -}); diff --git a/tests/Unit/Services/Permissions/UserPermissionServiceTest.php b/tests/Unit/Services/Permissions/UserPermissionServiceTest.php new file mode 100644 index 0000000..864c8d2 --- /dev/null +++ b/tests/Unit/Services/Permissions/UserPermissionServiceTest.php @@ -0,0 +1,104 @@ +test_user; + + // Act + $user_permission_service = new UserPermissionService; + + $result = $user_permission_service->get($user); + + // Assert + expect($result['owned_character_ids'])->toBe($user->characters->pluck('character_id')->toArray()); +}); + +it('builds corporation_roles from user', function () { + + // Arrange + $user = test()->test_user; + + CharacterRole::query()->delete(); + + CharacterRole::factory()->create([ + 'character_id' => test()->test_character->character_id, + 'roles' => ['Director', 'Personnel Manager'], + ]); + + // Act + $user_permission_service = new UserPermissionService; + + $result = $user_permission_service->get($user); + + // Assert + expect($result['corporation_roles']) + ->toHaveCount(2) + ->toHaveKey('Director') + ->toHaveKey('Personnel Manager') + ->and($result['corporation_roles']['Director'])->toContain(test()->test_character->corporation_id) + ->and($result['corporation_roles']['Personnel Manager'])->toContain(test()->test_character->corporation_id); +}); + +it('builds permissions from user', function () { + + // Arrange + $user = test()->test_user; + + $role1 = Role::create(['name' => Str::random()]); + $role2 = Role::create(['name' => Str::random()]); + + // create 3 permissions + $permissions = collect([ + Permission::create(['name' => Str::random()]), + Permission::create(['name' => Str::random()]), + Permission::create(['name' => Str::random()]), + ]); + + // sync first two permissions to role1 + $role1->syncPermissions($permissions->take(2)); + + // sync last 2 permission to role2 + $role2->syncPermissions($permissions->slice(1)); + + $user->assignRole([$role1, $role2]); + + $role_permission_object_service = mock(RolePermissionObjectService::class, function (MockInterface $mock) use ($permissions) { + + $result1 = collect([ + $permissions[0]->name => [1, 2, 3], + $permissions[1]->name => [4, 5, 6], + ]); + + $result2 = collect([ + $permissions[1]->name => [7, 8, 9], + $permissions[2]->name => [10, 11, 12], + ]); + + $mock->shouldReceive('get') + // ->with($role1) + ->andReturn($result1, $result2); + }); + + // Act + $user_permission_service = new UserPermissionService($role_permission_object_service); + + $result = $user_permission_service->get($user); + + // Assert + expect($result['permissions']) + ->toHaveCount(3) + ->toHaveKeys($permissions->pluck('name')->toArray()) + ->and($result['permissions'][$permissions[0]->name])->toBe([1, 2, 3]) + ->and($result['permissions'][$permissions[1]->name])->toContain(4, 5, 6, 7, 8, 9) + ->and($result['permissions'][$permissions[2]->name])->toBe([10, 11, 12]); +}); + +describe('cache user permissions', function () {})->only(); diff --git a/tests/Unit/Services/Roles/AbstractRoleServiceTest.php b/tests/Unit/Services/Roles/AbstractRoleServiceTest.php new file mode 100644 index 0000000..0651c1f --- /dev/null +++ b/tests/Unit/Services/Roles/AbstractRoleServiceTest.php @@ -0,0 +1,98 @@ +role = Role::create(['name' => 'test']); + $this->role = $this->role->refresh(); + $this->service = new class($this->role) extends AbstractRoleService + { + public function syncMembers(): void {} + + public function canView(User $user): bool + { + return false; + } + + public function canJoin(User $user): bool + { + return false; + } + + public function canModerate(User $user): bool + { + return false; + } + }; +}); + +it('affiliates role to corporation and getting role on test user', function () { + + // Arrange + $test_character = test()->test_character; + $corporation_id = $test_character->corporation_id; + $alliance_id = $test_character->alliance_id; + + expect(Affiliation::count())->toEqual(0); + + // act + $this->service->syncAffiliateManyEntities( + new AffiliationData($corporation_id, 'corporation', AffiliationType::ALLOWED), + new AffiliationData($test_character->character_id, 'character', AffiliationType::ALLOWED), + new AffiliationData($alliance_id, 'alliance', AffiliationType::ALLOWED), + ); + + // Test + expect(Affiliation::count())->toEqual(3) + ->and(Affiliation::first()->affiliatable_id)->toEqual($corporation_id) + ->and(Affiliation::first()->affiliatable_type)->toEqual(CorporationInfo::class) + ->and(Affiliation::first()->type)->toEqual(AffiliationType::ALLOWED->value); +}); + +it('returns early when setting same role type', function () { + + // Arrange + $this->role->type = RoleType::AUTOMATIC; + $this->role->save(); + + // Act + $automated_role_service = new AutomaticRoleService($this->role); + $automated_role_service->automaticallyAssignRoleTo( + new CriteriaData(1, 'corporation'), + ); + + // Assert + expect($this->role->refresh()->type)->toEqual(RoleType::AUTOMATIC); +}); + +it('sets role type to', function (RoleType $role_type) { + + // Act + $this->service->setRoleType($role_type); + + // Assert + expect($this->role->refresh()->type)->toEqual($role_type); +})->with([ + RoleType::AUTOMATIC, + RoleType::ON_REQUEST, + RoleType::OPT_IN, + RoleType::MANUAL, +]); + +it('rename role', function () { + + // Act + $this->service->updateRoleName('new name'); + + // Assert + expect($this->role->refresh()->name)->toEqual('new name'); +}); diff --git a/tests/Unit/Services/Roles/AutomaticRoleServiceTest.php b/tests/Unit/Services/Roles/AutomaticRoleServiceTest.php new file mode 100644 index 0000000..8769a5a --- /dev/null +++ b/tests/Unit/Services/Roles/AutomaticRoleServiceTest.php @@ -0,0 +1,124 @@ +role = Role::create(['name' => 'test']); + $this->role = $this->role->refresh(); + $this->service = new AutomaticRoleService($this->role); +}); + +describe('assigning', function () { + it('role to corporation and getting role on test user', function () { + + $test_character = test()->test_character; + $corporation_id = $test_character->corporation_id; + + expect(test()->test_user->refresh()->hasRole($this->role->name))->toBeFalse(); + + $this->service->automaticallyAssignRoleTo( + new CriteriaData($corporation_id, 'corporation'), + ); + + expect(RoleMembership::get())->toHaveCount(2) // User and Corporation + ->and(test()->test_user->refresh()->hasRole($this->role->name))->toBeTrue(); + + }); + + it('role to alliance', function () { + + $test_character = test()->test_character; + $alliance_id = $test_character->alliance_id; + + $this->service->automaticallyAssignRoleTo( + new CriteriaData($alliance_id, 'alliance'), + ); + + expect(test()->test_user->refresh()->hasRole($this->role->name))->toBeTrue(); + }); + + it('role to corporation and alliance', function () { + + $test_character = test()->test_character; + $corporation_id = $test_character->corporation_id; + $alliance_id = $test_character->alliance_id; + + $this->service->automaticallyAssignRoleTo( + new CriteriaData($corporation_id, 'corporation'), + new CriteriaData($alliance_id, 'alliance'), + ); + + expect(RoleMembership::get())->toHaveCount(3) // User, Corporation and Alliance + ->and(test()->test_user->refresh()->hasRole($this->role->name))->toBeTrue(); + }); +}); + +describe('handling Members', function () { + it('removes role from user if nothing is assigned', function () { + + $test_user = test()->test_user; + + $test_user->assignRole($this->role); + + expect(test()->test_user->refresh()->hasRole($this->role->name))->toBeTrue(); + + $this->service->automaticallyAssignRoleTo(); + + expect(test()->test_user->refresh()->hasRole($this->role->name))->toBeFalse() + ->and(RoleMembership::query()->count())->toBe(0); + }); + + it('works also with role in constructor', function () { + /** @var Role $role */ + $role = Role::create(['name' => 'constructor test']); + + $service = new AutomaticRoleService($role); + + $test_character = test()->test_character; + $corporation_id = $test_character->corporation_id; + + $service->automaticallyAssignRoleTo( + new CriteriaData($corporation_id, 'corporation'), + ); + + expect(RoleMembership::get())->toHaveCount(2) // User and Corporation + ->and(test()->test_user->refresh()->hasRole($role->name))->toBeTrue(); + }); +}); + +it('sets role type to automatic', function () { + + expect($this->role->type)->toBe(RoleType::MANUAL); + + $this->service->setRoleType(RoleType::AUTOMATIC); + + expect($this->role->type)->toBe(RoleType::AUTOMATIC); +}); + +it('cannot view', function () { + expect($this->service->canView(test()->test_user))->toBeFalse(); +}); + +it('can view when meets criteria', function () { + $test_character = test()->test_character; + $corporation_id = $test_character->corporation_id; + + $this->service->automaticallyAssignRoleTo( + new CriteriaData($corporation_id, 'corporation'), + ); + + expect($this->service->canView(test()->test_user))->toBeTrue(); +}); + +it('cannot join', function () { + expect($this->service->canJoin(test()->test_user))->toBeFalse(); +}); + +it('cannot moderate', function () { + expect($this->service->canModerate(test()->test_user))->toBeFalse(); +}); diff --git a/tests/Unit/Services/Roles/BaseRoleServiceTest.php b/tests/Unit/Services/Roles/BaseRoleServiceTest.php new file mode 100644 index 0000000..7641d08 --- /dev/null +++ b/tests/Unit/Services/Roles/BaseRoleServiceTest.php @@ -0,0 +1,63 @@ +role = Role::create(['name' => faker()->name()]); + $this->role = $this->role->refresh(); + $this->service = new BaseRoleService; +}); + +describe('make', function () { + test('service can be made role', function () { + + $service = BaseRoleService::make($this->role); + + expect($service)->toBeInstanceOf(BaseRoleService::class); + }); + + test('service can be made role by id', function () { + + $service = BaseRoleService::make($this->role->id); + + expect($service)->toBeInstanceOf(BaseRoleService::class); + }); + + it('throws exception if role not found', function () { + + BaseRoleService::make('abc'); + })->expectException(RoleDoesNotExist::class); +}); + +it('can get automatic role service', function () { + + $service = BaseRoleService::make($this->role)->automatic(); + + expect($service)->toBeInstanceOf(AutomaticRoleService::class); +}); + +it('work with the various role types', function (RoleType $role_type) { + // Arrange + $this->role->update(['type' => $role_type->value]); + + $service = BaseRoleService::make($this->role->refresh()); + + $service->for($this->role); + + // act + $service->handleMembers(); + + $can_view = $service->canView(test()->test_user); + $can_join = $service->canJoin(test()->test_user); + $can_moderate = $service->canModerate(test()->test_user); + + // assert + expect($can_view)->toBeFalse() + ->and($can_join)->toBeFalse() + ->and($can_moderate)->toBeFalse(); + +})->with(RoleType::cases()); diff --git a/tests/Unit/Services/Roles/ManualRoleServiceTest.php b/tests/Unit/Services/Roles/ManualRoleServiceTest.php new file mode 100644 index 0000000..1c7dba0 --- /dev/null +++ b/tests/Unit/Services/Roles/ManualRoleServiceTest.php @@ -0,0 +1,80 @@ +role = Role::create(['name' => 'test']); + $this->role = $this->role->refresh(); + + $this->service = new ManualRoleService($this->role); +}); + +it('can add a member', function () { + $test_user = test()->test_user; + + $this->service->addMember($test_user); + + expect(RoleMembership::query()->count())->toBe(1) + ->and(RoleMembership::first())->entity_type->toBe(User::class); +}); + +it('can remove a member', function () { + $test_user = test()->test_user; + + $this->service->addMember($test_user); + + expect(RoleMembership::query()->count())->toBe(1); + + $this->service->removeMember($test_user); + + expect(RoleMembership::query()->count())->toBe(0); +}); + +it('can add user as moderator and does not change status', function () { + $test_user = test()->test_user; + + $this->service->addMember($test_user); + + expect(RoleMembership::first())->status->toBe(RoleMembershipStatus::ACTIVE->value); + + $this->service->setModerator($test_user); + + expect(RoleMembership::first())->status->toBe(RoleMembershipStatus::ACTIVE->value) + ->can_moderate->toBeTrue(); + + $this->service->setModerator($test_user, false); + + expect(RoleMembership::first())->status->toBe(RoleMembershipStatus::ACTIVE->value) + ->can_moderate->toBeFalse(); +}); + +it('syncs members', function () { + $test_user = test()->test_user; + + $this->service->addMember($test_user); + + expect(RoleMembership::first())->status->toBe(RoleMembershipStatus::ACTIVE->value); + + $this->service->syncMembers(); + + expect(RoleMembership::first())->status->toBe(RoleMembershipStatus::ACTIVE->value); +}); + +it('can view', function () { + expect($this->service->canView(test()->test_user))->toBeFalse(); +}); + +it('can join', function () { + expect($this->service->canJoin(test()->test_user))->toBeFalse(); +}); + +it('can moderate', function () { + + $this->service->setModerator(test()->test_user); + + expect($this->service->canModerate(test()->test_user))->toBeTrue(); +}); diff --git a/tests/Unit/Services/Roles/OnRequestRoleServiceTest.php b/tests/Unit/Services/Roles/OnRequestRoleServiceTest.php new file mode 100644 index 0000000..ebee34d --- /dev/null +++ b/tests/Unit/Services/Roles/OnRequestRoleServiceTest.php @@ -0,0 +1,275 @@ +role = Role::create(['name' => 'test', 'type' => RoleType::ON_REQUEST->value]); + $this->role = $this->role->refresh(); + + $this->service = new OnRequestRoleService($this->role); +}); + +describe('adding criteria for role application', function () { + it('adds criteria for role application with valid entities', function () { + // Arrange + $corporation_id = test()->test_character->corporation_id; + $alliance_id = test()->test_character->alliance_id; + + // Act + $this->service->addCriteriaForRoleApplication( + new CriteriaData($corporation_id, 'corporation'), + new CriteriaData($alliance_id, 'alliance'), + ); + + // Assert + expect(RoleMembership::query()->count())->toBe(2); + }); + + it('throws validation exception for invalid entities', function () { + // Act + $this->service->addCriteriaForRoleApplication( + new CriteriaData(test()->test_character->corporation_id, 'corporation'), + new CriteriaData(test()->test_character->alliance_id, 'invalid'), + ); + })->throws(ValueError::class); + + it('resets criterias', function () { + // Arrange + + // create random role membership that acts as criteria + RoleMembership::query()->create([ + 'role_id' => $this->role->id, + 'entity_id' => 12345, + 'entity_type' => CorporationInfo::class, + ]); + + // create user role membership that acts as member and should not be deleted + RoleMembership::query()->create([ + 'role_id' => $this->role->id, + 'entity_id' => test()->test_user->id, + 'entity_type' => User::class, + ]); + + // Act + $this->service->addCriteriaForRoleApplication( + new CriteriaData(test()->test_character->corporation_id, 'corporation'), + new CriteriaData(test()->test_character->alliance_id, 'alliance'), + ); + + // Assert + expect(RoleMembership::query()->count())->toBe(3) + ->and(RoleMembership::query()->where('entity_type', User::class)->count())->toBe(1) + ->and(RoleMembership::query()->where('entity_id', 12345)->count())->toBe(0); + }); +}); + +it('cannot submit application if no criteria is set', function () { + // arrange + $user = test()->test_user; + + // act + $this->service->submitApplicationForRole($user); + + // assert + +})->throws(Exception::class, 'User does not meet criteria to join role'); + +it('submits application for role', function () { + // arrange + $user = test()->test_user; + + $this->service->addCriteriaForRoleApplication( + new CriteriaData(test()->test_character->corporation_id, 'corporation'), + ); + + // act + $this->service->submitApplicationForRole($user); + + // assert + expect(RoleMembership::query()->where('role_id', $this->role->id)->where('status', 'pending')->count())->toBe(1) + ->and(RoleMembership::query()->where('role_id', $this->role->id)->where('entity_id', $user->id)->first()) + ->status->toBe(RoleMembershipStatus::PENDING->value) + ->entity_id->toBe($user->id); +}); + +it('approving application for role', function () { + // arrange + $user = test()->test_user; + $this->service->addCriteriaForRoleApplication( + new CriteriaData(test()->test_character->corporation_id, 'corporation'), + ); + + // act + $this->service->approveApplicationForRole($user); + + // assert + expect(RoleMembership::query()->where('role_id', $this->role->id)->count())->toBe(2) + ->and(RoleMembership::query()->where('role_id', $this->role->id)->where('entity_id', $user->id)->first()) + ->status->toBe(RoleMembershipStatus::ACTIVE->value) + ->entity_id->toBe($user->id); +}); + +it('throws exception when approving application for role with no criteria', function () { + // arrange + $user = test()->test_user; + + // act + $this->service->approveApplicationForRole($user); + + // assert +})->throws(Exception::class, 'User does not meet criteria to join role'); + +it('denies application for role', function () { + // arrange + $user = test()->test_user; + RoleMembership::query()->create([ + 'role_id' => $this->role->id, + 'entity_id' => $user->id, + 'entity_type' => User::class, + 'status' => RoleMembershipStatus::PENDING->value, + ]); + + // act + $this->service->denyApplication($user); + + // assert + expect(RoleMembership::query()->where('role_id', $this->role->id)->where('entity_id', $user->id)->count())->toBe(0); +}); + +it('removes application for role', function () { + // arrange + $user = test()->test_user; + RoleMembership::query()->create([ + 'role_id' => $this->role->id, + 'entity_id' => $user->id, + 'entity_type' => User::class, + 'status' => RoleMembershipStatus::PENDING->value, + ]); + + // act + $this->service->removeApplication($user); + + // assert + expect(RoleMembership::query()->where('role_id', $this->role->id)->where('entity_id', $user->id)->count())->toBe(0); +}); + +it('sets moderator status for user', function (bool $can_moderate) { + // arrange + $user = test()->test_user; + + // act + $this->service->setModerator($user, $can_moderate); + + // assert + expect(RoleMembership::query()->where('role_id', $this->role->id)->where('entity_id', $user->id)->first()) + ->can_moderate->toBe($can_moderate); +})->with([ + true, + false, +]); + +describe('sync', function () { + it('removes members outside criteria', function () { + // Arrange + $user = test()->test_user; + + RoleMembership::query()->create([ + 'role_id' => $this->role->id, + 'entity_id' => $user->id, + 'entity_type' => User::class, + 'status' => RoleMembershipStatus::ACTIVE->value, + ]); + + // Act + $this->service->syncMembers(); + + // Assert + expect(RoleMembership::count())->toBe(0); + + }); + + it('does not removes members within criteria', function () { + // Arrange + + // set criteria + $test_character = test()->test_character; + $test_character = test()->test_character; + RoleMembership::query()->create([ + 'role_id' => $this->role->id, + 'entity_id' => $test_character->corporation_id, + 'entity_type' => CorporationInfo::class, + ]); + + // Add Member + $user = test()->test_user; + RoleMembership::query()->create([ + 'role_id' => $this->role->id, + 'entity_id' => $user->id, + 'entity_type' => User::class, + 'status' => RoleMembershipStatus::ACTIVE->value, + ]); + + expect(RoleMembership::count())->toBe(2); + + // + // Act + $this->service->syncMembers(); + + // Assert + expect(RoleMembership::count())->toBe(2); + }); +}); + +describe('can', function () { + + beforeEach(function () { + $this->service->addCriteriaForRoleApplication( + new CriteriaData(test()->test_character->corporation_id, 'corporation'), + new CriteriaData(test()->test_character->alliance_id, 'alliance'), + ); + }); + + it('can view', function () { + // Act + $result = $this->service->canView(test()->test_user); + + // Assert + expect($result)->toBeTrue(); + }); + + it('can join', function () { + // Act + $result = $this->service->canJoin(test()->test_user); + + // Assert + expect($result)->toBeTrue(); + }); + +}); + +it('cannot moderate', function () { + // Act + $result = $this->service->canModerate(test()->test_user); + + // Assert + expect($result)->toBeFalse(); +}); + +it('can moderate', function () { + // Arrange + $user = test()->test_user; + $this->service->setModerator($user); + + // Act + $result = $this->service->canModerate($user); + + // Assert + expect($result)->toBeTrue(); +}); diff --git a/tests/Unit/Services/Roles/OptInRoleServiceTest.php b/tests/Unit/Services/Roles/OptInRoleServiceTest.php new file mode 100644 index 0000000..19a18b6 --- /dev/null +++ b/tests/Unit/Services/Roles/OptInRoleServiceTest.php @@ -0,0 +1,103 @@ +role = Role::create(['name' => 'test']); + $this->role = $this->role->refresh(); + + $this->service = new OptInRoleService($this->role); +}); + +it('can add criteria', function () { + + $this->service->addCriteriaForRole( + new CriteriaData(1, 'corporation'), + ); + + expect(RoleMembership::query()->count())->toBe(1) + ->and(RoleMembership::first())->entity_type->toBe(CorporationInfo::class); +}); + +it('can join role', function () { + $test_user = test()->test_user; + + $this->service->addCriteriaForRole( + new CriteriaData(test()->test_character->corporation_id, 'corporation'), + ); + + $this->service->joinRole($test_user); + + expect(RoleMembership::query()->count())->toBe(2); +}); + +it('can leave role', function () { + $test_user = test()->test_user; + $this->service->addCriteriaForRole( + new CriteriaData(test()->test_character->corporation_id, 'corporation'), + ); + + $this->service->joinRole($test_user); + + expect(RoleMembership::query()->count())->toBe(2); + + $this->service->leaveRole($test_user); + + expect(RoleMembership::query()->count())->toBe(1); +}); + +it('syncs members', function () { + $test_user = test()->test_user; + $this->service->addCriteriaForRole( + new CriteriaData(test()->test_character->corporation_id, 'corporation'), + ); + + $this->service->joinRole($test_user); + + // RoleMember + $role_member = RoleMembership::query()->where('entity_type', User::class)->get(); + + expect($role_member->count())->toBe(1) + ->and($role_member->first())->status->toBe(RoleMembershipStatus::ACTIVE->value); + + // remove criteria makes the user not meet the criteria anymore + $this->service->addCriteriaForRole( + new CriteriaData(1234, 'corporation'), + ); + + $this->service->syncMembers(); + + expect(RoleMembership::count())->toBe(1); +}); + +describe('it can', function () { + beforeEach(function () { + $this->service->addCriteriaForRole( + new CriteriaData(test()->test_character->corporation_id, 'corporation'), + ); + }); + + it('can view', function () { + $test_user = test()->test_user; + + expect($this->service->canView($test_user))->toBeTrue(); + }); + + it('can join', function () { + $test_user = test()->test_user; + + expect($this->service->canJoin($test_user))->toBeTrue(); + }); +}); + +it('cannot moderate', function () { + $test_user = test()->test_user; + + expect($this->service->canModerate($test_user))->toBeFalse(); +}); diff --git a/tests/Unit/Services/Roles/RolePermissionObjectServiceTest.php b/tests/Unit/Services/Roles/RolePermissionObjectServiceTest.php new file mode 100644 index 0000000..4f9c29d --- /dev/null +++ b/tests/Unit/Services/Roles/RolePermissionObjectServiceTest.php @@ -0,0 +1,38 @@ + Str::random()]); + + // create 3 permissions + $permissions = [ + Permission::create(['name' => Str::random()]), + Permission::create(['name' => Str::random()]), + Permission::create(['name' => Str::random()]), + ]; + + $role->syncPermissions($permissions); + + $mock = mock(RoleAffiliatedIdsService::class, function ($mock) { + $mock->shouldReceive('get')->andReturn([1, 2, 3]); + }); + + // Act + $role_permission_object_service = new RolePermissionObjectService($mock); + + $result = $role_permission_object_service->get($role); + + // Assert + expect($result)->toHaveCount(3) + ->toHaveKeys([$permissions[0]->name, $permissions[1]->name, $permissions[2]->name]) + ->and($result[$permissions[0]->name])->toBe([1, 2, 3]) + ->and($result[$permissions[1]->name])->toBe([1, 2, 3]) + ->and($result[$permissions[2]->name])->toBe([1, 2, 3]); +}); diff --git a/tests/Unit/Services/SsoScopes/GlobalSsoScopesServiceTest.php b/tests/Unit/Services/SsoScopes/GlobalSsoScopesServiceTest.php new file mode 100644 index 0000000..43803f2 --- /dev/null +++ b/tests/Unit/Services/SsoScopes/GlobalSsoScopesServiceTest.php @@ -0,0 +1,17 @@ +set($scopes); + + $sso_scopes = SsoScopes::query() + ->where('type', 'global') + ->first(); + + expect($sso_scopes->selected_scopes)->toBe($scopes); +}); diff --git a/tests/Unit/UpdateRefreshTokenActionTest.php b/tests/Unit/UpdateRefreshTokenActionTest.php index 7e81b5b..9595be7 100644 --- a/tests/Unit/UpdateRefreshTokenActionTest.php +++ b/tests/Unit/UpdateRefreshTokenActionTest.php @@ -31,7 +31,7 @@ test('create refresh token', function () { $eve_data = createEveUser(test()->test_user->id); - $action = new UpdateRefreshTokenAction(); + $action = new UpdateRefreshTokenAction; Event::fakeFor(fn () => $action($eve_data)); test()->assertDatabaseHas('refresh_tokens', [ @@ -45,7 +45,7 @@ // create RefreshToken $eveUser = createEveUser(); - $action = new UpdateRefreshTokenAction(); + $action = new UpdateRefreshTokenAction; Event::fakeFor(fn () => $action($eveUser)); test()->assertDatabaseHas('refresh_tokens', [ @@ -71,7 +71,7 @@ // create RefreshToken $eveUser = createEveUser(); - $action = new UpdateRefreshTokenAction(); + $action = new UpdateRefreshTokenAction; Event::fakeFor(fn () => $action($eveUser)); test()->assertDatabaseHas('refresh_tokens', [ @@ -98,7 +98,7 @@ // create RefreshToken $eveUser = createEveUser(); - $action = new UpdateRefreshTokenAction(); + $action = new UpdateRefreshTokenAction; Event::fakeFor(fn () => $action($eveUser)); test()->assertDatabaseHas('refresh_tokens', [