diff --git a/src/app/Handlers/Groupware.php b/src/app/Handlers/Activesync.php similarity index 73% copy from src/app/Handlers/Groupware.php copy to src/app/Handlers/Activesync.php index 9140595c..38b7a49b 100644 --- a/src/app/Handlers/Groupware.php +++ b/src/app/Handlers/Activesync.php @@ -1,21 +1,21 @@ sku->active) { \Log::error("Sku not active"); return false; } return true; } } diff --git a/src/app/Handlers/Groupware.php b/src/app/Handlers/Auth2F.php similarity index 74% copy from src/app/Handlers/Groupware.php copy to src/app/Handlers/Auth2F.php index 9140595c..cfdf792c 100644 --- a/src/app/Handlers/Groupware.php +++ b/src/app/Handlers/Auth2F.php @@ -1,21 +1,21 @@ sku->active) { \Log::error("Sku not active"); return false; } return true; } } diff --git a/src/app/Handlers/Base.php b/src/app/Handlers/Base.php index b4040dbb..aad97005 100644 --- a/src/app/Handlers/Base.php +++ b/src/app/Handlers/Base.php @@ -1,29 +1,40 @@ 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. + * + * @return int + */ + public static function priority(): int + { + return 80; + } } diff --git a/src/app/Handlers/Mailbox.php b/src/app/Handlers/Mailbox.php index 3ec6cf63..f80f592f 100644 --- a/src/app/Handlers/Mailbox.php +++ b/src/app/Handlers/Mailbox.php @@ -1,39 +1,50 @@ 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(); foreach ($domains as $_domain) { if ($domain == $_domain->namespace) { return true; } } \Log::info("Domain not for user"); */ return true; } + + /** + * 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 100; + } } diff --git a/src/app/Handlers/Storage.php b/src/app/Handlers/Storage.php index f866e461..880c1255 100644 --- a/src/app/Handlers/Storage.php +++ b/src/app/Handlers/Storage.php @@ -1,21 +1,37 @@ sku->active) { + \Log::error("Sku not active"); + return false; + } + // TODO: The storage can not be modified to below what is already consumed. return true; } + + /** + * 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 90; + } } diff --git a/src/app/Http/Controllers/API/SkusController.php b/src/app/Http/Controllers/API/SkusController.php index 5ad2951b..33366eea 100644 --- a/src/app/Http/Controllers/API/SkusController.php +++ b/src/app/Http/Controllers/API/SkusController.php @@ -1,168 +1,181 @@ errorResponse(404); } /** * Remove the specified sku from storage. * * @param int $id SKU identifier * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { // TODO return $this->errorResponse(404); } /** * Show the form for editing the specified sku. * * @param int $id SKU identifier * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { // TODO return $this->errorResponse(404); } /** * Display a listing of the sku. * * @return \Illuminate\Http\JsonResponse */ public function index() { $response = []; - $skus = Sku::select()->orderBy('title')->get(); + $skus = Sku::select()->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 foreach ($skus as $sku) { if ($data = $this->skuElement($sku)) { $response[] = $data; } } + usort($response, function ($a, $b) { + return ($b['prio'] <=> $a['prio']); + }); + return response()->json($response); } /** * Store a newly created sku in storage. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { // TODO return $this->errorResponse(404); } /** * Display the specified sku. * * @param int $id SKU identifier * * @return \Illuminate\Http\JsonResponse */ public function show($id) { // TODO return $this->errorResponse(404); } /** * Update the specified sku in storage. * * @param \Illuminate\Http\Request $request Request object * @param int $id SKU identifier * * @return \Illuminate\Http\JsonResponse */ public function update(Request $request, $id) { // TODO return $this->errorResponse(404); } /** * Convert SKU information to metadata used by UI to * display the form control * * @param \App\Sku $sku SKU object * * @return array|null Metadata */ protected function skuElement($sku): ?array { $type = $sku->handler_class::entitleableClass(); // ignore incomplete handlers if (!$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; } return $data; } } diff --git a/src/database/seeds/SkuSeeder.php b/src/database/seeds/SkuSeeder.php index bab3d943..287fd205 100644 --- a/src/database/seeds/SkuSeeder.php +++ b/src/database/seeds/SkuSeeder.php @@ -1,127 +1,153 @@ 'mailbox', 'name' => 'User Mailbox', 'description' => 'Just a mailbox', 'cost' => 444, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Mailbox', 'active' => true, ] ); Sku::create( [ 'title' => 'domain', 'name' => 'Hosted Domain', 'description' => 'Somewhere to place a mailbox', 'cost' => 100, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Domain', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-registration', 'name' => 'Domain Registration', 'description' => 'Register a domain with us', 'cost' => 101, 'period' => 'yearly', 'handler_class' => 'App\Handlers\DomainRegistration', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-hosting', 'name' => 'External Domain', 'description' => 'Host a domain that is externally registered', 'cost' => 100, 'units_free' => 1, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainHosting', 'active' => true, ] ); Sku::create( [ 'title' => 'domain-relay', 'name' => 'Domain Relay', 'description' => 'A domain you host at home, for which we relay email', 'cost' => 103, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainRelay', 'active' => false, ] ); Sku::create( [ 'title' => 'storage', 'name' => 'Storage Quota', 'description' => 'Some wiggle room', 'cost' => 25, 'units_free' => 2, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Storage', 'active' => true, ] ); Sku::create( [ 'title' => 'groupware', 'name' => 'Groupware Features', 'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.', 'cost' => 555, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Groupware', 'active' => true, ] ); Sku::create( [ 'title' => 'resource', 'name' => 'Resource', 'description' => 'Reservation taker', 'cost' => 101, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Resource', 'active' => false, ] ); Sku::create( [ 'title' => 'shared_folder', 'name' => 'Shared Folder', 'description' => 'A shared folder', 'cost' => 89, 'period' => 'monthly', 'handler_class' => 'App\Handlers\SharedFolder', 'active' => false, ] ); + + Sku::create( + [ + 'title' => '2fa', + 'name' => '2-Factor Authentication', + 'description' => 'Two factor authentication for webmail and administration panel', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Auth2F', + 'active' => true, + ] + ); + + Sku::create( + [ + 'title' => 'activesync', + 'name' => 'Activesync', + 'description' => 'Mobile synchronization', + 'cost' => 100, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Activesync', + 'active' => true, + ] + ); } } diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue index 8df5dd38..025d779d 100644 --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -1,336 +1,401 @@ diff --git a/src/tests/Browser.php b/src/tests/Browser.php index d45bec0a..12b2dafe 100644 --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -1,165 +1,191 @@ elements($selector); $count = count($elements); if ($visible) { foreach ($elements as $element) { if (!$element->isDisplayed()) { $count--; } } } Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $count"); return $this; } /** * Assert Tip element content */ public function assertTip($selector, $content) { return $this->click($selector) ->withinBody(function ($browser) use ($content) { $browser->assertSeeIn('div.tooltip .tooltip-inner', $content); }) ->click($selector); } /** * Assert specified error page is displayed. */ public function assertErrorPage(int $error_code) { $this->with(new Error($error_code), function ($browser) { // empty, assertions will be made by the Error component itself }); return $this; } /** * Assert that the given element has specified class assigned. */ public function assertHasClass($selector, $class_name) { $element = $this->resolver->findOrFail($selector); $classes = explode(' ', (string) $element->getAttribute('class')); Assert::assertContains($class_name, $classes, "[$selector] has no class '{$class_name}'"); return $this; } + /** + * Assert that the given element is readonly + */ + public function assertReadonly($selector) + { + $element = $this->resolver->findOrFail($selector); + $value = $element->getAttribute('readonly'); + + Assert::assertTrue($value == 'true', "Element [$selector] is not readonly"); + + return $this; + } + + /** + * Assert that the given element is not readonly + */ + public function assertNotReadonly($selector) + { + $element = $this->resolver->findOrFail($selector); + $value = $element->getAttribute('readonly'); + + Assert::assertTrue($value != 'true', "Element [$selector] is not readonly"); + + return $this; + } + /** * Assert that the given element contains specified text, * no matter it's displayed or not. */ public function assertText($selector, $text) { $element = $this->resolver->findOrFail($selector); Assert::assertTrue(strpos($element->getText(), $text) !== false, "No expected text in [$selector]"); return $this; } /** * Remove all toast messages */ public function clearToasts() { $this->script("jQuery('.toast-container > *').remove()"); return $this; } /** * Check if in Phone mode */ public static function isPhone() { return getenv('TESTS_MODE') == 'phone'; } /** * Check if in Tablet mode */ public static function isTablet() { return getenv('TESTS_MODE') == 'tablet'; } /** * Check if in Desktop mode */ public static function isDesktop() { return !self::isPhone() && !self::isTablet(); } /** * Returns content of a downloaded file */ public function readDownloadedFile($filename) { $filename = __DIR__ . "/Browser/downloads/$filename"; // Give the browser a chance to finish download if (!file_exists($filename)) { sleep(2); } Assert::assertFileExists($filename); return file_get_contents($filename); } /** * Removes downloaded file */ public function removeDownloadedFile($filename) { @unlink(__DIR__ . "/Browser/downloads/$filename"); return $this; } /** * Execute code within body context. * Useful to execute code that selects elements outside of a component context */ public function withinBody($callback) { if ($this->resolver->prefix != 'body') { $orig_prefix = $this->resolver->prefix; $this->resolver->prefix = 'body'; } call_user_func($callback, $this); if (isset($orig_prefix)) { $this->resolver->prefix = $orig_prefix; } return $this; } } diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php index 42c88f81..c5189ca6 100644 --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -1,500 +1,513 @@ 'John', 'last_name' => 'Doe', ]; /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); - Sku::where('title', 'test')->delete(); - $storage = Sku::where('title', 'storage')->first(); - Entitlement::where([ - ['sku_id', $storage->id], - ['entitleable_id', $john->id], - ['cost', 25] - ])->delete(); + Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); - Sku::where('title', 'test')->delete(); - $storage = Sku::where('title', 'storage')->first(); - Entitlement::where([ - ['sku_id', $storage->id], - ['entitleable_id', $john->id], - ['cost', 25] - ])->delete(); + Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete(); parent::tearDown(); } /** * Test user info page (unauthenticated) */ public function testInfoUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $user = User::where('email', 'john@kolab.org')->first(); $browser->visit('/user/' . $user->id)->on(new Home()); }); } /** * Test users list page (unauthenticated) */ public function testListUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/users')->on(new Home()); }); } /** * Test users list page */ public function testList(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-users', 'User accounts') ->click('@links .link-users') ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 3) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'ned@kolab.org') ->assertVisible('tbody tr:nth-child(1) button.button-delete') ->assertVisible('tbody tr:nth-child(2) button.button-delete') ->assertVisible('tbody tr:nth-child(3) button.button-delete'); }); }); } /** * Test user account editing page (not profile page) * * @depends testList */ public function testInfo(): void { - Sku::create([ - 'title' => 'test', - 'name' => 'Test SKU', - 'description' => 'The SKU for testing', - 'cost' => 666, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Groupware', - 'active' => true, - ]); - $this->browse(function (Browser $browser) { $browser->on(new UserList()) ->click('@table tr:nth-child(2) a') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') ->with('@form', function (Browser $browser) { // Assert form content $browser->assertSeeIn('div.row:nth-child(1) label', 'Status') ->assertSeeIn('div.row:nth-child(1) #status', 'Active') ->assertFocused('div.row:nth-child(2) input') ->assertSeeIn('div.row:nth-child(2) label', 'First name') ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name']) ->assertSeeIn('div.row:nth-child(3) label', 'Last name') ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name']) ->assertSeeIn('div.row:nth-child(4) label', 'Email') ->assertValue('div.row:nth-child(4) input[type=text]', 'john@kolab.org') ->assertDisabled('div.row:nth-child(4) input[type=text]') ->assertSeeIn('div.row:nth-child(5) label', 'Email aliases') ->assertVisible('div.row:nth-child(5) .listinput-widget') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['john.doe@kolab.org']) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(6) label', 'Password') ->assertValue('div.row:nth-child(6) input[type=password]', '') ->assertSeeIn('div.row:nth-child(7) label', 'Confirm password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('button[type=submit]', 'Submit'); // Clear some fields and submit $browser->type('#first_name', '') ->type('#last_name', '') ->click('button[type=submit]'); }) ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { $browser->assertToastTitle('') ->assertToastMessage('User data updated successfully') ->closeToast(); }); // Test error handling (password) $browser->with('@form', function (Browser $browser) { $browser->type('#password', 'aaaaaa') ->type('#password_confirmation', '') ->click('button[type=submit]') ->waitFor('#password + .invalid-feedback') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.') ->assertFocused('#password'); }) ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Form validation error') ->closeToast(); }); // TODO: Test password change // Test form error handling (aliases) $browser->with('@form', function (Browser $browser) { // TODO: For some reason, clearing the input value // with ->type('#password', '') does not work, maybe some dusk/vue intricacy // For now we just use the default password $browser->type('#password', 'simple123') ->type('#password_confirmation', 'simple123') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]'); }) ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Form validation error') ->closeToast(); }) ->with('@form', function (Browser $browser) { $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(2, 'The specified alias is invalid.', false); }); }); // Test adding aliases $browser->with('@form', function (Browser $browser) { $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(2) ->addListEntry('john.test@kolab.org'); }) ->click('button[type=submit]'); }) ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { $browser->assertToastTitle('') ->assertToastMessage('User data updated successfully') ->closeToast(); }); $john = User::where('email', 'john@kolab.org')->first(); $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first(); $this->assertTrue(!empty($alias)); // Test subscriptions $browser->with('@form', function (Browser $browser) { $browser->assertSeeIn('div.row:nth-child(8) label', 'Subscriptions') ->assertVisible('@skus.row:nth-child(8)') ->with('@skus', function ($browser) { - $browser->assertElementsCount('tbody tr', 4) - // groupware SKU - ->assertSeeIn('tbody tr:nth-child(1) td.name', 'Groupware Features') - ->assertSeeIn('tbody tr:nth-child(1) td.price', '5,55 CHF/month') + $browser->assertElementsCount('tbody tr', 5) + // Mailbox SKU + ->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox') + ->assertSeeIn('tbody tr:nth-child(1) td.price', '4,44 CHF/month') ->assertChecked('tbody tr:nth-child(1) td.selection input') - ->assertEnabled('tbody tr:nth-child(1) td.selection input') + ->assertDisabled('tbody tr:nth-child(1) td.selection input') ->assertTip( 'tbody tr:nth-child(1) td.buttons button', - 'Groupware functions like Calendar, Tasks, Notes, etc.' + 'Just a mailbox' ) - // Mailbox SKU - ->assertSeeIn('tbody tr:nth-child(2) td.name', 'User Mailbox') - ->assertSeeIn('tbody tr:nth-child(2) td.price', '4,44 CHF/month') + // Storage SKU + ->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota') + ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month') ->assertChecked('tbody tr:nth-child(2) td.selection input') ->assertDisabled('tbody tr:nth-child(2) td.selection input') ->assertTip( 'tbody tr:nth-child(2) td.buttons button', - 'Just a mailbox' + 'Some wiggle room' ) - // Storage SKU - ->assertSeeIn('tbody tr:nth-child(3) td.name', 'Storage Quota') - ->assertSeeIn('tr:nth-child(3) td.price', '0,00 CHF/month') + ->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) { + $browser->assertQuotaValue(2)->setQuotaValue(3); + }) + ->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month') + // groupware SKU + ->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features') + ->assertSeeIn('tbody tr:nth-child(3) td.price', '5,55 CHF/month') ->assertChecked('tbody tr:nth-child(3) td.selection input') - ->assertDisabled('tbody tr:nth-child(3) td.selection input') + ->assertEnabled('tbody tr:nth-child(3) td.selection input') ->assertTip( 'tbody tr:nth-child(3) td.buttons button', - 'Some wiggle room' + 'Groupware functions like Calendar, Tasks, Notes, etc.' ) - ->with(new QuotaInput('tbody tr:nth-child(3) .range-input'), function ($browser) { - $browser->assertQuotaValue(2)->setQuotaValue(3); - }) - ->assertSeeIn('tr:nth-child(3) td.price', '0,25 CHF/month') - // Test SKU - ->assertSeeIn('tbody tr:nth-child(4) td.name', 'Test SKU') - ->assertSeeIn('tbody tr:nth-child(4) td.price', '6,66 CHF/month') + // 2FA SKU + ->assertSeeIn('tbody tr:nth-child(4) td.name', '2-Factor Authentication') + ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(4) td.selection input') ->assertEnabled('tbody tr:nth-child(4) td.selection input') ->assertTip( 'tbody tr:nth-child(4) td.buttons button', - 'The SKU for testing' + 'Two factor authentication for webmail and administration panel' ) - ->click('tbody tr:nth-child(4) td.selection input'); + // ActiveSync SKU + ->assertSeeIn('tbody tr:nth-child(5) td.name', 'Activesync') + ->assertSeeIn('tbody tr:nth-child(5) td.price', '1,00 CHF/month') + ->assertNotChecked('tbody tr:nth-child(5) td.selection input') + ->assertEnabled('tbody tr:nth-child(5) td.selection input') + ->assertTip( + 'tbody tr:nth-child(5) td.buttons button', + 'Mobile synchronization' + ) + ->click('tbody tr:nth-child(5) td.selection input'); }) ->click('button[type=submit]'); }) ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { $browser->assertToastTitle('') ->assertToastMessage('User data updated successfully') ->closeToast(); }); - $this->assertUserEntitlements($john, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'test']); + $expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage']; + $this->assertUserEntitlements($john, $expected); + + // Test subscriptions interaction + $browser->with('@form', function (Browser $browser) { + $browser->with('@skus', function ($browser) { + // Uncheck 'groupware', expect activesync unchecked + $browser->click('@sku-input-groupware') + ->assertNotChecked('@sku-input-groupware') + ->assertNotChecked('@sku-input-activesync') + ->assertEnabled('@sku-input-activesync') + ->assertNotReadonly('@sku-input-activesync') + // Check 'activesync', expect an alert + ->click('@sku-input-activesync') + ->assertDialogOpened('Activesync requires Groupware Features.') + ->acceptDialog() + ->assertNotChecked('@sku-input-activesync') + // Check '2FA', expect 'activesync' unchecked and readonly + ->click('@sku-input-2fa') + ->assertChecked('@sku-input-2fa') + ->assertNotChecked('@sku-input-activesync') + ->assertReadonly('@sku-input-activesync') + // Uncheck '2FA' + ->click('@sku-input-2fa') + ->assertNotChecked('@sku-input-2fa') + ->assertNotReadonly('@sku-input-activesync'); + }); + }); }); } /** * Test user adding page * * @depends testList */ public function testNewUser(): void { $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->assertSeeIn('button.create-user', 'Create user') ->click('button.create-user') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'New user account') ->with('@form', function (Browser $browser) { // Assert form content $browser->assertFocused('div.row:nth-child(1) input') ->assertSeeIn('div.row:nth-child(1) label', 'First name') ->assertValue('div.row:nth-child(1) input[type=text]', '') ->assertSeeIn('div.row:nth-child(2) label', 'Last name') ->assertValue('div.row:nth-child(2) input[type=text]', '') ->assertSeeIn('div.row:nth-child(3) label', 'Email') ->assertValue('div.row:nth-child(3) input[type=text]', '') ->assertEnabled('div.row:nth-child(3) input[type=text]') ->assertSeeIn('div.row:nth-child(4) label', 'Email aliases') ->assertVisible('div.row:nth-child(4) .listinput-widget') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(5) label', 'Password') ->assertValue('div.row:nth-child(5) input[type=password]', '') ->assertSeeIn('div.row:nth-child(6) label', 'Confirm password') ->assertValue('div.row:nth-child(6) input[type=password]', '') ->assertSeeIn('div.row:nth-child(7) label', 'Package') // assert packages list widget, select "Lite Account" ->with('@packages', function ($browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account') ->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account') ->assertChecked('tbody tr:nth-child(1) input') ->click('tbody tr:nth-child(2) input') ->assertNotChecked('tbody tr:nth-child(1) input') ->assertChecked('tbody tr:nth-child(2) input'); }) ->assertSeeIn('button[type=submit]', 'Submit'); // Test browser-side required fields and error handling $browser->click('button[type=submit]') ->assertFocused('#email') ->type('#email', 'invalid email') ->click('button[type=submit]') ->assertFocused('#password') ->type('#password', 'simple123') ->click('button[type=submit]') ->assertFocused('#password_confirmation') ->type('#password_confirmation', 'simple') ->click('button[type=submit]'); }) ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Form validation error') ->closeToast(); }) ->with('@form', function (Browser $browser) { $browser->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.'); }); // Test form error handling (aliases) $browser->with('@form', function (Browser $browser) { $browser->type('#email', 'julia.roberts@kolab.org') ->type('#password_confirmation', 'simple123') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]'); }) ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Form validation error') ->closeToast(); }) ->with('@form', function (Browser $browser) { $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(1, 'The specified alias is invalid.', false); }); }); // Successful account creation $browser->with('@form', function (Browser $browser) { $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(1) ->addListEntry('julia.roberts2@kolab.org'); }) ->click('button[type=submit]'); }) ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { $browser->assertToastTitle('') ->assertToastMessage('User created successfully') ->closeToast(); }) // check redirection to users list ->waitForLocation('/users') ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(3) a', 'julia.roberts@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first(); $this->assertTrue(!empty($alias)); $this->assertUserEntitlements($julia, ['mailbox', 'storage', 'storage']); }); } /** * Test user delete * * @depends testNewUser */ public function testDeleteUser(): void { // First create a new user $john = $this->getTestUser('john@kolab.org'); $julia = $this->getTestUser('julia.roberts@kolab.org'); $package_kolab = \App\Package::where('title', 'kolab')->first(); $john->assignPackage($package_kolab, $julia); // Test deleting non-controller user $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(3) a', 'julia.roberts@kolab.org') ->click('tbody tr:nth-child(3) button.button-delete'); }) ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org') ->assertFocused('@button-cancel') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Delete') ->click('@button-cancel'); }) ->whenAvailable('@table', function (Browser $browser) { $browser->click('tbody tr:nth-child(3) button.button-delete'); }) ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->click('@button-action'); }) ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { $browser->assertToastTitle('') ->assertToastMessage('User deleted successfully') ->closeToast(); }) ->with('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 3) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'ned@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $this->assertTrue(empty($julia)); // Test clicking Delete on the controller record redirects to /profile/delete $browser ->with('@table', function (Browser $browser) { $browser->click('tbody tr:nth-child(2) button.button-delete'); }) ->waitForLocation('/profile/delete'); }); // Test that non-controller user cannot see/delete himself on the users list // Note: Access to /profile/delete page is tested in UserProfileTest.php $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 0); }); }); // Test that controller user (Ned) can see/delete all the users ??? $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('ned@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 3) ->assertElementsCount('tbody button.button-delete', 3); }); // TODO: Test the delete action in details }); // TODO: Test what happens with the logged in user session after he's been deleted by another user } } diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php index b2f81811..03d8fa1f 100644 --- a/src/tests/Feature/Controller/SkusTest.php +++ b/src/tests/Feature/Controller/SkusTest.php @@ -1,69 +1,70 @@ get("api/v4/skus"); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); - $domain_sku = Sku::where('title', 'domain')->first(); + $sku = Sku::where('title', 'mailbox')->first(); $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(7, $json); + $this->assertCount(9, $json); - $this->assertSame($domain_sku->id, $json[0]['id']); - $this->assertSame($domain_sku->title, $json[0]['title']); - $this->assertSame($domain_sku->name, $json[0]['name']); - $this->assertSame($domain_sku->description, $json[0]['description']); - $this->assertSame($domain_sku->cost, $json[0]['cost']); - $this->assertSame($domain_sku->units_free, $json[0]['units_free']); - $this->assertSame($domain_sku->period, $json[0]['period']); - $this->assertSame($domain_sku->active, $json[0]['active']); - $this->assertSame('domain', $json[0]['type']); - $this->assertSame('domain', $json[0]['handler']); + $this->assertSame(100, $json[0]['prio']); + $this->assertSame($sku->id, $json[0]['id']); + $this->assertSame($sku->title, $json[0]['title']); + $this->assertSame($sku->name, $json[0]['name']); + $this->assertSame($sku->description, $json[0]['description']); + $this->assertSame($sku->cost, $json[0]['cost']); + $this->assertSame($sku->units_free, $json[0]['units_free']); + $this->assertSame($sku->period, $json[0]['period']); + $this->assertSame($sku->active, $json[0]['active']); + $this->assertSame('user', $json[0]['type']); + $this->assertSame('mailbox', $json[0]['handler']); } /** * Test for SkusController::skuElement() */ public function testSkuElement(): void { $sku = Sku::where('title', 'storage')->first(); $result = $this->invokeMethod(new SkusController(), 'skuElement', [$sku]); $this->assertSame($sku->id, $result['id']); $this->assertSame($sku->title, $result['title']); $this->assertSame($sku->name, $result['name']); $this->assertSame($sku->description, $result['description']); $this->assertSame($sku->cost, $result['cost']); $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(); } }