diff --git a/src/app/Jobs/User/UpdateJob.php b/src/app/Jobs/User/UpdateJob.php index 8584ffcb..87a8e57c 100644 --- a/src/app/Jobs/User/UpdateJob.php +++ b/src/app/Jobs/User/UpdateJob.php @@ -1,37 +1,53 @@ getUser(); if (!$user) { return; } if ($user->role) { // Admins/resellers don't reside in LDAP (for now) return; } if (\config('app.with_ldap') && $user->isLdapReady()) { \App\Backends\LDAP::updateUser($user); } if (\config('app.with_imap') && $user->isImapReady()) { if (!\App\Backends\IMAP::updateUser($user)) { throw new \Exception("Failed to update mailbox for user {$this->userId}."); } } } + + /** + * Get the unique ID for the job. + * + * This together with ShouldBeUniqueUntilProcessing makes sure there's only one update job + * for the same user at the same time. E.g. if you delete 5 storage entitlements in one action, + * we'll reach to LDAP/IMAP backend only once (instead of five). + */ + public function uniqueId(): string + { + return (string) $this->userId; + } } diff --git a/src/config/queue.php b/src/config/queue.php index f65fdc31..13272ca3 100644 --- a/src/config/queue.php +++ b/src/config/queue.php @@ -1,110 +1,110 @@ env('QUEUE_CONNECTION', 'sync'), /* |-------------------------------------------------------------------------- | Queue Connections |-------------------------------------------------------------------------- | | Here you may configure the connection information for each server that | is used by your application. A default configuration has been added | for each back-end shipped with Laravel. You are free to add more. | | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" | */ 'connections' => [ 'sync' => [ 'driver' => 'sync', ], 'database' => [ 'driver' => 'database', 'table' => 'jobs', 'queue' => 'default', 'retry_after' => 90, 'after_commit' => false, ], 'beanstalkd' => [ 'driver' => 'beanstalkd', 'host' => 'localhost', 'queue' => 'default', 'retry_after' => 90, 'block_for' => 0, 'after_commit' => false, ], 'sqs' => [ 'driver' => 'sqs', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), 'queue' => env('SQS_QUEUE', 'default'), 'suffix' => env('SQS_SUFFIX'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'after_commit' => false, ], 'redis' => [ 'driver' => 'redis', 'connection' => 'default', 'queue' => env('REDIS_QUEUE', 'default'), 'retry_after' => 90, 'block_for' => null, - 'after_commit' => false, + 'after_commit' => true, ], ], /* |-------------------------------------------------------------------------- | Job Batching |-------------------------------------------------------------------------- | | The following options configure the database and table that store job | batching information. These options can be updated to any database | connection and table which has been defined by your application. | */ 'batching' => [ 'database' => env('DB_CONNECTION', 'mysql'), 'table' => 'job_batches', ], /* |-------------------------------------------------------------------------- | Failed Queue Jobs |-------------------------------------------------------------------------- | | These options configure the behavior of failed queue job logging so you | can control which database and table are used to store the jobs that | have failed. You may change them to any database / table you wish. | */ 'failed' => [ 'database' => env('DB_CONNECTION', 'mysql'), 'table' => 'failed_jobs', ], ]; diff --git a/src/tests/Feature/BillingTest.php b/src/tests/Feature/BillingTest.php index 6de67de2..f8ae681a 100644 --- a/src/tests/Feature/BillingTest.php +++ b/src/tests/Feature/BillingTest.php @@ -1,262 +1,261 @@ deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('jack@kolabnow.com'); \App\Package::withEnvTenantContext()->where('title', 'kolab-kube')->delete(); $this->user = $this->getTestUser('jane@kolabnow.com'); $this->package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $this->user->assignPackage($this->package); $this->wallet = $this->user->wallets->first(); $this->wallet_id = $this->wallet->id; } public function tearDown(): void { $this->deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('jack@kolabnow.com'); \App\Package::withEnvTenantContext()->where('title', 'kolab-kube')->delete(); parent::tearDown(); } /** * Test the expected results for a user that registers and is almost immediately gone. */ public function testTouchAndGo(): void { $this->assertCount(7, $this->wallet->entitlements); $this->assertEquals(0, $this->wallet->expectedCharges()); $this->user->delete(); $this->assertCount(0, $this->wallet->fresh()->entitlements->where('deleted_at', null)); $this->assertCount(7, $this->wallet->entitlements); } /** * Verify the last day before the end of a full month's trial. */ public function testNearFullTrial(): void { $this->backdateEntitlements( $this->wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)->addDays(1) ); $this->assertEquals(0, $this->wallet->expectedCharges()); } /** * Verify the exact end of the month's trial. */ public function testFullTrial(): void { $this->backdateEntitlements( $this->wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1) ); $this->assertEquals(990, $this->wallet->expectedCharges()); } /** * Verify that over-running the trial by a single day causes charges to be incurred. */ public function testOutRunTrial(): void { $this->backdateEntitlements( $this->wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1) ); $this->assertEquals(990, $this->wallet->expectedCharges()); } /** * Verify additional storage configuration entitlement created 'early' does incur additional * charges to the wallet. */ public function testAddtStorageEarly(): void { $this->backdateEntitlements( $this->wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1) ); $this->assertEquals(990, $this->wallet->expectedCharges()); $sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $entitlement = \App\Entitlement::create( [ 'wallet_id' => $this->wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->cost, 'entitleable_id' => $this->user->id, 'entitleable_type' => \App\User::class ] ); $this->backdateEntitlements( [$entitlement], Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1) ); $this->assertEquals(1015, $this->wallet->expectedCharges()); } /** * Verify additional storage configuration entitlement created 'late' does not incur additional * charges to the wallet. */ public function testAddtStorageLate(): void { $this->backdateEntitlements($this->wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)); $this->assertEquals(990, $this->wallet->expectedCharges()); $sku = \App\Sku::withEnvTenantContext()->where(['title' => 'storage'])->first(); $entitlement = \App\Entitlement::create( [ 'wallet_id' => $this->wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->cost, 'entitleable_id' => $this->user->id, 'entitleable_type' => \App\User::class ] ); $this->backdateEntitlements([$entitlement], Carbon::now()->subDays(14)); $this->assertEquals(990, $this->wallet->expectedCharges()); } public function testFifthWeek(): void { $targetDateA = Carbon::now()->subWeeks(5); $targetDateB = $targetDateA->copy()->addMonthsWithoutOverflow(1); $this->backdateEntitlements($this->wallet->entitlements, $targetDateA); $this->assertEquals(990, $this->wallet->expectedCharges()); $this->wallet->chargeEntitlements(); $this->assertEquals(-990, $this->wallet->balance); foreach ($this->wallet->entitlements()->get() as $entitlement) { $this->assertTrue($entitlement->created_at->isSameSecond($targetDateA)); $this->assertTrue($entitlement->updated_at->isSameSecond($targetDateB)); } } public function testSecondMonth(): void { $this->backdateEntitlements($this->wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(2)); $this->assertCount(7, $this->wallet->entitlements); $this->assertEquals(1980, $this->wallet->expectedCharges()); $sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $entitlement = \App\Entitlement::create( [ 'entitleable_id' => $this->user->id, 'entitleable_type' => \App\User::class, 'cost' => $sku->cost, 'sku_id' => $sku->id, 'wallet_id' => $this->wallet_id ] ); $this->backdateEntitlements([$entitlement], Carbon::now()->subMonthsWithoutOverflow(1)); $this->assertEquals(2005, $this->wallet->expectedCharges()); } public function testWithDiscountRate(): void { $package = \App\Package::create( [ 'title' => 'kolab-kube', 'name' => 'Kolab for Kuba Fans', 'description' => 'Kolab for Kube fans', 'discount_rate' => 50 ] ); $skus = [ \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first(), \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(), \App\Sku::withEnvTenantContext()->where('title', 'groupware')->first() ]; $package->skus()->saveMany($skus); $package->skus()->updateExistingPivot( \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(), ['qty' => 5], false ); $user = $this->getTestUser('jack@kolabnow.com'); $user->assignPackage($package); $wallet = $user->wallets->first(); $wallet_id = $wallet->id; $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)); $this->assertEquals(495, $wallet->expectedCharges()); } /** * Test cost calculation with a wallet discount */ public function testWithWalletDiscount(): void { $discount = \App\Discount::withEnvTenantContext()->where('code', 'TEST')->first(); $wallet = $this->user->wallets()->first(); $wallet->discount()->associate($discount); $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)); $this->assertEquals(891, $wallet->expectedCharges()); } } diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php index dd894471..4c04a455 100644 --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -1,208 +1,206 @@ deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestGroup('test-group@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestGroup('test-group@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); parent::tearDown(); } /** * Tests for EntitlementObserver * * @group skipci */ public function testEntitlementObserver(): void { $skuStorage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $skuMailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $user = $this->getTestUser('entitlement-test@kolabnow.com'); $wallet = $user->wallets->first(); // Test dispatching update jobs for the user, on quota update - Queue::fake(); + $this->fakeQueueReset(); $user->assignSku($skuMailbox, 1, $wallet); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0); - Queue::fake(); + $this->fakeQueueReset(); $user->assignSku($skuStorage, 1, $wallet); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($user) { return $user->id === TestCase::getObjectProperty($job, 'userId'); } ); - Queue::fake(); + $this->fakeQueueReset(); $user->entitlements()->where('sku_id', $skuMailbox->id)->first()->delete(); //FIXME this sometimes gives 1? Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0); - Queue::fake(); + $this->fakeQueueReset(); $user->entitlements()->where('sku_id', $skuStorage->id)->first()->delete(); //FIXME this sometimes gives 2? Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($user) { return $user->id === TestCase::getObjectProperty($job, 'userId'); } ); // TODO: Test all events in the observer in more detail } /** * Tests for entitlements * @todo This really should be in User or Wallet tests file */ public function testEntitlements(): void { $packageDomain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $packageKolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $skuDomain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $skuMailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $owner = $this->getTestUser('entitlement-test@kolabnow.com'); $user = $this->getTestUser('entitled-user@custom-domain.com'); $domain = $this->getTestDomain( 'custom-domain.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($packageDomain, $owner); $owner->assignPackage($packageKolab); $owner->assignPackage($packageKolab, $user); $wallet = $owner->wallets->first(); $this->assertCount(7, $owner->entitlements()->get()); $this->assertCount(1, $skuDomain->entitlements()->where('wallet_id', $wallet->id)->get()); $this->assertCount(2, $skuMailbox->entitlements()->where('wallet_id', $wallet->id)->get()); $this->assertCount(15, $wallet->entitlements); $this->backdateEntitlements( $owner->entitlements, Carbon::now()->subMonthsWithoutOverflow(1) ); $wallet->chargeEntitlements(); $this->assertTrue($wallet->fresh()->balance < 0); } /** * Test EntitleableTrait::toString() */ public function testEntitleableTitle(): void { - Queue::fake(); - $packageDomain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $packageKolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user = $this->getTestUser('entitled-user@custom-domain.com'); $group = $this->getTestGroup('test-group@custom-domain.com'); $domain = $this->getTestDomain( 'custom-domain.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $wallet = $user->wallets->first(); $domain->assignPackage($packageDomain, $user); $user->assignPackage($packageKolab); $group->assignToWallet($wallet); $sku_mailbox = \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $sku_group = \App\Sku::withEnvTenantContext()->where('title', 'group')->first(); $sku_domain = \App\Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku_mailbox->id)->first(); $this->assertSame($user->email, $entitlement->entitleable->toString()); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku_group->id)->first(); $this->assertSame($group->email, $entitlement->entitleable->toString()); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku_domain->id)->first(); $this->assertSame($domain->namespace, $entitlement->entitleable->toString()); // Make sure it still works if the entitleable is deleted $domain->delete(); $entitlement->refresh(); $this->assertSame($domain->namespace, $entitlement->entitleable->toString()); $this->assertNotNull($entitlement->entitleable); } /** * Test for EntitleableTrait::removeSku() */ public function testEntitleableRemoveSku(): void { $user = $this->getTestUser('entitlement-test@kolabnow.com'); $storage = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $user->assignSku($storage, 6); $this->assertCount(6, $user->fresh()->entitlements); $backdate = Carbon::now()->subWeeks(7); $this->backdateEntitlements($user->entitlements, $backdate); $user->removeSku($storage, 2); // Expect free units to be not deleted $this->assertCount(5, $user->fresh()->entitlements); // Here we make sure that updated_at does not change on delete $this->assertSame(6, $user->entitlements()->withTrashed()->whereDate('updated_at', $backdate)->count()); } } diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index 04276f53..96d5e1c8 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,1591 +1,1599 @@ deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestResource('test-resource@UserAccount.com'); $this->deleteTestSharedFolder('test-folder@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); Package::where('title', 'test-package')->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { \App\TenantSetting::truncate(); Package::where('title', 'test-package')->delete(); $this->deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestResource('test-resource@UserAccount.com'); $this->deleteTestSharedFolder('test-folder@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); parent::tearDown(); } /** * Tests for User::assignPackage() */ public function testAssignPackage(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $skuGroupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); // cost: 490 $skuMailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // cost: 500 $skuStorage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); // cost: 25 $package = Package::create([ 'title' => 'test-package', 'name' => 'Test Account', 'description' => 'Test account.', 'discount_rate' => 0, ]); // WARNING: saveMany() sets package_skus.cost = skus.cost $package->skus()->saveMany([ $skuMailbox, $skuGroupware, $skuStorage ]); $package->skus()->updateExistingPivot($skuStorage, ['qty' => 2, 'cost' => null], false); $package->skus()->updateExistingPivot($skuMailbox, ['cost' => null], false); $package->skus()->updateExistingPivot($skuGroupware, ['cost' => 100], false); $user->assignPackage($package); $this->assertCount(4, $user->entitlements()->get()); // mailbox + groupware + 2 x storage $entitlement = $wallet->entitlements()->where('sku_id', $skuMailbox->id)->first(); $this->assertSame($skuMailbox->id, $entitlement->sku->id); $this->assertSame($wallet->id, $entitlement->wallet->id); $this->assertEquals($user->id, $entitlement->entitleable_id); $this->assertTrue($entitlement->entitleable instanceof \App\User); $this->assertSame($skuMailbox->cost, $entitlement->cost); $entitlement = $wallet->entitlements()->where('sku_id', $skuGroupware->id)->first(); $this->assertSame($skuGroupware->id, $entitlement->sku->id); $this->assertSame($wallet->id, $entitlement->wallet->id); $this->assertEquals($user->id, $entitlement->entitleable_id); $this->assertTrue($entitlement->entitleable instanceof \App\User); $this->assertSame(100, $entitlement->cost); $entitlement = $wallet->entitlements()->where('sku_id', $skuStorage->id)->first(); $this->assertSame($skuStorage->id, $entitlement->sku->id); $this->assertSame($wallet->id, $entitlement->wallet->id); $this->assertEquals($user->id, $entitlement->entitleable_id); $this->assertTrue($entitlement->entitleable instanceof \App\User); $this->assertSame(0, $entitlement->cost); } /** * Tests for User::assignPlan() */ public function testAssignPlan(): void { $this->markTestIncomplete(); } /** * Tests for User::assignSku() */ public function testAssignSku(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $skuStorage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $skuMailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $user->assignSku($skuMailbox); $this->assertCount(1, $user->entitlements()->get()); $entitlement = $wallet->entitlements()->where('sku_id', $skuMailbox->id)->first(); $this->assertSame($skuMailbox->id, $entitlement->sku->id); $this->assertSame($wallet->id, $entitlement->wallet->id); $this->assertEquals($user->id, $entitlement->entitleable_id); $this->assertTrue($entitlement->entitleable instanceof \App\User); $this->assertSame($skuMailbox->cost, $entitlement->cost); // Test units_free handling for ($x = 0; $x < 5; $x++) { $user->assignSku($skuStorage); } $entitlements = $user->entitlements()->where('sku_id', $skuStorage->id) ->where('cost', 0) ->get(); $this->assertCount(5, $entitlements); $user->assignSku($skuStorage); $entitlements = $user->entitlements()->where('sku_id', $skuStorage->id) ->where('cost', $skuStorage->cost) ->get(); $this->assertCount(1, $entitlements); } /** * Verify a wallet assigned a controller is among the accounts of the assignee. */ public function testAccounts(): void { $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $this->assertTrue($userA->wallets()->count() == 1); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id); } /** * Test User::canDelete() method */ public function testCanDelete(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canDelete($admin)); $this->assertFalse($admin->canDelete($john)); $this->assertFalse($admin->canDelete($jack)); $this->assertFalse($admin->canDelete($reseller1)); $this->assertFalse($admin->canDelete($domain)); $this->assertFalse($admin->canDelete($domain->wallet())); // Reseller - kolabnow $this->assertFalse($reseller1->canDelete($john)); $this->assertFalse($reseller1->canDelete($jack)); $this->assertTrue($reseller1->canDelete($reseller1)); $this->assertFalse($reseller1->canDelete($domain)); $this->assertFalse($reseller1->canDelete($domain->wallet())); $this->assertFalse($reseller1->canDelete($admin)); // Normal user - account owner $this->assertTrue($john->canDelete($john)); $this->assertTrue($john->canDelete($ned)); $this->assertTrue($john->canDelete($jack)); $this->assertTrue($john->canDelete($domain)); $this->assertFalse($john->canDelete($domain->wallet())); $this->assertFalse($john->canDelete($reseller1)); $this->assertFalse($john->canDelete($admin)); // Normal user - a non-owner and non-controller $this->assertFalse($jack->canDelete($jack)); $this->assertFalse($jack->canDelete($john)); $this->assertFalse($jack->canDelete($domain)); $this->assertFalse($jack->canDelete($domain->wallet())); $this->assertFalse($jack->canDelete($reseller1)); $this->assertFalse($jack->canDelete($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canDelete($ned)); $this->assertTrue($ned->canDelete($john)); $this->assertTrue($ned->canDelete($jack)); $this->assertTrue($ned->canDelete($domain)); $this->assertFalse($ned->canDelete($domain->wallet())); $this->assertFalse($ned->canDelete($reseller1)); $this->assertFalse($ned->canDelete($admin)); } /** * Test User::canRead() method */ public function testCanRead(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canRead($admin)); $this->assertTrue($admin->canRead($john)); $this->assertTrue($admin->canRead($jack)); $this->assertTrue($admin->canRead($reseller1)); $this->assertTrue($admin->canRead($reseller2)); $this->assertTrue($admin->canRead($domain)); $this->assertTrue($admin->canRead($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canRead($john)); $this->assertTrue($reseller1->canRead($jack)); $this->assertTrue($reseller1->canRead($reseller1)); $this->assertTrue($reseller1->canRead($domain)); $this->assertTrue($reseller1->canRead($domain->wallet())); $this->assertFalse($reseller1->canRead($reseller2)); $this->assertFalse($reseller1->canRead($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canRead($reseller2)); $this->assertFalse($reseller2->canRead($john)); $this->assertFalse($reseller2->canRead($jack)); $this->assertFalse($reseller2->canRead($reseller1)); $this->assertFalse($reseller2->canRead($domain)); $this->assertFalse($reseller2->canRead($domain->wallet())); $this->assertFalse($reseller2->canRead($admin)); // Normal user - account owner $this->assertTrue($john->canRead($john)); $this->assertTrue($john->canRead($ned)); $this->assertTrue($john->canRead($jack)); $this->assertTrue($john->canRead($domain)); $this->assertTrue($john->canRead($domain->wallet())); $this->assertFalse($john->canRead($reseller1)); $this->assertFalse($john->canRead($reseller2)); $this->assertFalse($john->canRead($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canRead($jack)); $this->assertFalse($jack->canRead($john)); $this->assertFalse($jack->canRead($domain)); $this->assertFalse($jack->canRead($domain->wallet())); $this->assertFalse($jack->canRead($reseller1)); $this->assertFalse($jack->canRead($reseller2)); $this->assertFalse($jack->canRead($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canRead($ned)); $this->assertTrue($ned->canRead($john)); $this->assertTrue($ned->canRead($jack)); $this->assertTrue($ned->canRead($domain)); $this->assertTrue($ned->canRead($domain->wallet())); $this->assertFalse($ned->canRead($reseller1)); $this->assertFalse($ned->canRead($reseller2)); $this->assertFalse($ned->canRead($admin)); } /** * Test User::canUpdate() method */ public function testCanUpdate(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canUpdate($admin)); $this->assertTrue($admin->canUpdate($john)); $this->assertTrue($admin->canUpdate($jack)); $this->assertTrue($admin->canUpdate($reseller1)); $this->assertTrue($admin->canUpdate($reseller2)); $this->assertTrue($admin->canUpdate($domain)); $this->assertTrue($admin->canUpdate($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canUpdate($john)); $this->assertTrue($reseller1->canUpdate($jack)); $this->assertTrue($reseller1->canUpdate($reseller1)); $this->assertTrue($reseller1->canUpdate($domain)); $this->assertTrue($reseller1->canUpdate($domain->wallet())); $this->assertFalse($reseller1->canUpdate($reseller2)); $this->assertFalse($reseller1->canUpdate($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canUpdate($reseller2)); $this->assertFalse($reseller2->canUpdate($john)); $this->assertFalse($reseller2->canUpdate($jack)); $this->assertFalse($reseller2->canUpdate($reseller1)); $this->assertFalse($reseller2->canUpdate($domain)); $this->assertFalse($reseller2->canUpdate($domain->wallet())); $this->assertFalse($reseller2->canUpdate($admin)); // Normal user - account owner $this->assertTrue($john->canUpdate($john)); $this->assertTrue($john->canUpdate($ned)); $this->assertTrue($john->canUpdate($jack)); $this->assertTrue($john->canUpdate($domain)); $this->assertFalse($john->canUpdate($domain->wallet())); $this->assertFalse($john->canUpdate($reseller1)); $this->assertFalse($john->canUpdate($reseller2)); $this->assertFalse($john->canUpdate($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canUpdate($jack)); $this->assertFalse($jack->canUpdate($john)); $this->assertFalse($jack->canUpdate($domain)); $this->assertFalse($jack->canUpdate($domain->wallet())); $this->assertFalse($jack->canUpdate($reseller1)); $this->assertFalse($jack->canUpdate($reseller2)); $this->assertFalse($jack->canUpdate($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canUpdate($ned)); $this->assertTrue($ned->canUpdate($john)); $this->assertTrue($ned->canUpdate($jack)); $this->assertTrue($ned->canUpdate($domain)); $this->assertFalse($ned->canUpdate($domain->wallet())); $this->assertFalse($ned->canUpdate($reseller1)); $this->assertFalse($ned->canUpdate($reseller2)); $this->assertFalse($ned->canUpdate($admin)); } /** * Test user created/creating/updated observers */ public function testCreateAndUpdate(): void { Queue::fake(); $domain = \config('app.domain'); \App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 0); $user = User::create([ 'email' => 'USER-test@' . \strtoupper($domain), 'password' => 'test', ]); $result = User::where('email', "user-test@$domain")->first(); $this->assertSame("user-test@$domain", $result->email); $this->assertSame($user->id, $result->id); $this->assertSame(User::STATUS_NEW, $result->status); $this->assertSame(0, $user->passwords()->count()); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); // Test invoking KeyCreateJob $this->deleteTestUser("user-test@$domain"); \App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 1); $user = User::create(['email' => "user-test@$domain", 'password' => 'test']); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyCreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); // Update the user, test the password change $user->setSetting('password_expiration_warning', '2020-10-10 10:10:10'); $oldPassword = $user->password; $user->password = 'test123'; $user->save(); $this->assertNotEquals($oldPassword, $user->password); $this->assertSame(0, $user->passwords()->count()); $this->assertNull($user->getSetting('password_expiration_warning')); $this->assertMatchesRegularExpression( '/^' . now()->format('Y-m-d') . ' [0-9]{2}:[0-9]{2}:[0-9]{2}$/', $user->getSetting('password_update') ); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); // Update the user, test the password history $user->setSetting('password_policy', 'last:3'); $oldPassword = $user->password; $user->password = 'test1234'; $user->save(); $this->assertSame(1, $user->passwords()->count()); $this->assertSame($oldPassword, $user->passwords()->first()->password); $user->password = 'test12345'; $user->save(); $oldPassword = $user->password; $user->password = 'test123456'; $user->save(); $this->assertSame(2, $user->passwords()->count()); $this->assertSame($oldPassword, $user->passwords()->latest()->first()->password); } /** * Tests for User::domains() */ public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('useraccount.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); $domains = $user->domains()->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertContains('kolab.org', $domains); // Jack is not the wallet controller, so for him the list should not // include John's domains, kolab.org specifically $user = $this->getTestUser('jack@kolab.org'); $domains = $user->domains()->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertNotContains('kolab.org', $domains); // Public domains of other tenants should not be returned $tenant = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->first(); $domain->tenant_id = $tenant->id; $domain->save(); $domains = $user->domains()->pluck('namespace')->all(); $this->assertNotContains($domain->namespace, $domains); } /** * Test User::getConfig() and setConfig() methods */ public function testConfigTrait(): void { $user = $this->getTestUser('UserAccountA@UserAccount.com'); $user->setSetting('greylist_enabled', null); $user->setSetting('guam_enabled', null); $user->setSetting('password_policy', null); $user->setSetting('max_password_age', null); $user->setSetting('limit_geo', null); // greylist_enabled $this->assertSame(true, $user->getConfig()['greylist_enabled']); $result = $user->setConfig(['greylist_enabled' => false, 'unknown' => false]); $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result); $this->assertSame(false, $user->getConfig()['greylist_enabled']); $this->assertSame('false', $user->getSetting('greylist_enabled')); $result = $user->setConfig(['greylist_enabled' => true]); $this->assertSame([], $result); $this->assertSame(true, $user->getConfig()['greylist_enabled']); $this->assertSame('true', $user->getSetting('greylist_enabled')); // guam_enabled $this->assertSame(false, $user->getConfig()['guam_enabled']); $result = $user->setConfig(['guam_enabled' => false]); $this->assertSame([], $result); $this->assertSame(false, $user->getConfig()['guam_enabled']); $this->assertSame(null, $user->getSetting('guam_enabled')); $result = $user->setConfig(['guam_enabled' => true]); $this->assertSame([], $result); $this->assertSame(true, $user->getConfig()['guam_enabled']); $this->assertSame('true', $user->getSetting('guam_enabled')); // max_apssword_age $this->assertSame(null, $user->getConfig()['max_password_age']); $result = $user->setConfig(['max_password_age' => -1]); $this->assertSame([], $result); $this->assertSame(null, $user->getConfig()['max_password_age']); $this->assertSame(null, $user->getSetting('max_password_age')); $result = $user->setConfig(['max_password_age' => 12]); $this->assertSame([], $result); $this->assertSame('12', $user->getConfig()['max_password_age']); $this->assertSame('12', $user->getSetting('max_password_age')); // password_policy $result = $user->setConfig(['password_policy' => true]); $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); $this->assertSame(null, $user->getConfig()['password_policy']); $this->assertSame(null, $user->getSetting('password_policy')); $result = $user->setConfig(['password_policy' => 'min:-1']); $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); $result = $user->setConfig(['password_policy' => 'min:-1']); $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); $result = $user->setConfig(['password_policy' => 'min:10,unknown']); $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); \config(['app.password_policy' => 'min:5,max:100']); $result = $user->setConfig(['password_policy' => 'min:4,max:255']); $this->assertSame(['password_policy' => "Minimum password length cannot be less than 5."], $result); \config(['app.password_policy' => 'min:5,max:100']); $result = $user->setConfig(['password_policy' => 'min:10,max:255']); $this->assertSame(['password_policy' => "Maximum password length cannot be more than 100."], $result); \config(['app.password_policy' => 'min:5,max:255']); $result = $user->setConfig(['password_policy' => 'min:10,max:255']); $this->assertSame([], $result); $this->assertSame('min:10,max:255', $user->getConfig()['password_policy']); $this->assertSame('min:10,max:255', $user->getSetting('password_policy')); // limit_geo $this->assertSame([], $user->getConfig()['limit_geo']); $result = $user->setConfig(['limit_geo' => '']); $err = "Specified configuration is invalid. Expected a list of two-letter country codes."; $this->assertSame(['limit_geo' => $err], $result); $this->assertSame(null, $user->getSetting('limit_geo')); $result = $user->setConfig(['limit_geo' => ['usa']]); $this->assertSame(['limit_geo' => $err], $result); $this->assertSame(null, $user->getSetting('limit_geo')); $result = $user->setConfig(['limit_geo' => []]); $this->assertSame([], $result); $this->assertSame(null, $user->getSetting('limit_geo')); $result = $user->setConfig(['limit_geo' => ['US', 'ru']]); $this->assertSame([], $result); $this->assertSame(['US', 'RU'], $user->getConfig()['limit_geo']); $this->assertSame('["US","RU"]', $user->getSetting('limit_geo')); } /** * Test user account degradation and un-degradation */ public function testDegradeAndUndegrade(): void { - Queue::fake(); + $this->fakeQueueReset(); // Test an account with users, domain $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $yesterday = Carbon::now()->subDays(1); $this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); $this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); $wallet = $userA->wallets->first(); $this->assertSame(7, $entitlementsA->count()); $this->assertSame(7, $entitlementsB->count()); $this->assertSame(7, $entitlementsA->whereDate('updated_at', $yesterday->toDateString())->count()); $this->assertSame(7, $entitlementsB->whereDate('updated_at', $yesterday->toDateString())->count()); $this->assertSame(0, $wallet->balance); - Queue::fake(); // reset queue state + $this->fakeQueueReset(); // Degrade the account/wallet owner $userA->degrade(); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $this->assertTrue($userA->fresh()->isDegraded()); $this->assertTrue($userA->fresh()->isDegraded(true)); $this->assertFalse($userB->fresh()->isDegraded()); $this->assertTrue($userB->fresh()->isDegraded(true)); $balance = $wallet->fresh()->balance; $this->assertTrue($balance < 0); $this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count()); $this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count()); // Expect one update job for every user // @phpstan-ignore-next-line $userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) { return TestCase::getObjectProperty($job, 'userId'); })->all(); $this->assertSame([$userA->id, $userB->id], $userIds); // Un-Degrade the account/wallet owner $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $yesterday = Carbon::now()->subDays(1); $this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); $this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); - Queue::fake(); // reset queue state + $this->fakeQueueReset(); $userA->undegrade(); $this->assertFalse($userA->fresh()->isDegraded()); $this->assertFalse($userA->fresh()->isDegraded(true)); $this->assertFalse($userB->fresh()->isDegraded()); $this->assertFalse($userB->fresh()->isDegraded(true)); // Expect no balance change, degraded account entitlements are free $this->assertSame($balance, $wallet->fresh()->balance); $this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count()); $this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count()); // Expect one update job for every user // @phpstan-ignore-next-line $userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) { return TestCase::getObjectProperty($job, 'userId'); })->all(); $this->assertSame([$userA->id, $userB->id], $userIds); } /** * Test user deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package); $id = $user->id; $this->assertCount(7, $user->entitlements()->get()); $user->delete(); $this->assertCount(0, $user->entitlements()->get()); $this->assertTrue($user->fresh()->trashed()); $this->assertFalse($user->fresh()->isDeleted()); // Delete the user for real $job = new \App\Jobs\User\DeleteJob($id); $job->handle(); $this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted()); $user->forceDelete(); $this->assertCount(0, User::withTrashed()->where('id', $id)->get()); // Test an account with users, domain, and group, and resource $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userC = $this->getTestUser('UserAccountC@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $userA->assignPackage($package_kolab, $userC); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->assignToWallet($userA->wallets->first()); $resource = $this->getTestResource('test-resource@UserAccount.com', ['name' => 'test']); $resource->assignToWallet($userA->wallets->first()); $folder = $this->getTestSharedFolder('test-folder@UserAccount.com', ['name' => 'test']); $folder->assignToWallet($userA->wallets->first()); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id); $entitlementsResource = \App\Entitlement::where('entitleable_id', $resource->id); $entitlementsFolder = \App\Entitlement::where('entitleable_id', $folder->id); $this->assertSame(7, $entitlementsA->count()); $this->assertSame(7, $entitlementsB->count()); $this->assertSame(7, $entitlementsC->count()); $this->assertSame(1, $entitlementsDomain->count()); $this->assertSame(1, $entitlementsGroup->count()); $this->assertSame(1, $entitlementsResource->count()); $this->assertSame(1, $entitlementsFolder->count()); // Delete non-controller user $userC->delete(); $this->assertTrue($userC->fresh()->trashed()); $this->assertFalse($userC->fresh()->isDeleted()); $this->assertSame(0, $entitlementsC->count()); // Delete the controller (and expect "sub"-users to be deleted too) $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertSame(0, $entitlementsGroup->count()); $this->assertSame(0, $entitlementsResource->count()); $this->assertSame(0, $entitlementsFolder->count()); $this->assertSame(7, $entitlementsA->withTrashed()->count()); $this->assertSame(7, $entitlementsB->withTrashed()->count()); $this->assertSame(7, $entitlementsC->withTrashed()->count()); $this->assertSame(1, $entitlementsDomain->withTrashed()->count()); $this->assertSame(1, $entitlementsGroup->withTrashed()->count()); $this->assertSame(1, $entitlementsResource->withTrashed()->count()); $this->assertSame(1, $entitlementsFolder->withTrashed()->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domain->fresh()->trashed()); $this->assertTrue($group->fresh()->trashed()); $this->assertTrue($resource->fresh()->trashed()); $this->assertTrue($folder->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($group->isDeleted()); $this->assertFalse($resource->isDeleted()); $this->assertFalse($folder->isDeleted()); $userA->forceDelete(); $all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id); $transactions = \App\Transaction::where('object_id', $userA->wallets->first()->id); $this->assertSame(0, $all_entitlements->withTrashed()->count()); $this->assertSame(0, $transactions->count()); $this->assertCount(0, User::withTrashed()->where('id', $userA->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userB->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get()); $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); $this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get()); $this->assertCount(0, \App\Resource::withTrashed()->where('id', $resource->id)->get()); $this->assertCount(0, \App\SharedFolder::withTrashed()->where('id', $folder->id)->get()); } /** * Test eventlog on user deletion */ public function testDeleteAndEventLog(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); EventLog::createFor($user, EventLog::TYPE_SUSPENDED, 'test'); $user->delete(); $this->assertCount(1, EventLog::where('object_id', $user->id)->where('object_type', User::class)->get()); $user->forceDelete(); $this->assertCount(0, EventLog::where('object_id', $user->id)->where('object_type', User::class)->get()); } /** * Test user deletion vs. group membership * * The first Queue::assertPushed is sometimes 1 and sometimes 2 * @group skipci */ public function testDeleteAndGroups(): void { Queue::fake(); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userA->assignPackage($package_kolab, $userB); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->members = ['test@gmail.com', $userB->email]; $group->assignToWallet($userA->wallets->first()); $group->save(); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); $userGroups = $userA->groups()->get(); $this->assertSame(1, $userGroups->count()); $this->assertSame($group->id, $userGroups->first()->id); $userB->delete(); $this->assertSame(['test@gmail.com'], $group->fresh()->members); // Twice, one for save() and one for delete() above Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2); } /** * Test handling negative balance on user deletion */ public function testDeleteWithNegativeBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = -1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); \App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); $user->delete(); $reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->get(); $this->assertSame(-1000, $reseller_wallet->fresh()->balance); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Deleted user {$user->email}", $trans->description); $this->assertSame(-1000, $trans->amount); $this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type); } /** * Test handling positive balance on user deletion */ public function testDeleteWithPositiveBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = 1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); $user->delete(); $this->assertSame(0, $reseller_wallet->fresh()->balance); } /** * Test user deletion with PGP/WOAT enabled */ public function testDeleteWithPGP(): void { Queue::fake(); // Test with PGP disabled $user = $this->getTestUser('user-test@' . \config('app.domain')); $user->tenant->setSetting('pgp.enable', 0); $user->delete(); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 0); // Test with PGP enabled $this->deleteTestUser('user-test@' . \config('app.domain')); $user = $this->getTestUser('user-test@' . \config('app.domain')); $user->tenant->setSetting('pgp.enable', 1); $user->delete(); $user->tenant->setSetting('pgp.enable', 0); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyDeleteJob::class, function ($job) use ($user) { $userId = TestCase::getObjectProperty($job, 'userId'); $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userId == $user->id && $userEmail === $user->email; } ); } /** * Test user deletion vs. rooms */ public function testDeleteWithRooms(): void { $this->markTestIncomplete(); } /** * Tests for User::aliasExists() */ public function testAliasExists(): void { $this->assertTrue(User::aliasExists('jack.daniels@kolab.org')); $this->assertFalse(User::aliasExists('j.daniels@kolab.org')); $this->assertFalse(User::aliasExists('john@kolab.org')); } /** * Tests for User::emailExists() */ public function testEmailExists(): void { $this->assertFalse(User::emailExists('jack.daniels@kolab.org')); $this->assertFalse(User::emailExists('j.daniels@kolab.org')); $this->assertTrue(User::emailExists('john@kolab.org')); $user = User::emailExists('john@kolab.org', true); $this->assertSame('john@kolab.org', $user->email); } /** * Tests for User::findByEmail() */ public function testFindByEmail(): void { $user = $this->getTestUser('john@kolab.org'); $result = User::findByEmail('john'); $this->assertNull($result); $result = User::findByEmail('non-existing@email.com'); $this->assertNull($result); $result = User::findByEmail('john@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); // Use an alias $result = User::findByEmail('john.doe@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); Queue::fake(); // A case where two users have the same alias $ned = $this->getTestUser('ned@kolab.org'); $ned->setAliases(['joe.monster@kolab.org']); $result = User::findByEmail('joe.monster@kolab.org'); $this->assertNull($result); $ned->setAliases([]); // TODO: searching by external email (setting) $this->markTestIncomplete(); } /** * Test User::hasSku() and countEntitlementsBySku() methods */ public function testHasSku(): void { $john = $this->getTestUser('john@kolab.org'); $this->assertTrue($john->hasSku('mailbox')); $this->assertTrue($john->hasSku('storage')); $this->assertFalse($john->hasSku('beta')); $this->assertFalse($john->hasSku('unknown')); $this->assertSame(0, $john->countEntitlementsBySku('unknown')); $this->assertSame(0, $john->countEntitlementsBySku('2fa')); $this->assertSame(1, $john->countEntitlementsBySku('mailbox')); $this->assertSame(5, $john->countEntitlementsBySku('storage')); } /** * Test User::name() */ public function testName(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $this->assertSame('', $user->name()); $this->assertSame($user->tenant->title . ' User', $user->name(true)); $user->setSetting('first_name', 'First'); $this->assertSame('First', $user->name()); $this->assertSame('First', $user->name(true)); $user->setSetting('last_name', 'Last'); $this->assertSame('First Last', $user->name()); $this->assertSame('First Last', $user->name(true)); } /** * Test resources() method */ public function testResources(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $resources = $john->resources()->orderBy('email')->get(); $this->assertSame(2, $resources->count()); $this->assertSame('resource-test1@kolab.org', $resources[0]->email); $this->assertSame('resource-test2@kolab.org', $resources[1]->email); $resources = $ned->resources()->orderBy('email')->get(); $this->assertSame(2, $resources->count()); $this->assertSame('resource-test1@kolab.org', $resources[0]->email); $this->assertSame('resource-test2@kolab.org', $resources[1]->email); $resources = $jack->resources()->get(); $this->assertSame(0, $resources->count()); } /** * Test sharedFolders() method */ public function testSharedFolders(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $folders = $john->sharedFolders()->orderBy('email')->get(); $this->assertSame(2, $folders->count()); $this->assertSame('folder-contact@kolab.org', $folders[0]->email); $this->assertSame('folder-event@kolab.org', $folders[1]->email); $folders = $ned->sharedFolders()->orderBy('email')->get(); $this->assertSame(2, $folders->count()); $this->assertSame('folder-contact@kolab.org', $folders[0]->email); $this->assertSame('folder-event@kolab.org', $folders[1]->email); $folders = $jack->sharedFolders()->get(); $this->assertSame(0, $folders->count()); } /** * Test user restoring */ public function testRestore(): void { - Queue::fake(); + $this->fakeQueueReset(); // Test an account with users and domain $userA = $this->getTestUser('UserAccountA@UserAccount.com', [ 'status' => User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_SUSPENDED, ]); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domainA = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $domainB = $this->getTestDomain('UserAccountAdd.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domainA->assignPackage($package_domain, $userA); $domainB->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $now = \Carbon\Carbon::now(); $wallet_id = $userA->wallets->first()->id; // add an extra storage entitlement $ent1 = \App\Entitlement::create([ 'wallet_id' => $wallet_id, 'sku_id' => $storage_sku->id, 'cost' => 0, 'entitleable_id' => $userA->id, 'entitleable_type' => User::class, ]); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domainA->id); // First delete the user $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainA->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domainA->isDeleted()); // Backdate one storage entitlement (it's not expected to be restored) \App\Entitlement::withTrashed()->where('id', $ent1->id) ->update(['deleted_at' => $now->copy()->subMinutes(2)]); // Backdate entitlements to assert that they were restored with proper updated_at timestamp \App\Entitlement::withTrashed()->where('wallet_id', $wallet_id) ->update(['updated_at' => $now->subMinutes(10)]); - Queue::fake(); + $this->fakeQueueReset(); // Then restore it $userA->restore(); $userA->refresh(); $this->assertFalse($userA->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userA->isSuspended()); $this->assertFalse($userA->isLdapReady()); $this->assertFalse($userA->isImapReady()); $this->assertFalse($userA->isActive()); $this->assertTrue($userA->isNew()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($domainA->fresh()->trashed()); // Assert entitlements $this->assertSame(7, $entitlementsA->count()); // mailbox + groupware + 5 x storage $this->assertTrue($ent1->fresh()->trashed()); $entitlementsA->get()->each(function ($ent) { $this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); }); // We expect only CreateJob + UpdateJob pair for both user and domain. // Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method // is implemented we cannot skip the UpdateJob in any way. // I don't want to overwrite this method, the extra job shouldn't do any harm. $this->assertCount(4, Queue::pushedJobs()); // @phpstan-ignore-line Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($userA) { return $userA->id === TestCase::getObjectProperty($job, 'userId'); } ); } /** * Test user account restrict() and unrestrict() */ public function testRestrictAndUnrestrict(): void { - Queue::fake(); + $this->fakeQueueReset(); // Test an account with users, domain $user = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $user->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user); $user->assignPackage($package_kolab, $userB); $this->assertFalse($user->isRestricted()); $this->assertFalse($userB->isRestricted()); $user->restrict(); $this->assertTrue($user->fresh()->isRestricted()); $this->assertFalse($userB->fresh()->isRestricted()); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($user) { return TestCase::getObjectProperty($job, 'userId') == $user->id; } ); $userB->restrict(); $this->assertTrue($userB->fresh()->isRestricted()); - Queue::fake(); // reset queue state + $this->fakeQueueReset(); $user->refresh(); $user->unrestrict(); $this->assertFalse($user->fresh()->isRestricted()); $this->assertTrue($userB->fresh()->isRestricted()); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($user) { return TestCase::getObjectProperty($job, 'userId') == $user->id; } ); - Queue::fake(); // reset queue state + $this->fakeQueueReset(); $user->unrestrict(true); $this->assertFalse($user->fresh()->isRestricted()); $this->assertFalse($userB->fresh()->isRestricted()); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($userB) { return TestCase::getObjectProperty($job, 'userId') == $userB->id; } ); } /** * Tests for AliasesTrait::setAliases() */ public function testSetAliases(): void { - Queue::fake(); - Queue::assertNothingPushed(); + $this->fakeQueueReset(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $this->assertCount(0, $user->aliases->all()); $user->tenant->setSetting('pgp.enable', 1); // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $user->tenant->setSetting('pgp.enable', 0); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Add another alias + $this->fakeQueueReset(); $user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']); - Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); - Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); + Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); + Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]->alias); $this->assertSame('useralias2@useraccount.com', $aliases[1]->alias); $user->tenant->setSetting('pgp.enable', 1); // Remove an alias + $this->fakeQueueReset(); $user->setAliases(['UserAlias1@UserAccount.com']); $user->tenant->setSetting('pgp.enable', 0); - Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); + Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyDeleteJob::class, function ($job) use ($user) { $userId = TestCase::getObjectProperty($job, 'userId'); $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userId == $user->id && $userEmail === 'useralias2@useraccount.com'; } ); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Remove all aliases + $this->fakeQueueReset(); $user->setAliases([]); - Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 4); + Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); $this->assertCount(0, $user->aliases()->get()); } /** * Tests for suspendAccount() */ public function testSuspendAccount(): void { $user = $this->getTestUser('UserAccountA@UserAccount.com'); $wallet = $user->wallets()->first(); // No entitlements, expect the wallet owner to be suspended anyway $user->suspendAccount(); $this->assertTrue($user->fresh()->isSuspended()); // Add entitlements and more suspendable objects into the wallet $user->unsuspend(); $mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $domain_sku = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $group_sku = Sku::withEnvTenantContext()->where('title', 'group')->first(); $resource_sku = Sku::withEnvTenantContext()->where('title', 'resource')->first(); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userB->assignSku($mailbox_sku, 1, $wallet); $domain = $this->getTestDomain('UserAccount.com', ['type' => \App\Domain::TYPE_PUBLIC]); $domain->assignSku($domain_sku, 1, $wallet); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->assignSku($group_sku, 1, $wallet); $resource = $this->getTestResource('test-resource@UserAccount.com'); $resource->assignSku($resource_sku, 1, $wallet); $this->assertFalse($user->isSuspended()); $this->assertFalse($userB->isSuspended()); $this->assertFalse($domain->isSuspended()); $this->assertFalse($group->isSuspended()); $this->assertFalse($resource->isSuspended()); $user->suspendAccount(); $this->assertTrue($user->fresh()->isSuspended()); $this->assertTrue($userB->fresh()->isSuspended()); $this->assertTrue($domain->fresh()->isSuspended()); $this->assertTrue($group->fresh()->isSuspended()); $this->assertFalse($resource->fresh()->isSuspended()); } /** * Tests for UserSettingsTrait::setSettings() and getSetting() and getSettings() */ public function testUserSettings(): void { - Queue::fake(); - Queue::assertNothingPushed(); + $this->fakeQueueReset(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0); // Test default settings // Note: Technicly this tests UserObserver::created() behavior $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(2, $all_settings); $this->assertSame('country', $all_settings[0]->key); $this->assertSame('CH', $all_settings[0]->value); $this->assertSame('currency', $all_settings[1]->key); $this->assertSame('CHF', $all_settings[1]->value); // Add a setting $user->setSetting('first_name', 'Firstname'); if (\config('app.with_ldap')) { Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); } // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname', $user->getSetting('first_name')); $this->assertSame('Firstname', $user->fresh()->getSetting('first_name')); // Update a setting + $this->fakeQueueReset(); $user->setSetting('first_name', 'Firstname1'); if (\config('app.with_ldap')) { - Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); + Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); } // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname1', $user->getSetting('first_name')); $this->assertSame('Firstname1', $user->fresh()->getSetting('first_name')); // Delete a setting (null) + $this->fakeQueueReset(); $user->setSetting('first_name', null); if (\config('app.with_ldap')) { - Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); + Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); } // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Delete a setting (empty string) + $this->fakeQueueReset(); $user->setSetting('first_name', 'Firstname1'); $user->setSetting('first_name', ''); if (\config('app.with_ldap')) { - Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 5); + Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); } // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Set multiple settings at once + $this->fakeQueueReset(); $user->setSettings([ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'country' => null, ]); - // TODO: This really should create a single UserUpdate job, not 3 + // Thanks to job locking it creates a single UserUpdate job if (\config('app.with_ldap')) { - Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 7); + Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); } // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname2', $user->getSetting('first_name')); $this->assertSame('Firstname2', $user->fresh()->getSetting('first_name')); $this->assertSame('Lastname2', $user->getSetting('last_name')); $this->assertSame('Lastname2', $user->fresh()->getSetting('last_name')); $this->assertSame(null, $user->getSetting('country')); $this->assertSame(null, $user->fresh()->getSetting('country')); - $all_settings = $user->settings()->orderBy('key')->get(); - $this->assertCount(3, $all_settings); + $expected = [ + 'currency' => 'CHF', + 'first_name' => 'Firstname2', + 'last_name' => 'Lastname2', + ]; - // Test getSettings() method - $this->assertSame( - [ - 'first_name' => 'Firstname2', - 'last_name' => 'Lastname2', - 'unknown' => null, - ], - $user->getSettings(['first_name', 'last_name', 'unknown']) - ); + $this->assertSame($expected, $user->settings()->orderBy('key')->get()->pluck('value', 'key')->all()); + + $expected = [ + 'first_name' => 'Firstname2', + 'last_name' => 'Lastname2', + 'unknown' => null, + ]; + + $this->assertSame($expected, $user->getSettings(['first_name', 'last_name', 'unknown'])); } /** * Tests for User::users() */ public function testUsers(): void { $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $john->wallets()->first(); $users = $john->users()->orderBy('email')->get(); $this->assertCount(4, $users); $this->assertEquals($jack->id, $users[0]->id); $this->assertEquals($joe->id, $users[1]->id); $this->assertEquals($john->id, $users[2]->id); $this->assertEquals($ned->id, $users[3]->id); $users = $jack->users()->orderBy('email')->get(); $this->assertCount(0, $users); $users = $ned->users()->orderBy('email')->get(); $this->assertCount(4, $users); } /** * Tests for User::walletOwner() (from EntitleableTrait) */ public function testWalletOwner(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $this->assertSame($john->id, $john->walletOwner()->id); $this->assertSame($john->id, $jack->walletOwner()->id); $this->assertSame($john->id, $ned->walletOwner()->id); // User with no entitlements $user = $this->getTestUser('UserAccountA@UserAccount.com'); $this->assertSame($user->id, $user->walletOwner()->id); } /** * Tests for User::wallets() */ public function testWallets(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $this->assertSame(1, $john->wallets()->count()); $this->assertCount(1, $john->wallets); $this->assertInstanceOf(\App\Wallet::class, $john->wallets->first()); $this->assertSame(1, $ned->wallets()->count()); $this->assertCount(1, $ned->wallets); $this->assertInstanceOf(\App\Wallet::class, $ned->wallets->first()); } } diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php index 104d2168..dac3856b 100644 --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -1,910 +1,910 @@ users as $user) { $this->deleteTestUser($user); } Sku::select()->update(['fee' => 0]); Payment::query()->delete(); VatRate::query()->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { foreach ($this->users as $user) { $this->deleteTestUser($user); } Sku::select()->update(['fee' => 0]); Payment::query()->delete(); VatRate::query()->delete(); Plan::withEnvTenantContext()->where('title', 'individual')->update(['months' => 1]); parent::tearDown(); } /** * Test that turning wallet balance from negative to positive * unsuspends and undegrades the account */ public function testBalanceTurnsPositive(): void { Queue::fake(); $user = $this->getTestUser('UserWallet1@UserWallet.com'); $user->suspend(); $user->degrade(); $wallet = $user->wallets()->first(); $wallet->balance = -100; $wallet->save(); $this->assertTrue($user->isSuspended()); $this->assertTrue($user->isDegraded()); $this->assertNotNull($wallet->getSetting('balance_negative_since')); $wallet->balance = 100; $wallet->save(); $user->refresh(); $this->assertFalse($user->isSuspended()); $this->assertFalse($user->isDegraded()); $this->assertNull($wallet->getSetting('balance_negative_since')); // Test un-restricting users on balance change $this->deleteTestUser('UserWallet1@UserWallet.com'); $owner = $this->getTestUser('UserWallet1@UserWallet.com'); $user1 = $this->getTestUser('UserWallet2@UserWallet.com'); $user2 = $this->getTestUser('UserWallet3@UserWallet.com'); $package = Package::withEnvTenantContext()->where('title', 'lite')->first(); $owner->assignPackage($package, $user1); $owner->assignPackage($package, $user2); $wallet = $owner->wallets()->first(); $owner->restrict(); $user1->restrict(); $user2->restrict(); $this->assertTrue($owner->isRestricted()); $this->assertTrue($user1->isRestricted()); $this->assertTrue($user2->isRestricted()); - Queue::fake(); + $this->fakeQueueReset(); $wallet->balance = 100; $wallet->save(); $this->assertFalse($owner->fresh()->isRestricted()); $this->assertFalse($user1->fresh()->isRestricted()); $this->assertFalse($user2->fresh()->isRestricted()); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); // TODO: Test group account and unsuspending domain/members/groups } /** * Test for Wallet::balanceLastsUntil() */ public function testBalanceLastsUntil(): void { // Monthly cost of all entitlements: 990 // 28 days: 35.36 per day // 31 days: 31.93 per day $user = $this->getTestUser('jane@kolabnow.com'); $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $user->assignPlan($plan); $wallet = $user->wallets()->first(); // User/entitlements created today, balance=0 $until = $wallet->balanceLastsUntil(); $this->assertSame( Carbon::now()->addMonthsWithoutOverflow(1)->toDateString(), $until->toDateString() ); // User/entitlements created today, balance=-10 CHF $wallet->balance = -1000; $until = $wallet->balanceLastsUntil(); $this->assertSame(null, $until); // User/entitlements created today, balance=-9,99 CHF (monthly cost) $wallet->balance = 990; $until = $wallet->balanceLastsUntil(); $daysInLastMonth = \App\Utils::daysInLastMonth(); $delta = Carbon::now()->addMonthsWithoutOverflow(1)->addDays($daysInLastMonth)->diff($until)->days; $this->assertTrue($delta <= 1); $this->assertTrue($delta >= -1); // Old entitlements, 100% discount $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); $discount = \App\Discount::withEnvTenantContext()->where('discount', 100)->first(); $wallet->discount()->associate($discount); $until = $wallet->refresh()->balanceLastsUntil(); $this->assertSame(null, $until); // User with no entitlements $wallet->discount()->dissociate($discount); $wallet->entitlements()->delete(); $until = $wallet->refresh()->balanceLastsUntil(); $this->assertSame(null, $until); } /** * Verify a wallet is created, when a user is created. */ public function testCreateUserCreatesWallet(): void { $user = $this->getTestUser('UserWallet1@UserWallet.com'); $this->assertCount(1, $user->wallets); $this->assertSame(\config('app.currency'), $user->wallets[0]->currency); $this->assertSame(0, $user->wallets[0]->balance); } /** * Verify a user can haz more wallets. */ public function testAddWallet(): void { $user = $this->getTestUser('UserWallet2@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); $this->assertCount(2, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertEquals(0, $wallet->balance); } ); // For now all wallets use system currency $this->assertFalse($user->wallets()->where('currency', 'USD')->exists()); } /** * Verify we can not delete a user wallet that holds balance. */ public function testDeleteWalletWithCredit(): void { $user = $this->getTestUser('UserWallet3@UserWallet.com'); $user->wallets()->each( function ($wallet) { $wallet->credit(100)->save(); } ); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can not delete a wallet that is the last wallet. */ public function testDeleteLastWallet(): void { $user = $this->getTestUser('UserWallet4@UserWallet.com'); $this->assertCount(1, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can remove a wallet that is an additional wallet. */ public function testDeleteAddtWallet(): void { $user = $this->getTestUser('UserWallet5@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); // For now additional wallets with a different currency is not allowed $this->assertFalse($user->wallets()->where('currency', 'USD')->exists()); /* $user->wallets()->each( function ($wallet) { if ($wallet->currency == 'USD') { $this->assertNotFalse($wallet->delete()); } } ); */ } /** * Verify a wallet can be assigned a controller. */ public function testAddController(): void { $userA = $this->getTestUser('WalletControllerA@WalletController.com'); $userB = $this->getTestUser('WalletControllerB@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertCount(1, $userB->accounts); $aWallet = $userA->wallets()->first(); $bAccount = $userB->accounts()->first(); $this->assertTrue($bAccount->id === $aWallet->id); } /** * Test Wallet::getMinMandateAmount() */ public function testGetMinMandateAmount(): void { $user = $this->getTestUser('WalletControllerA@WalletController.com'); $user->setSetting('plan_id', null); $wallet = $user->wallets()->first(); // No plan assigned $this->assertSame(Payment::MIN_AMOUNT, $wallet->getMinMandateAmount()); // Plan assigned $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $plan->months = 12; $plan->save(); $user->setSetting('plan_id', $plan->id); $this->assertSame(990 * 12, $wallet->getMinMandateAmount()); // Plan and discount $discount = Discount::where('discount', 30)->first(); $wallet->discount()->associate($discount); $wallet->save(); $this->assertSame((int) (990 * 12 * 0.70), $wallet->getMinMandateAmount()); } /** * Test Wallet::isController() */ public function testIsController(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $jack->wallet(); $this->assertTrue($wallet->isController($john)); $this->assertTrue($wallet->isController($ned)); $this->assertFalse($wallet->isController($jack)); } /** * Verify controllers can also be removed from wallets. */ public function testRemoveWalletController(): void { $userA = $this->getTestUser('WalletController2A@WalletController.com'); $userB = $this->getTestUser('WalletController2B@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $userB->refresh(); $userB->accounts()->each( function ($wallet) use ($userB) { $wallet->removeController($userB); } ); $this->assertCount(0, $userB->accounts); } /** * Test for charging entitlements (including tenant commission calculations) */ public function testChargeEntitlements(): void { $user = $this->getTestUser('jane@kolabnow.com'); $discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first(); $wallet = $user->wallets()->first(); $wallet->discount()->associate($discount); $wallet->save(); // Add 40% fee to all SKUs Sku::select()->update(['fee' => DB::raw("`cost` * 0.4")]); $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $user->assignPlan($plan); $user->assignSku($storage, 5); $user->setSetting('plan_id', null); // disable plan and trial // Reset reseller's wallet balance and transactions $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); $reseller_wallet->transactions()->delete(); // Set fake NOW date to make simpler asserting results that depend on number of days in current/last month Carbon::setTestNow(Carbon::create(2021, 5, 21, 12)); // ------------------------------------------------ // Test skipping entitlements before a month passed // ------------------------------------------------ $backdate = Carbon::now()->subWeeks(3); $this->backdateEntitlements($user->entitlements, $backdate); // we expect no charges $this->assertSame(0, $wallet->chargeEntitlements()); $this->assertSame(0, $wallet->balance); $this->assertSame(0, $reseller_wallet->balance); $this->assertSame(0, $wallet->transactions()->count()); $this->assertSame(12, $user->entitlements()->where('updated_at', $backdate)->count()); // ------------------------------------ // Test normal charging of entitlements // ------------------------------------ // Backdate and charge entitlements, we're expecting one month to be charged $backdate = Carbon::now()->subWeeks(7); $this->backdateEntitlements($user->entitlements, $backdate); // Test with $apply=false argument $charge = $wallet->chargeEntitlements(false); $this->assertSame(778, $charge); $this->assertSame(0, $wallet->balance); $this->assertSame(0, $wallet->transactions()->count()); $charge = $wallet->chargeEntitlements(); $wallet->refresh(); $reseller_wallet->refresh(); // User discount is 30% // Expected: groupware: floor(490 * 70%) + mailbox: floor(500 * 70%) + storage: 5 * floor(25 * 70%) = 778 $this->assertSame(778, $charge); $this->assertSame(-778, $wallet->balance); // Reseller fee is 40% // Expected: 778 - groupware: floor(490 * 40%) - mailbox: floor(500 * 40%) - storage: 5 * floor(25 * 40%) = 332 $this->assertSame(332, $reseller_wallet->balance); $transactions = $wallet->transactions()->get(); $this->assertCount(1, $transactions); $trans = $transactions[0]; $this->assertSame('', $trans->description); $this->assertSame(-778, $trans->amount); $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); $reseller_transactions = $reseller_wallet->transactions()->get(); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Charged user jane@kolabnow.com", $trans->description); $this->assertSame(332, $trans->amount); $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); // Assert all entitlements' updated_at timestamp $date = $backdate->addMonthsWithoutOverflow(1); $this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get()); // Assert per-entitlement transactions $entitlement_transactions = Transaction::where('transaction_id', $transactions[0]->id) ->where('type', Transaction::ENTITLEMENT_BILLED) ->get(); $this->assertSame(7, $entitlement_transactions->count()); $this->assertSame(778, $entitlement_transactions->sum('amount')); $groupware_entitlement = $user->entitlements->where('sku_id', '===', $groupware->id)->first(); $mailbox_entitlement = $user->entitlements->where('sku_id', '===', $mailbox->id)->first(); $this->assertSame(1, $entitlement_transactions->where('object_id', $groupware_entitlement->id)->count()); $this->assertSame(1, $entitlement_transactions->where('object_id', $mailbox_entitlement->id)->count()); $excludes = [$mailbox_entitlement->id, $groupware_entitlement->id]; $this->assertSame(5, $entitlement_transactions->whereNotIn('object_id', $excludes)->count()); // ----------------------------------- // Test charging deleted entitlements // ----------------------------------- $wallet->balance = 0; $wallet->save(); $wallet->transactions()->delete(); $reseller_wallet->balance = 0; $reseller_wallet->save(); $reseller_wallet->transactions()->delete(); $user->removeSku($storage, 2); // we expect the wallet to have been charged for 19 days of use of 2 deleted storage entitlements $charge = $wallet->chargeEntitlements(); $wallet->refresh(); $reseller_wallet->refresh(); // 2 * floor(25 / 31 * 70% * 19) = 20 $this->assertSame(20, $charge); $this->assertSame(-20, $wallet->balance); // 20 - 2 * floor(25 / 31 * 40% * 19) = 8 $this->assertSame(8, $reseller_wallet->balance); $transactions = $wallet->transactions()->get(); $this->assertCount(1, $transactions); $trans = $transactions[0]; $this->assertSame('', $trans->description); $this->assertSame(-20, $trans->amount); $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); $reseller_transactions = $reseller_wallet->transactions()->get(); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Charged user jane@kolabnow.com", $trans->description); $this->assertSame(8, $trans->amount); $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); // Assert per-entitlement transactions $entitlement_transactions = Transaction::where('transaction_id', $transactions[0]->id) ->where('type', Transaction::ENTITLEMENT_BILLED) ->get(); $storage_entitlements = $user->entitlements->where('sku_id', $storage->id)->where('cost', '>', 0)->pluck('id'); $this->assertSame(2, $entitlement_transactions->count()); $this->assertSame(20, $entitlement_transactions->sum('amount')); $this->assertSame(2, $entitlement_transactions->whereIn('object_id', $storage_entitlements)->count()); // -------------------------------------------------- // Test skipping deleted entitlements already charged // -------------------------------------------------- $wallet->balance = 0; $wallet->save(); $wallet->transactions()->delete(); $reseller_wallet->balance = 0; $reseller_wallet->save(); $reseller_wallet->transactions()->delete(); // we expect no charges $this->assertSame(0, $wallet->chargeEntitlements()); $this->assertSame(0, $wallet->balance); $this->assertSame(0, $wallet->transactions()->count()); $this->assertSame(0, $reseller_wallet->fresh()->balance); // --------------------------------------------------------- // Test (not) charging entitlements deleted before 14 days // --------------------------------------------------------- $backdate = Carbon::now()->subDays(13); $ent = $user->entitlements->where('sku_id', $groupware->id)->first(); Entitlement::where('id', $ent->id)->update([ 'created_at' => $backdate, 'updated_at' => $backdate, 'deleted_at' => Carbon::now(), ]); // we expect no charges $this->assertSame(0, $wallet->chargeEntitlements()); $this->assertSame(0, $wallet->balance); $this->assertSame(0, $wallet->transactions()->count()); $this->assertSame(0, $reseller_wallet->fresh()->balance); // expect update of updated_at timestamp $this->assertSame(Carbon::now()->toDateTimeString(), $ent->fresh()->updated_at->toDateTimeString()); // ------------------------------------------------------- // Test charging a degraded account // Test both deleted and non-deleted in the same operation // ------------------------------------------------------- // At this point user has: mailbox + 8 x storage $backdate = Carbon::now()->subWeeks(7); $this->backdateEntitlements($user->entitlements->fresh(), $backdate); $user->status |= User::STATUS_DEGRADED; $user->saveQuietly(); $wallet->refresh(); $wallet->balance = 0; $wallet->save(); $reseller_wallet->balance = 0; $reseller_wallet->save(); Transaction::truncate(); $charge = $wallet->chargeEntitlements(); $reseller_wallet->refresh(); // User would be charged if not degraded: mailbox: floor(500 * 70%) + storage: 3 * floor(25 * 70%) = 401 $this->assertSame(0, $charge); $this->assertSame(0, $wallet->balance); // Expected: 0 - mailbox: floor(500 * 40%) - storage: 3 * floor(25 * 40%) = -230 $this->assertSame(-230, $reseller_wallet->balance); // Assert all entitlements' updated_at timestamp $date = $backdate->addMonthsWithoutOverflow(1); $this->assertSame(9, $wallet->entitlements()->where('updated_at', $date)->count()); // There should be only one transaction at this point (for the reseller wallet) $this->assertSame(1, Transaction::count()); } /** * Test for charging entitlements when in trial */ public function testChargeEntitlementsTrial(): void { $user = $this->getTestUser('jane@kolabnow.com'); $wallet = $user->wallets()->first(); // Add 40% fee to all SKUs Sku::select()->update(['fee' => DB::raw("`cost` * 0.4")]); $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $user->assignPlan($plan); $user->assignSku($storage, 5); // Reset reseller's wallet balance and transactions $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); $reseller_wallet->transactions()->delete(); // Set fake NOW date to make simpler asserting results that depend on number of days in current/last month Carbon::setTestNow(Carbon::create(2021, 5, 21, 12)); // ------------------------------------ // Test normal charging of entitlements // ------------------------------------ // Backdate and charge entitlements, we're expecting one month to be charged $backdate = Carbon::now()->subWeeks(7); // 2021-04-02 $this->backdateEntitlements($user->entitlements, $backdate, $backdate); $charge = $wallet->chargeEntitlements(); $reseller_wallet->refresh(); // Expected: storage: 5 * 25 = 125 (the rest is free in trial) $this->assertSame($balance = -125, $wallet->balance); $this->assertSame(-$balance, $charge); // Reseller fee is 40% // Expected: 125 - 5 * floor(25 * 40%) = 75 $this->assertSame($reseller_balance = 75, $reseller_wallet->balance); // Assert wallet transaction $transactions = $wallet->transactions()->get(); $this->assertCount(1, $transactions); $trans = $transactions[0]; $this->assertSame('', $trans->description); $this->assertSame($balance, $trans->amount); $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); // Assert entitlement transactions $etransactions = Transaction::where('transaction_id', $trans->id)->get(); $this->assertCount(5, $etransactions); $trans = $etransactions[0]; $this->assertSame(null, $trans->description); $this->assertSame(25, $trans->amount); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $trans->type); // Assert all entitlements' updated_at timestamp $date = $backdate->addMonthsWithoutOverflow(1); $this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get()); // Run again, expect no changes $charge = $wallet->chargeEntitlements(); $wallet->refresh(); $this->assertSame(0, $charge); $this->assertSame($balance, $wallet->balance); $this->assertCount(1, $wallet->transactions()->get()); $this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get()); // ----------------------------------- // Test charging deleted entitlements // ----------------------------------- $wallet->balance = 0; $wallet->save(); $reseller_wallet->balance = 0; $reseller_wallet->save(); Transaction::truncate(); $user->removeSku($storage, 2); $charge = $wallet->chargeEntitlements(); $wallet->refresh(); $reseller_wallet->refresh(); // we expect the wallet to have been charged for 19 days of use of // 2 deleted storage entitlements: 2 * floor(25 / 31 * 19) = 30 $this->assertSame(-30, $wallet->balance); $this->assertSame(30, $charge); // Reseller fee is 40% // Expected: 30 - 2 * floor(25 / 31 * 40% * 19) = 18 $this->assertSame(18, $reseller_wallet->balance); // Assert wallet transactions $transactions = $wallet->transactions()->get(); $this->assertCount(1, $transactions); $trans = $transactions[0]; $this->assertSame('', $trans->description); $this->assertSame(-30, $trans->amount); $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); // Assert entitlement transactions $etransactions = Transaction::where('transaction_id', $trans->id)->get(); $this->assertCount(2, $etransactions); $trans = $etransactions[0]; $this->assertSame(null, $trans->description); $this->assertSame(15, $trans->amount); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $trans->type); // Assert the deleted entitlements' updated_at timestamp was bumped $this->assertSame(2, $wallet->entitlements()->withTrashed()->whereColumn('updated_at', 'deleted_at')->count()); // TODO: Test a case when trial ends after the entitlement deletion date } /** * Tests for award() and penalty() */ public function testAwardAndPenalty(): void { $user = $this->getTestUser('UserWallet1@UserWallet.com'); $wallet = $user->wallets()->first(); // Test award $this->assertSame($wallet->id, $wallet->award(100, 'test')->id); $this->assertSame(100, $wallet->balance); $this->assertSame(100, $wallet->fresh()->balance); $transaction = $wallet->transactions()->first(); $this->assertSame(100, $transaction->amount); $this->assertSame(Transaction::WALLET_AWARD, $transaction->type); $this->assertSame('test', $transaction->description); $wallet->transactions()->delete(); // Test penalty $this->assertSame($wallet->id, $wallet->penalty(100, 'test')->id); $this->assertSame(0, $wallet->balance); $this->assertSame(0, $wallet->fresh()->balance); $transaction = $wallet->transactions()->first(); $this->assertSame(-100, $transaction->amount); $this->assertSame(Transaction::WALLET_PENALTY, $transaction->type); $this->assertSame('test', $transaction->description); } /** * Tests for chargeback() and refund() */ public function testChargebackAndRefund(): void { $this->markTestIncomplete(); } /** * Tests for updateEntitlements() */ public function testUpdateEntitlements(): void { $user = $this->getTestUser('jane@kolabnow.com'); $discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first(); $wallet = $user->wallets()->first(); $wallet->discount()->associate($discount); $wallet->save(); // Add 40% fee to all SKUs Sku::select()->update(['fee' => DB::raw("`cost` * 0.4")]); $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $user->assignPlan($plan); $user->setSetting('plan_id', null); // disable plan and trial // Reset reseller's wallet balance and transactions $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); $reseller_wallet->transactions()->delete(); // Set fake NOW date to make simpler asserting results that depend on number of days in current/last month Carbon::setTestNow(Carbon::create(2021, 5, 21, 12)); $now = Carbon::now(); // Backdate and charge entitlements $backdate = Carbon::now()->subWeeks(3)->setHour(10); $this->backdateEntitlements($user->entitlements, $backdate); // --------------------------------------- // Update entitlements with no cost charge // --------------------------------------- // Test with $withCost=false argument $charge = $wallet->updateEntitlements(false); $wallet->refresh(); $reseller_wallet->refresh(); $this->assertSame(0, $charge); $this->assertSame(0, $wallet->balance); $this->assertSame(0, $wallet->transactions()->count()); // Expected: 0 - groupware: floor(490 / 31 * 21 * 40%) - mailbox: floor(500 / 31 * 21 * 40%) = -267 $this->assertSame(-267, $reseller_wallet->balance); // Assert all entitlements' updated_at timestamp $date = $now->copy()->setTimeFrom($backdate); $this->assertCount(7, $wallet->entitlements()->where('updated_at', $date)->get()); $reseller_transactions = $reseller_wallet->transactions()->get(); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Charged user jane@kolabnow.com", $trans->description); $this->assertSame(-267, $trans->amount); $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); // ------------------------------------ // Update entitlements with cost charge // ------------------------------------ $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); $reseller_wallet->transactions()->delete(); $this->backdateEntitlements($user->entitlements, $backdate); $charge = $wallet->updateEntitlements(); $wallet->refresh(); $reseller_wallet->refresh(); // User discount is 30% // Expected: groupware: floor(490 / 31 * 21 * 70%) + mailbox: floor(500 / 31 * 21 * 70%) = 469 $this->assertSame(469, $charge); $this->assertSame(-469, $wallet->balance); // Reseller fee is 40% // Expected: 469 - groupware: floor(490 / 31 * 21 * 40%) - mailbox: floor(500 / 31 * 21 * 40%) = 202 $this->assertSame(202, $reseller_wallet->balance); $transactions = $wallet->transactions()->get(); $this->assertCount(1, $transactions); $trans = $transactions[0]; $this->assertSame('', $trans->description); $this->assertSame(-469, $trans->amount); $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); $reseller_transactions = $reseller_wallet->transactions()->get(); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Charged user jane@kolabnow.com", $trans->description); $this->assertSame(202, $trans->amount); $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); // Assert all entitlements' updated_at timestamp $date = $now->copy()->setTimeFrom($backdate); $this->assertCount(7, $wallet->entitlements()->where('updated_at', $date)->get()); // Assert per-entitlement transactions $groupware_entitlement = $user->entitlements->where('sku_id', '===', $groupware->id)->first(); $mailbox_entitlement = $user->entitlements->where('sku_id', '===', $mailbox->id)->first(); $entitlement_transactions = Transaction::where('transaction_id', $transactions[0]->id) ->where('type', Transaction::ENTITLEMENT_BILLED) ->get(); $this->assertSame(2, $entitlement_transactions->count()); $this->assertSame(469, $entitlement_transactions->sum('amount')); $this->assertSame(1, $entitlement_transactions->where('object_id', $groupware_entitlement->id)->count()); $this->assertSame(1, $entitlement_transactions->where('object_id', $mailbox_entitlement->id)->count()); } /** * Tests for vatRate() */ public function testVatRate(): void { $rate1 = VatRate::create([ 'start' => now()->subDay(), 'country' => 'US', 'rate' => 7.5, ]); $rate2 = VatRate::create([ 'start' => now()->subDay(), 'country' => 'DE', 'rate' => 10.0, ]); $user = $this->getTestUser('UserWallet1@UserWallet.com'); $wallet = $user->wallets()->first(); $user->setSetting('country', null); $this->assertSame(null, $wallet->vatRate()); $user->setSetting('country', 'PL'); $this->assertSame(null, $wallet->vatRate()); $user->setSetting('country', 'US'); $this->assertSame($rate1->id, $wallet->vatRate()->id); // @phpstan-ignore-line $user->setSetting('country', 'DE'); $this->assertSame($rate2->id, $wallet->vatRate()->id); // @phpstan-ignore-line // Test $start argument $rate3 = VatRate::create([ 'start' => now()->subYear(), 'country' => 'DE', 'rate' => 5.0, ]); $this->assertSame($rate2->id, $wallet->vatRate()->id); // @phpstan-ignore-line $this->assertSame($rate3->id, $wallet->vatRate(now()->subMonth())->id); $this->assertSame(null, $wallet->vatRate(now()->subYears(2))); } } diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php index 990c813b..3ea04ebd 100644 --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -1,769 +1,787 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Test Domain Owner', ]; /** * Some users for the hosted domain, ultimately including the owner. * * @var \App\User[] */ protected $domainUsers = []; /** * A specific user that is a regular user in the hosted domain. * * @var ?\App\User */ protected $jack; /** * A specific user that is a controller on the wallet to which the hosted domain is charged. * * @var ?\App\User */ protected $jane; /** * A specific user that has a second factor configured. * * @var ?\App\User */ protected $joe; /** * One of the domains that is available for public registration. * * @var ?\App\Domain */ protected $publicDomain; /** * A newly generated user in a public domain. * * @var ?\App\User */ protected $publicDomainUser; /** * A placeholder for a password that can be generated. * * Should be generated with `\App\Utils::generatePassphrase()`. * * @var ?string */ protected $userPassword; /** * Register the beta entitlement for a user */ protected function addBetaEntitlement($user, $titles = []): void { // Add beta + $title entitlements $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $user->assignSku($beta_sku); if (!empty($titles)) { Sku::withEnvTenantContext()->whereIn('title', (array) $titles)->get() ->each(function ($sku) use ($user) { $user->assignSku($sku); }); } } /** * Assert that the entitlements for the user match the expected list of entitlements. * * @param \App\User|\App\Domain $object The object for which the entitlements need to be pulled. * @param array $expected An array of expected \App\Sku titles. */ protected function assertEntitlements($object, $expected) { // Assert the user entitlements $skus = $object->entitlements()->get() ->map(function ($ent) { return $ent->sku->title; }) ->toArray(); sort($skus); Assert::assertSame($expected, $skus); } /** * Assert content of the SKU element in an API response * * @param string $sku_title The SKU title * @param array $result The result to assert * @param array $other Other items the SKU itself does not include */ protected function assertSkuElement($sku_title, $result, $other = []): void { $sku = Sku::withEnvTenantContext()->where('title', $sku_title)->first(); $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']); foreach ($other as $key => $value) { $this->assertSame($value, $result[$key]); } $this->assertCount(8 + count($other), $result); } /** * Set a specific date to existing entitlements */ protected function backdateEntitlements($entitlements, $targetDate, $targetCreatedDate = null): void { $wallets = []; $ids = []; foreach ($entitlements as $entitlement) { $ids[] = $entitlement->id; $wallets[] = $entitlement->wallet_id; } \App\Entitlement::whereIn('id', $ids)->update([ 'created_at' => $targetCreatedDate ?: $targetDate, 'updated_at' => $targetDate, ]); if (!empty($wallets)) { $wallets = array_unique($wallets); $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all(); \App\User::whereIn('id', $owners)->update([ 'created_at' => $targetCreatedDate ?: $targetDate ]); } } /** * Removes all beta entitlements from the database */ protected function clearBetaEntitlements(): void { $beta_handlers = [ 'App\Handlers\Beta', ]; $betas = Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all(); \App\Entitlement::whereIn('sku_id', $betas)->delete(); } /** * Creates the application. * * @return \Illuminate\Foundation\Application */ public function createApplication() { $app = require __DIR__ . '/../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); return $app; } /** * Create a set of transaction log entries for a wallet */ protected function createTestTransactions($wallet) { $result = []; $date = Carbon::now(); $debit = 0; $entitlementTransactions = []; foreach ($wallet->entitlements as $entitlement) { if ($entitlement->cost) { $debit += $entitlement->cost; $entitlementTransactions[] = $entitlement->createTransaction( Transaction::ENTITLEMENT_BILLED, $entitlement->cost ); } } $transaction = Transaction::create( [ 'user_email' => 'jeroen@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_DEBIT, 'amount' => $debit * -1, 'description' => 'Payment', ] ); $result[] = $transaction; Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]); $transaction = Transaction::create( [ 'user_email' => null, 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_CREDIT, 'amount' => 2000, 'description' => 'Payment', ] ); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; $types = [ Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY, ]; // The page size is 10, so we generate so many to have at least two pages $loops = 10; while ($loops-- > 0) { $type = $types[count($result) % count($types)]; $transaction = Transaction::create([ 'user_email' => 'jeroen.@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => $type, 'amount' => 11 * (count($result) + 1) * ($type == Transaction::WALLET_PENALTY ? -1 : 1), 'description' => 'TRANS' . $loops, ]); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; } return $result; } /** * Delete a test domain whatever it takes. * * @coversNothing */ protected function deleteTestDomain($name) { Queue::fake(); $domain = Domain::withTrashed()->where('namespace', $name)->first(); if (!$domain) { return; } $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $domain->forceDelete(); } /** * Delete a test group whatever it takes. * * @coversNothing */ protected function deleteTestGroup($email) { Queue::fake(); $group = Group::withTrashed()->where('email', $email)->first(); if (!$group) { return; } LDAP::deleteGroup($group); $group->forceDelete(); } /** * Delete a test resource whatever it takes. * * @coversNothing */ protected function deleteTestResource($email) { Queue::fake(); $resource = Resource::withTrashed()->where('email', $email)->first(); if (!$resource) { return; } LDAP::deleteResource($resource); $resource->forceDelete(); } /** * Delete a test room whatever it takes. * * @coversNothing */ protected function deleteTestRoom($name) { Queue::fake(); $room = \App\Meet\Room::withTrashed()->where('name', $name)->first(); if (!$room) { return; } $room->forceDelete(); } /** * Delete a test shared folder whatever it takes. * * @coversNothing */ protected function deleteTestSharedFolder($email) { Queue::fake(); $folder = SharedFolder::withTrashed()->where('email', $email)->first(); if (!$folder) { return; } LDAP::deleteSharedFolder($folder); $folder->forceDelete(); } /** * Delete a test user whatever it takes. * * @coversNothing */ protected function deleteTestUser($email) { Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } if (\config('app.with_imap')) { IMAP::deleteUser($user); } if (\config('app.with_ldap')) { LDAP::deleteUser($user); } $user->forceDelete(); } /** * Delete a test companion app whatever it takes. * * @coversNothing */ protected function deleteTestCompanionApp($deviceId) { Queue::fake(); $companionApp = CompanionApp::where('device_id', $deviceId)->first(); if (!$companionApp) { return; } $companionApp->forceDelete(); } /** * Helper to access protected property of an object */ protected static function getObjectProperty($object, $property_name) { $reflection = new \ReflectionClass($object); $property = $reflection->getProperty($property_name); $property->setAccessible(true); return $property->getValue($object); } /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get Group object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestGroup($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Group::firstOrCreate(['email' => $email], $attrib); } /** * Get Resource object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestResource($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $resource = Resource::where('email', $email)->first(); if (!$resource) { list($local, $domain) = explode('@', $email, 2); $resource = new Resource(); $resource->email = $email; $resource->domainName = $domain; if (!isset($attrib['name'])) { $resource->name = $local; } } foreach ($attrib as $key => $val) { $resource->{$key} = $val; } $resource->save(); return $resource; } /** * Get Room object by name, create it if needed. * * @coversNothing */ protected function getTestRoom($name, $wallet = null, $attrib = [], $config = [], $title = null) { $attrib['name'] = $name; $room = \App\Meet\Room::create($attrib); if ($wallet) { $room->assignToWallet($wallet, $title); } if (!empty($config)) { $room->setConfig($config); } return $room; } /** * Get SharedFolder object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestSharedFolder($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $folder = SharedFolder::where('email', $email)->first(); if (!$folder) { list($local, $domain) = explode('@', $email, 2); $folder = new SharedFolder(); $folder->email = $email; $folder->domainName = $domain; if (!isset($attrib['name'])) { $folder->name = $local; } } foreach ($attrib as $key => $val) { $folder->{$key} = $val; } $folder->save(); return $folder; } /** * Get User object by email, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestUser($email, $attrib = [], $createInBackends = false) { // Disable jobs (i.e. skip LDAP oprations) if (!$createInBackends) { Queue::fake(); } $user = User::firstOrCreate(['email' => $email], $attrib); if ($user->trashed()) { // Note: we do not want to use user restore here User::where('id', $user->id)->forceDelete(); $user = User::create(['email' => $email] + $attrib); } return $user; } /** * Get CompanionApp object by deviceId, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestCompanionApp($deviceId, $user, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $companionApp = CompanionApp::firstOrCreate( [ 'device_id' => $deviceId, 'user_id' => $user->id, 'notification_token' => '', 'mfa_enabled' => 1 ], $attrib ); return $companionApp; } /** * Call protected/private method of a class. * * @param object $object Instantiated object that we will run method on. * @param string $methodName Method name to call * @param array $parameters Array of parameters to pass into method. * * @return mixed Method return. */ protected function invokeMethod($object, $methodName, array $parameters = []) { $reflection = new \ReflectionClass(get_class($object)); $method = $reflection->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $parameters); } + /** + * Init fake queue. Release unique job locks. + */ + protected function fakeQueueReset() + { + // Release all locks for ShouldBeUnique jobs. Works only with Redis cache. + $db = \cache()->getStore()->lockConnection(); + $prefix = $db->getOptions()->prefix?->getPrefix(); + + foreach ($db->keys('*') as $key) { + if (strpos($key, 'laravel_unique_job') !== false) { + $db->del($prefix ? substr($key, strlen($prefix)) : $key); + } + } + + Queue::fake(); + } + /** * Extract content of an email message. * * @param \Illuminate\Mail\Mailable $mail Mailable object * * @return array Parsed message data: * - 'plain': Plain text body * - 'html: HTML body * - 'subject': Mail subject */ protected function renderMail(\Illuminate\Mail\Mailable $mail): array { $mail->build(); // @phpstan-ignore-line $result = $this->invokeMethod($mail, 'renderForAssertions'); return [ 'plain' => $result[1], 'html' => $result[0], 'subject' => $mail->subject, ]; } /** * Reset a room after tests */ public function resetTestRoom(string $room_name = 'john', $config = []) { $room = \App\Meet\Room::where('name', $room_name)->first(); $room->setSettings(['password' => null, 'locked' => null, 'nomedia' => null]); if ($room->session_id) { $room->session_id = null; $room->save(); } if (!empty($config)) { $room->setConfig($config); } return $room; } protected function setUpTest() { $this->userPassword = \App\Utils::generatePassphrase(); $this->domainHosted = $this->getTestDomain( 'test.domain', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $this->getTestDomain( 'test2.domain2', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $packageKolab = \App\Package::where('title', 'kolab')->first(); $this->domainOwner = $this->getTestUser('john@test.domain', ['password' => $this->userPassword]); $this->domainOwner->assignPackage($packageKolab); $this->domainOwner->setSettings($this->domainOwnerSettings); $this->domainOwner->setAliases(['alias1@test2.domain2']); // separate for regular user $this->jack = $this->getTestUser('jack@test.domain', ['password' => $this->userPassword]); // separate for wallet controller $this->jane = $this->getTestUser('jane@test.domain', ['password' => $this->userPassword]); $this->joe = $this->getTestUser('joe@test.domain', ['password' => $this->userPassword]); $this->domainUsers[] = $this->jack; $this->domainUsers[] = $this->jane; $this->domainUsers[] = $this->joe; $this->domainUsers[] = $this->getTestUser('jill@test.domain', ['password' => $this->userPassword]); foreach ($this->domainUsers as $user) { $this->domainOwner->assignPackage($packageKolab, $user); } $this->domainUsers[] = $this->domainOwner; // assign second factor to joe $this->joe->assignSku(Sku::where('title', '2fa')->first()); \App\Auth\SecondFactor::seed($this->joe->email); usort( $this->domainUsers, function ($a, $b) { return $a->email > $b->email; } ); $this->domainHosted->assignPackage( \App\Package::where('title', 'domain-hosting')->first(), $this->domainOwner ); $wallet = $this->domainOwner->wallets()->first(); $wallet->addController($this->jane); $this->publicDomain = \App\Domain::where('type', \App\Domain::TYPE_PUBLIC)->first(); $this->publicDomainUser = $this->getTestUser( 'john@' . $this->publicDomain->namespace, ['password' => $this->userPassword] ); $this->publicDomainUser->assignPackage($packageKolab); Cache::forget('duskconfig'); } public function tearDown(): void { foreach ($this->domainUsers as $user) { if ($user == $this->domainOwner) { continue; } $this->deleteTestUser($user->email); } if ($this->domainOwner) { $this->deleteTestUser($this->domainOwner->email); } if ($this->domainHosted) { $this->deleteTestDomain($this->domainHosted->namespace); } if ($this->publicDomainUser) { $this->deleteTestUser($this->publicDomainUser->email); } Cache::forget('duskconfig'); parent::tearDown(); } }