diff --git a/src/app/Console/Commands/Sku/ListUsers.php b/src/app/Console/Commands/Sku/ListUsers.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Sku/ListUsers.php @@ -0,0 +1,64 @@ +argument('sku')); + + if (!$sku) { + $sku = \App\Sku::where('title', $this->argument('sku'))->first(); + } + + if (!$sku) { + $this->error("Unable to find the SKU."); + return 1; + } + + $fn = function ($entitlement) { + $user_id = $entitlement->user_id; + if ($entitlement->entitleable_type == \App\User::class) { + $user_id = $entitlement->entitleable_id; + } + + return $user_id; + }; + + $users = \App\Entitlement::select('user_id', 'entitleable_id', 'entitleable_type') + ->join('wallets', 'wallets.id', '=', 'wallet_id') + ->where('sku_id', $sku->id) + ->get() + ->map($fn) + ->unique(); + + // TODO: This wereIn() might not scale + \App\User::whereIn('id', $users)->orderBy('email')->get() + ->pluck('email') + ->each(function ($email, $key) { + $this->info($email); + }); + } +} diff --git a/src/app/Console/Commands/UserAssignSku.php b/src/app/Console/Commands/UserAssignSku.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/UserAssignSku.php @@ -0,0 +1,60 @@ +argument('user'))->first(); + + if (!$user) { + $this->error("Unable to find the user {$this->argument('user')}."); + return 1; + } + + $sku = \App\Sku::find($this->argument('sku')); + + if (!$sku) { + $sku = \App\Sku::where('title', $this->argument('sku'))->first(); + } + + if (!$sku) { + $this->error("Unable to find the SKU {$this->argument('sku')}."); + return 1; + } + + $quantity = (int) $this->option('qty'); + + // Check if the entitlement already exists + if (empty($quantity)) { + if ($user->entitlements()->where('sku_id', $sku->id)->first()) { + $this->error("The entitlement already exists. Maybe try with --qty=X?"); + return 1; + } + } + + $user->assignSku($sku, $quantity ?: 1); + } +} diff --git a/src/app/Handlers/Activesync.php b/src/app/Handlers/Activesync.php --- a/src/app/Handlers/Activesync.php +++ b/src/app/Handlers/Activesync.php @@ -4,21 +4,38 @@ class Activesync extends \App\Handlers\Base { + /** + * The entitleable class for this handler. + * + * @return string + */ public static function entitleableClass(): string { return \App\User::class; } - public static function preReq($entitlement, $object): bool + /** + * SKU handler metadata. + * + * @param \App\Sku $sku The SKU object + * + * @return array + */ + public static function metadata(\App\Sku $sku): array { - if (!$entitlement->sku->active) { - \Log::error("Sku not active"); - return false; - } + $data = parent::metadata($sku); - return true; + $data['required'] = ['groupware']; + + return $data; } + /** + * The priority that specifies the order of SKUs in UI. + * Higher number means higher on the list. + * + * @return int + */ public static function priority(): int { return 70; diff --git a/src/app/Handlers/Auth2F.php b/src/app/Handlers/Auth2F.php --- a/src/app/Handlers/Auth2F.php +++ b/src/app/Handlers/Auth2F.php @@ -4,21 +4,38 @@ class Auth2F extends \App\Handlers\Base { + /** + * The entitleable class for this handler. + * + * @return string + */ public static function entitleableClass(): string { return \App\User::class; } - public static function preReq($entitlement, $object): bool + /** + * SKU handler metadata. + * + * @param \App\Sku $sku The SKU object + * + * @return array + */ + public static function metadata(\App\Sku $sku): array { - if (!$entitlement->sku->active) { - \Log::error("Sku not active"); - return false; - } + $data = parent::metadata($sku); - return true; + $data['forbidden'] = ['activesync']; + + return $data; } + /** + * The priority that specifies the order of SKUs in UI. + * Higher number means higher on the list. + * + * @return int + */ public static function priority(): int { return 60; diff --git a/src/app/Handlers/Base.php b/src/app/Handlers/Base.php --- a/src/app/Handlers/Base.php +++ b/src/app/Handlers/Base.php @@ -14,6 +14,56 @@ return ''; } + /** + * Check if the SKU is available to the user. An SKU is available + * to the user when either it is active or there's already an + * active entitlement. + * + * @param \App\Sku $sku The SKU object + * @param \App\User $user The user object + * + * @return bool + */ + public static function isAvailable(\App\Sku $sku, \App\User $user): bool + { + if (!$sku->active) { + if (!$user->entitlements()->where('sku_id', $sku->id)->first()) { + return false; + } + } + + return true; + } + + /** + * Metadata of this SKU handler. + * + * @param \App\Sku $sku The SKU object + * + * @return array + */ + public static function metadata(\App\Sku $sku): array + { + $handler = explode('\\', static::class); + $handler = strtolower(end($handler)); + + $type = explode('\\', static::entitleableClass()); + $type = strtolower(end($type)); + + return [ + // entitleable type + 'type' => $type, + // handler (as a keyword) + 'handler' => $handler, + // readonly entitlement state cannot be changed + 'readonly' => false, + // is entitlement enabled by default? + 'enabled' => false, + // priority on the entitlements list + 'prio' => static::priority(), + ]; + } + /** * Prerequisites for the Entitlement to be applied to the object. * @@ -24,6 +74,23 @@ */ public static function preReq($entitlement, $object): bool { + $type = static::entitleableClass(); + + if (empty($type) || empty($entitlement->entitleable_type)) { + \Log::error("Entitleable class/type not specified"); + return false; + } + + if ($type !== $entitlement->entitleable_type) { + \Log::error("Entitleable class mismatch"); + return false; + } + + if (!$entitlement->sku->active) { + \Log::error("Sku not active"); + return false; + } + return true; } diff --git a/src/app/Handlers/Base.php b/src/app/Handlers/Beta.php copy from src/app/Handlers/Base.php copy to src/app/Handlers/Beta.php --- a/src/app/Handlers/Base.php +++ b/src/app/Handlers/Beta.php @@ -2,7 +2,7 @@ namespace App\Handlers; -abstract class Base +class Beta extends \App\Handlers\Base { /** * The entitleable class for this handler. @@ -11,7 +11,7 @@ */ public static function entitleableClass(): string { - return ''; + return \App\User::class; } /** @@ -24,6 +24,13 @@ */ public static function preReq($entitlement, $object): bool { + // We allow inactive "beta" Sku to be assigned + + if (self::entitleableClass() !== $entitlement->entitleable_type) { + \Log::error("Entitleable class mismatch"); + return false; + } + return true; } @@ -35,6 +42,7 @@ */ public static function priority(): int { - return 0; + // Just above all other beta SKUs, please + return 10; } } diff --git a/src/app/Handlers/Beta/Base.php b/src/app/Handlers/Beta/Base.php new file mode 100644 --- /dev/null +++ b/src/app/Handlers/Beta/Base.php @@ -0,0 +1,73 @@ +active) { + $beta = \App\Sku::where('title', 'beta')->first(); + if (!$beta) { + return false; + } + + if ($user->entitlements()->where('sku_id', $beta->id)->first()) { + return true; + } + } else { + if ($user->entitlements()->where('sku_id', $sku->id)->first()) { + return true; + } + } + + return false; + } + + /** + * SKU handler metadata. + * + * @param \App\Sku $sku The SKU object + * + * @return array + */ + public static function metadata(\App\Sku $sku): array + { + $data = parent::metadata($sku); + + $data['required'] = ['beta']; + + return $data; + } + + /** + * Prerequisites for the Entitlement to be applied to the object. + * + * @param \App\Entitlement $entitlement + * @param mixed $object + * + * @return bool + */ + public static function preReq($entitlement, $object): bool + { + if (!parent::preReq($entitlement, $object)) { + return false; + } + + // TODO: User has to have the "beta" entitlement + + return true; + } +} diff --git a/src/app/Handlers/Beta/Meet.php b/src/app/Handlers/Beta/Meet.php new file mode 100644 --- /dev/null +++ b/src/app/Handlers/Beta/Meet.php @@ -0,0 +1,18 @@ +sku->active) { - \Log::error("Sku not active"); - return false; - } - - return true; - } } diff --git a/src/app/Handlers/DomainHosting.php b/src/app/Handlers/DomainHosting.php --- a/src/app/Handlers/DomainHosting.php +++ b/src/app/Handlers/DomainHosting.php @@ -4,18 +4,13 @@ class DomainHosting extends \App\Handlers\Base { + /** + * The entitleable class for this handler. + * + * @return string + */ public static function entitleableClass(): string { return \App\Domain::class; } - - public static function preReq($entitlement, $domain): bool - { - if (!$entitlement->sku->active) { - \Log::error("Sku not active"); - return false; - } - - return true; - } } diff --git a/src/app/Handlers/DomainRegistration.php b/src/app/Handlers/DomainRegistration.php --- a/src/app/Handlers/DomainRegistration.php +++ b/src/app/Handlers/DomainRegistration.php @@ -4,18 +4,13 @@ class DomainRegistration extends \App\Handlers\Base { + /** + * The entitleable class for this handler. + * + * @return string + */ public static function entitleableClass(): string { return \App\Domain::class; } - - public static function preReq($entitlement, $domain): bool - { - if (!$entitlement->sku->active) { - \Log::error("Sku not active"); - return false; - } - - return true; - } } diff --git a/src/app/Handlers/DomainRelay.php b/src/app/Handlers/DomainRelay.php --- a/src/app/Handlers/DomainRelay.php +++ b/src/app/Handlers/DomainRelay.php @@ -4,18 +4,13 @@ class DomainRelay extends \App\Handlers\Base { + /** + * The entitleable class for this handler. + * + * @return string + */ public static function entitleableClass(): string { return \App\Domain::class; } - - public static function preReq($entitlement, $domain): bool - { - if (!$entitlement->sku->active) { - \Log::error("Sku not active"); - return false; - } - - return true; - } } diff --git a/src/app/Handlers/Groupware.php b/src/app/Handlers/Groupware.php --- a/src/app/Handlers/Groupware.php +++ b/src/app/Handlers/Groupware.php @@ -4,21 +4,16 @@ class Groupware extends \App\Handlers\Base { + /** + * The entitleable class for this handler. + * + * @return string + */ public static function entitleableClass(): string { return \App\User::class; } - public static function preReq($entitlement, $user): bool - { - if (!$entitlement->sku->active) { - \Log::error("Sku not active"); - return false; - } - - return true; - } - /** * The priority that specifies the order of SKUs in UI. * Higher number means higher on the list. diff --git a/src/app/Handlers/Mailbox.php b/src/app/Handlers/Mailbox.php --- a/src/app/Handlers/Mailbox.php +++ b/src/app/Handlers/Mailbox.php @@ -4,37 +4,32 @@ class Mailbox extends \App\Handlers\Base { + /** + * The entitleable class for this handler. + * + * @return string + */ public static function entitleableClass(): string { return \App\User::class; } - public static function preReq($entitlement, $user): bool + /** + * SKU handler metadata. + * + * @param \App\Sku $sku The SKU object + * + * @return array + */ + public static function metadata(\App\Sku $sku): array { - if (!$entitlement->sku->active) { - \Log::error("Sku not active"); - return false; - } -/* - FIXME: This code prevents from creating initial mailbox SKU - on signup of group account, because User::domains() - does not return the new domain. - Either we make sure to create domain entitlement before mailbox - entitlement or make the method here aware of that case or? - - list($local, $domain) = explode('@', $user->email); - - $domains = $user->domains(); + $data = parent::metadata($sku); - foreach ($domains as $_domain) { - if ($domain == $_domain->namespace) { - return true; - } - } + // Mailbox is always enabled and cannot be unset + $data['readonly'] = true; + $data['enabled'] = true; - \Log::info("Domain not for user"); -*/ - return true; + return $data; } /** diff --git a/src/app/Handlers/Resource.php b/src/app/Handlers/Resource.php --- a/src/app/Handlers/Resource.php +++ b/src/app/Handlers/Resource.php @@ -4,19 +4,14 @@ class Resource extends \App\Handlers\Base { + /** + * The entitleable class for this handler. + * + * @return string + */ public static function entitleableClass(): string { // TODO return ''; } - - public static function preReq($entitlement, $owner): bool - { - if (!$entitlement->sku->active) { - \Log::error("Sku not active"); - return false; - } - - return true; - } } diff --git a/src/app/Handlers/SharedFolder.php b/src/app/Handlers/SharedFolder.php --- a/src/app/Handlers/SharedFolder.php +++ b/src/app/Handlers/SharedFolder.php @@ -4,19 +4,14 @@ class SharedFolder extends \App\Handlers\Base { + /** + * The entitleable class for this handler. + * + * @return string + */ public static function entitleableClass(): string { // TODO return ''; } - - public static function preReq($entitlement, $owner): bool - { - if (!$entitlement->sku->active) { - \Log::error("Sku not active"); - return false; - } - - return true; - } } diff --git a/src/app/Handlers/Storage.php b/src/app/Handlers/Storage.php --- a/src/app/Handlers/Storage.php +++ b/src/app/Handlers/Storage.php @@ -7,21 +7,36 @@ public const MAX_ITEMS = 100; public const ITEM_UNIT = 'GB'; + /** + * The entitleable class for this handler. + * + * @return string + */ public static function entitleableClass(): string { return \App\User::class; } - public static function preReq($entitlement, $object): bool + /** + * SKU handler metadata. + * + * @param \App\Sku $sku The SKU object + * + * @return array + */ + public static function metadata(\App\Sku $sku): array { - if (!$entitlement->sku->active) { - \Log::error("Sku not active"); - return false; - } + $data = parent::metadata($sku); - // TODO: The storage can not be modified to below what is already consumed. + $data['readonly'] = true; // only the checkbox will be disabled, not range + $data['enabled'] = true; + $data['range'] = [ + 'min' => $sku->units_free, + 'max' => self::MAX_ITEMS, + 'unit' => self::ITEM_UNIT, + ]; - return true; + return $data; } /** diff --git a/src/app/Http/Controllers/API/V4/SkusController.php b/src/app/Http/Controllers/API/V4/SkusController.php --- a/src/app/Http/Controllers/API/V4/SkusController.php +++ b/src/app/Http/Controllers/API/V4/SkusController.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Controller; use App\Sku; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; class SkusController extends Controller { @@ -46,18 +47,14 @@ } /** - * Display a listing of the sku. + * Get a list of active SKUs. * * @return \Illuminate\Http\JsonResponse */ public function index() { // Note: Order by title for consistent ordering in tests - $skus = Sku::select()->orderBy('title')->get(); - - // Note: we do not limit the result to active SKUs only. - // It's because we might need users assigned to old SKUs, - // we need to display these old SKUs on the entitlements list + $skus = Sku::where('active', true)->orderBy('title')->get(); $response = []; @@ -114,6 +111,52 @@ return $this->errorResponse(404); } + /** + * Get a list of SKUs available to the user. + * + * @param int $id User identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function userSkus($id) + { + $user = \App\User::find($id); + + if (empty($user)) { + return $this->errorResponse(404); + } + + if (!Auth::guard()->user()->canRead($user)) { + return $this->errorResponse(403); + } + + $type = request()->input('type'); + $response = []; + + // Note: Order by title for consistent ordering in tests + $skus = Sku::orderBy('title')->get(); + + foreach ($skus as $sku) { + if (!$sku->handler_class::isAvailable($sku, $user)) { + continue; + } + + if ($data = $this->skuElement($sku)) { + if ($type && $type != $data['type']) { + continue; + } + + $response[] = $data; + } + } + + usort($response, function ($a, $b) { + return ($b['prio'] <=> $a['prio']); + }); + + return response()->json($response); + } + /** * Convert SKU information to metadata used by UI to * display the form control @@ -124,59 +167,18 @@ */ protected function skuElement($sku): ?array { - $type = $sku->handler_class::entitleableClass(); + $data = array_merge($sku->toArray(), $sku->handler_class::metadata($sku)); // ignore incomplete handlers - if (!$type) { + if (empty($data['type'])) { return null; } - $type = explode('\\', $type); - $type = strtolower(end($type)); - - $handler = explode('\\', $sku->handler_class); - $handler = strtolower(end($handler)); - - $data = $sku->toArray(); - - $data['type'] = $type; - $data['handler'] = $handler; - $data['readonly'] = false; - $data['enabled'] = false; - $data['prio'] = $sku->handler_class::priority(); - // Use localized value, toArray() does not get them right $data['name'] = $sku->name; $data['description'] = $sku->description; - unset($data['handler_class']); - - switch ($handler) { - case 'activesync': - $data['required'] = ['groupware']; - break; - - case 'auth2f': - $data['forbidden'] = ['activesync']; - break; - - case 'storage': - // Quota range input - $data['readonly'] = true; // only the checkbox will be disabled, not range - $data['enabled'] = true; - $data['range'] = [ - 'min' => $data['units_free'], - 'max' => $sku->handler_class::MAX_ITEMS, - 'unit' => $sku->handler_class::ITEM_UNIT, - ]; - break; - - case 'mailbox': - // Mailbox is always enabled and cannot be unset - $data['readonly'] = true; - $data['enabled'] = true; - break; - } + unset($data['handler_class'], $data['created_at'], $data['updated_at']); return $data; } diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -24,14 +24,6 @@ */ public function creating(Entitlement $entitlement): bool { - while (true) { - $allegedly_unique = \App\Utils::uuidStr(); - if (!Entitlement::find($allegedly_unique)) { - $entitlement->{$entitlement->getKeyName()} = $allegedly_unique; - break; - } - } - // can't dispatch job here because it'll fail serialization // Make sure the owner is at least a controller on the wallet @@ -53,6 +45,14 @@ return false; } + while (true) { + $allegedly_unique = \App\Utils::uuidStr(); + if (!Entitlement::find($allegedly_unique)) { + $entitlement->{$entitlement->getKeyName()} = $allegedly_unique; + break; + } + } + return true; } diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -175,12 +175,6 @@ $wallet = $this->wallet(); $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); - // TODO: Sanity check, this probably should be in preReq() on handlers - // or in EntitlementObserver - if ($sku->handler_class::entitleableClass() != User::class) { - throw new \Exception("Cannot assign non-user SKU ({$sku->title}) to a user"); - } - while ($count > 0) { \App\Entitlement::create([ 'wallet_id' => $wallet->id, diff --git a/src/database/seeds/local/SkuSeeder.php b/src/database/seeds/local/SkuSeeder.php --- a/src/database/seeds/local/SkuSeeder.php +++ b/src/database/seeds/local/SkuSeeder.php @@ -151,5 +151,31 @@ 'active' => true, ] ); + + Sku::create( + [ + 'title' => 'beta', + 'name' => 'Beta program', + 'description' => 'Access to beta program subscriptions', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta', + 'active' => false, + ] + ); + + Sku::create( + [ + 'title' => 'meet', + 'name' => 'Video chat', + 'description' => 'Video conferencing tool', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\Meet', + 'active' => true, + ] + ); } } diff --git a/src/database/seeds/production/SkuSeeder.php b/src/database/seeds/production/SkuSeeder.php --- a/src/database/seeds/production/SkuSeeder.php +++ b/src/database/seeds/production/SkuSeeder.php @@ -151,5 +151,31 @@ 'active' => true, ] ); + + Sku::create( + [ + 'title' => 'beta', + 'name' => 'Beta program', + 'description' => 'Access to beta program subscriptions', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta', + 'active' => false, + ] + ); + + Sku::create( + [ + 'title' => 'meet', + 'name' => 'Video chat', + 'description' => 'Video conferencing tool', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\Meet', + 'active' => true, + ] + ); } } diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -448,7 +448,7 @@ }) // Create subscriptions list - axios.get('/api/v4/skus') + axios.get('/api/v4/users/' + user_id + '/skus') .then(response => { // "merge" SKUs with user entitlement-SKUs response.data.forEach(sku => { @@ -468,7 +468,7 @@ this.skus.push(item) - if (sku.title == '2fa') { + if (sku.handler == 'auth2f') { this.has2FA = true this.sku2FA = sku.id } diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -219,11 +219,10 @@ this.discount_description = this.user.wallet.discount_description this.status = response.data.statusInfo - axios.get('/api/v4/skus') + axios.get('/api/v4/users/' + this.user_id + '/skus?type=user') .then(response => { // "merge" SKUs with user entitlement-SKUs this.skus = response.data - .filter(sku => sku.type == 'user') .map(sku => { if (sku.id in this.user.skus) { sku.enabled = true diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -67,6 +67,7 @@ Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); + Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); @@ -109,6 +110,7 @@ Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); + Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -46,6 +46,9 @@ $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); + + $betas = Sku::where('handler_class', 'like', '%\\Beta%')->pluck('id')->all(); + Entitlement::whereIn('sku_id', $betas)->delete(); } /** @@ -66,6 +69,9 @@ $wallet->discount()->dissociate(); $wallet->save(); + $betas = Sku::where('handler_class', 'like', '%\\Beta%')->pluck('id')->all(); + Entitlement::whereIn('sku_id', $betas)->delete(); + parent::tearDown(); } @@ -583,4 +589,68 @@ }); }); } + + /** + * Test beta entitlements + * + * @depends testList + */ + public function testBetaEntitlements(): void + { + $this->browse(function (Browser $browser) { + $john = User::where('email', 'john@kolab.org')->first(); + $sku = Sku::where('title', 'beta')->first(); + $john->assignSku($sku); + + $browser->visit('/user/' . $john->id) + ->on(new UserInfo()) + ->with('@skus', function ($browser) { + $browser->assertElementsCount('tbody tr', 7) + // Beta SKU + ->assertSeeIn('tbody tr:nth-child(6) td.name', 'Beta program') + ->assertSeeIn('tbody tr:nth-child(6) td.price', '0,00 CHF/month') + ->assertChecked('tbody tr:nth-child(6) td.selection input') + ->assertEnabled('tbody tr:nth-child(6) td.selection input') + ->assertTip( + 'tbody tr:nth-child(6) td.buttons button', + 'Access to beta program subscriptions' + ) + // Beta/Meet SKU + ->assertSeeIn('tbody tr:nth-child(7) td.name', 'Video chat') + ->assertSeeIn('tr:nth-child(7) td.price', '0,00 CHF/month') + ->assertNotChecked('tbody tr:nth-child(7) td.selection input') + ->assertEnabled('tbody tr:nth-child(7) td.selection input') + ->assertTip( + 'tbody tr:nth-child(7) td.buttons button', + 'Video conferencing tool' + ) + // Check Meet, Uncheck Beta, expect Meet unchecked + ->click('#sku-input-meet') + ->click('#sku-input-beta') + ->assertNotChecked('#sku-input-beta') + ->assertNotChecked('#sku-input-meet') + // Click Meet expect an alert + ->click('#sku-input-meet') + ->assertDialogOpened('Video chat requires Beta program.') + ->acceptDialog() + // Enable Meet and Beta and submit + ->click('#sku-input-beta') + ->click('#sku-input-meet'); + }) + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); + + $expected = ['beta', 'groupware', 'mailbox', 'meet', 'storage', 'storage']; + $this->assertUserEntitlements($john, $expected); + + $browser->visit('/user/' . $john->id) + ->on(new UserInfo()) + ->click('#sku-input-beta') + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); + + $expected = ['groupware', 'mailbox', 'storage', 'storage']; + $this->assertUserEntitlements($john, $expected); + }); + } } diff --git a/src/tests/Feature/Console/Sku/ListUsersTest.php b/src/tests/Feature/Console/Sku/ListUsersTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/Sku/ListUsersTest.php @@ -0,0 +1,78 @@ +deleteTestUser('sku-list-users@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('sku-list-users@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command runs + */ + public function testHandle(): void + { + // Warning: We're not using artisan() here, as this will not + // allow us to test "empty output" cases + $code = \Artisan::call('sku:list-users meet'); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame('', $output); + + $code = \Artisan::call('sku:list-users unknown'); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("Unable to find the SKU.", $output); + + $code = \Artisan::call('sku:list-users 2fa'); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("ned@kolab.org", $output); + + $code = \Artisan::call('sku:list-users mailbox'); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("jack@kolab.org\njoe@kolab.org\njohn@kolab.org\nned@kolab.org", $output); + + $code = \Artisan::call('sku:list-users domain-hosting'); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("john@kolab.org", $output); + + $sku = \App\Sku::where('title', 'meet')->first(); + $user = $this->getTestUser('sku-list-users@kolabnow.com'); + $user->assignSku($sku); + + $code = \Artisan::call('sku:list-users meet'); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame($user->email, $output); + + $user->assignSku($sku); + + $code = \Artisan::call('sku:list-users meet'); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame($user->email, $output); + } +} diff --git a/src/tests/Feature/Console/UserAssignSkuTest.php b/src/tests/Feature/Console/UserAssignSkuTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/UserAssignSkuTest.php @@ -0,0 +1,63 @@ +deleteTestUser('add-entitlement@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('add-entitlement@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command runs + */ + public function testHandle(): void + { + $sku = \App\Sku::where('title', 'meet')->first(); + $user = $this->getTestUser('add-entitlement@kolabnow.com'); + + $this->artisan('user:assign-sku unknown@unknown.org ' . $sku->id) + ->assertExitCode(1) + ->expectsOutput("Unable to find the user unknown@unknown.org."); + + $this->artisan('user:assign-sku ' . $user->email . ' unknownsku') + ->assertExitCode(1) + ->expectsOutput("Unable to find the SKU unknownsku."); + + $this->artisan('user:assign-sku ' . $user->email . ' ' . $sku->id) + ->assertExitCode(0); + + $this->assertCount(1, $user->entitlements()->where('sku_id', $sku->id)->get()); + + // Try again (also test sku by title) + $this->artisan('user:assign-sku ' . $user->email . ' ' . $sku->title) + ->assertExitCode(1) + ->expectsOutput("The entitlement already exists. Maybe try with --qty=X?"); + + $this->assertCount(1, $user->entitlements()->where('sku_id', $sku->id)->get()); + + // Try again with --qty option, to force the assignment + $this->artisan('user:assign-sku ' . $user->email . ' ' . $sku->title . ' --qty=1') + ->assertExitCode(0); + + $this->assertCount(2, $user->entitlements()->where('sku_id', $sku->id)->get()); + } +} diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php --- a/src/tests/Feature/Controller/SkusTest.php +++ b/src/tests/Feature/Controller/SkusTest.php @@ -2,12 +2,36 @@ namespace Tests\Feature\Controller; +use App\Entitlement; use App\Http\Controllers\API\V4\SkusController; use App\Sku; use Tests\TestCase; class SkusTest extends TestCase { + /** + * {@inheritDoc} + */ + public function setUp(): void + { + parent::setUp(); + + $betas = Sku::where('handler_class', 'like', '%\\Beta%')->pluck('id')->all(); + Entitlement::whereIn('sku_id', $betas)->delete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $betas = Sku::where('handler_class', 'like', '%\\Beta%')->pluck('id')->all(); + Entitlement::whereIn('sku_id', $betas)->delete(); + + parent::tearDown(); + } + + /** * Test fetching SKUs list */ @@ -25,7 +49,7 @@ $json = $response->json(); - $this->assertCount(9, $json); + $this->assertCount(7, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); @@ -41,12 +65,125 @@ } /** - * Test for SkusController::skuElement() + * Test fetching SKUs list for a user (GET /users//skus) */ - public function testSkuElement(): void + public function testUserSkus(): void { - $sku = Sku::where('title', 'storage')->first(); - $result = $this->invokeMethod(new SkusController(), 'skuElement', [$sku]); + $user = $this->getTestUser('john@kolab.org'); + + // Unauth access not allowed + $response = $this->get("api/v4/users/{$user->id}/skus"); + $response->assertStatus(401); + + $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(6, $json); + + $this->assertSkuElement('mailbox', $json[0], [ + 'prio' => 100, + 'type' => 'user', + 'handler' => 'mailbox', + 'enabled' => true, + 'readonly' => true, + ]); + + $this->assertSkuElement('storage', $json[1], [ + 'prio' => 90, + 'type' => 'user', + 'handler' => 'storage', + 'enabled' => true, + 'readonly' => true, + 'range' => [ + 'min' => 2, + 'max' => 100, + 'unit' => 'GB', + ] + ]); + + $this->assertSkuElement('groupware', $json[2], [ + 'prio' => 80, + 'type' => 'user', + 'handler' => 'groupware', + 'enabled' => false, + 'readonly' => false, + ]); + + $this->assertSkuElement('activesync', $json[3], [ + 'prio' => 70, + 'type' => 'user', + 'handler' => 'activesync', + 'enabled' => false, + 'readonly' => false, + 'required' => ['groupware'], + ]); + + $this->assertSkuElement('2fa', $json[4], [ + 'prio' => 60, + 'type' => 'user', + 'handler' => 'auth2f', + 'enabled' => false, + 'readonly' => false, + 'forbidden' => ['activesync'], + ]); + + $this->assertSkuElement('domain-hosting', $json[5], [ + 'prio' => 0, + 'type' => 'domain', + 'handler' => 'domainhosting', + 'enabled' => false, + 'readonly' => false, + ]); + + // Test filter by type + $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus?type=domain"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(1, $json); + $this->assertSame('domain', $json[0]['type']); + + // Test inclusion of beta SKUs + $sku = Sku::where('title', 'beta')->first(); + $user->assignSku($sku); + $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus?type=user"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(7, $json); + + $this->assertSkuElement('beta', $json[5], [ + 'prio' => 10, + 'type' => 'user', + 'handler' => 'beta', + 'enabled' => false, + 'readonly' => false, + ]); + + $this->assertSkuElement('meet', $json[6], [ + 'prio' => 0, + 'type' => 'user', + 'handler' => 'meet', + 'enabled' => false, + 'readonly' => false, + 'required' => ['beta'], + ]); + } + + /** + * Assert content of the SKU element in an API response + * + * @param string $sku_title The SKU title + * @param array $result The result to assert + * @param array $other Other items the SKU itself does not include + */ + protected function assertSkuElement($sku_title, $result, $other = []): void + { + $sku = Sku::where('title', $sku_title)->first(); $this->assertSame($sku->id, $result['id']); $this->assertSame($sku->title, $result['title']); @@ -56,15 +193,11 @@ $this->assertSame($sku->units_free, $result['units_free']); $this->assertSame($sku->period, $result['period']); $this->assertSame($sku->active, $result['active']); - $this->assertSame('user', $result['type']); - $this->assertSame('storage', $result['handler']); - $this->assertSame($sku->units_free, $result['range']['min']); - $this->assertSame($sku->handler_class::MAX_ITEMS, $result['range']['max']); - $this->assertSame($sku->handler_class::ITEM_UNIT, $result['range']['unit']); - $this->assertTrue($result['readonly']); - $this->assertTrue($result['enabled']); - - // Test all SKU types - $this->markTestIncomplete(); + + foreach ($other as $key => $value) { + $this->assertSame($value, $result[$key]); + } + + $this->assertCount(8 + count($other), $result); } } diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php --- a/src/tests/Feature/DomainTest.php +++ b/src/tests/Feature/DomainTest.php @@ -6,8 +6,6 @@ use App\Entitlement; use App\Sku; use App\User; -use Illuminate\Foundation\Testing\WithFaker; -use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; use Tests\TestCase; diff --git a/src/tests/Feature/SignupCodeTest.php b/src/tests/Feature/SignupCodeTest.php --- a/src/tests/Feature/SignupCodeTest.php +++ b/src/tests/Feature/SignupCodeTest.php @@ -5,8 +5,6 @@ use App\SignupCode; use Carbon\Carbon; use Tests\TestCase; -use Illuminate\Foundation\Testing\WithFaker; -use Illuminate\Foundation\Testing\RefreshDatabase; class SignupCodeTest extends TestCase { diff --git a/src/tests/Feature/VerificationCodeTest.php b/src/tests/Feature/VerificationCodeTest.php --- a/src/tests/Feature/VerificationCodeTest.php +++ b/src/tests/Feature/VerificationCodeTest.php @@ -6,8 +6,6 @@ use App\VerificationCode; use Carbon\Carbon; use Tests\TestCase; -use Illuminate\Foundation\Testing\WithFaker; -use Illuminate\Foundation\Testing\RefreshDatabase; class VerificationCodeTest extends TestCase { diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -8,8 +8,6 @@ use App\Wallet; use Carbon\Carbon; use Tests\TestCase; -use Illuminate\Foundation\Testing\WithFaker; -use Illuminate\Foundation\Testing\RefreshDatabase; class WalletTest extends TestCase {