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'));
+ }
}