diff --git a/src/app/Discount.php b/src/app/Discount.php index 32c6da87..8c54257f 100644 --- a/src/app/Discount.php +++ b/src/app/Discount.php @@ -1,65 +1,72 @@ 'integer', ]; protected $fillable = [ 'active', 'code', 'description', 'discount', + 'tenant_id', ]; /** @var array Translatable properties */ public $translatable = [ 'description', ]; /** * Discount value mutator * * @throws \Exception */ public function setDiscountAttribute($discount) { $discount = (int) $discount; if ($discount < 0) { \Log::warning("Expecting a discount rate >= 0"); $discount = 0; } if ($discount > 100) { \Log::warning("Expecting a discount rate <= 100"); $discount = 100; } $this->attributes['discount'] = $discount; } /** * List of wallets with this discount assigned. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php b/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php index bba9d526..31d264ec 100644 --- a/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php @@ -1,49 +1,45 @@ user(); - $tenantId = $user->tenant->id; - $discounts = []; - Discount::where([ - 'active' => true, - 'tenant_id' => $tenantId - ]) + $discounts = $user->tenant->discounts() + ->where('active', true) ->orderBy('discount') ->get() - ->map(function ($discount) use (&$discounts) { + ->map(function ($discount) { $label = $discount->discount . '% - ' . $discount->description; if ($discount->code) { $label .= " [{$discount->code}]"; } - $discounts[] = [ + return [ 'id' => $discount->id, 'discount' => $discount->discount, 'code' => $discount->code, 'description' => $discount->description, 'label' => $label, ]; }); return response()->json([ 'status' => 'success', 'list' => $discounts, 'count' => count($discounts), ]); } } diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php index ff6dee44..179a1de5 100644 --- a/src/app/Http/Kernel.php +++ b/src/app/Http/Kernel.php @@ -1,89 +1,89 @@ [ // \App\Http\Middleware\EncryptCookies::class, // \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, // \Illuminate\Session\Middleware\StartSession::class, // \Illuminate\Session\Middleware\AuthenticateSession::class, // \Illuminate\View\Middleware\ShareErrorsFromSession::class, // \App\Http\Middleware\VerifyCsrfToken::class, // \Illuminate\Routing\Middleware\SubstituteBindings::class, ], 'api' => [ 'throttle:120,1', 'bindings', ], ]; /** * The application's route middleware. * * These middleware may be assigned to groups or used individually. * * @var array */ protected $routeMiddleware = [ 'admin' => \App\Http\Middleware\AuthenticateAdmin::class, 'auth' => \App\Http\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 'can' => \Illuminate\Auth\Middleware\Authorize::class, 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'reseller' => \App\Http\Middleware\AuthenticateReseller::class, 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, ]; /** * The priority-sorted list of middleware. * * This forces non-global middleware to always be in the given order. * * @var array */ protected $middlewarePriority = [ \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\AuthenticateAdmin::class, + \App\Http\Middleware\AuthenticateReseller::class, \App\Http\Middleware\Authenticate::class, \Illuminate\Session\Middleware\AuthenticateSession::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Auth\Middleware\Authorize::class, - \App\Http\Middleware\AuthenticateAdmin::class, ]; } diff --git a/src/app/Observers/DiscountObserver.php b/src/app/Observers/DiscountObserver.php index 1f7a1999..261044cf 100644 --- a/src/app/Observers/DiscountObserver.php +++ b/src/app/Observers/DiscountObserver.php @@ -1,29 +1,33 @@ {$discount->getKeyName()} = $allegedly_unique; break; } } + + if (empty($discount->tenant_id)) { + $discount->tenant_id = (int) \config('app.tenant_id'); + } } } diff --git a/src/app/Tenant.php b/src/app/Tenant.php index 9b299158..aeec72b2 100644 --- a/src/app/Tenant.php +++ b/src/app/Tenant.php @@ -1,21 +1,31 @@ hasMany('App\Discount'); + } } diff --git a/src/resources/vue/Reseller/Dashboard.vue b/src/resources/vue/Reseller/Dashboard.vue index ba132758..a0d14883 100644 --- a/src/resources/vue/Reseller/Dashboard.vue +++ b/src/resources/vue/Reseller/Dashboard.vue @@ -1,9 +1,11 @@ diff --git a/src/routes/api.php b/src/routes/api.php index 931e2f70..4a9019b9 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,181 +1,181 @@ '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}/skus', 'API\V4\SkusController@userSkus'); 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::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/v4' ], function () { Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/webhooks', ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@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::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); 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); Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); Route::group( [ 'domain' => 'reseller.' . \config('app.domain'), 'middleware' => ['auth:api', 'reseller'], - 'prefix' => 'v4', + 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\Reseller\DomainsController@confirm'); Route::apiResource('entitlements', API\V4\Reseller\EntitlementsController::class); Route::apiResource('packages', API\V4\Reseller\PackagesController::class); Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::apiResource('discounts', API\V4\Reseller\DiscountsController::class); } ); diff --git a/src/tests/Browser/Reseller/LogonTest.php b/src/tests/Browser/Reseller/LogonTest.php new file mode 100644 index 00000000..966e0278 --- /dev/null +++ b/src/tests/Browser/Reseller/LogonTest.php @@ -0,0 +1,144 @@ +browse(function (Browser $browser) { + $browser->visit(new Home()) + ->with(new Menu(), function ($browser) { + $browser->assertMenuItems(['explore', 'blog', 'support', 'login']); + }) + ->assertMissing('@second-factor-input') + ->assertMissing('@forgot-password'); + }); + } + + /** + * Test redirect to /login if user is unauthenticated + */ + public function testLogonRedirect(): void + { + $this->browse(function (Browser $browser) { + $browser->visit('/dashboard'); + + // Checks if we're really on the login page + $browser->waitForLocation('/login') + ->on(new Home()); + }); + } + + /** + * Logon with wrong password/user test + */ + public function testLogonWrongCredentials(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('reseller@reseller.com', 'wrong') + // Error message + ->assertToast(Toast::TYPE_ERROR, 'Invalid username or password.') + // Checks if we're still on the logon page + ->on(new Home()); + }); + } + + /** + * Successful logon test + */ + public function testLogonSuccessful(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('reseller@reseller.com', 'reseller', true); + + // Checks if we're really on Dashboard page + $browser->on(new Dashboard()) + ->within(new Menu(), function ($browser) { + $browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout']); + }) + ->assertUser('reseller@reseller.com'); + + // Test that visiting '/' with logged in user does not open logon form + // but "redirects" to the dashboard + $browser->visit('/')->on(new Dashboard()); + }); + } + + /** + * Logout test + * + * @depends testLogonSuccessful + */ + public function testLogout(): void + { + $this->browse(function (Browser $browser) { + $browser->on(new Dashboard()); + + // Click the Logout button + $browser->within(new Menu(), function ($browser) { + $browser->clickMenuItem('logout'); + }); + + // We expect the logon page + $browser->waitForLocation('/login') + ->on(new Home()); + + // with default menu + $browser->within(new Menu(), function ($browser) { + $browser->assertMenuItems(['explore', 'blog', 'support', 'login']); + }); + + // Success toast message + $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); + }); + } + + /** + * Logout by URL test + */ + public function testLogoutByURL(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('reseller@reseller.com', 'reseller', true); + + // Checks if we're really on Dashboard page + $browser->on(new Dashboard()); + + // Use /logout url, and expect the logon page + $browser->visit('/logout') + ->waitForLocation('/login') + ->on(new Home()); + + // with default menu + $browser->within(new Menu(), function ($browser) { + $browser->assertMenuItems(['explore', 'blog', 'support', 'login']); + }); + + // Success toast message + $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); + }); + } +} diff --git a/src/tests/Feature/Controller/Reseller/DiscountsTest.php b/src/tests/Feature/Controller/Reseller/DiscountsTest.php new file mode 100644 index 00000000..5d6a41aa --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/DiscountsTest.php @@ -0,0 +1,94 @@ +first(); + $tenant->discounts()->delete(); + + self::useResellerUrl(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $tenant = Tenant::where('title', 'Sample Tenant')->first(); + $tenant->discounts()->delete(); + + parent::tearDown(); + } + + /** + * Test listing discounts (/api/v4/discounts) + */ + public function testIndex(): void + { + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller = $this->getTestUser('reseller@reseller.com'); + + // Non-admin user + $response = $this->actingAs($user)->get("api/v4/discounts"); + $response->assertStatus(403); + + // Admin user + $response = $this->actingAs($admin)->get("api/v4/discounts"); + $response->assertStatus(403); + + // Reseller (empty list) + $response = $this->actingAs($reseller)->get("api/v4/discounts"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + + // Add some discounts + $tenant = Tenant::where('title', 'Sample Tenant')->first(); + $discount_test = Discount::create([ + 'description' => 'Test reseller voucher', + 'code' => 'RESELLER-TEST', + 'discount' => 10, + 'tenant_id' => $tenant->id, + 'active' => true, + ]); + $discount_free = Discount::create([ + 'description' => 'Free account', + 'discount' => 100, + 'tenant_id' => $tenant->id, + 'active' => true, + ]); + + $response = $this->actingAs($reseller)->get("api/v4/discounts"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(2, $json['count']); + $this->assertSame($discount_test->id, $json['list'][0]['id']); + $this->assertSame($discount_test->discount, $json['list'][0]['discount']); + $this->assertSame($discount_test->code, $json['list'][0]['code']); + $this->assertSame($discount_test->description, $json['list'][0]['description']); + $this->assertSame('10% - Test reseller voucher [RESELLER-TEST]', $json['list'][0]['label']); + + $this->assertSame($discount_free->id, $json['list'][1]['id']); + $this->assertSame($discount_free->discount, $json['list'][1]['discount']); + $this->assertSame($discount_free->code, $json['list'][1]['code']); + $this->assertSame($discount_free->description, $json['list'][1]['description']); + $this->assertSame('100% - Free account', $json['list'][1]['label']); + } +} diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php index a69d434e..a6a7f5e4 100644 --- a/src/tests/TestCase.php +++ b/src/tests/TestCase.php @@ -1,36 +1,48 @@ created_at = $targetDate; $entitlement->updated_at = $targetDate; $entitlement->save(); $owner = $entitlement->wallet->owner; $owner->created_at = $targetDate; $owner->save(); } } /** * Set baseURL to the admin UI location */ protected static function useAdminUrl(): void { // This will set base URL for all tests in a file. // If we wanted to access both user and admin in one test // we can also just call post/get/whatever with full url \config(['app.url' => str_replace('//', '//admin.', \config('app.url'))]); url()->forceRootUrl(config('app.url')); } + + /** + * Set baseURL to the reseller UI location + */ + protected static function useResellerUrl(): void + { + // This will set base URL for all tests in a file. + // If we wanted to access both user and admin in one test + // we can also just call post/get/whatever with full url + \config(['app.url' => str_replace('//', '//reseller.', \config('app.url'))]); + url()->forceRootUrl(config('app.url')); + } } diff --git a/src/tests/TestCaseDusk.php b/src/tests/TestCaseDusk.php index 6fb3f5cd..30398dc9 100644 --- a/src/tests/TestCaseDusk.php +++ b/src/tests/TestCaseDusk.php @@ -1,105 +1,116 @@ addArguments([ '--lang=en_US', '--disable-gpu', '--headless', '--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream', '--enable-usermedia-screen-capturing', // '--auto-select-desktop-capture-source="Entire screen"', '--ignore-certificate-errors', '--incognito', ]); // For file download handling $prefs = [ 'profile.default_content_settings.popups' => 0, 'download.default_directory' => __DIR__ . '/Browser/downloads', ]; $options->setExperimentalOption('prefs', $prefs); if (getenv('TESTS_MODE') == 'phone') { // Fake User-Agent string for mobile mode $ua = 'Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/537.36' . ' (KHTML, like Gecko) Chrome/60.0.3112.90 Mobile Safari/537.36'; $options->setExperimentalOption('mobileEmulation', ['userAgent' => $ua]); $options->addArguments(['--window-size=375,667']); } elseif (getenv('TESTS_MODE') == 'tablet') { // Fake User-Agent string for mobile mode $ua = 'Mozilla/5.0 (Linux; Android 6.0.1; vivo 1603 Build/MMB29M) AppleWebKit/537.36 ' . ' (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36'; $options->setExperimentalOption('mobileEmulation', ['userAgent' => $ua]); $options->addArguments(['--window-size=800,640']); } else { $options->addArguments(['--window-size=1280,1024']); } // Make sure downloads dir exists and is empty if (!file_exists(__DIR__ . '/Browser/downloads')) { mkdir(__DIR__ . '/Browser/downloads', 0777, true); } else { foreach (glob(__DIR__ . '/Browser/downloads/*') as $file) { @unlink($file); } } return RemoteWebDriver::create( 'http://localhost:9515', DesiredCapabilities::chrome()->setCapability( ChromeOptions::CAPABILITY, $options ) ); } /** * Replace Dusk's Browser with our (extended) Browser */ protected function newBrowser($driver) { return new Browser($driver); } /** * Set baseURL to the admin UI location */ protected static function useAdminUrl(): void { // This will set baseURL for all tests in this file // If we wanted to visit both user and admin in one test // we can also just call visit() with full url Browser::$baseUrl = str_replace('//', '//admin.', \config('app.url')); } + + /** + * Set baseURL to the reseller UI location + */ + protected static function useResellerUrl(): void + { + // This will set baseURL for all tests in this file + // If we wanted to visit both user and admin in one test + // we can also just call visit() with full url + Browser::$baseUrl = str_replace('//', '//reseller.', \config('app.url')); + } }