diff --git a/src/app/Console/Commands/DomainRestore.php b/src/app/Console/Commands/DomainRestore.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/DomainRestore.php @@ -0,0 +1,54 @@ +where('namespace', $this->argument('domain'))->first(); + + if (!$domain) { + $this->error("Domain not found."); + return 1; + } + + if (!$domain->trashed()) { + $this->error("The domain is not yet deleted."); + return 1; + } + + $wallet = $domain->wallet(); + + if ($wallet && !$wallet->owner) { + $this->error("The domain owner is deleted."); + return 1; + } + + DB::beginTransaction(); + $domain->restore(); + DB::commit(); + } +} diff --git a/src/app/Console/Commands/UserRestore.php b/src/app/Console/Commands/UserRestore.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/UserRestore.php @@ -0,0 +1,47 @@ +where('email', $this->argument('user'))->first(); + + if (!$user) { + $this->error('User not found.'); + return 1; + } + + if (!$user->trashed()) { + $this->error('The user is not yet deleted.'); + return 1; + } + + DB::beginTransaction(); + $user->restore(); + DB::commit(); + } +} diff --git a/src/app/Console/ObjectUpdateCommand.php b/src/app/Console/ObjectUpdateCommand.php --- a/src/app/Console/ObjectUpdateCommand.php +++ b/src/app/Console/ObjectUpdateCommand.php @@ -2,6 +2,7 @@ namespace App\Console; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\Schema; /** diff --git a/src/app/Jobs/Domain/DeleteJob.php b/src/app/Jobs/Domain/DeleteJob.php --- a/src/app/Jobs/Domain/DeleteJob.php +++ b/src/app/Jobs/Domain/DeleteJob.php @@ -24,6 +24,11 @@ \App\Backends\LDAP::deleteDomain($domain); $domain->status |= \App\Domain::STATUS_DELETED; + + if ($domain->isLdapReady()) { + $domain->status ^= \App\Domain::STATUS_LDAP_READY; + } + $domain->save(); } } diff --git a/src/app/Jobs/Domain/VerifyJob.php b/src/app/Jobs/Domain/VerifyJob.php --- a/src/app/Jobs/Domain/VerifyJob.php +++ b/src/app/Jobs/Domain/VerifyJob.php @@ -16,9 +16,5 @@ $domain = $this->getDomain(); $domain->verify(); - - // TODO: What should happen if the domain is not registered yet? - // Should we start a new job with some specified delay? - // Or we just give the user a button to start verification again? } } diff --git a/src/app/Jobs/User/DeleteJob.php b/src/app/Jobs/User/DeleteJob.php --- a/src/app/Jobs/User/DeleteJob.php +++ b/src/app/Jobs/User/DeleteJob.php @@ -28,6 +28,15 @@ \App\Backends\LDAP::deleteUser($user); $user->status |= \App\User::STATUS_DELETED; + + if ($user->isLdapReady()) { + $user->status ^= \App\User::STATUS_LDAP_READY; + } + + if ($user->isImapReady()) { + $user->status ^= \App\User::STATUS_IMAP_READY; + } + $user->save(); } } diff --git a/src/app/Observers/DomainObserver.php b/src/app/Observers/DomainObserver.php --- a/src/app/Observers/DomainObserver.php +++ b/src/app/Observers/DomainObserver.php @@ -88,6 +88,32 @@ } /** + * Handle the domain "restoring" event. + * + * @param \App\Domain $domain The domain. + * + * @return void + */ + public function restoring(Domain $domain) + { + // Make sure it's not DELETED/LDAP_READY/SUSPENDED + if ($domain->isDeleted()) { + $domain->status ^= Domain::STATUS_DELETED; + } + if ($domain->isLdapReady()) { + $domain->status ^= Domain::STATUS_LDAP_READY; + } + if ($domain->isSuspended()) { + $domain->status ^= Domain::STATUS_SUSPENDED; + } + if ($domain->isConfirmed() && $domain->isVerified()) { + $domain->status |= Domain::STATUS_ACTIVE; + } + + // Note: $domain->save() is invoked between 'restoring' and 'restored' events + } + + /** * Handle the domain "restored" event. * * @param \App\Domain $domain The domain. @@ -96,7 +122,25 @@ */ public function restored(Domain $domain) { - // + // Restore domain entitlements + // We'll restore only these that were deleted last. So, first we get + // the maximum deleted_at timestamp and then use it to select + // domain entitlements for restore + $deleted_at = \App\Entitlement::withTrashed() + ->where('entitleable_id', $domain->id) + ->where('entitleable_type', Domain::class) + ->max('deleted_at'); + + if ($deleted_at) { + \App\Entitlement::withTrashed() + ->where('entitleable_id', $domain->id) + ->where('entitleable_type', Domain::class) + ->where('deleted_at', '>=', (new \Carbon\Carbon($deleted_at))->subMinute()) + ->update(['updated_at' => now(), 'deleted_at' => null]); + } + + // Create the domain in LDAP again + \App\Jobs\Domain\CreateJob::dispatch($domain->id); } /** diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -255,6 +255,86 @@ } /** + * Handle the user "restoring" event. + * + * @param \App\User $user The user + * + * @return void + */ + public function restoring(User $user) + { + // Make sure it's not DELETED/LDAP_READY/IMAP_READY/SUSPENDED anymore + if ($user->isDeleted()) { + $user->status ^= User::STATUS_DELETED; + } + if ($user->isLdapReady()) { + $user->status ^= User::STATUS_LDAP_READY; + } + if ($user->isImapReady()) { + $user->status ^= User::STATUS_IMAP_READY; + } + if ($user->isSuspended()) { + $user->status ^= User::STATUS_SUSPENDED; + } + + $user->status |= User::STATUS_ACTIVE; + + // Note: $user->save() is invoked between 'restoring' and 'restored' events + } + + /** + * Handle the user "restored" event. + * + * @param \App\User $user The user + * + * @return void + */ + public function restored(User $user) + { + $wallets = $user->wallets()->pluck('id')->all(); + + // Restore user entitlements + // We'll restore only these that were deleted last. So, first we get + // the maximum deleted_at timestamp and then use it to select + // entitlements for restore + $deleted_at = \App\Entitlement::withTrashed() + ->where('entitleable_id', $user->id) + ->where('entitleable_type', User::class) + ->max('deleted_at'); + + if ($deleted_at) { + $threshold = (new \Carbon\Carbon($deleted_at))->subMinute(); + + // We need at least the user domain so it can be created in ldap. + // FIXME: What if the domain is owned by someone else? + $domain = $user->domain(); + if ($domain->trashed() && !$domain->isPublic()) { + // Note: Domain entitlements will be restored by the DomainObserver + $domain->restore(); + } + + // Restore user entitlements + \App\Entitlement::withTrashed() + ->where('entitleable_id', $user->id) + ->where('entitleable_type', User::class) + ->where('deleted_at', '>=', $threshold) + ->update(['updated_at' => now(), 'deleted_at' => null]); + + // Note: We're assuming that cost of entitlements was correct + // on user deletion, so we don't have to re-calculate it again. + } + + // FIXME: Should we reset user aliases? or re-validate them in any way? + + // Create user record in LDAP, then run the verification process + $chain = [ + new \App\Jobs\User\VerifyJob($user->id), + ]; + + \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); + } + + /** * Handle the "retrieving" event. * * @param User $user The user that is being retrieved. diff --git a/src/tests/Feature/Console/DomainRestoreTest.php b/src/tests/Feature/Console/DomainRestoreTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/DomainRestoreTest.php @@ -0,0 +1,91 @@ +deleteTestUser('user@force-delete.com'); + $this->deleteTestDomain('force-delete.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('user@force-delete.com'); + $this->deleteTestDomain('force-delete.com'); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + Queue::fake(); + + // Non-existing domain + $code = \Artisan::call("domain:restore unknown.org"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("Domain not found.", $output); + + // Create a user account for delete + $user = $this->getTestUser('user@force-delete.com'); + $domain = $this->getTestDomain('force-delete.com', [ + 'status' => \App\Domain::STATUS_NEW, + 'type' => \App\Domain::TYPE_HOSTED, + ]); + $package_kolab = \App\Package::where('title', 'kolab')->first(); + $package_domain = \App\Package::where('title', 'domain-hosting')->first(); + $user->assignPackage($package_kolab); + $domain->assignPackage($package_domain, $user); + $wallet = $user->wallets()->first(); + $entitlements = $wallet->entitlements->pluck('id')->all(); + + $this->assertCount(5, $entitlements); + + // Non-deleted domain + $code = \Artisan::call("domain:restore force-delete.com"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("The domain is not yet deleted.", $output); + + $domain->delete(); + + $this->assertTrue($domain->fresh()->trashed()); + + // Deleted domain + $code = \Artisan::call("domain:restore force-delete.com"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("", $output); + + $this->assertFalse($domain->fresh()->trashed()); + + $user->delete(); + + $this->assertTrue($domain->fresh()->trashed()); + $this->assertTrue($user->fresh()->trashed()); + + // Deleted domain, deleted owner + $code = \Artisan::call("domain:restore force-delete.com"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("The domain owner is deleted.", $output); + + $this->assertTrue($domain->fresh()->trashed()); + } +} diff --git a/src/tests/Feature/Console/UserRestoreTest.php b/src/tests/Feature/Console/UserRestoreTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/UserRestoreTest.php @@ -0,0 +1,80 @@ +deleteTestUser('user@force-delete.com'); + $this->deleteTestDomain('force-delete.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('user@force-delete.com'); + $this->deleteTestDomain('force-delete.com'); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + Queue::fake(); + + // Non-existing user + $code = \Artisan::call("user:restore unknown@unknown.org"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("User not found.", $output); + + // Create a user account for delete + $user = $this->getTestUser('user@force-delete.com'); + $domain = $this->getTestDomain('force-delete.com', [ + 'status' => \App\Domain::STATUS_NEW, + 'type' => \App\Domain::TYPE_HOSTED, + ]); + $package_kolab = \App\Package::where('title', 'kolab')->first(); + $package_domain = \App\Package::where('title', 'domain-hosting')->first(); + $user->assignPackage($package_kolab); + $domain->assignPackage($package_domain, $user); + $wallet = $user->wallets()->first(); + $entitlements = $wallet->entitlements->pluck('id')->all(); + + $this->assertCount(5, $entitlements); + + // Non-deleted user + $code = \Artisan::call("user:restore {$user->email}"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("The user is not yet deleted.", $output); + + $user->delete(); + + $this->assertTrue($user->trashed()); + $this->assertTrue($domain->fresh()->trashed()); + + // Deleted user + $code = \Artisan::call("user:restore {$user->email}"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("", $output); + + $this->assertFalse($user->fresh()->trashed()); + $this->assertFalse($domain->fresh()->trashed()); + } +} diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php --- a/src/tests/Feature/DomainTest.php +++ b/src/tests/Feature/DomainTest.php @@ -31,6 +31,8 @@ foreach ($this->domains as $domain) { $this->deleteTestDomain($domain); } + + $this->deleteTestUser('user@gmail.com'); } /** @@ -42,6 +44,8 @@ $this->deleteTestDomain($domain); } + $this->deleteTestUser('user@gmail.com'); + parent::tearDown(); } @@ -216,4 +220,81 @@ $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); } + + /** + * Test domain restoring + */ + public function testRestore(): void + { + Queue::fake(); + + $domain = $this->getTestDomain('gmail.com', [ + 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED + | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED, + 'type' => Domain::TYPE_PUBLIC, + ]); + + $user = $this->getTestUser('user@gmail.com'); + $sku = \App\Sku::where('title', 'domain-hosting')->first(); + $now = \Carbon\Carbon::now(); + + // Assign two entitlements to the domain, so we can assert that only the + // ones deleted last will be restored + $ent1 = \App\Entitlement::create([ + 'wallet_id' => $user->wallets->first()->id, + 'sku_id' => $sku->id, + 'cost' => 0, + 'entitleable_id' => $domain->id, + 'entitleable_type' => Domain::class, + ]); + $ent2 = \App\Entitlement::create([ + 'wallet_id' => $user->wallets->first()->id, + 'sku_id' => $sku->id, + 'cost' => 0, + 'entitleable_id' => $domain->id, + 'entitleable_type' => Domain::class, + ]); + + $domain->delete(); + + $this->assertTrue($domain->fresh()->trashed()); + $this->assertFalse($domain->fresh()->isDeleted()); + $this->assertTrue($ent1->fresh()->trashed()); + $this->assertTrue($ent2->fresh()->trashed()); + + // Backdate some properties + \App\Entitlement::withTrashed()->where('id', $ent2->id)->update(['deleted_at' => $now->subMinutes(2)]); + \App\Entitlement::withTrashed()->where('id', $ent1->id)->update(['updated_at' => $now->subMinutes(10)]); + + Queue::fake(); + + $domain->restore(); + $domain->refresh(); + + $this->assertFalse($domain->trashed()); + $this->assertFalse($domain->isDeleted()); + $this->assertFalse($domain->isSuspended()); + $this->assertFalse($domain->isLdapReady()); + $this->assertTrue($domain->isActive()); + $this->assertTrue($domain->isConfirmed()); + + // Assert entitlements + $this->assertTrue($ent2->fresh()->trashed()); + $this->assertFalse($ent1->fresh()->trashed()); + $this->assertTrue($ent1->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); + + // We expect only one CreateJob and one UpdateJob + // 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(2, 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\Domain\CreateJob::class, + function ($job) use ($domain) { + return $domain->id === TestCase::getObjectProperty($job, 'domainId'); + } + ); + } } diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -20,6 +20,7 @@ $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); + $this->deleteTestDomain('UserAccountAdd.com'); } public function tearDown(): void @@ -30,6 +31,7 @@ $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); + $this->deleteTestDomain('UserAccountAdd.com'); parent::tearDown(); } @@ -305,7 +307,7 @@ /** * Test user deletion vs. group membership */ - public function testDeleteAandGroups(): void + public function testDeleteAndGroups(): void { Queue::fake(); @@ -411,6 +413,119 @@ } /** + * Test user restoring + */ + public function testRestore(): void + { + Queue::fake(); + + // 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::where('title', 'kolab')->first(); + $package_domain = \App\Package::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::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(); + + // 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->assertTrue($userA->isActive()); + + $this->assertTrue($userB->fresh()->trashed()); + $this->assertTrue($domainB->fresh()->trashed()); + $this->assertFalse($domainA->fresh()->trashed()); + + // Assert entitlements + $this->assertSame(4, $entitlementsA->count()); // mailbox + groupware + 2 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'); + } + ); + Queue::assertPushedWithChain( + \App\Jobs\User\CreateJob::class, + [ + \App\Jobs\User\VerifyJob::class, + ] + ); + } + + /** * Tests for UserAliasesTrait::setAliases() */ public function testSetAliases(): void diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -201,14 +201,12 @@ { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); - $user = User::withTrashed()->where('email', $email)->first(); - - if (!$user) { - return User::firstOrCreate(['email' => $email], $attrib); - } + $user = User::firstOrCreate(['email' => $email], $attrib); - if ($user->deleted_at) { - $user->restore(); + 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;