diff --git a/bin/doctum b/bin/doctum index 0a7abfea..3e3dcd84 100755 --- a/bin/doctum +++ b/bin/doctum @@ -1,15 +1,18 @@ #!/bin/bash cwd=$(dirname $0) pushd ${cwd}/../src/ rm -rf ../docs/build/ cache/store/ +./artisan clear-compiled +./artisan cache:clear + php -dmemory_limit=-1 \ vendor/bin/doctum.php \ update \ doctum.config.php \ -v popd diff --git a/src/app/Discount.php b/src/app/Discount.php index 32c6da87..1134902f 100644 --- a/src/app/Discount.php +++ b/src/app/Discount.php @@ -1,65 +1,63 @@ 'integer', ]; protected $fillable = [ 'active', 'code', 'description', 'discount', ]; /** @var array Translatable properties */ public $translatable = [ 'description', ]; /** * Discount value mutator * * @throws \Exception */ public function setDiscountAttribute($discount) { $discount = (int) $discount; if ($discount < 0) { - \Log::warning("Expecting a discount rate >= 0"); $discount = 0; } if ($discount > 100) { - \Log::warning("Expecting a discount rate <= 100"); $discount = 100; } $this->attributes['discount'] = $discount; } /** * List of wallets with this discount assigned. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } } diff --git a/src/app/Domain.php b/src/app/Domain.php index f93f186e..459ee6e9 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,497 +1,502 @@ isPublic()) { return $this; } // See if this domain is already owned by another user. $wallet = $this->wallet(); if ($wallet) { \Log::error( "Domain {$this->namespace} is already assigned to {$wallet->owner->email}" ); return $this; } $wallet_id = $user->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'entitleable_id' => $this->id, 'entitleable_type' => Domain::class ] ); } } return $this; } /** * Return the entitlement to which this domain belongs, if any. * - * @return \Illuminate\Database\Eloquent\Relations\MorphOne<\App\Entitlement> + * @return \Illuminate\Database\Eloquent\Relations\MorphOne */ 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)', Domain::TYPE_PUBLIC); return self::whereRaw($where)->get(['namespace'])->pluck('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; } /** * Ensure the namespace is appropriately cased. + * + * @return void */ public function setNamespaceAttribute($namespace) { $this->attributes['namespace'] = strtolower($namespace); } /** * Domain status mutator * + * @return void + * * @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_CONFIRMED, self::STATUS_VERIFIED, self::STATUS_LDAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid domain status: {$status}"); } if ($this->isPublic()) { $this->attributes['status'] = $new_status; return; } if ($new_status & self::STATUS_CONFIRMED) { // if we have confirmed ownership of or management access to the domain, then we have // also confirmed the domain exists in DNS. $new_status |= self::STATUS_VERIFIED; $new_status |= self::STATUS_ACTIVE; } if ($new_status & self::STATUS_DELETED && $new_status & self::STATUS_ACTIVE) { $new_status ^= self::STATUS_ACTIVE; } if ($new_status & self::STATUS_SUSPENDED && $new_status & self::STATUS_ACTIVE) { $new_status ^= self::STATUS_ACTIVE; } // if the domain is now active, it is not new anymore. if ($new_status & self::STATUS_ACTIVE && $new_status & self::STATUS_NEW) { $new_status ^= self::STATUS_NEW; } $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; } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= Domain::STATUS_SUSPENDED; $this->save(); } /** * Unsuspend this domain. * * The domain is unsuspended through either of the following courses of actions; * * * The account balance has been topped up, or * * a suspected spammer has resolved their issues, or * * the command-line is triggered. * * Therefore, we can also confidently set the domain status to 'active' should the ownership of or management * access to have been confirmed before. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= Domain::STATUS_SUSPENDED; if ($this->isConfirmed() && $this->isVerified()) { $this->status |= Domain::STATUS_ACTIVE; } $this->save(); } /** * 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; } $records = \dns_get_record($this->namespace, DNS_ANY); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } // It may happen that result contains other domains depending on the host DNS setup // that's why in_array() and not just !empty() if (in_array($this->namespace, array_column($records, 'host'))) { $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 { // Note: Not all domains have a entitlement/wallet $entitlement = $this->entitlement()->withTrashed()->first(); return $entitlement ? $entitlement->wallet : null; } } diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php index 3ab2834b..f2844952 100644 --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -1,151 +1,157 @@ 'integer', ]; /** * Return the costs per day for this entitlement. * * @return float */ public function costsPerDay() { if ($this->cost == 0) { return (float) 0; } - $discount = $this->wallet->getDiscountRate(); + if ($this->wallet) { + $discount = $this->wallet->getDiscountRate(); + } else { + $discount = 0; + } $daysInLastMonth = \App\Utils::daysInLastMonth(); $costsPerDay = (float) ($this->cost * $discount) / $daysInLastMonth; return $costsPerDay; } /** * Create a transaction record for this entitlement. * * @param string $type The type of transaction ('created', 'billed', 'deleted'), but use the * \App\Transaction constants. * @param int $amount The amount involved in cents * * @return string The transaction ID */ public function createTransaction($type, $amount = null) { $transaction = \App\Transaction::create( [ 'object_id' => $this->id, 'object_type' => \App\Entitlement::class, 'type' => $type, 'amount' => $amount ] ); return $transaction->id; } /** * Principally entitleable objects such as 'Domain' or 'User'. * * @return mixed */ public function entitleable() { return $this->morphTo(); } /** * Returns entitleable object title (e.g. email or domain name). * * @return string|null An object title/name */ public function entitleableTitle(): ?string { if ($this->entitleable instanceof \App\User) { return $this->entitleable->email; } if ($this->entitleable instanceof \App\Domain) { return $this->entitleable->namespace; } + + return null; } /** * The SKU concerned. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function sku() { return $this->belongsTo('App\Sku'); } /** * 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 61a9ac12..b5b8ce72 100644 --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -1,165 +1,170 @@ {$entitlement->getKeyName()} = $allegedly_unique; break; } } // can't dispatch job here because it'll fail serialization + // make sure we only have entitlements that entitle valid subjects + if (!in_array($entitlement->entitleable_type, [\App\User::class, \App\Domain::class])) { + return false; + } + // Make sure the owner is at least a controller on the wallet $wallet = \App\Wallet::find($entitlement->wallet_id); if (!$wallet || !$wallet->owner) { return false; } $sku = \App\Sku::find($entitlement->sku_id); if (!$sku) { return false; } $result = $sku->handler_class::preReq($entitlement, $wallet->owner); if (!$result) { return false; } return true; } /** * Handle the entitlement "created" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function created(Entitlement $entitlement) { $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_CREATED); } /** * Handle the entitlement "deleted" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function deleted(Entitlement $entitlement) { // Remove all configured 2FA methods from Roundcube database if ($entitlement->sku->title == '2fa') { // FIXME: Should that be an async job? $sf = new \App\Auth\SecondFactor($entitlement->entitleable); $sf->removeFactors(); } $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED); } /** * Handle the entitlement "deleting" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function deleting(Entitlement $entitlement) { if ($entitlement->trashed()) { return; } // Start calculating the costs for the consumption of this entitlement if the // existing consumption spans >= 14 days. // // Effect is that anything's free for the first 14 days if ($entitlement->created_at >= Carbon::now()->subDays(14)) { return; } $owner = $entitlement->wallet->owner; // Determine if we're still within the free first month $freeMonthEnds = $owner->created_at->copy()->addMonthsWithoutOverflow(1); if ($freeMonthEnds >= Carbon::now()) { return; } $cost = 0; $now = Carbon::now(); // get the discount rate applied to the wallet. $discount = $entitlement->wallet->getDiscountRate(); // just in case this had not been billed yet, ever $diffInMonths = $entitlement->updated_at->diffInMonths($now); $cost += (int) ($entitlement->cost * $discount * $diffInMonths); // this moves the hypothetical updated at forward to however many months past the original $updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths); // now we have the diff in days since the last "billed" period end. // This may be an entitlement paid up until February 28th, 2020, with today being March // 12th 2020. Calculating the costs for the entitlement is based on the daily price // the price per day is based on the number of days in the last month // or the current month if the period does not overlap with the previous month // FIXME: This really should be simplified to $daysInMonth=30 $diffInDays = $updatedAt->diffInDays($now); if ($now->day >= $diffInDays) { $daysInMonth = $now->daysInMonth; } else { $daysInMonth = \App\Utils::daysInLastMonth(); } $pricePerDay = $entitlement->cost / $daysInMonth; $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0)); if ($cost == 0) { return; } $entitlement->wallet->debit($cost); } } diff --git a/src/app/SignupCode.php b/src/app/SignupCode.php index 1a11f690..e44603fd 100644 --- a/src/app/SignupCode.php +++ b/src/app/SignupCode.php @@ -1,106 +1,105 @@ 'array']; /** * The attributes that should be mutated to dates. * * @var array */ protected $dates = ['expires_at']; /** * Check if code is expired. * * @return bool True if code is expired, False otherwise */ public function isExpired() { // @phpstan-ignore-next-line return $this->expires_at ? Carbon::now()->gte($this->expires_at) : false; } /** * Generate a short code (for human). * * @return string */ public static function generateShortCode(): string { $code_length = env('SIGNUP_CODE_LENGTH', self::SHORTCODE_LENGTH); $code_chars = env('SIGNUP_CODE_CHARS', self::SHORTCODE_CHARS); $random = []; for ($i = 1; $i <= $code_length; $i++) { $random[] = $code_chars[rand(0, strlen($code_chars) - 1)]; } shuffle($random); return implode('', $random); } } diff --git a/src/app/Transaction.php b/src/app/Transaction.php index d504e468..140f14d4 100644 --- a/src/app/Transaction.php +++ b/src/app/Transaction.php @@ -1,191 +1,198 @@ 'integer', ]; /** @var boolean This model uses an automatically incrementing integer primary key? */ public $incrementing = false; /** @var string The type of the primary key */ protected $keyType = 'string'; /** * Returns the entitlement to which the transaction is assigned (if any) * * @return \App\Entitlement|null The entitlement */ public function entitlement(): ?Entitlement { if ($this->object_type !== Entitlement::class) { return null; } return Entitlement::withTrashed()->find($this->object_id); } /** * Transaction type mutator * * @throws \Exception */ public function setTypeAttribute($value): void { switch ($value) { case self::ENTITLEMENT_BILLED: case self::ENTITLEMENT_CREATED: case self::ENTITLEMENT_DELETED: // TODO: Must be an entitlement. $this->attributes['type'] = $value; break; case self::WALLET_AWARD: case self::WALLET_CREDIT: case self::WALLET_DEBIT: case self::WALLET_PENALTY: // TODO: This must be a wallet. $this->attributes['type'] = $value; break; default: throw new \Exception("Invalid type value"); } } /** * Returns a short text describing the transaction. * * @return string The description */ public function shortDescription(): string { $label = $this->objectTypeToLabelString() . '-' . $this->{'type'} . '-short'; return \trans("transactions.{$label}", $this->descriptionParams()); } /** * Returns a text describing the transaction. * * @return string The description */ public function toString(): string { $label = $this->objectTypeToLabelString() . '-' . $this->{'type'}; return \trans("transactions.{$label}", $this->descriptionParams()); } /** * Returns a wallet to which the transaction is assigned (if any) * * @return \App\Wallet|null The wallet */ public function wallet(): ?Wallet { if ($this->object_type !== Wallet::class) { return null; } return Wallet::find($this->object_id); } /** * Collect transaction parameters used in (localized) descriptions * * @return array Parameters */ private function descriptionParams(): array { $result = [ 'user_email' => $this->user_email, 'description' => $this->{'description'}, ]; if ($entitlement = $this->entitlement()) { $wallet = $entitlement->wallet; $cost = $entitlement->cost; $discount = $entitlement->wallet->getDiscountRate(); $result['entitlement_cost'] = $cost * $discount; $result['object'] = $entitlement->entitleableTitle(); $result['sku_title'] = $entitlement->sku->{'title'}; } else { $wallet = $this->wallet(); } - $result['wallet'] = $wallet->{'description'} ?: 'Default wallet'; - $result['amount'] = $wallet->money($this->amount); + if ($wallet) { + $description = $wallet->{'description'} ?: 'Default wallet'; + $result['amount'] = $wallet->money($this->amount); + } else { + $description = 'No wallet'; + $result['amount'] = "{$this->amount} cents in unknown currency"; + } + + $result['wallet'] = $description; return $result; } /** * Get a string for use in translation tables derived from the object type. * * @return string|null */ private function objectTypeToLabelString(): ?string { if ($this->object_type == Entitlement::class) { return 'entitlement'; } if ($this->object_type == Wallet::class) { return 'wallet'; } return null; } } diff --git a/src/app/User.php b/src/app/User.php index 17f131ca..739fa351 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,734 +1,737 @@ 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( [ '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 $sku, int $count = 1): User { // TODO: I guess wallet could be parametrized in future $wallet = $this->wallet(); $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([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $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|\App\Wallet $object A user|domain|wallet object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { if ($this->role == "admin") { return true; } if ($object instanceof User && $this->id == $object->id) { return true; } if ($object instanceof Wallet) { return $object->user_id == $this->id || $object->controllers->contains($this); } if (!method_exists($object, 'wallet')) { return false; } $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); } /** * Return the \App\Domain for this user. * * @return \App\Domain|null */ public function domain() { list($local, $domainName) = explode('@', $this->email); $domain = \App\Domain::withTrashed()->where('namespace', $domainName)->first(); return $domain; } /** * 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; } 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) { $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); } } /** * Find whether an email address exists (user or alias). + * * Note: This will also find deleted users. * * @param string $email Email address * @param bool $return_user Return User instance instead of boolean * @param bool $is_alias Set to True if the existing email is an alias * @param bool $existing Ignore deleted users * * @return \App\User|bool True or User model object if found, False otherwise */ public static function emailExists(string $email, bool $return_user = false, &$is_alias = false, $existing = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); if ($existing) { $user = self::where('email', $email)->first(); } else { $user = self::withTrashed()->where('email', $email)->first(); } if ($user) { return $return_user ? $user : true; } $aliases = UserAlias::where('alias', $email); if ($existing) { $aliases = $aliases->join('users', 'user_id', '=', 'users.id') ->whereNull('users.deleted_at'); } $alias = $aliases->first(); if ($alias) { $is_alias = true; return $return_user ? self::withTrashed()->find($alias->user_id) : true; } return false; } /** - * Helper to find user by email address, whether it is - * main email address, alias or an external email. + * Helper to find user by email address, whether it is main email address, alias or an external email. * * If there's more than one alias NULL will be returned. * * @param string $email Email address * @param bool $external Search also for an external email * * @return \App\User User model object if found */ public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $aliases = UserAlias::where('alias', $email)->get(); if (count($aliases) == 1) { return $aliases->first()->user; } // TODO: External email return null; } public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } /** * Check if user has an entitlement for the specified SKU. * * @param string $title The SKU title * * @return bool True if specified SKU entitlement exists */ public function hasSku($title): bool { $sku = Sku::where('title', $title)->first(); if (!$sku) { return false; } return $this->entitlements()->where('sku_id', $sku->id)->count() > 0; } /** - * Returns whether this domain is active. + * Returns whether this user is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** - * Returns whether this domain is deleted. + * Returns whether this user 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. + * Returns whether this user is confirmed to exist in IMAP. * * @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. + * Returns whether this user is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * A shortcut to get the user name. * * @param bool $fallback Return " User" if there's no name * * @return string Full user name */ public function name(bool $fallback = false): string { $firstname = $this->getSetting('first_name'); $lastname = $this->getSetting('last_name'); $name = trim($firstname . ' ' . $lastname); if (empty($name) && $fallback) { return \config('app.name') . ' User'; } return $name; } /** * Remove a number of entitlements for the SKU. * * @param \App\Sku $sku The SKU * @param int $count The number of entitlements to remove * * @return User Self */ public function removeSku(Sku $sku, int $count = 1): User { $entitlements = $this->entitlements() ->where('sku_id', $sku->id) ->orderBy('cost', 'desc') ->orderBy('created_at') ->get(); $entitlements_count = count($entitlements); foreach ($entitlements as $entitlement) { if ($entitlements_count <= $sku->units_free) { continue; } if ($count > 0) { $entitlement->delete(); $entitlements_count--; $count--; } } return $this; } /** * 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) { $this->setPasswordAttribute($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, ]; + if (!is_numeric($status)) { + throw new \Exception("non-numeric status."); + } + foreach ($allowed_values as $value) { - if ($status & $value) { + if (($status & $value) > 0) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid user status: {$status}"); } $this->attributes['status'] = $new_status; } /** * Any (additional) properties of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= User::STATUS_SUSPENDED; $this->save(); } /** * Unsuspend this domain. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= User::STATUS_SUSPENDED; $this->save(); } /** * Return users controlled by the current user. * * @param bool $with_accounts Include users assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $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'); } } diff --git a/src/app/Utils.php b/src/app/Utils.php index 5643d5d3..12976fb1 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,198 +1,199 @@ diffInDays($end) + 1; } /** * Generate a passphrase. Not intended for use in production, so limited to environments that are not production. * * @return string * * @throws \Exception */ public static function generatePassphrase() { +/* if (\config('app.env') == "production") { throw new \Exception("Thou shall not pass"); } - +*/ $alphaLow = 'abcdefghijklmnopqrstuvwxyz'; $alphaUp = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; $num = '0123456789'; $stdSpecial = '~`!@#$%^&*()-_+=[{]}\\|\'";:/?.>,<'; $source = $alphaLow . $alphaUp . $num . $stdSpecial; $result = ''; for ($x = 0; $x < 16; $x++) { $result .= substr($source, rand(0, (strlen($source) - 1)), 1); } return $result; } /** * Validate an email address against RFC conventions * * @param string $email The email address * * @return bool */ public static function isValidEmailAddress($email): bool { // the email address can not start with a dot. if (substr($email, 0, 1) == '.') { return false; } return true; } /** * Provide all unique combinations of elements in $input, with order and duplicates irrelevant. * * @param array $input The input array of elements. * * @return array[] */ public static function powerSet(array $input): array { $output = []; for ($x = 0; $x < count($input); $x++) { self::combine($input, $x + 1, 0, [], 0, $output); } return $output; } /** * Returns the current user's email address or null. * * @return string */ public static function userEmailOrNull(): ?string { $user = Auth::user(); if (!$user) { return null; } return $user->email; } /** * Returns a UUID in the form of an integer. * * @return integer */ public static function uuidInt(): int { $hex = Uuid::uuid4(); $bin = pack('h*', str_replace('-', '', $hex)); $ids = unpack('L', $bin); $id = array_shift($ids); return $id; } /** * Returns a UUID in the form of a string. * * @return string */ public static function uuidStr(): string { return Uuid::uuid4()->toString(); } private static function combine($input, $r, $index, $data, $i, &$output): void { $n = count($input); // Current cobination is ready if ($index == $r) { $output[] = array_slice($data, 0, $r); return; } // When no more elements are there to put in data[] if ($i >= $n) { return; } // current is included, put next at next location $data[$index] = $input[$i]; self::combine($input, $r, $index + 1, $data, $i + 1, $output); // current is excluded, replace it with next (Note that i+1 // is passed, but index is not changed) self::combine($input, $r, $index, $data, $i + 1, $output); } /** * Create self URL * * @param string $route Route/Path * * @return string Full URL */ public static function serviceUrl(string $route): string { $url = \config('app.public_url'); if (!$url) { $url = \config('app.url'); } return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/'); } /** * Create a configuration/environment data to be passed to * the UI * * @todo For a lack of better place this is put here for now * * @return array Configuration data */ public static function uiEnv(): array { $opts = ['app.name', 'app.url', 'app.domain']; $env = \app('config')->getMany($opts); $countries = include resource_path('countries.php'); $env['countries'] = $countries ?: []; $isAdmin = strpos(request()->getHttpHost(), 'admin.') === 0; $env['jsapp'] = $isAdmin ? 'admin.js' : 'user.js'; $env['paymentProvider'] = \config('services.payment_provider'); $env['stripePK'] = \config('services.stripe.public_key'); return $env; } } diff --git a/src/doctum.config.php b/src/doctum.config.php index cf75418f..79321815 100644 --- a/src/doctum.config.php +++ b/src/doctum.config.php @@ -1,35 +1,35 @@ files() ->name('*.php') ->exclude('bootstrap') ->exclude('cache') ->exclude('database') ->exclude('include') ->exclude('node_modules') - ->exclude('tests') + #->exclude('tests') ->exclude('vendor') ->in(__DIR__); $doctum = new Doctum( $iterator, [ - 'build_dir' => __DIR__ . '/../docs/build/%version%/', - 'cache_dir' => __DIR__ . '/cache/', + 'build_dir' => __DIR__ . '/../docs/build/%version%', + 'cache_dir' => __DIR__ . '/cache', 'default_opened_level' => 1, 'include_parent_data' => false, ] ); /* $doctum['filter'] = function () { return new TrueFilter(); }; */ return $doctum; diff --git a/src/tests/Functional/Methods/Auth/SecondFactorTest.php b/src/tests/Functional/Methods/Auth/SecondFactorTest.php index 572a33f0..dc70738a 100644 --- a/src/tests/Functional/Methods/Auth/SecondFactorTest.php +++ b/src/tests/Functional/Methods/Auth/SecondFactorTest.php @@ -1,63 +1,133 @@ domainUsers as $user) { if ($user->hasSku('2fa')) { $this->testUser = $user; break; } } // select any user without a second factor foreach ($this->domainUsers as $user) { if (!$user->hasSku('2fa')) { $this->testUserNone = $user; break; } } } /** * Verify factors exist for the test user. */ public function testFactors() { $mf = new \App\Auth\SecondFactor($this->testUser); $factors = $mf->factors(); $this->assertNotEmpty($factors); } /** * Verify no factors exist for the test user without factors. */ public function testFactorsNone() { $mf = new \App\Auth\SecondFactor($this->testUserNone); $factors = $mf->factors(); $this->assertEmpty($factors); } + + public function testFactorDriverValid() + { + $this->markTestIncomplete(); + } + + public function testVerifySuccess() + { + $this->markTestIncomplete(); + } + + public function testVerifyFailure() + { + $this->markTestIncomplete(); + } + + public function testSeed() + { + $this->markTestIncomplete(); + } + + public function code() + { + $this->markTestIncomplete(); + } + + public function testDbh() + { + $this->markTestIncomplete(); + } + + public function testMultipleFactors() + { + $this->markTestIncomplete(); + } + + public function testRead() + { + $this->markTestIncomplete(); + } + + public function testWrite() + { + $this->markTestIncomplete(); + } + + public function testRemove() + { + $this->markTestIncomplete(); + } + + public function testGetFactors() + { + $this->markTestIncomplete(); + } + + public function testKey2property() + { + $this->markTestIncomplete(); + } + + public function testGetPrefs() + { + $this->markTestIncomplete(); + } + + public function testSavePrefs() + { + $this->markTestIncomplete(); + } } diff --git a/src/tests/Functional/Methods/DiscountTest.php b/src/tests/Functional/Methods/DiscountTest.php new file mode 100644 index 00000000..0bd92cc8 --- /dev/null +++ b/src/tests/Functional/Methods/DiscountTest.php @@ -0,0 +1,18 @@ +markTestIncomplete(); + } +} diff --git a/src/tests/Functional/Methods/DomainTest.php b/src/tests/Functional/Methods/DomainTest.php index bebba58f..1c6a0643 100644 --- a/src/tests/Functional/Methods/DomainTest.php +++ b/src/tests/Functional/Methods/DomainTest.php @@ -1,202 +1,174 @@ publicDomain = \App\Domain::where('type', \App\Domain::TYPE_PUBLIC)->first(); - $this->publicDomainUser = $this->getTestUser('john@' . $this->publicDomain->namespace); - } - - public function tearDown(): void - { - $this->deleteTestUser($this->publicDomainUser->email); - - parent::tearDown(); - } - /** * Test that a public domain can not be assigned a package. */ public function testAssignPackagePublicDomain() { $domain = \App\Domain::where('type', \App\Domain::TYPE_PUBLIC)->first(); $package = \App\Package::where('title', 'domain-hosting')->first(); $sku = \App\Sku::where('title', 'domain-hosting')->first(); $numEntitlementsBefore = $sku->entitlements->count(); $domain->assignPackage($package, $this->publicDomainUser); // the domain is not associated with any entitlements. $entitlement = $domain->entitlement; $this->assertNull($entitlement); // the sku is not associated with more entitlements than before $numEntitlementsAfter = $sku->fresh()->entitlements->count(); $this->assertEqual($numEntitlementsBefore, $numEntitlementsAfter); } /** * Verify a domain that is assigned to a wallet already, can not be assigned to another wallet. */ public function testAssignPackageDomainWithWallet() { $package = \App\Package::where('title', 'domain-hosting')->first(); $sku = \App\Sku::where('title', 'domain-hosting')->first(); $this->assertSame($this->domainHosted->wallet()->owner->email, $this->domainOwner->email); $numEntitlementsBefore = $sku->entitlements->count(); $this->domainHosted->assignPackage($package, $this->publicDomainUser); // the sku is not associated with more entitlements than before $numEntitlementsAfter = $sku->fresh()->entitlements->count(); $this->assertEqual($numEntitlementsBefore, $numEntitlementsAfter); - // the wallet for this temporary user still holds no entitlements + // the wallet for this temporary user still holds no additional entitlements $wallet = $this->publicDomainUser->wallets()->first(); - $this->assertCount(0, $wallet->entitlements); + + $this->assertCount(4, $wallet->entitlements); } /** * Verify the function getPublicDomains returns a flat, single-dimensional, disassociative array of strings. */ public function testGetPublicDomainsIsFlatArray() { $domains = \App\Domain::getPublicDomains(); $this->assertisArray($domains); foreach ($domains as $domain) { $this->assertIsString($domain); } foreach ($domains as $num => $domain) { $this->assertIsInt($num); $this->assertIsString($domain); } } public function testGetPublicDomainsIsSorted() { $domains = \App\Domain::getPublicDomains(); sort($domains); $this->assertSame($domains, \App\Domain::getPublicDomains()); } /** * Verify we can suspend an active domain. */ public function testSuspendForActiveDomain() { Queue::fake(); $this->domainHosted->status |= \App\Domain::STATUS_ACTIVE; $this->assertFalse($this->domainHosted->isSuspended()); $this->assertTrue($this->domainHosted->isActive()); $this->domainHosted->suspend(); $this->assertTrue($this->domainHosted->isSuspended()); $this->assertFalse($this->domainHosted->isActive()); } /** * Verify we can unsuspend a suspended domain */ public function testUnsuspendForSuspendedDomain() { Queue::fake(); $this->domainHosted->status |= \App\Domain::STATUS_SUSPENDED; $this->assertTrue($this->domainHosted->isSuspended()); $this->assertFalse($this->domainHosted->isActive()); $this->domainHosted->unsuspend(); $this->assertFalse($this->domainHosted->isSuspended()); $this->assertTrue($this->domainHosted->isActive()); } /** * Verify we can unsuspend a suspended domain that wasn't confirmed */ public function testUnsuspendForSuspendedUnconfirmedDomain() { Queue::fake(); $this->domainHosted->status = \App\Domain::STATUS_NEW | \App\Domain::STATUS_SUSPENDED; $this->assertTrue($this->domainHosted->isNew()); $this->assertTrue($this->domainHosted->isSuspended()); $this->assertFalse($this->domainHosted->isActive()); $this->assertFalse($this->domainHosted->isConfirmed()); $this->assertFalse($this->domainHosted->isVerified()); $this->domainHosted->unsuspend(); $this->assertTrue($this->domainHosted->isNew()); $this->assertFalse($this->domainHosted->isSuspended()); $this->assertFalse($this->domainHosted->isActive()); $this->assertFalse($this->domainHosted->isConfirmed()); $this->assertFalse($this->domainHosted->isVerified()); } /** * Verify we can unsuspend a suspended domain that was verified but not confirmed */ public function testUnsuspendForSuspendedVerifiedUnconfirmedDomain() { Queue::fake(); $this->domainHosted->status = \App\Domain::STATUS_NEW | \App\Domain::STATUS_SUSPENDED | \App\Domain::STATUS_VERIFIED; $this->assertTrue($this->domainHosted->isNew()); $this->assertTrue($this->domainHosted->isSuspended()); $this->assertFalse($this->domainHosted->isActive()); $this->assertFalse($this->domainHosted->isConfirmed()); $this->assertTrue($this->domainHosted->isVerified()); $this->domainHosted->unsuspend(); $this->assertTrue($this->domainHosted->isNew()); $this->assertFalse($this->domainHosted->isSuspended()); $this->assertFalse($this->domainHosted->isActive()); $this->assertFalse($this->domainHosted->isConfirmed()); $this->assertTrue($this->domainHosted->isVerified()); } } diff --git a/src/tests/Functional/Methods/EntitlementTest.php b/src/tests/Functional/Methods/EntitlementTest.php new file mode 100644 index 00000000..319dbf69 --- /dev/null +++ b/src/tests/Functional/Methods/EntitlementTest.php @@ -0,0 +1,254 @@ +skuDomainHosting = \App\Sku::where('title', 'domain-hosting')->first(); + $this->skuMailbox = \App\Sku::where('title', 'mailbox')->first(); + $this->skuStorage = \App\Sku::where('title', 'storage')->first(); + + $this->discounts = [ + '50' => \App\Discount::create(['discount' => 50, 'active' => 1, 'description' => 'testing 50%']), + '100' => \App\Discount::create(['discount' => 100, 'active' => 1, 'description' => 'testing 100%']) + ]; + } + + /** + * Verify that an entitlement without a wallet associated with it will simply return zero. + */ + public function testCostsPerDayWithoutWallet() + { + $entitlement = new \App\Entitlement(); + + $daysInLastMonth = \App\Utils::daysInLastMonth(); + $entitlement->cost = $daysInLastMonth * 100; + + $this->assertEqual($entitlement->costsPerDay(), (float) 0.0); + } + + /** + * Verify that an entitlement without cost returns zero. + */ + public function testCostsPerDayWithoutCost() + { + $entitlement = $this->domainOwner->entitlements()->where('sku_id', $this->skuStorage->id)->first(); + + $this->assertIsInt($entitlement->cost); + $this->assertEqual($entitlement->cost, 0); + + $costsPerDay = $entitlement->costsPerDay(); + + $this->assertIsFloat($costsPerDay); + + $this->assertEqual($costsPerDay, (float) 0.0); + } + + /** + * Verify that an entitlement with costs returns something between zero and the original price, and that the + * original cost divided by the minimum number of days in any month (28) is higher or equal to the costs per day. + */ + public function testCostsPerDayWithWalletWithoutDiscount() + { + $entitlement = $this->domainOwner->entitlements()->where('sku_id', $this->skuMailbox->id)->first(); + + $this->assertIsInt($entitlement->cost); + $this->assertEqual($entitlement->cost, $this->skuMailbox->cost); + + $daysInLastMonth = \App\Utils::daysInLastMonth(); + + $costsPerDay = $entitlement->costsPerDay(); + + $this->assertIsFloat($costsPerDay); + + $this->assertTrue($costsPerDay > 0); + $this->assertTrue($costsPerDay < 444); + $this->assertTrue($costsPerDay <= (444 / 28)); + } + + /** + * Verify that an entitlement with costs returns something between zero and 50% of the original price, and that the + * original cost divided by the minimum number of days in any month (28) is higher or equal to the costs per day. + */ + public function testCostsPerDayWithWalletWithDiscountHalf() + { + $wallet = $this->domainOwner->wallets()->first(); + + $wallet->discount_id = $this->discounts['50']->id; + $wallet->save(); + + $entitlement = $this->domainOwner->entitlements()->where('sku_id', $this->skuMailbox->id)->first(); + + $this->assertIsInt($entitlement->cost); + $this->assertEqual($entitlement->cost, $this->skuMailbox->cost); + + $daysInLastMonth = \App\Utils::daysInLastMonth(); + + $costsPerDay = $entitlement->costsPerDay(); + + $this->assertIsFloat($costsPerDay); + + $this->assertTrue($costsPerDay > 0); + $this->assertTrue($costsPerDay < 222); + $this->assertTrue($costsPerDay <= (222 / 28)); + } + + public function testCreateTransaction() + { + $this->markTestIncomplete(); + } + + public function testEntitleableReturnDomain() + { + $entitlements = $this->domainOwner->wallets()->first()->entitlements(); + + $entitlement = $entitlements->where('sku_id', $this->skuDomainHosting->id)->first(); + + $this->assertInstanceOf('Illuminate\Database\Eloquent\Relations\MorphTo', $entitlement->entitleable()); + $this->assertInstanceOf('App\Domain', $entitlement->entitleable); + } + + public function testEntitleableReturnUser() + { + $entitlement = $this->domainOwner->entitlements()->where('sku_id', $this->skuMailbox->id)->first(); + + $this->assertInstanceOf('Illuminate\Database\Eloquent\Relations\MorphTo', $entitlement->entitleable()); + $this->assertInstanceOf('App\User', $entitlement->entitleable); + } + + public function testEntitleableDomain() + { + $entitlements = $this->domainOwner->wallets()->first()->entitlements(); + + $entitlement = $entitlements->where('sku_id', $this->skuDomainHosting->id)->first(); + + $domain = $entitlement->entitleable; + + $this->assertEqual($domain->id, $this->domainHosted->id); + } + + public function testEntitleableUser() + { + $entitlement = $this->domainOwner->entitlements()->where('sku_id', $this->skuMailbox->id)->first(); + + $user = $entitlement->entitleable; + + $this->assertEqual($user->id, $this->domainOwner->id); + } + + public function testEntitleableTitleDomain() + { + $entitlements = $this->domainOwner->wallets()->first()->entitlements(); + + $entitlement = $entitlements->where('sku_id', $this->skuDomainHosting->id)->first(); + + $namespace = $entitlement->entitleableTitle(); + + $this->assertSame($namespace, $this->domainHosted->namespace); + } + + public function testEntitleableTitleInvalidEntitleableId() + { + $this->expectException(\Exception::class); + + $wallet = $this->domainOwner->wallets()->first(); + + $entitlement = \App\Entitlement::create( + [ + 'sku_id' => $this->skuMailbox->id, + 'wallet_id' => $wallet->id, + 'entitleable_id' => 1234, + 'entitleable_type' => \App\User::class, + 'cost' => 0 + ] + ); + + $title = $entitlement->entitleableTitle(); + + $this->assertNull($title); + } + + public function testEntitleableTitleInvalidEntitleableTruncation() + { + // without the observer change, an exception would be thrown. + //$this->expectException(\Exception::class); + + $wallet = $this->domainOwner->wallets()->first(); + + $entitlement = \App\Entitlement::create( + [ + 'sku_id' => $this->skuMailbox->id, + 'wallet_id' => $wallet->id, + 'entitleable_id' => $wallet->id, + 'entitleable_type' => \App\Wallet::class, + 'cost' => 0 + ] + ); + + $title = $entitlement->entitleableTitle(); + + $this->assertNull($title); + } + + public function testEntitleableTitleInvalidEntitleableOverloadedProperty() + { + // without the observer change, an exception would be thrown. + //$this->expectException(\Exception::class); + + $wallet = $this->domainOwner->wallets()->first(); + + $entitlement = \App\Entitlement::create( + [ + 'sku_id' => $this->skuMailbox->id, + 'wallet_id' => $wallet->id, + 'entitleable_id' => 1234, + 'entitleable_type' => \App\Wallet::class, + 'cost' => 0 + ] + ); + + $title = $entitlement->entitleableTitle(); + + $this->assertNull($title); + } + + public function testEntitleableTitleUser() + { + $entitlement = $this->domainOwner->entitlements()->where('sku_id', $this->skuMailbox->id)->first(); + + $email = $entitlement->entitleableTitle(); + + $this->assertSame($email, $this->domainOwner->email); + } + + public function testSku() + { + $entitlements = $this->domainOwner->wallets()->first()->entitlements(); + + $entitlements->each( + function ($entitlement) { + $this->assertInstanceOf('Illuminate\Database\Eloquent\Relations\BelongsTo', $entitlement->sku()); + $this->assertInstanceOf('App\Sku', $entitlement->sku); + } + ); + } + + public function testWallet() + { + $this->markTestIncomplete(); + } +} diff --git a/src/tests/Functional/Methods/TransactionTest.php b/src/tests/Functional/Methods/TransactionTest.php new file mode 100644 index 00000000..2bc269ee --- /dev/null +++ b/src/tests/Functional/Methods/TransactionTest.php @@ -0,0 +1,32 @@ +markTestSkipped('requires the entire application to be bootstrapped, effectively functional'); + + foreach ($this->transactionTypes as $type) { + $this->transaction->{'type'} = $type; + + $this->assertNotNull($this->transaction->shortDescription()); + } + } +} diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php index 8d315d5f..4619acdd 100644 --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -1,378 +1,405 @@ '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. */ protected $jack; /** * A specific user that is a controller on the wallet to which the hosted domain is charged. */ protected $jane; /** * A specific user that has a second factor configured. */ protected $joe; /** * Assert two numeric values are the same. * * @param int|double|float $a * @param int|double|float $b */ protected function assertEqual($a, $b) { Assert::assertTrue(is_numeric($a)); Assert::assertTrue(is_numeric($b)); Assert::assertSame($a, $b); } /** * Assert that the entitlements for the user match the expected list of entitlements. * * @param \App\User $user The user for which the entitlements need to be pulled. * @param array $expected An array of expected \App\SKU titles. */ protected function assertUserEntitlements($user, $expected) { // Assert the user entitlements $skus = $user->entitlements()->get() ->map(function ($ent) { return $ent->sku->title; }) ->toArray(); sort($skus); Assert::assertSame($expected, $skus); } /** * 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, '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) { $transaction = Transaction::create( [ 'user_email' => 'jeroen.@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => $types[count($result) % count($types)], 'amount' => 11 * (count($result) + 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 user whatever it takes. * * @coversNothing */ protected function deleteTestUser($email) { Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } $job = new \App\Jobs\User\DeleteJob($user->id); $job->handle(); $user->forceDelete(); } /** * 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 User object by email, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestUser($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return User::firstOrCreate(['email' => $email], $attrib); } if ($user->deleted_at) { $user->restore(); } return $user; } /** * 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); } /** * 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 = array()) { $reflection = new \ReflectionClass(get_class($object)); $method = $reflection->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $parameters); } public function setUp(): void { parent::setUp(); $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 ] ); $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); // 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(\App\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); + $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); } public function tearDown(): void { foreach ($this->domainUsers as $user) { if ($user == $this->domainOwner) { continue; } $this->deleteTestUser($user->email); } $this->deleteTestUser($this->domainOwner->email); $this->deleteTestDomain($this->domainHosted->namespace); + $this->deleteTestUser($this->publicDomainUser->email); + parent::tearDown(); } } diff --git a/src/tests/Unit/Controller/DomainsTest.php b/src/tests/Unit/Controller/DomainsTest.php deleted file mode 100644 index dd676c73..00000000 --- a/src/tests/Unit/Controller/DomainsTest.php +++ /dev/null @@ -1,25 +0,0 @@ -markTestIncomplete(); - } - - /** - * Test DomainsController::statusInfo() - */ - public function testStatusInfo(): void - { - $this->markTestIncomplete(); - } -} diff --git a/src/tests/Unit/Controller/UsersTest.php b/src/tests/Unit/Controller/UsersTest.php deleted file mode 100644 index 89e0e5ae..00000000 --- a/src/tests/Unit/Controller/UsersTest.php +++ /dev/null @@ -1,25 +0,0 @@ -markTestIncomplete(); - } - - /** - * Test UsersController::statusInfo() - */ - public function testStatusInfo(): void - { - $this->markTestIncomplete(); - } -} diff --git a/src/tests/Unit/DomainTest.php b/src/tests/Unit/DomainTest.php deleted file mode 100644 index 37ab96e4..00000000 --- a/src/tests/Unit/DomainTest.php +++ /dev/null @@ -1,138 +0,0 @@ - 'test.com', - 'status' => \array_sum($domainStatuses), - 'type' => Domain::TYPE_EXTERNAL - ] - ); - - $domainStatuses = []; - - foreach ($statuses as $status) { - if ($domain->status & $status) { - $domainStatuses[] = $status; - } - } - - $this->assertSame($domain->status, \array_sum($domainStatuses)); - - // either one is true, but not both - $this->assertSame( - $domain->isNew() === in_array(Domain::STATUS_NEW, $domainStatuses), - $domain->isActive() === in_array(Domain::STATUS_ACTIVE, $domainStatuses) - ); - - $this->assertTrue( - $domain->isNew() === in_array(Domain::STATUS_NEW, $domainStatuses) - ); - - $this->assertTrue( - $domain->isActive() === in_array(Domain::STATUS_ACTIVE, $domainStatuses) - ); - - $this->assertTrue( - $domain->isConfirmed() === in_array(Domain::STATUS_CONFIRMED, $domainStatuses) - ); - - $this->assertTrue( - $domain->isSuspended() === in_array(Domain::STATUS_SUSPENDED, $domainStatuses) - ); - - $this->assertTrue( - $domain->isDeleted() === in_array(Domain::STATUS_DELETED, $domainStatuses) - ); - - $this->assertTrue( - $domain->isLdapReady() === in_array(Domain::STATUS_LDAP_READY, $domainStatuses) - ); - - $this->assertTrue( - $domain->isVerified() === in_array(Domain::STATUS_VERIFIED, $domainStatuses) - ); - } - } - - /** - * Test basic Domain funtionality - */ - public function testDomainType(): void - { - $types = [ - Domain::TYPE_PUBLIC, - Domain::TYPE_HOSTED, - Domain::TYPE_EXTERNAL, - ]; - - $domains = \App\Utils::powerSet($types); - - foreach ($domains as $domain_types) { - $domain = new Domain( - [ - 'namespace' => 'test.com', - 'status' => Domain::STATUS_NEW, - 'type' => \array_sum($domain_types), - ] - ); - - $this->assertTrue($domain->isPublic() === in_array(Domain::TYPE_PUBLIC, $domain_types)); - $this->assertTrue($domain->isHosted() === in_array(Domain::TYPE_HOSTED, $domain_types)); - $this->assertTrue($domain->isExternal() === in_array(Domain::TYPE_EXTERNAL, $domain_types)); - } - } - - /** - * Test domain hash generation - */ - public function testHash(): void - { - $domain = new Domain([ - 'namespace' => 'test.com', - 'status' => Domain::STATUS_NEW, - ]); - - $hash_code = $domain->hash(); - - $this->assertRegExp('/^[a-f0-9]{32}$/', $hash_code); - - $hash_text = $domain->hash(Domain::HASH_TEXT); - - $this->assertRegExp('/^kolab-verify=[a-f0-9]{32}$/', $hash_text); - - $this->assertSame($hash_code, str_replace('kolab-verify=', '', $hash_text)); - - $hash_cname = $domain->hash(Domain::HASH_CNAME); - - $this->assertSame('kolab-verify', $hash_cname); - - $hash_code2 = $domain->hash(Domain::HASH_CODE); - - $this->assertSame($hash_code, $hash_code2); - } -} diff --git a/src/tests/Unit/Mail/HelperTest.php b/src/tests/Unit/Mail/HelperTest.php deleted file mode 100644 index a8fdb980..00000000 --- a/src/tests/Unit/Mail/HelperTest.php +++ /dev/null @@ -1,84 +0,0 @@ -deleteTestUser('mail-helper-test@kolabnow.com'); - } - - /** - * {@inheritDoc} - */ - public function tearDown(): void - { - $this->deleteTestUser('mail-helper-test@kolabnow.com'); - parent::tearDown(); - } - - /** - * Test Helper::userEmails() - */ - public function testUserEmails(): void - { - $user = $this->getTestUser('mail-helper-test@kolabnow.com'); - - // User with no mailbox and no external email - list($to, $cc) = Helper::userEmails($user); - - $this->assertSame(null, $to); - $this->assertSame([], $cc); - - list($to, $cc) = Helper::userEmails($user, true); - - $this->assertSame(null, $to); - $this->assertSame([], $cc); - - // User with no mailbox but with external email - $user->setSetting('external_email', 'external@test.com'); - list($to, $cc) = Helper::userEmails($user); - - $this->assertSame('external@test.com', $to); - $this->assertSame([], $cc); - - list($to, $cc) = Helper::userEmails($user, true); - - $this->assertSame('external@test.com', $to); - $this->assertSame([], $cc); - - // User with mailbox and external email - $sku = \App\Sku::where('title', 'mailbox')->first(); - $user->assignSku($sku); - - list($to, $cc) = Helper::userEmails($user); - - $this->assertSame($user->email, $to); - $this->assertSame([], $cc); - - list($to, $cc) = Helper::userEmails($user, true); - - $this->assertSame($user->email, $to); - $this->assertSame(['external@test.com'], $cc); - - // User with mailbox, but no external email - $user->setSetting('external_email', null); - list($to, $cc) = Helper::userEmails($user); - - $this->assertSame($user->email, $to); - $this->assertSame([], $cc); - - list($to, $cc) = Helper::userEmails($user, true); - - $this->assertSame($user->email, $to); - $this->assertSame([], $cc); - } -} diff --git a/src/tests/Unit/Mail/NegativeBalanceBeforeDeleteTest.php b/src/tests/Unit/Mail/NegativeBalanceBeforeDeleteTest.php deleted file mode 100644 index f3963900..00000000 --- a/src/tests/Unit/Mail/NegativeBalanceBeforeDeleteTest.php +++ /dev/null @@ -1,62 +0,0 @@ -getTestUser('ned@kolab.org'); - $wallet = $user->wallets->first(); - $wallet->balance = -100; - $wallet->save(); - - $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_DELETE); - - \config([ - 'app.support_url' => 'https://kolab.org/support', - ]); - - $mail = $this->fakeMail(new NegativeBalanceBeforeDelete($wallet, $user)); - - $html = $mail['html']; - $plain = $mail['plain']; - - $walletUrl = \App\Utils::serviceUrl('/wallet'); - $walletLink = sprintf('%s', $walletUrl, $walletUrl); - $supportUrl = \config('app.support_url'); - $supportLink = sprintf('%s', $supportUrl, $supportUrl); - $appName = \config('app.name'); - - $this->assertMailSubject("$appName Final Warning", $mail['message']); - - $this->assertStringStartsWith('', $html); - $this->assertTrue(strpos($html, $user->name(true)) > 0); - $this->assertTrue(strpos($html, $walletLink) > 0); - $this->assertTrue(strpos($html, $supportLink) > 0); - $this->assertTrue(strpos($html, "This is a final reminder to settle your $appName") > 0); - $this->assertTrue(strpos($html, $threshold->toDateString()) > 0); - $this->assertTrue(strpos($html, "$appName Support") > 0); - $this->assertTrue(strpos($html, "$appName Team") > 0); - - $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); - $this->assertTrue(strpos($plain, $walletUrl) > 0); - $this->assertTrue(strpos($plain, $supportUrl) > 0); - $this->assertTrue(strpos($plain, "This is a final reminder to settle your $appName") > 0); - $this->assertTrue(strpos($plain, $threshold->toDateString()) > 0); - $this->assertTrue(strpos($plain, "$appName Support") > 0); - $this->assertTrue(strpos($plain, "$appName Team") > 0); - } -} diff --git a/src/tests/Unit/Mail/NegativeBalanceReminderTest.php b/src/tests/Unit/Mail/NegativeBalanceReminderTest.php deleted file mode 100644 index 53755cbc..00000000 --- a/src/tests/Unit/Mail/NegativeBalanceReminderTest.php +++ /dev/null @@ -1,62 +0,0 @@ -getTestUser('ned@kolab.org'); - $wallet = $user->wallets->first(); - $wallet->balance = -100; - $wallet->save(); - - $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_SUSPEND); - - \config([ - 'app.support_url' => 'https://kolab.org/support', - ]); - - $mail = $this->fakeMail(new NegativeBalanceReminder($wallet, $user)); - - $html = $mail['html']; - $plain = $mail['plain']; - - $walletUrl = \App\Utils::serviceUrl('/wallet'); - $walletLink = sprintf('%s', $walletUrl, $walletUrl); - $supportUrl = \config('app.support_url'); - $supportLink = sprintf('%s', $supportUrl, $supportUrl); - $appName = \config('app.name'); - - $this->assertMailSubject("$appName Payment Reminder", $mail['message']); - - $this->assertStringStartsWith('', $html); - $this->assertTrue(strpos($html, $user->name(true)) > 0); - $this->assertTrue(strpos($html, $walletLink) > 0); - $this->assertTrue(strpos($html, $supportLink) > 0); - $this->assertTrue(strpos($html, "you are behind on paying for your $appName account") > 0); - $this->assertTrue(strpos($html, $threshold->toDateString()) > 0); - $this->assertTrue(strpos($html, "$appName Support") > 0); - $this->assertTrue(strpos($html, "$appName Team") > 0); - - $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); - $this->assertTrue(strpos($plain, $walletUrl) > 0); - $this->assertTrue(strpos($plain, $supportUrl) > 0); - $this->assertTrue(strpos($plain, "you are behind on paying for your $appName account") > 0); - $this->assertTrue(strpos($plain, $threshold->toDateString()) > 0); - $this->assertTrue(strpos($plain, "$appName Support") > 0); - $this->assertTrue(strpos($plain, "$appName Team") > 0); - } -} diff --git a/src/tests/Unit/Mail/NegativeBalanceSuspendedTest.php b/src/tests/Unit/Mail/NegativeBalanceSuspendedTest.php deleted file mode 100644 index 7190ffab..00000000 --- a/src/tests/Unit/Mail/NegativeBalanceSuspendedTest.php +++ /dev/null @@ -1,62 +0,0 @@ -getTestUser('ned@kolab.org'); - $wallet = $user->wallets->first(); - $wallet->balance = -100; - $wallet->save(); - - $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_DELETE); - - \config([ - 'app.support_url' => 'https://kolab.org/support', - ]); - - $mail = $this->fakeMail(new NegativeBalanceSuspended($wallet, $user)); - - $html = $mail['html']; - $plain = $mail['plain']; - - $walletUrl = \App\Utils::serviceUrl('/wallet'); - $walletLink = sprintf('%s', $walletUrl, $walletUrl); - $supportUrl = \config('app.support_url'); - $supportLink = sprintf('%s', $supportUrl, $supportUrl); - $appName = \config('app.name'); - - $this->assertMailSubject("$appName Account Suspended", $mail['message']); - - $this->assertStringStartsWith('', $html); - $this->assertTrue(strpos($html, $user->name(true)) > 0); - $this->assertTrue(strpos($html, $walletLink) > 0); - $this->assertTrue(strpos($html, $supportLink) > 0); - $this->assertTrue(strpos($html, "Your $appName account has been suspended") > 0); - $this->assertTrue(strpos($html, $threshold->toDateString()) > 0); - $this->assertTrue(strpos($html, "$appName Support") > 0); - $this->assertTrue(strpos($html, "$appName Team") > 0); - - $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); - $this->assertTrue(strpos($plain, $walletUrl) > 0); - $this->assertTrue(strpos($plain, $supportUrl) > 0); - $this->assertTrue(strpos($plain, "Your $appName account has been suspended") > 0); - $this->assertTrue(strpos($plain, $threshold->toDateString()) > 0); - $this->assertTrue(strpos($plain, "$appName Support") > 0); - $this->assertTrue(strpos($plain, "$appName Team") > 0); - } -} diff --git a/src/tests/Unit/Mail/NegativeBalanceTest.php b/src/tests/Unit/Mail/NegativeBalanceTest.php deleted file mode 100644 index 01285fa4..00000000 --- a/src/tests/Unit/Mail/NegativeBalanceTest.php +++ /dev/null @@ -1,55 +0,0 @@ - 'https://kolab.org/support', - ]); - - $mail = $this->fakeMail(new NegativeBalance($wallet, $user)); - - $html = $mail['html']; - $plain = $mail['plain']; - - $walletUrl = \App\Utils::serviceUrl('/wallet'); - $walletLink = sprintf('%s', $walletUrl, $walletUrl); - $supportUrl = \config('app.support_url'); - $supportLink = sprintf('%s', $supportUrl, $supportUrl); - $appName = \config('app.name'); - - $this->assertMailSubject("$appName Payment Required", $mail['message']); - - $this->assertStringStartsWith('', $html); - $this->assertTrue(strpos($html, $user->name(true)) > 0); - $this->assertTrue(strpos($html, $walletLink) > 0); - $this->assertTrue(strpos($html, $supportLink) > 0); - $this->assertTrue(strpos($html, "your $appName account balance has run into the nega") > 0); - $this->assertTrue(strpos($html, "$appName Support") > 0); - $this->assertTrue(strpos($html, "$appName Team") > 0); - - $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); - $this->assertTrue(strpos($plain, $walletUrl) > 0); - $this->assertTrue(strpos($plain, $supportUrl) > 0); - $this->assertTrue(strpos($plain, "your $appName account balance has run into the nega") > 0); - $this->assertTrue(strpos($plain, "$appName Support") > 0); - $this->assertTrue(strpos($plain, "$appName Team") > 0); - } -} diff --git a/src/tests/Unit/Mail/PasswordResetTest.php b/src/tests/Unit/Mail/PasswordResetTest.php deleted file mode 100644 index fb0f3dd8..00000000 --- a/src/tests/Unit/Mail/PasswordResetTest.php +++ /dev/null @@ -1,50 +0,0 @@ - 123456789, - 'mode' => 'password-reset', - 'code' => 'code', - 'short_code' => 'short-code', - ]); - - $code->user = new User([ - 'name' => 'User Name', - ]); - - $mail = $this->fakeMail(new PasswordReset($code)); - - $html = $mail['html']; - $plain = $mail['plain']; - - $url = Utils::serviceUrl('/password-reset/' . $code->short_code . '-' . $code->code); - $link = "$url"; - $appName = \config('app.name'); - - $this->assertMailSubject("$appName Password Reset", $mail['message']); - - $this->assertStringStartsWith('', $html); - $this->assertTrue(strpos($html, $link) > 0); - $this->assertTrue(strpos($html, $code->user->name(true)) > 0); - - $this->assertStringStartsWith("Dear " . $code->user->name(true), $plain); - $this->assertTrue(strpos($plain, $link) > 0); - } -} diff --git a/src/tests/Unit/Mail/PaymentFailureTest.php b/src/tests/Unit/Mail/PaymentFailureTest.php deleted file mode 100644 index 6cf8f46f..00000000 --- a/src/tests/Unit/Mail/PaymentFailureTest.php +++ /dev/null @@ -1,54 +0,0 @@ -amount = 123; - - \config(['app.support_url' => 'https://kolab.org/support']); - - $mail = $this->fakeMail(new PaymentFailure($payment, $user)); - - $html = $mail['html']; - $plain = $mail['plain']; - - $walletUrl = \App\Utils::serviceUrl('/wallet'); - $walletLink = sprintf('%s', $walletUrl, $walletUrl); - $supportUrl = \config('app.support_url'); - $supportLink = sprintf('%s', $supportUrl, $supportUrl); - $appName = \config('app.name'); - - $this->assertMailSubject("$appName Payment Failed", $mail['message']); - - $this->assertStringStartsWith('', $html); - $this->assertTrue(strpos($html, $user->name(true)) > 0); - $this->assertTrue(strpos($html, $walletLink) > 0); - $this->assertTrue(strpos($html, $supportLink) > 0); - $this->assertTrue(strpos($html, "$appName Support") > 0); - $this->assertTrue(strpos($html, "Something went wrong with auto-payment for your $appName account") > 0); - $this->assertTrue(strpos($html, "$appName Team") > 0); - - $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); - $this->assertTrue(strpos($plain, $walletUrl) > 0); - $this->assertTrue(strpos($plain, $supportUrl) > 0); - $this->assertTrue(strpos($plain, "$appName Support") > 0); - $this->assertTrue(strpos($plain, "Something went wrong with auto-payment for your $appName account") > 0); - $this->assertTrue(strpos($plain, "$appName Team") > 0); - } -} diff --git a/src/tests/Unit/Mail/PaymentMandateDisabledTest.php b/src/tests/Unit/Mail/PaymentMandateDisabledTest.php deleted file mode 100644 index 4e43f475..00000000 --- a/src/tests/Unit/Mail/PaymentMandateDisabledTest.php +++ /dev/null @@ -1,53 +0,0 @@ - 'https://kolab.org/support']); - - $mail = $this->fakeMail(new PaymentMandateDisabled($wallet, $user)); - - $html = $mail['html']; - $plain = $mail['plain']; - - $walletUrl = \App\Utils::serviceUrl('/wallet'); - $walletLink = sprintf('%s', $walletUrl, $walletUrl); - $supportUrl = \config('app.support_url'); - $supportLink = sprintf('%s', $supportUrl, $supportUrl); - $appName = \config('app.name'); - - $this->assertMailSubject("$appName Auto-payment Problem", $mail['message']); - - $this->assertStringStartsWith('', $html); - $this->assertTrue(strpos($html, $user->name(true)) > 0); - $this->assertTrue(strpos($html, $walletLink) > 0); - $this->assertTrue(strpos($html, $supportLink) > 0); - $this->assertTrue(strpos($html, "$appName Support") > 0); - $this->assertTrue(strpos($html, "Your $appName account balance") > 0); - $this->assertTrue(strpos($html, "$appName Team") > 0); - - $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); - $this->assertTrue(strpos($plain, $walletUrl) > 0); - $this->assertTrue(strpos($plain, $supportUrl) > 0); - $this->assertTrue(strpos($plain, "$appName Support") > 0); - $this->assertTrue(strpos($plain, "Your $appName account balance") > 0); - $this->assertTrue(strpos($plain, "$appName Team") > 0); - } -} diff --git a/src/tests/Unit/Mail/PaymentSuccessTest.php b/src/tests/Unit/Mail/PaymentSuccessTest.php deleted file mode 100644 index e155d520..00000000 --- a/src/tests/Unit/Mail/PaymentSuccessTest.php +++ /dev/null @@ -1,54 +0,0 @@ -amount = 123; - - \config(['app.support_url' => 'https://kolab.org/support']); - - $mail = $this->fakeMail(new PaymentSuccess($payment, $user)); - - $html = $mail['html']; - $plain = $mail['plain']; - - $walletUrl = \App\Utils::serviceUrl('/wallet'); - $walletLink = sprintf('%s', $walletUrl, $walletUrl); - $supportUrl = \config('app.support_url'); - $supportLink = sprintf('%s', $supportUrl, $supportUrl); - $appName = \config('app.name'); - - $this->assertMailSubject("$appName Payment Succeeded", $mail['message']); - - $this->assertStringStartsWith('', $html); - $this->assertTrue(strpos($html, $user->name(true)) > 0); - $this->assertTrue(strpos($html, $walletLink) > 0); - $this->assertTrue(strpos($html, $supportLink) > 0); - $this->assertTrue(strpos($html, "$appName Support") > 0); - $this->assertTrue(strpos($html, "The auto-payment for your $appName account") > 0); - $this->assertTrue(strpos($html, "$appName Team") > 0); - - $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); - $this->assertTrue(strpos($plain, $walletUrl) > 0); - $this->assertTrue(strpos($plain, $supportUrl) > 0); - $this->assertTrue(strpos($plain, "$appName Support") > 0); - $this->assertTrue(strpos($plain, "The auto-payment for your $appName account") > 0); - $this->assertTrue(strpos($plain, "$appName Team") > 0); - } -} diff --git a/src/tests/Unit/Mail/SignupVerificationTest.php b/src/tests/Unit/Mail/SignupVerificationTest.php deleted file mode 100644 index c7796658..00000000 --- a/src/tests/Unit/Mail/SignupVerificationTest.php +++ /dev/null @@ -1,48 +0,0 @@ - 'code', - 'short_code' => 'short-code', - 'data' => [ - 'email' => 'test@email', - 'first_name' => 'First', - 'last_name' => 'Last', - ], - ]); - - $mail = $this->fakeMail(new SignupVerification($code)); - - $html = $mail['html']; - $plain = $mail['plain']; - - $url = Utils::serviceUrl('/signup/' . $code->short_code . '-' . $code->code); - $link = "$url"; - $appName = \config('app.name'); - - $this->assertMailSubject("$appName Registration", $mail['message']); - - $this->assertStringStartsWith('', $html); - $this->assertTrue(strpos($html, $link) > 0); - $this->assertTrue(strpos($html, 'First Last') > 0); - - $this->assertStringStartsWith('Dear First Last', $plain); - $this->assertTrue(strpos($plain, $url) > 0); - } -} diff --git a/src/tests/Unit/Mail/SuspendedDebtorTest.php b/src/tests/Unit/Mail/SuspendedDebtorTest.php deleted file mode 100644 index 746fddd5..00000000 --- a/src/tests/Unit/Mail/SuspendedDebtorTest.php +++ /dev/null @@ -1,65 +0,0 @@ - 'https://kolab.org/support', - 'app.kb.account_suspended' => 'https://kb.kolab.org/account-suspended', - 'app.kb.account_delete' => 'https://kb.kolab.org/account-delete', - ]); - - $mail = $this->fakeMail(new SuspendedDebtor($user)); - - $html = $mail['html']; - $plain = $mail['plain']; - - $walletUrl = \App\Utils::serviceUrl('/wallet'); - $walletLink = sprintf('%s', $walletUrl, $walletUrl); - $supportUrl = \config('app.support_url'); - $supportLink = sprintf('%s', $supportUrl, $supportUrl); - $deleteUrl = \config('app.kb.account_delete'); - $deleteLink = sprintf('%s', $deleteUrl, $deleteUrl); - $moreUrl = \config('app.kb.account_suspended'); - $moreLink = sprintf('here', $moreUrl); - $appName = \config('app.name'); - - $this->assertMailSubject("$appName Account Suspended", $mail['message']); - - $this->assertStringStartsWith('', $html); - $this->assertTrue(strpos($html, $user->name(true)) > 0); - $this->assertTrue(strpos($html, $walletLink) > 0); - $this->assertTrue(strpos($html, $supportLink) > 0); - $this->assertTrue(strpos($html, $deleteLink) > 0); - $this->assertTrue(strpos($html, "You have been behind on paying for your $appName account") > 0); - $this->assertTrue(strpos($html, "over 14 days") > 0); - $this->assertTrue(strpos($html, "See $moreLink for more information") > 0); - $this->assertTrue(strpos($html, "$appName Support") > 0); - $this->assertTrue(strpos($html, "$appName Team") > 0); - - $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); - $this->assertTrue(strpos($plain, $walletUrl) > 0); - $this->assertTrue(strpos($plain, $supportUrl) > 0); - $this->assertTrue(strpos($plain, $deleteUrl) > 0); - $this->assertTrue(strpos($plain, "You have been behind on paying for your $appName account") > 0); - $this->assertTrue(strpos($plain, "over 14 days") > 0); - $this->assertTrue(strpos($plain, "See $moreUrl for more information") > 0); - $this->assertTrue(strpos($plain, "$appName Support") > 0); - $this->assertTrue(strpos($plain, "$appName Team") > 0); - } -} diff --git a/src/tests/Unit/Methods/DiscountTest.php b/src/tests/Unit/Methods/DiscountTest.php new file mode 100644 index 00000000..11b86d82 --- /dev/null +++ b/src/tests/Unit/Methods/DiscountTest.php @@ -0,0 +1,64 @@ +discount = new \App\Discount(); + } + + /** + * With no setup, no teardown is needed. + */ + public function tearDown(): void + { + // nothing to do here + } + + public function testSetDiscountAttributeDouble() + { + $this->discount->discount = (double)1.01; + + $this->assertIsInt($this->discount->discount); + } + + public function testSetDiscountAttributeFloat() + { + $this->discount->discount = (float)1.01; + + $this->assertIsInt($this->discount->discount); + } + + /** + * Test setting discount value + */ + public function testDiscountValueLessThanZero() + { + $this->discount->discount = -1; + + $this->assertTrue($this->discount->discount == 0); + } + + /** + * Test setting discount value + */ + public function testDiscountValueMoreThanHundred() + { + $this->discount->discount = 101; + + $this->assertTrue($this->discount->discount == 100); + } +} diff --git a/src/tests/Unit/Methods/DomainTest.php b/src/tests/Unit/Methods/DomainTest.php index a0c516ec..6dd68743 100644 --- a/src/tests/Unit/Methods/DomainTest.php +++ b/src/tests/Unit/Methods/DomainTest.php @@ -1,162 +1,171 @@ domain = new \App\Domain(); } + /** + * With no setup, no teardown is needed. + */ + public function tearDown(): void + { + // nothing to do here + } + /** * Test lower-casing namespace attribute. */ public function testSetNamespaceAttributeLowercases() { $this->domain = new \App\Domain(); $this->domain->namespace = 'UPPERCASE'; // @phpstan-ignore-next-line $this->assertTrue($this->domain->namespace === 'uppercase'); } /** * Test setting the status to something invalid */ public function testSetStatusAttributeInvalid() { $this->expectException(\Exception::class); $this->domain->status = 123456; } /** * Test public domain. */ public function testSetStatusAttributeOnPublicDomain() { $this->domain->{'type'} = \App\Domain::TYPE_PUBLIC; $this->domain->status = 115; $this->assertTrue($this->domain->status == 115); } /** * Test status mutations */ public function testSetStatusAttributeActiveMakesForNotNew() { $this->domain->status = \App\Domain::STATUS_NEW; $this->assertTrue($this->domain->isNew()); $this->assertFalse($this->domain->isActive()); $this->domain->status |= \App\Domain::STATUS_ACTIVE; $this->assertFalse($this->domain->isNew()); $this->assertTrue($this->domain->isActive()); } /** * Verify setting confirmed sets verified. */ public function testSetStatusAttributeConfirmedMakesForVerfied() { $this->domain->status = \App\Domain::STATUS_CONFIRMED; $this->assertTrue($this->domain->isConfirmed()); $this->assertTrue($this->domain->isVerified()); } /** * Verify setting confirmed sets active. */ public function testSetStatusAttributeConfirmedMakesForActive() { $this->domain->status = \App\Domain::STATUS_CONFIRMED; $this->assertTrue($this->domain->isConfirmed()); $this->assertTrue($this->domain->isActive()); } /** * Verify setting deleted drops active. */ public function testSetStatusAttributeDeletedVoidsActive() { $this->domain->status = \App\Domain::STATUS_ACTIVE; $this->assertTrue($this->domain->isActive()); $this->assertFalse($this->domain->isNew()); $this->assertFalse($this->domain->isDeleted()); $this->domain->status |= \App\Domain::STATUS_DELETED; $this->assertFalse($this->domain->isActive()); $this->assertFalse($this->domain->isNew()); $this->assertTrue($this->domain->isDeleted()); } /** * Verify setting suspended drops active. */ public function testSetStatusAttributeSuspendedVoidsActive() { $this->domain->status = \App\Domain::STATUS_ACTIVE; $this->assertTrue($this->domain->isActive()); $this->assertFalse($this->domain->isSuspended()); $this->domain->status |= \App\Domain::STATUS_SUSPENDED; $this->assertFalse($this->domain->isActive()); $this->assertTrue($this->domain->isSuspended()); } /** * Verify we can suspend a suspended domain without disaster. * * This doesn't change anything to trigger a save. */ public function testSuspendForSuspendedDomain() { $this->domain->status = \App\Domain::STATUS_ACTIVE; $this->domain->status |= \App\Domain::STATUS_SUSPENDED; $this->assertTrue($this->domain->isSuspended()); $this->assertFalse($this->domain->isActive()); $this->domain->suspend(); $this->assertTrue($this->domain->isSuspended()); $this->assertFalse($this->domain->isActive()); } /** * Verify we can unsuspend an active (unsuspended) domain * * This doesn't change anything to trigger a save. */ public function testUnsuspendForActiveDomain() { $this->domain->status = \App\Domain::STATUS_ACTIVE; $this->assertFalse($this->domain->isSuspended()); $this->assertTrue($this->domain->isActive()); $this->domain->unsuspend(); $this->assertFalse($this->domain->isSuspended()); $this->assertTrue($this->domain->isActive()); } } diff --git a/src/tests/Unit/Methods/EntitlementTest.php b/src/tests/Unit/Methods/EntitlementTest.php new file mode 100644 index 00000000..32dcd1ad --- /dev/null +++ b/src/tests/Unit/Methods/EntitlementTest.php @@ -0,0 +1,90 @@ +entitlement = new \App\Entitlement(); + } + + /** + * With no setup, no teardown is needed. + */ + public function tearDown(): void + { + // nothing to do here + } + + /** TODO: This is a functional test because of the want for a discount on the associated wallet. */ + public function testCostsPerDay() + { + $this->markTestSkipped('This is a functional test'); + + $daysInLastMonth = \App\Utils::daysInLastMonth(); + $this->entitlement->cost = $daysInLastMonth * 100; + + $this->assertEqual($this->entitlement->costsPerDay(), 100); + } + + public function testCreateTransaction() + { + $this->markTestSkipped('This is a functional test'); + } + + public function testEntitleable() + { + $this->markTestSkipped('This is a functional test'); + } + + public function testEntitleableTitle() + { + $this->markTestSkipped('This is a functional test'); + } + + public function testSku() + { + $this->markTestSkipped('This is a functional test'); + } + + public function testWallet() + { + $this->markTestSkipped('This is a functional test'); + } + + public function testCost() + { + $this->entitlement->cost = 1000; + $this->assertEqual($this->entitlement->cost, 1000); + } + + public function testCostDouble() + { + $this->entitlement->cost = (double) 1000.49; + $this->assertEqual($this->entitlement->cost, 1000); + } + + public function testCostFloat() + { + $this->entitlement->cost = (float) 1000.49; + $this->assertEqual($this->entitlement->cost, 1000); + } + + public function testCostNegative() + { + $this->entitlement->cost = -1000; + $this->assertEqual($this->entitlement->cost, -1000); + } +} diff --git a/src/tests/Unit/Methods/SignupCodeTest.php b/src/tests/Unit/Methods/SignupCodeTest.php new file mode 100644 index 00000000..03f7f64a --- /dev/null +++ b/src/tests/Unit/Methods/SignupCodeTest.php @@ -0,0 +1,63 @@ +signupcode = new \App\SignupCode(); + } + + /** + * With no setup, no teardown is needed. + */ + public function tearDown(): void + { + // nothing to do here + } + + /** + * Verify that on the object and database level, the signup code's expiry is not set. + * + * It is set in the observer, which is a ::create(), which is therefore functional. + */ + public function testExpiredAtDefaultNull() + { + $this->assertNull($this->signupcode->expires_at); + } + + /** + * Verify that on the object and database level, the signup code's expiry is not set. + * + * It is set in the observer, which is a ::create(), which is therefore functional. + * + * That means ->isExpired() should always return false. + */ + public function testIsExpired() + { + $this->assertFalse($this->signupcode->isExpired()); + } + + public function testGenerateShortCode() + { + $codes = []; + + // 2 ^ 10 generated codes yields duplicates + // the number of signups with valid codes is to happen within 24 hours + for ($x = 0; $x < pow(2, 9); $x++) { + $code = \App\SignupCode::generateShortCode(); + + $this->assertTrue(!in_array($code, $codes), "Duplicate code generated at {$x} codes"); + + $codes[] = $code; + } + } +} diff --git a/src/tests/Unit/Methods/TransactionTest.php b/src/tests/Unit/Methods/TransactionTest.php new file mode 100644 index 00000000..79f52e46 --- /dev/null +++ b/src/tests/Unit/Methods/TransactionTest.php @@ -0,0 +1,52 @@ +transaction = new \App\Transaction(); + } + + /** + * With no setup, no teardown is needed. + */ + public function tearDown(): void + { + // nothing to do here + } + + public function testSetTypeAttributeAnyValid() + { + foreach ($this->transactionTypes as $type) { + $this->transaction->{'type'} = $type; + + $this->assertSame($this->transaction->{'type'}, $type); + } + } + + public function testSetTypeAttributeInvalid() + { + $this->expectException(\Exception::class); + + $this->transaction->{'type'} = 1; + } +} diff --git a/src/tests/Unit/Methods/UserTest.php b/src/tests/Unit/Methods/UserTest.php new file mode 100644 index 00000000..c6aaf3a2 --- /dev/null +++ b/src/tests/Unit/Methods/UserTest.php @@ -0,0 +1,146 @@ +user = new \App\User(); + } + + public function tearDown(): void + { + // nothing to do here + } + + public function testGetJWTCustomClaims() + { + $this->assertEmpty($this->user->getJWTCustomClaims()); + } + + public function testIsNewFailure() + { + $this->assertFalse($this->user->isNew()); + } + + public function testIsNewSuccess() + { + $this->user->status |= \App\User::STATUS_NEW; + $this->assertTrue($this->user->isNew()); + } + + public function testIsActiveFailure() + { + $this->assertFalse($this->user->isActive()); + } + + public function testIsActiveSuccess() + { + $this->user->status |= \App\User::STATUS_ACTIVE; + $this->assertTrue($this->user->isActive()); + } + + public function testIsSuspendedFailure() + { + $this->assertFalse($this->user->isSuspended()); + } + + public function testIsSuspendedSuccess() + { + $this->user->status |= \App\User::STATUS_SUSPENDED; + $this->assertTrue($this->user->isSuspended()); + } + + public function testIsDeletedFailure() + { + $this->assertFalse($this->user->isDeleted()); + } + + public function testIsDeletedSuccess() + { + $this->user->status |= \App\User::STATUS_DELETED; + $this->assertTrue($this->user->isDeleted()); + } + + public function testIsImapReadyFailure() + { + $this->assertFalse($this->user->isImapReady()); + } + + public function testIsImapReadySuccess() + { + $this->user->status |= \App\User::STATUS_IMAP_READY; + $this->assertTrue($this->user->isImapReady()); + } + + public function testIsLdapReadyFailure() + { + $this->assertFalse($this->user->isLdapReady()); + } + + public function testIsLdapReadySuccess() + { + $this->user->status |= \App\User::STATUS_LDAP_READY; + $this->assertTrue($this->user->isLdapReady()); + } + + public function testSetStatusAttributeAnyValid() + { + foreach ($this->statuses as $status) { + $this->user->status = $status; + + $this->assertSame($this->user->status, $status); + } + } + + public function testSetStatusAttributeAnyValidCombination() + { + foreach ($this->statuses as $status) { + $this->user->status |= $status; + + $this->assertTrue(($this->user->status & $status) > 0); + } + } + + public function testSetStatusAttributeInvalidTooHigh() + { + $this->expectException(\Exception::class); + + $this->user->status = pow(2, 6) + 1; + } + + public function testSetStatusAttributeNonNumeric() + { + $this->expectException(\Exception::class); + + $this->user->status = 'something definitely invalid for an integer field'; + } + + public function testSuspendSuspendedUser() + { + $this->user->status |= \App\User::STATUS_SUSPENDED; + $this->user->suspend(); + + $this->assertTrue($this->user->isSuspended()); + } + + public function testUnsuspendNotSuspendedUser() + { + $this->user->status |= \App\User::STATUS_ACTIVE; + $this->user->unsuspend(); + + $this->assertFalse($this->user->isSuspended()); + } +} diff --git a/src/tests/Unit/Methods/UtilsTest.php b/src/tests/Unit/Methods/UtilsTest.php new file mode 100644 index 00000000..e9bb063c --- /dev/null +++ b/src/tests/Unit/Methods/UtilsTest.php @@ -0,0 +1,92 @@ +assertIsInt($numDays); + + $this->assertTrue($numDays >= 28); + $this->assertTrue($numDays <= 31); + } + + /** + * Verify that the result for the function is within boundaries per the expected. + * + * A leap year is expected to happen on any anno domini divisible by 4, but not on any anno domini divisible by + * 100, with the exception of those also divisible by 400. + * + * For those that read this documentation within the lifetime of this code, this means 2000 AD to 2400 AD, I hope + * you'll find this test to be accurate, if not useful. + * + * We're not validating specifically whether or not \Carbon\Carbon does its job OK. We're validating the assertion + * it does per our own limited comprehension of things, so our other tests can enjoy the benefits of such + * validation having happened here. + */ + public function testDaysInLastMonthForAllFortyEightMonthsPast() + { + $today = Carbon::now(); + + $found29 = false; + $needFound29 = false; + + // anywhere within the past 9 years, a leap year must have happened. + $numMonths = 12 * 9; + + for ($x = 0; $x < $numMonths; $x++) { + $testMonth = $today->copy()->subMonthsWithoutOverflow($x); + if ($testMonth->isLeapYear()) { + $needFound29 = true; + } + } + + for ($x = 0; $x < $numMonths; $x++) { + $testMonth = $today->copy()->subMonthsWithoutOverflow($x); + $testDate = Carbon::create($testMonth->year, $testMonth->month, 1, 12); + Carbon::setTestNow($testDate); + + $numDays = \App\Utils::daysInLastMonth(); + + if ($numDays == 29) { + $found29 = true; + } + + $this->assertIsInt($numDays); + + $this->assertTrue($numDays >= 28); + $this->assertTrue($numDays <= 31); + } + + $this->assertTrue($found29 && $needFound29); + } + + public function testGeneratePassphrase() + { + $passphrases = []; + + for ($x = 0; $x < pow(2, 10); $x++) { + $passphrase = \App\Utils::generatePassPhrase(); + + $this->assertFalse(in_array($passphrase, $passphrases)); + + $passphrases[] = $passphrase; + } + } +} diff --git a/src/tests/Unit/Methods/VerificationCodeTest.php b/src/tests/Unit/Methods/VerificationCodeTest.php new file mode 100644 index 00000000..d061ae2b --- /dev/null +++ b/src/tests/Unit/Methods/VerificationCodeTest.php @@ -0,0 +1,41 @@ +verificationcode = new \App\VerificationCode(); + } + + /** + * With no setup, no teardown is needed. + */ + public function tearDown(): void + { + // nothing to do here + } + + public function testGenerateShortCode() + { + $codes = []; + + // 2 ^ 10 generated codes yields duplicates + // the number of verification with valid codes is to happen within 8 hours + for ($x = 0; $x < pow(2, 9); $x++) { + $code = \App\VerificationCode::generateShortCode(); + + $this->assertTrue(!in_array($code, $codes), "Duplicate code generated at {$x} codes"); + + $codes[] = $code; + } + } +} diff --git a/src/tests/Unit/Methods/WalletSettingTest.php b/src/tests/Unit/Methods/WalletSettingTest.php new file mode 100644 index 00000000..469afd28 --- /dev/null +++ b/src/tests/Unit/Methods/WalletSettingTest.php @@ -0,0 +1,9 @@ + $email], - ['email' => [new ExternalEmail()]] - ); - - $result = null; - if ($v->fails()) { - $result = $v->errors()->toArray()['email'][0]; - } - - $this->assertSame($expected_result, $result); - } -} diff --git a/src/tests/Unit/Rules/UserEmailDomainTest.php b/src/tests/Unit/Rules/UserEmailDomainTest.php deleted file mode 100644 index 559d337c..00000000 --- a/src/tests/Unit/Rules/UserEmailDomainTest.php +++ /dev/null @@ -1,18 +0,0 @@ -markTestIncomplete(); - } -} diff --git a/src/tests/Unit/Rules/UserEmailLocalTest.php b/src/tests/Unit/Rules/UserEmailLocalTest.php deleted file mode 100644 index ecf233c2..00000000 --- a/src/tests/Unit/Rules/UserEmailLocalTest.php +++ /dev/null @@ -1,21 +0,0 @@ -markTestIncomplete(); - - // the email address can not start with a dot. - $this->assertFalse(\App\Utils::isValidEmailAddress('.something@test.domain')); - } -} diff --git a/src/tests/Unit/SignupCodeTest.php b/src/tests/Unit/SignupCodeTest.php deleted file mode 100644 index ed2ed2fb..00000000 --- a/src/tests/Unit/SignupCodeTest.php +++ /dev/null @@ -1,23 +0,0 @@ -assertTrue(is_string($code)); - $this->assertTrue(strlen($code) === env('SIGNUP_CODE_LENGTH', SignupCode::SHORTCODE_LENGTH)); - $this->assertTrue(strspn($code, env('SIGNUP_CODE_CHARS', SignupCode::SHORTCODE_CHARS)) === strlen($code)); - } -} diff --git a/src/tests/Unit/TransactionTest.php b/src/tests/Unit/TransactionTest.php deleted file mode 100644 index 15c9976b..00000000 --- a/src/tests/Unit/TransactionTest.php +++ /dev/null @@ -1,205 +0,0 @@ -delete(); - $user = $this->getTestUser('jane@kolabnow.com'); - $wallet = $user->wallets()->first(); - - // Create transactions - - $transaction = Transaction::create([ - 'object_id' => $wallet->id, - 'object_type' => Wallet::class, - 'type' => Transaction::WALLET_PENALTY, - 'amount' => 9, - 'description' => "A test penalty" - ]); - - $transaction = Transaction::create([ - 'object_id' => $wallet->id, - 'object_type' => Wallet::class, - 'type' => Transaction::WALLET_DEBIT, - 'amount' => 10 - ]); - - $transaction = Transaction::create([ - 'object_id' => $wallet->id, - 'object_type' => Wallet::class, - 'type' => Transaction::WALLET_CREDIT, - 'amount' => 11 - ]); - - $transaction = Transaction::create([ - 'object_id' => $wallet->id, - 'object_type' => Wallet::class, - 'type' => Transaction::WALLET_AWARD, - 'amount' => 12, - 'description' => "A test award" - ]); - - $sku = Sku::where('title', 'mailbox')->first(); - $entitlement = Entitlement::where('sku_id', $sku->id)->first(); - $transaction = Transaction::create([ - 'user_email' => 'test@test.com', - 'object_id' => $entitlement->id, - 'object_type' => Entitlement::class, - 'type' => Transaction::ENTITLEMENT_CREATED, - 'amount' => 13 - ]); - - $sku = Sku::where('title', 'domain-hosting')->first(); - $entitlement = Entitlement::where('sku_id', $sku->id)->first(); - $transaction = Transaction::create([ - 'user_email' => 'test@test.com', - 'object_id' => $entitlement->id, - 'object_type' => Entitlement::class, - 'type' => Transaction::ENTITLEMENT_BILLED, - 'amount' => 14 - ]); - - $sku = Sku::where('title', 'storage')->first(); - $entitlement = Entitlement::where('sku_id', $sku->id)->first(); - $transaction = Transaction::create([ - 'user_email' => 'test@test.com', - 'object_id' => $entitlement->id, - 'object_type' => Entitlement::class, - 'type' => Transaction::ENTITLEMENT_DELETED, - 'amount' => 15 - ]); - - $transactions = Transaction::where('amount', '<', 20)->orderBy('amount')->get(); - - $this->assertSame(9, $transactions[0]->amount); - $this->assertSame(Transaction::WALLET_PENALTY, $transactions[0]->type); - $this->assertSame( - "The balance of Default wallet was reduced by 0,09 CHF; A test penalty", - $transactions[0]->toString() - ); - $this->assertSame( - "Charge: A test penalty", - $transactions[0]->shortDescription() - ); - - $this->assertSame(10, $transactions[1]->amount); - $this->assertSame(Transaction::WALLET_DEBIT, $transactions[1]->type); - $this->assertSame( - "0,10 CHF was deducted from the balance of Default wallet", - $transactions[1]->toString() - ); - $this->assertSame( - "Deduction", - $transactions[1]->shortDescription() - ); - - $this->assertSame(11, $transactions[2]->amount); - $this->assertSame(Transaction::WALLET_CREDIT, $transactions[2]->type); - $this->assertSame( - "0,11 CHF was added to the balance of Default wallet", - $transactions[2]->toString() - ); - $this->assertSame( - "Payment", - $transactions[2]->shortDescription() - ); - - $this->assertSame(12, $transactions[3]->amount); - $this->assertSame(Transaction::WALLET_AWARD, $transactions[3]->type); - $this->assertSame( - "Bonus of 0,12 CHF awarded to Default wallet; A test award", - $transactions[3]->toString() - ); - $this->assertSame( - "Bonus: A test award", - $transactions[3]->shortDescription() - ); - - $ent = $transactions[4]->entitlement(); - $this->assertSame(13, $transactions[4]->amount); - $this->assertSame(Transaction::ENTITLEMENT_CREATED, $transactions[4]->type); - $this->assertSame( - "test@test.com created mailbox for " . $ent->entitleableTitle(), - $transactions[4]->toString() - ); - $this->assertSame( - "Added mailbox for " . $ent->entitleableTitle(), - $transactions[4]->shortDescription() - ); - - $ent = $transactions[5]->entitlement(); - $this->assertSame(14, $transactions[5]->amount); - $this->assertSame(Transaction::ENTITLEMENT_BILLED, $transactions[5]->type); - $this->assertSame( - sprintf("%s for %s is billed at 0,14 CHF", $ent->sku->title, $ent->entitleableTitle()), - $transactions[5]->toString() - ); - $this->assertSame( - sprintf("Billed %s for %s", $ent->sku->title, $ent->entitleableTitle()), - $transactions[5]->shortDescription() - ); - - $ent = $transactions[6]->entitlement(); - $this->assertSame(15, $transactions[6]->amount); - $this->assertSame(Transaction::ENTITLEMENT_DELETED, $transactions[6]->type); - $this->assertSame( - sprintf("test@test.com deleted %s for %s", $ent->sku->title, $ent->entitleableTitle()), - $transactions[6]->toString() - ); - $this->assertSame( - sprintf("Deleted %s for %s", $ent->sku->title, $ent->entitleableTitle()), - $transactions[6]->shortDescription() - ); - } - - /** - * Test that an exception is being thrown on invalid type - */ - public function testInvalidType(): void - { - $this->expectException(\Exception::class); - - $transaction = Transaction::create( - [ - 'object_id' => 'fake-id', - 'object_type' => Wallet::class, - 'type' => 'invalid', - 'amount' => 9 - ] - ); - } - - public function testEntitlementForWallet(): void - { - $transaction = \App\Transaction::where('object_type', \App\Wallet::class) - ->whereIn('object_id', \App\Wallet::pluck('id'))->first(); - - $entitlement = $transaction->entitlement(); - $this->assertNull($entitlement); - $this->assertNotNull($transaction->wallet()); - } - - public function testWalletForEntitlement(): void - { - $transaction = \App\Transaction::where('object_type', \App\Entitlement::class) - ->whereIn('object_id', \App\Entitlement::pluck('id'))->first(); - - $wallet = $transaction->wallet(); - $this->assertNull($wallet); - - $this->assertNotNull($transaction->entitlement()); - } -} diff --git a/src/tests/Unit/UserTest.php b/src/tests/Unit/UserTest.php deleted file mode 100644 index 6ff02868..00000000 --- a/src/tests/Unit/UserTest.php +++ /dev/null @@ -1,89 +0,0 @@ - 'user@email.com']); - - $user->password = 'test'; - - $ssh512 = "{SSHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ" - . "6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w=="; - - $this->assertRegExp('/^\$2y\$12\$[0-9a-zA-Z\/.]{53}$/', $user->password); - $this->assertSame($ssh512, $user->password_ldap); - } - - /** - * Test User password mutator - */ - public function testSetPasswordLdapAttribute(): void - { - $user = new User(['email' => 'user@email.com']); - - $user->password_ldap = 'test'; - - $ssh512 = "{SSHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ" - . "6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w=="; - - $this->assertRegExp('/^\$2y\$12\$[0-9a-zA-Z\/.]{53}$/', $user->password); - $this->assertSame($ssh512, $user->password_ldap); - } - - /** - * Test basic User funtionality - */ - public function testStatus(): void - { - $statuses = [ - User::STATUS_NEW, - User::STATUS_ACTIVE, - User::STATUS_SUSPENDED, - User::STATUS_DELETED, - User::STATUS_IMAP_READY, - User::STATUS_LDAP_READY, - ]; - - $users = \App\Utils::powerSet($statuses); - - foreach ($users as $user_statuses) { - $user = new User( - [ - 'email' => '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 testStatusInvalid(): void - { - $this->expectException(\Exception::class); - - $user = new User( - [ - 'email' => 'user@email.com', - 'status' => 1234567, - ] - ); - } -} diff --git a/src/tests/Unit/UtilsTest.php b/src/tests/Unit/UtilsTest.php deleted file mode 100644 index 9771f36e..00000000 --- a/src/tests/Unit/UtilsTest.php +++ /dev/null @@ -1,104 +0,0 @@ -assertIsArray($result); - $this->assertCount(0, $result); - - $set = ["a1"]; - - $result = \App\Utils::powerSet($set); - - $this->assertIsArray($result); - $this->assertCount(1, $result); - $this->assertTrue(in_array(["a1"], $result)); - - $set = ["a1", "a2"]; - - $result = \App\Utils::powerSet($set); - - $this->assertIsArray($result); - $this->assertCount(3, $result); - $this->assertTrue(in_array(["a1"], $result)); - $this->assertTrue(in_array(["a2"], $result)); - $this->assertTrue(in_array(["a1", "a2"], $result)); - - $set = ["a1", "a2", "a3"]; - - $result = \App\Utils::powerSet($set); - - $this->assertIsArray($result); - $this->assertCount(7, $result); - $this->assertTrue(in_array(["a1"], $result)); - $this->assertTrue(in_array(["a2"], $result)); - $this->assertTrue(in_array(["a3"], $result)); - $this->assertTrue(in_array(["a1", "a2"], $result)); - $this->assertTrue(in_array(["a1", "a3"], $result)); - $this->assertTrue(in_array(["a2", "a3"], $result)); - $this->assertTrue(in_array(["a1", "a2", "a3"], $result)); - } - - /** - * Test for Utils::serviceUrl() - */ - public function testServiceUrl(): void - { - $public_href = 'https://public.url/cockpit'; - $local_href = 'https://local.url/cockpit'; - - \config([ - 'app.url' => $local_href, - 'app.public_url' => '', - ]); - - $this->assertSame($local_href, Utils::serviceUrl('')); - $this->assertSame($local_href . '/unknown', Utils::serviceUrl('unknown')); - $this->assertSame($local_href . '/unknown', Utils::serviceUrl('/unknown')); - - \config([ - 'app.url' => $local_href, - 'app.public_url' => $public_href, - ]); - - $this->assertSame($public_href, Utils::serviceUrl('')); - $this->assertSame($public_href . '/unknown', Utils::serviceUrl('unknown')); - $this->assertSame($public_href . '/unknown', Utils::serviceUrl('/unknown')); - } - - /** - * Test for Utils::uuidInt() - */ - public function testUuidInt(): void - { - $result = Utils::uuidInt(); - - $this->assertTrue(is_int($result)); - $this->assertTrue($result > 0); - } - - /** - * Test for Utils::uuidStr() - */ - public function testUuidStr(): void - { - $result = Utils::uuidStr(); - - $this->assertTrue(is_string($result)); - $this->assertTrue(strlen($result) === 36); - $this->assertTrue(preg_match('/[^a-f0-9-]/i', $result) === 0); - } -} diff --git a/src/tests/Unit/VerificationCodeTest.php b/src/tests/Unit/VerificationCodeTest.php deleted file mode 100644 index a1902dd6..00000000 --- a/src/tests/Unit/VerificationCodeTest.php +++ /dev/null @@ -1,26 +0,0 @@ -assertTrue(is_string($code)); - $this->assertTrue(strlen($code) === $code_length); - $this->assertTrue(strspn($code, $code_chars) === strlen($code)); - } -} diff --git a/src/tests/Unit/WalletTest.php b/src/tests/Unit/WalletTest.php deleted file mode 100644 index 67565751..00000000 --- a/src/tests/Unit/WalletTest.php +++ /dev/null @@ -1,32 +0,0 @@ - 'CHF', - ]); - - $money = $wallet->money(-123); - $this->assertSame('-1,23 CHF', $money); - - // This test is here to remind us that the method will give - // different results for different locales, but also depending - // if NumberFormatter (intl extension) is installed or not. - // NumberFormatter also returns some surprising output for - // some locales and e.g. negative numbers. - // We'd have to improve on that as soon as we'd want to use - // other locale than the default de_DE. - } -}