diff --git a/src/app/Domain.php b/src/app/Domain.php index 56c8a7ae..17e0418c 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,350 +1,350 @@ wallets()->get()[0]->id; + $wallet_id = $user->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ - 'owner_id' => $user->id, 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'entitleable_id' => $this->id, 'entitleable_type' => Domain::class ] ); } } return $this; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Return list of public+active domain names */ public static function getPublicDomains(): array { $where = sprintf('(type & %s) AND (status & %s)', Domain::TYPE_PUBLIC, Domain::STATUS_ACTIVE); return self::whereRaw($where)->get(['namespace'])->map(function ($domain) { return $domain->namespace; })->toArray(); } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is confirmed the ownership of. * * @return bool */ public function isConfirmed(): bool { return ($this->status & self::STATUS_CONFIRMED) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this domain is registered with us. * * @return bool */ public function isExternal(): bool { return ($this->type & self::TYPE_EXTERNAL) > 0; } /** * Returns whether this domain is hosted with us. * * @return bool */ public function isHosted(): bool { return ($this->type & self::TYPE_HOSTED) > 0; } /** * Returns whether this domain is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is public. * * @return bool */ public function isPublic(): bool { return ($this->type & self::TYPE_PUBLIC) > 0; } /** * Returns whether this domain is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isVerified(): bool { return ($this->status & self::STATUS_VERIFIED) > 0; } /** * Domain status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_CONFIRMED, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_VERIFIED, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid domain status: {$status}"); } $this->attributes['status'] = $new_status; } /** * Ownership verification by checking for a TXT (or CNAME) record * in the domain's DNS (that matches the verification hash). * * @return bool True if verification was successful, false otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function confirm(): bool { if ($this->isConfirmed()) { return true; } $hash = $this->hash(self::HASH_TEXT); $confirmed = false; // Get DNS records and find a matching TXT entry $records = \dns_get_record($this->namespace, DNS_TXT); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $record) { if ($record['txt'] === $hash) { $confirmed = true; break; } } // Get DNS records and find a matching CNAME entry // Note: some servers resolve every non-existing name // so we need to define left and right side of the CNAME record // i.e.: kolab-verify IN CNAME .domain.tld. if (!$confirmed) { $cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace; $records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $records) { if ($records['target'] === $cname) { $confirmed = true; break; } } } if ($confirmed) { $this->status |= Domain::STATUS_CONFIRMED; $this->save(); } return $confirmed; } /** * Generate a verification hash for this domain * * @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT * * @return string Verification hash */ public function hash($mod = null): string { $cname = 'kolab-verify'; if ($mod === self::HASH_CNAME) { return $cname; } $hash = \md5('hkccp-verify-' . $this->namespace); return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash; } /** * Verify if a domain exists in DNS * * @return bool True if registered, False otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function verify(): bool { if ($this->isVerified()) { return true; } $record = \dns_get_record($this->namespace, DNS_ANY); if ($record === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } if (!empty($record)) { $this->status |= Domain::STATUS_VERIFIED; $this->save(); return true; } return false; } /** * Returns the wallet by which the domain is controlled * * @return \App\Wallet A wallet object */ public function wallet(): Wallet { return $this->entitlement()->first()->wallet; } } diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php index 6a56d6c9..62e25e28 100644 --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -1,103 +1,92 @@ 'integer', ]; /** * Principally entitleable objects such as 'Domain' or 'User'. * * @return mixed */ public function entitleable() { return $this->morphTo(); } /** * The SKU concerned. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function sku() { return $this->belongsTo('App\Sku'); } - /** - * The owner of this entitlement. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function owner() - { - return $this->belongsTo('App\User', 'owner_id', 'id'); - } - /** * The wallet this entitlement is being billed to * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function wallet() { return $this->belongsTo('App\Wallet'); } /** * Cost mutator. Make sure cost is integer. */ public function setCostAttribute($cost): void { $this->attributes['cost'] = round($cost); } } diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php index f6d0695d..c2c961cd 100644 --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -1,67 +1,56 @@ {$entitlement->getKeyName()} = $allegedly_unique; break; } } // can't dispatch job here because it'll fail serialization // Make sure the owner is at least a controller on the wallet - $owner = \App\User::find($entitlement->owner_id); $wallet = \App\Wallet::find($entitlement->wallet_id); - if (!$owner) { + if (!$wallet || !$wallet->owner) { return false; } - if (!$wallet) { - return false; - } - - if (!$wallet->owner() == $owner) { - if (!$wallet->controllers->contains($owner)) { - return false; - } - } - $sku = \App\Sku::find($entitlement->sku_id); if (!$sku) { return false; } - $result = $sku->handler_class::preReq($entitlement, $owner); + $result = $sku->handler_class::preReq($entitlement, $wallet->owner); if (!$result) { return false; } } } diff --git a/src/app/User.php b/src/app/User.php index e24a88d8..c8b0d643 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,580 +1,575 @@ 'datetime', ]; /** * Any wallets on which this user is a controller. * * This does not include wallets owned by the user. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function accounts() { return $this->belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Email aliases of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function aliases() { return $this->hasMany('App\UserAlias', 'user_id'); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } $wallet_id = $this->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ - 'owner_id' => $this->id, 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] ); } } return $user; } /** * Assign a package plan to a user. * * @param \App\Plan $plan The plan to assign * @param \App\Domain $domain Optional domain object * * @return \App\User Self */ public function assignPlan($plan, $domain = null): User { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } return $this; } /** * Assign a Sku to a user. * * @param \App\Sku $sku The sku to assign. * @param int $count Count of entitlements to add * * @return \App\User Self * @throws \Exception */ public function assignSku($sku, int $count = 1): User { // TODO: I guess wallet could be parametrized in future $wallet = $this->wallet(); - $owner = $wallet->owner; $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); // TODO: Sanity check, this probably should be in preReq() on handlers // or in EntitlementObserver if ($sku->handler_class::entitleableClass() != User::class) { throw new \Exception("Cannot assign non-user SKU ({$sku->title}) to a user"); } while ($count > 0) { \App\Entitlement::create([ - 'owner_id' => $owner->id, 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $sku->units_free >= $exists ? $sku->cost : 0, 'entitleable_id' => $this->id, 'entitleable_type' => User::class ]); $exists++; $count--; } return $this; } /** * Check if current user can delete another object. * * @param \App\User|\App\Domain $object A user|domain object * * @return bool True if he can, False otherwise */ public function canDelete($object): bool { if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, // this may change in future, controllers are not 0-regression feature return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); } /** * Check if current user can read data of another object. * * @param \App\User|\App\Domain $object A user|domain object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { if (!method_exists($object, 'wallet')) { return false; } if ($object instanceof User && $this->id == $object->id) { return true; } $wallet = $object->wallet(); return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); } /** * Check if current user can update data of another object. * * @param \App\User|\App\Domain $object A user|domain object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { if (!method_exists($object, 'wallet')) { return false; } if ($object instanceof User && $this->id == $object->id) { return true; } return $this->canDelete($object); } /** * List the domains to which this user is entitled. * * @return Domain[] */ public function domains() { $dbdomains = Domain::whereRaw( sprintf( '(type & %s) AND (status & %s)', Domain::TYPE_PUBLIC, Domain::STATUS_ACTIVE ) )->get(); $domains = []; foreach ($dbdomains as $dbdomain) { $domains[] = $dbdomain; } - $entitlements = Entitlement::where('owner_id', $this->id)->get(); - - foreach ($entitlements as $entitlement) { - if ($entitlement->entitleable instanceof Domain) { + foreach ($this->wallets as $wallet) { + $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); + foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; \Log::info("Found domain for {$this->email}: {$domain->namespace} (owned)"); $domains[] = $domain; } } foreach ($this->accounts as $wallet) { - foreach ($wallet->entitlements as $entitlement) { - if ($entitlement->entitleable instanceof Domain) { - $domain = $entitlement->entitleable; - \Log::info("Found domain {$this->email}: {$domain->namespace} (charged)"); - $domains[] = $domain; - } + $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); + foreach ($entitlements as $entitlement) { + $domain = $entitlement->entitleable; + \Log::info("Found domain {$this->email}: {$domain->namespace} (charged)"); + $domains[] = $domain; } } return $domains; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Entitlements for this user. * * Note that these are entitlements that apply to the user account, and not entitlements that * this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement', 'entitleable_id', 'id'); } public function addEntitlement($entitlement) { if (!$this->entitlements->contains($entitlement)) { return $this->entitlements()->save($entitlement); } } /** * Helper to find user by email address, whether it is * main email address, alias or external email * * @param string $email Email address * * @return \App\User User model object if found */ public static function findByEmail(string $email): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $alias = UserAlias::where('alias', $email)->first(); if ($alias) { return $alias->user; } // TODO: External email return null; } 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) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * 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) > 0; } /** * Returns whether this user is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this user is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Any (additional) properties of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } /** * Return users controlled by the current user. * * Users assigned to wallets the current user controls or owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users() { $wallets = array_merge( $this->wallets()->pluck('id')->all(), $this->accounts()->pluck('wallet_id')->all() ); return $this->select(['users.*', 'entitlements.wallet_id']) ->distinct() ->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', 'App\User'); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Returns the wallet by which the user is controlled * * @return \App\Wallet A wallet object */ public function wallet(): Wallet { $entitlement = $this->entitlement()->first(); // TODO: No entitlement should not happen, but in tests we have // such cases, so we fallback to the user's wallet in this case return $entitlement ? $entitlement->wallet : $this->wallets()->first(); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * 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/2019_09_17_102628_create_sku_entitlements.php b/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php index bf0d9257..448aec0c 100644 --- a/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php +++ b/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php @@ -1,64 +1,62 @@ string('id', 36)->primary(); $table->string('title', 64); $table->json('name'); $table->json('description'); $table->integer('cost'); $table->smallinteger('units_free')->default('0'); $table->string('period', strlen('monthly'))->default('monthly'); $table->string('handler_class')->nullable(); $table->boolean('active')->default(false); $table->timestamps(); } ); Schema::create( 'entitlements', function (Blueprint $table) { $table->string('id', 36)->primary(); - $table->bigInteger('owner_id'); $table->bigInteger('entitleable_id'); $table->string('entitleable_type'); $table->integer('cost')->default(0)->nullable(); $table->string('wallet_id', 36); $table->string('sku_id', 36); $table->string('description')->nullable(); $table->timestamps(); $table->foreign('sku_id')->references('id')->on('skus')->onDelete('cascade'); - $table->foreign('owner_id')->references('id')->on('users')->onDelete('cascade'); $table->foreign('wallet_id')->references('id')->on('wallets')->onDelete('cascade'); } ); } /** * Reverse the migrations. * * @return void */ public function down() { // TODO: drop foreign keys first Schema::dropIfExists('entitlements'); Schema::dropIfExists('skus'); } } diff --git a/src/tests/Feature/BillingTest.php b/src/tests/Feature/BillingTest.php index 76cced2f..e7111584 100644 --- a/src/tests/Feature/BillingTest.php +++ b/src/tests/Feature/BillingTest.php @@ -1,247 +1,244 @@ deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('jack@kolabnow.com'); \App\Package::where('title', 'kolab-kube')->delete(); $this->user = $this->getTestUser('jane@kolabnow.com'); $this->package = \App\Package::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::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(4, $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(4, $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()->subMonths(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()->subMonths(1)); $this->assertEquals(999, $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()->subMonths(1)->subDays(1) ); $this->assertEquals(999, $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()->subMonths(1)->subDays(1) ); $this->assertEquals(999, $this->wallet->expectedCharges()); $sku = \App\Sku::where(['title' => 'storage'])->first(); $entitlement = \App\Entitlement::create( [ - 'owner_id' => $this->user->id, '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()->subMonths(1)->subDays(1) ); $this->assertEquals(1024, $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()->subMonths(1)); $this->assertEquals(999, $this->wallet->expectedCharges()); $sku = \App\Sku::where(['title' => 'storage'])->first(); $entitlement = \App\Entitlement::create( [ - 'owner_id' => $this->user->id, '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(999, $this->wallet->expectedCharges()); } public function testFifthWeek(): void { $targetDateA = Carbon::now()->subWeeks(5); $targetDateB = $targetDateA->copy()->addMonths(1); $this->backdateEntitlements($this->wallet->entitlements, $targetDateA); $this->assertEquals(999, $this->wallet->expectedCharges()); $this->wallet->chargeEntitlements(); $this->assertEquals(-999, $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()->subMonths(2)); $this->assertCount(4, $this->wallet->entitlements); $this->assertEquals(1998, $this->wallet->expectedCharges()); $sku = \App\Sku::where(['title' => 'storage'])->first(); $entitlement = \App\Entitlement::create( [ - 'owner_id' => $this->user->id, '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()->subMonths(1)); $this->assertEquals(2023, $this->wallet->expectedCharges()); } public function testWithDiscount(): void { $package = \App\Package::create( [ 'title' => 'kolab-kube', 'name' => 'Kolab for Kuba Fans', 'description' => 'Kolab for Kube fans', 'discount_rate' => 50 ] ); $skus = [ \App\Sku::firstOrCreate(['title' => 'mailbox']), \App\Sku::firstOrCreate(['title' => 'storage']), \App\Sku::firstOrCreate(['title' => 'groupware']) ]; $package->skus()->saveMany($skus); $package->skus()->updateExistingPivot( \App\Sku::firstOrCreate(['title' => 'storage']), ['qty' => 2], false ); $user = $this->getTestUser('jack@kolabnow.com'); $user->assignPackage($package); $wallet = $user->wallets->first(); $wallet_id = $wallet->id; $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subMonths(1)); $this->assertEquals(500, $wallet->expectedCharges()); } } diff --git a/src/tests/Feature/Controller/DomainsTest.php b/src/tests/Feature/Controller/DomainsTest.php index db792da5..69804cec 100644 --- a/src/tests/Feature/Controller/DomainsTest.php +++ b/src/tests/Feature/Controller/DomainsTest.php @@ -1,177 +1,175 @@ deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); } public function tearDown(): void { $this->deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); parent::tearDown(); } /** * Test domain confirm request */ public function testConfirm(): void { $sku_domain = Sku::where('title', 'domain-hosting')->first(); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Entitlement::create([ - 'owner_id' => $user->id, 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('error', $json['status']); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('Domain verified successfully.', $json['message']); // Not authorized access $response = $this->actingAs($john)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(403); // Authorized access by additional account controller $domain = $this->getTestDomain('kolab.org'); $response = $this->actingAs($ned)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(200); } /** * Test fetching domains list */ public function testIndex(): void { // User with no domains $user = $this->getTestUser('test1@domainscontroller.com'); $response = $this->actingAs($user)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertSame([], $json); // User with custom domain(s) $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($john)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSame('kolab.org', $json[0]['namespace']); $response = $this->actingAs($ned)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSame('kolab.org', $json[0]['namespace']); } /** * Test fetching domain info */ public function testShow(): void { $sku_domain = Sku::where('title', 'domain-hosting')->first(); $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Entitlement::create([ - 'owner_id' => $user->id, 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($domain->id, $json['id']); $this->assertEquals($domain->namespace, $json['namespace']); $this->assertEquals($domain->status, $json['status']); $this->assertEquals($domain->type, $json['type']); $this->assertTrue($json['confirmed'] === false); $this->assertSame($domain->hash(Domain::HASH_TEXT), $json['hash_text']); $this->assertSame($domain->hash(Domain::HASH_CNAME), $json['hash_cname']); $this->assertSame($domain->hash(Domain::HASH_CODE), $json['hash_code']); $this->assertCount(4, $json['config']); $this->assertTrue(strpos(implode("\n", $json['config']), $domain->namespace) !== false); $this->assertCount(8, $json['dns']); $this->assertTrue(strpos(implode("\n", $json['dns']), $domain->namespace) !== false); $this->assertTrue(strpos(implode("\n", $json['dns']), $domain->hash()) !== false); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); // Not authorized - Other account domain $response = $this->actingAs($john)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); $domain = $this->getTestDomain('kolab.org'); // Ned is an additional controller on kolab.org's wallet $response = $this->actingAs($ned)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(200); // Jack has no entitlement/control over kolab.org $response = $this->actingAs($jack)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); } } diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php index ab9a3b53..8bd655c0 100644 --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -1,110 +1,107 @@ deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); } public function tearDown(): void { $this->deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); parent::tearDown(); } /** * Tests for User::AddEntitlement() */ public function testUserAddEntitlement(): void { $package_domain = Package::where('title', 'domain-hosting')->first(); $package_kolab = Package::where('title', 'kolab')->first(); $sku_domain = Sku::where('title', 'domain-hosting')->first(); $sku_mailbox = Sku::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($package_domain, $owner); $owner->assignPackage($package_kolab); $owner->assignPackage($package_kolab, $user); $wallet = $owner->wallets->first(); $this->assertCount(4, $owner->entitlements()->get()); - $this->assertCount(1, $sku_domain->entitlements()->where('owner_id', $owner->id)->get()); - $this->assertCount(2, $sku_mailbox->entitlements()->where('owner_id', $owner->id)->get()); + $this->assertCount(1, $sku_domain->entitlements()->where('wallet_id', $wallet->id)->get()); + $this->assertCount(2, $sku_mailbox->entitlements()->where('wallet_id', $wallet->id)->get()); $this->assertCount(9, $wallet->entitlements); $this->backdateEntitlements($owner->entitlements, Carbon::now()->subMonths(1)); $wallet->chargeEntitlements(); $this->assertTrue($wallet->fresh()->balance < 0); } public function testAddExistingEntitlement(): void { $this->markTestIncomplete(); } public function testEntitlementFunctions(): void { $user = $this->getTestUser('entitlement-test@kolabnow.com'); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $this->assertNotNull($wallet); $sku = \App\Sku::where('title', 'mailbox')->first(); $this->assertNotNull($sku); - $entitlement = Entitlement::where('owner_id', $user->id)->where('sku_id', $sku->id)->first(); + $entitlement = Entitlement::where('wallet_id', $wallet->id)->where('sku_id', $sku->id)->first(); $this->assertNotNull($entitlement); $e_sku = $entitlement->sku; $this->assertSame($sku->id, $e_sku->id); $e_wallet = $entitlement->wallet; $this->assertSame($wallet->id, $e_wallet->id); - $e_owner = $entitlement->owner; - $this->assertEquals($user->id, $e_owner->id); - $e_entitleable = $entitlement->entitleable; $this->assertEquals($user->id, $e_entitleable->id); $this->assertTrue($e_entitleable instanceof \App\User); } } diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index d3eda2d8..a80c8961 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,373 +1,371 @@ deleteTestUser('user-create-test@' . \config('app.domain')); $this->deleteTestUser('userdeletejob@kolabnow.com'); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); } public function tearDown(): void { $this->deleteTestUser('user-create-test@' . \config('app.domain')); $this->deleteTestUser('userdeletejob@kolabnow.com'); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); parent::tearDown(); } /** * Tests for User::assignPackage() */ public function testAssignPackage(): void { $this->markTestIncomplete(); } /** * Tests for User::assignPlan() */ public function testAssignPlan(): void { $this->markTestIncomplete(); } /** * Tests for User::assignSku() */ public function testAssignSku(): void { $this->markTestIncomplete(); } /** * Verify user creation process */ public function testUserCreateJob(): void { // Fake the queue, assert that no jobs were pushed... Queue::fake(); Queue::assertNothingPushed(); $user = User::create([ 'email' => 'user-create-test@' . \config('app.domain') ]); Queue::assertPushed(\App\Jobs\UserCreate::class, 1); Queue::assertPushed(\App\Jobs\UserCreate::class, function ($job) use ($user) { $job_user = TestCase::getObjectProperty($job, 'user'); return $job_user->id === $user->id && $job_user->email === $user->email; }); Queue::assertPushedWithChain(\App\Jobs\UserCreate::class, [ \App\Jobs\UserVerify::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\UserVerify::class, 1); Queue::assertPushed(\App\Jobs\UserVerify::class, function ($job) use ($user) { $job_user = TestCase::getObjectProperty($job, 'user'); return $job_user->id === $user->id && $job_user->email === $user->email; }); */ } /** * Verify a wallet assigned a controller is among the accounts of the assignee. */ public function testListUserAccounts(): 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); } public function testAccounts(): void { $this->markTestIncomplete(); } public function testCanDelete(): void { $this->markTestIncomplete(); } public function testCanRead(): void { $this->markTestIncomplete(); } public function testCanUpdate(): void { $this->markTestIncomplete(); } /** * Tests for User::domains() */ public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); $domains = []; foreach ($user->domains() as $domain) { $domains[] = $domain->namespace; } $this->assertContains(\config('app.domain'), $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 = []; foreach ($user->domains() as $domain) { $domains[] = $domain->namespace; } $this->assertContains(\config('app.domain'), $domains); $this->assertNotContains('kolab.org', $domains); } public function testUserQuota(): void { // TODO: This test does not test much, probably could be removed // or moved to somewhere else, or extended with // other entitlements() related cases. $user = $this->getTestUser('john@kolab.org'); $storage_sku = \App\Sku::where('title', 'storage')->first(); $count = 0; foreach ($user->entitlements()->get() as $entitlement) { if ($entitlement->sku_id == $storage_sku->id) { $count += 1; } } $this->assertTrue($count == 2); } /** * Test user deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('userdeletejob@kolabnow.com'); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $id = $user->id; - $entitlements = \App\Entitlement::where('owner_id', $id)->get(); - $this->assertCount(4, $entitlements); + $this->assertCount(4, $user->entitlements()->get()); $user->delete(); - $entitlements = \App\Entitlement::where('owner_id', $id)->get(); - $this->assertCount(0, $entitlements); + $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\UserDelete($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 $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userC = $this->getTestUser('UserAccountC@UserAccount.com'); $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::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); $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); $this->assertSame(4, $entitlementsA->count()); $this->assertSame(4, $entitlementsB->count()); $this->assertSame(4, $entitlementsC->count()); $this->assertSame(1, $entitlementsDomain->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->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domain->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domain->isDeleted()); } /** * 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); // TODO: searching by external email (setting) $this->markTestIncomplete(); } /** * Tests for UserAliasesTrait::setAliases() */ public function testSetAliases(): void { Queue::fake(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); $this->assertCount(0, $user->aliases->all()); // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Add another alias $user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']); $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); // Remove an alias $user->setAliases(['UserAlias1@UserAccount.com']); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Remove all aliases $user->setAliases([]); $this->assertCount(0, $user->aliases()->get()); // TODO: Test that the changes are propagated to ldap } /** * Tests for UserSettingsTrait::setSettings() */ public function testSetSettings(): void { $this->markTestIncomplete(); } /** * Tests for User::users() */ public function testUsers(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $john->wallets()->first(); $users = $john->users()->orderBy('email')->get(); $this->assertCount(3, $users); $this->assertEquals($jack->id, $users[0]->id); $this->assertEquals($john->id, $users[1]->id); $this->assertEquals($ned->id, $users[2]->id); $this->assertSame($wallet->id, $users[0]->wallet_id); $this->assertSame($wallet->id, $users[1]->wallet_id); $this->assertSame($wallet->id, $users[2]->wallet_id); $users = $jack->users()->orderBy('email')->get(); $this->assertCount(0, $users); $users = $ned->users()->orderBy('email')->get(); $this->assertCount(3, $users); } public function testWallets(): void { $this->markTestIncomplete(); } }