diff --git a/src/app/Backends/IMAP.php b/src/app/Backends/IMAP.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/IMAP.php @@ -0,0 +1,22 @@ +morphOne('App\Entitlement', 'entitleable'); @@ -124,6 +124,16 @@ return $this->type & self::TYPE_PUBLIC; } + /** + * Returns whether this domain is registered in LDAP. + * + * @return bool + */ + public function isLdapReady(): bool + { + return $this->status & self::STATUS_LDAP_READY; + } + /** * Returns whether this domain is suspended. * @@ -134,6 +144,17 @@ return $this->status & self::STATUS_SUSPENDED; } + /** + * Returns whether this (external) domain has been verified + * to exist in DNS. + * + * @return bool + */ + public function isVerified(): bool + { + return $this->status & self::STATUS_VERIFIED; + } + /** * Domain status mutator * @@ -149,6 +170,8 @@ self::STATUS_CONFIRMED, self::STATUS_SUSPENDED, self::STATUS_DELETED, + self::STATUS_LDAP_READY, + self::STATUS_VERIFIED, ]; foreach ($allowed_values as $value) { @@ -164,4 +187,23 @@ $this->attributes['status'] = $new_status; } + + /** + * Verify if a domain exists in DNS + * + * @param string $domain Domain name + * + * @return bool True if registered, False otherwise + * @throws \Exception + */ + public static function verifyDomain(string $domain): bool + { + $record = dns_get_record($domain, DNS_SOA); + + if ($record === false) { + throw new \Exception("Failed to get DNS record for $domain"); + } + + return !empty($record); + } } diff --git a/src/app/Jobs/ProcessDomainCreate.php b/src/app/Jobs/ProcessDomainCreate.php --- a/src/app/Jobs/ProcessDomainCreate.php +++ b/src/app/Jobs/ProcessDomainCreate.php @@ -43,6 +43,11 @@ */ public function handle() { - LDAP::createDomain($this->domain); + if (!$this->domain->isLdapReady()) { + LDAP::createDomain($this->domain); + + $this->domain->status |= Domain::STATUS_LDAP_READY; + $this->domain->save(); + } } } diff --git a/src/app/Jobs/ProcessDomainCreate.php b/src/app/Jobs/ProcessDomainVerify.php copy from src/app/Jobs/ProcessDomainCreate.php copy to src/app/Jobs/ProcessDomainVerify.php --- a/src/app/Jobs/ProcessDomainCreate.php +++ b/src/app/Jobs/ProcessDomainVerify.php @@ -2,15 +2,14 @@ namespace App\Jobs; -use App\Backends\LDAP; use App\Domain; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; -use Illuminate\Queue\SerializesModels; use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; -class ProcessDomainCreate implements ShouldQueue +class ProcessDomainVerify implements ShouldQueue { use Dispatchable; use InteractsWithQueue; @@ -43,6 +42,15 @@ */ public function handle() { - LDAP::createDomain($this->domain); + if (!$this->domain->isVerified()) { + if (Domain::verifyDomain($this->domain->namespace)) { + $this->domain->status |= Domain::STATUS_VERIFIED; + $this->domain->save(); + } + + // 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/ProcessUserCreate.php b/src/app/Jobs/ProcessUserCreate.php --- a/src/app/Jobs/ProcessUserCreate.php +++ b/src/app/Jobs/ProcessUserCreate.php @@ -44,6 +44,11 @@ */ public function handle() { - LDAP::createUser($this->user); + if (!$this->user->isLdapReady()) { + LDAP::createUser($this->user); + + $this->user->status |= User::STATUS_LDAP_READY; + $this->user->save(); + } } } diff --git a/src/app/Jobs/ProcessUserCreate.php b/src/app/Jobs/ProcessUserVerify.php copy from src/app/Jobs/ProcessUserCreate.php copy to src/app/Jobs/ProcessUserVerify.php --- a/src/app/Jobs/ProcessUserCreate.php +++ b/src/app/Jobs/ProcessUserVerify.php @@ -2,7 +2,6 @@ namespace App\Jobs; -use App\Backends\LDAP; use App\User; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -10,7 +9,7 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class ProcessUserCreate implements ShouldQueue +class ProcessUserVerify implements ShouldQueue { use Dispatchable; use InteractsWithQueue; @@ -44,6 +43,12 @@ */ public function handle() { - LDAP::createUser($this->user); + if (!$this->user->isImapReady()) { + if (IMAP::verifyAccount($this->user->email)) { + $this->user->status |= User::STATUS_IMAP_READY; + $this->user->status |= User::STATUS_ACTIVE; + $this->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 @@ -22,6 +22,8 @@ break; } } + + $domain->status |= Domain::STATUS_NEW; } /** @@ -33,7 +35,12 @@ */ public function created(Domain $domain) { - \App\Jobs\ProcessDomainCreate::dispatch($domain); + // Create domain record in LDAP, then check if it exists in DNS + $chain = [ + new \App\Jobs\ProcessDomainVerify($domain), + ]; + + \App\Jobs\ProcessDomainCreate::withChain($chain)->dispatch($domain); } /** 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 @@ -24,6 +24,9 @@ break; } } + + $user->status |= User::STATUS_NEW; + // can't dispatch job here because it'll fail serialization } @@ -54,7 +57,12 @@ $user->wallets()->create(); - \App\Jobs\ProcessUserCreate::dispatch($user); + // Create user record in LDAP, then check if the account is created in IMAP + $chain = [ + new \App\Jobs\ProcessUserVerify($user), + ]; + + \App\Jobs\ProcessUserCreate::withChain($chain)->dispatch($user); } /** diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -18,6 +18,20 @@ use NullableFields; use UserSettingsTrait; + // a new user, default on creation + public const STATUS_NEW = 1 << 0; + // it's been activated + public const STATUS_ACTIVE = 1 << 1; + // user has been suspended + public const STATUS_SUSPENDED = 1 << 2; + // user has been deleted + public const STATUS_DELETED = 1 << 3; + // user has been created in LDAP + public const STATUS_LDAP_READY = 1 << 4; + // user mailbox has been created in IMAP + public const STATUS_IMAP_READY = 1 << 5; + + // change the default primary key type public $incrementing = false; protected $keyType = 'bigint'; @@ -28,7 +42,11 @@ * @var array */ protected $fillable = [ - 'name', 'email', 'password', 'password_ldap' + 'name', + 'email', + 'password', + 'password_ldap', + 'status' ]; /** @@ -37,7 +55,9 @@ * @var array */ protected $hidden = [ - 'password', 'password_ldap', 'remember_token', + 'password', + 'password_ldap', + 'remember_token', ]; protected $nullable = [ @@ -150,6 +170,82 @@ return $user; } + public function getJWTIdentifier() + { + return $this->getKey(); + } + + public function getJWTCustomClaims() + { + return []; + } + + /** + * Returns whether this domain is active. + * + * @return bool + */ + public function isActive(): bool + { + return $this->status & self::STATUS_ACTIVE; + } + + /** + * Returns whether this domain is deleted. + * + * @return bool + */ + public function isDeleted(): bool + { + return $this->status & self::STATUS_DELETED; + } + + /** + * Returns whether this (external) domain has been verified + * to exist in DNS. + * + * @return bool + */ + public function isImapReady(): bool + { + return $this->status & self::STATUS_IMAP_READY; + } + + /** + * Returns whether this user is registered in LDAP. + * + * @return bool + */ + public function isLdapReady(): bool + { + return $this->status & self::STATUS_LDAP_READY; + } + + /** + * Returns whether this user is new. + * + * @return bool + */ + public function isNew(): bool + { + return $this->status & self::STATUS_NEW; + } + + /** + * Returns whether this domain is suspended. + * + * @return bool + */ + public function isSuspended(): bool + { + return $this->status & self::STATUS_SUSPENDED; + } + + /** + * Any (additional) properties of this user. + * + * @return \App\UserSetting[] + */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); @@ -175,16 +271,6 @@ return $this->hasMany('App\Wallet'); } - public function getJWTIdentifier() - { - return $this->getKey(); - } - - public function getJWTCustomClaims() - { - return []; - } - public function setPasswordAttribute($password) { if (!empty($password)) { @@ -204,4 +290,36 @@ ); } } + + /** + * User status mutator + * + * @throws \Exception + */ + public function setStatusAttribute($status) + { + $new_status = 0; + + $allowed_values = [ + self::STATUS_NEW, + self::STATUS_ACTIVE, + self::STATUS_SUSPENDED, + self::STATUS_DELETED, + self::STATUS_LDAP_READY, + self::STATUS_IMAP_READY, + ]; + + foreach ($allowed_values as $value) { + if ($status & $value) { + $new_status |= $value; + $status ^= $value; + } + } + + if ($status > 0) { + throw new \Exception("Invalid user status: {$status}"); + } + + $this->attributes['status'] = $new_status; + } } diff --git a/src/database/migrations/2014_10_12_000000_create_users_table.php b/src/database/migrations/2014_10_12_000000_create_users_table.php --- a/src/database/migrations/2014_10_12_000000_create_users_table.php +++ b/src/database/migrations/2014_10_12_000000_create_users_table.php @@ -22,6 +22,7 @@ $table->timestamp('email_verified_at')->nullable(); $table->string('password')->nullable(); $table->string('password_ldap')->nullable(); + $table->smallinteger('status'); $table->rememberToken(); $table->timestamps(); 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 @@ -17,7 +17,51 @@ { parent::setUp(); - Domain::where('namespace', 'public-active.com')->delete(); + Domain::where('namespace', 'public-active.com') + ->orWhere('namespace', 'gmail.com')->delete(); + } + + /** + * Test domain creating jobs + */ + public function testCreateJobs(): void + { + // Fake the queue, assert that no jobs were pushed... + Queue::fake(); + Queue::assertNothingPushed(); + + $domain = Domain::create([ + 'namespace' => 'gmail.com', + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_EXTERNAL, + ]); + + Queue::assertPushed(\App\Jobs\ProcessDomainCreate::class, 1); + Queue::assertPushed(\App\Jobs\ProcessDomainCreate::class, function ($job) use ($domain) { + $job_domain = TestCase::getObjectProperty($job, 'domain'); + + return $job_domain->id === $domain->id + && $job_domain->namespace === $domain->namespace; + }); + + Queue::assertPushedWithChain(\App\Jobs\ProcessDomainCreate::class, [ + \App\Jobs\ProcessDomainVerify::class, + ]); + +/* + FIXME: Looks like we can't really do detailed assertions on chained jobs + Another thing to consider is if we maybe should run these jobs + independently (not chained) and make sure there's no race-condition + in status update + + Queue::assertPushed(\App\Jobs\ProcessDomainVerify::class, 1); + Queue::assertPushed(\App\Jobs\ProcessDomainVerify::class, function ($job) use ($domain) { + $job_domain = TestCase::getObjectProperty($job, 'domain'); + + return $job_domain->id === $domain->id + && $job_domain->namespace === $domain->namespace; + }); +*/ } /** @@ -29,9 +73,7 @@ $this->assertNotContains('public-active.com', $public_domains); - // Fake the queue, assert that no jobs were pushed... Queue::fake(); - Queue::assertNothingPushed(); $domain = Domain::create([ 'namespace' => 'public-active.com', @@ -43,14 +85,6 @@ $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); - Queue::assertPushed(\App\Jobs\ProcessDomainCreate::class, 1); - Queue::assertPushed(\App\Jobs\ProcessDomainCreate::class, function ($job) use ($domain) { - $job_domain = TestCase::getObjectProperty($job, 'domain'); - - return $job_domain->id === $domain->id - && $job_domain->namespace === $domain->namespace; - }); - $domain = Domain::where('namespace', 'public-active.com')->first(); $domain->status = Domain::STATUS_ACTIVE; $domain->save(); diff --git a/src/tests/Feature/Jobs/ProcessDomainCreateTest.php b/src/tests/Feature/Jobs/ProcessDomainCreateTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/ProcessDomainCreateTest.php @@ -0,0 +1,50 @@ +delete(); + } + + /** + * Test job handle + */ + public function testHandle(): void + { + $domain = $this->getTestDomain( + 'domain-create-test.com', + [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_EXTERNAL, + ] + ); + + $this->assertFalse($domain->isLdapReady()); + + $mock = \Mockery::mock('alias:App\Backends\LDAP'); + $mock->shouldReceive('createDomain') + ->once() + ->with($domain) + ->andReturn(null); + + $job = new ProcessDomainCreate($domain); + $job->handle(); + + $this->assertTrue($domain->fresh()->isLdapReady()); + } +} diff --git a/src/tests/Feature/Jobs/ProcessDomainVerifyTest.php b/src/tests/Feature/Jobs/ProcessDomainVerifyTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/ProcessDomainVerifyTest.php @@ -0,0 +1,69 @@ +orWhere('namespace', 'some-non-existing-domain.fff') + ->delete(); + } + + /** + * Test job handle (existing domain) + * + * @group dns + */ + public function testHandle(): void + { + $domain = $this->getTestDomain( + 'gmail.com', + [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_EXTERNAL, + ] + ); + + $this->assertFalse($domain->isVerified()); + + $job = new ProcessDomainVerify($domain); + $job->handle(); + + $this->assertTrue($domain->fresh()->isVerified()); + } + + /** + * Test job handle (non-existing domain) + * + * @group dns + */ + public function testHandleNonExisting(): void + { + $domain = $this->getTestDomain( + 'some-non-existing-domain.fff', + [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_EXTERNAL, + ] + ); + + $this->assertFalse($domain->isVerified()); + + $job = new ProcessDomainVerify($domain); + $job->handle(); + + $this->assertFalse($domain->fresh()->isVerified()); + } +} diff --git a/src/tests/Feature/Jobs/UserCreateTest.php b/src/tests/Feature/Jobs/UserCreateTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/UserCreateTest.php @@ -0,0 +1,44 @@ +delete(); + } + + /** + * Test job handle + */ + public function testHandle(): void + { + $user = $this->getTestUser('new-job-user@' . \config('app.domain')); + + $this->assertFalse($user->isLdapReady()); + + $mock = \Mockery::mock('alias:App\Backends\LDAP'); + $mock->shouldReceive('createUser') + ->once() + ->with($user) + ->andReturn(null); + + $job = new ProcessUserCreate($user); + $job->handle(); + + $this->assertTrue($user->fresh()->isLdapReady()); + } +} diff --git a/src/tests/Feature/Jobs/UserVerify.php b/src/tests/Feature/Jobs/UserVerify.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/UserVerify.php @@ -0,0 +1,53 @@ +delete(); + } + + /** + * Test job handle + */ + public function testHandle(): void + { + $user = $this->getTestUser('new-job-user@' . \config('app.domain')); + + $this->assertFalse($user->isImapReady()); + + $mock = \Mockery::mock('alias:App\Backends\IMAP'); + $mock->shouldReceive('verifyAccount') + ->once() + ->with($user->email) + ->andReturn(false); + + $job = new ProcessUserVerify($user); + $job->handle(); + + $this->assertTrue($user->fresh()->isImapReady() === false); + + $mock->shouldReceive('verifyAccount') + ->once() + ->with($user->email) + ->andReturn(true); + + $job->handle(); + + $this->assertTrue($user->fresh()->isImapReady()); + } +} 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 @@ -35,6 +35,24 @@ return $job_user->id === $user->id && $job_user->email === $user->email; }); + + Queue::assertPushedWithChain(\App\Jobs\ProcessUserCreate::class, [ + \App\Jobs\ProcessUserVerify::class, + ]); +/* + FIXME: Looks like we can't really do detailed assertions on chained jobs + Another thing to consider is if we maybe should run these jobs + independently (not chained) and make sure there's no race-condition + in status update + + Queue::assertPushed(\App\Jobs\ProcessUserVerify::class, 1); + Queue::assertPushed(\App\Jobs\ProcessUserVerify::class, function ($job) use ($user) { + $job_user = TestCase::getObjectProperty($job, 'user'); + + return $job_user->id === $user->id + && $job_user->email === $user->email; + }); +*/ } /** diff --git a/src/tests/Unit/DomainTest.php b/src/tests/Unit/DomainTest.php --- a/src/tests/Unit/DomainTest.php +++ b/src/tests/Unit/DomainTest.php @@ -10,7 +10,7 @@ /** * Test basic Domain funtionality */ - public function testDomainStatus() + public function testDomainStatus(): void { $statuses = [ Domain::STATUS_NEW, @@ -18,6 +18,8 @@ Domain::STATUS_CONFIRMED, Domain::STATUS_SUSPENDED, Domain::STATUS_DELETED, + Domain::STATUS_LDAP_READY, + Domain::STATUS_VERIFIED, ]; $domains = \App\Utils::powerSet($statuses); @@ -36,13 +38,30 @@ $this->assertTrue($domain->isConfirmed() === in_array(Domain::STATUS_CONFIRMED, $domain_statuses)); $this->assertTrue($domain->isSuspended() === in_array(Domain::STATUS_SUSPENDED, $domain_statuses)); $this->assertTrue($domain->isDeleted() === in_array(Domain::STATUS_DELETED, $domain_statuses)); + $this->assertTrue($domain->isLdapReady() === in_array(Domain::STATUS_LDAP_READY, $domain_statuses)); + $this->assertTrue($domain->isVerified() === in_array(Domain::STATUS_VERIFIED, $domain_statuses)); } } + /** + * Test setStatusAttribute exception + */ + public function testDomainStatusInvalid(): void + { + $this->expectException(\Exception::class); + + $domain = new Domain( + [ + 'namespace' => 'test.com', + 'status' => 1234567, + ] + ); + } + /** * Test basic Domain funtionality */ - public function testDomainType() + public function testDomainType(): void { $types = [ Domain::TYPE_PUBLIC, @@ -66,4 +85,14 @@ $this->assertTrue($domain->isExternal() === in_array(Domain::TYPE_EXTERNAL, $domain_types)); } } + + /** + * Test domain verification + * + * @group dns + */ + public function testVerifyDomain(): void + { + // TODO + } } diff --git a/src/tests/Unit/UserTest.php b/src/tests/Unit/UserTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Unit/UserTest.php @@ -0,0 +1,57 @@ + 'user@email.com', + 'status' => \array_sum($user_statuses), + ] + ); + + $this->assertTrue($user->isNew() === in_array(User::STATUS_NEW, $user_statuses)); + $this->assertTrue($user->isActive() === in_array(User::STATUS_ACTIVE, $user_statuses)); + $this->assertTrue($user->isSuspended() === in_array(User::STATUS_SUSPENDED, $user_statuses)); + $this->assertTrue($user->isDeleted() === in_array(User::STATUS_DELETED, $user_statuses)); + $this->assertTrue($user->isLdapReady() === in_array(User::STATUS_LDAP_READY, $user_statuses)); + $this->assertTrue($user->isImapReady() === in_array(User::STATUS_IMAP_READY, $user_statuses)); + } + } + + /** + * Test setStatusAttribute exception + */ + public function testUserStatusInvalid(): void + { + $this->expectException(\Exception::class); + + $user = new User( + [ + 'email' => 'user@email.com', + 'status' => 1234567, + ] + ); + } +}