diff --git a/bin/phpunit-fast b/bin/phpunit-fast index 1e59d978..e2f0e4d9 100755 --- a/bin/phpunit-fast +++ b/bin/phpunit-fast @@ -1,12 +1,13 @@ #!/bin/bash cwd=$(dirname $0) pushd ${cwd}/../src/ php vendor/bin/phpunit \ + --no-coverage \ --stop-on-defect \ --stop-on-error \ --stop-on-failure $* popd diff --git a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php index df0aaeb3..e2d3b4bc 100644 --- a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php @@ -1,55 +1,104 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::find($owner)) { foreach ($owner->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; $result->push($domain); } } $result = $result->sortBy('namespace')->values(); } } elseif (!empty($search)) { if ($domain = Domain::where('namespace', $search)->first()) { $result->push($domain); } } // Process the result $result = $result->map(function ($domain) { $data = $domain->toArray(); $data = array_merge($data, self::domainStatuses($domain)); return $data; }); $result = [ 'list' => $result, 'count' => count($result), 'message' => \trans('app.search-foundxdomains', ['x' => count($result)]), ]; return response()->json($result); } + + /** + * Suspend the domain + * + * @param \Illuminate\Http\Request $request The API request. + * @params string $id Domain identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function suspend(Request $request, $id) + { + $domain = Domain::find($id); + + if (empty($domain) || $domain->isPublic()) { + return $this->errorResponse(404); + } + + $domain->suspend(); + + return response()->json([ + 'status' => 'success', + 'message' => __('app.domain-suspend-success'), + ]); + } + + /** + * Un-Suspend the domain + * + * @param \Illuminate\Http\Request $request The API request. + * @params string $id Domain identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function unsuspend(Request $request, $id) + { + $domain = Domain::find($id); + + if (empty($domain) || $domain->isPublic()) { + return $this->errorResponse(404); + } + + $domain->unsuspend(); + + return response()->json([ + 'status' => 'success', + 'message' => __('app.domain-unsuspend-success'), + ]); + } } diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php index d1c0ab90..76974430 100644 --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -1,54 +1,56 @@ 'The auto-payment has been removed.', 'mandate-update-success' => 'The auto-payment has been updated.', 'planbutton' => 'Choose :plan', 'process-user-new' => 'Registering a user...', 'process-user-ldap-ready' => 'Creating a user...', 'process-user-imap-ready' => 'Creating a mailbox...', 'process-domain-new' => 'Registering a custom domain...', 'process-domain-ldap-ready' => 'Creating a custom domain...', 'process-domain-verified' => 'Verifying a custom domain...', 'process-domain-confirmed' => 'Verifying an ownership of a custom domain...', 'process-success' => 'Setup process finished successfully.', 'process-error-user-ldap-ready' => 'Failed to create a user.', 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.', 'process-error-domain-ldap-ready' => 'Failed to create a domain.', 'process-error-domain-verified' => 'Failed to verify a domain.', 'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.', 'domain-verify-success' => 'Domain verified successfully.', 'domain-verify-error' => 'Domain ownership verification failed.', + 'domain-suspend-success' => 'Domain suspended successfully.', + 'domain-unsuspend-success' => 'Domain unsuspended successfully.', 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', 'user-suspend-success' => 'User suspended successfully.', 'user-unsuspend-success' => 'User unsuspended successfully.', 'user-reset-2fa-success' => '2-Factor authentication reset successfully.', 'search-foundxdomains' => ':x domains have been found.', 'search-foundxusers' => ':x user accounts have been found.', 'wallet-award-success' => 'The bonus has been added to the wallet successfully.', 'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.', 'wallet-update-success' => 'User wallet updated successfully.', 'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).', 'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.', 'wallet-notice-today' => 'You will run out of credit today, top up your balance now.', 'wallet-notice-trial' => 'You are in your free trial period.', 'wallet-notice-trial-end' => 'Your free trial is about to end, top up to continue.', ]; diff --git a/src/resources/vue/Admin/Domain.vue b/src/resources/vue/Admin/Domain.vue index cfa0159c..ff9af51b 100644 --- a/src/resources/vue/Admin/Domain.vue +++ b/src/resources/vue/Admin/Domain.vue @@ -1,69 +1,91 @@ diff --git a/src/routes/api.php b/src/routes/api.php index 569b5136..9f3f8cb6 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,117 +1,119 @@ 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/init', 'API\SignupController@init'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::apiResource('entitlements', API\V4\EntitlementsController::class); 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}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); } ); Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/webhooks', ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); } ); Route::group( [ 'domain' => 'admin.' . \config('app.domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\Admin\DomainsController@confirm'); + Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); + Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('entitlements', API\V4\Admin\EntitlementsController::class); Route::apiResource('packages', API\V4\Admin\PackagesController::class); 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::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); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Admin\DiscountsController::class); } ); diff --git a/src/tests/Browser/Admin/DomainTest.php b/src/tests/Browser/Admin/DomainTest.php index e6055a5d..109c55bd 100644 --- a/src/tests/Browser/Admin/DomainTest.php +++ b/src/tests/Browser/Admin/DomainTest.php @@ -1,89 +1,119 @@ browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $browser->visit('/domain/' . $domain->id)->on(new Home()); }); } /** * Test domain info page */ public function testDomainInfo(): void { $this->browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $domain_page = new DomainPage($domain->id); $john = $this->getTestUser('john@kolab.org'); $user_page = new UserPage($john->id); // Goto the domain page $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true) ->on(new Dashboard()) ->visit($user_page) ->on($user_page) ->click('@nav #tab-domains') ->pause(1000) ->click('@user-domains table tbody tr:first-child td a'); $browser->on($domain_page) ->assertSeeIn('@domain-info .card-title', 'kolab.org') ->with('@domain-info form', function (Browser $browser) use ($domain) { $browser->assertElementsCount('.row', 2) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)') ->assertSeeIn('.row:nth-child(1) #domainid', "{$domain->id} ({$domain->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 1); // Assert Configuration tab $browser->assertSeeIn('@nav #tab-config', 'Configuration') ->with('@domain-config', function (Browser $browser) { $browser->assertSeeIn('pre#dns-verify', 'kolab-verify.kolab.org.') ->assertSeeIn('pre#dns-config', 'kolab.org.'); }); }); } + + /** + * Test suspending/unsuspending a domain + * + * @depends testDomainInfo + */ + public function testSuspendAndUnsuspend(): void + { + $this->browse(function (Browser $browser) { + $domain = $this->getTestDomain('domainscontroller.com', [ + 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE + | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED + | Domain::STATUS_VERIFIED, + 'type' => Domain::TYPE_EXTERNAL, + ]); + + $browser->visit(new DomainPage($domain->id)) + ->assertVisible('@domain-info #button-suspend') + ->assertMissing('@domain-info #button-unsuspend') + ->click('@domain-info #button-suspend') + ->assertToast(Toast::TYPE_SUCCESS, 'Domain suspended successfully.') + ->assertSeeIn('@domain-info #status span.text-warning', 'Suspended') + ->assertMissing('@domain-info #button-suspend') + ->click('@domain-info #button-unsuspend') + ->assertToast(Toast::TYPE_SUCCESS, 'Domain unsuspended successfully.') + ->assertSeeIn('@domain-info #status span.text-success', 'Active') + ->assertVisible('@domain-info #button-suspend') + ->assertMissing('@domain-info #button-unsuspend'); + }); + } } diff --git a/src/tests/Feature/Controller/Admin/DomainsTest.php b/src/tests/Feature/Controller/Admin/DomainsTest.php index 32210530..5dcaa7a8 100644 --- a/src/tests/Feature/Controller/Admin/DomainsTest.php +++ b/src/tests/Feature/Controller/Admin/DomainsTest.php @@ -1,87 +1,159 @@ deleteTestDomain('domainscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { + $this->deleteTestDomain('domainscontroller.com'); + parent::tearDown(); } /** * Test domains searching (/api/v4/domains) */ public function testIndex(): void { $john = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Non-admin user $response = $this->actingAs($john)->get("api/v4/domains"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($admin)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search with no matches expected $response = $this->actingAs($admin)->get("api/v4/domains?search=abcd12.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by a domain name $response = $this->actingAs($admin)->get("api/v4/domains?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner $response = $this->actingAs($admin)->get("api/v4/domains?owner={$john->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner (Ned is a controller on John's wallets, // here we expect only domains assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($admin)->get("api/v4/domains?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); } + + /** + * Test domain suspending (POST /api/v4/domains//suspend) + */ + public function testSuspend(): void + { + Queue::fake(); // disable jobs + + $domain = $this->getTestDomain('domainscontroller.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_EXTERNAL, + ]); + $user = $this->getTestUser('test@domainscontroller.com'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + + // Test unauthorized access to admin API + $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/suspend", []); + $response->assertStatus(403); + + $this->assertFalse($domain->fresh()->isSuspended()); + + // Test suspending the user + $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/suspend", []); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("Domain suspended successfully.", $json['message']); + $this->assertCount(2, $json); + + $this->assertTrue($domain->fresh()->isSuspended()); + } + + /** + * Test user un-suspending (POST /api/v4/users//unsuspend) + */ + public function testUnsuspend(): void + { + Queue::fake(); // disable jobs + + $domain = $this->getTestDomain('domainscontroller.com', [ + 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED, + 'type' => Domain::TYPE_EXTERNAL, + ]); + $user = $this->getTestUser('test@domainscontroller.com'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + + // Test unauthorized access to admin API + $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/unsuspend", []); + $response->assertStatus(403); + + $this->assertTrue($domain->fresh()->isSuspended()); + + // Test suspending the user + $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/unsuspend", []); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("Domain unsuspended successfully.", $json['message']); + $this->assertCount(2, $json); + + $this->assertFalse($domain->fresh()->isSuspended()); + } }